개인 프로젝트/modu

[daon] 3일차 개발 회고록

highlev 2026. 5. 12. 01:14

개발 일지 (한국관광공사 API 기반 관광지/무장애 데이터 배치 적재 구조 설계)

오늘의 주요 작업

1. 배치 시스템을 만들기 위한 데이터 분석

  • 현재 DB에 적재하기 위해 필요한 api는 다음과 같다.
일반 관광 API
- areaBasedSyncList2
- detailCommon2
- detailIntro2
- detailInfo2

무장애 관광 API
- areaBasedSyncList2
- detailWithTour2
  • 각각의 api에 대해 조금 더 깊이 다뤄보자.

일반 관광 API

areaBasedSyncList2

  • 일반 관광지 목록을 동기화하기 위한 목록 API
  • 전국에 있는 관광지들에 대해 대략적인 정보를 가져온다.
  • 이 api가 attraction 테이블의 70퍼센트 정도를 담당한다.
  • modifiedtime 옵션을 넣지 않으면 초기 적재에 사용할 수 있고, 초기 적재가 완료되면, modifiedtime 옵션을 추가하여, 새로 수정된 데이터를 반영할 수 있다.
contentid        관광지 고유 ID
contenttypeid    관광 타입 ID
title            관광지 이름
addr1            주소
addr2            상세주소
areacode         지역코드
sigungucode      시군구코드
cat1             대분류
cat2             중분류
cat3             소분류
mapx             경도
mapy             위도
firstimage       대표 이미지
firstimage2      썸네일 이미지
createdtime      최초 등록일
modifiedtime     수정일
tel              전화번호
zipcode          우편번호
showflag         표출 여부

detailCommon2

  • 앞서 언급한areaBasedSyncList2와 비슷하지만, 추가로 overview라는, 해당 관광지에 대한 부연 설명이 텍스트로 주어짐
  • 사용자 입장에서는 이런 부연 설명을 읽어보는 것이 해당 관광지를 이해하는 데에 큰 도움이 될 수 있기 때문에, api를 사용함

detailIntro2

  • 관광 타입별 소개 정보 API
  • 특이한 점은, contentTypeId 필드에 따라서, 응답 받는 필드 자체가 달라진다. 예를 들어서 설명해보겠다.
  • 타입이 관광지인 경우
infocenter       문의 및 안내
parking          주차시설
restdate         쉬는 날
usetime          이용시간
chkbabycarriage  유모차 대여 여부
chkpet           애완동물 동반 가능 여부
chkcreditcard    신용카드 가능 여부
expguide         체험안내
expagerange      체험가능 연령
  • 타입이 음식점인 경우
firstmenu        대표 메뉴
treatmenu        취급 메뉴
opentimefood     영업시간
restdatefood     쉬는 날
parkingfood      주차시설
reservationfood  예약안내
  • 이런 식으로, contentId가 달라질 때마다 필드 자체가 바뀌기 때문에, 관광지 카테고리에 따른 응답 분기를 잘해야한다.

detailInfo2

  • 하나의 관광지에 여러 줄로 반복되는 정보를 가져온다.
infoname = 이용가능시설
infotext = 팔각정, 분수쇼, 조명시설 ...

infoname = 화장실
infotext = 있음

infoname = 한국어 안내서비스
infotext = 없음

detailIntro2와의 공통점/차이점

  • 공통점
    • contentTypeId 별로 각기 다른 종류의 데이터를 가져온다.
  • 차이점
    • 각기 다른 종류의 데이터라고 해도, 필드명은 infoname, infotext로 정형화 되어있다.
    • detailIntro2의 데이터는 key에 해당하는 값이 원자값이었는데, detailInfo2는 팔각정, 분수쇼, 조명시설.. 등 여러 value가 한번에 다 들어가있을 수 있다.

치명적인 이슈

