광고
~ / posts / developer
smith@lab:~/posts$ cat developer/spring-domain-event-listener-phase-pool-sizing-guide.md

스프링 도메인 이벤트 설정 고르기, 리스너 단계와 커넥션 풀 계산

주문을 취소하면 포인트를 돌려주고, 쿠폰을 살리고, 알림을 보내야 해요. 스프링에서 이 후속 처리를 도메인 이벤트로 넘기기로 했다면 남는 질문은 "어느 리스너 단계에, 어떤 전파 속성으로, 동기로 둘까 비동기로 둘까"예요. 결론부터 말하면 후속 처리가 실패했을 때 원래 작업도 취소돼야 하는지, 빠지면 사람이 찾아야 하는지 두 질문으로 방식을 고르고, 커밋 뒤 리스너가 DB 에 쓴다면 커넥션 풀 크기를 다시 계산해야 해요.

이 글은 이벤트가 프록시를 거쳐 나가는 원리 대신, 운영에서 숫자로 돌아오는 결정을 다뤄요. 리스너 단계마다 응답 시간이 얼마나 늘어나는지, REQUIRES_NEW 가 왜 풀 잠김을 부르는지, 발행 기록 테이블이 한 달에 얼마나 쌓이는지를 2026년 10월 28일 기준 공식 문서(Spring Framework 7.0.10, Spring Modulith 2.1.1, 스프링 부트 레퍼런스)와 소스로 확인해 계산했어요. 애그리거트 경계를 어떻게 긋는지는 DDD 애그리거트 경계 판단표 글에 따로 정리해 두었어요.

광고

후속 처리 방식 판단표 — 먼저 두 질문에 답하기

도메인 이벤트를 받는 방식은 크게 다섯 가지예요. 무엇을 고를지는 기술보다 업무 질문으로 정해져요. 아래 순서도의 첫 질문은 "후속 처리가 실패하면 원래 작업도 취소돼야 하나"예요. 예라면 사실상 두 애그리거트를 한 트랜잭션으로 고치는 셈이라, 잠금 범위가 넓어지는 비용을 감수하고 같은 트랜잭션에 둬요. 아니라면 커밋 뒤로 보내고, 두 번째 질문 "빠지면 사람이 찾아야 하나"로 유실 대비 수준을 정해요.

후속 처리 실패 시 원래 작업 취소 여부, 빠지면 사람이 찾아야 하는지, 느린 외부 호출인지, 다른 서비스로 보내는지 차례로 물어 같은 트랜잭션·AFTER_COMMIT·비동기·발행 기록·아웃박스 중 하나를 고르는 순서도
위에서부터 답하면 시작점이 정해져요
후속 처리 예실패하면 원래 작업도 취소?빠지면 사람이 찾나?고를 방식
주문 합계 검증, 같은 명령의 감사 로그예—@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 라고 가정하고 계산해 볼게요.

주문 취소 요청 응답 시간 (예시 가정으로 계산)
업무 트랜잭션응답 전에 도는 리스너
0100200300400동기 AFTER_COMMIT · 업무 트랜잭션 40ms동기 AFTER_COMMIT · 응답 전에 도는 리스너 325ms동기 AFTER_COMMIT@Async 리스너 · 업무 트랜잭션 40ms@Async 리스너 · 응답 전에 도는 리스너 0ms@Async 리스너발행 기록 + 비동기 · 업무 트랜잭션 43ms발행 기록 + 비동기 · 응답 전에 도는 리스너 0ms발행 기록 + 비동기

알림 서버가 타임아웃 3초까지 늘어지면 동기 방식 응답도 3초를 넘겨요

단위: ms · 자료: 트랜잭션 40ms, 리스너 15·10·300ms, 기록 행 1ms 가정으로 직접 계산
표로 보기
구분업무 트랜잭션응답 전에 도는 리스너
동기 AFTER_COMMIT40325
@Async 리스너400
발행 기록 + 비동기430

