Tour API 사용하기

Park Junhyun·2025년 8월 3일

관광데이터 공모전을 준비하면서 TourAPI를 사용하게 되었다.

내가 사용할건 한국관광공사_국문 관광정보 서비스_GW 이다.

공공데이터 포털에 접속해서 로그인을 한 후, 해당 API 활용신청을 하면, 인증키(service key)가 발급된다.

이제 이거를 Spring boot에서 호출하고, Response를 받는 코드를 짜야한다.

우선 API를 호출하고, Response를 받기위해 TourAPIClient라는 클래스를 만들었다.

설명전에 한국관광공사_국문 관광정보 서비스_GW 서비스중 keyword로 검색하기 API를 사용하면서 겪었던 문제점을 나열해보자면

1. URL 인코딩 : serviceKey에 포함된 특수문자(+)가 이미 인코딩된 상태(%2B)로 URL 문자열에 포함되었을 때, RestTemplate에 String URL을 넘기면 내부적으로 다시 인코딩되어 %2B가 %252B로 바뀌어 API가 키를 잘못 해석했다.
2. 응답 파싱: JSON 응답 스키마와 DTO 구조가 불일치하여 Jackson 파싱 오류가 발생했고, DTO에 미지정 필드를 무시하도록 설정해 해결했다.

TourAPIClient

package com.example.tourding.external.tourAPI;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@Component
@RequiredArgsConstructor

public class TourAPIClient {
    private final RestTemplate restTemplate;
    private final ObjectMapper jsonMapper = new ObjectMapper();

    @Value("${tour.client.serviceKey}")
    private String serviceKey;

    private static final String baseUrl = "https://apis.data.go.kr/B551011/KorService2";

    public SearchKeyWordResponse searchKeyWord(String keyword) {
        String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8);
        String safeServiceKey = serviceKey.replace("+", "%2B");

        String urlString = baseUrl + "/searchKeyword2?MobileOS=IOS&MobileApp=tourding&_type=json&arrange=A"
                + "&keyword=" + encodedKeyword
                + "&serviceKey=" + safeServiceKey;

        URI url = URI.create(urlString);

        HttpHeaders headers = new HttpHeaders();
        headers.set("accept", "*/*");
        headers.set("User-Agent", "curl/7.88.1");
        headers.set("Connection", "keep-alive");

        System.out.println(url);

        HttpEntity<Void> entity = new HttpEntity<>(headers);

        ResponseEntity<String> rawResponse = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);

        String body = rawResponse.getBody();
        String contentType = rawResponse.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);

        // 디버깅용
        System.out.println("[DEBUG] 응답 상태 코드: " + rawResponse.getStatusCode());
        System.out.println("[DEBUG] 응답 헤더: " + rawResponse.getHeaders());
        System.out.println("[DEBUG] Content-Type: " + contentType);
        System.out.println("[DEBUG] Body preview: " + (body != null ? body.substring(0, Math.min(body.length(), 500)) : "null"));


        if (body == null || body.isBlank()) {
            throw new IllegalStateException("API 응답이 비어있음");
        }

        // XML 에러 감지: contentType에 xml 포함되거나 본문이 < 로 시작하면
        if ((contentType != null && contentType.contains("xml")) || body.trim().startsWith("<")) {
            if (body.contains("SERVICE_KEY_IS_NOT_REGISTERED_ERROR")) {
                throw new IllegalStateException("서비스 키 인증 실패: SERVICE_KEY_IS_NOT_REGISTERED_ERROR");
            }
            // 다른 XML 에러면 메시지 추출
            throw new IllegalStateException("XML 에러 응답: " + extractSimpleErrorMessage(body));
        }

        // JSON이면 파싱
        try {
            return jsonMapper.readValue(body, SearchKeyWordResponse.class);
        } catch (Exception e) {
            throw new RuntimeException("JSON 파싱 실패: " + e.getMessage() + " / body: " + body, e);
        }

    }

    private String extractSimpleErrorMessage(String xml) {
        String marker = "<returnAuthMsg>";
        if (xml.contains(marker)) {
            int start = xml.indexOf(marker) + marker.length();
            int end = xml.indexOf("</returnAuthMsg>", start);
            if (end > start) {
                return xml.substring(start, end);
            }
        }
        return "알 수 없는 XML 에러";
    }
}
  • 여러 시행착오가 있었다.
  • 코드를 잠깐 설명하자면 일단 한국관광공사_국문 관광정보 서비스_GW가 제공하는 API중에서 keyword로 검색하기 API를 우선 사용할거라서 이걸로 테스트 코드를 먼저 만든것이다.
    여기서 제일 까다로웠던건, 응답 요청을 보낼 때 Response Type을 Param에 끼워서 보낼 수 있는데, 내가 JSON으로 요청을 보내도, service key 에러 등 응답 에러가 뜨면 에러메시지는 XML로 리턴이 된다.
    따라서 응답을 우선 String으로 받고, 만약 String 형태가 XML이면, 에러로 간주해서 에러 로그를 띄워주고, 만약 JSON이면 Response에 맞게 만들어놓은 SearchKeyWordResponse랑 파싱을 해서 데이터를 받아 오도록 했다.
  • 또한 Reponse를 받는 과정에서 restTemplate.exchange로 Http 요청을 보냈는데, 여기에 넣는 url이 String이면 자동으로 Encoding이 됐었다.
    그런데 이때 Encoding을 전부다 제대로 해줬으면 좋았겠지만, +는 문자 공백으로 인식하고, Encoding을 안해주는 사고가 발생을 했다.
    처음에는 원인을 몰라서 UriComponentsBuilder를 사용해서 전부 encoding을 해서 넣어줬는데, 계속 이중 인코딩이 되고, + 만 따로 인코딩을 한 문자열을 넣어줘도 계속 이중 인코딩이 돼서, 디버깅 로그에 찍히는 url로 들어가면 정상적으로 JSON이 반환이 되는데, 프로그램에서 보내는 요청은 계속 ServiceKey Error로 받아졌다.
    그래서 해결방법은 +만 직접 인코딩을 해주고, 그 문자열을 URI.create를 사용해서 객체로 변환 후, 요청 파라미터에 넣어줬다.
    그결과 자동으로 encoding이 되지않아 이중인코딩이 막아졌고, 정상적으로 Response를 받을 수 있게되었다.

SearchKeyWordResponse

package com.example.tourding.external.tourAPI;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

import java.util.Collections;
import java.util.List;

@Getter
public class SearchKeyWordResponse {
    private Response response;

    @Getter
    public static class Response {
        private Header header;
        private Body body;
    }

    @Getter
    public static class Header {
        private String resultMsg;
        private String resultCode;
    }

    @Getter
    public static class Body {
        private int numOfRows;
        private int pageNo;
        private int totalCount;
        private Items items;
        
        public List<Item> getItemList() {
            if(items == null || items.getItem() == null) {
                return Collections.emptyList();
            }
            return items.getItem();
        }
    }

    @Getter
    public static class Items {
        @JsonProperty("item")
        @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) // item이 하나만 들어와도 리스트로 받을 수 있게
        private List<Item> item;
    }

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class Item {
        private String title; // 장소 이름
        private String addr1; // 장소 주소
        private String contentid; // 장소 고유 id
        private String contenttypeid; // 장소 고유 카테고리 id
        private String firstimage; // 장소 이미지1
        private String firstimage2; // 장소 이미지2
        private String mapx; // 장소 위도
        private String mapy; // 장소 경도
    }

}

  • 이거는 API에서 받은 Resonse를 파싱하기 위한 클래스인데, 필요한 것들만 받으려고 Item에서 몇개를 빼먹고 적었는데, 파싱 오류가 났다.
  • 알고보니 Response 전부다 받을게 아니면, @JsonIgnoreProperties(ignoreUnknown = true) 이거를 추가해줘야지 내가 몇개를 무시하고 받아도 에러가 나지 않는다

SearchKeyWordRespDto

package com.example.tourding.tourApi.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder

public class SearchKeyWordRespDto {
    // tourApi에서 키워드 검색조회 /searchKeyword2 주소로 API 호출할 떄 사용
    private String title; // 장소 이름
    private String addr1; // 장소 주소
    private String contentid; // 장소 고유 id
    private String contenttypeid; // 장소 고유 카테고리 id
    private String firstimage; // 장소 이미지1
    private String firstimage2; // 장소 이미지2
    private String mapx; // 장소 위도
    private String mapy; // 장소 경도

}
  • 여기서는 진짜 내가 필요한 필드들만 모아놨고, TourApiServie에서 SearchKeyWordResponse랑 SearchKeyWordRespDto 이거를 Mapping 해서 반환하는 작업을 할 것이다.

TourApiService

package com.example.tourding.tourApi.service;

import com.example.tourding.external.tourAPI.SearchKeyWordResponse;
import com.example.tourding.external.tourAPI.TourAPIClient;
import com.example.tourding.tourApi.dto.SearchKeyWordRespDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;
import java.util.Timer;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor

public class TourApiService {
    private final TourAPIClient tourAPIClient;

    public List<SearchKeyWordRespDto> searchByKeyword(String keyword) {
        SearchKeyWordResponse response = tourAPIClient.searchKeyWord(keyword);
        if(response.getResponse() == null
                || response.getResponse().getBody() == null
                || response.getResponse().getBody().getItems() == null
                || response.getResponse().getBody().getItems().getItem() == null) {
            return Collections.emptyList();
        }

        return response.getResponse().getBody().getItems().getItem().stream()
                .map(item -> SearchKeyWordRespDto.builder()
                        .title(item.getTitle())
                        .addr1(item.getAddr1())
                        .contentid(item.getContentid())
                        .contenttypeid(item.getContenttypeid())
                        .firstimage(item.getFirstimage())
                        .firstimage2(item.getFirstimage2())
                        .mapx(item.getMapx())
                        .mapy(item.getMapy())
                        .build())
                .collect(Collectors.toList());
    }
}
  • 서비스단에서는 특별한건 없고, Reponse가 null이면 빈 배열을 반환하는 작업 후, mapping해서 리턴하도록 했다.

새로 발견된 에러

새로 발견된 에러가 있는데, 만약 Response를 받았는데, 검색결과가 없는경우에
{
  "response": {
    "header": {
      "resultCode": "0000",
      "resultMsg": "OK"
    },
    "body": {
      "items": "",
      "numOfRows": 0,
      "pageNo": 2,
      "totalCount": 10
    }
  }
}

이렇게 items가 배열이 아닌, 빈 문자열로 들어오게 된다.
따라서 Jackson이 Items 타입으로 변환하려다가 "Cannot coerce empty String to Items value" 예외가 발생했다.
-> 빈 문자열은 Jackson이 POJO 타입으로 변환할 수 없기 때문이다.
따라서 TourAPIClient에서 응답을 받은 후,

        jsonMapper.coercionConfigFor(LogicalType.POJO)
                .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);

이 코드를 추가해서 만약 빈문자열이 들어오면 null로 처리하도록 하게하고,
SearchKeyWordReponse에서 값을 받을 때

@Getter
    public static class Body {
        private int numOfRows;
        private int pageNo;
        private int totalCount;
        private Items items;

        public List<Item> getItemList() {
            if(items == null || items.getItem() == null) {
                return Collections.emptyList();
            }
            return items.getItem();
        }
    }

이렇게 null검사를 해서 안전하게 받을 수 있는 함수를 만들어 놓은 뒤,
TourApiService에서 값을 받을 때

        SearchKeyWordResponse response = tourAPIClient.searchKeyWord(keyword, pageNum);

        List<SearchKeyWordResponse.Item> items = response.getResponse()
                .getBody()
                .getItemList();

만들어 놓은 함수를 사용해서 빈문자열 -> null -> 빈 배열 처리가 된 것을 받을 수 있도록 처리했다.

profile
안녕하세요 박준현입니다.

0개의 댓글