본문 바로가기

Spring

스프링 이벤트 사용 시 트랜잭션 분리를 위해 어떤 어노테이션을 사용해야 할까?

프로젝트에서 트랜잭션 분리의 필요성을 느낀 배경

기존 중고 거래에 '경매'를 도입한 '중고 경매 거래 플랫폼' 프로젝트를 진행하게 되었습니다. 판매자가 등록한 경매 상품이 낙찰되면 낙찰자와 판매자는 1:1 채팅을 주고받을 수 있게 되고, 채팅을 통해 거래를 진행하게 됩니다. 채팅을 전송할 때 알림이 전송되지 않아 불편함을 겪는 사용자가 발생했고, 알림 기능을 추가하면서 문제가 발생했습니다.

 

메시지 전송 기능에 알림 기능을 도입하면서 알림 전송 실패로 인해 메시지 전송 트랜잭션이 롤백 되는 문제였습니다. 비즈니스 중요도를 고려하면 사용자 편의를 위한 부가적인 알림 기능이 메시지 전송이라는 메인 기능에 영향을 미치는 것이 부자연스럽다고 판단해 트랜잭션 분리를 고려하게 되었습니다.

먼저 서비스 코드는 다음과 같습니다.

 

// Message Service
@Transactional
public void sendMessage(final MessageDto messsageDto)  {
   final Message message = messageDto.toEntity();
   messageRepository.save(message); // 전송한 메시지 저장

   try {
      final NotificationMessageDto dto = NotificationMessageDto.from(message);
      notificationService.send(dto); // 알림 전송
   } catch (final Exception ex) {
      log.error("exception type : {}, ", ex.getClass().getSimpleName(), ex);
   }
}

 

// Notification Service
@Transactional
public void send(final NotificationMessageDto notificationMessageDto) {
   // 기기 토큰 조회 로직 생략
   
   // FCM 알림 전송 API용 Message DTO
   final Message messageDto = Message.builder()
                                       .setToken(deviceToken.getDeviceToken())
                                       .putData("image", notificationMessageDto.image())
                                       .putData("body", notificationMessageDto.body())
                                       .build();
	                                               
   // 알림 전송 API 요청
   firebaseMessaging.send(messageDto);
}

 

문제가 되었던 부분은 NotificationService에서 알림 전송용 DTO인 Message를 생성할 때 알림에 포함될 이미지를 대입하는 .putData("image", notificationMessageDto.image())부분입니다. Message DTO의 모든 필드에는 @NonNull 어노테이션이 붙어있었고, 따라서 null을 포함하면 안 되는 DTO였습니다. 그런데 알림 전송 시 사용자 프로필 이미지가 등록되지 않은 경우 "image" 필드에 null을 전달하게 됩니다. 따라서 프로필이 없는 사용자가 메시지를 전송하게 될 경우 NullPointerException 예외가 발생하게 된 것입니다. 이에 따라 메시지 트랜잭션 롤백으로 이어지게 되었습니다.

 

그런데 의문이 생기는 점은 분명 메시지 서비스에서 알림 서비스 로직을 모두 try-catch로 묶어주었고, catch문에서는 모든 예외에 대한 핸들링이 가능하도록 Exception을 받고 있음에도 메시지 전송 트랜잭션이 롤백 되어 메시지 전송이 불가능한 이슈로 이어진다는 것이었습니다.

 

해당 문제가 발생한 이유는 메시지 전송 트랜잭션과 알림 전송 트랜잭션이 분리되지 않았기 때문입니다. NotificationService 의 send() 메서드는 @Transactional 어노테이션을 가지고 있고, 전파 옵션은 디폴트인 propagation = Propagation.REQUIRED로 지정되어 있습니다. 따라서 NotificationService 의 send() 메서드는 MessageService의 sendMessage() 트랜잭션에 참여하게 되고, 두 트랜잭션은 하나의 물리 트랜잭션으로 묶이게 됩니다. 이때 동일한 물리 트랜잭션에는 같은 롤백 규칙이 적용되는데, 트랜잭션 롤백 규칙 기본값은 외부 트랜잭션에 참여한 내부 트랜잭션에서 RuntimeException이 발생하는 경우 rollback-only 마킹을 하는 것입니다. 따라서 MessageService의 sendMessage() 트랜잭션에서 예외가 발생하지 않았지만, 해당 트랜잭션이 완료되는 시점에 rollback-only 마킹으로 인해 최종적으로 물리 트랜잭션이 롤백 되는 것입니다.

 

