Files
wandergo/docs/design/place-db.md
T
ttipo 57f332a731
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m37s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m11s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m39s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m42s
CI / Frontend (lint · type-check · test) (push) Successful in 11m2s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m25s
fix(backend): 쉬는날 문구를 잘못 읽어 영업 중 표시가 틀리던 것 수정
관광공사 쉬는날(restdate) 원문에서 'X요일' 낱말만 골라 읽어서, 영업 중 표시가 틀린 장소가 17곳 있었다
(2026-11-05 관광공사 상세 26곳을 받은 뒤 점검).
- "공휴일이면 다음 첫 번째 평일 휴관" 의 '번째' 가 '매월 둘째 일요일' 규칙에 걸려 매주 월요일 휴무까지 빠졌다
  (국립대구박물관 등 4곳이 월요일에 영업 중으로 보였다)
- "월요일~목요일" 은 양 끝 요일만 읽고, "주말"·"평일" 은 아예 읽지 않았다(8곳)
- "일요일을 제외한 공휴일", "월요일이 공휴일인 경우 화요일 휴무" 처럼 조건 속 요일을 정기 휴무로 읽었다(5곳)

쉬는날 원문을 '/'·'※'·괄호 단위 구절로 나눠, 조건·예외 문구는 버리고 요일 범위·평일·주말까지 읽는다.
매주가 아닌 휴무(매월·격주·N주차 등)는 구절마다 판단해 안내로만 남기고, 시설별 쉬는날은 모든 시설이
쉬는 요일만 휴무로 둔다. 시기별 쉬는날은 어느 시기인지 몰라 계절 구분으로 두고 영업 중 판단을 하지 않는다.
영업시간 문장 속 "월요일 휴관" 구절에는 범위·평일을 적용하지 않는다 — 같은 구절의 "월~금 10:00~18:00" 은
여는 요일이다.

reparse-hours 로 22곳이 바뀌었고 모두 원문과 대조했다(위 17곳 + 격주·매달 휴무 4곳 + 시기별 1곳).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:16:50 +09:00

42 KiB
Raw Blame History

장소 DB 설계 (v1.7, 2026-11-03)

WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다. 1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령), 중복 병합, 조회 API(§11), 축제·행사 동기화·API와 KOPIS 공연 동기화(§12)를 구현했다 — 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. "앱이 보는 값"은 뷰 하나에서만 정한다. 큐레이션 값 > 공공 값 > 미확인 순서를 여기서만 적용한다(규칙을 서비스마다 따로 적지 않는다).
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 마지막 동기화에서 확인된 시각
merged_into_id uuid 같은 가게를 다른 출처가 이미 가지고 있으면 그 장소 id(상가정보 → 관광공사). 값이 있으면 검색·추천에 나오지 않는다 — §4.2
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일, 전시 183일(EVENT_MAX_SPAN_DAYS).
  • 여러 출처(2026-11-03, 마이그레이션 1793704967449-AddEventSourceAndVenue): source(tour 관광공사 · kopis 공연예술통합전산망 · kcisa 한국문화정보원 · dgfca 대구문화예술진흥원) + (source, source_id) 유니크. 종류에 exhibition, 장르(genre)·예매 링크(booking_url)· 공연장(venue_id → event_venue) 추가. KOPIS 공연은 좌표가 공연장 상세에만 있어 event_venue(출처별 시설 id 유니크)에 캐시한다.
  • 출처별 동기화는 자기 출처 행사만 숨긴다(관광공사 동기화가 KOPIS 공연을 숨기지 않게).

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 여행코스, 좌표가 없거나 한국 밖인 항목.
  • 좌표가 주소(없으면 법정동 코드)의 시·도 밖인 항목과, 좌표가 실제 위치가 아닌 것으로 확인한 항목(place.constants.ts TOUR_WRONG_LOCATION_IDS)도 받지 않는다. 이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다. 2026-11-04 회귀 점검: '오천서원'(주소 대구 수성구 · 좌표 포항), 대구올레 팔공산 3·4·5코스(주소·좌표 모두 운영 사무실이라 김광석길에서 '도보 7분'으로 보였다). 실제 좌표를 확인하면 큐레이션으로 되살린다.
  • 관광공사 사진 서버(tong.visitkorea.or.kr)의 http:// 주소는 https:// 로 바꿔 저장한다(image-url.ts preferHttps). 2026-11-04 대구 장소 사진 1,059건 중 279건이 http 였다(같은 서버가 https 로도 준다). 웹(https 페이지)에서는 http 사진이 막힌다.
  • 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 이고 merged_into_id 없음 검색·지도·상세
