-- 0003 · 섹션 콘텐츠를 `sites.theme` 에서 꺼내 행으로 -- -- 지금은 색·서체(디자인)와 섹션별 콘텐츠가 `site.sites.theme` JSONB 한 칸에 같이 있다. -- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 -- 나머지 41,500 B 가 섹션이다. 그중 콘텐츠(sections[].data)만 39,645 B — 94%. -- 가장 큰 섹션 하나(itinerary)가 17,990 B 로, 디자인 전체의 28 배다. -- -- 크기가 문제인 게 아니다(JSONB 는 1GB 까지 든다). 문제는 **쓰기 단위**다: -- · 사장님이 영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓴다 -- · 같은 사이트를 둘이 만지면 나중 쓰기가 앞을 통째로 덮는다 -- · 항목마다 "누가 넣었나 · 확인됐나"를 물을 자리가 없다(facts 는 status 를 갖는다) -- · 검증이 없어 렌더러에 존재하지도 않는 섹션이 남는다 -- (실측: 조이모텔에 course·schedule — 켤 수는 있는데 화면엔 아무 일도 안 일어난다) -- -- ★ 이 테이블이 JSON import/export 의 단위다. 사이트 하나 = 이 행 묶음 + theme(디자인). CREATE TABLE IF NOT EXISTS site.site_contents ( site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), site_id uuid NOT NULL, -- site.sites.site_id section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' … data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[] 계약) source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 3=crawl 4=llm -- ★ 공유 콘텐츠는 값을 복제하지 않고 **id 로 가리킨다**(축제·지역 이야기). -- 가리키는 동안 data 는 비어 있을 수 있다 — 발행할 때 원본을 펼쳐 payload 에 싣는다. shared_ref uuid NULL, status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축: 3=verified 4=corrected 만 노출 sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); -- 한 사이트의 한 섹션은 한 행이다. 순서·on/off 는 theme 이 갖는다. CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON site.site_contents (site_id, section_id) WHERE deleted = FALSE; -- 지역 이야기 — 가요·인물·연표·엽서·퀴즈는 **업장이 아니라 지역의 것**이다. -- 군산 이야기는 군산 숙소가 같이 쓴다. 지금은 만들 자리가 아예 없어서 -- 시안(/s/stay)에는 사람이 3만 자를 손으로 넣었다. CREATE TABLE IF NOT EXISTS local.region_stories ( region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드 kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz' data JSONB NOT NULL, source_type SMALLINT NOT NULL DEFAULT 4, -- 4=llm 1=운영자 status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 검수 전에는 안 나간다 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind ON local.region_stories (region_code, kind) WHERE deleted = FALSE; -- 이미 theme 에 박혀 있는 콘텐츠를 옮긴다. -- ★ data 는 문자열로 저장돼 있다(서버가 파싱하지 않는 규약) — JSONB 로 되돌린다. -- 깨진 JSON 은 옮기지 않는다. 원본은 theme 에 남으므로 잃지 않는다. INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order) SELECT s.site_id, sec->>'id', (sec->>'data')::JSONB, 1, -- 사장님이 넣은 것으로 본다 3, -- 이미 발행에 쓰이던 값이라 verified ordinality - 1 FROM site.sites s CROSS JOIN LATERAL jsonb_array_elements(s.theme->'sections') WITH ORDINALITY AS t(sec, ordinality) WHERE s.deleted = FALSE AND s.theme ? 'sections' AND sec->>'data' IS NOT NULL AND sec->>'data' <> '' AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL ON CONFLICT DO NOTHING; -- ★ theme.sections[].data 는 지우지 않는다. 읽는 쪽(site_payload._theme)이 새 테이블을 -- 먼저 보고 없을 때만 theme 으로 떨어지게 한 뒤, 별도 번호로 뗀다.