이 부분은 문제 상황의 배경을 설명하기 위한 개념이므로 자세한 내용은 우아한형제들의 기술 블로그를 확인해 보시면 좋을 것 같습니다.

 

결론은 다음과 같습니다.

  1. 메시지 전송이 알림 기능에 의한 영향을 받지 않도록 하기 위해 try-catch를 사용해 알림 기능에서 발생하는 예외의 영향을 분리하고자 시도
  2. 두 서비스의 트랜잭션이 하나의 물리 트랜잭션으로 묶이면서 RuntimeException 발생 시 물리 트랜잭션에 참여한 모든 논리 트랜잭션이 롤백 되는 문제 발생
  3. 두 서비스 메서드 트랜잭션 분리의 필요성을 느낌

이 글을 쓰게 된 목적

스프링 이벤트로 의존성을 분리해야겠다는 결정을 내린 순간에는 스프링 이벤트에 대한 지식이 전무한 상태였습니다. 그래서 이벤트 리스너, 이벤트 발행 메서드, 이벤트 DTO가 어느 패키지에 포함되어있어야 하는지 궁금했고, 다른 크루들이 사용한 이벤트 리스너를 참고하다 보니 모든 크루들의 이벤트 리스너 형태가 달랐습니다.

 

마찬가지로 트랜잭션을 분리하고 순서를 보장하기 위해 구글링 하는 과정에서도 각자의 비즈니스 로직이 다른 만큼 추천하는 이벤트 리스너 사용법이 모두 달랐고, 또 이벤트 리스너와 트랜잭션 분리를 위해 사용한 방법으로 인해 발생하는 문제점들도 서로 상이했습니다. 이벤트 리스너를 담당한 만큼 현재 우리 서비스의 비즈니스 로직에 적절한 어노테이션을 사용하고 있는 것인지 궁금했고, 이 궁금증을 해소하고자 여러 가지 실험을 해본 뒤 이 글을 작성하게 되었습니다.

트랜잭션 분리를 위한 시도

MessageService와 NotificationService 메서드의 트랜잭션을 분리하기 위해 고민하던 중 두 서비스는 비즈니스적으로 독립적인 서비스라고 판단했습니다. 앞서 언급한 것처럼 알림 서비스는 사용자 편의성을 위한 부가적인 기능이라는 점에서, 메시지를 저장하는 핵심 기능에 영향을 미치면 안 되기 때문입니다.

 

사용자의 입장에서 생각해보면 메시지를 정상적으로 전송 완료했는데, 전송한 메시지에 대한 알림이 전송 실패했다는 이유로 내가 전송한 메시지에까지 문제가 발생한다면 이상하다고 느끼게 되겠지요. 따라서 메시지 전송과 알림 전송은 트랜잭션뿐만 아니라 서비스도 분리되어야 한다고 판단해 트랜잭션을 분리하는 과정에서 스프링 이벤트를 사용해 두 서비스의 의존성을 분리해 주었습니다. 하지만 의존성을 분리해 주었다고 트랜잭션이 분리된 것은 아니기 때문에 트랜잭션을 분리하기 위한 방법도 고려해야 하므로 스프링 이벤트를 사용하면서 트랜잭션을 분리할 방법들을 고민하게 되었습니다.

@EventListener와 트랜잭션 전파 옵션 사용하기

@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(final NotificationEvent notificationEvent) {
   try {
      notificationService.sendNotification(notificationEvent);
   } catch (final Exception exception) {
      log.error("exception type : {}, ", ex.getClass().getSimpleName(), ex);
   }
}

 

처음에는 단순히 @EventListener를 사용해 이벤트를 처리하고, 트랜잭션 전파 옵션을 사용해 이벤트 발행 트랜잭션과 이벤트 수행 트랜잭션을 분리하는 방식을 고려했습니다. 트랜잭션 전파 옵션 중 Propagation.REQUIRES_NEW를 사용하면 트랜잭션을 물리적으로 분리하게 됩니다. 따라서 이벤트 리스너에서 호출하는 NotificationService는 MessageService의 트랜잭션과 다른 커넥션을 갖게 되어 물리적으로 분리되고, 따라서 NotificationService 트랜잭션의 커밋이나 롤백 여부와 관계없이 MessageService의 트랜잭션은 독립적으로 동작할 수 있게 됩니다.

 