is_brand_store 사후면세점(SH040300)이고 백화점·쇼핑몰 자체가 아님 목록·필터에서 빼고 이름 검색(q)에만 낸다
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(오늘 ~ 오늘+90일, 대구+경북) 대구 1 · 경북 7 (90일)
KOPIS 공연 매일 pblprfr(오늘 ~ 오늘+90일) → 새·바뀐 공연만 상세 → 새 공연장만 시설 상세 대구 153 · 경북 91 (90일)
폐업 대조 분기 행안부 인허가(휴게음식점·제과점·일반음식점) 영업상태와 주소·상호로 대조 → gone 후보 활용신청 후
  • 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트).
  • 동기화는 place 만 쓴다. place_curation 은 읽지도 쓰지도 않는다.

4.1 실행 방법 (구현됨: 관광공사 국문·반려동물·상가정보·축제·KOPIS 공연)

# 개발 — 키가 있는 백엔드 컨테이너 안에서 (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 -- event                 # 축제·공연·행사(대구+경북, 오늘부터 90일 안). 매일. 새로 생기거나 바뀐 행사만 상세
dc exec backend npm run place:sync -- kopis                 # KOPIS 공연(대구+경북, 90일). 매일. KOPIS_SERVICE_KEY 필요. 첫 실행은 상세 250회 안팎(0.6초 간격, 약 4분)
dc exec backend npm run place:sync -- dedupe                # 같은 가게 병합(키 불필요). tour-kor·store 가 끝날 때도 자동으로 돈다
dc exec backend npm run place:sync -- reparse-hours         # 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요). 파서를 고친 뒤 한 번
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 … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터).
  • 목록을 끝까지 받았을 때만 사라진 장소를 숨긴다. 사라진 수가 5건을 넘고 기존의 20% 도 넘거나, 기존이 전부 사라졌으면 원본 장애로 보고 숨기지 않는다(sync/removal-guard.ts, 관광공사·상가정보·축제 공통). 비율만 보면 항목이 적은 곳의 정상 취소가 막혀서 바꿨다.
  • 원본 수정일(modifiedtime)이 바뀐 장소는 detail_synced_at 을 비워 상세를 다시 받는다.
  • 영업시간 해석(2026-11-03 대구 관광공사 1,174곳): 시간표 809 · 상시 개방 210 · 점포별 상이 75 · 영업시간 아닌 문장 20 · 원문 없음(숙소) 60. 원문은 구간이 구분 없이 붙거나("…20:00금요일~일요일 …"), 줄바꿈으로만 나뉘거나, "[토요일]" 머리말 다음 줄에 시간이 오는 등 형식이 제각각이다. 파서를 고친 뒤 reparse-hours 로 124곳을 바로잡았다(API 재호출 없음).
  • 2026-11-04 회귀 점검에서 더 고쳤다(reparse-hours 로 107곳 바뀜):
    • 줄바꿈 없이 붙은 글머리표("- 11:0021:00- 준비시간 16:0017:00- 마지막 주문 20:20", 107곳)를 구간으로 나눈다. 나누지 않으면 영업시간이 준비시간 구간에 섞여 낮에 '영업 종료'로 보였다(39곳). 요일 시간이 통째로 빈 곳도 41곳에서 0곳이 됐다.
    • "[칠성시장] … [칠성야시장] 일요일 …" 처럼 시설 머리말이 여럿이면 시설마다 시간표를 만들고 요일별로 합친다(어느 시설이든 열려 있으면 영업 중). 뒤 시설의 요일 시간이 앞 시설 시간을 지우지 않게 하려는 것이다.
    • '휴게시간'은 쉬는 시간으로 읽고, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분).
    • "금요일 / 토요일" 처럼 요일 사이의 '/' 는 나열로 읽고, "13:00~13:00" 같은 0분 구간은 버린다(24시간으로 읽지 않는다).
    • 남은 한계: 쉬는 시간은 시설·요일 구분 없이 하루 전체에 적용한다. 원문이 틀린 곳(팔공산 닭갈비 "준비시간(주말) 05:00~16:30")은 그대로다.
  • 2026-11-05 쉬는날 해석을 고쳤다(reparse-hours 로 22곳 바뀜, 영업 중 표시가 틀린 17곳 포함). 쉬는날 원문은 '/'·'※'·괄호 단위 구절로 읽는다.
    • 조건·예외 문구("월요일이 공휴일인 경우 화요일 휴무", "일요일을 제외한 법정공휴일", "공휴일 다음의 첫 번째 평일")의 요일은 정기 휴무로 읽지 않는다. '첫 번째'의 '째'가 '매월 둘째 일요일' 규칙에 걸려 국립대구박물관 등 4곳은 매주 월요일 휴무까지 빠져 있었다.
    • 요일 범위("월요일목요일", "평일(월금)")와 '평일·주말'을 요일로 읽는다. 영업시간 문장 속 "월요일 휴관" 구절에는 적용하지 않는다 — 같은 구절의 "월금 10:0018:00" 은 여는 요일이다. 쉬는날 원문의 여는 날 안내("화~일 운영")도 휴무로 읽지 않는다.
    • 매주가 아닌 휴무(매월·매달·격주·몇째 주·N주차·마지막 주)는 구절마다 판단해 안내로만 남긴다. 괄호 안 주기는 앞 구절을 꾸민다("매주 월요일 (매월 2주, 4주)").
    • 시설별 쉬는날("[북카페] 일요일 [다목적홀] 주말", "서문시장 매월 … / 대구서문 야시장 매주 월요일목요일")은 모든 시설이 쉬는 요일만 휴무로 둔다. 시기별 쉬는날("[3월7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(seasonal)으로 두고 영업 중 판단을 하지 않는다.
    • 남은 한계: 영업시간 문장 속 시설별 휴관("[휘트니스] … 매주 일요일 휴관", 엘리바덴 월성점)은 시설 전체 휴무로 읽힌다.
  • 상가정보는 storeListInDong(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(--signgu= 로 범위 변경). 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 gone(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다). 업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 source_snapshot 에 남긴다.
  • 2026-11-02 임시 DB + 가짜 상가정보 API 로 확인: 첫 적재 → 다음 분기 폐업 추정·업종 변경·신규 → 급감 시 폐업 처리 건너뜀 → 한도 0 이면 아무것도 바꾸지 않음.
  • 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영.

4.2 중복 병합 (같은 가게)

같은 가게를 관광공사·상가정보가 둘 다 가진 경우(2026-11-02 대구 약 21곳) 상가정보 쪽 merged_into_id 에 관광공사 장소 id 를 넣는다. 관광공사 쪽에 영업시간·사진·소개가 있어서 그쪽을 남긴다. 규칙(modules/place/shop-name.ts, sync/place-dedupe.ts):

  • 관광공사 쪽이 카페·식당·주점일 때만 — 박물관·공원·경기장 안의 카페("이디야커피 국립대구박물관점")를 그 시설과 합치지 않는다
  • 100m 안
  • 상호가 같거나(공백·괄호·기호 무시), 한쪽이 다른 쪽을 포함하고 짧은 쪽이 2글자 이상 — 한 글자 상호 '동'·'문' 오탐 방지
  • 후보가 여럿이면 가장 가까운 곳. 매번 전부 다시 계산한다(관광공사 장소가 숨겨지면 병합도 풀린다)
  • 상세 API 는 병합된 id 로 들어와도 병합 대상 장소를 보여 준다(공유 링크가 깨지지 않게)
  • ⚠️ 큐레이션 가져오기(4단계)는 병합된 상가정보 행을 가리키는 큐레이션을 병합 대상 장소로 옮겨야 한다

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} + 중복 병합 (2026-11-02), 축제 GET /api/events·/{id} + 축제 동기화 (2026-11-03)
  6. 일·주·분기 동기화 + 폐업 대조

11. 조회 API (구현됨)

비로그인으로 쓴다(둘러보기·검색 비로그인 허용 합의). 노출 규칙은 place_listing 뷰가 정하고 API 는 읽기만 한다.

엔드포인트 내용
GET /api/places q(이름) · lat+lng+radius(기본 1,500m, 100~20,000) · category(쉼표 구분) · pet · indoor · trusted · cursor · limit. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 {items, pageInfo} 에 total 포함
GET /api/places/{id} 상세 — 영업시간(원문·해석 상태·출처), 반려동물(크기·조건), 이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기. 병합된 id 면 대상 장소, 숨김·폐업 추정이면 RES_001
  • 목록 항목의 openNow 는 한국 시간 기준 영업 중 여부다. 알 수 없으면 null → 화면은 "영업시간 미확인 · 방문 전 확인".
  • 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제).
  • trusted=true 는 관광공사 장소와 운영자가 확인한 장소만 준다(확인 안 된 상가정보 매장·'추천 안 함' 판정 제외). 장소 상세 '함께 가기 좋은 곳'이 쓴다. 앱이 가까운 30곳을 받아 거르던 때는 도심처럼 상가가 빽빽한 곳에서 받은 곳이 모두 상가라 칸이 비었다 (2026-11-04 회귀 점검: 관광공사 장소 상세 860곳 중 30곳은 칸이 사라지고 186곳은 덜 보였다).
  • 행사 목록에서 끝(to)을 생략하고 없는 시작일(from=2030-13-01)을 보내면 500 이 나던 것을 VAL_001(400)로 고쳤다(2026-11-04).
  • 실패 코드: 검색 조건·커서·id 형식 오류 VAL_001(400), 없음 RES_001(404). 새 코드는 만들지 않았다.
  • 계약 테스트: backend/test/place.e2e-spec.ts — 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 검증한다(16개).
  • 아직 없는 것: 영어 등 다국어 응답(place_i18n), 지도 화면용 사각형(bbox) 조회.

