본문 바로가기

Proj/Tripot

TourAPI를 이용하여 축제 데이터 수집하기

1. 개요

 이번에는 다음의 기능을 구현할 계획에 있다.

 금일 진행하는 팝업스토어와 축제 리스트를 지도에 위치를 나타냄과 같이 보여주고, 해당 정보를 확인할 수 있는 기능이다. 필자는 축제 리스트 조회를 맡았고, 이를 구현하고자 한다.

2. API

 축제 정보는 공공데이터 API에서 가져오도록 하고, 다음의 두 데이터셋이 눈에 띄었다.

2-1. 전국문화축제표준데이터

https://www.data.go.kr/data/15013104/standard.do#/tab_layer_open

 

전국문화축제표준데이터

국가에서 보유하고 있는 다양한 데이터를『공공데이터의 제공 및 이용 활성화에 관한 법률(제11956호)』에 따라 개방하여 국민들이 보다 쉽고 용이하게 공유•활용할 수 있도록 공공데이터(Datase

www.data.go.kr

표준 데이터지만 Open API도 지원한다. 각종 축제의 기간, 축제 내용, 개최 주소지 등이 포함되어있고, 일부 위경도 좌표가 누락되어 있다.

2-2. TourAPI 4.0 한국관광공사 국문 관광정보 서비스

https://www.data.go.kr/data/15101578/openapi.do#tab_layer_detail_function\

 TourAPI 4.0에서 제공하는 관광정보 서비스이다. 행사, 관광지, 문화시설, 숙박 등 다양한 국내 관광 관련 정보 및 API를 제공한다.

 

 두 API 중 상대적으로 데이터가 많고, 위경도 누락이 적으며, 사진 url까지 제공해주는 후자를 선택하기로 했다.

3. 구현

 위에서 볼 수 있듯이 해당 API는 다양한 기능을 지원한다. 행사정보조회와 키워드 검색 기능이 따로 존재하고, 상세정보가 포함된 공통정보조회가 또 따로 존재한다. 현재 기능 구현을 위해서는 검색 기능이 필요하고, 상세정보를 시작일, 종료일과 같이 조회해야 하며, 무한스크롤 기반으로 동작해야 한다. 해당 조건을 충족시키기 위해 다음의 절차를 거쳐 구현하기로 했다.

  1. 우선 축제 데이터를 조회하여 자체 DB에 저장한다
    1. 상세정보는 미리 저장하지 않는다. 축제 하나당 공통정보조회를 1회 요청해야 하는 데, 1000개면 1000번의 요청이 불필요하게 사용된다.
  2. 축제 리스트 조회는 자체적으로 구현한다.
    1. 필요 시 당일 개최되지 않는 축제도 조회하도록 한다.
  3. 자체 서비스에서 축제 상세정보를 조회할 때 공통정보조회 API를 요청하여 가져와 DB에 있는 정보와 같이 가공하여 사용자에게 리턴한다.

 

 해당 포스팅에서는 1번 기능을 구현하도록 한다.

우선 설정 파일에 요청에 사용할 url과 미리 발급받은 서비스 키를 넣는다.

 

WebClient를 사용하여 요청을 할텐데, 그 전에 해당 API는 어떤 형식으로 응답을 받는지 확인해보자.

{
  "response": {
    "header": {
      "resultCode": "0000",
      "resultMsg": "OK"
    },
    "body": {
      "items": {
        "item": [
          {
            "addr1": "서울특별시 송파구 양재대로 932 (가락동)",
            "addr2": "가락몰 3층 하늘공원",
            "booktour": "",
            "cat1": "A02",
            "cat2": "A0207",
            "cat3": "A02070200",
            "contentid": "3113671",
            "contenttypeid": "15",
            "createdtime": "20240415100726",
            "eventstartdate": "20250509",
            "eventenddate": "20250511",
            "firstimage": "http://tong.visitkorea.or.kr/cms/resource/91/3484791_image2_1.jpg",
            "firstimage2": "http://tong.visitkorea.or.kr/cms/resource/91/3484791_image3_1.jpg",
            "cpyrhtDivCd": "Type3",
            "mapx": "127.1107693087",
            "mapy": "37.4960786971",
            "mlevel": "6",
            "modifiedtime": "20250401110550",
            "areacode": "1",
            "sigungucode": "18",
            "tel": "02-3435-0286",
            "title": "가락몰 빵축제 전국빵지자랑"
          }
        ]
      },
      "numOfRows": 1,
      "pageNo": 1,
      "totalCount": 1
    }
  }
}

 이를 대충 다시 그려보면 다음과 같다.

 이렇게 받을 DTO를 우선 만들어보자.

public class FestivalApiResponse {

    public FestivalApiInnerResponse response;

}

public class FestivalApiInnerResponse {

    private FestivalHeader header;
    private FestivalBody body;

}

public class FestivalHeader {

    private String resultCode;
    private String resultMsg;
}

public class FestivalBody {

    private FestivalApiItems items;

    private long numOfRows;
    private int pageNo;
    private long totalCount;

}

public class FestivalApiItems {

    private List<FestivalApiItem> item;
}

public class FestivalApiItem {

    private String addr1;
    private String addr2;
    private String booktour;
    private String cat1;
    private String cat2;
    private String cat3;

    //공공데이터 지정 축제 고유번호
    private String contentid;

    //축제: 15
    private String contenttypeid;
    private String createdtime;

    //축제 시작일 및 종료일
    private String eventstartdate;
    private String eventenddate;

    //원본 이미지 (500*333)
    private String firstimage;

    //썸네일 이미지(150*100)
    private String firstimage2;

    //저작권 유형  Type1:제1유형(출처표시-권장) / Type3:제3유형(제1유형 + 변경금지)
    private String cpyrhtDivCd;

    //(x, y): (경도(logt), 위도(lat))
    private String mapx;
    private String mapy;


    private String mlevel;
    private String modifiedtime;
    private String areacode;
    private String sigungucode;
    private String tel;

    //콘텐츠 제목
    private String title;



}

 

 편의상 애노테이션은 제외했다. 이제 WebClient를 이용하여 정보를 받아보자. 

 

3-1. 인코딩 문제

 서비스 키를 그대로 집어넣어서 요청을 하면 오류가 발생한다. 다음은 디버깅하여 발견한 서비스 키의 일부이다.

Expected: %3D%3D
Actual: 253D%253D

 

의도치 않은 인코딩이 발생한 것을 확인할 수 있었다.

    public DefaultUriBuilderFactory(String baseUriTemplate) {
        this.encodingMode = DefaultUriBuilderFactory.EncodingMode.TEMPLATE_AND_VALUES;
        this.parsePath = true;
        this.baseUri = UriComponentsBuilder.fromUriString(baseUriTemplate);
    }

UriBuilder는 기본적으로 인코딩 모드가 TEMPLATE_AND_VALUES이다. 

 

해당 모드는 템플릿과 값을 모두 인코딩하므로 이를 바꿔주어야 한다. 다음의 코드를 사용하여 바꿀 수 있다.

 

        DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(festivalUrl);
        uriBuilderFactory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);

        WebClient.builder().uriBuilderFactory(uriBuilderFactory).build();

