[Spring] 외부 API 통신 리팩토링을 하게 된 이유

유아 Yooa·2023년 7월 5일
2

Spring

목록 보기
12/18
post-thumbnail

Overview

지난 5월 공공 데이터 포털의 Open API를 사용하여 기상청 정보를 조회할 수 있는 서버를 만들어보고 이와 관련하여 자세하게 포스팅했다.

당시 공공 데이터 포털에서 제공하는 샘플 코드를 참고하면서 다른 개발자분들의 레퍼런스를 참고하며 작성했었다. 당시에는 정돈되어 있는 코드라고 생각했지만 이를 실제 프로젝트에 적용되면서 문제가 드러났다.

코드가 너무 옛날 방식이라는 것.
코드와 함께 살펴보자.

1. 기존 코드 (HttpConnection)

기존 코드에 대한 자세한 설명은 위 포스팅에 있으니 참고하자.

AbstractOpenAPIService

  • 공공 데이터 포털 외 다른 서비스에서도 Open API를 활용할 예정이었기에 상위 클래스를 생성하고 API connect에 필요한 메소드를 선언해두었다.
    • 하위 클래스는 상속받은 connectApi에 요청 url만 인수로 넘겨주면 통신이 가능하게 설계했다.
  • 나름 상위 클래스의 인스턴스화를 막고자 추상 클래스로 선언하였으나 추상 메소드는 하나도 정의되어 있지 않은 상황이다.
  • Http Connect하는 로직은 공공 데이터 포털에서 게시해놓은 샘플 코드와 같은데 이는 Java 1.8 기준이다. 현재 나의 프로젝트는 Java 17을 사용하고 있는데 말이다.
  • HttpURLConnection 클래스를 사용하기 위한 절차가 길다보니 가독성이 떨어진다.

    가장 중요한 문제는 외부 API 호출을 여러번 해야하는 요청의 경우 HttpURLConnection이 가진 동기 방식의 특징 때문에 Head-Of-Line Blocking이 발생해 응답 시간이 지연된다는 것.

public abstract class AbstractOpenAPIService {

    String connectApi(String urlStr) {
        HttpURLConnection urlConnection = null;
        InputStream stream = null;
        String result = null;

        try {
            URL url = new URL(urlStr);

            urlConnection = (HttpURLConnection) url.openConnection();
            stream = getNetworkConnection(urlConnection);
            result = readStreamToString(stream);

            if (stream != null) {
                stream.close();
            }
        } catch(IOException e) {
            e.printStackTrace();
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }
        }
        return result;
    }

    /* URLConnection 을 전달받아 연결정보 설정 후 연결, 연결 후 수신한 InputStream 반환 */
    InputStream getNetworkConnection(HttpURLConnection urlConnection) throws IOException {
        urlConnection.setConnectTimeout(3000);
        urlConnection.setReadTimeout(3000);
        urlConnection.setRequestMethod("GET");
        urlConnection.setDoInput(true);

        try {
            if(urlConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
                throw new ApplicationException(ErrorCode.SOCKET_TIMEOUT_EXCEPTION);
            }
        } catch (SocketTimeoutException e) {
            throw new ApplicationException(ErrorCode.SOCKET_TIMEOUT_EXCEPTION);
        }


        return urlConnection.getInputStream();
    }

    /* InputStream을 전달받아 문자열로 변환 후 반환 */
    String readStreamToString(InputStream stream) throws IOException{
        StringBuilder result = new StringBuilder();

        BufferedReader br = new BufferedReader(new InputStreamReader(stream, "UTF-8"));

        String readLine;
        while((readLine = br.readLine()) != null) {
            result.append(readLine + "\n\r");
        }

        br.close();

        return result.toString();
    }

    /* json 형태의 문자열을 valueType.class로 parsing 후 반환 */
    <T> T parsingJsonObject(String json, Class<T> valueType) {
        T result = null;

        try {
            ObjectMapper mapper = new ObjectMapper();
            result = mapper.readValue(json, valueType);
        } catch (ValueInstantiationException e) {
            throw new ApplicationException(ErrorCode.NOT_FOUND_ITEM_EXCEPTION);
        } catch(Exception e) {
            e.printStackTrace();
        }

        return result;
    }
}

AbstractOpenAPIService을 상속한 구체 클래스

@Service
public class TourAPIService extends AbstractOpenAPIService {

    @Value("${open-api.data-go-kr.service-key}")
    private String serviceKey;

    @Value("${open-api.data-go-kr.callback-url}")
    private String callbackUrl;

    public TourAPICommonListResponse fetchDataFromLocationBasedApi(double mapX, double mapY,
                                                                   String contentTypeId, String arrangeType,
                                                                   int page, int pageSize) {
        String api = "/locationBasedList1";
        int radius = 20000;

        String urlStr = callbackUrl + api +
                "?serviceKey=" + serviceKey +
                "&mapX=" + mapX +
                "&mapY=" + mapY +
                "&radius=" + radius;

        String result = connectApi(urlStr);
        
        return parsingJsonObject(result, TourAPICommonListResponse.class);
    }

}

2. 개선 돌파구 찾기

처음에는 HttpConnection을 이용하여 외부 API를 호출하는 방법밖에 몰랐다 보니, 다른 사람들도 느린 응답과 가독성 떨어지는 코드의 문제에 대하여 무념무상(?)한건가 싶었다.

비슷한 사례를 겪은 레퍼런스가 있나 찾다보니 RestTemplate을 알게되었다.

RestTemplate

Spring 3.0 이후 http(web).client 패키지로 포함되어 HTTP 요청에 대한 JSON, XML, String 같은 응답을 받을 수 있는 동기식 템플릿 메소드 API이다.
(* 동기식 : REST API 호출이후 응답을 받을 때까지 기다림.)

쉽게 말해, Restful 원칙을 지키면서 HTTP 통신을 유용하게 쓸 수 있는 템플릿으로 HttpConnection 기존 코드가 가진 기계적이고 반복적인 코드를 깔끔하게 정리해줄 수 있는 솔루션이었다.

오호! 코드가 많이 간결해지네..? 성능도 더 좋아지는건가?

Synchronous client to perform HTTP requests, exposing a simple, template method API over underlying HTTP client libraries such as the JDK HttpURLConnection, Apache HttpComponents, and others.

RestTemplate 공식 문서 중 일부이다.

내부적으로 HttpURLConnection, Apache HttpComponents 등을 사용해 Http 통신하는 로직을 추상화했다는 것을 알 수 있다.
사실상 가장 중요한 성능상의 문제는 해결할 수 없는 것.

NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.

NOTE에는 Spring 5.0 부터는 org.springframework.web.reactive.client.WebClient더 modern한 API라고 표현하며 WebCilent 사용을 권장하고 있다.

RestTemplate을 오늘 처음 알았는데 이미 레거시가 되었다ㅋㅋ..
곧 dprecated 될 것 같다고.


WebClient

Non-blocking, reactive client to perform HTTP requests, exposing a fluent, reactive API over underlying HTTP client libraries such as Reactor Netty.

Web Client 공식 문서 중 일부이다. 스프링 5.0에서 추가되어 싱글 스레드 방식과 Non-Blocking 방식의 Http 통신을 지원해주는 인터페이스이다.

WebClient 내부에서는 HTTP 클라이언트 라이브러리에 처리를 위임한다. 디폴트로 Reactor Netty를 사용한다고.

내가 주목한 점은 WebClientNon-Blocking 통신 방법이다. 이는 2개 이상의 REST 요청을 병행으로 수행할 수 있다는 것이다.

쉽게 말해, 여러 번의 외부 API 호출이 있더라도 앞의 커넥션 처리를 기다리지 않고도 다음 로직을 수행할 수 있어 속도 향상을 보일 수 있다는 것!

3. 리팩토링

