
DeadlineMate
모임 & 검색
- QueryDSL BooleanBuilder + Exists Subquery로 다대다 관계(모임↔카테고리·태그) 동적 필터링 — IN 대신 EXISTS로 불필요한 조인 제거
- 목록 조회 N+1 제거 — 모임 ID 배치 수집 후 태그·이미지·리더 정보를 IN 쿼리로 각 1회 조회, LinkedHashMap으로 순서 보장
- CQRS 분리 — GatheringService(생성·수정·삭제)·GatheringQueryService(목록·상세 조회) 책임 분리, 스케줄러로 일일 RECRUITING→IN_PROGRESS 상태 자동 전환 및 이벤트 발행
- PESSIMISTIC_WRITE 락으로 동시 신청 시 maxMembers 초과 방지, Batch UPDATE로 currentMembers 원자적 증감 처리
이벤트 기반 알림
- @TransactionalEventListener(AFTER_COMMIT)으로 모임 생성·시작·완료·찌르기·평가 5종 이벤트 발행 — 트랜잭션 커밋 후에만 외부 호출하여 데이터 일관성 보장
- 평판 시스템 이벤트 분리 — 평가 데이터 저장과 userClient 평판 점수 업데이트를 이벤트로 분리해 외부 서비스 실패 시에도 평가 데이터 유실 없는 fault tolerance 확보
인증
- OAuthClientFactory 패턴으로 Kakao·Google 멀티 프로바이더 통합 — OAuthClient 인터페이스 구현체를 Map으로 관리해 새 제공자 추가 시 클래스 1개만 등록
- LoginAttemptService로 로그인 시도 횟수 추적, 임계치 초과 시 잠금 처리로 브루트포스 방어
테스트 & CI
- 51개 테스트 파일 — Controller(단위)·Service(Mockito BDD)·Repository(@DataJpaTest/H2)·E2E(@SpringBootTest) 3-layer 피라미드 구조로 전 도메인 커버
- @Nested + @DisplayName 한글 계층 구조로 테스트 의도 명확화, ArgumentCaptor·BDDMockito.given/then 패턴으로 이벤트 발행·상태 변이 검증
- GitHub Actions CI — PR → main/dev 머지 전 자동 테스트 실행, H2 테스트 프로파일 분리로 외부 DB 의존성 없는 빌드 환경 구성
- 전 도메인 51개 테스트 파일 작성 — 단위·통합·E2E 3-layer 구조 + GitHub Actions CI로 PR마다 자동 검증
- 모임 목록 조회 시 태그·이미지·리더를 건별 조회해 N+1이 발생하던 문제를 IN 쿼리 일괄 처리로 해결 — 4개 테이블 각 1회 쿼리로 최적화
- 알림 실패가 모임 데이터에 영향을 주는 강결합 구조를 @TransactionalEventListener 이벤트 분리로 해결 — 평가 서비스 장애 시에도 모임 데이터 유실 없는 fault tolerance 확보
- 동시 신청 시 maxMembers 초과 가능성을 PESSIMISTIC_WRITE + Batch UPDATE로 해결 — 경쟁 조건 없는 원자적 증감 처리
프로젝트 회고
EXISTS vs IN — 다대다 필터링의 성능 차이
모임에는 여러 카테고리와 태그가 붙고, 사용자는 복수 조건으로 필터링합니다. 처음엔 IN으로 구현했는데 카테고리·태그 수만큼 조인 결과가 중복 행으로 폭증했습니다.
// IN — 다대다 조인 시 중복 행 발생, DISTINCT 필요
queryFactory.select(gathering)
.from(gathering)
.join(gathering.categories, category)
.where(category.id.in(categoryIds)) // 중복 행 발생
.distinct()
.fetch();
// EXISTS — 조인 없이 존재 여부만 확인, 중복 없음
BooleanExpression categoryFilter = JPAExpressions
.selectOne()
.from(gatheringCategory)
.where(
gatheringCategory.gathering.eq(gathering),
gatheringCategory.category.id.in(categoryIds)
)
.exists();EXISTS는 조건을 만족하는 행이 하나라도 있으면 즉시 true를 반환합니다. 전체를 스캔하지 않아도 되고, 조인으로 인한 중복 행도 없습니다. 카테고리·태그·모임 상태 조건을 BooleanBuilder로 조합해도 쿼리가 단순하게 유지됐습니다.
@TransactionalEventListener(AFTER_COMMIT) — 커밋 후에만 알림을
모임 생성 직후 알림을 발송하는 로직을 처음엔 서비스 메서드 안에 직접 호출했습니다. 문제는 알림 발송이 실패하면 모임 생성 트랜잭션 전체가 롤백된다는 점이었습니다.
// 문제: 알림 실패 시 모임 생성까지 롤백
@Transactional
public void createGathering(...) {
gatheringRepository.save(gathering);
notificationClient.send(...); // 실패 시 전체 롤백
}
// 개선: 커밋 확정 후에만 알림 발송
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onGatheringCreated(GatheringCreatedEvent event) {
notificationClient.send(event.getGatheringId());
// 여기서 실패해도 모임 데이터는 이미 커밋됨
}AFTER_COMMIT을 쓰면 트랜잭션이 성공적으로 커밋된 후에만 이벤트 핸들러가 실행됩니다. 알림 발송이 실패해도 모임 데이터는 보존되고, 모임 도메인과 알림 로직이 코드 레벨에서도 분리됩니다.
평판 점수 업데이트도 같은 패턴을 적용했습니다. 외부 서비스 호출 실패가 핵심 도메인 트랜잭션에 영향을 주지 않아야 한다는 원칙을 이 프로젝트에서 처음 직접 구현했습니다.
PESSIMISTIC_WRITE — 동시 신청 경쟁 조건 해소
모임 신청 시 currentMembers를 읽고 maxMembers와 비교한 뒤 증가시키는 흐름이 있습니다. 동시에 여러 요청이 들어오면 같은 값을 읽어 정원을 초과할 수 있습니다.
// PESSIMISTIC_WRITE — 조회 시점에 배타 잠금 획득
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT g FROM Gathering g WHERE g.id = :id")
Optional<Gathering> findByIdWithLock(@Param("id") Long id);
// 서비스
Gathering gathering = gatheringRepository.findByIdWithLock(id)
.orElseThrow();
if (gathering.getCurrentMembers() >= gathering.getMaxMembers()) {
throw new GatheringFullException();
}
// Batch UPDATE로 원자적 증감
gatheringRepository.incrementMembers(id);Optimistic Lock도 고려했지만, 모임 신청은 경쟁이 발생할 가능성이 높고 재시도 로직을 클라이언트에 넘기기 애매한 상황이었습니다. DB 레벨에서 확실하게 직렬화하는 PESSIMISTIC_WRITE가 이 케이스에는 더 적합하다고 판단했습니다.
Rich Domain Model — 비즈니스 로직은 엔티티가 가져야 한다
처음에는 서비스 레이어에서 상태를 직접 바꾸는 방식으로 작성했습니다. 서비스가 엔티티 필드를 열어서 값을 설정하는 전형적인Anemic Domain Model 패턴이었습니다.
// Anemic — 서비스가 검증과 상태 변경을 직접 수행
if (!gathering.getLeaderId().equals(requesterId)) {
throw new BusinessException(ErrorCode.INVALID_GATHERING_LEADER);
}
gathering.setStatus(GatheringStatus.COMPLETED); // 외부에서 직접 수정
// Rich — 검증과 상태 변경이 엔티티 메서드로 캡슐화
gathering.validateLeader(requesterId); // 내부에서 예외 발생
gathering.complete(); // 상태 전이 조건을 엔티티가 직접 검증
// Gathering.complete()
public void complete() {
if (this.status != GatheringStatus.IN_PROGRESS) {
throw new BusinessException(ErrorCode.GATHERING_COMPLETE_NOT_ALLOWED);
}
this.status = GatheringStatus.COMPLETED;
}Rich Domain Model로 바꾸면 서비스 코드가 얇아지고, 동일한 규칙이 여러 곳에서 호출되더라도 검증 로직이 한 곳에만 존재합니다.Todo.updateContent()는 변경 여부를 boolean으로 반환하는데, "같은 값이면 업데이트로 치지 않는다"는 규칙도 엔티티가 판단합니다.
CQRS + Projection — 읽기 모델을 따로 만드는 이유
모임 목록 조회는 여러 테이블을 조인하고, 페이지네이션에 정렬 조건까지 붙습니다. JPA 엔티티 그래프로 이걸 처리하면 지연 로딩·즉시 로딩 선택 문제와 N+1이 반복적으로 따라옵니다.
// Command — 쓰기 전용, 트랜잭션 보장
@Service
@Transactional
public class GatheringService {
public CreateGatheringResponse create(CreateGatheringCommand command) { ... }
public void delete(Long gatheringId, Long requesterId) { ... }
}
// Query — 읽기 전용, @Transactional(readOnly = true)
@Service
@Transactional(readOnly = true)
public class GatheringQueryService {
public GatheringListResponse getGatherings(GatheringSearchCondition condition, ...) { ... }
}
// Projection Read Model — 조회에 필요한 컬럼만 담은 불변 레코드
@Builder
public record GatheringListRow(
Long id, Long leaderId, GatheringType type,
String title, int maxMembers, int currentMembers,
LocalDate startDate, LocalDate endDate, GatheringStatus status
) {}GatheringListRow는 목록 조회에 필요한 컬럼만 담은 Projection입니다. 엔티티 전체를 로드하지 않아 불필요한 필드를 가져오지 않고, QueryDSL에서 Projections.constructor()로 직접 매핑합니다.
Command 객체도 불변 Record로 정의해, 서비스 메서드 시그니처가 파라미터 폭탄이 되는 걸 막았습니다. 필드를 추가해도 호출부가 영향받지 않습니다.
OAuthClientFactory — Enum 키로 멀티 프로바이더 관리
Kakao·Google 두 가지 OAuth를 지원하면서 처음 구현하면 자연스럽게 provider 문자열로 분기하는 if-else가 생깁니다. 새 제공자가 추가될 때마다 조건 분기를 찾아 수정해야 하고, 누락 시 런타임 오류로 이어집니다.
// OAuthProviderType — 별도 모듈로 분리해 import해서 사용
public enum OAuthProviderType {
KAKAO, GOOGLE;
}
// OAuthClient 인터페이스
public interface OAuthClient {
OAuthUserInfo getUserInfo(String accessToken);
OAuthProviderType getProviderType();
}
// Factory — Enum을 키로 Map에 보관
@Component
public class OAuthClientFactory {
private final Map<OAuthProviderType, OAuthClient> clients;
public OAuthClientFactory(List<OAuthClient> clientList) {
this.clients = clientList.stream()
.collect(Collectors.toMap(
OAuthClient::getProviderType,
Function.identity()
));
}
public OAuthClient getClient(OAuthProviderType providerType) {
return Optional.ofNullable(clients.get(providerType))
.orElseThrow(() -> new UnsupportedOAuthProviderException(providerType));
}
}OAuthProviderType을 별도 모듈로 분리해 여러 서비스에서 import해서 쓰도록 구성했습니다. 새 OAuth 제공자를 추가할 때는 Enum에 값 하나 추가 + OAuthClient 구현 클래스 1개 등록만 하면 Factory와 라우팅 코드는 전혀 건드리지 않아도 됩니다.
String 키 대신 Enum을 쓴 이유는 오타로 인한 런타임 오류를 컴파일 타임에 잡고, 지원하는 제공자 목록을 코드만 보고 파악할 수 있게 하기 위해서입니다.
성장과 배움
이 프로젝트를 통해 얻은 것:
- Rich Domain Model — 비즈니스 규칙을 엔티티에 캡슐화해 서비스 코드가 얇아지고 로직 중복이 사라짐
- CQRS + Projection — Command/Query 서비스 분리, 읽기 모델(GatheringListRow)로 불필요한 컬럼 로드 제거
- EXISTS vs IN — 다대다 관계 필터링에서 EXISTS가 중복 행 없이 더 효율적인 이유
- @TransactionalEventListener(AFTER_COMMIT) — 외부 호출 실패가 핵심 트랜잭션에 영향을 주지 않는 설계
- PESSIMISTIC_WRITE vs Optimistic Lock — 경쟁 빈도와 재시도 가능 여부로 락 전략 선택
- OAuthClientFactory + Enum 키 — String 분기 제거, 컴파일 타임 타입 안전성 확보, 제공자 추가 시 클래스 1개만 등록