detailInfo2와 detailIntro2는 요청 파라미터에 contentId가 필요하다.
  • 이말인 즉슨, 한 관광지를 조회하기 위해 하나의 트래픽을 소모해야한다는 뜻이다.
  • 우리는 개발 전용으로 이 API를 사용하기 때문에, 하루에 1000번의 API 요청만 가능하다.
  • 한 관광지에 대해서 detailInfo2와 detailIntro2를 요청하기 위해서는 2번의 API 요청을 해야한다는 소리이다.
  • 그렇기 때문에, 초기 배치에서 이 detailInfo2와 detailIntro2를 돌리기에는 너무 큰 리스크라는 결론이 난다.
  • 결과적으로, detailInfo2와 detailIntro2는 MVP를 다 뽑고, DB가 안정화되고 나서 스케줄러를 통해 조금씩 정보를 가져오는 용도로 사용할 계획이다.

무장애 관광 API

areaBasedSyncList2

  • 앞서 언급했던 일반 관광 API의 areaBasedSyncList2와 동일하다.
  • 우리 서비스의 MVP를 뽑기 위해서, 나는 이 API에서 데이터를 우선적으로 가져왔다.
  • attraction 테이블의 accessibleCandidate 컬럼을 두어서, 이 필드값이 true면 무장애 관광 API에 존재하는 관광지라는 것을 표시했다.

detailWithTour2

  • 이 서비스의 본체 API이다. 이 API가 있기 때문에, 우리가 이 서비스를 계획할 수 있었다.
  • 무장애 상세정보를 제공하는 API이다.
- 지체장애 관련
parking           장애인 주차장
publictransport   대중교통
route             접근로
ticketoffice      매표소
promotion         홍보물
wheelchair        휠체어 대여
exit              출입통로
elevator          엘리베이터
restroom          장애인 화장실
auditorium        관람석
room              객실
handicapetc       지체장애 기타 상세

- 시각장애 관련
braileblock       점자블록
helpdog           보조견 동반
guidehuman        안내요원
audioguide        오디오가이드
bigprint          큰 활자 홍보물
brailepromotion   점자 홍보물 및 점자표지판
guidesystem       유도 안내 설비
blindhandicapetc  시각장애 기타 상세

- 청각장애 관련
signguide          수화 안내
videoguide         자막 비디오가이드 / 영상 자막 안내
hearingroom        객실
hearinghandicapetc 청각장애 기타 상세

-영유아가족 관련
stroller          유모차
lactationroom     수유실
babysparechair    유아용 보조의자
infantsfamilyetc  영유아가족 기타 상세
  • 응답 예시는 다음과 같다.
parking = 장애인 주차장 있음_무장애 편의시설
wheelchair = 대여가능(수동휠체어 2대)
exit = 주출입구는 경사로가 있어 휠체어 접근 가능함
restroom = 장애인 화장실 있음

accessibility_info 테이블의 등장 배경

  • 응답 예시를 보면 알겠지만, 각 필드에 대해서 데이터가 정형화되어있지 않다.
  • 그냥 일반 raw_string으로 저장이 되어 있기 때문에, 무장애 정보를 저장을 하기 위해서는 accessibility_info 테이블을 만들어서, 이 accessibility_info와 attraction을 N:1 관계를 가지도록 설정을 해야한다.
  • 이렇게 되면, 하나의 관광지는 여러 무장애 정보(ex. 휠체어 가능, 장애인 화장실 보유)를 가질 수 있게 된다.

accessibility_info 테이블 구조

  • 이 테이블 구조의 핵심은, API로 받아온 각 raw_value를 분석해서, status, type, category를 나누는 것이다.
  • 이렇게 종류별로 구분을 해놓으면, 나중에 필터링 기능을 추가하기에 용이한 상태가 된다.

category, type 분류 체계 정해놓기

  • 카테고리 분류는, 이미 API 명세서에 나와있는 구조를 참고하여 진행하였다.

  • 이런 식으로, 각 필드별로 어느정도 카테고리화가 되어있어서 이 부분을 참고하였다.
  • type은 필드마다 고유한 type을 만들었기 때문에, 필드의 개수만큼 type을 만들었다.
  • 무장애 정보를 정형화된 컬럼으로 저장을 하기 위해 다음과 같이 분류 체계를 만들어 놓았다.
