[Spring] WebClient를 이용한 API 호출 개선

라임이·2024년 1월 10일
0

Spring

목록 보기
2/3


HttpClient

해당 글은 Spring 프레임워크에서 WebClient를 사용하여 API 호출을 보다 편리하게 만드는 방법을 다루고 있다.
OkHttp와 WebClient의 비교를 통해 WebClient의 강점과 함께 Axios를 사용한 React에서의 API 호출 예제도 소개되었다.
글에 작성된 코드의 목적은 API 호출을 간편하게 만드는 것이며, WebClient의 장점을 활용하여 동기/비동기 호출, JSON 파싱 등을 효율적으로 처리한다. 코드는 Java와 Spring을 기반으로 하며, 간결하고 표현력 있는 메소드를 사용하여 코드의 가독성을 높이고자 노력하였다.

먼저 OkHttp와 WebClient에서 API를 어떤 식으로 호출하고 응답을 가져오는지 살펴보자.

OkHttp를 이용한 API 호출 예

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class OkHttpExample {

    public static void main(String[] args) throws Exception {
        OkHttpClient client = new OkHttpClient();
        String url = "https://api.example.com/data";
        
        Request request = new Request.Builder()
                .url(url)
                .build();

        try (Response response = client.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new RuntimeException("Failed to get data: " + response);
            }

            MyDto myDto = objectMapper.readValue(response.body().string(), MyDto.class);

            System.out.println("Response: " + myDto.getData());
        }
    }
}

WebClient를 이용한 API 호출 예

import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

public class WebClientExample {

    public static void main(String[] args) {
        WebClient webClient = WebClient.create();
        String url = "https://api.example.com/data";

        Mono<MyDto> responseMono = webClient.get()
                .uri(url)
                .retrieve()
                .bodyToMono(MyDto.class);

        responseMono.subscribe(myDto -> {
            System.out.println("Response: " + myDto.getData());
        });
    }
}

OkHttp의 경우 그나마 과정이 단순하고 깔끔해 보이지만, WebClient는 다양한 기능을 제공하기 때문에 처음 외부 API를 호출해보는 입장에서는 사용하기 번거로운 부분이 존재한다.
각 메소드가 어떤 역할을 수행하는지 처음에 알기 힘들며, 상황에 따라 반환되는 자료형(Mono, Flux)이 다르고, 사용법도 조금씩 바뀌기 때문에 사용에 번거로움이 있다.

이번에는 React에서 Axios로 호출하는 코드 예제를 살펴보겠다.

React Axios를 이용한 API 호출 예

import axios from 'axios';

