자바 백엔드 입문 38편. Spring ApplicationEvent와 @EventListener로 서비스 간 결합도를 낮추는 도메인 이벤트 패턴을 사내 게시판 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 38편이에요. 이번 38편은 "서비스끼리 직접 호출 대신 이벤트로 느슨하게 연결" 하는 패턴 — Spring ApplicationEvent 와 @EventListener 를 풀어 가요.
서비스 간 직접 호출의 함정
주문이 완료되면 — 이메일 발송·SMS 발송·재고 차감·포인트 적립이 일어나야 해요. 가장 직관적인 코드:
@Service
public class OrderService {
private final EmailService emailService;
private final SmsService smsService;
private final StockService stockService;
private final PointService pointService;
public void completeOrder(Order order) {
// 핵심 로직
order.complete();
repository.save(order);
// 부가 작업 4개 직접 호출
emailService.sendOrderConfirm(order);
smsService.sendOrderConfirm(order);
stockService.decreaseStock(order);
pointService.addPoints(order);
}
}
문제: - 결합도 높음 — OrderService가 4개 서비스를 다 알아야 함 - 테스트 어려움 — Mock 4개 만들어야 OrderService 테스트 가능 - 신규 부가 작업 추가 = OrderService 코드 수정 - 하나 실패 시 — 주문 자체가 롤백? 부분 성공 어떻게?
해결 = 이벤트 패턴. OrderService는 "주문 완료됐다" 만 외치고, 듣고 싶은 서비스가 알아서 반응.
ApplicationEvent — 사내 알림 게시판 비유
ApplicationEvent = "사내 알림 게시판". 발행자는 게시판에 "이런 일 일어났다" 만 적고, 관심 부서가 알아서 게시판을 보고 반응.
[OrderService] → 게시판에 "OrderCompleted" 박음
↓
[EmailListener] ← 게시판 보고 → 이메일 발송
[SmsListener] ← 게시판 보고 → SMS 발송
[StockListener] ← 게시판 보고 → 재고 차감
[PointListener] ← 게시판 보고 → 포인트 적립
OrderService는 "누가 듣는지" 신경 안 써. 결합도 0.
이벤트 객체 정의
자바 17+ record로 간결.
public record OrderCompletedEvent(Long orderId, Long userId, int amount) { }
옛 스타일은 extends ApplicationEvent 또는 일반 클래스. Spring 4.2+ 부터 — 그냥 POJO·record면 OK.
이벤트 발행 — ApplicationEventPublisher
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher publisher;
public void completeOrder(Order order) {
order.complete();
repository.save(order);
publisher.publishEvent(new OrderCompletedEvent(
order.getId(), order.getUserId(), order.getAmount()));
}
}
ApplicationEventPublisher 주입 → publishEvent(이벤트) 호출. 끝. OrderService는 누가 듣는지 모름.
이벤트 수신 — @EventListener
@Component
@RequiredArgsConstructor
public class OrderEmailListener {
private final EmailService emailService;
@EventListener
public void onOrderCompleted(OrderCompletedEvent event) {
emailService.sendOrderConfirm(event.orderId());
}
}
@Component
@RequiredArgsConstructor
public class OrderStockListener {
private final StockService stockService;
@EventListener
public void onOrderCompleted(OrderCompletedEvent event) {
stockService.decrease(event.orderId());
}
}
@EventListener 메서드 매개변수 타입으로 — 어떤 이벤트 들을지 결정. 신규 부가 작업 추가 = 새 @Component + @EventListener. OrderService 코드는 그대로.
기본 동작 — 동기 + 같은 트랜잭션
기본 @EventListener 는 "동기" + "발행자와 같은 트랜잭션" 안에서 실행.
@Transactional
public void completeOrder(Order order) {
// 트랜잭션 시작
order.complete();
repository.save(order);
publisher.publishEvent(new OrderCompletedEvent(...)); // ← 리스너 동기 호출
// 트랜잭션 끝
}
문제: - 리스너가 예외 던지면 → 주문 자체 롤백 - 리스너가 5초 걸리면 → 주문 응답도 5초 늦어짐 - 이메일 발송 실패가 주문 실패로 둔갑
이게 입문자가 가장 자주 빠지는 함정.
@TransactionalEventListener — 트랜잭션 후 실행
@Component
public class OrderEmailListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCompleted(OrderCompletedEvent event) {
emailService.sendOrderConfirm(event.orderId());
}
}
@TransactionalEventListener(phase = AFTER_COMMIT) = "트랜잭션 커밋 성공 후" 실행. 이메일 실패가 주문 롤백으로 이어지지 않아요. 한국 회사 백엔드 표준.
phase 종류:
- BEFORE_COMMIT — 커밋 전
- AFTER_COMMIT — 커밋 후 (가장 흔함)
- AFTER_ROLLBACK — 롤백 후
- AFTER_COMPLETION — 커밋·롤백 후 둘 다
@Async — 비동기 처리
이메일 발송이 5초 걸려도 주문 응답은 즉시. 39편 @Async 에서 깊이.
@SpringBootApplication
@EnableAsync // ← 활성화
public class Application { ... }
@Component
public class OrderEmailListener {
@Async // ← 비동기
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCompleted(OrderCompletedEvent event) {
emailService.sendOrderConfirm(event.orderId()); // 별도 스레드
}
}
@Async + @TransactionalEventListener(AFTER_COMMIT) 조합이 "비동기 도메인 이벤트" 표준. 주문 → 즉시 응답 → 이메일·SMS·재고 차감 백그라운드.
이벤트 패턴 vs 메시지 큐 (Kafka·RabbitMQ)
ApplicationEvent는 — 같은 JVM 안에서만 동작. JVM 종료되면 이벤트 손실. 다음과 비교:
| ApplicationEvent | Kafka·RabbitMQ | |
|---|---|---|
| 범위 | 같은 JVM 안 | 여러 서버 |
| 영속성 | 없음 — JVM 죽으면 손실 | 디스크 보관 |
| 재시도 | 직접 구현 | 내장 |
| 모니터링 | 직접 구현 | 풍부한 도구 |
| 도입 비용 | 0 | Kafka 클러스터 구축 |
룰: - 같은 서비스 안 모듈 분리 = ApplicationEvent - 서비스 간 통신·재시도 필수 = Kafka·RabbitMQ
작은 서비스는 ApplicationEvent로 시작 → 규모 커지면 Kafka로 마이그레이션.
도메인 이벤트 — DDD 영향
ApplicationEvent의 진짜 가치 = 도메인 이벤트 패턴. "비즈니스 의미 있는 사건" 을 이벤트로 표현.
좋은 이벤트 이름:
- OrderCompletedEvent
- UserRegisteredEvent
- PaymentSucceededEvent
- StockExhaustedEvent
나쁜 이벤트 이름 (구현 디테일):
- EmailSentEvent — "이메일 발송" 은 결과지 사건 아님
- DatabaseUpdatedEvent — DB 관점
이벤트는 "무엇이 일어났나" 를 비즈니스 언어로. 이게 도메인 주도 설계 (DDD) 의 핵심.
이벤트 패턴 안티패턴
(1) 순환 이벤트
@EventListener
public void onA(EventA e) {
publisher.publishEvent(new EventB()); // EventB 발행
}
@EventListener
public void onB(EventB e) {
publisher.publishEvent(new EventA()); // 다시 EventA — 무한 루프!
}
리스너 안에서 이벤트 또 발행은 주의. 의도하지 않은 순환 = 무한 루프.
(2) 너무 많은 책임
한 리스너에서 너무 많은 일을 처리하면 — 이벤트 패턴 의미 사라짐. 작은 단위로 쪼개기.
(3) 비동기 안에서 트랜잭션 의존 코드
@Async 리스너 안에서 — 발행자의 영속성 컨텍스트 (47편 영속성 컨텍스트) 가 이미 닫혀 있어요. LazyLoading 시도하면 LazyInitializationException 폭발.
해결 — 이벤트 객체에 필요한 데이터 미리 박기. ID만 던지지 말고 "비동기 처리에 필요한 모든 값" 을 record에 담아 전달.
@Async + @TransactionalEventListener(AFTER_COMMIT) 조합이 표준. 주문·결제·회원 가입 같은 핵심 흐름에서 — 부가 작업을 도메인 이벤트로 분리. 결합도 ↓, 응답 속도 ↑.
한 줄 정리 — ApplicationEvent + @EventListener = 같은 JVM 안 결합도 낮추는 표준 패턴. @TransactionalEventListener(AFTER_COMMIT) + @Async 조합이 한국 회사 백엔드 표준. 서비스 간·재시도 필수 시 Kafka로.
시험 직전 한 번 더 — ApplicationEvent 입문자가 매번 헷갈리는 것
- ApplicationEvent = 같은 JVM 안 도메인 이벤트
- 동기 = 발행자 같은 스레드·트랜잭션
- 비동기 =
@Async+@EnableAsync - 트랜잭션 후 =
@TransactionalEventListener(AFTER_COMMIT) - 이벤트 객체 = record 또는 POJO (자바 17+ record 권장)
- 발행 =
ApplicationEventPublisher.publishEvent(event) - 수신 =
@EventListener+ 매개변수 타입으로 매칭 - 한국 회사 표준 = @Async + @TransactionalEventListener(AFTER_COMMIT)
- TransactionPhase = BEFORE_COMMIT·AFTER_COMMIT·AFTER_ROLLBACK·AFTER_COMPLETION
- 기본 동기 = 리스너 예외 → 발행자 트랜잭션 롤백
- AFTER_COMMIT = 리스너 실패가 발행자 영향 X
- @Async 리스너에서 발행자 엔티티 LazyLoading X — 데이터 미리 박기
- 같은 JVM 한정 = JVM 죽으면 손실
- 서비스 간 = Kafka·RabbitMQ
- 도메인 이벤트 이름 = 비즈니스 언어 (
OrderCompletedEvent) - 구현 디테일 이벤트 이름 안 좋음 (
DatabaseUpdatedEvent) - 순환 이벤트 주의 — 무한 루프 위험
- DDD 도메인 이벤트 패턴 = ApplicationEvent로 구현
- 모듈 분리·결합도 감소 = 마이크로서비스 직전 단계
- 작은 서비스 ApplicationEvent → 규모 커지면 Kafka 마이그레이션
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 33편 — @ExceptionHandler @ControllerAdvice
- 34편 — Bean Validation @Valid @NotNull
- 35편 — 커스텀 Validator 만들기
- 36편 — Logback SLF4J 로깅
- 37편 — Spring Security 기초
다음 글: