EventListener 잘 쓰는 거 어떻게 하는 건데

Choi Wontak·2025년 5월 25일

아이쿠MSA

목록 보기
11/12
post-thumbnail

난이도 ⭐️⭐️⭐️
작성 날짜 2025.05.25

고민 내용

아이쿠 프로젝트에서는 관심사를 분리하여 결합도를 낮출 수 있는 이벤트 주도 아키텍처의 개발을 진행하고 있다.

스프링에서는 ApplicationEventPublisher를 통해 이를 실현할 수 있다.

private final ApplicationEventPublisher eventPublisher;

// 실제 사용 부분
eventPublisher.publishEvent(new MyEvent("EntityA Created"));

이런 방식으로 간편하게 이벤트 Pub이 가능하다.

하지만...
가장 어려움을 겪는 부분은 발행된 이벤트를 처리하는 EventListener 부분이다.
문제도 이 부분에서 발생했다.

이번 고민거리는 복잡하기 때문에 예제를 통해 단순화하여 글을 작성하고자 한다.

@Transactional
public void createEntityA() {
     EntityA a = new EntityA();
     a.setName("Test A");
     entityARepository.save(a);

     eventPublisher.publishEvent(new MyEvent("EntityA Created"));
}

이벤트를 발행하는 부분이다. EntityA를 저장하기 위해 트랜잭션을 갖고 있으며, 저장한 이후 이벤트를 발행한다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(MyEvent event) {
     myService.createEntityB();
}

이벤트 발생 시 이벤트를 처리하기 위한 리스너이다.
발행하는 파트의 트랜잭션이 종료되어 EntityA가 저장된 이후 실행될 로직이기 때문에 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)을 사용하였다.

@Transactional
public void createEntityB() {
     EntityB b = new EntityB();
     b.setDescription("Created in Event Listener");
     entityBRepository.save(b);
}

리스너가 실행하는 메서드이다. 이번에는 EntityB를 저장하기 위해 트랜잭션 어노테이션을 달아주었다.

@Test
void 엔티티A저장후_이벤트리스너를통해_엔티티B도저장되는지확인() throws Exception {
     // when
     myService.createEntityA();

     // then (AFTER_COMMIT은 커밋 후 비동기처럼 동작하기 때문에 약간 대기 필요)
     Thread.sleep(1000);  // 이벤트 리스너 동작 시간 확보

     List<EntityA> listA = entityARepository.findAll();
     List<EntityB> listB = entityBRepository.findAll();

     assertThat(listA).hasSize(1);
     assertThat(listB).hasSize(1);
}

우리가 기대하는 결과는 EntityA와 EntityB가 모두 저장되는 것이다.
과연 테스트는 통과할까?

테스트는 실패한다!
EntityA는 잘 저장되었으나 EntityB는 저장되지 않았다..!

🤔 EntityB는 왜 저장되지 않았을까?


찾아보기

AbstractPlatformTransactionManagerprocessCommit에서 그 이유를 찾을 수 있었다.

isNewTransaction이 true인 경우만 doCommit을 실행한다.

processCommit에 break point를 걸고 확인해보았다.

첫 번째 EntityA를 저장하는 부분
세션ID는 412903043, newTransaction은 true로 되어있어 doCommit이 실행된다.

두 번째 EntityB를 저장하는 부분
세션ID는 412903043, newTransaction은 false

문제는 이벤트로 분리되었다고 생각했던 두 메서드가 같은 세션에서 실행되고 있었다는 것이다.
세션ID를 공유하고 있다는 것은, 같은 쓰레드 로컬의 EntityManager를 사용한다는 것이며 트랜잭션이 전파되었다고 유추할 수 있다.

이때 같이 묶이는 트랜잭션은 논리 트랜잭션인데, 물리 트랜잭션은 이미 커밋되었기 때문에 이후의 커밋에 대해 실행되지 않는 것이다. (newTransaction == false)

해결 방법

그렇다면 어떻게 해야될까?

코드 레벨에서 볼 때, 단순히 newTransaction을 true로 만들어주면 커밋이 실행되니 이를 만들어줄 방법을 생각해보자.

1. Async 이용하기

