관광공사 원문 107곳이 "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20" 처럼
줄바꿈 없이 붙어 온다. 맨 앞 글머리표만 지우던 파서는 이를 한 구간으로 보고 영업시간까지
준비시간·마지막 주문으로 읽었다. 그래서 39곳이 영업 중인 낮에 '영업 종료'로 보였고
41곳은 요일 시간이 통째로 비었다(2026-11-04 회귀 점검).
- 시각·닫는 괄호 뒤의 '- '(다음이 숫자가 아닌 말)와 대괄호 머리말 앞에서 구간을 나눈다
- 시설 머리말("[칠성시장] … [칠성야시장] …")마다 시간표를 따로 만들고 요일별로 합친다.
나누기만 하면 뒤 시설의 요일 시간이 앞 시설 시간을 지워 일부 시간대가 빠졌다
- '휴게시간'은 쉬는 시간, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분)
- 요일 사이의 '/' 는 나열로 읽고, 0분 구간("13:00~13:00")은 24시간으로 읽지 않고 버린다
개발 DB 는 reparse-hours 로 다시 해석했다(107곳 바뀜, 공공데이터 API 재호출 없음).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
38 KiB
장소 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 — 동기화 기록
핵심은 두 가지다.
- 공공 원본(
place)과 사람이 확인한 값(place_curation)을 테이블로 나눈다. 동기화가 아무리 덮어써도 큐레이션은 남는다. - "앱이 보는 값"은 뷰 하나에서만 정한다. 큐레이션 값 > 공공 값 > 미확인 순서를 여기서만 적용한다(규칙을 서비스마다 따로 적지 않는다).
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 여행코스, 좌표가 없거나 한국 밖인 항목. - 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:00
21:00- 준비시간 16:0017:00- 마지막 주문 20:20", 107곳)를 구간으로 나눈다. 나누지 않으면 영업시간이 준비시간 구간에 섞여 낮에 '영업 종료'로 보였다(39곳). 요일 시간이 통째로 빈 곳도 41곳에서 0곳이 됐다. - "[칠성시장] … [칠성야시장] 일요일 …" 처럼 시설 머리말이 여럿이면 시설마다 시간표를 만들고 요일별로 합친다(어느 시설이든 열려 있으면 영업 중). 뒤 시설의 요일 시간이 앞 시설 시간을 지우지 않게 하려는 것이다.
- '휴게시간'은 쉬는 시간으로 읽고, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분).
- "금요일 / 토요일" 처럼 요일 사이의 '/' 는 나열로 읽고, "13:00~13:00" 같은 0분 구간은 버린다(24시간으로 읽지 않는다).
- 남은 한계: 쉬는 시간은 시설·요일 구분 없이 하루 전체에 적용한다. 원문이 틀린 곳(팔공산 닭갈비 "준비시간(주말) 05:00~16:30")은 그대로다.
- 줄바꿈 없이 붙은 글머리표("- 11:00
- 상가정보는
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·lngdouble 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. 구현 순서 (제안)
- ✅ 엔티티 + 마이그레이션:
place,place_curation,place_i18n,festival_event,place_sync_run,place_listing뷰 (2026-11-02) - ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일)
- ✅ 상가정보 적재 명령(대구 중구·남구·수성구 데이트 업종) (2026-11-02)
- 큐레이션 CSV 가져오기 명령(미리보기 → 반영)
- ✅ 조회 API:
GET /api/places(검색·근처·필터·커서),GET /api/places/{id}+ 중복 병합 (2026-11-02), 축제GET /api/events·/{id}+ 축제 동기화 (2026-11-03) - 일·주·분기 동기화 + 폐업 대조
11. 조회 API (구현됨)
비로그인으로 쓴다(둘러보기·검색 비로그인 허용 합의). 노출 규칙은 place_listing 뷰가 정하고 API 는 읽기만 한다.
| 엔드포인트 | 내용 |
|---|---|
GET /api/places |
q(이름) · lat+lng+radius(기본 1,500m, 100~20,000) · category(쉼표 구분) · pet · indoor · cursor · limit. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 {items, pageInfo} 에 total 포함 |
GET /api/places/{id} |
상세 — 영업시간(원문·해석 상태·출처), 반려동물(크기·조건), 이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기. 병합된 id 면 대상 장소, 숨김·폐업 추정이면 RES_001 |
- 목록 항목의
openNow는 한국 시간 기준 영업 중 여부다. 알 수 없으면 null → 화면은 "영업시간 미확인 · 방문 전 확인". - 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제).
- 실패 코드: 검색 조건·커서·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)- 목록
pblprfr(signgucode, stdate=오늘, eddate=오늘+N일, rows=100). 끝난 공연(공연완료)·다른 시·도·중복은 건너뛴다. 목록을 끝까지 받았으면 받은 기간 안에서 목록에 없는 공연을 숨긴다(원본 장애 판단은 §4.1). - 상세
pblprfr/{mt20id}— 새 공연, 어제 이후 등록·수정된 공연(목록afterdate), 상세를 받은 지 7일 지난 공연만. 요금pcseguidance→fee_text, 공연 시간dtguidance→playtime_text, 예매처 첫 링크 →booking_url, 소개 = 줄거리sty+ 출연·관람 시간·관람 연령 줄. 장소 이름은 상세가 홀까지 주므로("대구콘서트하우스 (그랜드홀 …)") 상세 값을 쓴다. - 공연시설
prfplc/{mt10id}— 진행 중·예정 공연이 쓰는 시설 중 새 시설과 30일 지난 시설만 →event_venue. 좌표는 주소의 시·도 영역 안일 때만 쓴다(geo.tsisInSido). 아니면 비우고 경고를 남긴다(그 공연은 근처 조회에서만 빠진다). - 시설의 좌표·주소·전화를 공연에 복사한다. 조회 API 가 공연 좌표로 거리를 계산하기 때문이다. 시설 좌표를 고치면 다음 실행에 공연도 바뀐다.
- 목록
- 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]", "[대구 북구]", "[안동] (11월)")를 떼어 저장한다. 2026-11-03 244건 중 203건에 붙어 있었다.
꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)"·"(11.10)" 같은 메모는 남긴다(
kopis-mapper.tsstripRegionTag). - 포스터는
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)- 목록
area2(sido=대구·경북, from·to). 분야가 '전시'인 것만 넣는다 — 공연은 KOPIS 와 겹친다. 관광공사·KOPIS 에 이름 비교 키(event-name.ts, 괄호·문장부호·공백 제거)가 같고 기간이 겹치는 행사가 있으면 넣지 않는다. 같은 출처에 제목만 다르게 두 번 등록된 전시(2026-11-03: "문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》")는 같은 장소 · 같은 시작·종료일 · 제목 비교 키가 연속 6글자 이상 겹칠 때 하나로 보고 먼저 등록된 것(seq 가 작은 것)만 남긴다(event-dedupe.ts). 장소·기간만으로는 합치지 않는다 — 여러 전시실에서 같은 날 여러 전시를 여는 곳이 있다. 목록을 끝까지 받았으면 받은 기간 안에서 이번에 넣지 않은 전시를 숨긴다(원본 장애 판단은 §4.1). - 상세
detail2— 새 전시와 상세를 받은 지 7일 지난 전시만(원본에 수정일이 없다). 요금price, 소개contents1, 전화, 주소placeAddr, 안내 링크url.
- 목록
- 좌표는 목록에 있다(
gpsY위도 ·gpsX경도). 지역의 시·도 영역 밖이면 버린다. - 제목에
&lsquo;처럼 한 번 더 인코딩된 문자가 온다 →htmlToText가 따옴표·가운뎃점·번호 엔티티까지 푼다. - 이미지는 저장하지 않는다. 문화공공데이터광장 저작권정책상 공공누리 1유형이 아니면 사전 협의가 필요한데 아직 확인하지 않았다
(조사 문서 4장 4번). 확인되면
kcisa-mapper.ts에서thumbnail·imgUrl을 넣고 다시 실행한다. - 전시는 몇 달씩 하므로 상시 프로그램 상한이 183일이다(§2.4). 1년짜리 상설 전시는 목록에서 빠진다.
- 출처 표기: "한국문화정보원 문화포털". 호출: 대구·경북 하루 20회 안팎(개발 계정 10,000/일).
- 검증:
backend/test/kcisa-sync.e2e-spec.ts— 전시만 넣기 · 다른 출처 중복 건너뛰기 · 상세 · 빈 목록이면 숨기지 않기 · 없어진 전시 숨기기.