o2o-site-AEO/docs/DATA_MODEL.md

23 KiB

DATA MODEL — 값이 DB 에서 페이지까지 가는 길

이 문서 하나만 읽고도 "이 값이 어느 표 어느 칸에 있고, 왜 화면에 나왔거나 안 나왔는지" 를 짚을 수 있어야 한다.

정의는 두 곳이고 둘 다 최신이어야 한다 — ORM(solution/backend/common/database/model/models.py) 과 DDL(postgres-init/init-data/init.sql + migrations/). 컬럼 주석은 ORM 이 더 자세하다.


0. 표 17개, 스키마는 public 한 벌

도메인별 스키마(company·place·fact·local·site·job)는 2026-09-09 에 걷어냈다. 스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다. 소속은 이름이 말한다(place_* · site_* · area_*).

users                          사장님 계정
├ places                       사업장 — 모든 것의 스코프 키
│ ├ place_channels             채널 URL(네이버·TourAPI·홈페이지) — 크롤링 대상
│ ├ place_units                객실 · 메뉴 · 프로그램
│ ├ place_photos               사진
│ ├ place_facts        ★       사실. 이 제품의 심장
│ ├ place_faqs                 FAQ
│ ├ place_songs                이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
│ ├ place_social_posts         SNS 게재 글 — 초안 → 승인 → 게시 (사장님이 누를 때만)
│ └ place_area_refs            업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
├ area_contents        ★       지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
└ sites                        발행 사이트 — 사업장당 1개
  ├ site_sections              섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
  ├ site_versions       ★      빌드 버전 — snapshot 박제
  └ site_publish_logs          발행 시도 기록(반려 사유 포함)
owner_social_accounts          사장님이 연결한 SNS 계정 — ★ 위임받은 토큰을 보관하는 유일한 표
jobs                           작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래 · SNS

FK 제약은 걸지 않는다(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(deleted)이고, 자연키 유니크는 deleted = false 부분 인덱스로 건다 — jobs 만 예외다(잡은 이력이라 안 지운다).


1. 한 장으로 보는 흐름

[사장님 화면]                    [백엔드]                          [산출물]

가게 이름 입력
  └ POST /v1/place ─────────→ places 행 생성 (status=DRAFT)
카카오 로컬에서 내 가게 선택
  └ POST .../verify ────────→ places.verified_at · latitude/longitude
                              · region_code · external_category 박제
                              ★ verified_at 이 NULL 이면 이 아래로 못 간다

[수집 시작] ────────────────→ jobs(COLLECT) 적재
                              worker: services/collect_service.run_collect
                               ├ 네이버 플레이스·TourAPI 직접 해석  → place_channels
                               ├ (선택) Perplexity 로 URL 후보 발견  → place_channels.raw
                               ├ 확정된 URL 만 크롤링              → place_facts · place_photos
                               └ 하위 단위 자동 생성                → place_units
                              이어서 jobs(VISION) → place_photos.label/alt_text/status
                                    jobs(COPY)   → place_faqs · 소개문 place_facts
                                    jobs(LOCAL_SYNC) → area_contents (지역당 1회)

[에디터]
  템플릿 고르기 ─────────────→ sites.template_id
  색·서체·섹션 순서/on-off ──→ sites.theme (JSONB)
  섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행)
  주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m
  미리보기 ──────────────────→ GET /v1/place/{id}/site/preview
                              ★ 발행과 **같은 함수**로 payload 를 만든다(DB 를 안 건드린다)

[발행하기] ─────────────────→ jobs(BUILD, publish=true)
                              services/build_service.run_build ── 아래 3절
                               └ out/payloads/<slug>.json  ★ 백엔드의 유일한 산출물
                                    │
                                    │  (컨테이너 경계)
                                    ▼
                              solution-prerender 컨테이너
                              scripts/watch-payloads.mjs → prerender.ts
                                    │
                                    ▼
                              out/s/<slug>/index.html · llms.txt
                              out/sitemap.xml · robots.txt · /s (목록)

