[모두의 랜덤 디펜스] WebClient Mocking하기 (ExchangeFunction Stubbing)

2024. 4. 9. 17:54Backend/Spring

Meta: WebClient, Mocking, ExchangeFunction, RestTemplate, Spring Boot, MockWebServer, API Testing, Reactive Programming, Mono, ClientResponse

이 글에서는 Spring Boot에서 WebClient를 Mocking하는 방법에 대해 설명하고, MockWebServer와 ExchangeFunction을 사용한 두 가지 접근 방식을 소개합니다.

항상 RestTemplate만 사용하다, 이번 프로젝트를 진행하면서 Http Client를 WebClient로 변경했습니다.

 

비즈니스 로직 중 직접 만든 lambda를 호출하여 응답을 얻는 부분이 존재하는데, 외부 API를 호출하는 부분을 Mocking하여 테스트코드를 구성하고자 했습니다.  

 

외부 API를 mocking해야하는 이유

외부 API를 직접 호출해서 테스트 하면 더욱 신뢰성 있는 테스트가 될 수 있지않나? 라고 생각할 수도 있지만

 

1. 외부 서비스의 가용성에 따라 테스트 코드의 성공/실패 여부가 결정될 수 있다.

2. 외부 API호출로 테스트 시간이 증가한다.

 

위의 이유로 외부 API를 mocking하는 것이 중요합니다.

 

이제 아래에서 WebClient를 Mocking하는 두 가지 방법에 관해 알아보겠습니다!

 

MockWebServer

WebClient의 요청을 mocking하는 첫 번째 방법은 MockWebServer를 사용하는 방법입니다.

 

MockWebServer를 사용함으로써, 실제 네트워크 호출 없이 HTTP 요청과 응답을 제어하고 검증할 수 있습니다.

 

import org.springframework.web.reactive.function.client.WebClient;

public class UserService {
    private final WebClient webClient;

    public UserService(WebClient.Builder webClientBuilder, String baseUrl) {
        this.webClient = webClientBuilder.baseUrl(baseUrl).build();
    }

    public String getUserInfo(String userId) {
        return webClient.get()
                .uri("/users/{userId}", userId)
                .retrieve()
                .bodyToMono(String.class)
                .block();
    }
}

 

public class UserServiceTest {
    private MockWebServer mockWebServer;
    private UserService userService;

    @BeforeEach
    void setUp() throws Exception {
        mockWebServer = new MockWebServer();
        mockWebServer.start();
        userService = new UserService(WebClient.builder(), mockWebServer.url("/").toString());
    }

    @AfterEach
    void tearDown() throws Exception {
        mockWebServer.shutdown();
    }

    @Test
    void testGetUserInfo() {
        String userId = "user123";
        String expectedJson = "{\"userId\":\"user123\",\"name\":\"example\"}";

        mockWebServer.enqueue(new MockResponse()
                .setBody(expectedJson)
                .addHeader("Content-Type", "application/json"));

        // when
        String result = userService.getUserInfo(userId);

        // then
        assertThat(result)
                .isEqualTo(expectedJson);
    }

