주문을 취소하면 포인트를 돌려주고, 쿠폰을 살리고, 알림을 보내야 해요. 스프링에서 이 후속 처리를 도메인 이벤트로 넘기기로 했다면 남는 질문은 "어느 리스너 단계에, 어떤 전파 속성으로, 동기로 둘까 비동기로 둘까"예요. 결론부터 말하면 후속 처리가 실패했을 때 원래 작업도 취소돼야 하는지, 빠지면 사람이 찾아야 하는지 두 질문으로 방식을 고르고, 커밋 뒤 리스너가 DB 에 쓴다면 커넥션 풀 크기를 다시 계산해야 해요.
이 글은 이벤트가 프록시를 거쳐 나가는 원리 대신, 운영에서 숫자로 돌아오는 결정을 다뤄요. 리스너 단계마다 응답 시간이 얼마나 늘어나는지, REQUIRES_NEW 가 왜 풀 잠김을 부르는지, 발행 기록 테이블이 한 달에 얼마나 쌓이는지를 2026년 10월 28일 기준 공식 문서(Spring Framework 7.0.10, Spring Modulith 2.1.1, 스프링 부트 레퍼런스)와 소스로 확인해 계산했어요. 애그리거트 경계를 어떻게 긋는지는 DDD 애그리거트 경계 판단표 글에 따로 정리해 두었어요.
후속 처리 방식 판단표 — 먼저 두 질문에 답하기
도메인 이벤트를 받는 방식은 크게 다섯 가지예요. 무엇을 고를지는 기술보다 업무 질문으로 정해져요. 아래 순서도의 첫 질문은 "후속 처리가 실패하면 원래 작업도 취소돼야 하나"예요. 예라면 사실상 두 애그리거트를 한 트랜잭션으로 고치는 셈이라, 잠금 범위가 넓어지는 비용을 감수하고 같은 트랜잭션에 둬요. 아니라면 커밋 뒤로 보내고, 두 번째 질문 "빠지면 사람이 찾아야 하나"로 유실 대비 수준을 정해요.
| 후속 처리 예 | 실패하면 원래 작업도 취소? | 빠지면 사람이 찾나? | 고를 방식 |
|---|---|---|---|
| 주문 합계 검증, 같은 명령의 감사 로그 | 예 | — | @EventListener 또는 BEFORE_COMMIT |
| 캐시 무효화, 주기적 재색인이 있는 검색 색인 | 아니오 | 아니오 | AFTER_COMMIT (짧은 작업) |
| 푸시 알림, 통계 수집 API 호출 | 아니오 | 아니오 | @Async + AFTER_COMMIT, 전용 실행기 |
| 포인트 환급, 재고 복원, 쿠폰 되살리기 | 아니오 | 예 | Spring Modulith @ApplicationModuleListener + 발행 기록 |
| 결제·배송 서비스에 알리기 | 아니오 | 예 | 아웃박스 또는 Modulith 이벤트 외부화 + 멱등 소비자 |
판단이 애매한 경우가 "알림"이에요. 마케팅 푸시는 빠져도 되지만, 환불 완료 문자처럼 법적·고객 응대 근거가 되는 알림은 아래쪽 줄로 내려야 해요. 표의 두 번째 열을 도메인 담당자에게 직접 물어 숫자로 받아 두면(예: "적립은 5분까지 늦어도 된다") 이후 재시도 간격과 감시 기준의 근거가 돼요.
리스너 단계·속성 설정값 고르기
스프링 프레임워크의 트랜잭션 바운드 이벤트 문서가 정한 단계는 BEFORE_COMMIT, AFTER_COMMIT(기본값), AFTER_ROLLBACK, AFTER_COMPLETION 네 가지예요. 같은 문서는 트랜잭션이 없으면 리스너를 아예 부르지 않고, fallbackExecution = true 로만 바꿀 수 있다고 적어요. 설정값별로 언제 쓰는지 정리하면 이래요.
| 설정 | 언제 쓰나 | 주의할 점 |
|---|---|---|
phase = BEFORE_COMMIT | 같은 트랜잭션에서 마지막에 검증·기록 | 여기서 던진 예외는 커밋을 부른 쪽으로 올라가 전체가 롤백돼요 |
phase = AFTER_COMMIT (기본) | 커밋된 사실만 보고 움직일 때 | 같은 스레드에서 응답 전에 돌고, 예외는 로그로만 남아요 |
phase = AFTER_ROLLBACK | 실패 알림, 예약해 둔 자원 풀기 | 리스너는 이벤트만 받아요. 실패 처리에 필요한 값은 이벤트에 담아요 |
phase = AFTER_COMPLETION | 성공·실패 상관없이 정리 | 결과를 구분해야 하면 두 리스너로 나누는 편이 읽기 쉬워요 |
@Transactional(propagation = REQUIRES_NEW) | 커밋 뒤 리스너가 DB 에 쓸 때 | 아래 계산처럼 커넥션을 하나 더 잡아요 |
@Async | 느린 외부 호출, 응답과 분리할 때 | 실행기 큐가 차거나 서버가 죽으면 이벤트가 사라져요 |
fallbackExecution = true | 배치·스크립트처럼 트랜잭션 없이도 돌아야 할 때 | 트랜잭션 안팎에서 실행 시점이 달라져 버그를 숨겨요 |
@Order | 같은 이벤트의 리스너 순서가 의미 있을 때 | 순서에 기대는 설계라면 리스너를 하나로 합치는 것도 검토해요 |
@TransactionalEventListener 메서드에 그냥 @Transactional 을 붙이면 시작 단계에서 막혀요. 프레임워크 소스 RestrictedTransactionalEventListenerFactory가 REQUIRES_NEW 나 NOT_SUPPORTED 가 아니면 예외를 던지도록 되어 있어요. 커밋 뒤에는 원래 트랜잭션에 "참여"해도 다시 커밋되지 않기 때문에, 실수를 일찍 잡아 주는 장치예요.
Spring Modulith 의 @ApplicationModuleListener 는 Spring Modulith 이벤트 문서 설명대로 @Async + @Transactional(propagation = REQUIRES_NEW) + @TransactionalEventListener 를 묶은 줄임말이에요. 그래서 비동기 실행기와 커넥션 풀 계산이 그대로 적용돼요.
숫자로 보는 비용 ① 동기 AFTER_COMMIT 이 응답 시간에 더하는 것
기본 AFTER_COMMIT 은 "커밋 뒤"라서 사용자와 상관없을 것 같지만, 실제로는 트랜잭션을 연 메서드가 반환되기 전에 같은 스레드에서 돌아요. 예시로 주문 취소 트랜잭션이 40ms, 리스너가 포인트 환급 15ms·쿠폰 복원 10ms·알림 API 300ms 라고 가정하고 계산해 볼게요.
- 동기
AFTER_COMMIT세 개: 40 + 15 + 10 + 300 = 365ms. 사용자는 알림 서버 응답까지 기다려요. - 세 리스너를
@Async로: 40ms. 실행기에 작업을 넘기는 시간은 무시할 만해요. - 발행 기록(리스너 3개 → 업무 트랜잭션 안에서 기록 행 3개 추가) + 비동기: 행 하나 넣는 데 약 1ms 로 잡으면 43ms.
알림 서버가 타임아웃 3초까지 늘어지면 동기 방식 응답도 3초를 넘겨요
표로 보기
| 구분 | 업무 트랜잭션 | 응답 전에 도는 리스너 |
|---|---|---|
| 동기 AFTER_COMMIT | 40 | 325 |
| @Async 리스너 | 40 | 0 |
| 발행 기록 + 비동기 | 43 | 0 |
계산에서 볼 점은 평균보다 꼬리예요. 알림 서버가 가끔 타임아웃까지 늘어지면 동기 리스너는 그 시간을 그대로 응답에 얹어요. 커밋은 이미 끝났으니 사용자 화면에는 실패처럼 보여도 주문은 취소된 상태가 돼요. 재시도 버튼을 누르면 "이미 취소된 주문" 오류가 나는 흔한 장면이 여기서 나와요.
비동기로 옮기면 응답은 빨라지지만 대가가 있어요. 스프링 부트 작업 실행 문서에 따르면 실행기 빈을 따로 두지 않았을 때 자동 구성되는 ThreadPoolTaskExecutor 는 코어 스레드 8개예요. 부트 설정 목록은 spring.task.execution.pool.queue-capacity 가 무제한이면 풀이 늘지 않아 max-size 를 무시한다고 적어요. 즉 기본값에서는 리스너가 아무리 밀려도 8개만 돌고 나머지는 메모리 큐에 쌓여요. 초당 50건 이벤트에 리스너 한 번이 300ms 면 필요한 동시 실행 수는 50 × 0.3 = 15개라서, 8개로는 큐가 계속 자라요. 그 큐는 재기동하면 사라져요. 알림처럼 외부로 나가는 리스너는 전용 실행기를 두고 큐 크기와 거절 정책을 정하세요.
숫자로 보는 비용 ② REQUIRES_NEW 가 커넥션을 두 개 쥐는 계산
AbstractPlatformTransactionManager 소스의 processCommit 을 보면 커밋 뒤 triggerAfterCommit·triggerAfterCompletion 을 먼저 부르고, 마지막 finally 에서 cleanupAfterCompletion 으로 트랜잭션 자원을 정리해요. AFTER_COMMIT 리스너는 afterCompletion 안에서 돌아요. 그래서 리스너가 REQUIRES_NEW 로 새 트랜잭션을 열면, 원래 커넥션을 아직 쥔 채로 두 번째 커넥션을 풀에서 빌려요. TransactionSynchronization Javadoc도 이 시점엔 트랜잭션 자원이 아직 살아 있을 수 있다고 적어요.
HikariCP 풀 크기 문서는 이런 "풀 잠김(pool-locking)"을 피하는 최소 크기를 풀 크기 = Tn × (Cm − 1) + 1 로 줘요. Tn 은 동시에 커넥션을 쓰는 최대 스레드 수, Cm 은 스레드 하나가 동시에 쥐는 최대 커넥션 수예요.
- 리스너가 쓰기를 안 하거나
@Async로 다른 스레드에서 쓰면 요청 스레드의 Cm = 1. 잠김은 생기지 않아요. - 커밋 뒤 리스너가
REQUIRES_NEW로 쓰면 Cm = 2. 동시에 이 경로를 타는 요청이 20개면 풀이 21 이상이어야 해요. - 그 리스너가 부르는 서비스가 또
REQUIRES_NEW를 열면 Cm = 3. 같은 20개에 41이 필요해요.
실제로 벌어지는 장면은 이래요. 히카리 기본 풀 10개에 주문 취소가 동시에 10건 몰리면, 10개 스레드가 각자 원래 커넥션을 쥔 채 두 번째 커넥션을 기다려요. 아무도 놓지 않으니 히카리 기본 connectionTimeout 30초가 지나서야 모두 예외로 끝나요. 이 예외는 afterCompletion 안에서 났기 때문에 TransactionSynchronizationUtils 소스대로 "TransactionSynchronization.afterCompletion threw exception" 로그만 남고 호출자에게 올라가지 않아요. 결과는 30초 지연 + 포인트 환급 10건 조용히 누락이에요.
해결 순서는 셋이에요. 첫째, 쓰기가 있는 커밋 뒤 리스너는 @Async 로 옮겨 요청 스레드의 Cm 을 1로 만들어요. 비동기 스레드에는 바깥 트랜잭션이 없어서 REQUIRES_NEW 도 커넥션 하나만 써요. 둘째, 동기로 둬야 한다면 위 공식으로 풀을 키우되, 톰캣 기본 최대 스레드 200처럼 웹 스레드가 풀보다 훨씬 많다는 점을 기억해요. 셋째, 리스너 안에서 또 REQUIRES_NEW 를 여는 중첩을 없애요. 풀 크기와 스레드 수를 함께 맞추는 계산은 스프링 배치 파티셔닝 커넥션 풀 계산 글에도 같은 방식으로 정리해 두었어요.
숫자로 보는 비용 ③ 발행 기록 테이블 쓰기량과 크기
Spring Modulith 의 이벤트 발행 기록은 이벤트를 받을 트랜잭셔널 리스너마다 행을 하나씩 원래 업무 트랜잭션 안에서 써요. 리스너가 성공하면 그 행을 완료로 바꾸고, 실패하면 남겨 둬요. 문서는 완료 처리 방식 spring.modulith.events.completion-mode 를 세 가지로 설명해요.
UPDATE(기본): 완료 날짜만 채우고 행은 남겨요. 문서는 지우는 코드를 따로 두지 않으면 테이블이 끝없이 자라고 새 기록 쓰기도 느려질 수 있다고 경고해요.DELETE: 완료하면 지워요. 완료 이력은 조회할 수 없어요.ARCHIVE: 아카이브 테이블로 옮기고 원본을 지워요. 이력은 남지만 아카이브가 자라요.
예시로 하루 주문 이벤트 20만 건, 받는 리스너 3개라고 해 볼게요. 업무 트랜잭션 안에서 하루 60만 행을 새로 넣어요. 완료 처리까지 합친 하루 쓰기는 UPDATE 120만(삽입 60만 + 갱신 60만), DELETE 120만(삽입 + 삭제), ARCHIVE 180만(삽입 + 아카이브 삽입 + 삭제)이에요. 30일 뒤 남는 행 수는 아래와 같아요.
행 하나가 인덱스 포함 1KB 라면 1,800만 행은 약 18GB 예요
표로 보기
| 구분 | 원본 테이블 | 아카이브 테이블 |
|---|---|---|
| UPDATE (기본) | 1,800 | 0 |
| ARCHIVE | 0 | 1,800 |
| DELETE | 0 | 0 |
설정 판단은 간단해요. 완료 이력을 조회할 화면이나 감사 요구가 없으면 DELETE 로 시작해요. 이력이 필요하면 ARCHIVE 를 쓰고 아카이브 테이블에 보존 기간 정리 작업을 붙여요. UPDATE 를 유지한다면 CompletedEventPublications 의 오래된 완료 건 삭제 메서드를 스케줄로 돌려요. 그리고 이벤트 본문은 직렬화돼 serialized_event 열에 들어가요. Modulith 부록의 스키마를 보면 PostgreSQL 은 TEXT 지만 H2 는 VARCHAR(4000) 이라, 엔티티를 통째로 담은 이벤트는 테스트에서만 길이 초과로 깨지기도 해요. 실제 저장된 JSON 을 JSON Formatter에 붙여 펼쳐 보면 쓸데없이 실린 필드가 바로 보여요.
운영에서 자주 나는 문제와 확인 순서
1. 리스너가 아예 안 돌아요. 먼저 저장소 호출을 봐요. Spring Data 도메인 이벤트 문서는 @DomainEvents 이벤트가 save·saveAll·delete·deleteAll·deleteAllInBatch·deleteInBatch 에서만 나간다고 적어요. 더티 체킹만 하고 save 를 안 불렀거나, deleteById·직접 짠 @Modifying 쿼리를 썼다면 이벤트가 없어요. 다음으로 트랜잭션이 있는지 봐요. 테스트나 배치에서 @Transactional 없이 부르면 트랜잭셔널 리스너는 디버그 로그 "No transaction is active - skipping" 만 남겨요.
2. 리스너는 돌았는데 DB 에 없어요. 커밋 뒤 리스너의 쓰기가 REQUIRES_NEW 없이 원래 트랜잭션에 참여만 하고 끝난 경우예요. 예외가 없어서 놓치기 쉬워요.
3. 커밋은 빨리 됐는데 응답이 느려요. 느린 요청의 스레드 덤프에서 리스너 메서드가 보이면 동기 AFTER_COMMIT 이에요. 비용 ①처럼 외부 호출 리스너를 비동기로 옮겨요.
4. 부하가 몰리면 30초씩 멈추고 타임아웃이 나요. 히카리 지표에서 대기 스레드 수가 치솟고 활성 커넥션이 풀 최대치에 붙어 있는지 봐요. 비용 ②의 풀 잠김이에요. 스프링 부트의 spring.jpa.open-in-view 가 켜져 있으면(기본값) 영속성 컨텍스트가 요청 끝까지 살아 있어 커넥션을 더 오래 쥘 수 있으니 같이 확인해요.
5. 오류 화면은 없는데 후속 처리만 빠져요. 로그에서 "afterCompletion threw exception" 을 찾아요. 커밋 뒤 리스너의 예외는 이 줄로만 남아요. 이 문자열로 알림을 걸어 두면 조용한 누락을 일찍 잡아요.
6. 재배포나 장애 뒤에 일부가 빠졌어요. 발행 기록을 쓴다면 event_publication 에서 완료 안 된 행과 status 를 봐요. Modulith 2.0 부터 상태가 PUBLISHED·PROCESSING·COMPLETED·FAILED·RESUBMITTED 로 나뉘고, 처리 중 죽은 건은 PROCESSING 에 머물러요. spring.modulith.events.staleness.processing 같은 오래됨 기준은 기본값이 0이라 꺼져 있으니, 켜 두어야 멈춘 건이 FAILED 로 바뀌어 FailedEventPublications 로 다시 보낼 수 있어요. 메모리 이벤트만 썼다면 흔적이 없으니 주문 테이블과 환급 내역을 맞대어 찾아요.
7. 같은 환급이 두 번 들어갔어요. 발행 기록과 아웃박스는 최소 한 번 전달이에요. republish-outstanding-events-on-restart 를 켰다면 부록이 경고하듯 여러 인스턴스에서는 다른 인스턴스가 처리 중인 건까지 다시 보낼 수 있어요. 받는 쪽에서 이벤트 키(예: 주문 ID + 이벤트 종류, 또는 이벤트 ID)를 상태 변경과 같은 트랜잭션에 기록해 중복을 걸러요. 이벤트 ID 를 새로 붙인다면 시간순으로 정렬되는 UUIDv7 이 인덱스에 유리해요. UUID 생성기로 v7 값을 만들어 형식을 확인해 볼 수 있어요.
8. IllegalStateException 이나 지연 로딩 예외가 나요. 리스너가 받은 애그리거트 인스턴스에 이벤트를 더 담으면 발행 도중 개수가 바뀌어 저장소 프록시가 예외를 던져요. 리스너에서는 id 로 다시 조회해 고쳐요. 비동기 리스너에서 LazyInitializationException 이 나면 이벤트에 엔티티를 담은 경우예요. 이벤트는 id 와 필요한 값만 담은 불변 레코드로 바꿔요.
도입 전에 따질 것 — 체크리스트
이벤트로 나누면 코드는 깔끔해지지만 운영할 것이 늘어요. 시작 전에 아래를 팀에서 정해 두세요.
- 이벤트 발행 규칙: 이벤트를 담는 유스케이스는 끝에
save()를 명시적으로 부른다. 코드 리뷰 항목으로 넣으면 1번 문제가 대부분 사라져요. - 리스너별 표의 두 열: 실패 시 원래 작업 취소 여부, 빠지면 사람이 찾는지. 리스너 클래스 주석이나 문서에 남겨요.
- 커넥션 예산: 커밋 뒤 리스너 중 동기
REQUIRES_NEW쓰기가 있으면 Tn × (Cm − 1) + 1 로 풀 크기를 다시 계산하거나 비동기로 옮겨요. - 실행기 설정: 비동기 리스너용 실행기의 코어 수·큐 크기·거절 정책. 기본 8개 + 무제한 큐가 우리 처리량에 맞는지 비용 ①처럼 계산해요.
- 기록 테이블 수명: 완료 모드(
DELETE·ARCHIVE·UPDATE), 정리 주기, 오래됨 기준, 실패 건을 누가 언제 다시 보낼지. - 멱등성: 받는 쪽마다 중복을 거르는 키와 저장 위치.
- 감시: "afterCompletion threw exception" 로그, 미완료 발행 기록 수, 비동기 큐 길이를 지표나 알림으로.
- 테스트: 테스트 클래스 전체에
@Transactional을 걸면 롤백으로 끝나 커밋 뒤 리스너가 안 돌아요. 커밋까지 가는 테스트를 따로 두거나, Modulith 를 쓴다면 Modulith 테스트 문서의Scenario로 발행과 비동기 결과를 기다려 검증해요.
서비스가 하나이고 후속 처리가 한두 개뿐이라면, 한 트랜잭션에서 둘 다 고치는 쪽이 더 단순할 수 있어요. 이벤트는 후속 동작이 계속 늘어나거나 실패를 서로 격리해야 할 때 값을 해요.
자주 묻는 질문
AFTER_COMMIT 리스너에서 REQUIRES_NEW 대신 @Async 만 붙이면 저장되나요?
다른 스레드에는 바깥 트랜잭션이 없어서 저장소 메서드 자체의 트랜잭션으로 저장은 돼요. 하지만 여러 번 쓰기를 한 단위로 묶으려면 여전히 @Transactional(propagation = REQUIRES_NEW) 가 필요해요. @ApplicationModuleListener 가 둘을 같이 묶는 이유예요.
히카리 풀을 크게 늘리면 풀 잠김이 해결되지 않나요?
공식대로 늘리면 잠김은 피할 수 있어요. 다만 HikariCP 문서는 DB 가 동시에 처리할 수 있는 쿼리 수를 넘는 큰 풀은 오히려 느려진다고 권하지 않아요. 요청 스레드가 커넥션을 두 개 쥐지 않도록 구조를 바꾸는 쪽을 먼저 보세요.
발행 기록을 쓰면 아웃박스는 필요 없나요?
한 애플리케이션 안 모듈 사이라면 발행 기록으로 충분한 경우가 많아요. 다른 서비스로 메시지를 보내야 하면 Modulith 의 이벤트 외부화(카프카·AMQP·JMS 등)나 별도 아웃박스 릴레이를 써요. 외부화도 트랜잭셔널 리스너 위에서 돌아 발행 기록이 실패를 지켜 줘요.
이벤트 순서가 보장되나요?
같은 스레드의 동기 리스너는 발행 순서와 @Order 대로 돌아요. 비동기로 바꾸면 실행기 스레드끼리 순서가 섞일 수 있어요. Modulith 문서도 외부화가 동시에 일어나면 나중 이벤트가 먼저 나갈 수 있다며 serialize-externalization 옵션을 안내해요. 순서가 중요하면 받는 쪽에서 버전이나 시각으로 오래된 이벤트를 버리는 편이 확실해요.
이벤트에 어떤 값을 담아야 하나요?
과거에 일어난 사실을 설명하는 id 와 최소한의 값만 담은 불변 레코드가 안전해요. 직렬화돼 기록 테이블에 남으니 개인정보처럼 오래 남기면 안 되는 값은 빼고, 필요하면 리스너가 id 로 다시 조회해요.
정리
- 후속 처리 방식은 "실패하면 원래 작업도 취소돼야 하나", "빠지면 사람이 찾아야 하나" 두 질문으로 골라요.
- 기본
AFTER_COMMIT은 응답 전에 같은 스레드에서 돌아요. 예시 계산에서 동기 365ms 가 비동기로 40ms 가 됐고, 기본 실행기는 코어 8개 + 무제한 큐라는 점을 함께 계산해요. - 커밋 뒤
REQUIRES_NEW는 요청당 커넥션 두 개를 쥐어요. 풀은 Tn × (Cm − 1) + 1 이상이어야 하고, 아니면 30초 지연과 조용한 누락이 생겨요. - 발행 기록은 하루 20만 이벤트·리스너 3개면 30일에 1,800만 행이 쌓여요. 완료 모드와 정리 주기, 오래됨 기준을 처음부터 정해요.
- 관련 글: DDD 애그리거트 경계 판단표, JPA N+1 해결 방법 고르기
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 DDD 도메인 이벤트 발행 원리 — save()와 AFTER_COMMIT에서 도식과 코드로 단계별로 풀었어요.