★ 컨테이너 이름을 헷갈리지 않는다. 굽는 것은 solution-prerender 다. solution-frontend 는 개발용(profiles: ["dev"])이라 운영에서 아예 뜨지 않는다 — restart solution-frontend 는 아무 일도 안 하면서 성공한다.


2. 표별 — 무엇을 담나 · 누가 쓰나 · 어디로 나가나

places — 모든 것의 스코프 키

칸 무엇 쓰이는 곳
owner_user_id 사장님 계정 스코프 키. 조회는 전부 이 값으로 좁힌다(회사/테넌트를 걷어내고 이 컬럼이 그 자리를 받았다)
category 업종 코드 업종 스키마 선택(common/category_schema) — 어떤 fact key 가 허용되는지, 어떤 섹션을 기본으로 켜는지
verified_at 카카오 로컬 검증 시각 ★ NULL 이면 수집도 발행도 금지. 검증 없이 수집하면 남의 가게가 섞인다
latitude/longitude 좌표 빌드 시점 TourAPI 반경 조회(주변 맛집·축제·관광지)
region_code 행정구역 코드 ★ 지역 콘텐츠 캐시 키. 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회
external_category 외부 DB 분류 원문 주변 맛집에서 같은 중분류(경쟁 업소)를 빼는 기준
content_updated_at 노출값이 마지막으로 바뀐 시각 개별 재빌드 대상 판별 — site_versions.built_at < content_updated_at 인 사이트만 다시 굽는다

place_facts — 이 제품의 심장

모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. 출처 없는 사실은 규칙 위반이다.

칸 무엇 쓰이는 곳
key 업종 스키마에 정의된 필드 키 스키마에 없는 key 는 저장 자체가 거부된다
value · unit 값과 단위 화면 · JSON-LD · llms.txt 가 같은 값을 쓴다
status 1 UNVERIFIED / 2 PENDING_OWNER / 3 VERIFIED / 4 CORRECTED / 5 REJECTED / 6 EXPIRED ★ 3·4 만 사이트에 나간다(PUBLISHABLE_FACT_STATUSES). 4 는 사장님이 고친 값이라 잠긴다 — 재수집이 덮어쓰지 못한다
source_type · source_url 출처 payload 에 그대로 실어 화면이 "언제 무엇으로 확인된 값인지" 를 보여준다
unit_id NULL 이면 사업장 fact, 있으면 객실·메뉴 fact 객실별 요금·정원이 여기로 들어간다
expires_at 유효기간 지나면 EXPIRED 로 내려 재수집 대상이 된다

활성 유니크는 (place, unit, key) 당 노출값 1건이다(status 3·4 부분 인덱스). 후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.

수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다 (2026-09-14, services/fact_service). 예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로 막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는 값이라 승인할 화면을 이미 지나가 있었다.

지금 규칙은 누가 그 자리를 이미 차지했는지로 갈린다.

그 key 의 현재 노출값 수집값이 오면
없음 바로 노출값(VERIFIED). verified_by 는 비운다 — 사람이 승인한 이력과 구별된다
같은 값 REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다
사장님이 넣은 값(OWNER) · 정정본(CORRECTED) 덮지 않는다. PENDING_OWNER 후보로 쌓여 사람이 고른다
앞선 수집값 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력)

즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다. UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다.

place_channels — 크롤링 대상 URL

confirmed_at 이 NULL 이면 크롤링하지 않는다. 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다. raw 에는 Perplexity 응답을 통째로 박제하지만 사실 근거로 쓰지 않는다 — 환각 추적용이다.

place_photos — 사진

status 가 APPROVED(2) 인 것만 사이트에 나간다. Gemini Vision 신뢰도가 낮으면 PENDING_REVIEW(1) 로 남아 빌드에서 빠진다. source_type·origin_url 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(DECISIONS 1-2) 결론에 따라 걸러낼 수 있어야 한다.

place_faqs — FAQ

출처(generated_by)마다 근거 요구가 다르다.

generated_by 무엇 source_fact_ids 어디에 나가나
LLM(4) 확인된 fact 로 쓴 문장 근거 key 필수 — 없으면 저장하지 않는다(copy_service) 화면 · JSON-LD · llms.txt
OWNER(1) 사장님이 쓰거나 고친 문장 없을 수 있다 화면 · JSON-LD · llms.txt
TEMPLATE(5) 20개를 채운 공통 질문 + 문의 안내 답 없음 화면만

