# 장소 DB 설계 (v1.3, 2026-11-02) WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다. 1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`. 이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다. ## 0. 전제 (이미 정한 것) | 전제 | 근거 | | --- | --- | | 장소 데이터는 **공공데이터(자동 갱신) + 운영자 큐레이션**만 저장한다 | 카카오는 ID·place_url·사용자가 입력한 글자 외 저장 금지 (`docs/research/pre-dev-check/kakao_place_id.md`) | | 카카오 응답은 LLM 에 보내지 않는다 → AI 후보는 자체 장소 DB 에서만 | 같은 문서 | | AI 추천은 **큐레이션 O 장소 우선 + 관광공사 장소(보조)**. 확인 안 된 상가정보 매장은 추천하지 않는다 | 2026-11-02 합의 | | 확인 안 된 공공데이터 장소도 **검색·지도에는 노출**, "영업시간 미확인 · 방문 전 확인" | 2026-11-02 합의 | | 관광공사 적재는 **지역 목록·동기화 API** 로 한다. 위치 검색은 반경 안 장소의 15–38%만 돌려준다 | `docs/research/data-check/` 정정 메모, 2026-11-02 실측 | | 1차 지역은 대구(도심·수성못·앞산), 한국어·영어 출시, 4개 언어 전제 | MVP 결정 | | 영업시간 문장은 `backend/src/shared/utils/opening-hours.ts` 로 해석한다 | 구현·테스트 완료 | ## 1. 전체 구조 ``` [공공데이터 API] [운영자] 관광공사 국문·영문·반려동물 큐레이션 표(CSV) 상가정보 · 축제(searchFestival2) │ │ 동기화 명령(일·주·분기) │ 가져오기 명령 ▼ ▼ ┌───────────┐ 1:0..1 ┌────────────────┐ │ place │──────────│ place_curation │ ← 동기화가 절대 건드리지 않는다 └───────────┘ └────────────────┘ │ │ 1:N │ │ └── place_i18n(영·일·중) │ ▼ ▼ place_listing (뷰) — 공공 값과 큐레이션 값을 합친 "앱이 보는 값" + 노출 플래그 │ ▼ 검색·지도·상세 API / AI 후보 조회 / 홈 동네 추천 festival_event — 축제·공연·행사 (홈·장소 상세의 축제 칸) place_sync_run — 동기화 기록 ``` 핵심은 두 가지다. 1. **공공 원본(`place`)과 사람이 확인한 값(`place_curation`)을 테이블로 나눈다.** 동기화가 아무리 덮어써도 큐레이션은 남는다. 2. **"앱이 보는 값"은 뷰 하나에서만 정한다.** 큐레이션 값 > 공공 값 > 미확인 순서를 여기서만 적용한다(규칙을 서비스마다 따로 적지 않는다). ```mermaid erDiagram place ||--o| place_curation : "확인 정보" place ||--o{ place_i18n : "언어별 텍스트" place ||--o{ festival_event : "개최 장소(선택)" place { uuid id PK text source "tour | store" text source_id "UK(source, source_id)" text category text name float lat float lng text status "active | hidden | gone" } place_curation { uuid place_id PK text verdict "recommend | not_recommend | pending" date verified_at } place_i18n { uuid place_id PK text lang PK text name } festival_event { uuid id PK text source_id UK date start_date date end_date } ``` ## 2. 테이블 ### 2.1 `place` — 공공데이터 장소 (출처 1건 = 1행) | 컬럼 | 타입 | 설명 | | --- | --- | --- | | `id` | uuid PK | 앱·공유 링크에 노출하는 ID | | `source` | text | `tour`(관광공사) · `store`(상가정보) | | `source_id` | text | contentid / bizesId. **`(source, source_id)` 유니크** — 동기화·큐레이션 가져오기의 연결 키 | | `category` | text | 자체 분류(§2.6). 검색 탭·AI 슬롯에 쓴다 | | `source_category` | text | 원본 코드(lclsSystm3 / indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 | | `source_type` | text | 관광공사 관광타입(contenttypeid). 소개정보(detailIntro2) 조회에 필요하다 | | `name`, `branch_name` | text | 한국어 이름, 지점명(상가정보 brchNm) | | `address_road`, `address_jibun` | text | | | `lat`, `lng` | double precision | WGS84. §5 위치 검색 | | `ldong_regn_cd`, `ldong_signgu_cd`, `adong_cd` | text | 법정동 시도(2)·시군구(3), 행정동(상가 8자리). 지역 필터·동기화 범위 | | `phone`, `homepage` | text | 관광공사만 | | `overview` | text | 소개(detailCommon2) | | `public_hours_text`, `public_rest_text` | text | 영업시간·쉬는날 **원문** (유형별 필드 매핑은 `tools/data-check/curation_candidates.py` 의 `INTRO_FIELDS`) | | `public_hours` | jsonb | 원문을 파서로 해석한 결과(`ParsedOpeningHours`). 동기화 때 다시 계산 | | `public_pet` | jsonb | 관광공사 반려동물 동반 정보 원문(acmpyTypeCd·acmpyPsblCpam·acmpyNeedMtr 등). 없으면 null = **미확인**(불가 아님) | | `public_pet_policy` | text | 위 원문을 동기화 때 요약한 값: `allowed`(전구역) · `partial`(일부구역) · null. 뷰는 이 값만 본다 | | `public_extra` | jsonb | 유형별 부가 정보(입장료·주차 등). 필터로 승격할 항목은 §8 | | `image_url`, `image_thumb_url` | text | firstimage / firstimage2 | | `image_license` | text | `Type1` · `Type3` · null. 화면에 출처 표시 필수 | | `status` | text | `active` · `hidden`(관광공사 비표출 showflag=0) · `gone`(다음 스냅샷에서 사라짐·폐업 추정) | | `source_modified_at` | timestamptz | 관광공사 modifiedtime | | `source_snapshot` | text | 상가정보 기준년월(stdrYm) | | `last_seen_at` | timestamptz | 마지막 동기화에서 확인된 시각 | | `detail_synced_at` | timestamptz | 상세(개요·영업시간)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다 — 하루 한도로 나뉜 적재를 이어서 받는 기준 | | `created_at`, `updated_at` | timestamptz | | - **지우지 않는다.** 사라진 장소는 `status=gone` 으로 숨긴다. 큐레이션과 코스가 그 장소를 가리키고 있을 수 있다. - 숙박도 저장한다(검색의 '숙소' 탭). AI 코스·큐레이션 대상에서는 뺀다. ### 2.2 `place_curation` — 운영자가 확인한 정보 (place 1 : 0..1) 큐레이션 표의 오른쪽 12칸이 그대로 옮겨 온다. | 컬럼 | 타입 | 큐레이션 표 칸 | 설명 | | --- | --- | --- | --- | | `place_id` | uuid PK, FK | 출처+출처ID 로 찾음 | | | `verdict` | text | 데이트추천(O/X) | `recommend` · `not_recommend` · `pending` | | `reason_ko` | text | 추천이유 | **큐레이터가 직접 쓴 문장만** (리뷰 문장 복사 금지) | | `reason_i18n` | jsonb | — | `{en: …}`. 자체 작성 글이라 기계 번역 가능 | | `hours_text`, `rest_text` | text | 영업시간(확인)·휴무(확인) | | | `hours` | jsonb | — | 위 문장을 파서로 해석한 결과 | | `pet_policy` | text | 반려동물(가능/일부/불가/미확인) | `allowed` · `partial` · `not_allowed` · `unknown` | | `pet_sizes` | text[] | 반려동물 조건 | `small` · `medium` · `large` (디자인의 소형·중형·대형견) | | `pet_conditions` | text | 반려동물 조건 | "테라스석만", "켄넬 필수" 등 | | `setting` | text | 실내/실외 | `indoor` · `outdoor` · `both` — 비 오는 날 실내 우선 | | `official_url` | text | 공식채널URL | 업체 본인 채널만 | | `verified_method` | text | 확인방법 | `phone` · `official_channel` · `visit` | | `verified_at` | date | 확인일 | 화면에 "11월 2일 확인"으로 노출 | | `verified_by` | text | 확인자 | 운영자 식별자 | | `recheck_due_at` | date | — | `verified_at + 6개월`(제안). 폐업 의심 시 앞당김 | | `memo` | text | 메모 | 내부용, API 로 내보내지 않는다 | | `updated_at` | timestamptz | | | - 카카오·네이버에서 본 정보는 어떤 칸에도 옮기지 않는다. 확인 근거는 업체 본인 채널·전화·방문뿐이다. ### 2.3 `place_i18n` — 언어별 표시 텍스트 | 컬럼 | 설명 | | --- | --- | | `place_id`, `lang` | PK. `lang` = `en` · `ja` · `zh-Hans` · `zh-Hant` | | `name`, `address`, `overview` | | | `origin` | `tour_en`(관광공사 영문) · `machine`(기계 번역) · `curator` | | `origin_id` | 영문 contentid (국문과 다르다) | | `updated_at` | | - 한국어는 `place` 본체에 둔다. - 관광공사 영문은 국문과 연결 필드가 없어 **좌표(50m 안) + 분류 코드**로 맞춘다. 맞지 않은 영문 항목은 기록만 하고 붙이지 않는다(첫 적재 때 매칭률을 잰다). ### 2.4 `festival_event` — 축제·공연·행사 | 컬럼 | 설명 | | --- | --- | | `id` uuid PK, `source_id` 유니크 | 관광공사 contentid | | `kind` | `festival`(EV01) · `performance`(EV02) · `event`(EV03) | | `name`, `start_date`, `end_date`, `lat`, `lng`, `address` | | | `place_id` | 개최 장소가 `place` 에 있으면 연결(선택) | | `fee_text`, `playtime_text`, `overview`, `image_url`, `image_license` | `usetimefestival` 은 **요금**이다(시간 아님) | | `i18n` | jsonb `{en: {name, overview}}` | | `status`, `last_seen_at` | | - "60일 넘는 상시 프로그램 제외"는 **조회할 때** 적용한다(저장은 그대로). ### 2.5 `place_sync_run` — 동기화 기록 `id`, `job`(tour_kor·tour_eng·tour_pet·store·event), `scope`(예: `27`), `started_at`, `finished_at`, `fetched`, `inserted`, `updated`, `hidden`, `gone`, `calls`(오퍼레이션별 호출 수 jsonb), `errors`(앞 20건), `snapshot`. 문의가 오면 "언제 받은 데이터인가"를 여기서 답한다. ### 2.6 자체 분류 (`category`) 검색 탭(관광지 · 축제 · 맛집·카페 · 숙소 · 산책·자연)과 AI 코스 슬롯을 이 값으로 고른다. 관광공사 대응은 「신분류체계정보 관광타입정보 연계 정의서」(`docs/data-openapi/`, 소분류 240개)를 보고 확정했다. **소분류 → 중분류 → 대분류 → 관광타입** 순서로 찾는다. 코드는 `backend/src/modules/place/sync/tour-mapper.ts` 가 기준이다. | category | 관광공사 | 상가정보 | 검색 탭 | AI 2순위 | | --- | --- | --- | --- | --- | | `nature` | NA 자연관광 전체 · VE03 도시공원 · VE040300 둘레길 | — | 산책·자연 | O | | `attraction` | HS 역사관광 · VE01 랜드마크 · VE02 테마공원 · VE04 골목길·문화거리·마을 · VE05 관광단지 | — | 관광지 | O | | `culture` | VE06 공연장·영화관 · VE07 박물관·전시관·미술관 · VE12 서점·기타문화시설 | — | 관광지 | O | | `leisure` | LS 레저스포츠 · EX 체험관광(공예·템플스테이·웰니스) · VE10 경기장·스포츠센터 | — | 관광지 | O | | `food` | FD 음식(아래 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 | O | | `cafe` | FD05 카페·찻집 · FD030100 제과 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 | O(관광공사만) | | `bar` | FD04 바·펍·주점 | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 | — | | `shopping` | SH 쇼핑(백화점·쇼핑몰·시장·사후면세점…) | — | 관광지 | — | | `lodging` | AC 숙박 | — | 숙소 | — | | `other` | VE08 연회장 · VE09 문화원·도서관·학교 | — | (전체에만) | — | - 저장하지 않는 것: EV 축제·공연·행사(→ `festival_event`), 관광타입 25 여행코스, 좌표가 없거나 한국 밖인 항목. - 2026-11-02 대구 도심 쇼핑 135곳 대부분이 `SH040300` **사후면세점**(더현대 입점 브랜드 매장)이었다. 쇼핑은 큐레이션으로만 AI 후보가 된다. - 분류 코드가 비면 관광타입(12 관광지·14 문화시설·28 레포츠·32 숙박·38 쇼핑·39 음식점)으로 대신한다. ## 3. "앱이 보는 값" — `place_listing` 뷰 `place` LEFT JOIN `place_curation` 으로 만든다. API·AI 는 이 뷰만 읽는다. **값의 우선순위** | 항목 | 규칙 | 화면 표기 | | --- | --- | --- | | 영업시간 | 큐레이션 `hours` → 공공 `public_hours`(해석 성공 시) → 미확인 | "11월 2일 확인" / "출처 한국관광공사" / "영업시간 미확인 · 방문 전 확인" | | 반려동물 | 큐레이션 `pet_policy` → 공공 `public_pet` 있으면 해당 값 → `unknown` | 같은 방식 | | 실내/실외 | 큐레이션 `setting` → null | | **노출 플래그** (2026-11-02 합의를 그대로 옮긴 것) | 플래그 | 조건 | 쓰는 곳 | | --- | --- | --- | | `is_searchable` | `status = active` | 검색·지도·상세 | | `is_home_pick` | `verdict = recommend` **이고 `verified_at` 있음** | 홈 동네 추천 | | `ai_tier` | 1: `verdict = recommend` 이고 `verified_at` 있음 / 2: `source = tour` 이고 코스 분류(숙박·쇼핑 매장 제외)이며 `verdict` 가 `not_recommend` 가 아님 / null: 그 외(**상가정보 미확인 매장 포함**) | AI 후보 조회 | - `not_recommend` 는 추천에서만 빠지고 검색에는 남는다. - 추천(O)만 표시하고 아직 확인하지 않은 상가정보 매장은 AI·홈 어디에도 쓰지 않는다(O/X 표시와 확인은 큐레이션의 서로 다른 단계다). 관광공사 장소는 확인 전이라도 2순위로 남는다. - 큐레이션 행이 있어도 `verified_at` 이 비었으면 영업시간·반려동물 값을 쓰지 않는다(확인 안 된 값을 확인된 것처럼 보이지 않게). - 2026-11-02 임시 DB 에서 8가지 경우(관광공사·상가 × 큐레이션 없음/추천+확인/추천만/X/사라짐, 숙소, 점포별 상이)로 규칙을 확인했다. 조회 API 를 만들 때 같은 경우를 DB 를 쓰는 e2e 테스트로 고정한다. ## 4. 동기화 모두 **Nest 애플리케이션 컨텍스트로 도는 CLI 명령**으로 만든다. API 서버 프로세스 안에서 돌리지 않는다. 운영 서버의 cron 이 부른다. 키는 backend env 의 `PUBLIC_DATA_SERVICE_KEY`(이미 example 에 있음). 호출 수는 `place_sync_run.calls` 에 남기고, **오퍼레이션별 하루 상한**(개발 계정 1,000)에 닿으면 멈춘 뒤 다음 날 이어서 한다. | 작업 | 주기 | 방법 | 대구 규모(2026-11-02) | | --- | --- | --- | --- | | 관광공사 국문 초기 적재 | 1회 | `areaBasedList2`(lDongRegnCd=27) 전체 → 항목마다 `detailCommon2`·`detailIntro2` | 목록 1,193건 · 상세 오퍼레이션별 약 1,200회 → **개발 계정으로 2일** | | 관광공사 국문 변경분 | 매일 | `areaBasedSyncList2`(modifiedtime=어제) → 바뀐 것만 상세 재조회, `showflag=0` → `hidden` | 하루 수십 건 예상 | | 반려동물 | 매주 | `detailPetTour2` contentId 없이 전체(약 9,700건, 10회) → contentid 로 붙임 | 10회 | | 관광공사 영문 | 매주 | 영문 `areaBasedList2` → 좌표+분류로 매칭 → `place_i18n(en)` | | | 상가정보 | 분기 | 대상 행정동 `storeListInDong`(divId=adongCd) × 데이트 업종 → bizesId 로 upsert. **새 스냅샷에 없는 ID → `gone`** | 도심·수성못·앞산 행정동 | | 축제·행사 | 매일 | `searchFestival2`(오늘−60일 ~ 오늘+60일) | | | 폐업 대조 | 분기 | 행안부 인허가(휴게음식점·제과점·일반음식점) 영업상태와 주소·상호로 대조 → `gone` 후보 | 활용신청 후 | - 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트). - 동기화는 `place` 만 쓴다. `place_curation` 은 읽지도 쓰지도 않는다. ### 4.1 실행 방법 (구현됨: 관광공사 국문·반려동물·상가정보) ```bash # 개발 — 키가 있는 백엔드 컨테이너 안에서 (dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development) dc exec backend npm run migration:run # 스키마 먼저 dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 → 상세. 한도에 닿으면 멈춘다 dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회) dc exec backend npm run place:sync -- store # 상가정보 데이트 업종(대구 중구·남구·수성구, 30회 안팎). 분기마다 dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수 # 다음 날 같은 tour-kor 명령을 다시 실행하면 남은 상세를 이어서 받는다 # 운영 — cron node dist/commands/place-sync.js tour-kor ``` - 한도: `TOUR_API_DAILY_LIMIT`(비우면 1,000) − 여유분 20. 오늘(KST) `place_sync_run` 에 기록된 호출부터 센다. 같은 키를 점검 스크립트 등 다른 곳에서 쓴 호출은 기록에 없으므로, 서버가 한도 초과(코드 22)로 거부하면 그때 멈춘다. - 상세는 nature → attraction → culture → cafe → food … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터). - 목록을 끝까지 받았을 때만 사라진 장소를 숨기고, 목록이 기존 노출 장소의 80% 미만이면 원본 장애로 보고 숨기지 않는다. - 원본 수정일(modifiedtime)이 바뀐 장소는 `detail_synced_at` 을 비워 상세를 다시 받는다. - 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경). 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾸고, 결과가 기존의 80% 미만이면 건너뛴다. 업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다. - 2026-11-02 임시 DB + 가짜 상가정보 API 로 확인: 첫 적재 → 다음 분기 폐업 추정·업종 변경·신규 → 급감 시 폐업 처리 건너뜀 → 한도 0 이면 아무것도 바꾸지 않음. - 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영. ## 5. 위치 검색 - `lat`·`lng` double precision + 인덱스. 조회는 "경계 사각형으로 1차 필터 → 하버사인 거리로 정렬". - 대구 규모(수천 건)에서는 충분하다. **PostGIS 는 지금 도입하지 않는다.** 전국 확장으로 수십만 건이 되거나 다각형(동네 경계) 검색이 필요해지면 `DB_IMAGE` 를 PostGIS 이미지로 바꾸고 geography 컬럼 + GiST 인덱스를 추가한다. ## 6. 큐레이션 가져오기 - MVP 는 **CSV 가져오기 명령**으로 시작한다. 운영자 웹 화면은 2단계. - 입력: `data/curation/<지역>/*.csv` (정렬표·시범표 형식 그대로). - `(출처, 출처ID)` → `place(source, source_id)` 로 찾는다. 장소가 없으면(아직 적재 전·사라짐) 건너뛰고 보고한다. - 값 검증: `O/X`, `가능/일부/불가/미확인`, `실내/실외`, `전화/공식채널/방문`, 날짜 형식. 틀린 행은 반영하지 않고 행 번호와 함께 보고한다. - 먼저 **미리보기(dry-run)** 로 바뀔 내용을 보여 주고, 확인 후 반영한다. - 큐레이션 표는 운영 자산이다. 저장소에 커밋할지(공공데이터 + 우리가 확인한 사실만 담김), 구글 시트로 옮길지는 따로 정한다. ## 7. 다른 테이블과의 연결 (코스 설계 때 확정) 코스의 장소 한 칸(`course_stop`)은 **둘 중 하나**만 가진다. - `place_id` — 자체 장소 DB 의 장소 - `kakao_place_id` + `kakao_place_url` + `user_label`(사용자가 직접 입력한 이름) — 카카오 검색으로 직접 추가한 장소 카카오 장소는 이름·주소·좌표를 저장할 수 없다. **지도에 어떻게 표시할지는 코스 설계 때 정한다**(예: 사용자가 지도에서 직접 찍은 위치). ## 8. MVP 에서 하지 않는 것 - 카카오·네이버 데이터 저장, UGC 리뷰 - 운영자 웹 관리 화면(CSV 가져오기로 대신) - 상가정보 전국 적재(대상 행정동만) - 입장료·주차 필터의 정규화(`public_extra` 에 원문으로 두고, 디자인의 '무료 입장'·'주차' 필터는 큐레이션 칸 추가 또는 원문 해석으로 2차에 결정) ## 9. 확인이 필요한 것 | 항목 | 언제 | | --- | --- | | `areaBasedSyncList2` 의 modifiedtime 이 "그날"인지 "그 이후"인지 | 변경분 동기화 첫 실행 | | 영문 ↔ 국문 매칭률 | 영문 첫 적재 | | Type3(변경금지) 이미지를 카드에서 잘라 보여 주는 것이 '변경'인지 | 화면 구현 전 (공공누리 기준) | | 상가정보 반경·행정동 API 가 빠뜨리는 업소가 있는지 | 상가정보 첫 적재 (동 단위 결과와 반경 결과 비교) | | 큐레이션 표 보관 위치(저장소 / 구글 시트) | 큐레이션 본격 시작 전 | ## 10. 구현 순서 (제안) 1. ✅ 엔티티 + 마이그레이션: `place`, `place_curation`, `place_i18n`, `festival_event`, `place_sync_run`, `place_listing` 뷰 (2026-11-02) 2. ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일) 3. ✅ 상가정보 적재 명령(대구 중구·남구·수성구 데이트 업종) (2026-11-02) 4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영) 5. 조회 API: `GET /api/places`(검색·필터·커서 페이지네이션), `GET /api/places/{id}`, `GET /api/events` 6. 일·주·분기 동기화 + 폐업 대조