(물리 트랜잭션과 논리 트랜잭션에 대한 이해가 필요하다면 링크를 참고해 주세요.)

참고로 물리적인 트랜잭션이 분리되었다고 하더라도 내부 트랜잭션에서 예외에 대한 핸들링이 되지 않을 경우 발생한 예외로 인해 외부 트랜잭션까지 롤백 될 수 있다는 점을 고려해 try-catch문을 이용한 예외 핸들링 작업은 필요합니다.

 

주의할 점

Propagation.REQUIRES_NEW를 사용하는 경우 데드락이 발생할 수 있다는 점을 주의해야 합니다. 앞서 언급한 것처럼 Propagation.REQUIRES_NEW를 사용하는 것은 새로운 커넥션을 한 개 더 할당받는 물리적인 트랜잭션의 분리입니다. 커넥션을 새롭게 할당받아 새로운 트랜잭션을 시작하고 트랜잭션이 종료될 때까지 기존 트랜잭션은 대기하게 됩니다. 즉, Propagation.REQUIRES_NEW를 사용하면 기존 트랜잭션이 종료되지 않은 상태에서 새로운 커넥션을 할당받기 때문에 메시지 전송이라는 하나의 요청에서 두 개의 커넥션을 점유하게 되는 것입니다. 이에 따라 데드락이 발생할 가능성이 생깁니다.

 

예를 들어 아래 그림과 같이 HikariCP의 max-pool-size가 2라고 가정해 봅시다.

이때 두 개의 스레드가 동시에 messageService.send()를 요청해 각 스레드 별로 한 개의 커넥션을 점유하게 된 경우, 모든 커넥션은 사용 중인 상태가 됩니다. 그리고 각 스레드는 notificationService.sendNotification()를 수행하기 위해 새로운 커넥션이 필요합니다.

 

그런데 더 이상 사용 가능한 커넥션이 없기 때문에 각 스레드는 커넥션이 반납될 때까지 대기하게 되고, 메시지 전송 요청이 트랜잭션을 종료하고 커넥션을 반납하기 위해서는 알림 전송 요청 트랜잭션이 완료되어야 합니다. 즉, 두 개의 스레드는 이미 커넥션을 하나씩 점유하고 있는 상황에서 다른 커넥션이 종료되기만을 기다리고 있는데, 두 스레드 모두 새로운 커넥션이 있어야만 커넥션을 반납할 수 있는 상황입니다.

 

이처럼 모든 스레드가 커넥션을 반납하기 위해 다른 커넥션이 필요한 상황에서, 어떤 스레드도 커넥션을 반납할 수 없는 상황이 '데드락'입니다.

@Async를 사용해 데드락 피하기

@Async
@EventListener
public void sendNotification(final NotificationEvent notificationEvent) {
	try {
		notificationService.sendNotification(notificationEvent);
	} catch (final Exception exception) {
		log.error("exception type : {}, ", ex.getClass().getSimpleName(), ex);
	}
}

 

첫 번째 방법으로는 새로운 트랜잭션과 기존 트랜잭션을 확실하게 분리해 줄 수 있었지만, 문제는 데드락이 발생할 수 있다는 점이었습니다. 어떻게 하면 데드락 문제를 피하면서 트랜잭션을 분리해 줄 수 있을까요?

 

@Async 어노테이션을 사용하는 것입니다. @Async를 사용한 메서드는 스레드가 분리되어 비동기적으로 작업을 수행하게 됩니다. 비동기적으로 작업을 수행한다는 것은 기존 트랜잭션인 메시지 전송 트랜잭션이 더 이상 알림 전송 트랜잭션이 완료될 때까지 기다리지 않고, 각 작업이 병렬적으로 수행된다는 의미입니다. 여기서 주목할 부분은 '기다리지 않는다는 것'입니다. 새로운 트랜잭션이 독립적인 스레드에서 수행되기 때문에 특정 스레드가 커넥션을 점유하면서 또 다른 커넥션을 기다리지 않게 되고, 따라서 데드락이 발생하지 않게 되는 것입니다. 이때 새로운 스레드를 생성하면 무조건 새로운 커넥션을 할당받기 때문에 자연스럽게 트랜잭션이 분리되어 트랜잭션 전파 속성을 사용한 트랜잭션 분리가 불필요하게 됩니다.

 