★ 예전 문서는 "비면 발행 게이트가 반려한다" 고 적었지만 그런 검사는 없었다(2026-09-14 확인). 근거 강제는 저장 시점(copy_service)에 있다. 채우기 규칙은 DECISIONS 8절.

place_songs — 이 숙소의 노래

발행할 때마다 한 곡 만든다. 가사는 소개문과 같은 재료(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고, 곡은 Suno 가 붙인다.

★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라 "맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus: GENERATING · READY · FAILED). 스냅샷은 READY 만 싣는다.

★ origin_url(Suno 가 준 주소)은 발행본에 나가지 않는다. 만료되는 주소라 그대로 실으면 발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 solution/site/songs/<song_id>.mp3 에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(/s/<slug>/<song_id>.mp3)만 나간다. 표에는 추적용으로만 남긴다.

★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — latest_ready 가 READY 중 최신 하나를 고른다.

place_social_posts · owner_social_accounts — SNS 게재

사장님이 [SNS에 알리기] 를 누를 때만 생긴다. 발행의 부수효과가 아니다 — 발행은 우리 화면을 굽는 일이고, 이건 사장님이 자기 이름으로 하는 말이다(DECISIONS 8절).

★ 승인 대기는 잡이 아니라 이 표의 상태다. 잡으로 매달면 lease(120초)가 만료돼 reaper 가 회수하고 attempts 가 올라 결국 DEAD 가 된다. 큐는 "지금 할 일" 만 표현한다. 상태: DRAFTING → PENDING_APPROVAL → APPROVED → POSTING → POSTED(+ DECLINED·EXPIRED· FAILED·UNKNOWN). 발행본에는 POSTED 만 나간다.

★ POSTING 이 10분 넘게 남아 있으면 UNKNOWN 으로 내린다 — 시간을 근거로 APPROVED 로 되돌리지 않는다. 외부가 이미 받았을 수 있고, 되돌리면 같은 글이 두 번 올라간다.

★ approval_token_sha 는 해시만 저장한다(원문은 링크에만 있다). 일회성은 토큰이 아니라 status='PENDING_APPROVAL' 조건이 붙은 단일 UPDATE 가 보장한다(DECISIONS 8-3).

★ owner_social_accounts 는 place 가 아니라 user 에 붙는다. 계정은 사람의 것이고, 사장님이 업장을 둘 가져도 계정은 하나다. 토큰은 SOCIAL_TOKEN_SECRET 으로 암호화해 넣는다 — 이 표만이 위임받은 자격증명을 담는다(place_channels 는 공개 URL 목록이라 섞지 않는다).

area_contents + place_area_refs — 지역 콘텐츠

★ 키가 region_code 다. 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.

종류(content_type) 출처 kind
1 WEATHER Open-Meteo —
2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE TourAPI (좌표 반경) —
6 STORY Perplexity songs daily people chronicle reading postcard quiz

body(JSONB)에 항목이 들어간다. 지역 이야기는 종류당 한 행이고 항목들은 body.items 안에 있다.

place_area_refs 에는 업장마다 다른 것만 둔다 — distance_m(정렬·도보시간의 원값)과 hidden. 예전에는 이 표가 값을 통째로 들고 있어서(place_contents) 업장마다 TourAPI 응답이 복제됐다 — 실측(2026-09-09) 한 곳에 144행. hidden 은 재수집이 덮어쓰지 않는다.

★ 외부 API 가 실패해도 이 행을 지우거나 비우지 않는다. 직전 값을 유지하고 알림만 낸다.

sites — 발행 사이트(사업장당 1개)

칸 무엇 왜 서버에 두나
template_id 사장님이 고른 템플릿 키 서버는 해석하지 않고 보관·반환만 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다
theme (JSONB) 색·서체·섹션 순서/on-off/배리에이션 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문
status 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED ★ 해지는 물리 삭제가 아니라 상태 전이다 — 색인된 페이지를 갑자기 404 로 만들지 않는다
current_version_id 지금 나가 있는 버전
thumbnail_url 쇼케이스 카드 그림 ★ 발행에 성공한 뒤에만 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지

★ templateId 를 theme 안에 넣지 않는다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다.

site_sections — 섹션 콘텐츠 (sites.theme 와 역할이 다르다)

theme 은 모양, 여기는 내용. 2026-09-09 에 갈랐다 — 실측(/s/stay): theme 42,150 B 중 디자인이 636 B(1.5%), 콘텐츠가 39,645 B(94%)였다. 크기가 아니라 쓰기 단위가 문제였다: 영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다 "누가 넣었나 · 확인됐나" 를 물을 자리가 없었다.

(site_id, section_id) 당 1행. section_id 는 songs itinerary video people local …. data 는 shared 의 XxxItem[] 계약을 그대로 담는다. shared_ref 가 있으면 값을 복제하지 않고 원본(area_contents)을 가리키고, 발행할 때 펼친다.

★ section_id = 'local' 의 data.places 는 배열이 아니라 맵이다 — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은 읽는 쪽이 갖는다.

site_versions — 빌드 버전, 그리고 정적 빌드의 경계

칸 무엇
snapshot (JSONB) ★ 빌드 시점 데이터 박제. 방문자는 DB 와 만나지 않는다
jsonld 렌더러가 실제로 내보낸 구조화 데이터. 백엔드가 따로 계산하지 않는다
unique_content_count 렌더러가 센 고유 콘텐츠 수. 0 이면 발행 거부(스팸 판정 대상)
build_status PENDING → BUILDING → BUILT / FAILED
build_error 실패 사유 원문
built_at places.content_updated_at 과 비교해 재빌드 대상을 고른다

snapshot 이 감사 기록이기도 하다 — fact 마다 status·source_type·source_url·verified_at 을 같이 싣는다. "왜 이 값이 나갔나" 를 나중에 되짚을 수 있어야 하기 때문이다.

site_publish_logs — 발행 시도 기록

게이트가 막았으면 result=REJECTED + reject_reason + detail(막힌 항목 목록)을 남긴다. 화면의 반려 카드가 이 사유 코드로 문구를 고른다 — 전부 "렌더 실패" 로 뭉개면 사장님이 손댈 곳을 모른다.

jobs — 작업 큐 (PostgreSQL 을 큐로)

COPY 단계는 jobs.progress(JSONB)의 steps·attempt에 기록한다. 생성 화면 복구와 모듈별 책임은 GENERATION_FLOW.md.

job_type 핸들러 하는 일
1 COLLECT collect_service.run_collect 채널 발견 → 검증 → 크롤링 → fact·사진 적재
2 VISION vision_service.run_vision 사진 분류 + alt 생성
3 COPY copy_service.run_copy 소개문·FAQ (확보된 fact 만 근거)
4 BUILD build_service.run_build ★ 정적 빌드 + 발행 게이트
5 LOCAL_SYNC story_service.run_local_sync 지역 이야기 생성(지역당 1회)
6 AI_CHECK 미구현 reports 모듈이 붙을 때
  • 할당은 단일 문장 원자 claim(FOR UPDATE SKIP LOCKED + 같은 UPDATE + RETURNING) — 워커가 몇 개든 이중 할당이 불가능하다.
  • 복구는 타임아웃 추측이 아니라 lease_until 만료 소유권이다. 컨테이너를 재시작해도 진행 중이던 잡이 증발하지 않는다.
  • dedupe_key 로 활성 중복(PENDING/RUNNING)을 막는다 — 지역 이야기는 story:{region_code} 라 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다.
  • ★ 이 표만 raw SQL 경로가 있다. 표 이름을 옮기면 ORM 이름 변경이 여기까지 안 따라온다 — 2026-09-09 에 job.jobs → jobs 를 놓쳐 큐가 통째로 멈췄다(화면에는 "버튼만 안 먹는" 것으로 보였다).

3. 값 하나가 페이지까지 가는 길

place_facts (status=3 or 4)                 ← 이 필터가 snapshot.py 한 곳에만 있다
  └ build_snapshot()          services/snapshot.py:59
      · fact  : VERIFIED / CORRECTED 만
      · 사진  : APPROVED 만
      · FAQ   : VERIFIED / CORRECTED 만
      · 지역  : PUBLISHED + 노출기간 안 + 종류별 20건까지
  └ site_versions.snapshot 에 박제
      └ to_site_payload()     services/site_payload.py:708
          ★ 여기서 DB 를 다시 읽지 않는다 — 입력은 박제된 스냅샷뿐이다.
            다시 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 "스냅샷과 다른 페이지" 가 나온다
      └ out/payloads/<slug>.json
          └ prerender.ts → out/s/<slug>/index.html
              화면 · JSON-LD · llms.txt 가 **같은 값**에서 나온다

게이트는 두 번 돈다.

  1. 1차 (렌더 전, DB 사실 기준) — 상호명·업종·미검증 fact. payload 를 쓰기 전에 막는다. 렌더러에 넘긴 뒤 막으면 검증 안 된 값이 디스크에 한 번 나갔다 온다.
  2. 2차 (렌더 후, 실제로 구워진 HTML 기준) — JSON-LD 불일치 · 고유 콘텐츠 수. 1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다.

★ 지역 정보(주변 맛집·축제)는 빌드 시점에 업장 좌표로 새로 받는다. 실패해도 빌드는 계속한다 — 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고, 직전 값이 그대로 있다.


4. DB 에 없는 것

경계를 아는 것이 표를 아는 것만큼 중요하다.

것 어디 있나
HTML · 사이트맵 · llms.txt out/ 디렉토리. 백엔드는 HTML 을 만들지 않는다
렌더링 결과 보고서 out/payloads/.status/<slug>.json (프리렌더 → 백엔드 단방향)
섹션 목록 · 배리에이션 키 · 색 토큰 이름 프론트가 소유. 서버는 theme JSONB 로 통째로 보관만
빈 방 재고 · 예약 접수 · 결제 어디에도 없다. 예약 섹션은 화면 목업이고 연동이 없다
방문자 세션 없다. 정적 페이지라 방문자는 DB 와 만나지 않는다

5. 표를 고칠 때

  1. ORM(models.py) 과 init.sql 둘 다 고친다.
  2. 이미 데이터가 든 DB 를 위해 postgres-init/migrations/NNNN_*.sql 을 더한다.
  3. 적용: cd solution/backend && .venv/bin/python scripts/migrate.py (서버는 SERVERS.md ## DB 참조)

★ 표 이름을 옮겼으면 정적 검사를 돌린다. import 도 타입검사도 안 잡는 자리가 셋 있다 — raw SQL, 클래스 생성자, 그리고 표와 이름만 같은 속성.

cd solution/backend && python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"

2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아 죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.

SNS (2026-09-14)

표 키·범위 데이터·인덱스
owner_social_accounts (0012) account_id, user_id/provider provider_user_id·handle·profile_url, 암호화 access/refresh token·만료·scopes·status·last_error. deleted=false, linked/needs_reauth인 user/provider 부분 유니크
place_social_posts (0013) post_id, place_id/user_id/site_version_id 승인 계정 account_id, provider·본문·고정 URL·grounded_facts, nonce 해시·시각·채널, 게시 ID·permalink·posted_at·last_error. 같은 place/version은 삭제 전까지 유니크. POSTED 최신 조회 인덱스

Provider 1=X 예약값(구현 없음), 2=Threads. 상태는 DRAFTING/DRAFT/PENDING_APPROVAL/APPROVED/POSTING/POSTED/DECLINED/EXPIRED/FAILED/UNKNOWN. DRAFT는 복사 가능한 작성 완료 원고, UNKNOWN은 중복 방지를 위한 수동 확인 상태다. SNS 승인 CAS와 잡 삽입은 같은 트랜잭션. SOCIAL_DRAFT=8, SOCIAL_POST=9, 승인 대기는 잡이 아니다. POSTED 최신 3건만 snapshot → payload.socialPosts로 전달한다. 자격증명·nonce·근거 원문은 제외한다.