문제 상황
이전 포스팅을 보고 오면 이해가 더 쉬울 것이다.
예제 코드
댓글을 저장하면 알림을 저장하는 로직을 단순화시킨 예제 코드이다.
CommentController
@RestController
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
@PostMapping("/comments")
public ResponseEntity<Void> create(@RequestParam String writer, @RequestParam String content) {
Long commentId = commentService.create(writer, content);
return ResponseEntity.created(URI.create("/comments/" + commentId)).build();
}
}
CommentService
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public Long create(String writer, String content) {
Comment comment = commentRepository.save(new Comment(writer, content));
applicationEventPublisher.publishEvent(new PushEvent(comment));
return comment.getId();
}
}
PushEventListener
@Component
@RequiredArgsConstructor
public class PushEventListener {
private final PushNotificationRepository pushNotificationRepository;
@EventListener
public void handle(PushEvent event) {
Comment comment = (Comment)event.getEntity();
String message = comment.getWriter() + "님이 댓글을 남겼습니다.";
PushNotification pushNotification = new PushNotification(message);
pushNotificationRepository.save(pushNotification);
}
}
포스트맨 찔러보기
- POST 요청으로 댓글을 저장하자 알림도 함께 저장된 것을 확인할 수 있다.
- ApplicationEventPublisher에 등록한 이벤트가 잘 실행되는 것을 확인할 수 있다.
- 아래 테스트 코드도 통과한다.
@DisplayName("댓글을 저장하면 알림도 저장된다.")
@Test
void createComment_saveNotification() {
// given
String writer = "does";
String content = "댓글 내용입니다.";
// when
Long commentId = commentService.create(writer, content);
// then
Optional<Comment> findComment = commentRepository.findById(commentId);
assertAll(
() -> assertThat(findComment).isPresent(),
() -> assertThat(findComment.get().getWriter()).isEqualTo("does"),
() -> assertThat(pushNotificationRepository.findAll()).hasSize(1)
);
}
알림 로직에 예외가 발생하는 상황 만들어보기
- 댓글 로직은 정상인데 알림을 저장하는 PushEventListener에 예외를 발생시켜 보자
- 댓글의 작성자(wirter)가 “ex”이면 예외가 발생하도록 코드를 수정했다.
@EventListener
@Transactional
public void handle(PushEvent event) {
Comment comment = (Comment)event.getEntity();
String writer = comment.getWriter();
validateWriter(writer);
String message = writer + "님이 댓글을 남겼습니다.";
PushNotification pushNotification = new PushNotification(message);
pushNotificationRepository.save(pushNotification);
}
private void validateWriter(String writer) {
if (writer.equals("ex")) {
throw new IllegalArgumentException("알림 로직에서 예외가 발생");
}
}
- 이렇게 하고 포스트맨으로 ex라는 wirter가 댓글을 작성해보자
- 예상했듯이 예외가 발생했다. 하지만 알림에 예외가 발생한 것이므로 작성한 댓글은 DB에 반영이 되어있어야 하는데 아래를 보면 댓글도 저장되지 않았다.
왜 댓글도 같이 롤백되었을까?
댓글 저장, 알림 저장 로직 흐름은 다음과 같다.
- CommentController.create() (댓글 저장 요청)
- → CommentRepository.save() (댓글 저장)
- →@Transactional PushEventListener.handle() (알림 이벤트 처리)
- →PushNotificationRepository.save() (알림 저장)
- → ApplicationEventPublisher.publishEvent() (알림 이벤트 등록)
- → @Transactional CommentService.create() (댓글 로직 수행)
- CommentService.save()에서 댓글 로직을 시작하면서 @Transactional로 트랜잭션을 시작하고 뒤에 알림을 저장하기 위해 알림 이벤트를 처리하며 또 트랜잭션을 시작한다.
- 이때 기본적으로 스프링 트랜잭션의 전파 속성이 기본적으로 이미 존재하는 트랜잭션이 있으면 거기에 참여하기 때문에 두 트랜잭션이 하나의 트랜잭션으로 묶이게 된다.
- 같은 트랜잭션이기 때문에 알림 이벤트를 핸들링하는 과정에 예외가 발생해도 댓글까지 모두 한 번에 롤백되어 버리는 것이다. (트랜잭션의 원자성)
테스트 클래스에서 @Transactional 문제 1
- 실제 포스트맨으로 찔렀을 때 모두 롤백되는 문제가 있는 것을 확인했으니 아래 테스트도 통과가 되면 안 된다.
- 알림에서 예외가 발생해서 알림은 DB에 반영되지 않아도 댓글은 DB에 존재해야 함을 확인하는 테스트다.
@DisplayName("알림에 예외가 발생해도 댓글은 저장된다.")
@Test
void exceptionNotification_createComment() {
// given
String writer = "ex";
String content = "댓글 내용입니다.";
// when
assertThatThrownBy(() -> commentService.create(writer, content))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("알림 로직에서 예외가 발생");
// then
Optional<Comment> findComment = commentRepository.findById(commentId);
assertAll(
() -> assertThat(findComment).isPresent(),
() -> assertThat(findComment.get().getWriter()).isEqualTo("ex"),
() -> assertThat(pushNotificationRepository.findAll()).hasSize(0)
);
}
- 하지만 테스트는 왜인지 통과한다! (
이게 왜 되지??) - 이는 테스트 클래스에 붙은 @Transactional 때문이다.
- 우리는 흔히 테스트들 간의 격리를 위해 DB 롤백을 위해 @Transactional를 주로 쓴다.
- 테스트 클래스에 붙은 @Transactional은 모든 로직을 수행하고 assert문까지 모두 검증한 뒤에 한 번에 변경된 데이터들을 모두 롤백시킨다.
- 즉 댓글이 저장되고 알림에 예외가 발생해서 댓글만 DB에 반영된 상태일 때 롤백이 바로 일어나는 것이 아니라, assert문들이 동작해서 테스트를 그린으로 만든 뒤에 롤백이 한 번에 일어나기 때문에 테스트가 통과하게 되는 것이다.
- 테스트 클래스에 @Transactional을 제거하자 예상하던 대로 테스트가 실패하는 것을 확인했다.
트랜잭션 분리하기
테스트 클래스에 @Transactional을 없앴을 때 방금 테스트를 통과시키도록 조치를 취해보자
@TransactionalEventListener
@Transactional(propagation=REQUIRES_NEW)
public void handle(PushEvent event) {
- @TransactionalEventListener 사용
- 이벤트를 발생 시킨 CommentService 트랜잭션이 커밋된 후에 이벤트를 받는 로직이 실행되도록 한다.
- propagation = Propagation.REQUIRES_NEW 자식 트랜잭션에 적용
- @TransactionalEventListener으로 커밋된 후에 알림 이벤트가 처리된다고 해도 트랜잭션 전파 설정이 REQURIED이면 커밋이 됐어도 하나의 트랜잭션이다. 커밋된 후의 트랜잭션에선 데이터 변경을 해도 DB에 반영되지 않는다.
- 때문에 새로운 트랜잭션을 생성하기 위해 전파 레벨을 Propagation.REQUIRES_NEW로 해줄 필요가 있다.
트랜잭션 분리 결과
- writer가 “ex”임에도 201 created가 응답으로 돌아온다.
- writer가 “ex”인 댓글은 저장되었다.
- 알림 이벤트를 처리하면서 예외가 발생했기에 알림은 저장되지 않았다.
- 테스트 클래스에 @Transactional 없이도 아래의 테스트가 통과하게 된다
@DisplayName("알림에 예외가 발생해도 댓글은 저장된다.")
@Test
void exceptionNotification_createComment() {
// given
String writer = "ex";
String content = "댓글 내용입니다.";
// when
commentService.create(writer, content);
// then
assertAll(
() -> assertThat(commentRepository.findAll().get(0).getWriter()).isEqualTo("ex"),
() -> assertThat(pushNotificationRepository.findAll()).hasSize(0)
);
}
(트랜잭션을 분리하자 commentService.create()에서 알림에 대한 예외가 발생하지 않아서 assertThatThrownBy를 제거했다.)
테스트 클래스에서의 @Transactinal 문제 2
- 테스트 클래스에 @Transactional 붙어 있으면 @TransactionalEventListener이 동작하지 않음
- @TransactionalEventListener은 직전 트랜잭션 커밋 후에 동작
- 운영 코드에서는 부모 트랜잭션이 이벤트를 발행한 클래스부터 (CommentService) 시작하지만 테스트 안에서는 테스트를 진행하는 메서드 (CommentServiceTest) 그 자체 트랜잭션이 부모 트랜잭션임
- (이벤트 발생 메서드(CommentService)는 전파 속성이 REQUIRED이기 때문)
- 때문에 테스트 클래스에 @Transactional이 붙어 있으면 @TransactionalEventListener가 붙은 클래스가 작동하지 않음
- 테스트 전체가 다 끝나야 커밋 또는 롤백이 일어나고 이후에 @TransactionalEventListener 메서드가 이벤트를 처리하기 때문
- 때문에 테스트 클래스에 @Transactional이 있으면 위의 "댓글을 저장하면 알림도 저장된다."테스트가 알림 이벤트가 처리되지 않아 DB에 알림이 0개가 되어 실패한다.
테스트 클래스에서의 @Transactinal 문제 3
- 트랜잭션 전파 레벨이 *REQUIRES_NEW*인 경우 테스트 @Transactional의 롤백이 먹히지 않는다.
- 데이터가 남아 있어 이후 테스트에서 테스트 격리가 되지 않는다.
- 부모 트랜잭션에서 롤백이 일어나도 자식 트랜잭션 전파 레벨이 *REQUIRES_NEW*이면 부모만 롤백된다.
- 댓글은 롤백되어도 알림은 롤백되지 않는 문제 발생 → 다른 테스트들에 영향을 줌
비동기 처리는 필요할까?
- 이벤트를 처리하는 리스너 메서드에 @Async를 붙이고, @EnableAsync를 @SpringBootApplication과 함께 붙여주면 알림 이벤트 처리를 비동기로 처리할 수 있다.
- 이렇게 하면 외부 api로 진짜 알림을 전송하는 로직까지 포함할 때 알림 전송 시간이 오리 걸려도 댓글에 대한 저장 시나리오 자체는 빠르게 일어난다.
- 비동기가 아닌 상태에서 알림 api가 네트워크 문제 등으로 시간이 오래 걸리면 사용자가 그만큼 기다려야 하지만 비동기로 처리하면 안 기다려도 된다.
결론
- @TransactionalEventListener와 propagation = Propagation.REQUIRES_NEW로 댓글과 알림의 트랜잭션을 분리할 수 있었다.
- @Async로 원한다면 비동기 처리 또한 할 수 있다.
- 하지만 위 코드를 추가해서 테스트 코드를 짜기 위해선 테스트 클래스의 @Transactional이 방해가 된다.
- 트랜잭션 분리를 적용하기 위해선 테스트에서 @Transactional을 제거하고 다른 방법으로 테스트 격리를 해야 할 필요가 있다.
'우아한테크코스 > 프로젝트-SMODY' 카테고리의 다른 글
운영 서버에서 예외 발생 시 자동 깃헙 이슈 생성 (0) | 2022.10.12 |
---|---|
[Spring] 동시성과 예외, 그리고 트랜잭션 (0) | 2022.10.01 |
[Spring] 알림 기능 비동기 처리하기 (0) | 2022.09.17 |
결합은 낮추고 전략은 다양하게, 알림 기능 적용기 (0) | 2022.08.15 |
[JPA] 조회 쿼리 N+1 문제 해결 (0) | 2022.07.10 |