12. 축제·공연·행사 (구현됨)

동기화 place:sync -- event [--regions=27,47] [--days=90] (매일)

  • 범위는 대구(27) + 경북(47): 2026-11-03 진단(tools/data-check/festival_region_check.py) 결과 앞으로 90일 관광공사 행사가 전국 106 · 대구 1 · 경북 7건이었다. 조회 조건 문제는 없었다(지역 코드 빈 행사 0건). 관광공사는 행사가 가까워져야 등록하는 경우가 많고 겨울은 행사 자체가 적다. 근교 행사를 거리와 함께 보여 주려고 경북까지 받는다.

  • searchFestival2 를 오늘~N일 기간으로 부른다. 서버는 기간이 겹치는 행사를 준다(종료일 ≥ 요청 시작일).

  • 지역 조건(lDongRegnCd)을 보내고, 응답에서도 다른 지역 행사는 한 번 더 거른다(서버가 조건을 무시해도 섞이지 않게).

  • 종류: lclsSystm2 EV01 축제 → festival, EV02 공연 → performance, EV03 행사 → event(분류가 비면 행사).

  • 상세(detailCommon2 소개 + detailIntro2 관광타입 15)는 새로 생기거나 수정일이 바뀐 행사만 받는다(detail_synced_at). 요금은 이름과 달리 usetimefestival, 공연 시간 playtime, 장소 eventplace, 홈페이지는 eventhomepage 우선.

  • 좌표가 없는 행사도 저장한다(근처 조회에서만 빠진다). 받은 기간 안에서 목록에 없는 행사는 숨긴다(원본 장애 판단은 §4.1).

  • 2026-11-03 임시 DB + 가짜 API 로 확인: 다른 지역·날짜 없는 행사 건너뜀 → 재실행 시 상세 재조회 없음 → 수정 1건만 상세 재조회, 취소 1건 숨김 → 빈 응답이면 숨기지 않음.