아예 별개의 세션을 만들어서 새로운 트랜잭션을 사용하도록 해보자.

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent(MyEvent event) {
     System.out.println("AFTER_COMMIT 리스너 동작: " + event.getMessage());
     myService.createEntityB();  // 메서드 C 호출
}

EntityA를 저장하는 부분

EntityB를 저장하는 부분

SessionID도 다르고 newTransaction도 둘 다 true

테스트도 성공!

2. REQUIRES_NEW 이용하기

@Transactional 어노테이션의 옵션인 REQUIRES_NEW를 이용한다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createEntityB() {
     EntityB b = new EntityB();
     b.setDescription("Created in Event Listener");
     entityBRepository.save(b);
}

EntityA를 저장하는 부분

EntityB를 저장하는 부분

마찬가지로 SessionID도 다르고 newTransaction도 둘 다 true

트랜잭션이 시작될 때 Spring은 새로운 EntityManager를 생성해서 현재 쓰레드에 바인딩한다.
세션ID는 EntityManager에서 꺼내오기 때문에 세션 ID 값은 다르게 나온다.

테스트도 성공!

이 방식은 이전의 트랜잭션은 일시정지하고, REQUIRES_NEW 이후의 메서드를 위한 새로운 DB 커넥션을 풀에서 동시에 소비한다.
따라서 커넥션 풀이 고갈될 위험이 있다.
@Async를 사용하면 트랜잭션의 일시정지가 필요 없이 독립적으로 수행 가능하며, 반환을 대기할 수 있기 때문에 해당 문제에서 자유로울 수 있다.

3. 커밋 순서가 중요하지 않다면

3-1. 그냥 EventListener 사용하기

단순 EventListener는 관심사 분리라는 장점을 제외하면 함수 호출과 동일하다.
publishEvent를 한 현재 스레드에서 EventListener가 붙어있는 함수들을 순차적으로 호출하기 때문이다.

따라서 트랜잭션은 자연스럽게 묶이며, 모든 함수 호출이 끝난 경우 트랜잭션은 종료되며 데이터베이스에 커밋된다.

3-2. BEFORE_COMMIT 사용하기

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

AFTER_COMMIT이 트랜잭션 커밋 이후 EventListener 로직을 실행하는 것이었다면,
BEFORE_COMMIT은 말 그대로 커밋 직전에 실행하는 것이다.

트랜잭션은 아직 커밋된 적이 없기 때문에, 이후의 EventListener 로직에서 영속된 값도 커밋이 가능하다.

그럼 BEFORE_COMMIT과 EventListener의 차이는?

@EventListener: 트랜잭션과 무관, 이벤트 발행하면 즉시 실행
@TransactionalEventListener: 트랜잭션 경계에 따라 정해진 시점에 실행

예시는 publishEvent가 caller의 끝 부분에 나왔지만, 만약 publishEvent 뒤에도 코드가 있다고 가정해보자.

@EventListener라면, 뒤쪽의 코드를 block하여 Listener의 로직을 수행한 후 publishEvent 뒤의 코드를 실행한다.

@TransactionalEventListener의 BEFORE_COMMIT이라면, Caller 메서드의 끝에 도달하여 이제 커밋해볼까~ 하는 순간에 잠깐! 하고 Listener의 로직을 실행한다. (따라서 트랜잭션 있을 때만 사용 가능하다.)

트랜잭션 경계가 중요하다면 @TransactionalEventListener의 BEFORE_COMMIT를, Caller에 트랜잭션이 없거나 경계가 중요하지 않으면 @EventListener를 사용하면 될 것 같다.


결론

결론
Async, EventListener, TransactionalEventListener, Transactional 등등...
너무 많은 개념이 혼재해서 헷갈렸었는데 이번 기회에 정리하면서 머릿속이 좀 정돈된 것 같다.
이벤트를 이용해 개발하는 것이 장점이 크다고 생각하기 때문에 잘 써먹어봐야겠다!


참고한 포스팅

https://00h0.tistory.com/102

https://lenditkr.github.io/spring/transactional-event-listener/

https://hojun-dev.tistory.com/entry/JAVA-eventListener-transactionalEventListener-%EC%98%88%EC%99%B8-%EB%B0%8F-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C-%EC%B4%9D%EC%A0%95%EB%A6%AC#hELLO

profile
백엔드 주니어 주니어 개발자

0개의 댓글