순환 의존을 이벤트로 끊은 이야기 — Aggregate 간 통신 패턴
도메인 이벤트로 Aggregate 간 느슨한 결합 만들기
- 0180줄짜리 UseCase를 15줄로 줄인 이야기 — 도메인 이벤트 도출기
- 02검색에서 지원자가 사라졌다 — BEFORE_COMMIT vs AFTER_COMMIT 삽질기
- 03지원 한 번에 리스너 10개가 반응한다 — 부수효과 실전 해부
- 04순환 의존을 이벤트로 끊은 이야기 — Aggregate 간 통신 패턴← 현재
이전 3편에서 이벤트 설계, 트랜잭션 전략, 부수효과 분리를 다뤘다. 이번에는 서로 다른 Aggregate가 이벤트로 대화하는 패턴을 다룬다. 같은 Aggregate 안에서의 이벤트와는 결이 다르다. 순환 의존, 트랜잭션 경계, 실패 전파 -- 고민할 게 많다.
순환 의존이 생기다
ATS에서 채용 공고(Posting)를 만들면 기본 전형(Step)을 자동으로 생성해야 한다. "서류 전형 → 1차 면접 → 최종 면접" 같은 기본 전형을 공고 생성 시 함께 만들어주는 기능이다.
처음에는 직관적으로 구현했다.
@Service
@RequiredArgsConstructor
public class CreatePostingUseCase {
private final PostingService postingService;
private final StepService stepService; // Step Aggregate에 직접 의존
@Transactional
public PostingWithDetailDto execute(final CreatePostingCommand command) {
final PostingWithDetailDto posting = postingService.create(command);
stepService.configureDefaultSteps(posting.getId(), defaultStepCommands); // 직접 호출
return posting;
}
}여기까지는 괜찮았다. 문제는 공고 삭제 기능을 만들 때 터졌다. 공고를 삭제하면 해당 전형도 삭제해야 한다. 그런데 전형을 삭제할 때는 해당 전형에 지원자가 있는지 확인하기 위해 공고 정보가 필요했다. 코드로 보면 이렇다.
// PostingUseCase → StepService 의존 (공고 생성 시 전형 생성)
// StepUseCase → PostingService 의존 (전형 삭제 시 공고 상태 확인)
// → 순환 의존!순환 의존 문제
Spring Boot라서 빈 순환 참조 에러가 바로 나지는 않았다(@Lazy로 피하거나, 필드 주입으로 우회할 수 있다). 하지만 그건 문제를 숨기는 것이지 해결하는 게 아니다. Posting 코드를 수정하면 Step 테스트가 깨지고, Step 코드를 수정하면 Posting 테스트가 깨지는 상황이 반복됐다.
이벤트로 끊기
해결 방법은 명확했다. Posting은 "공고가 생성됐다"는 이벤트만 발행하고, Step은 그 이벤트를 듣고 자기 할 일을 한다.
// Posting UseCase — Step을 전혀 모른다
@Service
@RequiredArgsConstructor
public class CreatePostingUseCase {
private final PostingService postingService;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public PostingWithDetailDto execute(final CreatePostingCommand command) {
final PostingWithDetailDto posting = postingService.create(command);
eventPublisher.publishEvent(new PostingCreatedEvent(posting));
return posting;
}
}// Step Aggregate가 Posting 이벤트에 반응
@Slf4j
@RequiredArgsConstructor
@Component
public class StepEventListener {
private final StepService stepService;
private final DeleteStepUseCase deleteStepUseCase;
private final AtsProps atsProps;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void createDefaultSteps(final PostingCreatedEvent event) {
try {
final PostingWithDetailDto posting = event.getPosting();
final List<CreateStepCommand> commands = atsProps.getDefaultStepNames().stream()
.map(stepName -> CreateStepCommand.builder()
.name(stepName)
.build())
.toList();
stepService.configureDefaultSteps(posting.getId(), commands);
} catch (final Exception e) {
log.error("공고 생성 시 기본 전형 생성 에러", e);
}
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void deleteSteps(final PostingDeletedEvent event) {
try {
deleteStepUseCase.deleteSteps(event.getPosting().getId());
} catch (final Exception e) {
log.error("공고 삭제 시 전형 삭제 에러", e);
}
}
}이벤트 기반 단방향 의존
이제 의존 방향이 단방향이다. Step이 Posting의 이벤트를 구독하지만, Posting은 Step의 존재를 모른다. Posting 코드를 아무리 수정해도 Step 테스트가 깨지지 않는다. 반대로 Step 리스너를 수정해도 Posting에는 영향이 없다.
기본 전형 이름은 atsProps.getDefaultStepNames()에서 가져온다. 설정 파일에서 관리하니까 코드 수정 없이 기본 전형을 바꿀 수 있다. "서류 전형, 1차 면접, 최종 면접"이 기본값인데, 고객사마다 다른 기본값을 쓸 수 있도록 확장할 여지도 남겼다.
사례 2: 결제 완료 → 장바구니 상태 변경
ATS뿐 아니라 Commerce 모듈에서도 같은 패턴을 쓰고 있다. 결제가 완료되면 장바구니 아이템 상태를 변경해야 한다. Order Aggregate와 Cart Aggregate 사이의 통신이다.
// 결제 완료 이벤트 — 장바구니 아이템 ID만 담는다
public record PaymentCompletedEvent(List<Long> cartItemIds) {
public static PaymentCompletedEvent from(final Order order) {
return new PaymentCompletedEvent(order.getCartItemIds());
}
}// Cart Aggregate의 리스너
@Slf4j
@RequiredArgsConstructor
@Component
public class CartEventListener {
private final UpdateCartItemOrderedUseCase updateCartItemOrderedUseCase;
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateApplicationDuplicated(final PaymentCompletedEvent event) {
log.info("결제 성공 후 장바구니 항목 삭제");
try {
final List<Long> cartItemIds = event.cartItemIds();
updateCartItemOrderedUseCase.updateCartItem(cartItemIds);
} catch (final Exception e) {
log.error("결제 성공 후 장바구니 항목 삭제 실패 : {}", e.getMessage(), e);
}
}
}여기서 주목할 점이 두 가지 있다.
첫째, AFTER_COMMIT이다. 결제가 완료된 후에 장바구니를 정리한다. 만약 장바구니 상태 변경이 실패해도 결제는 이미 성공한 상태여야 한다. 고객 입장에서 "결제했는데 실패했다"가 되면 안 된다.
둘째, @Transactional(propagation = Propagation.REQUIRES_NEW)가 붙어 있다. 메인 트랜잭션은 이미 커밋됐으므로, 장바구니를 수정하려면 새 트랜잭션이 필요하다. 2편에서 다뤘던 패턴이 여기서도 등장한다.
"리스너가 실패하면 발행 측도 롤백되어야 하는가?"
Cross-Aggregate 이벤트를 설계할 때 매번 마주치는 질문이다. 이걸 틀리면 데이터 불일치가 생긴다.
"기본 전형 없는 공고"가 존재하면 안 되는가?
→ 처음에는 "예"라고 생각했지만...
→ 전형은 나중에 만들어도 공고는 사용 가능
→ 결국 AFTER_COMMIT으로 결정
장바구니 상태가 안 바뀌어도 결제는 유효한가?
→ 당연히 유효하다
→ 장바구니는 나중에 정리해도 된다
→ AFTER_COMMIT이 맞다
실제로 우리 시스템의 Cross-Aggregate 이벤트는 전부 AFTER_COMMIT이 됐다. 초기에는 StepEventListener의 기본 전형 생성을 BEFORE_COMMIT으로 할까 고민했지만, 전형 생성이 실패해도 공고 자체는 존재할 수 있고 관리자가 수동으로 전형을 추가할 수 있으므로 AFTER_COMMIT으로 결정했다.
BEFORE_COMMIT이 적합한 케이스는 같은 Aggregate 내부의 파생 데이터(예: ApplicantMeta)에 가깝다. Cross-Aggregate 간에는 Eventual Consistency를 수용하는 게 대부분 맞다.
Cross-Aggregate 이벤트 전체 지도
Cross-Aggregate Event Communication Map
이 지도를 만들어놓고 보니 패턴이 보인다.
PostingCreatedEvent가 가장 많은 리스너를 깨운다 (3개). 공고 하나 만들면 전형, 지원서 양식, 검색 데이터가 따라서 만들어진다. PostingDeletedEvent는 더 많다 (3개). 삭제는 항상 생성보다 복잡하다.
그리고 방향이 전부 한쪽이다. Posting → Step이지 Step → Posting이 아니다. Applicant → Posting(통계 sync)이지 Posting → Applicant(조회)가 아니다. 이벤트로 통신하면 자연스럽게 단방향 의존이 강제된다.
이벤트를 안 쓸 때
모든 Aggregate 간 통신을 이벤트로 해야 하는 건 아니다. 이벤트가 과할 때도 있다.
읽기 전용(조회)이면 직접 호출이 낫다. 지원자 상세 페이지에서 전형 정보를 보여줄 때 "지원자가 전형을 조회한 이벤트"를 발행하는 건 과하다. 그냥 StepService를 호출하면 된다. 이벤트는 상태를 변경하는 부수효과에 적합하다.
또 하나, 이벤트가 2~3개 수준이면 직접 호출이 더 명확할 수 있다. 이벤트의 가치는 부수효과가 늘어날수록 빛난다. 부수효과가 1개뿐이라면 이벤트 도입의 복잡성이 이점보다 클 수 있다. 우리도 처음부터 이벤트를 쓴 게 아니라, UseCase가 뚱뚱해지는 고통을 겪고 나서 도입했다.
4편을 마치며 — 돌아보기
4편에 걸쳐서 도메인 이벤트를 도입한 여정을 정리했다. 교과서 순서와는 좀 달랐다. 우리는 이론부터 시작한 게 아니라, 문제를 먼저 겪고 해결책을 찾아간 순서였다.
시리즈 요약: 겪은 문제와 배운 것
몇 가지 원칙을 정리하며 마무리한다.
이벤트는 "이미 일어난 사실"이다. 과거형으로 이름 짓고, 이벤트 안에 리스너가 필요로 하는 데이터를 담는다. 같은 JVM 내 메모리 전달이므로 DTO를 통째로 넣어도 괜찮다.
BEFORE_COMMIT은 정말 필요할 때만. 대부분의 이벤트 리스너는 AFTER_COMMIT이 맞다. "이게 실패하면 메인 로직도 실패해야 하는가?"에 "예"라고 답할 수 있을 때만 BEFORE_COMMIT을 쓴다.
AFTER_COMMIT에서는 반드시 try-catch. 비동기 스레드에서 예외가 조용히 사라지면 운영 중 문제를 추적할 수 없다. 이벤트 이름, 관련 ID와 함께 에러 로그를 남긴다.
이벤트는 흐름을 숨긴다. 이게 가장 큰 트레이드오프다. UseCase만 봐서는 어떤 부수효과가 발생하는지 알 수 없다. 이벤트-리스너 매핑 지도를 관리하는 게 중요하다. 이 시리즈에 나온 시각화 같은 걸 README나 위키에 넣어두면 새 팀원 온보딩에도 도움이 된다.
모든 것을 이벤트로 만들지 않는다. 조회는 직접 호출이 낫고, 부수효과가 1개뿐이면 이벤트의 복잡성이 이점을 넘을 수 있다. UseCase가 뚱뚱해지기 시작할 때, 그때 이벤트를 도입해도 늦지 않다.