[Spring Boot] ApplicationEventPublisher를 활용한 도메인 간 역할 분리
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);
}
비동기 처리 다이어그램

비동기 처리 시 주의
- 예외 전파 안 됨
- 재시도 전략 필요
- 로깅 필수
- 모니터링 필요
도메인 분리 전/후 구조 비교
직접 호출 구조

이벤트 기반 구조