주의할 점

하지만 '비동기'로 처리된다는 것은 항상 순서에 주의해야 한다는 것을 의미합니다. 이벤트를 발행할 때 하나의 스레드를 생성해 병렬적으로 작업을 진행하기 때문에 동기적으로 수행되어야 하는 로직에는 적절하지 않습니다.

 

예를 들어 위 예제의 경우에는 MessageService에서 전송된 메시지를 DB에 저장한 이후 알림을 전송해야 합니다. 그런데 만약 전송한 메시지 저장과 메시지 알림 전송이 비동기로 수행된다면 메시지가 DB에 정상적으로 commit 되기 이전에 알림이 먼저 전송될 가능성이 있습니다. 이때 알림이 이미 사용자에게 전송된 이후 메시지 전송 트랜잭션에 문제가 발생해 트랜잭션이 rollback 된다면 이미 사용자는 메시지가 전송되었다는 알림을 받은 상황에서 DB에는 정작 메시지가 저장되지 않는 상황이 발생합니다. 따라서 사용자는 메시지 전송 알림을 확인한 뒤 메시지를 확인하려고 하는데, DB에 저장된 메시지가 없어 메시지를 정상적으로 조회할 수 없는 문제가 발생합니다.

따라서 비즈니스 로직에 맞춰 기능이 비동기로 수행될 수 있는 작업인지 판단해 적절하게 @Async를 사용해야 합니다.

 

(참고 : @Async를 사용할 때는 Application에 @EnableAsync 어노테이션을 추가해 비동기를 활성화해 주어야 합니다.)

@TransactionalEventListener 사용하기

@TransactionalEventListener
public void sendNotification(final NotificationEvent notificationEvent) {
	try {
		notificationService.sendNotification(notificationEvent);
	} catch (final Exception exception) {
		log.error("exception type : {}, ", ex.getClass().getSimpleName(), ex);
	}
}

 

이벤트 리스너 코드를 리뷰해 주던 팀원이 질문을 한 개 던져주었습니다.

해당 이벤트 리스너 메서드는 DB에 저장된 이후에 동작해야 할 것 같은데, 이에 대해서 고려했는지 궁금합니다!

트랜잭션 커밋 순서와 관련된 것인데요. 비즈니스 로직을 고려했을 때, 메시지 전송 트랜잭션이 정상적으로 commit된 이후에 메시지 알림을 전송하는 순서를 보장하고 싶습니다. 그런데 앞서 언급한 @EventListener와 @Async를 사용한 경우에는 순서를 보장할 수 없고, @EventListener와 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용했을 때는 데드락이 발생할 수 있다는 문제점이 있습니다.

 

메시지 전송 → 알림 전송의 순서를 보장하면서도 두 개의 트랜잭션을 안전하게 분리할 방법은 없을까요?

이때 사용할 수 있는 것이 @TransactionalEventListener입니다. @TransactionalEventListener는 이벤트 리스너의 실행 시점을 지정해 줄 수 있습니다. 기본값은 AFTER_COMMIT으로, 말 그대로 이벤트 리스너의 실행 시점이 기존 트랜잭션의 commit이 완료된 이후라는 의미입니다. 이렇게 된다면 기존 트랜잭션인 메시지 전송 트랜잭션이 DB에 commit 된 이후에 알림 전송을 수행하는 순서를 보장할 수 있습니다.

 

따라서 트랜잭션 commit이 완료된 상태이기 때문에 이후의 로직에서 예외가 발생하더라도 이미 commit된 트랜잭션에는 영향을 미치지 않습니다. 한 번 DB에 commit 되거나 rollback 된 트랜잭션의 상태는 더 이상 변경할 수 없기 때문입니다. 따라서 트랜잭션 전파 속성을 사용하지 않고도 기존 트랜잭션과 이벤트 리스너의 트랜잭션을 분리할 수 있게 됩니다.

 

주의할 점

현재 저의 코드에서는 @TransactionalEventListener만으로도 목표했던 트랜잭션 분리가 가능해집니다. 그러나 해당 어노테이션은 DB 쓰기 작업이 수행되는 로직에는 적절하지 않습니다.

 