const fetchData = async () => {
  try {
    const response = await axios.get('https://api.example.com/data');
    console.log('Data:', response.data);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
};

fetchData();

Axios를 이용해서 API를 어떻게 호출하고 데이터를 활용하는지 한눈에 보인다.
axios.httpMethod('url'); 이라고 적으면 명시한 httpMethod로 url에 요청을 보내고 response에 응답을 저장한다.
이후 닷(.)을 이용해 json 필드에 손쉽게 접근, 데이터를 활용한다.

위의 방식을 보며 기존 방식을 보다 간결하고 표현력 있게 만들고자 했다.

코드를 작성하기 전, 다음의 설계를 고려했다.

  1. api를 호출하는 서비스를 작성한다.
    • 해당 서비스의 메소드는 url, httpMethod, responseType을 매개변수로 갖는다.
  2. 동기, 비동기 방식을 모두 간편하게 지원한다.
    • 그동안의 라이브러리는 해당 방식을 동시에 지원하지 않거나, 방식에 따라 호출 방법, 반환 타입이 달라지는 문제가 있었다.
  3. 손쉽게 응답 데이터를 파싱하고 이를 바로 이용할 수 있도록 한다.
    • 이는 기존 DTO를 작성해야만 필드에 접근하고 데이터를 활용할 수 있었던 점을 개선한다. 물론, 기존의 방식 또한 지원한다.
    • responseType을 사용자가 지정함으로써, 이를 가능토록 한다.

해당 기능을 모두 포함하기 위해서는 다음의 코드가 필요하다.

  1. 동기/비동기 API 호출 로직을 담은 메소드(DTO 사용 여부 선택 가능)
  2. 동기/비동기 방식과 무관하게 반환되는 DTO Class
  3. Json 파싱 메소드

Spring 프레임워크와의 호환을 위해 Spring5.0부터 지원되는 WebClient를 이용해 기능을 구현했다.
WebClient가 생소한 사람을 위해 간단하게 설명하고 본론으로 들어가겠다.

WebClient이란

Spring WebClient

Spring 공식 문서에 따르면 다음과 같이 설명되어있다.

WebClient는 Reactive 프로그래밍을 지원하며, Reactor Netty와 같은 기본 HTTP 클라이언트 라이브러리를 기반으로 하는 HTTP 요청을 수행하는 비차단(Non-blocking) 및 반응형(Reactive) 클라이언트입니다.
create() 또는 create(String)과 같은 정적 팩토리 메서드 또는 builder()를 사용하여 인스턴스를 준비할 수 있습니다.

응답 본문과 관련된 예제에 대한 자세한 내용은 아래와 같습니다:
retrieve(): 응답 본문을 검색합니다.
exchangeToMono(): Mono 형태로 응답을 교환합니다.
exchangeToFlux(): Flux 형태로 응답을 교환합니다.

또한 요청 본문과 관련된 예제에 대한 자세한 내용은 다음과 같습니다:
bodyValue(Object): 요청 본문을 지정된 값으로 설정합니다.
body(Publisher, Class): 요청 본문을 지정된 클래스의 Publisher로 설정합니다.

이 라이브러리는 5.0 버전 이후에 제공되며, Rossen Stoyanchev, Arjen Poutsma, Sebastien Deleuze, Brian Clozel 등의 개발자에 의해 개발되었습니다.

WebClient의 강점

  1. 간결한 코드: WebClient은 간결하고 표현력 있는 메소드를 제공해 코드를 단순하게 유지할 수 있다.
  2. 동기/비동기 호출 지원: 반응형 프로그래밍을 지원하여 동기와 비동기 호출을 편리하게 처리할 수 있다.
  3. JSON 파싱 편의성: bodyToMono(MyDto.class)와 같은 메소드로 JSON 파싱을 간편하게 수행할 수 있다.
  4. 타입 안정성: 제네릭과 반응형 타입인 Mono를 사용하여 타입 안정성을 보장한다.

WebClient 간편하게 만들기

이제 본격적으로 WebClient를 이용해서 API 호출을 보다 간편하게 할 수 있도록 개선하겠다.
Spring 프로젝트에서 사용할 예정이기 때문에 Service를 별도로 만들어서 작업했다.

  1. 반환 데이터 타입(DTO) 설계

모든 요청에 대한 응답을 동일한 타입으로 통일하여 사용 편의성을 제공한다.
일반적으로 API 요청 후 필요한 정보(HttpStatus, HttpHeaders, Body)만을 갖도록 구성하였다.
HttpStatus의 경우 ReasonPhrase(응답 상태에 대한 메시지)까지 필요한 경우는 크게 없을 거라 여겨져 응답 상태 번호만 저장하도록 했다.

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;

@Getter
@Setter
@NoArgsConstructor(force = true)
@AllArgsConstructor
public class ApiResponse<T> {

    private final int status;

    private final HttpHeaders headers;

    private final T body;

    public static <T> ApiResponse<T> of(ResponseEntity<T> responseEntity) {
        return new ApiResponse<>(
                responseEntity.getStatusCodeValue(),
                responseEntity.getHeaders(),
                responseEntity.getBody()
        );
    }

}

of()의 경우 응답을 받아와 객체 변환을 할 때 코드 중복이 일어나는걸 방지하고, 코드를 간결하게 유지하기 위해 작성되었다.

  1. Json 파싱

Json을 파싱할 때 일반적으로 DTO를 추가적으로 작성해서 매핑을 시키는 방식을 사용한다.
React에서 별도의 DTO 없이 닷(.)을 이용해서 접근하는 걸 보고 영감을 얻었다.

그런데 자바에서 이러한 기능이 그냥 가능할 리가 있겠나. 당연히 불가능하다.

이런저런 방식을 생각해보다가 결국 Json이기만 하면 형식은 정형화되어있으니 문자열로 취급해서 파싱하면 되겠다는 생각이 문득 들었다.
하지만 String 타입만으로 처리하기에는 괄호가 열고 닫히는 과정이 발생하기 때문에 로직이 다소 복잡해지겠다는 느낌적인 느낌이 들었다.
그래서 구글링을 통해 이런저런 방법을 찾아봤고, ObjectMapper를 이용해서 Json을 Map<String, Object>형식으로 매핑시킬 수 있음을 알 수 있었다.

위 내용을 정리하면 로직은 다음과 같다.

  1. Json 문자열을 객체로 변환: 메소드의 시작 부분에서는 주어진 Json 문자열(jsonResponse)을 Jackson 라이브러리를 사용하여 Map 형태의 객체로 변환한다. 이는 parseJsonResponse 메소드를 통해 수행된다.
  2. Json 경로를 필드로 분할: 주어진 필드 경로(fieldPath)를 점(.)을 기준으로 분할하여 각 필드를 배열에 저장한다.
  3. 객체 내 필드 순회: 분할된 필드 배열을 순회하면서 해당 필드가 Map인지, List인지에 따라 처리를 다르게 한다.
    • Map인 경우: 현재 맵에서 해당 필드를 키로 가지고 있는 값이 또 다른 맵인 경우, 이를 현재 맵으로 갱신한다.
    • List인 경우: 현재 맵에서 해당 필드를 키로 가지고 있는 값이 리스트인 경우, 리스트에서 주어진 인덱스에 해당하는 맵으로 갱신한다.
  4. 최종 결과 반환: 위의 과정을 거쳐 최종적으로 도달한 맵 객체를 Json 문자열로 변환하여 반환한다. 이는 toJsonString 메소드를 통해 수행된다.
  5. 에러 처리: 중간에 어떠한 문제가 발생하면 해당 에러 메시지를 반환한다.
    • 필드가 존재하지 않거나, 배열의 인덱스가 범위를 벗어나는 경우 등이 해당된다.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
import java.util.Map;

public class JsonUtils {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    public static String extractValueFromJson(String jsonResponse, String fieldPath) {
        Map<String, Object> jsonMap = parseJsonResponse(jsonResponse);

        String[] fields = fieldPath.split("\\.");
        Map<String, Object> currentMap = jsonMap;

        for (String field : fields) {
            if (field.contains("[")) {
                String[] parts = field.split("\\[");
                String arrayFieldName = parts[0];
                int index = Integer.parseInt(parts[1].substring(0, parts[1].length() - 1));

                if (currentMap.containsKey(arrayFieldName)) {
                    Object fieldValue = currentMap.get(arrayFieldName);
                    if (fieldValue instanceof List) {
                        List<Map<String, Object>> list = (List<Map<String, Object>>) fieldValue;
                        if (index < list.size()) {
                            currentMap = list.get(index);
                        } else {
                            return "Array index out of bounds";
                        }
                    } else {
                        return "Field is not an array";
                    }
                } else {
                    return "Field not found";
                }
            } else {
                if (currentMap.containsKey(field)) {
                    Object fieldValue = currentMap.get(field);
                    if (fieldValue instanceof Map) {
                        currentMap = (Map<String, Object>) fieldValue;
                    } else if (fieldValue instanceof List) {
                        return toJsonString(fieldValue);
                    } else {
                        return fieldValue.toString();
                    }
                } else {
                    return "Field not found";
                }
            }
        }
        return toJsonString(currentMap);
    }

    public static Map<String, Object> parseJsonResponse(String jsonResponse) {
        try {
            return objectMapper.readValue(jsonResponse, new TypeReference<Map<String, Object>>() {});
        } catch (IOException e) {
            throw new RuntimeException("Error parsing JSON response", e);
        }
    }

    public static String toJsonString(Object object) {
        try {
            return objectMapper.writeValueAsString(object);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Error converting object to JSON string", e);
        }
    }

}
  1. API 호출을 담당하는 비즈니스 로직(Service)

기존 설계와 동일하게 동기/비동기 방식을 모두 지원한다.
반환형은 1에서 설계한 ApiResponse로 통일되며, Axios와 유사한 방식(url, HttpMethod)으로 API를 호출한다.
다만, 2의 방식과 기존의 방식을 모두 지원하기 위해 responseType을 매개변수로 같이 받는다.
또한, 사용자가 동기/비동기를 쉽게 선택할 수 있게 만들기 위해 isAsync도 매개변수로 받는다.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import page.clab.api.type.dto.ApiResponse;
import page.clab.api.util.JsonUtils;

@Service
public class ApiService {

    private final WebClient webClient;

    public ApiService(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.build();
    }

    public <T> ApiResponse<T> callApi(String url, HttpMethod httpMethod, Class<T> responseType, boolean isAsync) {
        WebClient.RequestHeadersSpec<?> request = webClient.method(httpMethod).uri(url);
        try {
            return isAsync ? callApiAsync(request, responseType).get() : callApiSync(request, responseType);
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException("Error retrieving API response", e);
        }
    }

    public ApiResponse<String> callApiAndReturnJsonString(String url, HttpMethod httpMethod, boolean isAsync) {
        WebClient.RequestHeadersSpec<?> request = webClient.method(httpMethod).uri(url);
        try {
            return isAsync ? callApiAsync(request, String.class).get() : callApiSync(request, String.class);
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException("Error retrieving API response", e);
        }
    }

    public String extractValueFromJson(String jsonResponse, String fieldPath) {
        return JsonUtils.extractValueFromJson(jsonResponse, fieldPath);
    }

    private <T> ApiResponse<T> callApiSync(WebClient.RequestHeadersSpec<?> request, Class<T> responseType) {
        return ApiResponse.of(request.retrieve().toEntity(responseType).block());
    }

    private <T> CompletableFuture<ApiResponse<T>> callApiAsync(WebClient.RequestHeadersSpec<?> request, Class<T> responseType) {
        return request.retrieve()
                .toEntity(responseType)
                .map(responseEntity -> ApiResponse.of(responseEntity))
                .toFuture();
    }

}

코드 테스트

정상적으로 코드가 작동하는지 HTTPie를 이용해서 실험해보았다.
사실 비동기의 경우 동시에 여러번 호출한 뒤, 스레드의 생명주기를 함께 살펴보아야 되지만 이는 생략했다.
또한, 모든 API가 정상 작동하지만, 사진으로 인해 글이 너무 길어져 일부만 담았다.

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.concurrent.ExecutionException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import page.clab.api.service.ApiService;
import page.clab.api.type.dto.ApiResponse;
import page.clab.api.type.dto.ApiTestResponseModel;

@RestController
@RequestMapping("/api-call-tests")
@RequiredArgsConstructor
@Tag(name = "API Call Test", description = "API 호출 관련 API")
@Slf4j
public class ApiController {

    private final ApiService apiService;

    @Operation(summary = "동기 API 호출 예제")
    @GetMapping("/call-api-sync")
    public ApiResponse<ApiTestResponseModel> callApiSyncExample(@RequestParam String url) throws ExecutionException, InterruptedException {
        return apiService.callApi(url, HttpMethod.GET, ApiTestResponseModel.class, false);
    }

    @Operation(summary = "비동기 API 호출 예제")
    @GetMapping("/call-api-async")
    public ApiResponse<ApiTestResponseModel> callApiAsyncExample(@RequestParam String url) throws ExecutionException, InterruptedException {
        return apiService.callApi(url, HttpMethod.GET, ApiTestResponseModel.class, true);
    }

    @Operation(summary = "동기 API 호출 예제 (JSON String 반환)")
    @GetMapping("/call-api-sync-json")
    public ApiResponse<String> callApiSyncJsonExample(@RequestParam String url) throws ExecutionException, InterruptedException {
        return apiService.callApiAndReturnJsonString(url, HttpMethod.GET, false);
    }

    @Operation(summary = "비동기 API 호출 예제 (JSON String 반환)")
    @GetMapping("/call-api-async-json")
    public ApiResponse<String> callApiAsyncJsonExample(@RequestParam String url) throws ExecutionException, InterruptedException {
        return apiService.callApiAndReturnJsonString(url, HttpMethod.GET, true);
    }

    @Operation(summary = "JSON String에서 특정 필드 추출 예제")
    @GetMapping("/extract-value")
    public String extractValueExample(@RequestParam String url, @RequestParam String fieldPath) throws ExecutionException, InterruptedException {
        String jsonResponse = apiService.callApiAndReturnJsonString(url, HttpMethod.GET, true).getBody();
        return apiService.extractValueFromJson(jsonResponse, fieldPath);
    }

}

API Test
API Test

정상적으로 작동한다.
body가 DTO와 정상적으로 매핑이 되어서 형식이 깨지지 않고 나왔음을 확인할 수 있다.

Json String을 반환하는 API도 테스트해보겠다.

API Test

예상처럼 정상 작동한다.
body 타입을 String.class로 해놨기 때문에 DTO 매핑했을 때와 달리 순수 문자열로 반환되는걸 볼 수 있다.

이번에는 Json String을 이용해서 body 정보를 파싱하고, 원하는 데이터만 가져오는 작업을 해보겠다.
리스트인 경우도 테스트하기 위해 body에 정보를 1개 더 추가해줬다.

API Test

data 필드 안의 정보를 정상적으로 가져왔다.

API Test

data.items 정보도 리스트로 문제없이 가져온다.

API Test

data.items가 리스트이기 때문에 특정 요소의 정보에도 접근할 수 있어야 된다.
fieldPath로 data.items[0]을 넘긴 결과 0번 인덱스의 값만 반환함을 알 수 있다.

API Test

가장 안쪽에 위치한 필드인 data.items[0].name을 가져온 결과이다.

정리하며

사실 처음에는 뭣도 모르고 Spring3버전부터 지원되던 RestTemplate으로 작업을 했어서 되게 유용하겠다는 생각이 들었었다.
글을 작성하면서 공식 문서를 다시 보니 Spring5버전부터는 WebClient라는게 지원됨을 알았고, RestTemplate보다 강력하다는걸 깨달았다. 그래서 WebClient로 다시 리팩토링해서 글을 다시 작성했다.

문제는 그거다. WebClient도 워낙 간결하게 잘 짜여져있다보니, 지금의 내 입장에서는 내가 만든 코드가 조금은 편리할지 몰라도 그렇게까지 강력하지는 못하다는 것이다. 또한 간단하게 사용하기에는 좋겠지만, 분명 사용하다보면 내 코드로는 부족한 기능들이 더러 생길 걸로 예상된다.

만들 때는 Axios를 보며 신나서 만들었지만, 최종 산출물을 보니 드라마틱한 변화는 없었어서 조금 아쉬움이 남는다.
만들면서 힐링도 되고 공부도 했으니 이걸로 만족하자.

profile
백엔드 개발이 즐거운 4학년 컴공생

0개의 댓글

관련 채용 정보