플랭고는 친구와 함께 일정을 수정하고 장소를 추가하는 등 일정 공유 기능을 제공한다. 이때 새로운 일정에 초대되거나, 일정 초대를 수락/거절하거나 공유 중인 일정이 수정되는 등의 상황에 알림(자체 알림 목록 + 푸시)이 발송되도록 기능을 구현해두었다.
기존에 알림을 발송하는 흐름은 다음과 같다. 먼저 일정 서비스에서 알림을 발송해야 하는 경우에 알림 DTO를 만들어 알림 서비스를 호출한다. 그러면 알림 서비스는 알림을 생성하고 외부 서비스인 FCM을 통해 대상 회원의 기기로 푸시 알림을 보낸다.
이러한 흐름으로 알림을 발송하면 위 그림처럼 일정 -> 알림 -> 외부 서비스를 거치는 흐름 전체가 하나의 트랜잭션으로 묶이게 된다. 이런 식으로 코드를 구현하면 크게 다음과 같은 문제가 생길 수 있다고 생각했다.
- 알림이나 외부 서비스 호출 중에 예외가 발생하면 제대로 완료된
일정 초대
도 롤백된다 - 위 과정에서 가장 주요한 작업인
일정 초대
가 종료되어도알림 발송
과푸시 발송
이 완료될 때 까지 클라이언트는 응답을 대기해야 한다
이러한 문제를 해결하기 위해 알림 서비스와 FCM 호출 서비스를 일정 서비스와 분리하기로 했다. 그런데 막상 하다보니 생각한대로 잘 안 되는 부분들이 있었고, 왜 그랬는지 문제를 해결하기 위해 시도한 방법과 결과들을 정리했다.
1. Propagation.REQUIRES_NEW
가장 먼저 트랜잭션을 분리하기 위해 @Transactional의 propagation 속성을 Propagation.REQUIRES_NEW
로 주는 방법을 생각했다.
위 설명처럼 이 속성은 작업을 수행할 때 기존 트랜잭션을 suspend하고 새로운 트랜잭션을 생성한다고 한다. 그래서 오? 그럼 새로운 트랜잭션이 만들어지니깐 알림 이후에 실행되는 로직에서 예외가 발생해도 부모 트랜잭션인 일정 서비스의 로직은 제대로 커밋되겠군. 이라고 생각했다.
그래서 알림 서비스의 send()
메서드를 아래와 같이 변경해 주었다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void send(Long targetMemberId, NotificationType notificationType, NotificationArguments notificationArguments) {
Member targetMember = findMemberById(targetMemberId);
...
그리고 아래와 같이 알림 서비스에서 예외를 던지도록 하고 테스트를 해보았다.
하지만 결과는 알림 서비스뿐만아니라 일정 서비스도 롤백되는 것을 확인했다.
이 부분에 대한 원인은 정확히는 모르겠는데 일정 서비스를 호출하면서 새로운 트랜잭션이 생성되긴 하지만 부모 트랜잭션이 아직 커밋/롤백 되지 않고 열려있는 상태라서 자식 트랜잭션에서 문제가 생기면 부모 트랜잭션이 닫히면서 롤백되도록 하지 않았을까 생각한다.
24. 01. 03. 수정
위에서 propagation 속성을 Propagation.REQUIRES_NEW
로 설정해도 부모 트랜잭션의 롤백이 이뤄지던 이유는.. 부모 트랜잭션이 실행되는 메서드에서 try-catch를 해주지 않았기 때문이다.. 그럼 당연히 예외가 전파되면서 부모 메서드에서도 예외가 터질테니 트랜잭션이 롤백되겠지.. 바보야..
위와 같이 try-catch를 해두면 (자식 트랜잭션을 Propagation.REQUIRES_NEW로 설정한 경우에) 아래와 같이 테스트가 통과하는 것을 볼 수 있다.
반면, try-catch를 해주어도 propagation 속성이 Propagation.REQUIRED
라면 아래와 같이 테스트에 실패하는 것을 볼 수 있다.
이와 같은 현상이 발생하는 이유는 현재 propagation이 REQUIRED로 부모와 자식이 병합되고 있는데, 이 경우 아무리 try-catch로 잡아주어도 결국 하나의 트랜잭션이기 때문에 예외가 발생하면 무조건 트랜잭션 전체를 rollback-only
로 마킹하여 커밋 시 롤백하도록 되어있기 때문이다.
(자세한 내용은 우아한 형제들 기술블로그 : 응? 이게 왜 롤백되는거지? 에서~~)
따라서 Propagation.REQUIRES_NEW
해야 새로운 트랜잭션이 만들어지고 새로운 자식 트랜잭션에만 rollback 마킹이 되어 부모 트랜잭션에는 영향을 주지 않게 된다.
2. @TransactionalEventListener + REQUIRES_NEW
찾아보니 이를 해결하기 위해 @TransactionalEventListener를 사용할 수 있는 것 같았다. 이 어노테이션은 이벤트를 리스닝하는데, 이벤트를 발행한 트랜잭션이 종료 (커밋/롤백) 된 후에 이벤트를 처리한다. 따라서 위 상황에 적용하면 일정 서비스가 커밋
된 이후에 알림 트랜잭션을 시작
하도록 할 수 있는 것이다.
24. 01. 03. 수정
이 부분도 기존에는 롤백을 막기 위해 @TransactionalEventListener를 사용했으나 위 수정 내용을 통해 다른 방법으로 해결할 수 있음을 알게 되었다. 다만, 그러한 방법(try-catch & REQUIRES_NEW) 으로 트랜잭션을 분리하면 알림을 보내는 모든 코드에 try-catch 문을 넣어주어야 하므로 여전히 Event를 사용하고 이를 통해 코드의 결합도를 낮추는 것이 의미가 있다고 생각했다.
먼저 이벤트로 발행할 객체를 만든다.
그리고 알림 서비스에 TransactionalEventListener 어노테이션을 달아준다.
@TransactionalEventListener
@Transactional
public void send(NotificationEvent event) {
Member targetMember = findMemberById(event.getTargetMemberId());
...
마지막으로 일정 서비스에서 이벤트를 publish 해준다.
그리고 다시 테스트를 해봤다. 그 결과 의도한대로 일정 트랜잭션이 커밋된 후에 이벤트가 리스닝되어 알림 서비스에서의 예외 발생이 일정 서비스의 커밋/롤백에 영향을 주지 않는 것을 확인했다.
디버깅을 해봐도 (일정 초대 요청을 했으므로) 제대로 일정 멤버가 추가된 것을 확인할 수 있다.
하지만 이 방법엔 치명적인 문제가 있었으니.. 바로 이벤트를 통해 실행되는 로직에서는 쓰기 작업을 할 수가 없다 는 것이다. 아래와 같이 알림 서비스에서 예외 발생 로직을 제거하고 제대로 테스트를 해보면 알림이 저장되지 않는 것을 확인할 수가 있다.
그 이유는 Transactional 의 propagation 옵션 때문인데, 새로운 트랜잭션을 생성하지 않는 DEFAULT 옵션인 PROPAGATION_REQUIRED
때문에 이벤트로 호출된 알림 서비스에서는 기존 트랜잭션을 그대로 사용하게 된다. 그런데 이미 일정 서비스에서 커밋이 이뤄졌으니 더 이상 커밋을 할 수가 없는 것이다. 따라서 쓰기 작업을 할 수가 없다. (엥?!)
당황스럽지만 이때 앞서 시도했던 해결책인 REQUIRES_NEW
로 propagation 옵션을 바꿔주면
새로운 트랜잭션이 생성되면서 테스트가 통과하는 것을 볼 수 있다.
3. 비동기 처리
2번 해결책을 통해 일정 서비스는 알림, 푸시 서비스의 성공 여부와 관계없이 개별적으로 커밋/롤백을 수행하게 되었다. 근데 아직 처음에 언급한 문제들 중에 두번째 즉, 클라이언트가 푸시 발송 때 까지 응답을 대기해야 한다는 문제는 해결되지 않았다.
아래 로그를 보면 일정, 알림, 푸시 서비스가 모두 동일한 쓰레드에서 실행되는 것을 볼 수 있다.
이를 해결하기 위해 알림 서비스가 비동기로 실행되도록 해주었다. 알림 서비스에 @Async를 달고 적당한 설정 클래스에 @EnableAsync 어노테이션을 달아주었다.
24. 01. 03. 수정
추가로, @Async를 통해 비동기를 사용하면 사실 트랜잭션 분리는 신경쓰지 않아도 된다. 말 그대로 별도의 쓰레드에서 실행되기 때문에 비동기 쓰레드에서의 작업이 기존 쓰레드에서의 작업에 영향을 주지 않기 때문이다. 트랜잭션 분리만을 위해서 비동기를 사용하는 것은 적절하지 않겠지만 이 포스팅에서는 비동기 자체가 필요하여 트랜잭션 분리 이후에 비동기도 적용해주었다.
알림 서비스
설정 클래스
다시 테스트를 실행해보니 의도한대로 알림 서비스부터 별도의 쓰레드에서 실행되는 것을 확인할 수 있었다.
성능 비교
마지막으로 비동기로 알림/푸시를 보낼 때와 동기로 보낼 때 실제 API 응답 시간에 성능 차이가 있는지 정말 간단하게만 측정을 해보았다. 회원 A가 회원 B를 일정 A에 초대하고 탈퇴시키는 요청(두 요청 모두 알림 발송 대상)을 각각 100번 반복하게 하고 응답 시간을 평균내 보았다.
먼저 기존 동기로 알림을 보내는 경우이다.
다음으로 비동기로 알림을 보내는 경우이다.
결과는 API 응답 시간이 무려 평균 210.12ms 에서 6.14ms로 약 34배 개선되었다. 사실 별 차이 없으면 어떻게 마무리하지 고민하고 있었는데 ㅋㅋㅋ 의도한대로 결과가 나와서 뿌듯했다.
끗
이렇게 부수적인 로직에서 발생한 예외가 주요 로직에 영향을 주지 않도록 트랜잭션을 분리하고, 클라이언트가 보다 빠르게 응답을 받아볼 수 있도록 코드 개선을 해보았다. 개선된 시간을 보니 실제 서비스되고 있었다면 유저가 별거 아닌 요청에 엄청 쓸데없이 오래 기다리고 있었겠구나 하는 생각이 들었다. 외부 API 호출은 꼭 비동기로 분리하자!
다른 많은 블로거분들의 경험을 참고해서 엄청 빠르게 원하는 결과를 얻을 수 있었다. 감사합니다! 참고한 자료들은 아래에 적어두었다.
참고 자료
- [Spring] REQUIRES_NEW 옵션만으론 자식이 롤백될 때 부모도 롤백된다
- Event 처리는 비동기가 아니다?
- Spring ApplicationEvent 비동기로 처리될 것만 같지?
- 스프링 이벤트 발행과 구독으로 트랜잭션 분리하기
- [Spring] Spring의 Event를 어떻게 사용하는지에 대해서 알아봅시다. - @TransactionalEventListener에 대해서
- @Transactional 분리가 안되는 이유 / 실험을 통해 트랜잭션 전파 유형과 Spring AOP 이해
- [Spring] 트랜잭션의 전파 설정별 동작
'개발 공부 > Spring' 카테고리의 다른 글
백엔드 개발을 공부하고 있습니다.