[Springboot] 외부 API 서버는 mocking하여 테스트해야 한다.

유아 Yooa·2023년 7월 30일
1

Spring

목록 보기
15/18
post-thumbnail

Overview

어플리케이션을 개발할 때 외부 API를 사용하는 것은 드문 일이라고 생각한다. 예를 들어 소셜 로그인을 구현한다거나, 데이터를 받아와서 활용해야 한다거나 등 다양한 케이스가 있기 때문이다.

필자는 기상청 Open API와 통신해 데이터를 예쁘게 가공하여 클라이언트 측으로 반환하는 기능을 구현해야 했고, 외부 API 사용 로직을 작성하게 되었다.

외부 서버를 mocking 하는 이유

외부 서버는 우리가 제어할 수 있는 대상이 아니기에 그 서버를 mocking 하고 우리 서비스의 로직은 정확히 동작한다는 것을 테스트로 검증해야 한다.

여전히 왜 그래야 하는데?🤷라는 물음이 지워지지 않는다면 나의 사례로 이해해 보자.

직접 외부 API 서버와 통신하기

외부 API 로직을 처음 작성하고 이를 테스트할 때, 가장 단순한 방법은 직접 서버와 통신해 보는 것이었다.

@Test
@DisplayName("초단기 실황 조회 API 통신 테스트")
public void callUltraSrtNcstAPITest() {
	// given
    LatXLngY grid = CoordinateUtil.convertGRID_GPS(X, Y);
    int baseX = (int)grid.x;
    int baseY = (int)grid.y;
        
    LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
    String baseDate = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
    String baseTime = now.format(DateTimeFormatter.ofPattern("HH00"));

    // when
    FcstAPICommonListResponse list = vilageFcstAPIService.getNowCast(baseX, baseY, baseDate, baseTime);

    // then
    assertThat(response.getFcstAPICommonItemResponseList().size()).isEqualTo(8);
}

위와 같은 방법은 가장 쉽고 직관적이지만 큰 문제를 가지고 있다.

1. 외부 서버의 반환 값이 실시간으로 바뀐다.

의도한 대로 기능이 동작하는지를 검증해 주어야 하는데 반환 리스트의 사이즈 밖에 확인을 못해주고 있다.
요청 파라미터 중 baseTime과 baseDate에 따라 실시간으로 다른 값이 오기에 적당한 검증 방법 탐색이 어려웠다. 외부 서버의 값이 바뀔 때마다 테스트 코드를 변경해 줄 수도 없는 노릇이다.

2. 외부 서버가 항상 정상적으로 운영될 거라는 보장이 없다.

기상청 서버로 데이터를 요청하는 로직인데, 만약 기상청 서버가 제대로 동작하지 않는다면 우리의 테스트로 실패하게 된다.

저 코드를 작성했을 당시에는 문제를 인식하지 못했고, '이 정도면 완전 잘 짰군ㅋㅋ'이라는 착각 속에 PR을 날렸다. 😔 분명 로컬에서 멀쩡하게 작동하던 테스트 코드가 Github Action을 활용한 CI 과정에서 테스트를 실패했다.

java.lang.IllegalStateException: Timeout on blocking read for 5000000000 NANOSECONDS
...

로그를 살펴보니 통신 과정에서 timeout이 되었다는 오류였다. 정확한 원인 파악은 못했지만 추측으로는 Github Action 상에서 테스트가 진행되며 환경이 다르기에 통신 속도가 느려졌다는 것 같았다.

@Bean
    DataGoKrApi dataGoKrApi() {

        WebClient webClient = WebClient.builder()
                .baseUrl(dataGoKrBaseUrl)
                .clientConnector(new ReactorClientHttpConnector(httpClient()))
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        return HttpServiceProxyFactory
                .builder(WebClientAdapter.forClient(webClient))
                .blockTimeout(Duration.ofMillis(5000))
                .build()
                .createClient(DataGoKrApi.class);
    }

로그에 찍힌 5000000000 NANOSECONDSHttpServiceProxy에 설정해 준 blockTimeout 값인 듯 했다.

결국 시간을 더 늘려보았고 결론적으로 [5-> 7-> 10-> 30]까지 설정을 변경하게 되었다. 30초로 설정하니 동작했다.

그러나.. 실제 환경이라고 생각했을 때, 외부 API 서버가 다운되어도 통신에 30초를 기다려야 하는 최악의 상황이 발생할 수 있다.

설상가상 타임아웃 시간을 늘려도 또 다른 오류가 발생했다.

org.springframework.web.reactive.function.client.WebClientRequestException
	at app//org.springframework.web.reactive.function.client.ExchangeFunctions$DefaultExchangeFunction.lambda$wrapException$9(ExchangeFunctions.java:136)
	Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below: 
Error has been observed at the following site(s):
	*__checkpoint ⇢ Request to GET http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst?serviceKey={serviceKey}&dataType=json&nx=60&ny=127&base_date=20230726&base_time=2000&pageNo=1&numOfRows=60 [DefaultWebClient]