먼저 간단한 테스트코드로 테스트해 본 결과를 확인해 보겠습니다. 아래 코드와 같이 @TransactionalEventListener 이벤트 리스너에 새로운 EventEntity를 DB에 저장하는 로직이 있습니다. 해당 알림 전송 기록은 정상적으로 저장되지 않는 것을 확인할 수 있습니다.

 

[이벤트 발행]

 

@Transactional
public void publishTransactionalEventListener() {
    eventPublisher.publishEvent(new MyTransactionalEventListenerEvent("my transactional event listener"));
}

 

[이벤트 리스너]

 

// 이벤트 리스너
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRED)
public void handleTransactionalEventListener(final MyTransactionalEventListenerEvent event) {
    final EventEntity eventEntity = new EventEntity(event.getValue()); // name 필드에 "publish transactional event listener" 저장
    eventListenerRepository.save(eventEntity); // DB 쓰기 작업
}

 

[이벤트]

 

// 이벤트
@Entity
public class EventEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    
    // 생성자, getter 생략
}

 

[테스트 코드]

 

// 이벤트 리스너에서 DB에 저장한 내역 확인
@Test
void transactional_event_listener의_트랜잭션_전파_속성이_REQUIRED인경우_DB쓰기_작업이_불가능하다() {
    myService.publishTransactionalEventListener();

    assertThat(repository.findByName("my transactional event listener"))
            .isNotNull();
}

 

아래와 같은 예외가 발생하며 테스트가 실패하는 것을 확인할 수 있습니다.

 

Expecting actual not to be null
java.lang.AssertionError: 
Expecting actual not to be null
	at transaction.stage3.MyServiceTest.transactional_event_listener의_트랜잭션_전파_속성이_REQUIRED인경우_DB쓰기_작업이_불가능하다(MyServiceTest.java:40)

... 생략

@TransactionalEventListener에서만 DB 쓰기 작업이 안 되는 이유

@TransactionalEventListener의 실행 과정을 디버깅하며 학습한 내용을 바탕으로 작성한 내용입니다.

@TransactionalEventListener는 이벤트 리스너를 위한 어노테이션이지만, 저는 트랜잭션 분리에 관심이 있었기 때문에 왜 AFTER_COMMIT 속성을 가질 때는 REQUIRED 전파 속성을 갖는 트랜잭션의 커밋이 불가능한지가 궁금해졌습니다.

 

결론부터 이야기하자면, AFTER_COMMIT의 실행 시점이 물리 트랜잭션을 commit하고, connection을 unbind하는 시점 사이에 있기 때문입니다. 물리 트랜잭션의 커밋 로직은 다음과 같습니다.

  1. 물리 트랜잭션인 경우 DB에 커밋
  2. AFTER_COMMIT에 해당하는 작업 수행 (이벤트 리스너 호출)
  3. connection 반납

1번을 보면 ‘물리 트랜잭션인 경우’에만 DB에 커밋한다고 되어있습니다.

 

기존 트랜잭션이 존재하는 경우 기존 트랜잭션에 참여하는 REQUIRED 전파 속성의 트랜잭션은 실제로 DB에 커밋하는 코드를 호출할 수 없습니다. 실제로 DB에 커밋하는 코드를 호출하는 조건은 isNewTransaction()이 true인 경우만 해당하는데, isNewTransaction은 새로운 connection을 가져와 물리 트랜잭션을 생성할 때 true로 변경되기 때문입니다. 즉, 기존 트랜잭션에 참여하는 논리 트랜잭션의 경우에는 commit()을 호출하더라도 커밋하는 시점에 실제로 DB에 커밋하는 코드를 호출할 수 없게 되는 것입니다.

 

정리하자면 트랜잭션 커밋이 발생할 때 실제 DB에 커밋하는 메서드를 호출하기 위해서는 논리 트랜잭션이 아닌 새로운 물리 트랜잭션을 생성하거나, 아니면 물리 트랜잭션이 커밋되기 전에 커밋하고자 하는 부분을 모두 영속성 컨텍스트에 업데이트해 물리 트랜잭션이 커밋되는 시점에 함께 DB에 커밋될 수 있도록 해야 합니다.

 