private static final Map<String, AccessibilityFieldMapping> WITH_TOUR_FIELD_MAP = Map.ofEntries(
            entry("parking", AccessibilityCategory.PHYSICAL, AccessibilityType.ACCESSIBLE_PARKING),
            entry("publictransport", AccessibilityCategory.PHYSICAL, AccessibilityType.PUBLIC_TRANSPORT),
            entry("route", AccessibilityCategory.PHYSICAL, AccessibilityType.ROUTE),
            entry("ticketoffice", AccessibilityCategory.PHYSICAL, AccessibilityType.TICKET_OFFICE),
            entry("wheelchair", AccessibilityCategory.PHYSICAL, AccessibilityType.WHEELCHAIR),
            entry("exit", AccessibilityCategory.PHYSICAL, AccessibilityType.EXIT),
            entry("elevator", AccessibilityCategory.PHYSICAL, AccessibilityType.ELEVATOR),
            entry("restroom", AccessibilityCategory.PHYSICAL, AccessibilityType.ACCESSIBLE_RESTROOM),
            entry("auditorium", AccessibilityCategory.PHYSICAL, AccessibilityType.AUDITORIUM),
            entry("room", AccessibilityCategory.PHYSICAL, AccessibilityType.ROOM),
            entry("handicapetc", AccessibilityCategory.PHYSICAL, AccessibilityType.ETC),
            entry("braileblock", AccessibilityCategory.VISUAL, AccessibilityType.BRAILLE_BLOCK),
            entry("helpdog", AccessibilityCategory.VISUAL, AccessibilityType.HELP_DOG),
            entry("guidehuman", AccessibilityCategory.VISUAL, AccessibilityType.GUIDE_HUMAN),
            entry("audioguide", AccessibilityCategory.VISUAL, AccessibilityType.AUDIO_GUIDE),
            entry("bigprint", AccessibilityCategory.VISUAL, AccessibilityType.BIG_PRINT),
            entry("brailepromotion", AccessibilityCategory.VISUAL, AccessibilityType.BRAILLE_PROMOTION),
            entry("guidesystem", AccessibilityCategory.VISUAL, AccessibilityType.GUIDE_SYSTEM),
            entry("blindhandicapetc", AccessibilityCategory.VISUAL, AccessibilityType.ETC),
            entry("signguide", AccessibilityCategory.HEARING, AccessibilityType.SIGN_GUIDE),
            entry("videoguide", AccessibilityCategory.HEARING, AccessibilityType.VIDEO_GUIDE),
            entry("hearingroom", AccessibilityCategory.HEARING, AccessibilityType.ROOM),
            entry("hearinghandicapetc", AccessibilityCategory.HEARING, AccessibilityType.ETC),
            entry("stroller", AccessibilityCategory.INFANT_FAMILY, AccessibilityType.STROLLER),
            entry("lactationroom", AccessibilityCategory.INFANT_FAMILY, AccessibilityType.LACTATION_ROOM),
            entry("babysparechair", AccessibilityCategory.INFANT_FAMILY, AccessibilityType.BABY_SPARE_CHAIR),
            entry("infantsfamilyetc", AccessibilityCategory.INFANT_FAMILY, AccessibilityType.ETC)
    );

정형화된 분류 체계를 이용해서, 각 필드의 raw_value를 읽어서 분류하기

private static final List<String> NEGATIVE_KEYWORDS = List.of(
            "없음",
            "불가",
            "미설치",
            "미제공",
            "제공 안함",
            "제공안함",
            "이용불가",
            "해당없음",
            "해당 없음"
    );

    private static final List<String> PARTIAL_KEYWORDS = List.of(
            "부분",
            "일부",
            "일부구역",
            "일부 구역",
            "일부공간",
            "일부 공간",
            "보조 필요",
            "직원 지원",
            "제한",
            "조건"
    );

    private static final List<String> NEED_CHECK_KEYWORDS = List.of(
            "문의",
            "확인",
            "사전",
            "변동",
            "현장",
            "유동",
            "미정"
    );

    private static final List<String> POSITIVE_KEYWORDS = List.of(
            "있음",
            "가능",
            "대여",
            "설치",
            "운영",
            "제공",
            "구비",
            "동반 가능"
    );

    public AccessibilityStatus parse(String rawValue) {
        if (rawValue == null || rawValue.isBlank()) {
            return AccessibilityStatus.UNKNOWN;
        }

        String normalized = normalize(rawValue);

        if (containsAny(normalized, NEGATIVE_KEYWORDS)) {
            return AccessibilityStatus.UNAVAILABLE;
        }
        if (containsAny(normalized, PARTIAL_KEYWORDS)) {
            return AccessibilityStatus.PARTIAL;
        }
        if (containsAny(normalized, NEED_CHECK_KEYWORDS)) {
            return AccessibilityStatus.NEED_CHECK;
        }
        if (containsAny(normalized, POSITIVE_KEYWORDS)) {
            return AccessibilityStatus.AVAILABLE;
        }
        return AccessibilityStatus.NEED_CHECK;
    }
  • 사용자 입장에서는 신뢰성이 가장 중요하기 때문에, 있다면 있다고, 없다면 없다고, 애매하면 애매하다고 공지를 해둘 필요가 있다.
  • "일부 가능"이라고 해놓은 관광지를, 단순히 "가능"이라고 표시를 해두면 사용자 입장에서는 배신감을 느낄 수 있기 때문이다.
  • 그렇기 때문에 위와 같이 raw_value의 문자열을 읽어서, 등장하는 키워드에 따라 status(가능, 일부가능, 불가능 등)을 구분해서 DB에 저장하도록 했다.

2. 데이터 적재를 위한 배치 시스템 구축

  • 데이터 저장을 위한 엔티티와 파싱 로직은 준비가 완료된 상태이다.
  • 아까도 언급했지만, 일일 트래픽 한도는 1000건이기 때문에, detailIntro2/deatilInfo2 API를 사용하지 않더라도, 무장애 관련 관광지를 모두 저장을 하기 위해서는 여러 날에 걸쳐서 저장을 해야한다.
  • 그렇기 때문에 스케줄러를 생성해서, 매일 같은 시각에 API를 호출하도록 만들었다.

스케줄러 우선순위 설정

  1. 무장애 API에 있는 관광지들의 일반 정보 저장
  2. 무장애 API에 있는 관광지들의 부가 정보 저장
  3. 무장애 API에 있는 관광지들의 무장애 상세 정보 저장
  4. 일반 API에 있는 관광지들의 일반 정보 저장
  5. 일반 API에 있는 관광지들의 부가 정보 저장
  6. 일반 API에 있는 관광지들의 상세 정보 저장 (detailIntro2, detailInfo2)

무장애 API에 있는 관광지들의 일반 정보 저장

  • 아래 코드는, 무장애 API와 일반 API의 관광지 일반 정보를 저장하는 스케줄러이다.
  • 비록 일일 트래픽 한도가 1000개이긴 하지만, 무장애 관광지의 개수는 1만개 정도이고, 한번에 여러 관광지 정보를 조회할 수 있기 때문에 금방 적재가 가능했다.
  • 현재 일반 관광지 저장은 비활성화 해놓은 상태이고, 무장애 관광지에 대해서만 일반 정보를 저장하도록 하여 적재를 완료한 상태이다.
public void runInitialLoad() {
        if (!props.isEnabled()) {
            return;
        }

        int maxPages = props.getMaxPagesPerRun();

        log.info(
                "초기 배치 스케줄러 실행 시작. generalEnabled={}, accessibleEnabled={}, maxPagesPerRun={}",
                props.isGeneralEnabled(),
                props.isAccessibleEnabled(),
                maxPages
        );

        try {
            // 무장애 관광지
            if (props.isAccessibleEnabled()) {
                initialLoadService.runOneJob(TourBatchCursorJobType.ACCESSIBLE_LIST, maxPages);
            }
            // 일반 관광지
            if (props.isGeneralEnabled()) {
                initialLoadService.runOneJob(TourBatchCursorJobType.GENERAL_LIST, maxPages);
            }

            log.info("관광지 일반 정보 적재 완료");

        } catch (TourApiTrafficExceededException e) {
            log.warn("하루 API 사용량 초과.", e);
        } catch (Exception e) {
            log.error("스케줄러 에러 발생으로 인한 종료.", e);
        }
    }
  • 무장애 관광지 정보 저장 결과

  • 앞서 언급했다시피, 무장애 관광지 API의 areaBasedSyncList2에서 언급된 관광지들의 accessible_candidate 컬럼의 값은 1이다.
  • 이 관광지들에 대해서, 아직 common_detail_status는 NOT_STARTED로, 부가 정보인 overview는 적재가 되지 않은 상태이다.
매일 똑같은 로직을 실행하면, 결국에 똑같은 attraction_id에 대해서만 조회되는 것 아닌가?
  • 결론 : 그렇지 않다.
  • 왜냐하면 attraction 테이블의 accessibility_status 필드와 common_detail_status 필드를 이용하기 때문이다.
  • API를 요청할 때, 이 필드 값이 SUCCESS가 아닌 필드에 대해서만 새로운 API를 요청하기 때문에, 결과적으로는 매번 새로운 관광지에 대한 API를 요청하게 된다.

무장애 API에 있는 관광지들의 부가 정보 + 무장애 상세 정보 저장

  • 무장애 관광지의 일반 정보들은 모두 저장이 된 상태이기 때문에, 부가 정보와 무장애 상세 정보를, 스케줄러를 이용해서 주기적으로 적재를 시도한다.
  • 현상황은, 이 단계에 머물러 있는 상황이다.

부가 정보가 저장된 뒤의 DB 현황

  • 이렇게 common_detail_status의 값이 SUCCESS가 된 관광지들은 성공적으로 overview에 정보가 저장되었음을 알 수 있다.

무장애 상세 정보가 저장된 뒤 DB 현황

  • 예시로, 고유 attraction_id가 1인 관광지에 대해 accessibility_info 정보를 가져왔다.
  • UNKNOWN으로 저장된 이유는, detailWithTour2에서 해당 필드에 대한 정보가 없기 때문이다.
  • UNKNOWN으로 저장된 필드는 나중에 검색 대상에서 제외를 할 계획이다.
  • 첫 번째 레코드를 보면, parking 필드의 raw_value가 "장애인 주차장 있음(8개)" 이기 때문에, status는 AVAILABLE로 설정이 되었다.

향후 계획

  • 매일 같은 시각에 무장애 정보들을 적재하고 있기 때문에, MVP 구성을 위한 기본적인 구색은 갖춰졌다고 가정한다.
  • 스케줄러 실행을 통해 모든 무장애 정보가 저장이 되고 나면, 앞서 언급한 스케줄러 우선순위의 4번부터 차례대로 실행할 예정이다.
  • 이제 본격적인 기능 구현을 위해, 가장 기본적인 기능인 로그인, 회원가입 (feat. spring security)을 구현해보고자 한다.

'개인 프로젝트 > modu' 카테고리의 다른 글

[daon] 7일차 개발 회고록  (0) 2026.06.07
[daon] 6일차 개발 회고록  (0) 2026.05.29
[daon] 5일차 개발 회고록  (1) 2026.05.25
[daon] 2일차 개발 회고록  (0) 2026.05.06
[daon] 1일차 개발 회고록  (1) 2026.05.03