Spring

[kerdy] EventListener & TransactionalEventListener를 통해 문제 해결해보기 (적용편)

자바생 2023. 8. 20. 21:42
728x90

글을 쓰게 된 이유

 

이 글은 (1) 편과 이어집니다.

(1) 편은 예제를 통해서 적용해 보았고,

(2) 편은 이제 실제 커디 프로젝트에 적용해보려고 합니다.

 

 

이 글은 이제 팀원들을 이해시키기 위해서 작성해보려고 합니다.

그래서 (1) 편과 중복된 얘기가 있을 수 있습니다! 감안하고 봐주시면 감사하겠습니다.

 

 

 

기준 테스트

 

 

@Test
@DisplayName("publish(Comment) : 댓글 이벤트가 성공적으로 발행되면 UpdateNotification이 성공적으로 저장될 수 있다.")
void test_publish_comment() throws Exception {
  //given
  final Event event = eventRepository.save(EventFixture.인프콘_2023());
  final Member 댓글_작성자1 = memberRepository.findById(1L).get();
  final Comment 부모_댓글 = commentRepository.save(Comment.createRoot(event, 댓글_작성자1, "내용1"));
  final Member 댓글_작성자2 = memberRepository.save(new Member(13444L, "image", "username"));

  doNothing().when(firebaseCloudMessageClient).sendMessageTo(any(UpdateNotification.class));

  final CommentAddRequest 알림_트리거_댓글_요청 =
      new CommentAddRequest("내용2", event.getId(), 부모_댓글.getId());

  //when
  commentCommandService.create(알림_트리거_댓글_요청, 댓글_작성자2);

  //then
  assertAll(
      () -> verify(firebaseCloudMessageClient, times(1)).sendMessageTo(any(UpdateNotification.class)),
      () -> assertEquals(1, updateNotificationRepository.findAll().size())
  );
}

 

 

기준 테스트는 위와 같습니다.

 

 

댓글을 생성하고, 이벤트를 발행하고, 구독하고, 실제 파이어베이스에 메시지를 보내는 클래스를 모킹 하였습니다.

 

 

 

기존 코드

 

 

public CommentResponse create(
    final CommentAddRequest commentAddRequest,
    final Member member
) {
  final Event savedEvent = eventRepository.findById(commentAddRequest.getEventId())
      .orElseThrow(() -> new EventException(EventExceptionType.NOT_FOUND_EVENT));
  final String content = commentAddRequest.getContent();

  final Comment comment = commentAddRequest.optionalParentId()
      .map(commentId -> Comment.createChild(
          savedEvent,
          findSavedComment(commentId),
          member,
          content)
      )
      .orElseGet(() -> Comment.createRoot(savedEvent, member, content));

  final Comment savedComment = commentRepository.save(comment);

  eventPublisher.publish(savedComment, member); // 1

  return CommentResponse.from(savedComment);
}

 

 

먼저 댓글을 생성하는 코드입니다.

 

 

 

댓글을 작성하고 나서 (1)을 통해 ‘댓글 생성’을 알립니다.

아직 이벤트를 발행하지는 않은 상태입니다.

 

 

public void publish(final Comment trigger, final Member loginMember) {
  final Set<Comment> notificationCommentCandidates = trigger.getParent()
      .map(parent -> findRelatedCommentsExcludingLoginMember(loginMember, parent))
      .orElse(Collections.emptySet());

  notificationCommentCandidates.stream()
      .map(it -> UpdateNotificationEvent.of(it, trigger.getId()))
      .forEach(applicationEventPublisher::publishEvent); // 1
}

 

알림 요구사항에 맞게 로직을 작성한 뒤 (1)에서 ‘댓글 생성 알림을 발행’합니다.

 

 

@EventListener
public void createUpdateNotification(final UpdateNotificationEvent updateNotificationEvent) {
  final UpdateNotification updateNotification = new UpdateNotification(
      updateNotificationEvent.getReceiverId(),
      updateNotificationEvent.getRedirectId(),
      UpdateNotificationType.from(updateNotificationEvent.getUpdateNotificationType()),
      updateNotificationEvent.getCreatedAt()
  );

  final UpdateNotification savedNotification =
      updateNotificationRepository.save(updateNotification);

  firebaseCloudMessageClient.sendMessageTo(savedNotification); // 1
}

 

 