3-2. DataBufferLimitException

 인메모리 사이즈는 기본값으로 256KB로 설정되어 있고, 이를 초과하면 발생하는 오류이다. 이를 수정하여 해결할 수 있다.

       FestivalApiResponse result = WebClient.builder()
                .uriBuilderFactory(uriBuilderFactory)
                .baseUrl(festivalUrl)
                .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(30 * 1024 * 1024))     //DataBufferLimitException 해결
                .build()
                .get()
                .uri(uriBuilder -> uriBuilder
                        .scheme("https")
                        .path("/searchFestival1")
                        .queryParam("numOfRows", 2000)
                        .queryParam("pageNo", 1)
                        .queryParam("MobileOS", "IOS")
                        .queryParam("MobileApp", "Tripot")
                        .queryParam("_type", "json")
                        .queryParam("listYN", "Y")
                        .queryParam("eventStartDate", "20250101")           //TODO: 이거 고려
                        .queryParam("serviceKey", festivalApiKey)
                        .build(true))
                .retrieve()
                .bodyToMono(FestivalApiResponse.class)
                .block();

해당 문제를 해결하고, uri를 적절히 설정하여 결과를 받아온다.

 

 if (result.getResponse()==null || !result.getResponse().getHeader().getResultCode().equals("0000")) {
            throw new CustomException(StatusCode.FESTIVAL_CREATE_FAIL);
        }

        List<FestivalApiItem> item = result.getResponse().getBody().getItems().getItem();

        for (FestivalApiItem festivalInfo : item) {

            if (!festivalRepository.existsByContentId(Long.valueOf(festivalInfo.getContentid()))) {
                Festival festival = Festival.builder()
                        .contentId(Long.valueOf(festivalInfo.getContentid()))
                        .title(festivalInfo.getTitle())
                        .location(festivalInfo.getAddr1() + " " + festivalInfo.getAddr2())
                        .imgUrl(festivalInfo.getFirstimage())
                        .startDate(stringToDate(festivalInfo.getEventstartdate()))
                        .endDate(stringToDate(festivalInfo.getEventenddate()))
                        .lat(Double.valueOf(festivalInfo.getMapy()))
                        .logt(Double.valueOf(festivalInfo.getMapx()))
                        .build();

                festivalRepository.save(festival);

            }
        }

새로운 행사 정보일 경우 이를 가공하여 저장한다.

 

 축제 데이터가 정상적으로 저장되는 것을 확인할 수 있다.