계산에서 볼 점은 평균보다 꼬리예요. 알림 서버가 가끔 타임아웃까지 늘어지면 동기 리스너는 그 시간을 그대로 응답에 얹어요. 커밋은 이미 끝났으니 사용자 화면에는 실패처럼 보여도 주문은 취소된 상태가 돼요. 재시도 버튼을 누르면 "이미 취소된 주문" 오류가 나는 흔한 장면이 여기서 나와요.

비동기로 옮기면 응답은 빨라지지만 대가가 있어요. 스프링 부트 작업 실행 문서에 따르면 실행기 빈을 따로 두지 않았을 때 자동 구성되는 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 은 스레드 하나가 동시에 쥐는 최대 커넥션 수예요.

풀 잠김 없이 필요한 최소 커넥션 풀 크기
요청당 커넥션 2개요청당 커넥션 3개
050100150200250동시 10요청 · 요청당 커넥션 2개 11개동시 10요청 · 요청당 커넥션 3개 21개동시 10요청동시 20요청 · 요청당 커넥션 2개 21개동시 20요청 · 요청당 커넥션 3개 41개동시 20요청동시 50요청 · 요청당 커넥션 2개 51개동시 50요청 · 요청당 커넥션 3개 101개동시 50요청동시 100요청 · 요청당 커넥션 2개 101개동시 100요청 · 요청당 커넥션 3개 201개동시 100요청

히카리 기본 풀 10개는 동시 10요청이 모두 이 경로를 타면 이미 부족해요

단위: 개 · 자료: HikariCP About Pool Sizing 공식 Tn × (Cm − 1) + 1 로 직접 계산
표로 보기
구분요청당 커넥션 2개요청당 커넥션 3개
동시 10요청1121
동시 20요청2141
동시 50요청51101
동시 100요청101201

실제로 벌어지는 장면은 이래요. 히카리 기본 풀 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 를 세 가지로 설명해요.

예시로 하루 주문 이벤트 20만 건, 받는 리스너 3개라고 해 볼게요. 업무 트랜잭션 안에서 하루 60만 행을 새로 넣어요. 완료 처리까지 합친 하루 쓰기는 UPDATE 120만(삽입 60만 + 갱신 60만), DELETE 120만(삽입 + 삭제), ARCHIVE 180만(삽입 + 아카이브 삽입 + 삭제)이에요. 30일 뒤 남는 행 수는 아래와 같아요.

30일 뒤 남는 발행 기록 행 수 (하루 이벤트 20만 건·리스너 3개 예시)
원본 테이블아카이브 테이블
05001,0001,5002,000UPDATE (기본) · 원본 테이블 1,800만 행UPDATE (기본) · 아카이브 테이블 0만 행UPDATE (기본)ARCHIVE · 원본 테이블 0만 행ARCHIVE · 아카이브 테이블 1,800만 행ARCHIVEDELETE · 원본 테이블 0만 행DELETE · 아카이브 테이블 0만 행DELETE

행 하나가 인덱스 포함 1KB 라면 1,800만 행은 약 18GB 예요

단위: 만 행 · 자료: 20만 × 3 × 30일로 직접 계산, 미완료 행은 0에 가깝다고 가정
표로 보기
구분원본 테이블아카이브 테이블
UPDATE (기본)1,8000
ARCHIVE01,800
DELETE00

설정 판단은 간단해요. 완료 이력을 조회할 화면이나 감사 요구가 없으면 DELETE 로 시작해요. 이력이 필요하면 ARCHIVE 를 쓰고 아카이브 테이블에 보존 기간 정리 작업을 붙여요. UPDATE 를 유지한다면 CompletedEventPublications 의 오래된 완료 건 삭제 메서드를 스케줄로 돌려요. 그리고 이벤트 본문은 직렬화돼 serialized_event 열에 들어가요. Modulith 부록의 스키마를 보면 PostgreSQL 은 TEXT 지만 H2 는 VARCHAR(4000) 이라, 엔티티를 통째로 담은 이벤트는 테스트에서만 길이 초과로 깨지기도 해요. 실제 저장된 JSON 을 JSON Formatter에 붙여 펼쳐 보면 쓸데없이 실린 필드가 바로 보여요.

