검색에서 지원자가 사라졌다 — BEFORE_COMMIT vs AFTER_COMMIT 삽질기
도메인 이벤트로 Aggregate 간 느슨한 결합 만들기
- 0180줄짜리 UseCase를 15줄로 줄인 이야기 — 도메인 이벤트 도출기
- 02검색에서 지원자가 사라졌다 — BEFORE_COMMIT vs AFTER_COMMIT 삽질기← 현재
- 03지원 한 번에 리스너 10개가 반응한다 — 부수효과 실전 해부
- 04순환 의존을 이벤트로 끊은 이야기 — Aggregate 간 통신 패턴
이전 편에서 도메인 이벤트를 도입해서 UseCase를 깔끔하게 만들었다. 기분 좋게 배포하고 2주쯤 지났을 때, CS팀에서 연락이 왔다.
"00기업에서 지원자 검색이 안 된다고 합니다. 분명히 지원했는데 목록에 안 보인다고요."
사라진 지원자
DB를 열어봤다. applicant 테이블에는 데이터가 있다. 그런데 applicant_meta 테이블에 해당 row가 없었다. 검색은 applicant_meta를 기반으로 동작하니까, Meta가 없으면 검색에서 빠진다.
처음엔 "데이터 마이그레이션 누락인가?" 싶었다. 그런데 새로 지원한 사람도 간헐적으로 Meta가 없었다. 5명 지원하면 4명은 정상인데 1명이 빠져 있는 식이었다. 재현도 쉽지 않았다.
리스너 코드를 다시 봤다. 문제가 보였다.
// 당시 코드 — 이게 버그의 원인이었다
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void createApplicantMeta(final ApplicantAppliedEvent event) {
try {
final CreateApplicantMetaCommand command = CreateApplicantMetaCommand.of(
event.getApplicantWithAnswer()
);
applicantMetaService.createApplicantSummary(command);
} catch (final Exception e) {
log.error("지원자 지원 시 지원자 검색 데이터 생성 실패", e);
}
}AFTER_COMMIT + @Async였다. 메인 트랜잭션이 커밋된 후, 별도 스레드에서 Meta를 생성하는 구조. 대부분은 잘 동작했다. 그런데 서버 부하가 높을 때 스레드 풀이 가득 차면 비동기 작업이 밀렸다. 밀리다가 타임아웃으로 실패하면? catch에서 에러 로그만 남기고 끝이었다. Meta 없는 지원자가 조용히 생겨나고 있었다.
"Meta는 선택이 아니라 필수다"
여기서 근본적인 질문을 해야 했다. ApplicantMeta는 지원자와 함께 존재해야 하는 데이터인가, 아니면 나중에 있어도 되는 부수효과인가?
대답은 명확했다. Meta가 없으면 검색에서 지원자가 보이지 않는다. 채용 담당자 입장에서는 지원자가 존재하지 않는 것과 같다. Meta 생성이 실패하면 지원 자체가 실패해야 한다. 이건 이메일 발송 같은 "있으면 좋고 없어도 되는" 부수효과가 아니었다.
BEFORE_COMMIT으로 바꿔야 했다.
BEFORE_COMMIT vs AFTER_COMMIT 실행 시점
핵심은 이거다. BEFORE_COMMIT 리스너는 메인 트랜잭션이 커밋되기 직전에, 같은 트랜잭션 안에서 실행된다. Meta 생성이 실패하면 메인 트랜잭션 전체가 롤백된다. 지원자도 Meta도 둘 다 없거나, 둘 다 있거나. 중간 상태가 없다.
@Async + BEFORE_COMMIT은 함정이다
수정하면서 한 가지 실수를 더 할 뻔했다. 기존 코드에 @Async가 붙어 있었는데, phase만 BEFORE_COMMIT으로 바꾸면 되는 거 아닌가 싶었다.
// 절대 이렇게 하면 안 된다
@Async // <-- 별도 스레드
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) // <-- 같은 트랜잭션?
public void createApplicantMeta(final ApplicantAppliedEvent event) {
// 이 코드는 별도 스레드에서 실행되므로
// 메인 트랜잭션에 참여하지 못한다!
}@Async는 별도 스레드에서 실행한다. BEFORE_COMMIT은 메인 트랜잭션 안에서 실행되어야 한다. 이 둘을 같이 쓰면 별도 스레드에서 새 트랜잭션이 열린다. 메인 트랜잭션과 아무 관계가 없다. Meta 생성이 실패해도 메인 트랜잭션은 모르고 커밋해버린다. AFTER_COMMIT + @Async보다 더 나쁜 상황이다 -- 적어도 AFTER_COMMIT은 메인 커밋 이후에 실행이라도 되지, 이 조합은 타이밍조차 보장이 안 된다.
BEFORE_COMMIT에서는 절대 @Async를 사용하면 안 된다. 별도 스레드는 메인 트랜잭션에 참여할 수 없다. 실제 코드에 이 TODO가 남아 있었다: // TODO: 2024-03-06 이거 확인 한 번 해보기. -- 확인해 본 결과, 문제가 맞았다.
수정된 코드
ApplicantMetaEventListener를 이렇게 바꿨다.
@Slf4j
@RequiredArgsConstructor
@Component
public class ApplicantMetaEventListener {
private final ApplicantMetaService applicantMetaService;
// @Async 제거! BEFORE_COMMIT은 동기로 실행되어야 한다
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void createApplicantMeta(final ApplicantAppliedEvent event) {
final CreateApplicantMetaCommand command = CreateApplicantMetaCommand.of(
event.getApplicantWithAnswer()
);
applicantMetaService.createApplicantSummary(command);
// try-catch 없음! 실패하면 메인 트랜잭션도 롤백되어야 하니까
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void createApplicantMeta(final SingleApplicantRegisteredEvent event) {
final CreateApplicantMetaCommand command = CreateApplicantMetaCommand.of(
event.getApplicantWithAnswer()
);
applicantMetaService.createApplicantSummary(command);
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void createApplicantMeta(final ApplicantBulkRegisteredEvent event) {
final List<CreateApplicantMetaCommand> commands = event.getApplicants().stream()
.map(CreateApplicantMetaCommand::of)
.toList();
applicantMetaService.createBulkApplicantMeta(commands);
}
}두 가지가 바뀌었다. @Async를 제거했고, try-catch도 제거했다.
BEFORE_COMMIT 리스너에서 예외를 삼켜버리면(catch해서 로그만 남기면) 메인 트랜잭션은 성공하고 Meta만 없는 상태가 된다. 이전과 같은 버그다. BEFORE_COMMIT 리스너에서는 예외를 그대로 전파해야 한다.
반면 AFTER_COMMIT 리스너에서는 반드시 try-catch를 해야 한다.
// AFTER_COMMIT: try-catch 필수
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateApplicationDuplicated(final ApplicantAppliedEvent event) {
try {
final ApplicantWithAnswerDto applicant = event.getApplicantWithAnswer();
applicantService.updateDuplicated(
event.getPostingId(), applicant.getName(), applicant.getPhoneNumber()
);
} catch (final Exception e) {
log.error("지원자 지원 시 중복 지원 여부 업데이트 실패", e);
}
}왜? @Async 스레드에서 예외가 던져지면 AsyncUncaughtExceptionHandler가 처리하는데, 기본 핸들러는 스택 트레이스만 찍고 끝이다. "어떤 이벤트의, 어떤 지원자에 대한, 어떤 처리가 실패했는지" 맥락이 없다. 명시적으로 catch해서 이벤트 정보와 함께 로그를 남겨야 운영 중 문제를 추적할 수 있다.
페이즈 선택 기준 — 결국 하나의 질문
온갖 기준표를 만들어봤지만, 결국 판단은 하나의 질문으로 귀결됐다.
구체적으로 정리하면 이렇다.
| 리스너 | 페이즈 | 이유 |
|---|---|---|
| Meta 생성 | BEFORE_COMMIT | Meta 없으면 검색 불가. 같이 커밋되어야 함 |
| Meta 삭제 | AFTER_COMMIT | 지원자 삭제 후 Meta 삭제 실패해도 지원자 삭제는 유효 |
| 중복 체크 | AFTER_COMMIT | 중복 표시가 잠깐 안 돼도 지원 자체는 성공해야 함 |
| 이메일 발송 | AFTER_COMMIT | 외부 시스템(SMTP) 장애가 지원을 막으면 안 됨 |
| 알림톡 | AFTER_COMMIT | 같은 이유. 외부 시스템 의존 |
| 전형 생성 | AFTER_COMMIT | 전형 없는 공고가 잠시 존재해도 UX에 치명적이지 않음 |
| 지원자 수 동기화 | AFTER_COMMIT | 통계 숫자는 eventual consistency로 충분 |
@TransactionalEventListener의 기본 페이즈가 AFTER_COMMIT인 데는 이유가 있다. 대부분의 이벤트 리스너는 부수효과다. BEFORE_COMMIT은 정말 데이터 일관성이 깨지는 경우에만 써야 한다. 우리 코드에서도 BEFORE_COMMIT은 Meta 생성 딱 하나뿐이다.
AFTER_COMMIT 리스너의 REQUIRES_NEW
한 가지 더 겪은 문제가 있다. AFTER_COMMIT 리스너에서 DB를 수정할 때, @Transactional을 안 붙이면 동작하지 않는다. 메인 트랜잭션은 이미 커밋됐으니까.
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새 트랜잭션 시작
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateApplicationDuplicated(final ApplicantAppliedEvent event) {
// 이 안에서 DB 수정이 필요하면 REQUIRES_NEW가 필수
}Commerce 모듈의 CartEventListener에서 이 패턴을 실제로 쓰고 있다. 결제 완료 후 장바구니 아이템 상태를 변경하는데, 메인 트랜잭션(결제)은 이미 커밋됐으므로 새 트랜잭션이 필요하다.
교훈
돌이켜 보면 버그의 원인은 단순했다. **"이 데이터가 없으면 서비스가 정상 동작하는가?"**를 제대로 묻지 않았다. 모든 부수효과를 같은 바구니에 넣고 AFTER_COMMIT으로 통일한 게 실수였다.
이벤트 리스너를 만들 때 항상 이 질문을 먼저 해야 한다. 대답이 "아니, 이게 없으면 서비스가 안 돌아가"라면 그건 부수효과가 아니라 핵심 로직의 일부다. BEFORE_COMMIT으로 같은 트랜잭션에 묶어야 한다.
다음 편에서는 이벤트 리스너들이 실제로 하는 일을 하나하나 뜯어본다. 중복 체크의 markAsDuplicate / unmarkAsDuplicate 로직, 삭제 시 왜 중복을 재계산해야 하는지, 이메일 발송 상태를 EmailSentEvent로 추적하는 이유까지.