WebClient를 처음 사용해보기에 아직은 능숙하게 다룰 수 있을 정도거나 깊게 이해한 것은 아니지만 천천히 리팩토링 해보기로 했다.

HttpURLConnection 관련 로직 삭제

  • 추상 메소드를 사용하지 않음에도 붙여주었던 abstract 역할을 지웠다.
  • String 형태로 응답이 오는 json을 parsing 해주는 메소드를 공통으로 사용하기 위해 OpenAPIService라는 상위 클래스를 두었다.
public class OpenAPIService {

    <T> T parsingJsonObject(String json, Class<T> valueType) {
        try {
            ObjectMapper mapper = new ObjectMapper();
            T result = mapper.readValue(json, valueType);

            return result;
        } catch (ValueInstantiationException e) {
            throw new ApplicationException(ErrorCode.NOT_FOUND_ITEM_EXCEPTION);
        } catch(Exception e) {
            throw new ApplicationException(ErrorCode.INTERNAL_SERVER_EXCEPTION);
        }
    }
}

의존성 추가

Spring WebFlux 모듈에서 Web Client를 제공해주고 있기 때문에 의존성을 추가해주었다.

implementation 'org.springframework.boot:spring-boot-starter-webflux'

WebClient 기본 이용법

WebClient를 사용하기 위해 객체를 생성해야 한다. 생성하는 방법은 크게 2가지로,

  • create() 정적 팩토리 메소드를 활용하던가
  • builder 패턴으로 생성하던가
    create()가 단순 생성 방법이라면 , builder() 패턴을 통하여 커스텀하게 설정을 변경할 수 있다.

    WebClient 는 기본적으로 Immutable 하게 생성된다.
    싱클톤으로 객체를 생성해서 설정을 그때마다 변경해서 사용할려면 mutable() 속성을 사용하여 생성할 수 있다고.

// baseUrl e.g. https://apis.data.go.kr
 WebClient webClient = WebClient.builder()
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

// serviceKey : {SERVICE_KEY_HERE}
String result = webClient.get()
					.uri(uriBuilder -> uriBuilder
                    			.path("/B551011/KorService1/locationBasedList1")
    							.queryParam("serviceKey", serviceKey)
                        		.build())
                        .retrieve()
                        .bodyToMono(String.class)
                        .block();
                         
  • retrieve()ClientResponse 개체의 body를 받아 디코딩하고 사용자가 사용할 수 있도록 미리 만든 개체를 제공하는 간단한 메소드이다.
    • 응답 상태 및 헤더와 함께 세밀한 조정을 하고 싶다면 exchange()를 활용할 수 있었는데 deprecated 되었다. Response 콘텐츠를 모두 처리하면 메모리 누수가 발생할 수 있기 때문.
    • Spring 5.3 부터는 exchangeToMono(Function), exchangeToFlux(Function)을 사용하면 된다고 한다.
  • 비동기 Non-Block 방식의 block()을 활용해 Mono 객체를 동기식 데이터 객체로 받아온다.

🚨 WebClinet 퍼센트 인코딩 이슈

설레는 마음에 부풀어 외부 API를 호출하는 테스트 코드를 시행해보았다.
세상은 내 편이 아닌건지 SERVICE KEY IS NOT REGISTERED ERROR를 맞닥뜨렸다.

HttpURLConnection 코드에서는 정상적으로 동작하던 serviceKey가 갑자기 등록되지 않다고 뜨니 당황스러웠다.

원인

찾아보니 WebClient.uri()는 주어진 모든 매개변수를 UriComponentBuilder#encode()라는 옵션을 사용해 인코딩한다고 한다. 이 과정에서 키 값이 달라져서 발생한 문제였던 것.

퍼센트 인코딩이라고 알려진 이 문제는 serviceKey가 두 번 인코딩 되었기에 발생한 문제이다.
공공 데이터 포털에서의 인코딩된 키를 사용하고 있지만 WebClient가 다시 인코딩을 하며 이중 인코딩이 발생했다.

해결

DefaultUriBuilderFactory 객체를 생성해 인코딩 모드를 None으로 변경하고 WebClient 객체 생성에 적용하면 해결할 수 있다.

DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory();
factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE);
 
 // baseUrl e.g. https://apis.data.go.kr
 WebClient webClient = WebClient.builder()
 				.uriBuilderFactory(factory)
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
                
// serviceKey : {SERVICE_KEY_HERE}
String result = webClient.get()
					.uri(uriBuilder -> uriBuilder
                    			.path("/B551011/KorService1/locationBasedList1")
    							.queryParam("serviceKey", serviceKey)
                        		.build())
                        .retrieve()
                        .bodyToMono(String.class)
                        .block();

Http Interface

Spring 6.0에서 annotation과 interface로 선언적 HTTP를 작성할 수 있는 Http Interface가 추가되었다. 해당 서비스를 구현하는 proxy 객체를 생성하면 쉽게 Http 요청을 보낼 수 있다는 것.

선언적인 방식으로 생산성을 높일 수 있고, interface를 사용해 변화에도 유용하게 반응할 수 있다는 장점을 가진다.

이를 통해 더 깔끔하게 구조화해보자.

DataGoKrApi
HTTP 요청을 위한 인터페이스를 구현한다. 필요한 파라미터도 설정해준다.

public interface DataGoKrApi {

    @GetExchange("/B551011/KorService1/locationBasedList1")
    String getLocationBasedTourInfo(@RequestParam("serviceKey") String serviceKey,
                                    @RequestParam("MobileOS") String mobileOS,
                                    @RequestParam("MobileApp") String mobileApp,
                                    @RequestParam("_type") String dataType,
                                    @RequestParam("mapX") double mapX,
                                    @RequestParam("mapY") double mapY,
                                    @RequestParam("radius") int radius,
                                    @RequestParam("pageNo") int page,
                                    @RequestParam("numOfRows") int pageSize,
                                    @RequestParam("contentTypeId") String contentTypeId,
                                    @RequestParam("arrange") String arrangeType
    );
}

해당 인터페이스에 대한 proxy 구현체를 만들어주어야 한다. proxy 생성에는 WebClient 객체를 필요로 한다.

@Configuration
public class DataGoKrConfig {

    @Value("${open-api.data-go-kr.base-url}")
    private String baseUrl;

    @Bean
    DataGoKrApi dataGoKrApi() {
        WebClient webClient = WebClient.builder()
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        return HttpServiceProxyFactory
                .builder(WebClientAdapter.forClient(webClient))
                .build()
                .createClient(DataGoKrApi.class);
    }

}

사용하는 방법은 다음과 같다.
DataGoKrAPIService

@Service
public class DataGoKrAPIService extends OpenAPIService {

    private final DataGoKrApi dataGoKrApi;

    public DataGoKrAPIService(DataGoKrApi dataGoKrApi) {
        this.dataGoKrApi = dataGoKrApi;
    }

    @Value("${open-api.data-go-kr.service-decode-key}")
    private String serviceKey;
    
    ...

    public TourAPICommonListResponse getLocationBasedTourApi(double mapX, double mapY, int page, int pageSize,
                                                             String contentTypeId, String arrangeType) {
        String result = dataGoKrApi.getLocationBasedTourInfo(serviceKey, mobileOS, mobileApp, dataType,
                mapX, mapY, RADIUS, page, pageSize,
                contentTypeId, arrangeType);

        return parsingJsonObject(result, TourAPICommonListResponse.class);
    }
}

참고로 Http Interface를 쓰면 인코딩하지 않은 serviceKey를 넣어도 알아서 인코딩을 해준다.

요청을 annotation으로 간단하게 선언할 수 있고, 구조와 역할도 명확하게 분리되면서 가독성도 훨씬 높아진 것 같다!


ref

profile
기록이 주는 즐거움

0개의 댓글