그런데 예제의 @TransactionalEventListener는 REQUIRED 전파 속성을 갖는 논리 트랜잭션이기에 DB에 커밋하는 코드를 호출할 수 없습니다. 따라서 변경 내역을 DB에 반영하기 위해서는 물리 트랜잭션이 커밋되기 전 영속성 컨텍스트에 값을 모두 변경해두고, 물리 트랜잭션이 DB에 커밋될 때 함께 커밋될 수 있도록 하는 방법밖에 없습니다. 하지만 @TransactionalEventListener의 기본 속성은 AFTER_COMMIT이기 때문에 물리 트랜잭션이 실행되고 난 이후에 동작하게 되어 이벤트 리스너 수행이 완료된 시점에서 실행 가능한 코드는 3번 connection 반납뿐입니다.

 

이러한 이유로 인해 REQUIRED 전파 속성을 갖는 @TransactionalEventListener 메서드는 DB에 쓰기 작업이 불가능해지는 결론으로 이어지게 된 것입니다.

커밋이 수행되는 processCommit() 메서드로 실행 과정 확인하기

조금 더 깊게 이해하기 위해 스프링 트랜잭션 커밋을 할 때의 동작 과정을 살펴보면 다음과 같습니다.

processCommit()을 호출하고 있고, processCommit()의 실행 순서를 간단히 살펴보면 다음과 같습니다.


AbstractPlatformTransactionManager의 processCommit 전체 코드 참고

더보기
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
    try {
        boolean beforeCompletionInvoked = false;

        try {
            boolean unexpectedRollback = false;
            this.prepareForCommit(status);
            this.triggerBeforeCommit(status);
            this.triggerBeforeCompletion(status);
            beforeCompletionInvoked = true;
            if (status.hasSavepoint()) {
                if (status.isDebug()) {
                    this.logger.debug("Releasing transaction savepoint");
                }

                unexpectedRollback = status.isGlobalRollbackOnly();
                status.releaseHeldSavepoint();
            } else if (status.isNewTransaction()) {
                if (status.isDebug()) {
                    this.logger.debug("Initiating transaction commit");
                }

                unexpectedRollback = status.isGlobalRollbackOnly();
                this.doCommit(status);
            } else if (this.isFailEarlyOnGlobalRollbackOnly()) {
                unexpectedRollback = status.isGlobalRollbackOnly();
            }

            if (unexpectedRollback) {
                throw new UnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only");
            }
        } catch (UnexpectedRollbackException var17) {
            this.triggerAfterCompletion(status, 1);
            throw var17;
        } catch (TransactionException var18) {
            if (this.isRollbackOnCommitFailure()) {
                this.doRollbackOnCommitException(status, var18);
            } else {
                this.triggerAfterCompletion(status, 2);
            }

            throw var18;
        } catch (Error | RuntimeException var19) {
            if (!beforeCompletionInvoked) {
                this.triggerBeforeCompletion(status);
            }

            this.doRollbackOnCommitException(status, var19);
            throw var19;
        }

        try {
            this.triggerAfterCommit(status);
        } finally {
            this.triggerAfterCompletion(status, 0);
        }
    } finally {
        this.cleanupAfterCompletion(status);
    }

}

 

위 코드는 아래와 같은 흐름으로 트랜잭션 커밋이 진행됩니다.

 

1. 먼저 첫 번째 try-catch문에서는 커밋을 위한 일련의 과정이 수행됩니다. prepareForCommit(), triggerBeforeCommit(), triggerBeforeCompletion() 등 커밋을 수행하기 이전 선행되어야 하는 작업을 수행합니다.

 

 

2. 그리고 두 번째 분기문인 else if (status.isNewTransaction())를 확인해 보면, global rollback 여부를 확인하고, 비로소 this.doCommit()을 호출하게 됩니다. 이때 물리 트랜잭션에 해당하면 isNewTransaction()는 true가, 논리 트랜잭션에 해당하면 false가 반환됩니다. (해당 내용이 잘 이해가 가지 않는다면 레오의 글을 참고하시면 좋을 것 같습니다.)

 

코드 참고


3. doCommit()은 AbstractPlatformTransactionManager를 상속받은 클래스에서 구현해야 하며, 따라서 AbstractPlatformTransactionManager를 상속받은 JpaTransactionManager의 doCommit()을 실행해 실제로 DB에 커밋하게 됩니다.

 

코드 참고

 

4. 마지막으로 실행한 트랜잭션의 unexpectedRollback을 확인해 롤백이 발생하지 않는다면 다음 try-catch문으로 넘어가게 됩니다.


