@ApplicationModuleListener: 모듈 간 통합을 이벤트로 느슨하게 연결할 때 쓰는 이벤트 리스너 선언 단축 표기.- Spring Framework 기본 기능이 아니라 Spring Modulith 프로젝트가 제공하는 애노테이션이다.
- 모듈러 모놀리식에서 한 모듈이 다른 모듈을 직접 호출하지 않고, 도메인 이벤트를 발행해 후속 처리를 위임하는 패턴을 위한 것.
세 애노테이션의 합성#
- 이 애노테이션은 세 가지를 한 번에 붙인 메타 애노테이션이다.
@Async — 발행 측과 다른 스레드에서 비동기로 실행한다.@Transactional(propagation = REQUIRES_NEW) — 리스너 자신의 새 트랜잭션에서 실행한다.@TransactionalEventListener — 이벤트를 발행한 트랜잭션이 커밋된 뒤 호출된다(기본 phase가 AFTER_COMMIT).
// 단축 표기
@Component
class InventoryManagement {
@ApplicationModuleListener
fun on(event: OrderCompleted) { /* … */ }
}
// 위와 동일한 의미
@Component
class InventoryManagement {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
fun on(event: OrderCompleted) { /* … */ }
}
- 각 애노테이션이 붙는 이유:
@TransactionalEventListener의 기본 phase가 AFTER_COMMIT이라, 발행 측 트랜잭션이 커밋에 성공해야만 리스너가 돈다. 발행 측이 롤백되면 리스너는 실행되지 않는다.@Async로 발행 측 스레드와 분리해, 리스너가 오래 걸려도 발행 측 응답을 막지 않는다.AFTER_COMMIT 시점엔 원래 트랜잭션이 이미 끝나 있으므로, 리스너에서 DB 쓰기를 하려면 새 트랜잭션이 필요하다. 그래서 REQUIRES_NEW.
- 발행 측은 그냥
ApplicationEventPublisher로 이벤트를 던지면 된다.
@Service
class OrderManagement(val events: ApplicationEventPublisher) {
@Transactional
fun complete(order: Order) {
// 주문 애그리거트 상태 전이
events.publishEvent(OrderCompleted(order.id))
}
}
단순 @Async + @TransactionalEventListener의 함정#
- 메타 애노테이션을 풀어 쓴 것만으로는 한 가지 문제가 남는다: 이벤트 유실.
@Async + @TransactionalEventListener 조합에서, 원 트랜잭션은 이미 커밋됐는데 리스너가 예외로 실패하면 그 이벤트는 그냥 사라진다.- 발행 측은 이미 커밋·성공 응답을 끝냈으므로 되돌릴 방법이 없다.
- 동기 리스너였다면 예외가 호출 흐름에 전파되지만,
@Async라 별도 스레드에서 조용히 죽는다.
@ApplicationModuleListener가 단순 조합과 결정적으로 다른 점은, Spring Modulith의 이벤트 발행 레지스트리와 함께 동작해 이 유실을 막는다는 것이다.
이벤트 발행 레지스트리 (Event Publication Registry)#
- 동작 순서:
- 이벤트가 발행되면, Spring Modulith가 발행 기록을 DB에 먼저 저장(INSERT)한다.
- 이 INSERT는 발행 측(원래) 트랜잭션 안에서 일어나므로, 비즈니스 상태 변경과 원자적으로 커밋된다.
- 커밋 후 리스너가 호출되고, 정상 종료하면 해당 기록을 완료(completed)로 표시한다.
- 리스너가 실패하면 기록이 미완료(incomplete) 상태로 남는다 → 유실되지 않고 추적·재처리 가능.
- 트랜잭셔널 아웃박스(transactional outbox)와 같은 발상이다. “상태 변경과 이벤트 기록을 한 트랜잭션에 묶고, 전달은 나중에 보장한다.”
DB에 어떻게 저장되나#
- 저장 위치는 영속 기술에 따라 다르다. JDBC면
EVENT_PUBLICATION 테이블, JPA면 대응 엔티티, MongoDB면 컬렉션. 아래는 JDBC 기준 표준 스키마(MySQL)다.
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION (
ID VARCHAR(36) NOT NULL, -- 발행 기록 식별자(UUID)
LISTENER_ID VARCHAR(512) NOT NULL, -- 대상 리스너 식별자
EVENT_TYPE VARCHAR(512) NOT NULL, -- 이벤트 클래스 FQCN
SERIALIZED_EVENT VARCHAR(4000) NOT NULL, -- 직렬화된 이벤트 본문
PUBLICATION_DATE TIMESTAMP(6) NOT NULL, -- 발행 시각
COMPLETION_DATE TIMESTAMP(6) DEFAULT NULL, -- 완료 시각(미완료면 NULL)
STATUS VARCHAR(20), -- 상태(PUBLISHED/COMPLETED 등)
COMPLETION_ATTEMPTS INT, -- 처리 시도 횟수
LAST_RESUBMISSION_DATE TIMESTAMP(6) DEFAULT NULL, -- 마지막 재제출 시각
PRIMARY KEY (ID),
INDEX EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX (COMPLETION_DATE)
);
- 핵심: 이벤트 1건을 발행한다고 row가 1건 생기는 게 아니다. 그 이벤트를 받는 트랜잭셔널 리스너(
@ApplicationModuleListener 등) 하나당 row 하나가 생긴다.- 같은 이벤트를 리스너 3개가 듣고 있으면
LISTENER_ID만 다른 row 3건이 INSERT된다. - 완료/미완료는 (이벤트 × 리스너) 단위로 독립 추적된다. 리스너 A는 성공하고 B만 실패하면 B의 row만 미완료로 남는다.
SERIALIZED_EVENT에는 이벤트 객체가 직렬화돼 들어간다(기본 Jackson JSON).- 그래서 이벤트는 직렬화 가능해야 한다. 재발행 시 이 컬럼을 역직렬화해 이벤트를 복원하므로, 클래스 구조가 바뀌면 과거 기록을 못 읽을 수 있다(역직렬화 호환성 주의).
- 리스너가 성공하면 완료 처리 모드에 따라 이 row가 바뀐다:
UPDATE면 COMPLETION_DATE를 채우고, DELETE면 row를 지우고, ARCHIVE면 아카이브 테이블로 옮긴다.
미완료 이벤트 재발행#
- 애플리케이션이 리스너 실행 중 죽거나 리스너가 예외로 끝나면 미완료 row(
COMPLETION_DATE가 NULL)가 남는다. - 재시작 시 자동 재발행:
spring.modulith.events.republish-outstanding-events-on-restart=true (기본 false). - 여기서 “재발행(republish)“은 DB에 이벤트를 다시 기록(INSERT)한다는 뜻이 아니다.
- 이미 저장돼 있는 미완료 row를 읽어,
SERIALIZED_EVENT를 역직렬화해 이벤트 객체를 복원하고, LISTENER_ID가 가리키는 바로 그 리스너를 다시 호출하는 것이다. - row는 새로 생기지 않는다. 같은 row를 재사용하며
COMPLETION_ATTEMPTS(시도 횟수)가 늘고, 이번에 성공하면 그 row가 완료 처리된다. - 즉 “재발행"의 대상은 DB 기록이 아니라 **리스너로의 전달(재호출)**이다.
- 런타임 수동 재처리:
IncompleteEventPublications 빈을 주입해 미완료 발행을 다시 제출할 수 있다(역시 기존 row 기반 재호출). - 결과적으로 전달 보장 수준은 at-least-once다. 리스너는 같은 이벤트를 두 번 받을 수 있으므로 멱등하게 짜야 한다.
완료 처리 모드 (Completion Mode)#
- 리스너 성공 시 발행 기록을 어떻게 처리할지
spring.modulith.events.completion-mode로 정한다. 값 3가지:
| 모드 | 동작 |
|---|
UPDATE (기본) | 완료 시각 컬럼을 채워 완료 표시. 기록은 테이블에 그대로 남는다. |
DELETE | 완료된 기록을 삭제한다. |
ARCHIVE | 완료된 기록을 별도 아카이브 테이블로 옮긴다. |
- 기본
UPDATE는 기록이 계속 누적되므로 주기적 정리가 필요하다.CompletedEventPublications 빈으로 정리한다: deletePublicationsOlderThan(Duration)(나이 기준), deletePublications(Predicate)(조건 기준).
영속화 스타터와 스키마#
- 레지스트리는 영속 저장소가 있어야 동작한다. 영속 기술별 스타터를 골라 의존성에 추가한다.
spring-modulith-starter-jpa, spring-modulith-starter-jdbc, spring-modulith-starter-mongodb, spring-modulith-starter-neo4j.- 스타터가 직렬화·레지스트리 인프라를 자동 구성한다.
- JDBC/JPA는 이벤트 발행 테이블 스키마가 필요하다.
spring.modulith.events.jdbc-schema-initialization.enabled=true로 자동 생성하거나, DB별 제공 스키마(MySQL·PostgreSQL 등)를 직접 적용한다.
이벤트 외부화 (externalization)#
- 같은 레지스트리 기반으로, 발행한 이벤트를 Kafka·RabbitMQ 같은 외부 브로커로 내보내는 기능도 있다.
- 외부화 역시 트랜잭셔널 이벤트 리스너로 구현돼, 브로커 전송이 실패하면 미완료 기록을 통해 재시도할 수 있다.
@TransactionalEventListener(phase = AFTER_COMMIT)만 쓰던 코드(애그리거트에서 이벤트를 모아 커밋 후 동기 처리)와 비교하면, @ApplicationModuleListener는 비동기 + 새 트랜잭션 + 발행 레지스트리 기반 유실 방지까지 한 애노테이션으로 묶어 준다.- 모듈 간 통신을 이벤트로 분리하면서도, “발행 측이 커밋했는데 후속 처리가 유실되는” 문제를 막아야 할 때 적합하다.
- 단, 동작 전제로 비동기 처리가 활성화돼 있어야 하고(
@EnableAsync 등), 리스너는 재전달을 견디도록 멱등하게 작성해야 한다.
참고 자료#