본문 바로가기

프로젝트/오디

FCM 알림 비동기 + 이벤트 리스닝으로 리팩터링 하기 - 2편(테스트)

안녕하세요 브로콜리입니다.

 

지난 글에서는 동기화되어 있던 FCM 알림 로직을 비동기 + 이벤트 리스닝을 방식으로 리팩터링한 과정에 대해 소개해드렸습니다. 그 과정에서 1) 알림 로직과 비즈니스 로직간의 결합도 감소 2) latency 성능 개선 이라는 이점을 얻을 수 있었습니다.

 

그러나, 모든 구현의 완성은 테스트인만큼 비동기 + 이벤트 리스닝 방식을 어떻게 테스트할지 고민이 생겼습니다.


Situation : 비동기 + 이벤트 리스닝 방식을 테스트하자!

 

다시한번 상황을 짚어보겠습니다. 비동기 + 이벤트 리스닝 방식으로 개선된 알림 서비스는 다음과 같은 모습입니다.

1) 상위 모듈은 fcmEventPublisher를 통해 이벤트를 발행합니다.

2) 발행된 이벤트는 fcmEventListener를 통해 수신합니다.

3) fcmEventListener는 이벤트를 기반으로 fcmPushSender와 fcmSubscriber에게 처리를 위임합니다.

 


Task : 이벤트 발행 / 수신 + 예외 핸들링 테스트

개선된 구조를 반영해 검증하고 싶은 것들을 나열해보았습니다.

 

1) 이벤트 발행 테스트 : 상위 모듈에서 메서드를 실행했을 때, 이벤트가 발행되는가?
2) 이벤트 수신 테스트 :  특정 이벤트가 발행되었을 때, 리스닝 메서드가 호출되는가
3) 예외 핸들링 테스트 : 비동기 메서드에서 예외가 발생한다면, 예외 핸들러가 호출되는가

 


 

Action : ApplicationEvents + CountDownLatch를 활용한 테스트

 

Action-1) 이벤트 발행 테스트

상위 모듈에서 이벤트 발행 테스트를 진행하면서 2가지 고민이 생겼습니다.

 

1) 이벤트가 발행되었다는 사실을 어떻게 검증하나? > ApplicationEvents

실제로 내가 원하는 이벤트가 발행되었다는 사실을 검증하고 싶었습니다.

 

시도 : ApplicationEventPublisher를 @MockBean으로 > 주입이 안됨

그래서 처음에는 ApplicationEventPublisher를 @MockBean으로 처리하여 이벤트 발행 메서드를 호출하는지 검증하고자 했는데요. 모킹이 되지 않고 실제 ApplicationContext가 주입되는 문제가 있었습니다. 구글링을 해보니  ApplicationEventPublisher가 Bean이 아니라 Spring의 자체 컨텍스트이기 때문에 주입이 되지 않음을 확인했습니다. 

 

실제로 이 문제에 대한 이슈들도 존재했습니다. Spring은 공식 문서를 찾아보니 이벤트 발행을 검증하기 위해서는 @RecordApplicationEvents를 활용하는 것을 권장했습니다.

 


@RecordApplicationEvents란?

어플리케이션 이벤트의 발생을 기록하는 기능을 제공하는 어노테이션입니다.

 

공식 문서에 수록된 예제를 통해 활용방법을 이해하면 다음과 같습니다.

@SpringJUnitConfig(/* ... */)
@RecordApplicationEvents
class OrderServiceTests {

	@Autowired
	OrderService orderService;

	@Autowired
	ApplicationEvents events;

	@Test
	void submitOrder() {
		// Invoke method in OrderService that publishes an event
		orderService.submitOrder(new Order(/* ... */));
		// Verify that an OrderSubmitted event was published
		long numEvents = events.stream(OrderSubmitted.class).count();
		assertThat(numEvents).isEqualTo(1);
	}
}
1) 테스트 클래스 위에 @RecordApplicatinEvents를 붙입니다.
2) ApplicationEvents를 주입합니다.
3) ApplicationEvents API를 통해 특정 이벤트의 발행 수를 검증합니다.

 