끝내 정확한 원인을 파악하진 못했지만(혹시 이 오류에 대해 아시는 분은 댓글을.. 부탁드립니다🙇‍♀️)
URL에 대한 GET 요청 시 예외가 발생했다는 것은 파악할 수 있었다. 이 역시도 로컬에서는 정상적으로 테스트가 동작되었지만 깃허브 액션상에서만 발생하는 예외였다.

아무래도 이건 아니다 싶었고, 외부 API를 사용하는 로직에서는 통상적으로 서버를 Mocking 한다는 것을 알게 된다.

바로 mocking 하는 방식으로 수정하기로 결단을 내리게 된다.


외부 서버를 mocking 하다.

필자의 사례를 통해 보았듯이, 우리의 서비스 동작에 대한 테스트 결과가 외부 환경에 의존하여 바뀔 수 있다는 것은 바람직하지 못하다. 테스트는 내가 예측한 대로 동작이 되어야 하는데 그렇지 못하기 때문이다.

그래서 이에 대한 의존도를 낮추기 위해서 서버와의 통신을 Mocking하는 것이 좋은 대안이 된다고 한다. 이를 통하여 나의 비즈니스 로직에만 집중하여 테스트할 수 있고, 외부 API 서버가 다운되더라도 관계없이 언제든 일관된 테스트가 가능하다.

Controller 단위 테스트에서 Service를 Mocking하고 테스트를 진행하는 외부 서버도 Mocking하여 우리의 비즈니스 로직만을 테스트하고자 하는 맥락이라 생각하면 쉽다고.👀
출처 - https://velog.io/@kyle/%EC%99%B8%EB%B6%80-API%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%A0-%EA%B2%83%EC%9D%B8%EA%B0%80

외부 서버를 Mocking 하는 방법은 여러 가지가 있다. 그러므로 자신에게 맞는 방법을 선택하고 사용해야 한다.
(해당 포스팅 하단 [ref]에 여러 가지 케이스의 mocking 방식 레퍼를 달아놓았으니 참고해도 좋을 듯하다.)

나의 경우는 WebClientHttpServiceProxy 객체로 만들어 요청을 인터페이스 방식으로 구현했다. 그렇기에 인터페이스의 응답 값(외부 서버의 응답 값)을 mocking 해주는 방식을 사용했다.

@MockBean
private DataGoKrApi dataGoKrApi;
    
@BeforeEach
public void before() {
	ultraSrtNcstAPIRes = MockResponse.testNcstRes();

    objectMapper = new ObjectMapper();

    CoordinateUtil.LatXLngY grid = CoordinateUtil.convertGRID_GPS(MockRequest.X, MockRequest.Y);
    this.baseX = (int)grid.x;
    this.baseY = (int)grid.y;
}

@Test
@DisplayName("초단기 실황 조회 API 통신 테스트")
public void callUltraSrtNcstAPITest() throws JsonProcessingException {
	// given
	String baseDate = "20230726";
	String baseTime = "0100";

	// dataGoKrApi.getNowForecastInfo Mocking
	String apiResponse = objectMapper.writeValueAsString(ultraSrtNcstAPIRes);
	when(dataGoKrApi.getNowForecastInfo(serviceKey, dataType, baseX, baseY,
                baseDate, baseTime, BASE_PAGE, NCST_PAGE_SIZE))
	.thenReturn(apiResponse);

	FcstAPICommonListResponse list = vilageFcstAPIService.getNowCast(baseX, baseY, baseDate, baseTime);

	// then
	for(FcstAPICommonItemResponse item : list.getFcstAPICommonItemResponseList()) {

	assertThat(item.getBaseDate()).isEqualTo(baseDate);
    assertThat(item.getBaseTime()).isEqualTo(baseTime);
    
    switch(item.getCategory()) {
    case "PTY" ->  assertThat(item.getObsrValue()).isEqualTo("0");
    case "REH" ->  assertThat(item.getObsrValue()).isEqualTo("89");
    case "RN1" ->  assertThat(item.getObsrValue()).isEqualTo("0");
    case "T1H" ->  assertThat(item.getObsrValue()).isEqualTo("26.1");
    case "UUU" ->  assertThat(item.getObsrValue()).isEqualTo("1");
    case "VEC" ->  assertThat(item.getObsrValue()).isEqualTo("205");
    case "VVV" ->  assertThat(item.getObsrValue()).isEqualTo("2.1");
    case "WSD" ->  assertThat(item.getObsrValue()).isEqualTo("2.3");
    }
}

외부 서버의 값을 Mocking 해주면서 외부 서버의 상태와 관계없이 일관된 검증이 가능한 테스트를 작성했다. 또한 Github Action 상에서 통신이 지연되거나 오류가 발생하는 상황도 방지할 수 있었다.


ref

profile
기록이 주는 즐거움

1개의 댓글

comment-user-thumbnail
2023년 7월 30일

잘 읽었습니다. 좋은 정보 감사드립니다.

답글 달기