API

엔드포인트 내용
GET /api/events from·to(YYYY-MM-DD, 기본 오늘~30일, 최대 92일) · lat+lng+radius(기본 5km, 최대 150km — 근교 경북까지) · kind · cursor · limit. 기간과 겹치는 행사를, 좌표가 있으면 거리순·없으면 시작일순으로. 60일 넘는 상시 프로그램과 숨김 행사는 뺀다. 항목에 ongoing(오늘 진행 중)
GET /api/events/{id} 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001
  • 화면 규칙(2026-11-03 합의):
    • 홈 '이번 주말 축제·행사' = from=토 · to=일 · 동네 중심 좌표 · radius=50000 → 대구 전역(군위 포함)과 경산·칠곡 등 바로 옆 도시까지 거리순. 처음(2026-11-03)에는 관광공사 행사뿐이라 120km(경북)였다. KOPIS·문화포털이 들어온 뒤 대구 도심 기준 이번 주말 50km 32건 · 120km 42건이 되었고, 120km 면 영덕·영주(차로 2시간)까지 나와 2026-11-04 50km 로 줄였다.
    • 비면 같은 반경으로 from=오늘 · to=오늘+30일을 불러 제목을 '다가오는 축제·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다).
    • 장소 상세 '근처 축제·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다.
  • 계약 테스트: backend/test/festival.e2e-spec.ts — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.

12.1 KOPIS 공연 (구현됨)

동기화 place:sync -- kopis [--regions=27,47] [--days=90] (매일, KOPIS_SERVICE_KEY 필요)

대구는 공연이 많은데 관광공사에는 거의 없다. 그래서 KOPIS 공연예술통합전산망을 공연 출처로 쓴다. 조사: docs/research/pre-dev-check/culture_event_sources.md. 2026-11-03 사용자 확인: KOPIS 데이터 저장·포스터 재사용 가능.

  • 진단(2026-11-03, tools/data-check/culture_api_check.py)
    • 90일을 한 번에 조회할 수 있다(31일 제한 없음). https 동작.
    • 앞으로 90일 공연: 대구 153건(클래식 62 · 대중음악 30 · 뮤지컬 25 · 연극 16 …), 경북 91건.
    • 목록에는 공연시설 id·좌표·수정일이 없다. 그래서 상세와 시설 상세를 따로 부른다.
    • 공연시설 좌표는 5곳 중 5곳에 있었다. 다만 1곳(대구 중구 주소)이 경주 좌표였다 → 아래 좌표 검사를 만든 이유.
  • 순서(sync/kopis.sync.ts)
    1. 목록 pblprfr(signgucode, stdate=오늘, eddate=오늘+N일, rows=100). 끝난 공연(공연완료)·다른 시·도·중복은 건너뛴다. 목록을 끝까지 받았으면 받은 기간 안에서 목록에 없는 공연을 숨긴다(원본 장애 판단은 §4.1).
    2. 상세 pblprfr/{mt20id} — 새 공연, 어제 이후 등록·수정된 공연(목록 afterdate), 상세를 받은 지 7일 지난 공연만. 요금 pcseguidance → fee_text, 공연 시간 dtguidance → playtime_text, 예매처 첫 링크 → booking_url, 소개 = 줄거리 sty + 출연·관람 시간·관람 연령 줄. 장소 이름은 상세가 홀까지 주므로("대구콘서트하우스 (그랜드홀 …)") 상세 값을 쓴다.
    3. 공연시설 prfplc/{mt10id} — 진행 중·예정 공연이 쓰는 시설 중 새 시설과 30일 지난 시설만 → event_venue. 좌표는 주소의 시·도 영역 안일 때만 쓴다(geo.ts isInSido). 아니면 비우고 경고를 남긴다(그 공연은 근처 조회에서만 빠진다).
    4. 시설의 좌표·주소·전화를 공연에 복사한다. 조회 API 가 공연 좌표로 거리를 계산하기 때문이다. 시설 좌표를 고치면 다음 실행에 공연도 바뀐다.
  • 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]", "[대구 북구]", "[안동] (11월)")를 떼어 저장한다. 2026-11-03 244건 중 203건에 붙어 있었다. 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)"·"(11.10)" 같은 메모는 남긴다(kopis-mapper.ts stripRegionTag).
  • 일정이 바뀐 공연은 제목에 "[일정변경]" 을 붙인 채 새 id 로 다시 등록된다(2026-11-04 대구 1쌍 — 같은 공연장·같은 날 두 건). 제목 앞의 "[일정변경]" 은 떼고(cleanKopisTitle), 같은 공연장·같은 기간·같은 제목이면 먼저 등록된 것만 남긴다 (event-dedupe.ts isSameVenueSameName). 나머지는 넣지 않아 숨겨진다. 문화포털 전시의 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다 — 음악제 이름을 앞에 붙인 같은 날 다른 공연을 합쳤다 (2026-11-05 "포항국제음악제, 아티스트 포항 Ⅱ. 문성우" / "포항국제음악제, 포항시립교향악단 with …").
  • 포스터는 http://www.kopis.or.kr/… 로 온다. 앱이 http 이미지를 막으므로 https://kopis.or.kr/… 로 바꿔 저장한다(목록·상세 사진 모두 포스터).
  • 호출: 0.6초 간격(KOPIS 는 초당 10회에서 차단). 오퍼레이션별(pblprfr·pblprfr-detail·prfplc-detail) 하루 상한 980. 공식 한도는 밝혀져 있지 않다. 이후 하루 호출은 목록 4회 + 상세 수십 회로 예상한다.
  • 출처 표기: 조회 API 상세의 attribution.provider = "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)".
  • 검증: backend/test/kopis-sync.e2e-spec.ts. 가짜 KOPIS 응답과 실제 DB 로 다음을 확인한다. 첫 적재와 시설 좌표 복사, 틀린 좌표 비우기 → 재실행 시 상세·시설 재조회 없음 → 바뀐 공연만 상세 재조회, 없어진 공연 숨김 → 같은 공연 중복 숨김.
  • 알려진 한계
    • 관광공사 공연(EV02)과 같은 공연이 둘 다 나올 수 있다. 출처 간 같은 행사 병합은 아직 없다(조사 문서 3-3 의 규칙으로 나중에).
    • '이번 주말'은 기간이 겹치는지만 본다. 평일만 하는 공연도 섞인다(dtguidance 요일 해석은 나중에).
    • 기간이 60일 넘는 공연(대구 8건)은 상시 프로그램 규칙으로 목록에서 빠진다.