이를 활용하여 발행 테스트를 작성할 수 있었습니다. 가령 재촉하기 메시지를 발송하는 알림 로직의 경우 재촉하기 이벤트의 발행 여부를 판단하기 위해 다음과 같은 테스트를 작성하였습니다.

@RecordApplicationEvents
public class NotificationServiceTest{

    @Autowired
    private ApplicationEvents applicationEvents;

    @DisplayName("재촉하기 이벤트가 발행된다")
    @Test
    void sendSendNudgeMessageMessage() {
        Meeting odyMeeting = fixtureGenerator.generateMeeting();
        Mate requestMate = fixtureGenerator.generateMate(odyMeeting);
        Mate nudgedMate = fixtureGenerator.generateMate(odyMeeting);

        notificationService.sendNudgeMessage(requestMate, nudgedMate);

        assertThat(applicationEvents.stream(NudgeEvent.class))
                .hasSize(1);
    }
}

 

주입한 applicationEvents을 이용해 NudgeEvent의 발행 여부를 검증할 수 있었습니다.

 


 

2) 비동기 메서드의 종료 시점을 어떻게 아는가?

 

테스트 스레드와 별개의 스레드에서 실행되는 비동기 메서드의 경우 결과를 확인하지 않습니다. 따라서 Mockito의 호출량을 검증할 때 비동기 메서드보다 테스트 스레드가 먼저 실행되어서 문제가 되는 경우들이 있었습니다. 

 

예를 들어 우리가 일반적으로 동기 / Block 메서드를 호출할 떄 테스트의 흐름은 다음과 같습니다.

 

즉, 가장 하위 메서드가 호출된 이후에 연쇄적으로 자신을 호출한 주체에게 완료되었음을 알리며 다시 테스트 스레드를 이어가게 되고, 이에 따라 구독이 완료된 시점에 검증을 정확히 할 수 있습니다.

 

그러나, 이벤트를 발행하고 수신하는 로직을 비동기로 처리하면 테스트는 다음과 같이 실행됩니다.

즉, 호출만 하고 결과값이 void인 경우 결과를 확인하지 않기 때문에 실제로 검증하고자 하는 비동기 메서드가 호출되기 전에 검증을 해버려 테스트가 통과하지 않는 경우가 생기는 것입니다.

 

시도 > Thread.sleep(1000)

처음에는 상위 모듈을 호출하고 Thread.sleep(1000)을 통해 1초 뒤에 검증을 실행하면서 테스트를 통과하게 하였습니다.

 

그러나, 당연히 이 방법이 좋지 않은 테스트라는 사실은 알고 있었습니다. 1초라는 시간이 임의로 설정한 값임과 동시에 네트워크 환경에 따라 비동기 메서드 완료까지 1초가 넘게 걸리면 통과여부를 예측하지 못하게 되기 때문입니다.

 

이러한 우려를 증명하듯 코드 리뷰 과정에서 카키가 정확히 같은 리뷰를 남겨주었고, CountDownLatch를 활용한 테스트 방식을 제안해주었습니다. (다시한번 감사합니다 카키..)

 

 

 

CountDownLatch란?

CountDownLatch는 자바에서 제공하는 동기화 도구로 여러 스레드가 특정 조건을 만족할 때까지 기다리는 기능을 제고압니다. 주요 개념은 다음과 같습니다.

 

1. 카운트 다운(CountDown) : 초기화시 지정된 카운트를 가지고 CountDownLatch를 초기화합니다. 이후에 스레드가 하나 완료되거나 작업을 끝낼 때 CountDown이 감소합니다. 혹은 countDown() 메서드를 통해 명시적으로 카운트 다운을 할수도 있습니다.

 

