아래 코드는 GitHub 에서 보실 수 있습니다.
글을 쓰게 된 이유
우아한테크코스(이하 우테코)에서 진행한 프로젝트 ‘커디’에서 알림 서비스를 개발했습니다.
해당 서비스를 개발하다보니 아래와 같은 궁금점들이 생겼습니다.
서드파티인 파이어베이스를 통해 알림을 보내게 되면 기존 로직에 비해 오래 걸릴 텐데,
동기적으로 수행한다면 사용자가 알림이 전송될 때까지 기다려야 하는 걸까?
만약에 애플리케이션 로직(알림 저장)은 성공했지만 파이어베이스 서버가 잘못되면 알림 전송이 실패될 텐데,
즉, 같은 트랜잭션 내에 묶여있으면 실패로 될 텐데 이게 맞는 걸까?
사용자 입장에서는 정확하게 알림을 저장했지만, 파이어베이스 서버가 잘못되어 알림이 사라지는 경우가 맞는 걸까?
등과 같은 다양한 생각이 들었습니다.
그래서 이 글을 통해 위와 같은 문제들을 해결하는 과정들을 테스트를 통해서 알아보고,
다음 글로 실제 프로젝트에 적용하면서 마무리하겠습니다.
목표
- 댓글이벤트가 성공적으로 발행되면 알림 발송
- 파이어베이스 오류로 알림 발송이 실패할 경우 다시 재요청할 수 있어야 함
- 이때 사용자는 댓글이 성공적으로 저장됐기 때문에 타 사용자에게는 알림이 가는 줄 암
- 사용자는 댓글을 저장할 뿐, 알림은 애플리케이션 내에 자체적으로 수행되는 것이므로 사용자가 알림이 완료될 때까지 기다릴 필요가 없음
이벤트 사용? 서비스 호출?
알림 서비스를 만들면서 이벤트 사용과 서비스 호출에 대해 고민을 했었습니다.
이벤트를 사용한 가장 큰 이유는 관심사의 분리입니다.
“댓글 입장에서는 알림을 알아야 할까?”에 대한 대답은 No였습니다.
서비스 호출을 하게 되면 댓글이 알림을 의존하게 되고, 불필요한 의존이 생기는 것이지요.
그래서 이벤트를 사용하게 됐습니다.
댓글 생성 이벤트 성공적으로 발행될 경우 알림 발송
@Transactional
public Comment save(final String content) {
final Comment comment = new Comment(content);
final Comment savedComment = commentRepository.save(comment);
eventPublisher.publish(comment);
return savedComment;
}
public void publish(final Comment comment) {
final Notification notification = new Notification(comment.getId());
publisher.publishEvent(notification);
}
@EventListener
@Transactional
public void listen(final Notification notification) {
final Notification savedNotification = notificationRepository.save(notification);
try {
externalClient.sendMessage();
} catch (final Exception exception) {
throw new IllegalArgumentException("알림 정상적으로 발송 X");
}
savedNotification.send();
}
@Test
@DisplayName("댓글을 성공적으로 저장한다면 알림이 발송된다.")
void test_save() throws Exception {
final Comment comment = commentService.save("댓글");
final Notification notification = notificationRepository.findByRedirectId(comment.getId());
assertTrue(notification.isNotificationSent());
}
만약 댓글 생성에서 문제가 발생한다면?
@Transactional
public Comment save(final String content) {
final Comment comment = new Comment(content);
final Comment savedComment = commentRepository.save(comment);
eventPublisher.publish(comment);
throwException();
return savedComment;
}
그러면 어떻게 될까요??
테스트는 Exception이 발생했기 때문에 실패할 겁니다.
하지만 이벤트는 성공적으로 발행되었고, 실제 Notification이 저장됐습니다.
이러면 알림은 받았는데 실제로 가보니 댓글이 없는 상황이 생깁니다.
어떻게 해결할 수 있을까요?
스프링에서 제공하는 @TransactionalEventListener를 사용하면 됩니다.
An EventListener that is invoked according to a TransactionPhase. This is an annotation-based equivalent of TransactionalApplicationListener. If the event is not published within an active transaction, the event is discarded unless the fallbackExecution flag is explicitly set. If a transaction is running, the event is handled according to its TransactionPhase.
해당 어노테이션은 TransactionPhase에 호출되는 EventListener라고 합니다.
만약 트랜잭션이 활성화되지 않은 곳에서 해당 이벤트 리스너가 호출되지 않고 이벤트가 삭제됩니다.
그래서 옵션으로 이전 트랜잭션의 상태에 따라서 이벤트 리스너가 어떻게 동작되는지 설정할 수 있습니다.
제가 원하던 기능이 그대로 있습니다.
제공하는 여러 phase가 있지만 저는 default인 AFTER_COMMIT을 사용하도록 하겠습니다.
즉, 댓글이 성공적으로 저장되면(트랜잭션이 커밋되면) → 알림 listen
@TransactionalEventListener
@Transactional
public void listen(final Notification notification) {
final Notification savedNotification = notificationRepository.save(notification);
try {
externalClient.sendMessage();
} catch (final Exception exception) {
throw new IllegalArgumentException("알림 정상적으로 발송 X");
}
savedNotification.send();
}
다시 테스트를 실행해 보죠
이벤트는 발행되었지만 이전과 다르게 기존 트랜잭션이 롤백되었기 때문에 이벤트를 listen 하지 않아서 ‘메시지 요청 성공적’이라는 로그가 존재하지 않습니다.
정리
정리를 한번 해볼게요.
댓글 저장 → 알림 발행 → 알림 listen 과정
@TransactionalEventListener를 적용하기 전
댓글 저장 → 알림 발행 → 댓글 에러로 롤백 → 알림 listen
결과로 알림은 왔지만 댓글이 존재하지 않게 됩니다.
@TransactionalEventListener를 적용하기 전
댓글 저장 → 알림 발행 → 댓글 에러로 롤백 → 알림 listen X
결과로 알림이 오지도 않았고, 댓글이 존재하지 않기 때문에 성공적입니다.
@TransactionalEventListener를 추가한 후 트랜잭션이 시작되지 않는다?
@Transactional
public Comment save(final String content) {
final Comment comment = new Comment(content);
final Comment savedComment = commentRepository.save(comment);
eventPublisher.publish(comment);
return savedComment;
}
@TransactionalEventListener
@Transactional
public void listen(final Notification notification) {
final Notification savedNotification = notificationRepository.save(notification);
try {
externalClient.sendMessage();
} catch (final Exception exception) {
throw new IllegalArgumentException("알림 정상적으로 발송 X");
}
savedNotification.send();
}
@Test
@DisplayName("댓글을 성공적으로 저장한다면 알림이 발송된다.")
void test_save() throws Exception {
final Comment comment = commentService.save("댓글");
final Notification notification = notificationRepository.findByRedirectId(comment.getId());
assertTrue(notification.isNotificationSent());
}
@TransactionalEventListener를 추가하고 나서 댓글 생성이 성공적으로 일어날 경우 테스트를 진행하게 되면 실패하게 됩니다.
NPE 가 발생하게 됩니다,, 즉, Notification이 제대로 저장되지 않았다는 겁니다.
로그를 한번 살펴볼게요.
분명히 기존에 있던 트랜잭션에 참여하는 로그와, repository에 save 하는 메서드까지 성공적으로 호출됐습니다.
그런데 왜 저장이 안 되는 걸까요??
로그를 자세히 보면, commit 하는 로그나 insert 쿼리가 없습니다.
listen 메서드에는 분명 @Transactional 이 붙어있는데 말이죠,,
비밀은 바로 @TransactinoalEventListener에 있습니다.
WARNING: if the TransactionPhase is set to AFTER_COMMIT (the default), AFTER_ROLLBACK, or AFTER_COMPLETION, the transaction will have been committed or rolled back already, but the transactional resources might still be active and accessible. As a consequence, any data access code triggered at this point will still "participate" in the original transaction, but changes will not be committed to the transactional resource. See TransactionSynchronization.afterCompletion(int) for details.
TransactionPhase에서 AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION에서는 기존 트랜잭션이 이미 커밋되었거나 롤백되었습니다.
즉, 해당 어노테이션가 사용된 곳에서는 기존 트랜잭션에 ‘참여’는 할 수 있지만 변경 사항에 대해서는 커밋되지 않는다고 합니다.
그러면 어떻게 해결할 수 있을까요?
문서에서 TransactionSynchronization.afterCompletion(int) 이 부분을 살펴보라고 했으니 한번 살펴보도록 하겠습니다.
NOTE: The transaction will have been committed or rolled back already, but the transactional resources might still be active and accessible. As a consequence, any data access code triggered at this point will still "participate" in the original transaction, allowing to perform some cleanup (with no commit following anymore!), unless it explicitly declares that it needs to run in a separate transaction. Hence: Use PROPAGATION_REQUIRES_NEW for any transactional operation that is called from here.
기존 트랜잭션에 계속 ‘참여’해서 별도의 트랜잭션에서 사용한다는 명시가 없으면 더 이상 커밋하지 않고 ‘참여’만 할 수 있습니다.
만약에 트랜잭션 작업이 필요하다면 PROPAGATION_REQUIRES_NEW를 사용해야 한다고 합니다.
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void listen(final Notification notification) {
final Notification savedNotification = notificationRepository.save(notification);
try {
externalClient.sendMessage();
} catch (final Exception exception) {
throw new IllegalArgumentException("알림 정상적으로 발송 X");
}
savedNotification.send();
}
@Test
@DisplayName("댓글을 성공적으로 저장한다면 알림이 발송된다.")
void test_save() throws Exception {
final Comment comment = commentService.save("댓글");
final Notification notification = notificationRepository.findByRedirectId(comment.getId());
assertTrue(notification.isNotificationSent());
}
새로운 트랜잭션이 시작되면서 기존에 존재하던 트랜잭션은 Suspending 됩니다.
그리고 MyEventListener.listen에서 새로운 트랜잭션이 시작되고, 커밋이 되면 기존에 suspending 되었던 트랜잭션이 다시 시작되고 close 됩니다.
근데 트랜잭션 전파에 대해서 공부를 하면 외부 트랜잭션은 내부 트랜잭션이 커밋되기 전에 suspending 됩니다.
위 로그를 보면 외부 트랜잭션이 커밋되고 나서 내부 트랜잭션이 커밋되고, 내부 JPA 세션 매니저가 닫히고, 외부 JPA 세션 매니저가 닫히게 됩니다.
왜 그럴까요?
바로 @TransactionalEventListener에서 우리는 AFTER_COMMIT이라는 phase를 명시해 주었기 때문입니다. 외부 트랜잭션이 커밋될 경우 내부 EventListener를 실행하기 때문이죠.
그래서 위 공식문서에서도 “내부 EventListener가 트랜잭션에는 참여하는 것이지만 실제 변경사항에 대한 커밋은 진행하지 않는다”라는 말도 있었습니다.
그래서 로그를 보면 기존 스프링 트랜잭션 전파를 공부할 때와는 조금 다른 것을 알 수 있습니다.
정리
트랜잭션 전파 타입을 지정해 주어 해결할 수 있었습니다.
AFTER_COMMIT은 단지 변경사항에 대한 트랜잭션을 커밋, 롤백하는 것이 아닌 단지 트랜잭션의 범위만 연장시켜 줍니다.
그래서 우리는 변경감지가 일어나지 않게 되고, 단지 트랜잭션 범위에만 있게 되는 것이죠.
그 이유는 AFTER_COMMIT으로 정했기 때문에 이전 트랜잭션은 이미 커밋됐기 때문입니다.
전파 전략을 REQUIRES_NEW 를 통해 해결할 수 있었죠.
테스트도 성공했습니다!!!
만약 파이어베이스에 문제가 생긴다면?
스프링에서 제공하는 트랜잭션의 커밋 & 롤백 전략은 checkedException일 경우에는 커밋을 하고,
unCheckedException일 경우에는 롤백을 하게 됩니다.
여기서 제 목표는 “파이어베이스에 문제가 있으면 알림을 일단 저장해 두고(API 요청 성공) 나중에 배치로 돌린다”였습니다.
즉, 파이어베이스(제어하지 못하는 것)에 문제가 생기면 일단 커밋을 하고 나서, Notification에 있는 column인 isNotificationSent를 통해 나중에 배치로 돌리면 됩니다.
연습 삼아서 먼저 unCheckedException을 발생시켜 보겠습니다.
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void listen(final Notification notification) {
final Notification savedNotification = notificationRepository.save(notification);
externalClient.sendMessageFailRuntimeException();
savedNotification.send();
}
@Test
@DisplayName("댓글을 성공적으로 저장하고나서, 알림 쪽에서 문제가 생길경우 커밋을 한다.(정상 작동)")
void test_save() throws Exception {
final Comment comment = commentService.save("댓글");
final Notification notification = notificationRepository.findByRedirectId(comment.getId());
assertFalse(notification.isNotificationSent());
}
롤백되는 것을 알 수 있습니다.
제 입장에서는 파이어베이스에 exception이 발생할 수 있기 때문에 try-catch로 감싸고 나서 만약에 Exception이 발생하게 되면 error 레벨의 로그를 찍고 정상적으로 수행하려 합니다.
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void listen(final Notification notification) {
final Notification savedNotification = notificationRepository.save(notification);
try {
externalClient.sendMessageFailException();
} catch (Exception e) {
log.error("[파이어베이스 에러 메시지] = {}", e.getMessage(), e);
}
savedNotification.send();
}
@Test
@DisplayName("댓글을 성공적으로 저장하고나서, 알림 쪽에서 문제가 생길경우 커밋을 한다.(정상 작동)")
void test_save() throws Exception {
final Comment comment = commentService.save("댓글");
final Notification notification = notificationRepository.findByRedirectId(comment.getId());
assertFalse(notification.isNotificationSent());
}
에러 로그가 찍히고 나서, 정상적으로 내부 트랜잭션이 커밋되고, 외부 트랜잭션이 닫히는 것을 알 수 있습니다.
지금까지
댓글이 정상적으로 저장될 시에 알림이 발송되고,
알림 서버에 문제가 생기게 되면 사용자에게는 성공했고, 정상적으로 수행되게 됩니다.
그리고 실패 시 알림이 저장되는 것까지 listen에서 하나의 트랜잭션으로 묶여있기 때문에 데이터를 유실되지 않습니다.
마지막으로 남은 것은 비동기로 처리하기입니다.
비동기로 처리해 보기
왜 알림 보내는 것을 비동기로 할까요?
댓글 생성 이벤트 발생 → 이벤트 listen → 파이어베이스에 알림 요청
이 요청이 하나의 스레드에서 수행되면, 사용자 입장에서는 알림 요청이 끝날 때까지 기다려야 합니다.
하지만 파이어베이스에 알림 요청에 비동기로 요청하게 된다면 사용자는 굳이 파이어베이스에 알림 요청까지 기다리지 않아도 됩니다.
이게 바로 적용하기 전의 저의 생각이었습니다.
생각 전환
@Async을 사용하여 동기, 비동기로 할 경우 메서드 실행시간을 측정해 보았습니다.(단위 ms)
동기
- 203, 205, 253, 330, 183(평균 234.8)
비동기
- 178, 169, 272, 223, 339(평균 236.2)
비동기면 훨씬 빠를 줄 알았는데 5개의 평균 시간을 재보니 비동기의 시간이 더 오래 걸립니다.
매번 다른 테스트 시간 차이로 인해 비동기가 더 느리다기보다는 둘의 차이는 없다고 보시면 됩니다.
그러면 비동기가 더 빠를 줄 알았는데 왜 비슷한 걸까요?
바로 AFTER_COMMIT 때문이었습니다.
@TransactionalEventListener의 phase를 AFTER_COMMIT으로 해놓았습니다.
즉, 이전 이벤트를 발행한 트랜잭션이 커밋돼야 해당 이벤트리스너가 실행이 되는 것이죠.
비동기로 실행해도 단지 다른 스레드로 동작하는 것뿐이지, 이전 트랜잭션이 커밋될 때까지 기다리게 됩니다.
그래서 시간이 비슷한 것이죠.
AFTER COMMIT을 한 이유는 댓글이 성공적으로 저장이 돼야 알림을 보내는 것이 맞기 때문입니다.
이 결과를 보면 굳이 비동기 처리를 하지 않아도 됩니다.
결론
이번 글을 통해서 알림을 보낼 때 특정한 상황을 가정해 보고 문제를 해결해 보았습니다.
무조건 비동기가 빠른 것이 아닌, 상황에 맞게 사용해야지 빠르게 잘 사용할 수 있는 것을 알 수 있었어요.
지금은 댓글이 잘 저장되고 나서 알림이 발행되야 하므로
댓글이 잘 저장되고(트랜잭션 커밋), 알림이 가야 하므로 비동기가 필요가 없게 됩니다.
단지 이전과 다른 스레드를 사용한다 뿐인 것이죠
이 과정에서 @TransactionalEventListener, 트랜잭션 propagation, 비동기 등 많은 것을 알 수 있었습니다.
'Spring' 카테고리의 다른 글
스프링에서 db read/write 라우팅에 관하여 (2) | 2024.09.09 |
---|---|
[kerdy] EventListener & TransactionalEventListener를 통해 문제 해결해보기 (적용편) (2) | 2023.08.20 |
[kerdy] AOP, ThreadLocal을 사용하여 N+1 detector 만들어보기 (6) | 2023.08.19 |
[kerdy] Spring Boot + Firebase 를 통해서 알림 기능 만들어보기 (0) | 2023.07.31 |
@Validated vs @Valid (2) | 2023.06.14 |