자바 백엔드 입문 38편 — Spring ApplicationEvent @EventListener

2026-05-17자바 백엔드 입문

자바 백엔드 입문 38편. Spring ApplicationEvent와 @EventListener로 서비스 간 결합도를 낮추는 도메인 이벤트 패턴을 사내 게시판 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 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 마이그레이션

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!