‘페스타고’에서 모바일 티켓의 상태를 실시간으로 변화시켜 주기 위해 FCM을 연동했다.
스태프 폰에서 티켓을 스캔 완료한 후, 학생 폰에 FCM을 전송하면 화면에 표시되는 QR코드를 비활성화처리한다.
이때, FCM 보내는 과정을 Spring Event를 활용해 처리했는데, 코드 리뷰를 진행하며 내가 @TransactionalEventListener와 @EventListener의 차이를 정확히 모른다는 사실을 깨달았다.
이번 기회에 Spring Event의 개념을 정리하고 넘어가 보고자 한다.
이벤트(Event)란?
이벤트는 특정 트리거나 조건이 충족되었을 때 시작되는 동작 혹은 사건이다.
이벤트 발행은 이벤트를 발생시키는 과정으로, 발행자(publisher)가 이벤트를 발행하면, 이를 구독하고 있던 구독자(listener)가 이를 감지하고 특정 동작을 실행한다.
예를 들면, 도서관에서 책을 반납을 한 사용자에게 책 반납 알림 메일을 전송한다고 가정하자.
책 반납 API에서 책 반납 처리를 하며, “책 반납 완료” 이벤트를 발행하면 구독자는 이벤트 발행을 감지하고 메일을 전송한다.
결합도 감소
이벤트의 가장 큰 특징은 “결합도를 낮춘다”는 것이다.
발행자와 구독자는 서로의 존재를 알 필요가 없으며, 발행자는 단순히 이벤트를 발행하고, 구독자는 그 이벤트를 구독할 뿐이다.
이는 시스템 내 모듈 간의 느슨한 결합(loose coupling)을 지원한다.
비동기성
이벤트는 비동기적으로 작동할 수 있어, 여러 작업을 동시에 처리하고 병렬로 실행할 수 있다. 이것은 응답 속도를 높이는 데 도움이 된다.
분산 시스템 활용
또한, 발행자와 구독자가 서로 다른 서버에 있어도 이벤트를 통해 통신할 수 있으므로 분산 시스템에서도 유용하게 활용된다.
Spring Event의 구성요소
이벤트 (Event)
이벤트는 객체로 정의된다. “책 반납”, “티켓 검사”, “사용자 로그인” 등의 이벤트를 객체로 표현할 수 있다.
이벤트 객체는 POJO로 정의할 수도 있고, ApplicationEvent 클래스를 상속해 정의할 수도 있다.
(스프링 4.2 이전에는 반드시 ApplicationEvent 클래스를 상속해 이벤트 객체를 정의해야 했다.)
public record BookReturnedEvent(
String bookTitle,
String userEmail) {
}
이벤트 발행자(Event Publisher)
발행을 위해 ApplicationContextPublisher를 주입받고, publishEvent 메서드를 호출해 이벤트를 발행한다.
@Service
@RequiredArgsConstructor
public class BookReturnService {
private final ApplicationEventPublisher eventPublisher
public void returnBook(String bookTitle, String userEmail) {
// 책 반납 로직 수행
// 책 반납 이벤트 발행
BookReturnedEvent event = new BookReturnedEvent(bookTitle, userEmail);
eventPublisher.publishEvent(event);
}
}
이벤트 리스너(Event Listener)
ApplicationListener 인터페이스를 구현하거나 @EventListener 어노테이션을 사용해 이벤트를 처리하는 메서드를 정의한다.
이 메서드는 이벤트를 매개변수로 받아 처리하며, ApplicationContext가 해당 이벤트를 발행하면 해당 메서드가 실행된다.
(스프링 4.2 이전까지는 반드시 ApplicationListener 인터페이스를 구현하는 방식을 사용해야 했으나, @EventListener의 등장으로 더욱 간편하게 이벤트를 처리할 수 있게 되었다.)
@Component
@RequiredArgsConstructor
public class EmailNotificationListener {
private final EmailClient emailClient;
@EventListener
public void handleBookReturnedEvent(BookReturnedEvent event) {
String bookTitle = event.bookTitle();
String userEmail = event.userEmail();
// 이메일 전송
emailClient.sendEmailNotification(userEmail, bookTitle);
}
}
Spring Event 이점 느껴보기
위의 도서관 예제에서 책을 반납했을 때 푸시 알림을 전송하는 요구사항도 새롭게 추가되었다고 가정하자.
만약, 이벤트를 활용하지 않았다면 아래와 같이 BookReturnService에 PushClient에 대한 의존성이 추가된다.
@Service
@RequiredArgsConstructor
public class BookReturnService {
private final EmailClient emailClient;
private final PushClient pushClient;
public void returnBook(String bookTitle, String userEmail) {
// 책 반납 로직 수행
emailClient.sendEmailNotification(bookTitle, userEmail);
pushClient.sendPushNotification(bookTitle, fcmToken);
}
}
하지만, 이벤트를 활용하면 BookReturnedEvent를 구독하는 리스너만 추가될 뿐, BookReturnService 코드에 새로운 종속성이 추가되거나 복잡성이 증가하지 않는다.
@Component
@RequiredArgsConstructor
public class PushNotificationListener {
private final PushClient pushClient;
@EventListener
public void handleBookReturnedEvent(BookReturnedEvent event) {
String title = event.bookTitle();
String fcmToken = event.fcmToken();
// 푸시 알림 전송
pushClient.sendPushNotification(fcmToken, bookTitle);
}
}
Spring Event의 특징
Publisher와 Listner 은 1:N 관계
위의 예제에서도 알아봤듯, 하나의 이벤트에 대해 다수의 이벤트 리스너가 존재할 수 있다.
이벤트가 발행되면, 해당 이벤트를 구독하고 있는 모든 이벤트 리스너가 이벤트를 수신한다.
동기 방식으로 동작
Spring Event는 기본적으로 동기(Sync) 방식으로 동작한다.
이벤트 발행자는 이벤트를 발행하고, 모든 이벤트 리스너가 이벤트 처리를 완료할 때까지 대기한다.
이러한 방식에서 이벤트 발행자와 이벤트 리스너의 동작이 하나의 트랜잭션 내에서 동기적으로 이루어진다. 즉, 이벤트 처리 중에 예외가 발생하면 해당 트랜잭션이 롤백된다.
스프링 5.0 이상부터는 @EventListner 어노테이션과 함께 @Async 어노테이션을 붙임으로써 비동기식으로 처리할 수 있다.
이렇게 하면 이벤트 처리가 별도의 스레드에서 비동기적으로 처리되며, 이벤트 처리 중 예외가 발생해도 발행자의 트랜잭션 롤백이 이루어지지 않는다.
@Async 어노테이션에 대한 자세한 정보는 아래 포스팅을 참고하자.
@TransactionalEventListener
@TransactionalEventListener는 트랜잭션 범위 내에서 발생한 이벤트를 처리하는 어노테이션이다.
TransactionPhase에 맞는 처리 시점에 이벤트가 처리된다.
@TransactionalEventListener에 대한 자세한 설명은 아래를 참고하자.
@Async와 @TransactionalEventListener
@EventListener
이벤트가 발행되는 즉시 해당 이벤트를 동기로 처리한다.
발행자는 리스너가 해당 이벤트 처리를 마칠 때까지 대기한 후 다음 동작을 수행한다.
발행자와 리스너는 동일한 트랜잭션 범위를 가진다.
따라서 리스너에서 이벤트를 처리하는 중 예외가 발생하면, 발행자의 기존 작업 내역도 함께 롤백된다.
또한, 리스너에서 예외가 발생하면 발행자는 더 이상 남은 작업을 수행하지 않고 예외가 발생하며 끝난다.
@Async @EventListener
이벤트가 발행되는 즉시 해당 이벤트를 비동기로 처리한다.
별도의 스레드에서 처리되기 때문에, 리스너는 발행자와 별도의 트랜잭션 범위를 가진다.
@TransactionalEventListener
TransactionalListener의 TransactionPhase는 AFTER_COMMIT이 default이다.
즉, 발행자의 트랜잭션이 커밋된 후 이벤트를 동기로 처리한다. (AFTER_COMMIT 에서는 발행자의 트랜잭션이 롤백되면 해당 이벤트를 처리하지 않는다.)
TransactionPhase에 따른 처리 시점은 아래와 같다.
- BEFORE_COMMIT: 발행자의 트랜잭션 커밋 직전에 처리한다.
- AFTER_COMMIT: 발행자의 트랜잭션 커밋 직후 처리한다.
- AFTER_ROLLBACK: 발행자의 트랜잭션 롤백 직후 처리한다.
- AFTER_COMPLETION: 발행자의 트랜잭션 커밋/롤백 직후 처리한다.
@Async @TransactionalEventListener
TransactionPhase에 맞는 처리 시점에 해당 이벤트를 비동기로 처리한다.
별도의 스레드에서 처리되기 때문에, 리스너는 발행자와 별도의 트랜잭션 범위를 가진다.
@TransactionalEventListener와 트랜잭션의 범위
우선 Spring에서의 트랜잭션 전파에 대해 간략하게 알아보자.
아래와 같은 전파 수준이 있으며, default는 REQUIRED이다.
- REQUIRED: 현재 트랜잭션이 존재하면 해당 트랜잭션에 참여하고, 없으면 트랜잭션을 시작한다.
- REQUIRES_NEW: 항상 새로운 트랜잭션을 시작하고, 기존 트랜잭션을 일시 중단시킨다.
- SUPPORTS: 현재 트랜잭션이 존재하면 참여하고, 없으면 트랜잭션 없이 작업을 수행한다.
- MANDATORY: 현재 트랜잭션이 존재하지 않으면 예외를 던지고, 존재하면 참여한다.
- NOT_SUPPORTED: 트랜잭션을 사용하지 않고 작업을 수행하며, 현재 트랜잭션이 존재하더라도 일시 중단시킨다.
그리고 TransactionalEventListener의 주석을 보면 아래와 같은 내용을 확인할 수 있다.
TransactionPhase를 AFTER_COMMIT/AFTER_ROLLBACK/AFTER_COMPLETION으로 설정하면, 트랜잭션은 이미 커밋/롤백 된 상태에서 이벤트 처리가 시작된다.
하지만 여전히 트랜잭션 리소스(ex. DB connection)는 활성 상태로 남아 있어 접근 가능하다.
따라서 이벤트 리스너가 전파 속성인 REQUIRED인 트랜잭션 메서드를 호출하면, 이미 커밋된 트랜잭션을 활용하게 된다.
이 트랜잭션은 읽기 작업을 실행할 수 있지만, 이미 커밋된 트랜잭션을 다시 커밋하는 것은 불가능하므로 수정(CUD) 작업은 불가능하다.
트랜잭션 전파 수준을 REQUIRES_NEW로 지정하면, 새로운 중첩 트랜잭션이 시작되어 이 문제를 해결할 수 있다. 새로운 트랜잭션은 기존 트랜잭션과 독립적으로 실행된다.
또한, @Async 어노테이션과 함께 사용하면 새로운 스레드에서 실행되므로 이 문제를 해결할 수 있다. 이 경우 이벤트 처리가 비동기적으로 실행되고, 독립적인 트랜잭션 컨텍스트를 가진다.
propagation = REQUIRES_NEW와 데드락
하지만 트랜잭션 전파 수준을 REQUIRES_NEW로 지정하면 데드락의 위험이 발생한다.
HikariCP의 기본 커넥션 개수는 10개이다.
기존 트랜잭션이 존재하는데 새로운 트랜잭션을 생성하려할 때 커넥션의 개수가 부족할 경우 데드락이 발생한다.
나는 이러한 데드락의 위험성 때문에 @TransactionalEventListener를 사용할 때 주로 @Async 어노테이션을 사용하는 방법을 택한다.
아래는 spring-framework의 관련 이슈이니 참고해도 좋을 것 같다.
TransactionalEventListener와 Exception
TransactionalEventListener에서 메서드 실행 후 마지막에 invokeAfterCompletion 함수가 호출된다.
이때, 예외를 catch해 error 레벨로 로깅을 한다.
즉, 예외가 TransactionEventListener 내부에서 catch되어 처리되기 때문에 발행자까지 예외가 전파되지 않는다.
이벤트의 단점
이벤트는 결합도를 줄여주고 유연성도 향상해 주는 항상 좋기만 한 친구인 것 같다.
하지만 아쉽게도 모든 상황에서 적합한 건 아니다.
이벤트를 사용할 때는 아래와 같은 단점도 존재하니, 본인의 상황에 맞게 이벤트 도입을 고려해야 한다.
오버헤드
이벤트를 발행하고, 이벤트 리스너를 호출하는 과정에서 오버헤드가 발생한다.
의존성 파악이 어렵다
이번 포스팅을 작성하며 느낀 건데, 의존성 파악이 굉장히 어렵다.
직접적인 메서드 호출과 달리 IDE를 통해 의존성 추적이 어렵다.
이벤트 발행할 때, 어떤 리스너들이 해당 이벤트를 구독하고 있는지 확인하기 위해 직접 검색해야 하는 경우가 많다.
이로 인해 코드를 이해하는데 어려움이 발생한다.
비즈니스 로직 복잡성
이벤트가 지나치게 복잡하게 사용될 경우, 코드가 이해하기 어려워지고 유지 보수가 어려워진다.
'프로그래밍' 카테고리의 다른 글
[Spring] Mockito 테스트의 중복 given절 줄이기: lenient를 활용한 유틸리티 클래스 (1) | 2023.10.08 |
---|---|
[Spring] TaskScheduler를 활용해 런타임에 동적으로 작업 예약하기 (7) | 2023.10.08 |
[Java] synchronized란? (1) | 2023.09.13 |
[Java] Thread란? (0) | 2023.09.13 |
Facade 객체를 활용해 트랜잭션에서 외부 API 통신 분리하기 (4) | 2023.09.11 |