2. 대기(Wait) : await() 메서드를 호출하여 카운트가 0이 될때 까지 대기합니다. 카운트가 0이되면 대기중인 스레드의 실행을 계속합니다.

 

자세한 CountDownLatch에 대한 사용법은 밸덩 링크를 첨부하겠습니다. 

 

그럼 CountDownLatch를 활용하여 비동기 메서드의 종료 시점과 검증 로직을 일치하는 작업을 어떻게 해준 것일까요?

 

바로 비동기 메서드가 호출되었을 때의 행동을 mocking하여 countDown해주도록 지정해주는 것입니다.

 

예를 들어 약속에 참여하는 테스트에서는 토픽 구독 + 입장 알림 이렇게 두 가지 이벤트가 발행되고 비동기로 수신되는데요. 종료 시점을 일치시켜주기위해 다음과 같이 countDownLatch를 활용할 수 있습니다.

@Test
void sendUnSavedMessage() throws InterruptedException, FirebaseMessagingException {
    Meeting tenMinutesLaterMeeting = fixtureGenerator.generateMeeting(LocalDateTime.now().plusMinutes(10L));
    Member member = fixtureGenerator.generateMember();
    MateSaveRequestV2 mateSaveRequestV2 = dtoGenerator.generateMateSaveRequest(tenMinutesLaterMeeting);

    CountDownLatch countDownLatch = new CountDownLatch(2);
    Mockito.doAnswer(invocation -> {
        countDownLatch.countDown();
        return null;

    }).when(firebaseMessaging).send(any(Message.class));

    RestAssured.given().log().all()
            .contentType(ContentType.JSON)
            .header(HttpHeaders.AUTHORIZATION, fixtureGenerator.generateAccessTokenValueByMember(member))
            .body(mateSaveRequestV2)
            .when()
            .post("/v2/mates")
            .then()
            .statusCode(201);

    countDownLatch.await(3L, TimeUnit.SECONDS);

    verify(firebaseMessaging, times(2))
            .send(any(Message.class));
}

 

이렇게 지정해주면 비동기 메서드의 호출과 동시에 초기화한 카운트 값이 하나씩 감소하게 됩니다. 그리고 두번째 비동기 메서드 호출과 동시에 카운트가 0이 될 때 다시 테스트 스레드의 로직을 이어나갑니다. 따라서 네트워크 환경과 무관하게 어디서든 일관적인 테스트 검증이 가능하게 됩니다.

 


Action-2) 이벤트 수신 테스트

이벤트 수신 테스트는 상대적으로 간단했습니다.

1) 특정 이벤트를 모킹하여 발행하고,

2) 모킹한 이벤트 리스너의 메서드가 호출되는지 행위 검증을 해주었습니다.

class FcmEventListenerTest extends BaseServiceTest {

    @Autowired
    private FcmEventPublisher eventPublisher;

    @DisplayName("SubscribeEvent 발생 시, 특정 주제 구독 로직을 실행한다")
    @Test
    void subscribeTopic() {
        SubscribeEvent subscribeEvent = mock(SubscribeEvent.class);

        eventPublisher.publish(subscribeEvent);

        verify(fcmEventListener, times(1)).subscribeTopic(eq(subscribeEvent));
    }

    @DisplayName("UnsubscribeEvent 발생 시, 주제 구독 해제 로직을 실행한다")
    @Test
    void unSubscribeTopic() {
        UnSubscribeEvent unSubscribeEvent = mock(UnSubscribeEvent.class);

        eventPublisher.publish(unSubscribeEvent);

        verify(fcmEventListener, times(1)).unSubscribeTopic(eq(unSubscribeEvent));
    }

    @DisplayName("NoticeEvent 발생 시, 공지 알림 발송 로직을 실행한다")
    @Test
    void sendNoticeMessage() {
        NoticeEvent noticeEvent = mock(NoticeEvent.class);

        eventPublisher.publish(noticeEvent);

        verify(fcmEventListener, times(1)).sendNoticeMessage(eq(noticeEvent));
    }