그리고 Listener를 통해서 이벤트를 listen 하여 (1)을 통해 파이어베이스에 알림을 보내도록 합니다.

 

 

 

 

문제 1

 

여기서 만약에 댓글을 생성하는 중에 exception이 발생하면 어떻게 될까요?

 

public CommentResponse create(
    final CommentAddRequest commentAddRequest,
    final Member member
) {
  // 코드 중복 ...

  System.out.println("댓글 생성 이벤트 publish!");
  
  eventPublisher.publish(savedComment, member);

  throwException();

  return CommentResponse.from(savedComment);
}

@EventListener
public void createUpdateNotification(final UpdateNotificationEvent updateNotificationEvent) {
  // 코드 중복 ...

  System.out.println("알림 발송");

  final UpdateNotification savedNotification =
      updateNotificationRepository.save(updateNotification);

  firebaseCloudMessageClient.sendMessageTo(savedNotification);
}

 

 

테스트를 돌려보겠습니다.

 

 

댓글 저장에는 실패했지만(exception 발생으로 롤백), 알림이 성공적으로 발송되는 것을 알 수 있습니다.

즉, 사용자 입장에서는 알림이 왔지만 실제 가보니 댓글이 존재하지 않는 것이지요.

 

 

그러면 우리는 어떻게 해결할 수 있을까요?

 

 

댓글이 성공적으로 저장된 다음에 알림을 보내야 합니다.

 

 

 

바로 @TransactionalEventListener를 사용해서 말이죠

 

 

 

@TransactionalEventListener 사용

 

 

@TransactionalEventListener
public void createUpdateNotification(final UpdateNotificationEvent updateNotificationEvent) {
  final UpdateNotification updateNotification = new UpdateNotification(
      updateNotificationEvent.getReceiverId(),
      updateNotificationEvent.getRedirectId(),
      UpdateNotificationType.from(updateNotificationEvent.getUpdateNotificationType()),
      updateNotificationEvent.getCreatedAt()
  );

  System.out.println("알림 발송");

  final UpdateNotification savedNotification =
      updateNotificationRepository.save(updateNotification);

  firebaseCloudMessageClient.sendMessageTo(savedNotification);
}

 

 

 

@TransactinoalEventListener를 사용하면서 이전 (1) 편에서의 설명과 같이 커밋이 되지 않았으니 event 자체가 listen 되지 않고 삭제되는 것을 알 수 있습니다.

 

 

그렇다면 이제 댓글 생성에서 문제가 생긴다면 알림이 가지 않는 것을 해결했습니다.

 

 

문제 2

 

 

이제 두 번째로 댓글 생성, 이벤트까지 제대로 수행됐는데 파이어베이스의 문제로 인해 알림이 발생하지 않는다면 어떻게 될까요?

 

파이어베이스는 우리가 제어할 수 없으므로 try-catch 문을 통해 에러 로그만을 남기고,

정상적으로 수행한 뒤, isSend라는 칼럼을 통해 확인할 수 있습니다.

 

다시 순서로 정리해 볼게요.

  1. try-catch를 통해 exception 발생 시 에러 로그 남김
  2. 에러 로그를 남기고 알림을 저장
  3. 알림이 제대로 발송되면 isSend라는 컬럼을 사용하여 확인
  4. isSend 가 false인 알림은 배치를 통해 다시 파이어베이스에 요청

이때 알림이 제대로 발송됐는지는 안드 분들이 확인해 주실 수 있기 때문에 3번 뒤 과정은 다음에 적용해 보도록 하겠습니다.

 

 

간단하게 설명하자면 UpdateNotification에 isSend라는 칼럼을 만든 뒤, isSend 상태를 변경시키는 API를 하나 만들어둡니다.

 

안드 분들이 제대로 알림이 도착했다면 id와 함께 만들어둔 API를 찌르게 된다면 isSend는 true로 변경되고, 배치로 다시 알림을 보내지 않게 됩니다.

 

다시 본론으로 넘어가서 try-catch를 통해 exception을 핸들링할게요.

 

 

 

 

파이어베이스 문제 X

 

파이어베이스에 문제가 생기지 않고 정상적은 흐름으로 갈 때의 예시를 먼저 들어볼게요.

 

 