    @Test
    void testGetUserInfoWithDifferentUserId() {
        String anotherUserId = "user456";
        String expectedJsonForAnotherUser = "{\"userId\":\"user456\",\"name\":\"example\"}";

        mockWebServer.enqueue(new MockResponse()
                .setBody(expectedJsonForAnotherUser)
                .addHeader("Content-Type", "application/json"));

        // when
        String result = userService.getUserInfo(anotherUserId);

        // then
        assertThat(result)
                .isEqualTo(expectedJsonForAnotherUser);
    }

 

 

 

mockWebServer.url("/").toString()은 내부적으로

임의의 Url이 들어가있고, WebClinet가 이 곳으로 요청을 보내면 그 요청이 mocking되어

stubbing된 응답이 반환됩니다.

 

이렇게 하면 외부 API를 호출하지 않고도 테스트를 실행할 수 있지만, 

 

추가 의존성이 필요하게되고,  동적으로 baseUrl을 주입받는 서비스의 구조가 아니라면 적용하기 힘들었습니다.

 

 

모두의 랜덤 디펜스에서도 baseUrl을 private static final String으로 정의하고 있어 위 방법을 사용할 수 없었습니다.

 

ExchangeFunction Mocking하기 

 

두 번째 방법은 WebClient의 ExchangeFunction을 mocking하는 방법입니다.

 

 

WebClient에서 요청을 보내고 응답을 받는 retrieve()메소드에서는 내부적으로 ExchangeFunction을 이용하여 HTTP 요청을 전송하고 응답을 받습니다.

 


retrieve() 메소드를 호출하면,

WebClient의

ExchangeFunction의 exchange 메소드를 호출하게 되고

 

ExchangeFunction의 exchange() 메소드 내에서 하는 행위를 간단하게 정리하면

1. ClientHttpRequest를 생성하여 서버로 보내고,

2. 서버로부터 ClientHttpResponse를 받습니다. [ClientHttpResponse에는 상태 코드, 헤더, 응답 본문 등 HTTP 응답의 모든 정보가 포함되어 있습니다.]

3. 이후 ClientHttpResponse를 Mono<ClientResponse>로 바꾸어 반환합니다.

 

따라서 ExchangeFunction의 exchange()를 stubbing하면 원하는 동작을 만들어낼 수 있습니다.

 


WebClient에서 요청/응답이 진행되는 부분 살펴보기

DefaultWebClient의 retrieve() 메소드는 

 

위 DefaultWebClient의 exchange() 메소드의

Mono<ClientResponse> responseMono = ((ExchangeFilterFunction)filterFunction).apply(DefaultWebClient.this.exchangeFunction)
			.exchange(request)
            .checkpoint("Request to " + WebClientUtils.getRequestDescription(request.method(), request.url()) + " [DefaultWebClient]")
            .switchIfEmpty(DefaultWebClient.NO_HTTP_CLIENT_RESPONSE_ERROR);

 

위 부분에서 요청을 보내고 응답을 받습니다.

 

ExchangeFunction 추상 클래스의 구현체인 ExchangeFunctions의 exchange메소드의 구현을 살펴보면

 

요청을 보낸 후

ClientResponse를 wrapping하는 Mono<ClientResponse>를 반환합니다.

 

 

(ClientResponse는 일반적으로 많이 본 Http 응답의 형태를 띠고 있습니다.)

 

따라서 정리해보면 응답을 주고받는 핵심 부분은 ExchangeFunction의 exchange 메소드입니다.

 

ExchangeFunction의 exchange 메소드 내에서 Request를 만들어 외부에 요청하고, 응답을 받아와 Mono<ClientResponse>를 반환합니다. 

 

DefaultWebClient의 retrieve에서 결국 ExchangeFunction의 exchange메소드 응답 결과를 통해 

 

WebClient의 ResponseSpec을 상속하는

DefaultResponseSpec을 반환합니다.

 

이 Spec에서 bodyToMono나 bodyToFlux를 통해 

 

response.bodyToMono나 bodyToFlux를 요청하고

원하는 타입으로 역직렬화합니다.

 

사실 어떤 역할을 하는지에만 집중했고, 역직렬화 라이브러리는 Jackson밖에 모르다보니 BodyExtractors가 존재하는 건 처음 알았습니다,,

 


그래서!!

 

아까 정리한 응답을 주고받는 핵심 부분은 ExchangeFunction의 exchange 메소드를 stubbing하면 원하는 것 처럼

외부 API를 호출하지 않고도 응답을 받아 외부 의존성을 제거할 수 있습니다.

 

WebClient는 정말 다행히 WebClient를 만들 때 exchangeFunction을 생성할 수 있습니다.

 

따라서 ExchangeFunction을 mocking하여 만든 WebClient를 의존성 주입을 직접 해주었고, 

 

각 테스트코드에서는

 

 

위와 같이 응답을 stubbing하여 ClientResponse를 만들었고, 

응답 타입이 Mono<ClientResponse>이다 보니 Mono.just라는 정적 메소드를 통해 wrapping해주었습니다.

 

이제 WebClient (DefaultWebClient)에서 retrieve()를 실행하면

내부적으로 ExchangeFunction(ExchangeFunctions)이 mocking되어 들어가고,

ExchangeFunction의 exchange 메소드는 stubbing된 결과를 반환할 것이고

 

(DefaultWebClient)에서 ExchangeFunction의 exchange받은 Mono<ClientResponse>를 통해 DefaultResponseSpec을 만들어 반환할 것입니다.

 

결국 DefaultResponseSpec에서 bodyToMono를 호출하든, bodyToFlux를 호출하든 외부 API요청만 안 갔을 뿐, 평소와 같은 응답인 Mono<ClientResponse>가 있으므로 정상적으로 동작할 것이고 

 

인터넷을 일시적으로 끊고 테스트를 동작시켜보아도

정상적으로 동작합니다!!!

 

후기

RestTemplate을 쓰다 WebClient의 대략적인 사용법만 익혀 사용하다보니 WebClient 자체도 익숙하지 못 했고, 

 

어디를 stubbing해야 WebClient를 성공적으로 mocking할 수 있을 지 하나도 감을 잡지 못했습니다,, (리액티브 프로그래밍의 경험도 없다보니 Mono와 Flux도 정말 수박 겉 핥기 수준으로 이해하고 있음)

아직 WebClient의 대략적인 작동 기전까지만 이해하고, 상세한 부분까지는 이해하지 못 했지만, 

 

앞으로 자주사용하게 될 WebClient의 내부 코드를 분석해보며 어느 부분을 stubbing해야 외부 API호출을 안 하면서도 정상적으로 동작하게 만들 수 있는지 이해하는 계기가 되었습니다!!