    @DisplayName("PushEvent 발생 + 트랜잭션 커밋 이후, 푸시 알림 발송 로직을 실행한다")
    @Test
    void sendPushMessage() {
        PushEvent pushEvent = mock(PushEvent.class);

        eventPublisher.publishWithTransaction(pushEvent);

        verify(fcmEventListener, times(1)).sendPushMessage(eq(pushEvent));
    }

    @DisplayName("트랜잭션이 열리지 않으면, 푸시 알림 발송 로직이 실행되지 않는다")
    @Test
    void notEventTriggerWhenTransactionNotOpen() {
        PushEvent pushEvent = mock(PushEvent.class);

        eventPublisher.publish(pushEvent);

        verifyNoInteractions(fcmEventListener);
    }

    @DisplayName("NudgeEvent 발생 시, 넛지 알림 발송 로직을 실행한다")
    @Test
    void sendNudgeMessage() {
        NudgeEvent nudgeEvent = mock(NudgeEvent.class);

        eventPublisher.publish(nudgeEvent);

        verify(fcmEventListener, times(1)).sendNudgeMessage(eq(nudgeEvent));
    }
}

Action-3) 에러 핸들러 호출 테스트

에러 핸들러 호출 테스트는 다음 순서를 따라 검증하였습니다.

1) AsyncExceptionHandler를 @MockBean으로 등록
2) 비동기 메서드를 호출 시, 에러를 발생하도록 stubbing
3) 에러 발생시 mocking한 에러 핸들러가 호출되는지 검증

 

class AsyncExceptionHandlerTest extends BaseControllerTest {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @MockBean
    private FcmSubscriber fcmSubscriber;

    @MockBean
    private AsyncExceptionHandler asyncExceptionHandler;

    @DisplayName("비동기 메서드에서 발생한 에러를 핸들링한다")
    @Test
    void handleUncaughtException() throws InterruptedException {
        ApplicationEvent subscribeEvent = mock(SubscribeEvent.class);
        Exception stubException = new RuntimeException();
        CountDownLatch countDownLatch = new CountDownLatch(1);

        doThrow(stubException)
                .when(fcmSubscriber)
                .subscribeTopic(any(), any());

        doAnswer(invocation -> {
            countDownLatch.countDown();
            return null;
        }).when(asyncExceptionHandler).handleUncaughtException(any(), any(), any(), any());

        eventPublisher.publishEvent(subscribeEvent);
        countDownLatch.await(3L, TimeUnit.SECONDS);

        verify(asyncExceptionHandler, times(1))
                .handleUncaughtException(any(), any(), eq(subscribeEvent));
    }
}

Results : 테스트를 통한 확신 얻기

 

다시한번 태스크를 정리해보면 다음과 같습니다.

1) 이벤트 발행 테스트 : 상위 모듈에서 메서드를 실행했을 때, 이벤트가 발행되는가?
2) 이벤트 수신 테스트 :  특정 이벤트가 발행되었을 때, 리스닝 메서드가 호출되는가
3) 예외 핸들링 테스트 : 비동기 메서드에서 예외가 발생한다면, 예외 핸들러가 호출되는가

 

개발의 끝은 확신이라는 믿음이 있기에 머릿속으로 상상했던 로직들이 실제로 동작하는 모습을 보니 기능 구현에 대한 확신을 얻을 수 있었습니다.

 

특히 이벤트 발행 테스트의 경우 @RecordApplicationEvents + ApplicationEvents를통해 실제 ApplicationContext에서 발행된 이벤트를 검증할 수 있었으며, 비동기 메서드가 호출될 때까지 스레드를 기다릭 해주는 CountDownLatch를 활용해 조금 더 깊은 범위의 테스트 검증을 수행할 수 있었습니다.