본문 바로가기

💻Spring

Spring WebClient를 이용한 Open API 통신

반응형

서론

회사 프로젝트의 상담 신청 Time Table에 대한 API를 구현해야했다.

요구사항은 다음과 같았다.

 

캘린더를 통해 해당 날짜에 맞는 타임 테이블을 노출 시켜준다.

  • 기획서 요구사항 
    • 연도와 월을 설정하여 조회시 상담 가능한 날짜를 노출시켜준다.
    • 날짜를 클릭하면 상담 가능한 시간대를 노출시켜준다.
  • 작업하며 정의된 추가 고려사항 (개인 테스트 케이스)
    • 휴일과 점심시간(수요일 2시간)을 제외하고 타임 테이블을 노출 시켜준다.
    • 상담원이 1명이므로, 이미 상담이 신청되어있는경우 해당 시간대는 노출시키지 않는다.
    • 해당 날짜에 상담 가능한 시간이 없는경우 해당 날짜를 노출시키지 않는다.
    • 조회하는 달을 조회 할 경우, 조회하는 일자 기준 이전 날짜를 노출시키지 않는다.
    • 조회시간 기준이 영업시간인 경우, 조회시간 이후의 시간대부터 노출시킨다. 
    • 공휴일을 노출시키지 않는다.

다른 로직들은 DB로 조회해서 이런 저런 기교를 통해 구현을 잘 했다만,

공휴일 쪽은 어떻게 처리 할 지 조금 골치가 아팠다.

처음에는 라이브러리를 통해 처리할까 하였는데, 한국의 공휴일에 관한 라이브러리는 따로 찾기가 어려웠기 때문에 

다른 방법을 찾아보다가 공공 데이터 포털에서 제공하는 한국천문연구원_특일 정보 API가 있는것을 확인하였고

해당 API를 적용시켜보기로 하였다.

 

본론

일단, 통신은 Spring Webflux에서 제공하는 WebClient를 통해 통신을 하고자 하였기 때문에 

pom.xml에 dependency를 추가해주었다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

 

이후 개발 테스트를 위하여

공공데이터 포털의 한국천문연구원_특일 정보 API  페이지에 들어가 개인 계정으로 접속 후 활용 신청을 하였다.

 

한국천문연구원_특일 정보

(천문우주정보)국경일정보, 공휴일정보, 기념일정보, 24절기정보, 잡절정보를 조회하는 서비스 입니다. 활용시 날짜, 순번, 특일정보의 분류, 공공기관 휴일 여부, 명칭을 확인할 수 있습니다.

www.data.go.kr

 

활용신청을 하면 다음과 같은 화면을 확인 할 수 있는데

 

API 문서를 통해 스펙을 확인하였고, 응답 구조 파악을 위해 Postman으로 먼저 테스트를 진행해보았다.

  • 요청 형식은 Get 메서드, EndPoint에 QueryParameter를 통해 연(필수),월(선택), 응답형식(선택), 데이터 개수(선택), 발급받은 SecretKey(필수)를 지정하여 받아오는 형식이였다. 
    • 응답 형식은 default가 xml이므로 json으로 설정해주었다.
    • SecretKey의 경우 Encoding 하여 API 통신을 해야하기 때문에, Postman 환경에선 Encoding된 SecretKey를 입력하여 확인하였다.

정상적으로 응답이 오는것을 확인하였다. 

응답 형식을 확인했으니 해당 응답 형식을 DTO로 변환하여

Spring Webflux의 WebClient를 통해 작업을 진행해보았다.

WebClient webClient = WebClient.builder()
        .baseUrl(OPEN_API_URL)
        .build();

String response = webClient.get()
        .uri(uriBuilder -> uriBuilder
                .queryParam("solYear", year)
                .queryParam("solMonth", month)
                .queryParam("_type", "json")
                .queryParam("numOfRows", 50)
                .queryParam("ServiceKey", OPEN_API_SERVICE_KEY)
                .build())
        .retrieve()
        .bodyToMono(String.class)
        .block();

        OpenApiResponseDto openApiResponseDto = new Gson().fromJson(response, OpenApiResponseDto.class);
        Set<Integer> holidays = openApiResponseDto.getHoliday();

 

크게 어려운 내용은 없이 위와같이 코드를 작성했다.

문제없이 통신이 되겠거니 하고 테스트코드를 돌려보았더니 두가지 문제가 발생하였다.

  1. Service Key의 인코딩 문제
  2. Response -> DTO로 변환 문제

 

Service Key의 인코딩 문제

아.. 일단 위에서 볼 수 있듯 SecretKey는 Encode된 key와 Decode된 key가 모두 제공이 된다.

앞서 말했듯 API 통신을 위해선 Key를 Encode를 한 이후 통신을 해야하는데,

webClient는 queryParameter를 내부적으로 인코딩을 진행을 한다.

따라서 인코딩된 key를 사용하면 중복 인코딩이 일어나기 때문에 Decode 된 키를 사용하였으나,

역시 등록되지 않은 키라고 계속 에러를 내뱉었다. 

디버그를 해보니, 디코드된 SecretKey에는 +와 /가 들어가는데 이 특수문자들은 인코딩이 되지 않고 있었던 것이다.

찾아보니 특수기호들 중 일부는 delim으로 분류되어 인코딩을 하지 않는다고 한다..

