Back-End/Spring Boot

[Spring Boot] ApplicationEventPublisher를 활용한 도메인 간 역할 분리

러러 2026. 1. 30. 21:59

OOP 기반으로 애플리케이션을 설계하다 보면 자연스럽게 도메인 간의 영역, 역할, 책임에 대해 고민하게 된다.
처음에는 기능이 정상 동작하는 것에 집중하지만, 시간이 지날수록 이런 질문이 반복된다.

"이 로직이 정말 이 도메인에 속하는 게 맞는가?"

 

예를 들어 주문이 생성되면 다음과 같은 작업이 연쇄적으로 발생할 수 있다.

  • 포인트 적립
  • 알림 발송
  • 감사 로그 기록
  • 외부 시스템 연동
  • 통계 집계

이때 주문 서비스에서 관련 서비스들을 직접 호출하기 시작하면,
주문 도메인이 여러 도메인에 강하게 결합된 중앙 허브가 되어버린다.

이 방식은 초기 개발 속도는 빠르지만, 코드가 커질수록 다음과 같은 문제가
발생한다.

  • 도메인 간 결합도 증가
  • 테스트 복잡도 상승
  • 변경 영향 범위 확대
  • 책임 집중
  • 사이드 이펙트 증가
  • 기능 확장 시 기존 코드 수정 필요

이 문제를 완화하는 방법 중 하나가 바로
Spring의 ApplicationEventPublisher 기반 이벤트 구조다.

이 글에서는 Spring Boot 환경에서 이벤트 기반 구조를 활용해
도메인 간 결합도를 낮추고 역할을 분리하는 방법을 단계적으로 정리한다.


Spring Application Event 구조 이해

Spring은 애플리케이션 내부 이벤트를 기반으로
객체 간 직접 호출 없이 협력할 수 있는 메커니즘을 제공한다.

구성 요소는 3가지다.

  • Publisher --- 이벤트를 발행하는 주체
  • Event --- 전달되는 데이터 객체
  • Listener --- 이벤트를 수신해 처리하는 주체

핵심 포인트는 Publisher가 Listener를 전혀 모른다는 것이다.

즉:

  • 누가 처리하는지 모른다
  • 몇 개가 처리하는지 모른다
  • 처리 방식이 무엇인지 모른다

이 구조는 결합도를 낮추고 확장성을 높인다.


이벤트 기반 협력 구조 다이어그램

발행자는 이벤트만 전달한다.
처리는 구독자들이 맡는다.

 

Spring에서는 ApplicationEventPublisher를 통해 이벤트를 발행할 수 있다.

Spring Boot 3.x 이상에서는 별도 설정 없이 바로 사용 가능하다.

이벤트 객체 설계

이벤트 객체는 다음 원칙으로 만드는 것이 좋다.

  • 불변 객체
  • 최소 데이터만 포함
  • 도메인 상태 전체가 아닌 식별자 중심
  • 사이드 이펙트 없는 구조

예제

public record OrderCreatedEvent(
        Long orderId,
        Long userId,
        Instant createdAt
) {}

record 타입을 쓰면 불변성과 가독성을 동시에 확보할 수 있다.


이벤트 발행 코드

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void createOrder(Order order) {
        var createdOrder = orderRepository.save(order);

        eventPublisher.publishEvent(
            new OrderCreatedEvent(createdOrder.getId(), createdOrder.getUserId(), Instant.now())
        );
    }
}

왜 이 방식이 좋은가

  • 후처리 로직 제거
  • 서비스 책임 축소
  • 의존성 감소
  • 확장 시 수정 없음
  • 테스트 격리 가능

@EventListener --- 이벤트 수신

이벤트를 수신하는 쪽에서는 @EventListener를 사용한다.

Spring은 파라미터 타입을 기준으로 자동 매핑한다.

기본 리스너 예제

@Component
@RequiredArgsConstructor
public class PointRewardListener {

    private final PointService pointService;

    @EventListener
    public void handle(OrderCreatedEvent event) {
        pointService.reward(event.userId());
    }
}

다중 리스너 구조

새로운 리스너를 추가해도
발행 코드는 수정할 필요가 없다.

이것이 이벤트 구조의 가장 큰 장점이다.


@TransactionalEventListener --- 트랜잭션 안전 처리

이벤트 처리에서 가장 많이 발생하는 실수는 이것이다.

트랜잭션 롤백 시에도 이벤트가 실행되는 문제

 

예:

  • 주문 저장 실패
  • 그런데 포인트는 적립됨
  • 데이터 불일치 발생

이를 방지하기 위해
트랜잭션 단계 기반 리스너를 사용한다.

AFTER_COMMIT 리스너

@Component
@RequiredArgsConstructor
public class PointRewardListener {

    private final PointService pointService;

    @TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT
    )
    public void handle(OrderCreatedEvent event) {
        pointService.reward(event.userId());
    }
}

트랜잭션 단계 옵션

Phase 설명

  • BEFORE_COMMIT 커밋 직전 실행
  • AFTER_COMMIT 커밋 성공 후 실행
  • AFTER_ROLLBACK 롤백 후 실행
  • AFTER_COMPLETION 커밋/롤백 무관

실무에서는 대부분 AFTER_COMMIT 사용이 안전하다.

트랜잭션 이벤트 흐름

동기 vs 비동기 이벤트

기본 이벤트 리스너는 동기 실행이다.

리스너가 오래 걸리면
발행자도 같이 느려진다.

비동기 처리 설정

@EnableAsync
@Configuration
public class AsyncConfig {
}
@Async
@EventListener
public void handle(OrderCreatedEvent event) {
    sendEmail(event);
}

비동기 처리 다이어그램

비동기 처리 시 주의

  • 예외 전파 안 됨
  • 재시도 전략 필요
  • 로깅 필수
  • 모니터링 필요

도메인 분리 전/후 구조 비교

직접 호출 구조

이벤트 기반 구조