[Java] 한국천문연구원 특일 정보 API 사용 시 주의 사항

공공데이터 중 한국천문원구원에서 제공하는 '특일 정보'를 받아올 때 발생하는 이슈들과 해결방법을 소개합니다.

프로젝트를 하나 진행 중에 공공데이터(data.go.kr)에서 제공하는 한국천문연구원 특일 정보와 연동을 해야할 상황이 생겼습니다.
이런 공공데이터 같은 경우에는 자체적으로 예제를 제공하기도하고 인터넷에도 자료가 많은데요. 그런데 이 공공데이터는 특이한 부분이 있어서 정보를 공유하고 예제 코드도 제공해드리고자 합니다.

Environment

  • Java 17 (Microsoft OpenJDK 17)
  • Spring Boot 3.3.2
  • Spring Webflux
  • Jackson (Bundled with Spring Boot)

Issue & Solution

Service Key 인코딩 문제

API에 요청하기 위해서는 Request Parameter에 Service Key를 넣어서 인증을 진행하게 되는데, 이게 UriComponentsBuilder를 통해서 Build하여 Request를 날리게 되면, 이 Key가 인코딩되어 전송되어 Service Key가 변형되는 문제가 있습니다.

그런다고, API에서 제공하는 Decoded Key를 넣어도 일부 문자에 대한 인코딩이 이상하게 되어 API가 Service Key를 올바르게 인식하지 못하는 문제가 있습니다. 그래서 저는 아래와 같이 해결하였습니다.

@Log4j2
@Service
@RequiredArgsConstructor
public class AnniversaryDateService {

    private WebClient webClient;

    @Value("${application.holiday-api.key:null}")
    private String holidayApiToken;

    @PostConstruct
    public void initWebClient() {
        final DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory();
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE);

        webClient = WebClient.builder()
                .uriBuilderFactory(factory)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .defaultHeader(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate, br")
                .build();
    }

상기와 같이 DefaultUriBuilderFactory 를 별도로 정의 한뒤, setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE)를 호출하여 인코딩을 사용하지 않도록 설정하였습니다. 그리고, WebClient 객체를 만들 때, DefaultUriBuilderFactory를 지정하여 인코딩을 사용하지 않도록 옵션을 추가하였습니다.

Json 불완전 대응

data.go.kr 특일 정보 API 명세

데이터 명세를 보면 요청을 할 때, Request Parameter로 _type이라는 Key에 'json'이라고 value를 기재하여 요청을 전송하면, 요청 결과를 Json 형태로 전달 받을 수 있게 되는데요.

여기서 문제는 모든 상황에서 Result를 Json으로 전송하지 않습니다. 요청이 유효해서 데이터가 올바르게 전달 됐을 경우에만 Json 형태로 전달이 되구요. 만약에 Service Key가 유효하지 않아서 오류가 생긴다던지 등의 대한 예외 상황이 발생하면 기존 처럼 XML 형태로 데이터가 전달된다는 점입니다. 그래서 Exception이 발생하게 됩니다.

Json 파싱 문제

이 부분이 좀 의아한 부분 중 하나인데요.

특일이 단 하나일 때

요청한 달에 특일이 단 하나라면 Objcet({})로 처리되어 있습니다. 여기까지는 정상 같이 보입니다.

특일이 여러 개 일 때

여러 개라면 array([])처리가 되어 있습니다. 뭔가 이상해보이기 시작합니다.

화룡정정으로 특일이 하나도 없다면 items 밑의 item이 아예 없고, items 객체가 String이 되어 버립니다.

너무나도 황당하게 API를 만들었습니다. 그냥 Array 객체로 구성하면 자동으로 0, 1, 2개 이상 상황 모두 대응이 될텐데 불편하게 만들어 두었습니다. 이거 때문에 Jackson으로 파싱할 때 별도의 Deserializer가 필요했습니다.

저는 Deserializer를 이렇게 구성하였습니다.


import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class ItemsDeserializer extends StdDeserializer<Items> {

  public ItemsDeserializer() {
    super(Items.class);
  }

  @Override
  public Items deserialize(JsonParser p, DeserializationContext ctxt)
      throws IOException, JsonProcessingException {

    // 1) JSON 노드 얻기
    JsonNode node = p.getCodec().readTree(p);

    // 2) 결과로 반환할 Items 객체 생성
    Items items = new Items();

    // 3) Items 안의 List<Holiday>를 채워 넣을 임시 List<Holiday>
    List<Holiday> holidayList = new ArrayList<>();

    // [Case A] items가 ""(빈 문자열)인 경우
    if (node.isTextual() && node.asText().isEmpty()) {
      // holidayList는 빈 상태 그대로 사용
      items.setItem(holidayList);
      return items;
    }

    // [Case B] items가 객체 {}인 경우
    if (node.isObject()) {
      // 내부에 "item" 노드가 있는지 확인
      JsonNode itemNode = node.get("item");
      if (itemNode != null) {
        // itemNode가 단일 객체 {}
        if (itemNode.isObject()) {
          Holiday oneHoliday = p.getCodec().treeToValue(itemNode, Holiday.class);
          holidayList.add(oneHoliday);
        }
        // itemNode가 배열 []
        else if (itemNode.isArray()) {
          for (JsonNode arrNode : itemNode) {
            Holiday holiday = p.getCodec().treeToValue(arrNode, Holiday.class);
            holidayList.add(holiday);
          }
        }
        // 그 외 문자열 등 예외 케이스는 일단 빈 리스트 처리
      }
      // itemNode == null일 때도 빈 리스트
      items.setItem(holidayList);
      return items;
    }

    // [Case C] 그 외 (배열 등) -> API 스펙상 거의 없겠지만 방어적으로 처리
    // 혹시나 items 필드가 배열로 오는 경우. (일반적이지 않지만)
    if (node.isArray()) {
      // 그래도 안에 "item"이 들어있을 수 있으니 시도
      for (JsonNode arrNode : node) {
        // 만약 arrNode 자체가 Holiday의 필드를 포함한다면 파싱
        Holiday holiday = p.getCodec().treeToValue(arrNode, Holiday.class);
        holidayList.add(holiday);
      }
      items.setItem(holidayList);
      return items;
    }

    // 여기까지 왔으면 알 수 없는 형태
    items.setItem(holidayList);
    return items;
  }
}

HTTP Status Code 미활용

404가 아니라면, 대부분 API 결과는 200 OK로 Return 됩니다. 예상이 가능한 오류도 200으로 Return 됩니다. 그래서, 프로그래밍을 하실 때 HTTP Status에 대한 핸들링을 하지 않아도 됩니다. Body에 있는 데이터를 토대로 Error 여부를 판단해야합니다.

결론

API Response를 Json으로 받아오는거라서 대단히 쉬울 줄 알았는데 이상하게 만들어서 힘들게 되었습니다.

글은 Json으로 받아올 수 있게 기똥차게 적었지만, 답은 XML로 받아오는 것이 아닌가 싶습니다. 변수가 이렇게 많으면 추후 다른 오류 핸들링도 힘들 것 같습니다.

예제 코드

이 곳에서 사용된 예제 코드는 Github Repo에서 확인해보실 수 있습니다.