공공데이터 장소(place)와 운영자 큐레이션(place_curation)을 테이블로 나눠 동기화가 사람이 확인한 값을 덮어쓰지 않게 한다. 앱이 보는 값(큐레이션 > 공공 > 미확인)과 노출 규칙은 place_listing 뷰 한 곳에서만 정한다. - 노출 규칙(2026-11-02 합의): 노출 중인 장소는 모두 검색, 홈 추천은 추천(O)+확인일, AI 후보는 1순위 큐레이션 추천·2순위 관광공사 장소. 확인 안 된 상가정보 매장은 AI 후보가 아니다 - 축제·공연·행사(festival_event), 언어별 텍스트(place_i18n), 동기화 기록(place_sync_run) 포함 - 코드값은 place.constants.ts 하나로 CHECK 제약·뷰가 공유 - 전역 SnakeNamingStrategy(DB snake_case ↔ TS camelCase), uuid 는 pgcrypto 임시 Postgres 15 에서 생성 → 적용 → 재생성 무차이 → revert 를 확인했고, 뷰 규칙은 8가지 경우로 검증했다. 설계: docs/design/place-db.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 KiB
장소 DB 설계 (v1.1, 2026-11-02)
WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다.
1단계(엔티티·마이그레이션)를 구현했다 — backend/src/modules/place/, backend/src/migrations/1793597729768-AddPlaceTables.ts.
이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다.
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) — 분류 규칙을 바꿔도 다시 계산할 수 있게 |
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 | 마지막 동기화에서 확인된 시각 |
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 코스 슬롯을 이 값으로 고른다.
| category | 관광공사(lclsSystm1 기준) | 상가정보 | 검색 탭 |
|---|---|---|---|
nature |
NA | — | 산책·자연 |
attraction |
HS, VE(문화시설 제외) | — | 관광지 |
culture |
VE07(문화시설) | — | 관광지 |
leisure |
LS | — | 관광지 |
food |
FD(카페 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 |
cafe |
FD05 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 |
bar |
— | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 |
shopping |
SH | — | 관광지 |
lodging |
AC | — | 숙소 |
- 위 대응은 2026-11-02 표본에서 본 코드로 만든 것이다.
lclsSystmCode2로 전체 코드표를 받아 확정한다.
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은 읽지도 쓰지도 않는다.
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. 확인이 필요한 것
| 항목 | 언제 |
|---|---|
lclsSystm 전체 코드표로 §2.6 분류 확정 |
엔티티 작성 전 (lclsSystmCode2 1회 호출) |
areaBasedSyncList2 의 modifiedtime 이 "그날"인지 "그 이후"인지 |
변경분 동기화 첫 실행 |
| 영문 ↔ 국문 매칭률 | 영문 첫 적재 |
| Type3(변경금지) 이미지를 카드에서 잘라 보여 주는 것이 '변경'인지 | 화면 구현 전 (공공누리 기준) |
| 상가정보 반경·행정동 API 가 빠뜨리는 업소가 있는지 | 상가정보 첫 적재 (동 단위 결과와 반경 결과 비교) |
| 큐레이션 표 보관 위치(저장소 / 구글 시트) | 큐레이션 본격 시작 전 |
10. 구현 순서 (제안)
- ✅ 엔티티 + 마이그레이션:
place,place_curation,place_i18n,festival_event,place_sync_run,place_listing뷰 (2026-11-02) - 관광공사 국문 초기 적재 명령(대구) — 개발 계정으로 2일
- 상가정보 적재 명령(대상 행정동)
- 큐레이션 CSV 가져오기 명령(미리보기 → 반영)
- 조회 API:
GET /api/places(검색·필터·커서 페이지네이션),GET /api/places/{id},GET /api/events - 일·주·분기 동기화 + 폐업 대조