때문에 +는 %2B로, /는 %2F로 replace를 하여 사용했는데,,

이것도 이 replace 된 문자들을 이중으로 인코딩하는 문제가 생겨 에러를 뱉어줬다 ^^!

결국, 방법을 찾았는데

 

DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(OPEN_API_URL);
factory.setEncodingMode(EncodingMode.VALUES_ONLY);

WebClient webClient = WebClient.builder()
        .baseUrl(OPEN_API_URL)
        .build();

String response = webClient.get()
        .uri(uriBuilder -> uriBuilder
                .queryParam("solYear", year)
                .queryParam("solMonth", month)
                .queryParam("_type", "json")
                .queryParam("numOfRows", 50)
                .queryParam("ServiceKey", OPEN_API_SERVICE_KEY)
                .build())
        .retrieve()
        .bodyToMono(String.class)
        .block();

 

인코딩된 SecretKey로 교체한 이후

DefaultUriBuilderFactory라는 팩토리 클래스를 통해 쿼리 파라미터의 값을 Encoding을 하지 않게끔 설정하였다. 

이후 정상적으로 응답이 내려왔으나, 진짜는 다음부터였다.

 

Response -> DTO로 변환 문제

API가 왜 이렇게 만들어졌나 정말 궁금했다.

앞서 테스트를 하며 Postman을 통해 필요한 정보인 공휴일은 item이 JsonObject 형태로 응답이 돌아왔다.

문득 공휴일이 여러개인 달은 어떻게 들어올까 싶어 확인을 해보니

이번엔 item이 JsonArray 형태로 응답이 돌아왔다 ...

그럼 공휴일이 없으면..? 어떻게 응답이 올까 확인해보니

위와같이 Text 형태로 응답이 돌아왔다.

그럼 item에 대한 응답 형식을 정리를 해보면

  • 공휴일이 0개인 달
    • Text
  • 공휴일이 1개인 달
    • JsonObject
  • 공휴일이 2개 이상인 달
    • JsonArray

DTO를 동적으로 받을 수 있게끔 설계를 해야했다.

다행히 jackson에서 제공하는 databind 라이브러리에는 JsonObject와 JsonArray, Integer, String, Boolean등을 받을 수 있는 JsonNode라는 추상 클래스 형태의 타입을 제공하였기 때문에 이 문제를 해결 할 수 있었다.

 

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class OpenApiResponseDto {
    // 0개인경우 items : ""
    // 1개인경우 items : item = Object
    // 여러개인경우 items : item = Array
    
    private JsonNode items;

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Item {
        private String locdate;
    }

    public static OpenApiResponseDto create(String json) {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode jsonNode = objectMapper.readTree(json);
            JsonNode items = jsonNode.get("response").get("body").get("items");
            OpenApiResponseDto openApiResponseDto = new OpenApiResponseDto(items);
            return openApiResponseDto;
        } catch (IOException e) {
            e.printStackTrace();
        }
        throw new IllegalArgumentException("json 형식이 올바르지 않습니다.");
    }

    public Set<Integer> getHoliday() {
        // 데이터가 없는 경우 String 타입
        if(this.items.isTextual()) {
            return new HashSet<>();
        }

        JsonNode item = this.items.get("item");

        // 데이터가 1개인 경우 response가 JsonObject타입
        if (item.isObject()) {
            String locDate = item.get("locdate").asText();
            return new HashSet<Integer>(){{ add(Integer.parseInt(locDate.substring(6,8)));}};
        }

        // 데이터가 여러개인 경우 response가 JsonArray타입
        if (item.isArray()) {
            List<String> holidays = new ArrayList<>();
            Iterator<JsonNode> items = item.elements();
            while(items.hasNext()) {
                holidays.add(items.next().get("locdate").asText());
            }
            return holidays.stream()
                    .map(holiday -> Integer.parseInt(holiday.substring(6,8)))
                    .collect(Collectors.toSet());
        }

        // 데이터가 없는 경우 String 타입
        return new HashSet<>();
    }
}

 

결론

위와 같이 response를 DTO로 변환 후, 해당 DTO에서 휴일을 가져오는 getHoliday 메서드를 통해 공휴일을 가져오는 로직으로 구현하여, 작업을 마무리 할 수 있었다.

Open API를 많이 써보지 않아 그런지 이런식의 요청과 응답 형태는 사용하는 Client 입장에서는 당황스러웠다.

 

요청의 SecretKey의 경우, Encoded SecretKey, Decoded SecretKey를 제공하는데, 이렇게 제공하는것 까지는 사용성에서 좋았으나, 일반적으로 인코딩을 진행하는 queryParam은 부적합하다고 생각이 되고, 차라리 값을 일반적으로 인코딩을 진행하지 않는 헤더를 통해 받는게 더 자연스럽다고 생각했다.

 

또한 응답의 형식도 데이터가 0개,1개,2개이상의 경우 저렇게 빈문자열, JsonObject, JsonArray 형식이 아닌

JsonArray로 통일 후 그 안에서 0개일 경우 [], 1개일 경우 [ {} ], 2개 이상일 경우 [ {}, {}, {} ] 이런식으로 응답을 해줬으면 좋지 않았을까라는 생각을 했다.

 

앞으로 API 설계 시 해당 API를 사용하는 Client 관점에서의 시야를 조금 더 생각해보고 API를 작업 하도록 해야겠다는 생각을 했다.

반응형