12.2 한국문화정보원 전시 (구현됨)

동기화 place:sync -- kcisa [--regions=27,47] [--days=90] (매일, 공공데이터포털 키 — 15138937 활용신청 필요)

  • 진단(2026-11-03): 앞으로 90일 대구 42건(전시 13 · 음악/콘서트 17 …), 경북 17건(전시 1). 좌표 채움 대구 38/42.
  • 순서(sync/kcisa.sync.ts)
    1. 목록 area2(sido=대구·경북, from·to). 분야가 '전시'인 것만 넣는다 — 공연은 KOPIS 와 겹친다. 관광공사·KOPIS 에 이름 비교 키(event-name.ts, 괄호·문장부호·공백 제거)가 같고 기간이 겹치는 행사가 있으면 넣지 않는다. 같은 출처에 제목만 다르게 두 번 등록된 전시(2026-11-03: "문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》")는 같은 장소 · 같은 시작·종료일 · 제목 비교 키가 연속 6글자 이상 겹칠 때 하나로 보고 먼저 등록된 것(seq 가 작은 것)만 남긴다(event-dedupe.ts). 장소·기간만으로는 합치지 않는다 — 여러 전시실에서 같은 날 여러 전시를 여는 곳이 있다. 목록을 끝까지 받았으면 받은 기간 안에서 이번에 넣지 않은 전시를 숨긴다(원본 장애 판단은 §4.1).
    2. 상세 detail2 — 새 전시와 상세를 받은 지 7일 지난 전시만(원본에 수정일이 없다). 요금 price, 소개 contents1, 전화, 주소 placeAddr, 안내 링크 url.
  • 좌표는 목록에 있다(gpsY 위도 · gpsX 경도). 지역의 시·도 영역 밖이면 버린다.
  • 제목에 &amp;lsquo; 처럼 한 번 더 인코딩된 문자가 온다 → htmlToText 가 따옴표·가운뎃점·번호 엔티티까지 푼다.
  • 이미지는 저장하지 않는다. 문화공공데이터광장 저작권정책상 공공누리 1유형이 아니면 사전 협의가 필요한데 아직 확인하지 않았다 (조사 문서 4장 4번). 확인되면 kcisa-mapper.ts 에서 thumbnail·imgUrl 을 넣고 다시 실행한다.
  • 전시는 몇 달씩 하므로 상시 프로그램 상한이 183일이다(§2.4). 1년짜리 상설 전시는 목록에서 빠진다.
  • 출처 표기: "한국문화정보원 문화포털". 호출: 대구·경북 하루 20회 안팎(개발 계정 10,000/일).
  • 검증: backend/test/kcisa-sync.e2e-spec.ts — 전시만 넣기 · 다른 출처 중복 건너뛰기 · 상세 · 빈 목록이면 숨기지 않기 · 없어진 전시 숨기기.