관광데이터 공모전을 준비하면서 TourAPI를 사용하게 되었다.
내가 사용할건 한국관광공사_국문 관광정보 서비스_GW 이다.
공공데이터 포털에 접속해서 로그인을 한 후, 해당 API 활용신청을 하면, 인증키(service key)가 발급된다.
이제 이거를 Spring boot에서 호출하고, Response를 받는 코드를 짜야한다.
우선 API를 호출하고, Response를 받기위해 TourAPIClient라는 클래스를 만들었다.
설명전에 한국관광공사_국문 관광정보 서비스_GW 서비스중 keyword로 검색하기 API를 사용하면서 겪었던 문제점을 나열해보자면
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 에러";
}
}
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; // 장소 경도
}
}
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; // 장소 경도
}
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());
}
}
{
"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 -> 빈 배열 처리가 된 것을 받을 수 있도록 처리했다.