propagation 설정

 

이벤트 발송도 잘됐는데 왜 UpdateNotification 이 제대로 저장되지 않았을까요?

 

 

이것 또한 1편에서 말한 @TransactinoalEventListener 때문입니다.

 

 

그러면 이제 propagation을 설정해 두겠습니다.

 

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
public void createUpdateNotification(final UpdateNotificationEvent updateNotificationEvent) {
  
	// 중복 코드 ...

  System.out.println("알림 발송");

  final UpdateNotification savedNotification =
      updateNotificationRepository.save(updateNotification);

  try {
    firebaseCloudMessageClient.sendMessageTo(savedNotification);
  } catch (Exception e) {
    log.error("파이어베이스 관련 에러, 알림 재요청 필요, {}", e.getMessage(), e);
  }

 

 

 

 

아까와는 다르게 insert 쿼리가 나가게 되고, REQUIRES_NEW propagation으로 새로운 트랜잭션도 만들어지는 것을 알 수 있습니다.

 

 

그래서 테스트가 통과하죠.

 

 

 

 

파이어베이스에 문제가 생길 경우

 

 

이번에는 새로운 테스트로 수행해 볼게요.

 

 

파이어베이스에 메시지를 보내는 메서드에서 Exception을 발생하도록요.

 

 

@Test
@DisplayName("publish(Comment) : 파이어베이스에 에러가 생기면 UpdateNotification이 성공적으로 저장될 수 있다.")
void test_publish_comment_error_firebase() throws Exception {
  //given
  final Event event = eventRepository.save(EventFixture.인프콘_2023());
  final Member 댓글_작성자1 = memberRepository.findById(1L).get();
  final Comment 부모_댓글 = commentRepository.save(Comment.createRoot(event, 댓글_작성자1, "내용1"));
  final Member 댓글_작성자2 = memberRepository.save(new Member(13444L, "image", "username"));

	// exception 발생
  doThrow(new IllegalArgumentException("파이어베이스 에러"))
      .when(firebaseCloudMessageClient).sendMessageTo(any(UpdateNotification.class));

  final CommentAddRequest 알림_트리거_댓글_요청 =
      new CommentAddRequest("내용2", event.getId(), 부모_댓글.getId());

  //when
  commentCommandService.create(알림_트리거_댓글_요청, 댓글_작성자2);

  //then
  assertAll(
      () -> verify(firebaseCloudMessageClient, times(1)).sendMessageTo(any(UpdateNotification.class)),
      () -> assertEquals(1, updateNotificationRepository.findAll().size())
  );
}

 

 

 

 

에러는 로깅이 되면서, 기존에 작성했던 테스트, 즉, 알림은 성공적으로 저장되는 것을 알 수 있습니다.

 

 

우리는 이제 파이어베이스가 잘못되어도 알림이 성공적으로 저장이 되고, 이를 핸들링할 수 있게 됐습니다.

 

 

이제 isSend 칼럼을 통해 알림을 받았는지 확인하고, 배치를 통해 알림을 재요청하는 스케줄러만 만들면 될 것 같습니다.

 

 

결론 

 

 

이번 글을 통해서 예제를 공부해 보고 실제 프로젝트에 적용해 보았습니다.

 

 

먼저 예제 코드를 작성해 보고 실제 프로젝트에 적용해 보니 시간이 많이 줄었지만,

실제로 적용하는 것은 예제 코드랑 확실히 다른 것을 알 수 있었습니다.

 

 

특히 테스트를 작성할 때 머리가 아팠습니다.

 

 

실제 API 요청을 날렸을 때, 어느 곳에서 트랜잭션을 시작하는지, 이벤트가 어디에서 시작하여 어디에서 끝나는지 확실하게 해야 했기 때문이죠,,

 

 

그래서 댓글 생성 - 파이어베이스 알림 보내는 로직을 통합 테스트해야 했고,

실제 알림을 보낼 수 없기 때문에 모킹을 해야 하죠.

 

 

댓글이 생성되는 CommentCommandService에서

이벤트가 실제로 listen 하는 것까지 테스트를 했고,

실제 메시지를 보내는 firebaseCloudMessageClient를 모킹 하여 Exception 발생할 때, 안 할 때 성공적으로 테스트를 작성할 수 있었습니다.

 

728x90