광고
광고

운영에서 자주 나는 문제와 확인 순서

리스너 미실행, 저장 누락, 응답 지연, 부하 때 30초 멈춤, 예외 없는 누락, 재배포 뒤 누락, 중복 반영, IllegalStateException 여덟 가지 증상과 먼저 볼 곳을 정리한 표
증상으로 줄을 찾고 오른쪽부터 확인해요

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 와 필요한 값만 담은 불변 레코드로 바꿔요.

도입 전에 따질 것 — 체크리스트

이벤트로 나누면 코드는 깔끔해지지만 운영할 것이 늘어요. 시작 전에 아래를 팀에서 정해 두세요.

서비스가 하나이고 후속 처리가 한두 개뿐이라면, 한 트랜잭션에서 둘 다 고치는 쪽이 더 단순할 수 있어요. 이벤트는 후속 동작이 계속 늘어나거나 실패를 서로 격리해야 할 때 값을 해요.

광고
광고

자주 묻는 질문

AFTER_COMMIT 리스너에서 REQUIRES_NEW 대신 @Async 만 붙이면 저장되나요?

다른 스레드에는 바깥 트랜잭션이 없어서 저장소 메서드 자체의 트랜잭션으로 저장은 돼요. 하지만 여러 번 쓰기를 한 단위로 묶으려면 여전히 @Transactional(propagation = REQUIRES_NEW) 가 필요해요. @ApplicationModuleListener 가 둘을 같이 묶는 이유예요.

히카리 풀을 크게 늘리면 풀 잠김이 해결되지 않나요?

공식대로 늘리면 잠김은 피할 수 있어요. 다만 HikariCP 문서는 DB 가 동시에 처리할 수 있는 쿼리 수를 넘는 큰 풀은 오히려 느려진다고 권하지 않아요. 요청 스레드가 커넥션을 두 개 쥐지 않도록 구조를 바꾸는 쪽을 먼저 보세요.

발행 기록을 쓰면 아웃박스는 필요 없나요?

한 애플리케이션 안 모듈 사이라면 발행 기록으로 충분한 경우가 많아요. 다른 서비스로 메시지를 보내야 하면 Modulith 의 이벤트 외부화(카프카·AMQP·JMS 등)나 별도 아웃박스 릴레이를 써요. 외부화도 트랜잭셔널 리스너 위에서 돌아 발행 기록이 실패를 지켜 줘요.

이벤트 순서가 보장되나요?

같은 스레드의 동기 리스너는 발행 순서와 @Order 대로 돌아요. 비동기로 바꾸면 실행기 스레드끼리 순서가 섞일 수 있어요. Modulith 문서도 외부화가 동시에 일어나면 나중 이벤트가 먼저 나갈 수 있다며 serialize-externalization 옵션을 안내해요. 순서가 중요하면 받는 쪽에서 버전이나 시각으로 오래된 이벤트를 버리는 편이 확실해요.

이벤트에 어떤 값을 담아야 하나요?

과거에 일어난 사실을 설명하는 id 와 최소한의 값만 담은 불변 레코드가 안전해요. 직렬화돼 기록 테이블에 남으니 개인정보처럼 오래 남기면 안 되는 값은 빼고, 필요하면 리스너가 id 로 다시 조회해요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 DDD 도메인 이벤트 발행 원리 — save()와 AFTER_COMMIT에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#스프링 도메인 이벤트#TransactionalEventListener 설정#AFTER_COMMIT REQUIRES_NEW#커넥션 풀 데드락#히카리 풀 크기#Spring Modulith 발행 기록#event_publication 테이블#도메인 이벤트 유실
← 이전 글레디스 분산 락 고르는 법, TTL·Redlock·펜싱 토큰 판단표다음 글 →tar 압축 해제 보안, 경로 순회·링크 탈출·파서 해석 차이 막는 법
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →