일반적으로 서비스 개발 시 여러가지 작업을 하나의 트랜잭션으로 처리하는 경우가 많이 있다. 개발자는 비즈니스 로직에만 신경쓰고 트랜잭션은 스프링에서 지원하는 @Transactional로 성공/실패에 따른 처리를 맡기므로 편하게 개발을 할 수가 있다.
하지만, 트랜잭션 내의 하나의 작업이 실패하는 경우 전체가 롤백되기를 기대하지만 일부 작업이 롤백이 되지 못하는 경우가 있다면 어떨까?
예를 들면 로컬 트랜잭션(DB 저장)이 아닌 네트워크 전송(HTTP, 메시징)을 하는 경우를 생각해 볼 수 있다.
아래 예제를 통해 좀 더 구체적으로 알아보자.
1. 사용 예제
다음은 사용자 등록을 하는 시나리오이며 사용자 등록과 함께 사용자의 쿠폰을 발행하는 서비스를 개발하는 예제이다.
- 사용자 등록
- 사용자 등록 이벤트 발생
- 쿠폰 발행
위의 예제를 코드로 구현하면 userService.register() 와 같은 형태가 될 것이다.
@Transactional
public void register(User user) {
log.info("1. userStore에 저장");
userStore.register(user);
log.info("2. userCreated 이벤트 발행");
applicationEventPublisher.publishEvent(new UserCreated(user));
log.info("3. coupon 저장");
couponService.register(user); // 여기서 쿠폰 등록 오류
}
위의 예제에서 쿠폰 서비스에 문제가 생겨서 쿠폰 등록을 하다가 실패(3번)가 발생했다고 해보자.
3번 쿠폰 저장에서 오류가 발생하면 1번(userStore 저장)은 트랜잭션에 의해 롤백이 되겠지만 2번(이벤트 발행)은 롤백이 되지 않는다. (kafka나 rabbitMQ와 같은 메시지큐에 메시지 발행)
이때 사용자는 생성되지 않았지만((1번 롤백)) 2번은 이벤트 전송이 되어버려서 이벤트를 롤백하지는 못할 것이다.
userCreated 이벤트를 받아서 처리하는 로직은 실행이 되어서 데이터 불일치 같은 서비스 상의 문제가 발생할 것이다.
이런 경우에 사용하는 것이 @TransactionalEventListener
이다.
@TransactionalEventListener
는 기본적으로 @Transactional
으로 묶여있는 메소드의 트랜잭션이 성공되면 호출이 된다.(기본값: AFTER_COMMIT)
물론 롤백되는 경우에 실행되게 할 수는 있지만 기본값으로 기준으로 설명한다.
2. 정상 케이스
아래 예제 소스로 이벤트가 어떤 순서로 실행이 되는지 한번 보자.
@Transactional
public void register(User user) {
log.info("1. userStore에 저장");
userStore.register(user);
log.info("2. userCreated 이벤트 발행");
applicationEventPublisher.publishEvent(new UserCreated(user));
log.info("3. coupon 저장");
couponService.register(user);
}
@TransactionalEventListener
public void send(UserCreated userCreated) {
log.info("4. userCreated 이벤트 수신 후 메시지큐로 이벤트 발행");
}
위의 register를 실행해보면 실행순서는 아래와 같다.
- userStore에 저장
- userCreated 이벤트 발행
- coupon 저장
- userCreated 이벤트 수신 후 메시지큐로 이벤트 발행
3. 실패 케이스
@Transactional
public void register(User user) {
log.info("1. userStore에 저장");
userStore.register(user);
log.info("2. userCreated 이벤트 발행");
applicationEventPublisher.publishEvent(new UserCreated(user));
log.info("3. coupon 저장");
couponService.register(user); // 여기서 쿠폰 등록 실패
}
@TransactionalEventListener
public void send(UserCreated userCreated) {
log.info("4. userCreated 이벤트 수신 후 메시지큐로 이벤트 발행");
}
위의 예제에서 쿠폰 발행에 문제가 생겨서 couponService.register에서 오류가 난다고 해보자. 그럼 어떻게 호출이 될까?
public class CouponService {
public void register(User user) {
throw new RuntimeException("쿠폰 등록 에러");
}
}
호출 결과
2023-01-14 22:05:35.522 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 1. userStore에 저장
2023-01-14 22:05:35.647 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 2. userCreated 이벤트 발행
2023-01-14 22:05:35.650 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 3. coupon 저장
2023-01-14 22:05:35.671 ERROR 6102 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: coupon error] with root cause
java.lang.RuntimeException: 쿠폰 등록 에러
- userStore에 저장
- userCreated 이벤트 발행
- coupon 저장
위에서 3번까지만 실행되고 4번(이벤트 수신 후 메시지큐로 이벤트 발생)은 실행이 되지 않는다. 왜냐하면 트랜잭션이 실패하여 롤백이 되었기 때문이다. @TransactionalEventListener
의 기본값은 트랜잭션이 성공되었을 때만 실행이 된다.
기본값: @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
그럼 트랜잭션이 롤백이 된 경우 특정 작업을 실행하고 싶다면 어떻게 해야 하나?
TransactionPhase.AFTER_ROLLBACK
으로 설정하면 롤백된 이후에 특정 작업을 실행할 수도 있다.
사용방법은 아래와 같다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void sendRollback(UserCreated userCreated) {
log.info("4. userCreated 이벤트 롤백됨");
}
실행 결과
2023-01-14 22:05:35.522 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 1. userStore에 저장
2023-01-14 22:05:35.647 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 2. userCreated 이벤트 발행
2023-01-14 22:05:35.650 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 3. coupon 저장
2023-01-14 22:05:35.657 INFO 6102 --- [nio-8080-exec-1] c.e.s.t.service.UserService : 4. userCreated 이벤트 롤백됨
2023-01-14 22:05:35.671 ERROR 6102 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: coupon error] with root cause
java.lang.RuntimeException: coupon error
위와 같이 롤백이 발생했을 경우로 phase를 설정(AFTER_ROLLBACK)한 경우 로그는 위와 같이 표시된다.
4. phase 설정
추가적으로 @TransactionalEventListener
는 phase 설정을 통해 언제 실행될지 설정할 수가 있다.
TransactionPhase | 설명 |
---|---|
BEFORE_COMMIT | 트랜잭션 커밋 직전에 실행됩니다 |
AFTER_COMMIT | 트랜잭션 커밋 이후에 실행됩니다 (기본값) |
AFTER_ROLLBACK | 트랜잭션 커밋 롤백 이후에 실행됩니다 |
AFTER_COMPLETION | 트랜잭션이 끝난 이후에 실행됩니다 |