5. 다음 try-catch문은 드디어 기다리던 AFTER_COMMIT가 실제로 실행되는 곳입니다. 메서드명만 봐도 직관적으로 알 수 있듯이 triggerAfterCommit()이 호출됩니다. 해당 메서드를 확인해 보면 다음과 같습니다.

 

코드 참고

 

// AbstractPlatformTransactionManager.class
private void triggerAfterCommit(DefaultTransactionStatus status) {
    if (status.isNewSynchronization()) {
        TransactionSynchronizationUtils.triggerAfterCommit();
    }
}

 

TransactionSynchronizationUtils의 triggerAfterCommit() 메서드를 호출하고 있습니다.
TransactionSynchronizationUtils.triggerAfterCommit()을 확인해 보면 다음과 같은 작업을 하고 있습니다.

 

// TransactionSynchronizationUtils.class
public static void triggerAfterCommit() {
    invokeAfterCommit(TransactionSynchronizationManager.getSynchronizations());
}

public static void invokeAfterCommit(@Nullable List<TransactionSynchronization> synchronizations) {
    if (synchronizations != null) {
        Iterator var1 = synchronizations.iterator();

        while(var1.hasNext()) {
            TransactionSynchronization synchronization = (TransactionSynchronization)var1.next();
            synchronization.afterCommit();
        }
    }

}

 

이벤트를 발행 메서드에서 publishEvent()를 호출했을 때 이 TransactionSynchronizationManager에 발행한 이벤트에 대한 정보가 저장됩니다. 그리고 실제로 실행되지는 않다가 커밋이 완료되고 afterCommit()이 호출되었을 때TransactionSynchronizationManager에서 event가 저장된 synchronization을 가져옵니다. 그리고 synchronization.afterCommit();을 통해 커밋 이후 실행될 작업을 수행하게 됩니다.

 

그런데 디버깅을 해보면 실제로 TransactionalEventListener에 해당하는 synchronization의 구현체는 TransactionalApplicationListenerSynchronization이고, 해당 클래스의 afterCommit() 메서드는 오버라이딩 되어있지 않습니다. 대신 afterCompletion()을 오버라이딩한 메서드에서 afterCommit 속성을 갖는 경우 메서드를 실행시키는 코드가 있네요!

 

public void afterCompletion(int status) {
    TransactionPhase phase = this.listener.getTransactionPhase();
    if (phase == TransactionPhase.AFTER_COMMIT && status == 0) { // TransactionalEventListener의 기본 phase는 AFTER_COMMIT, status == 0 : 트랜잭션이 커밋된 상태
        this.processEventWithCallbacks();
    } else if (phase == TransactionPhase.AFTER_ROLLBACK && status == 1) {
        this.processEventWithCallbacks();
    } else if (phase == TransactionPhase.AFTER_COMPLETION) {
        this.processEventWithCallbacks();
    }
}

 

 

아무튼 결론은 AFTER_COMMIT에 해당하는 메서드를 실행하는 곳은 finally 블록의 this.triggerAfterCompletion(status, 0); 내부에서 실행되는 것이라는 것을 눈으로 확인하게 되었습니다!

 

6. 마지막으로 가장 바깥쪽에 해당하는 try-catch-finally의 finally 블럭 내부를 보면 cleanupAfterCompletion()라는 작업을 수행하네요! 해당 코드를 들여다보면 현재 트랜잭션이 물리 트랜잭션인지 확인하는 분기문이 존재하고, 물리 트랜잭션이라면 doCleanupAfterCompletion()을 호출합니다. 드디어 가지고 있던 resource를 unbind 하는 곳까지 왔습니다. 해당 코드는 JpaTransactionManager의 메서드에서 확인하실 수 있습니다.

 

코드 참고

 

코드에서 본 것처럼 AFTER_COMMIT이자 REQUIRED 속성을 갖는 이벤트 리스너는 물리 트랜잭션이 커밋된 이후에 실행되고, 해당 메서드가 종료된 이후에는 더 이상 DB에 커밋할 수 있는 작업이 남아있지 않고 connection을 반납하는 코드만 남아있는 것을 확인할 수 있었습니다.

@TransactionalEventListener 사용 시 DB 쓰기 작업이 가능한 경우


