TL;DR
- ApplicationEvent는 이벤트 발행 시 타입에 맞는 리스너를 자동으로 실행하여, 트랜잭션 시점에 맞춘 후속 처리와 메인 로직을 분리한다.
들어가며
서비스 간 강함 결합을 끊기 위해 이벤트 방식을 사용하곤 한다.
기능을 구현하면서 리스너는 이벤트가 발행됐다는 사실을 어떻게 아는 걸까? 라는 궁금증이 생겼다.
'좋아요' 기능을 통해 이벤트 발행과 수신 사이에 내부 동작이 어떻게 되는지 공부해봤고, 이 내용을 공유하고자 한다.
좋아요 기능 설명
사용자가 상품 좋아요를 누르면
사용자와 상품 간의 좋아요 관계를 저장할 뿐만 아니라 상품의 좋아요 수를 증가시켜야 한다.
즉, 상품 좋아요 등록 후 후속 처리로 상품 좋아요 수 집계 처리를 해줘야 한다.
이런 후속 작업을 메인 로직에 포함시키면 코드가 복잡해지고, 책임이 섞이게 된다.
Spring의 Application Event를 사용해서 이런 문제를 해결할 수 있었다.
좋아요 등록 로직과 후속 처리를 분리하여, 메인 로직은 간결하게 유지하고 후속 작업은 이벤트 리스너에서 처리하였다.
@Transactional
public LikeInfo recordLike(LikeCommand.LikeProductCommand command) {
User user = userService.findUserByLoginId(command.loginId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
boolean wasCreated = likeService.recordLikeIfAbsent(user.getId(), command.productId());
if (wasCreated) {
eventPublisher.publishEvent(LikeEvent.LikeRecorded.from(command.productId()));
eventPublisher.publishEvent(UserBehaviorEvent.LikeRecorded.from(user.getId(), command.productId()));
}
Product product = productService.findProductById(command.productId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
return LikeInfo.from(
product.getId(),
product.getLikeCount().getCount()
);
}
그리고 별도의 리스너에서 후속 작업을 처리한다.
@Slf4j
@RequiredArgsConstructor
@Component
public class LikeCountEventListener {
private final ProductService productService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LikeEvent.LikeRecorded event) {
log.info("LikeRecorded 이벤트 수신 - 좋아요 수 증가: productId={}", event.productId());
productService.increaseLikeCount(event.productId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LikeEvent.LikeCancelled event) {
log.info("LikeCancelled 이벤트 수신 - 좋아요 수 감소: productId={}", event.productId());
productService.decreaseLikeCount(event.productId());
}
}
그런데 여기서 궁금한 점이 생겼다.
LikeFacade에서 이벤트를 발행했을 때, LikeCountEventListener는 어떻게 그 이벤트를 알 수 있었을까?
이벤트를 발행하는 코드와 리스너 코드는 서로 다른 클래스에 있고, 명시적으로 연결되어 있지 않다. 그런데도 리스너가 정확히 해당 이벤트를 받아서 처리할 수 있는 이유가 궁금했다.
이벤트 발행 : publishEvent() 호출
먼저 이벤트 발행이 어떻게 이루어지는지 살펴보았다.
private final ApplicationEventPublisher eventPublisher;
eventPublisher.publishEvent(LikeEvent.LikeRecorded.from(command.productId()));
AppicationEventPublisher는 Spring 프레임워크에서 제공하는 인터페이스이다. 이 인터페이스의 실제 구현체는 ApplicationContext이다.
Spring Boot 애플리케이션이 시작되면 ApplicationContext가 생성되고, 이 컨텍스트는 모든 빈을 관리한다.
LikeFacade가 ApplicationEventPublisher를 주입받을 때, 실제로는 ApplicationContext가 주입되는 것이다.
publishEvent() 메서드를 호출하면
- 이벤트 객체가 ApplicationContext로 전달된다.
- ApplciationContext는 내부적으로 이벤트를 관리하는 컴포넌트에 이벤트를 전달한다.
- 이벤트를 처리할 수 있는 리스너들을 찾아서 이벤트를 전달한다.
그렇다면 ApplicationContext는 어떻게 어떤 리스너가 어떤 이벤트를 처리할 수 있는지 알고 있을까
리스너 등록
Spring Boot 애플리케이션이 시작되면 다음과 같은 과정이 일어난다.
1. 빈 스캔 및 등록
- @Component, @Service, @Repository 등의 어노테이션이 붙은 클래스들을 스캔한다.
- 각 클래스를 빈을 등록
- 빈의 메서드들을 분석
2. 리스너 메서드 발견
LikeCountEventListener 클래스를 스캔할 때
@Component
public class LikeCountEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LikeEvent.LikeRecorded event) {
// ...
}
}
Spring은 다음과 같이 동작한다.
- @Component 어노테이션 발견 -> 빈으로 등록
- @TransactionalEventListener 어노테이션 발견 -> 이 메서드가 이벤트 리스너임을 인식함
- 메서드의 파라미터 타입 확인 -> LikeEvent.LikeRecorded
- 이 빈은 LikeEvent.LikeRecorded 타입의 이벤트를 처리할 수 있다는 정보를 내부 리스너 레지스트리에 등록
이 과정이 모든 리스너에 대해 반복된다. 결과적으로 Spring은 아래와 같은 매핑 정보를 가지게 된다.
LikeEvent.LikeRecorded → [LikeCountEventListener.handle(), UserBehaviorEventListener.handle(), ...]
LikeEvent.LikeCancelled → [LikeCountEventListener.handle(), ...]
UserBehaviorEvent.LikeRecorded → [UserBehaviorEventListener.handle(), ...]
3. 타입 기반 매칭
핵심은 타입 기반 매칭이다.
Spring은 메서드의 파라미터 타입을 보고 어떤 이벤트를 처리할 수 있는지 판단한다.
- handle(LikeEvent.LikeRecorded event) -> LikeEvent.LikeRecorded 타입의 이벤트 처리 가능
- handle(LikeEvent.LikeCancelled event) -> LikeEvent.LikeCancelled 타입의 이벤트 처리 가능
이렇게 타입 기반으로 매칭하기 때문에, 발행하는 쪽과 리스너 쪽이 명시적으로 연결되어 있지 않아도 동작할 수 있다.
이벤트 전달 과정
publishEvent()가 호출되었을 때 실제로 어떤 일이 일어날까
1. 이벤트 발행
eventPublisher.publishEvent(LikeEvent.LikeRecorded.from(command.productId()));
이 코드가 실행되면
- ApplicationContext의 publishEvent() 메서드가 호출된다.
- 그리고 전달된 이벤트 객체의 타입을 확인한다 (LikeEvent.LikeRecorded)
2. 리스너 검색 및 호출
- 내부 리스너 레지스트리에서 LikeEvent.LikeRecorded 타입을 처리할 수 있는 리스너들을 검색한다.
- 등록된 리스너 목록을 가져온다.
- 각 리스너의 메서드를 순차적으로 호출한다.
- 리스너 메서드가 실행되면서 후속 작업이 처리된다.
3. 실행 흐름
[LikeFacade.recordLike()]
↓
[eventPublisher.publishEvent(LikeEvent.LikeRecorded)]
↓
[ApplicationContext 내부]
↓
[리스너 레지스트리에서 LikeEvent.LikeRecorded 타입 처리 가능한 리스너 검색]
↓
[LikeCountEventListener.handle(LikeEvent.LikeRecorded) 호출]
↓
[UserBehaviorEventListener.handle(LikeEvent.LikeRecorded) 호출]
↓
[후속 작업 완료]
이 과정은 동기적으로 실행된다. 즉, 모든 리스너가 처리될 때까지 publishEvent() 호출이 완료되지 않는다.
@TransactionalEventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LikeEvent.LikeRecorded event) {
productService.increaseLikeCount(event.productId());
}
@TransactionalEventListener는 @EventListener와 달리 트랜잭션과 연동된다.
그리고 TransactionPhase에는
트랜잭션 커밋 전에 실행되는 BEFORE_COMMIT,
트랜잭션 커밋 후에 실행되는 AFTER_COMMIT 등이 있다.
@TransactionalEventListener는 내부적으로 다음과 같이 동작한다.
- 이벤트가 발행되면 리스너를 실행하지 전에 현재 활성화된 트랜잭션이 있는지 확인함
- 트랜잭션이 있다면, 지정된 TransactionPhase에 맞는 시점까지 이벤트 처리를 지연시킴
- AFTER_COMMIT인 경우, 트랜잭션이 커밋되면 리스너를 실행한다.