@TransactionalEventListener를 사용한다고 해도 DB 쓰기 작업이 불가능한 것은 이벤트 리스너가 기존 트랜잭션에 참여하는 논리 트랜잭션인 경우입니다. 즉, 이벤트 리스너에서 새로운 물리 트랜잭션을 열어주는 경우에는 얼마든지 DB에 커밋이 가능해진다는 것을 의미합니다.

방법도 간단합니다. 앞서 언급한 것처럼 Propagation.REQUIRES_NEW를 통해 새로운 물리 트랜잭션을 열어주거나, @Async를 사용해 스레드를 분리하여 트랜잭션을 분리해 주면 됩니다.

하지만 이전의 방식과 약간의 차이점은 존재합니다.


@TransactionalEventListener + REQUIRES_NEW 전파 속성

AFTER_COMMIT의 실행 시점은 기존 트랜잭션이 커밋된 이후, 그리고 connection을 반납하기 전입니다. 즉, 이벤트 리스너를 실행하는 시점에는 아직 connection이 반납되지 않은 상태입니다. 따라서 Propagation.REQUIRES_NEW를 통해 새로운 connection을 할당받게 되는 경우 기존의 방식과 같이 데드락이 발생할 위험이 있습니다. 이때 @Async가 해결 방법이 됩니다.


@TransactionalEventListener + @Async

@Async를 사용하는 것은 비동기로 실행된다는 것을 의미하기 때문에 순서에 주의해야 한다고 언급했습니다. 그런데 @TransactionalEventListener의 기본 속성은 AFTER_COMMIT입니다. 즉, 기존 트랜잭션 커밋이 완료된 시점에 실행되는 메서드입니다. 따라서 @Async를 통해 스레드가 분리되는 시점 또한 기존 트랜잭션 커밋이 완료된 이후가 됩니다. 따라서 원하던 대로 기존 트랜잭션이 커밋된 이후 수행되는 순서를 유지할 수 있으면서도 안전하게 트랜잭션을 분리할 수 있는 것입니다.

결론: 그래서 어떤 이벤트 리스너를 사용하면 되나요?

언제나 그렇듯 비즈니스 규칙에 적절한 이벤트 리스너와 트랜잭션 전파 속성을 사용하면 됩니다. 그리고 아래는 제가 학습한 내용을 바탕으로 정한 이벤트 리스너와 트랜잭션 분리를 위한 어노테이션 사용 기준입니다.

 

  1. 이벤트 발행 메서드와 이벤트 리스너가 하나의 트랜잭션으로 묶여야 하는 경우
    @EventListener + @Transactional(propagation = Propagation.REQUIRED) 사용

  2. 이벤트 발행 메서드와 이벤트 리스너의 실행 순서가 보장되지 않아도 되고, 트랜잭션 분리만 필요한 경우
    @EventListener + @Async 사용

  3. 이벤트 발행 메서드 → 이벤트 리스너의 순서가 보장되어야 하는 경우 또는 이벤트 발행 메서드와 이벤트 리스너의 트랜잭션을 분리해야 하는데, 이벤트 리스너에서 DB 쓰기 작업이 불필요한 경우
    @TransactionalEventListener 사용

  4. 이벤트 발행 메서드 → 이벤트 리스너의 순서가 보장되어야 하고, 이벤트 리스너에서 DB 쓰기 작업이 필요한 경우@TransactionalEventListener + @Async
    또는 @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

마무리

단순히 트랜잭션을 분리하고 의존성을 분리하려는 목적으로 @TransactionalEventListener를 사용했고, 트랜잭션 순서를 보장하기 위한 방법을 찾아보는 과정에서 해당 어노테이션이 붙은 메서드의 동작 원리를 학습하게 되었습니다. 그런데 결과적으로 스프링 트랜잭션의 동작 원리를 이해하지 못해 @TransactionalEventListener의 동작 원리를 이해하는 데 어려움이 있었던 것을 보면 스프링의 트랜잭션이 스프링에서 얼마나 중요한 개념인지 체감하게 되었던 것 같습니다.

스프링이 굉장히 방대한 기능을 가진 만큼 모든 스프링의 모든 기능을 이해하거나 미리 숙지하고 사용하기는 어려울 것입니다. 하지만 지금처럼 해결해야 하는 문제를 마주했을 때 최소한 그 사용하는 기능은 제대로 파악하고자 하는 의지와 호기심을 가지고 하나씩 공부해나가다 보면 언젠가 스프링과 조금 더 친해질 수 있을 것 같네요!