/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.
★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.
★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
"요청은 성공했는데 결과가 영영 안 옴" 이 된다.
- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
(발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL
검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
사장님 지적("1시간 30분 걸려서 갔는데 짧게 머문다")을 전수로 다시 셌다.
대상은 선유도가 아니었다(150분). 섬에서 돌아오는 길의 **끼니 간격**이다 —
늦은 점심 15:47 종료, 저녁 17:30 시작으로 103분이다. 늦은 점심 창(≤15:00)과
저녁 창(≥17:30)이 둘 다 합법이라 규칙 14종을 그냥 통과했다. 창 밖에 규칙이 따로 필요했다.
- build_itinerary.py: MEAL_GAP=180. `_window_of()` 가 앞 끼니 종료 + 3시간 뒤로 다음 끼니
창을 민다. 섬 2박3일 둘째 날이 쉼 168분으로 늘고 저녁이 18:47 로 밀렸다
- build_itinerary.py audit(): 규칙 15(끼니 간격) · 16(볼거리는 편도 이동보다 오래 머문다)
추가. 16은 **끼니를 뺀다** — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다
- audit_schedule.py 신설: 구워진 payload 를 표로 펴고, 만들 때 규칙이 **안 보는** 논리
12가지(L0~L11)를 센다 → SCHEDULE.md
- README: 규칙 14종 → 16종. 장소 대장 프롬프트에 "멀리 있는 곳 근처 식당도 넣어라" 추가 —
규칙만 고치면 다음 지역에서 또 난다
- README: 일정 프롬프트 위치 정정. shared/section-prompts.ts 가 아니라
frontend/.../canvas/dataSpec.ts:556 이고, video·event·itinerary 셋은 **빌더에만** 있어
서버 생성 잡이 만들 줄 모른다(daily 와 같은 사고). 그 프롬프트가 모델에게 시각 계산을
시키고 있는 것도 적었다 — 시연본 첫 판이 망가진 직접 원인이다
- README: 목업을 지울 때 1부를 docs/ 로 옮기라는 안내
검증: 규칙 16종 위반 0건 · audit_schedule 21/21 검수완료 · audit-all.mjs 34/34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README · SECTIONS · PROMPTS · REVIEW 넷으로 흩어져 있어서 무엇을 먼저 볼지 알 수 없었다.
특히 "실제 구현하는 사람이 어떤 파일을 열어야 하나" 가 어디에도 없었다 —
목업 폴더가 사양서라는 것과, 코드는 shared 계약 3개 + backend 서비스 4개 + site/src
섹션에서 고친다는 것.
- README.md 한 장으로 합침. 1부 실제 구현(참고할 파일 · 섹션 22개 현황 · 할 일 T1~T6 ·
프롬프트 전문 6종) / 2부 목업 고치기(굽기·배포·규칙 14종·주입분) / 3부 공통(보고 규칙 ·
틀렸던 방식 · 파일 목록 · 지울 것 · 남은 판단)
- SECTIONS.md · PROMPTS.md · REVIEW-2026-09-11.md 삭제 — 내용은 전부 README 로
- 일회용 probe 스크립트 24개 삭제. 남긴 것은 audit-all · rails-test · kingcheck 셋
- scripts/mockup/.gitignore 신설 — 중간 산출물(stay-payload-new.json)
검증: node audit-all.mjs 34/34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
구워진 index.html 이 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 루트 절대경로로
가리켰다. 그 파일들은 레포가 아니라 도커 볼륨에만 있어서, 레포만 받은 사람은 흰 화면을 본다.
번들은 더 나쁘다 — 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
실측: 구운 html 이 /assets 를 63곳 가리키고 있었다.
- img/mirror/ 61장 · vendor/ 2개를 레포로 끌어들였다
- patch_stay.py: 경로를 `/s/stay/img/mirror/` · `/s/stay/vendor/` 로 바꿔 박고,
레포에 없는 자산이 있으면 **빌드를 멈춘다**. 끝에 `/assets` 잔재가 남아도 멈춘다
- audit-all.mjs: 히어로 사진 검사를 새 경로로
- SECTIONS.md: 설명서에서 **작업 지시서**로 다시 씀. 프롬프트를 여기 다시 적지 않고
PROMPTS.md 의 절을 가리킨다(일정은 §3-1/3-2/3-3 세 단계다). 제품 쪽 짝 파일 표 추가
- README.md: "보고 규칙" 절 신설 — 실행하지 않은 것을 됐다고 쓰지 않는다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기려는 사람이,
화면을 보고 역산해야 하는 상태였다. 시연본은 payload 가 없는 목업이라
"화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있는데 그 간격이 어디에
얼마나 있는지 적힌 곳이 없었다.
섹션마다 값이 오는 payload 자리 · 계약 유무 · 프롬프트 위치 · 목업이 손으로 한 것을
적고, 계약에 아예 없는 셋(히어로 캐치프레이즈 · 자작곡 플레이어 · 일정 규칙)은
추가할 타입과 프롬프트 초안까지 넣었다.
- SECTIONS.md 신설: 22개 섹션표 · 새로 만들어야 할 6가지 · 프롬프트 작성 규칙 · 검증
- README.md: 제품으로 옮기는 사람을 SECTIONS.md 로 보낸다. 감사 항목 수 33 → 34 정정
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 에 목업 자산이 통째로 404 가 났을 때, 복구한 곳은 서버가 아니라
`stay-mockup` 워크트리였다. 지금 이 사진과 음원은 이 맥과 킹서버 볼륨에만 있다 —
컨테이너를 지우면 같이 간다. 자산 커밋을 따로 떼는 이유는 나중에 히스토리에서
덩치를 걷어낼 일이 생겨도 이 커밋 하나만 건드리면 되기 때문이다.
- img/ 28장: 히어로·소개·객실(야놀자 등록본 A동 12·B동 10)·정거장
- img/people/ 17장: 위키백과 문서 사진. 자유 라이선스가 확인된 것만이고
저작자 표시를 화면에 남긴다(라이선스 조건이라 지우면 안 된다)
- audio/ 5곡: 사장님이 직접 만든 곡. 헤더 미니 플레이어가 파일로 재생한다
(프로토타입이라 CDN 을 쓰지 않는다 — nginx `^~ /s/` 가 Range 206 으로 준다)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이 목업은 payload 가 없어 프리렌더 재굽기 대상이 아니다 — `index.html` 한 장이 유일본이고
자산이 지워지면 사람이 되돌려 넣어야 한다(CLAUDE.md 의 ★★ 함정). 지금까지 그 한 장을
이 맥에서만 만들고 있었다. 다른 사람이 이어받을 수 있도록 재료를 전부 올린다.
`build/index.html` 은 생성물이라 이그노어 그대로다 — `patch_stay.py` 로 다시 나온다.
- build_itinerary.py: 테마 21개 일정 생성. 좌표에서 이동시간을 계산하고(도보 4km/h,
1.5km 초과는 차 25km/h + 주차 5분) 입·퇴실·끼니 창을 맞춘다. 규칙 14종 감사가
**빌드 안**에 있어 하나라도 어기면 payload 를 쓰지 않고 멈춘다 — 검사가 빌드 밖에
있던 동안 뼈대를 고칠 때마다 안 보는 규칙이 생겼다(2026-09-11 REVIEW)
- build_story.py: 노래 25곡·인물 57명. 사진은 위키백과 문서 pageimages 만 믿는다
(이름 검색은 동명이인을 끌고 온다 — 이수현→걸그룹, 박성현→골퍼)
- patch_stay.py: 캐치프레이즈 100개·자작곡 5곡·객실 사진(A동 12/B동 10)을 넣고
payload 를 갈아 끼운 뒤 inject.css/js 를 `</body>` 앞에 주입해 index.html 을 짠다
- inject.js/css: React 가 다시 그려도 살아남아야 하는 다섯 가지(캐치프레이즈 순환·
헤더 미니 플레이어·지도 링크·사진 저작자 표시·카로셀 제어). `#root` 밖에 둔다
- audit-all.mjs / rails-test.mjs: 실제 브라우저로 34종 검사, 레일 13개 자동 넘김 전수
- README.md: 이어받는 사람이 먼저 읽는 문서. 배포·함정·§7 "내가 틀렸던 6가지"
- PROMPTS.md / TEXT.md / REVIEW-2026-09-11.md: 문구 생성 프롬프트 · 전체 텍스트 · 검수 보고
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
날짜를 한 줄로 펼쳐 놓으니 예약 화면으로 안 읽혔다. 두 주만 보이는 것도 문제였다 —
손님이 다음 달을 잡으려면 방법이 없었다.
- 월 단위 그리드로 바꿨다. 요일 머리(일~토)를 두고 1일 앞을 빈 칸으로 채워 **열을 맞춘다** —
달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다
- 이전·다음 달. 오늘이 든 달이 하한이고 앞으로 두 달까지다(MONTH_SPAN).
★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤까지 열어 두면 손님은 그 날짜가
열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다
- 토·일은 요일 머리와 함께 구분한다. 장식이 아니라 주말 요금이 붙는 날이라는 정보다
- **고를 수 없는 날은 지난 날짜뿐이다.** '마감'·'잔여' 는 여전히 만들지 않는다 —
우리는 그 값을 모르고, 지어내면 손님이 그걸 보고 다른 날을 고른다
★ 서버 렌더 게이트는 그대로다. '오늘' 을 브라우저에서 정하므로 정적 HTML 에는 달력이
굽히지 않는다 — 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는 일이 없다.
site tsc·eslint 통과 · vitest 51 passed(SSR 이 날짜를 굽지 않는지 보는 기존 검사 포함).
시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.
★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
**재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
- 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
- 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
(비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
- 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
- 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다
★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다
★ 같은 사진을 두 번 쓰지 않는다
네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다
★ 프롬프트(shared 한 벌, export 포함)
- postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다
실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다
검증: site·frontend 빌드 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
스테이,머뭄으로 실제 발행해 시안(/s/stay)과 대조한 결과에서 나온 셋이다.
★ 지역 이야기 다섯이 통째로 비어 있었다 (섹션 13 → 18)
`has_stories()` 가 "이 지역에 이야기가 있나" 를 `kind IS NOT NULL` 로 판정했다. 그런데
kind 는 이야기 전용 칸이 아니다 — 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를
채웠다(AREA_KIND). 그래서 주변정보가 한 건이라도 들어온 지역은 이야기가 0건이어도
"이미 있다" 로 판정돼 생성이 영영 건너뛰어졌다. 잡은 성공으로 끝나고 로그도 조용해서
생성기가 없는 것처럼 보였다. STORY_KINDS 를 명시해서 고친다.
실측(전북 군산시): 고친 뒤 54건 생성(가요 8·인물 10·연표 12·엽서 12·퀴즈 12),
발행본 섹션 13 → 18, 본문 6,598자 → 10,798자.
★ 업소 자체 홈페이지를 아무도 등록하지 않고 버리고 있었다
네이버 지역검색 응답의 `link` 가 업체 홈페이지인데(external/naver.py 머리주석이 "채널 URL
발견에 쓸 수 있는 부수입" 이라 적어 뒀다) 채널로 등록하는 코드가 없었다. 숙박은 자체
홈페이지 보유율이 3업종 중 가장 높고(표본 25건 중 19건), 네이버 플레이스가 fact 를 3건밖에
주지 않는 업소에서는 **그게 유일한 공개 출처**다. discover_official_site 로 등록·확정한다.
- 추측이 아니다. 네이버가 그 업소 레코드에 달아 둔 값이고 동일 업소 판정(pick_match)을
통과했을 때만 쓴다 — discover_naver_place 와 같은 근거라 자동 확정한다
- 수집 금지 호스트(인스타·OTA)도 **등록은 한다**. 크롤은 static_html 의 _DENY_HOSTS 와
robots 가 막지만, 공식 채널·sameAs 로는 유효한 사실이다
- ★ 검색어에 `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 행정구역
코드('52군산시')라 후보가 0건이 된다(실측). `naver_place_lookup.region_hint` 로 쓴다
- collect_service 에 남아 있던 옛 표 이름(place_links.DBType) 한 곳도 같이 고쳤다 —
스키마 재편 때 놓친 자리이고, 실행되는 순간에만 NameError 로 터진다
★ 숙박 예약 분기 복구 — `booking: isLodging ? StayBookingSection : BookingSection`
64ce467 에서 사라져 숙박 발행이 절대규칙 3 대조에 걸려 통째로 막혀 있었다.
검증: site tsc·eslint·vitest 51 passed. 백엔드 pytest 529 passed / 52 failed —
**52건은 이 변경 전 main 에서도 같은 수로 실패한다**(기준선 확인). 스테이,머뭄 실발행으로
공식 채널·예약 채널 노출과 18섹션 확인.
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):
발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
(gallery↔photos · location↔map · guide↔local)
겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)
소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.
**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.
**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.
**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).
**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.
**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.
곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**
검증(Playwright, 같은 사업장·1024px):
섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
15개 전부 높이·글자 수·제목이 정확히 같다
편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
`/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.
**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
(같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.
**자리 가르기**
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
**첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.
검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
숙박 예약 구성(요금·인원·창구) · 네이버 예약 딥링크 · 날짜/시간 목업 · 로컬 발행 함정 셋.
충돌 4건 해결:
- seo/verify.ts STRUCTURAL: main 이 unitCode·numberOfRooms 를 이미 넣었다. main 쪽을 살리고
"사람이 읽는 unitText 는 넣지 않는다" 만 주석으로 얹었다(같은 결론에 각자 도달했다)
- pages/HomePage.tsx: import 목록만 갈렸다 — StorySection(main) · StayBookingSection(feature)
둘 다 필요하다
- backend/collect_service.py: 테넌트 제거로 _finish 인자가 company_id → owner_user_id 로
바뀌었다. main 시그니처를 따르고 _store_booking_link 를 그 앞에 둔다.
place.verified_by · _add_link 시그니처는 그대로라 예약 링크 경로는 손댈 것이 없었다
- docs/DEVLOG.md: 양쪽 새 항목을 날짜 내림차순으로 합쳤다
검증: site tsc·eslint·vitest 51 passed · frontend tsc·eslint 통과 ·
백엔드 이미지 재빌드 후 collect_service import + LinkChannel.NAVER_BOOKING=7 확인.
예약 흐름을 눈으로 보려고 StayBookingDemo 를 예약 안내 섹션 안에 넣었다. 날짜(2주) ·
도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고 전화로 잇는다. 재고 조회도 접수도
결제도 없다 — PRODUCT.md 6절은 그대로다.
목업이라도 지킨 선:
- 마감/잔여를 만들지 않는다. 모르는 값을 그럴듯하게 그리면 목업이 아니라 거짓말이다
- 시간 후보는 체크인 fact(16:00)에서 시작한다. fact 가 없으면 시간 선택을 내지 않는다 —
확인된 값과 어긋나는 선택지는 목업에도 두지 않는다
- 요금은 요금표·JSON-LD 와 같은 출처(unitBaseRate)를 쓴다. 한 페이지가 두 값을 말하지 않게
- 확인 화면은 "접수됐다"고 쓰지 않는다(사실이 아니다). 경고문도 두지 않는다(2026-09-09 결정)
★ 날짜는 브라우저에서 만든다(mounted 게이트). 서버에서 구우면 발행 시각의 날짜가 정적
HTML 에 박혀, 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는다 — 화면은 멀쩡하고 기계가
읽는 값만 틀리는 종류다. SSR 은 안내 한 줄만 내보낸다.
구조화 데이터·llms.txt 는 그대로다(availability 없음). 목업을 AI 에게 예약 창구로 소개하면
그때부터는 목업이 아니다. 연동을 붙일 자리는 ConfirmPanel 한 곳이다.
tsc·eslint 통과, vitest 51 passed(신규 4). 발행본 재굽기 후 /s/<slug> 확인.
`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다.
그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08:
`/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째
페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다.
색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한
표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts
주석) 목록만 반대 형태로 남아 있었다.
- nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301.
`^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는
늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다
- prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가
이 값 하나를 쓴다
- directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신
검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측
`/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/<slug>`→200 · `/s/<slug>/`→200 ·
`/nope`→200 앱 셸(변화 없음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/… 인 사장님은 예약하려고
눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.
주소를 지어낼 필요가 없다 — 플레이스 응답의 __APOLLO_STATE__ 가 예약 주소를 직접 준다
(실측 2026-09-08, place 1273971279):
naverBooking.naverBookingUrl = https://m.booking.naver.com/booking/6/bizes/1067685
★ bookingBusinessId + businessTypeId 로 조립하지 않는다. 조립하면 예약을 안 받는 업소에도
주소가 생기고, 빈 화면을 본 손님은 그 가게가 예약을 안 받는 줄로 읽는다.
- LinkChannel.NAVER_BOOKING=7 (백엔드·shared·init.sql 주석)
- collector/base: RawSource.booking_url — 채널이 스스로 알려준 예약 주소
- naver_place_adapter._booking_url: ROOT_QUERY.placeDetail(...).naverBooking 에서 읽는다
- collect_service._store_booking_link: 예약 채널로 등록·자동 확정(근거는 discover_naver_place
와 같다 — 이미 확정된 플레이스가 내놓은 자기 예약 주소다)
- site/seo/jsonld BOOKING_CHANNELS: 순서가 우선순위(예약→야놀자→여기어때→플레이스).
화면 버튼과 makesOffer.url·potentialAction 이 같은 함수를 쓴다
- site/lib/derive: 같은 순서로 정렬 + 검색 결과 주소 배제. 문구는 bookingCtaLabel 이
"네이버 예약으로 바로 예약하기"로 낸다("네이버 예약에서 예약"이 되지 않게)
- 빌더도 채널을 안다(useCollectFlow 라벨 · ChannelUrlInput 호스트 판정)
tsc·eslint 통과, vitest 47 passed(신규 4). 어댑터는 실제 네이버 응답으로 확인.
목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수
없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML
에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치.
- index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted ·
.h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track
★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 —
히어로 제목이 본문 크기로 나오던 원인
- HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다
- AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About:
목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다
- FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설
★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어
"어떤 순서로 도는가" 를 그릴 수 없다
- items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서
사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로
- color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한
글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다
- seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다
- seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다
★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서
room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개).
stay 는 객실 2개 + room_size 없음이라 우연히 통과했다
- shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl ·
ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl
★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다
검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17.
남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false),
weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw
숙박으로 발행하면 서버 기본표가 booking 섹션을 켜는데, 발행본이 읽는 fact
(reservation_required·reservation_channel)가 **숙박 스키마에 없다**. 그래서 펜션·민박의
"실시간 예약" 섹션에는 전화번호 한 줄만 남았다 — 요금도 인원도 취소 규정도 없었다.
숙박은 예약이 곧 매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의
대부분인데, 그 답의 근거가 페이지에 없으면 AI 는 OTA 후기에서 추측한다.
★ 예약을 처리하게 만든 게 아니다. 재고도 결제도 갖지 않는다(PRODUCT.md 6절) — 날짜
선택기·예약 폼을 그리지 않았고, "여기서 결제되지 않는다" 를 화면 맨 앞과 llms.txt 에
명시했다. 없는 기능을 흉내내면 손님은 예약한 줄 알고 안 온다.
- site/sections/StayBookingSection: 객실별 요금·인원 / 예약 창구(전화 + 확정 채널) /
예약 전 확인 8항목. 근거가 없으면 섹션째 안 나간다
- site/lib/derive: stayBookingView() 가 그릴지 말지까지 판단한다 — 내비·탭이 같은 함수를
본다(각자 판단하면 눌러도 아무 일 없는 탭이 생긴다). 예약 채널은 문의 목록에서 뺀다
- site/seo/jsonld: unitBaseRate() 를 요금 숫자의 단일 출처로. makesOffer(객실별 1박) ·
potentialAction(확정 채널만) 추가. availability 는 안 넣는다 — 빈 방을 모른다
- site/seo/llms: 숙박 ## 예약 블록을 위쪽에. 아래에만 있으면 답에 안 실린다
- frontend/industryData, backend/site_payload: 기본 섹션명 "실시간 예약" → "예약 안내".
실시간 예약을 하지 않는데 제목이 그렇게 말했다(두 파일은 parity 테스트가 묶는다)
- site/seo/verify: 데모 payload 가 원래 굽히지 않던 오탐 둘을 고쳤다(main 에서 재현) —
속성의 & 이스케이프 때문에 화면에 있는 이미지 URL 을 못 찾던 것, ㎡ 의 단위 코드
MTK 를 본문에서 찾던 것. 되돌린 사본에서도 못 찾으면 그대로 실패다
tsc·eslint 통과, vitest 43 passed(신규 21). 데모 재굽기 성공 → /s/moonlight-stay-jeju 200.
백엔드 pytest 는 venv 가 없어 미실행 — 섹션표 parity 는 같은 방식으로 손대조했다.
/s/stay · /s/stay2 · /s/stay3 의 CSS·JS·이미지가 전부 404 가 됐고 재굽기로 살아나지 않았다.
out/s/ 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 손으로 넣은 목업이고, 프리렌더는
payload 를 받은 사이트만 굽는다 — 목업은 재굽기 대상이 아니라서 자산이 한 번 지워지면
영영 복구되지 않는다. 문서 어디에도 목업 얘기가 없어서(grep 0건) 그 존재를 모르고 자산
삭제 코드를 건드렸다.
보관 기간으로는 못 막는다. 기간이 지나면 같은 사고가 난다. 참조가 살아 있으면 남겨야 한다.
- referencedAssets(): 굽기 전에 out/s/**/index.html 을 훑어 /assets/… 참조를 모은다
- pruneAssets: 그 목록은 절대 지우지 않는다 — 보관 기간보다 우선한다
- AGENTS.md: 목업의 존재와 "자산 삭제 코드는 참조를 먼저 뺀다" 를 함정 맨 위에 ★★로
- docs/DEVLOG.md: 사고 기록과 복구 경로
검증: payload 없는 목업을 재현해 재굽기 → 참조 3개 유지. 대장을 60일 전으로 돌려 만료를
강제해도 유지. tsc·eslint 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
직전 커밋(옛 해시 자산 30일 보관)을 배포하자 기존 사이트의 CSS·JS 가 전부 404 가 됐다.
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
pruneAssets 가 "대장(.builds.json)에 없는 파일"을 만료로 보고 지웠다. 그런데 대장은 이
기능과 함께 처음 생긴다 — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이
전부 "대장에 없음"으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다.
검증을 out/ 을 비운 상태에서만 돌린 탓에 못 봤다 — 재현했어야 할 것은 빈 디렉토리가 아니라
"옛 자산은 있는데 대장은 없는" 상태, 즉 실제 배포 직전의 서버 모습이었다.
- scripts/prerender.ts: 대장에 없는 파일은 "지금 처음 본 것"으로 입양해 보관 기간을 새로 준다
- AGENTS.md: "기록이 없다"와 "만료됐다"를 같이 묶지 않는다 — 함정 목록에 ★로 박았다
- docs/DEVLOG.md: 사고 기록과 복구 절차(docker compose restart solution-frontend)
검증: 배포 직전 상태 재현 — out/assets 에 옛 해시 파일만 두고 대장 없이 첫 실행하면 옛 파일이
그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다.
tsc·eslint 통과, vitest 22 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
writeSharedAssets 가 빌드마다 out/assets 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 아직 다시 굽지 않은 사이트는
전부 CSS·JS 404 였다. 그 구멍을 "기동 시 전체 재굽기"와 "배포하면 반드시 전체 재업로드"라는
규칙으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.
진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 나중에 돌린다.
그 사이 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다.
유예 창이 필요한 건 통념이고(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다),
우리 창은 0초였다. 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다.
- scripts/prerender.ts: assets/ 통째 삭제 제거. 권한 때문에 지웠던 것인데
copyDirectoryFiles 가 파일마다 먼저 rmSync 하므로 그 문제는 그대로 해결된다
- ASSET_RETENTION_DAYS(30) · ASSET_MIN_BUILDS(2) — 기간이 지나도 직전 빌드는 남는다
- out/assets/.builds.json 대장 — mtime 으로 나이를 재지 않는다(복사·동기화가 시각을 갈아
버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다). 발행마다 이 함수가 도므로
번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다
- AGENTS.md 함정 항목 · docs/DEPLOY.md 2절 · docs/DEVLOG.md
남은 것: azure_static._upload_shared 가 매 발행마다 assets/ 전체를 올린다 — 보관 기간만큼
업로드량이 는다. Azure 는 지금 꺼져 있으므로 켤 때 기존 블롭 건너뛰기를 먼저 붙인다.
검증: 세 번 구워 확인 — 번들 해시가 바뀌어도 옛 파일 3개가 남고, 같은 번들로 다시 구우면
대장이 안 늘며(2줄 유지), 대장 마지막 줄을 60일 전으로 돌리자 그 빌드 파일 3개만 정리됐다.
tsc·eslint 통과, vitest 22 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
lastmod 를 구운 index.html 의 mtime 에서 읽었다. 렌더러를 배포하면 번들 해시가 바뀌어
내용이 같은 사이트까지 전부 다시 구워지고, mtime 은 그때마다 오늘이 된다 — 사이트맵이
"전 사이트가 오늘 갱신됨" 을 통보한다.
구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고 어긋나면 필드를 아예 무시한다
(Search Central: "the date and time of the last significant update" · "consistently and
verifiably accurate"). 지금 뭘 깨뜨리는 게 아니라, 사장님이 진짜로 내용을 고쳐 재발행한
날의 신호를 미리 죽여 두는 종류다. 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다.
- seo/directory.ts: readBakedTitle · readBakedLastmod 추가. lastmod 는 head 가 선언한
dateModified(= payload.site.updatedAt) 그 값이다 — 구글이 대조하는 값과 같아 어긋날 수 없다
- scripts/prerender.ts: 사이트맵 항목에서 mtime 제거, 파일 한 번 읽어 제목·lastmod 동시 추출.
mtime 은 dateModified 메타가 없던 산출물에만 남는 폴백이다
- seo/directory.test.ts: head.ts 태그와 파서의 커플링 고정 — 모양이 바뀌면 파서가 조용히
undefined 를 내고 mtime 으로 되돌아간다(빌드도 화면도 멀쩡한 회귀)
- docs/DEVLOG.md
tsc·eslint 통과, vitest 22 passed (신규 5건)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.
스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.
- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
`/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다
검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
서치콘솔 URL 검사(2026-09-07, /s/stay): "참조 페이지: 감지된 페이지 없음".
색인은 됐는데 이 호스트의 어떤 페이지도 발행본을 가리키지 않아, 크롤러가 발행본에
닿는 길이 사이트맵 하나뿐이었다. 사이트맵은 "이런 주소가 있다"만 말하고 볼 가치가
있는지는 말하지 않는다 — 그래서 색인은 되고 순위는 0인 상태가 됐다.
랜딩의 쇼케이스는 API 를 fetch 해 그리는 클라이언트 렌더라(ShowcaseGrid.tsx)
JS 를 실행하지 않는 크롤러에게는 없는 링크다. 그래서 정적 HTML 로 따로 굽는다.
- seo/directory.ts: `/s/` 목록 페이지(CollectionPage + ItemList LD)와 루트 llms.txt.
목록의 제목은 payload 가 아니라 **구운 index.html 의 <title>** 에서 읽는다 —
발행은 바뀐 사이트 하나만 굽기 때문에 payload 로 만들면 나머지가 슬러그로 떨어진다
- prerender.ts: 사이트맵에 랜딩과 목록 페이지를 추가. 목록 주소는 끝 슬래시가 있어야
한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡고, 없으면 사장님 앱으로 떨어진다
- nginx: `location ^~ /s/` 에 `index index.html`. try_files 첫 인자가 끝 슬래시면
nginx 가 디렉토리 검사로 읽고 거기서 멈춰 403 이 된다(=404 로도 안 떨어진다)
★ 루트 llms.txt 의 기대치: 구글은 안 쓴다고 공식 확인했고(2025-07 Illyes) 크롤러
트래픽으로도 거의 안 잡힌다(90일 5억 방문 중 408건). 두는 이유는 에이전트 경로
하나다 — 사용자가 AI 에게 "이 사이트 봐줘" 할 때의 fetch 는 봇 집계에 안 잡힌다.
비용이 함수 하나라 채택되면 이미 있는 쪽을 택했다. 발행본별 llms.txt 는 그대로다.
검증: tsc(solution/site) 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
이 호스트에는 들어오는 링크가 없다. 크롤러가 발행 사이트를 찾는 경로는 사이트맵
하나뿐인데 그 문이 두 군데서 막혀 있었다.
- 랜딩('/')에 `noindex, nofollow` 가 박혀 있었다. 관리자 화면이라고 보고 걸어 둔
것인데(7a6fee7 로 '/' 관문이 사라져 이제는 랜딩이다), nofollow 때문에
랜딩→발행 사이트로 이어지는 발견 경로까지 함께 죽어 있었다.
- 사이트맵이 `/s/<slug>/` 를 담는데 페이지 canonical 은 `/s/<slug>` 다
(shared/lib/slug.ts publishUrl). 서치콘솔은 제출 URL 을 전부 "대체 페이지"로
분류한다 — 색인은 되는데 제출분 0건으로 보이는, 조용히 틀리는 종류다.
- frontend/index.html: robots 를 index,follow 로. description·canonical·og 추가.
호스트는 적지 않고 Vite 가 빌드 때 `%VITE_PUBLISH_HOST%` 를 치환한다
(compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 갈라진다
- site/scripts/prerender.ts: 사이트맵 loc 의 끝 슬래시 제거 + 오리진 루트를 첫 항목으로
남은 것: 랜딩 body 가 빈 SPA 셸이라 렌더링에 기댄다. 랜딩 프리렌더는 별건이다.
검증: tsc(solution/site) 통과 · vite build 로 %VITE_PUBLISH_HOST% 치환 확인
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
옛 주소 w4ai.o2o.kr 은 앞단에 vhost 가 없어 전 경로가 Apache 자체 404 다(인증서도
CN=actions.o2o.kr, 2024 만료). 그런데 canonical·og:url·sitemap 이 전부 그 주소를
가리키고 있었다 — **화면은 멀쩡하고 기계가 읽는 값만 틀린** 상태라, 검색엔진에
아무리 등록해도 색인이 안 되는 종류다.
- 기본 호스트를 쓰는 자리 전부: site_payload.DEFAULT_HOST · compose 의 `:-` 기본값 4곳 ·
vite.config.ts allowedHosts · .env.example 둘 · check_search_ready.py · 데모 픽스처
- init.sql: site.sites.thumbnail_url 을 "기존 DB 보정(ALTER)" 절에 추가.
CREATE TABLE 에만 있어서 **새 DB 는 되고 기존 DB 만 조용히 깨졌다** —
실측(킹서버): GET /v1/showcase 가 200 인데 내용이 비었다
- docs/SERVERS.md: 배포 경로 ~/data2/o2o-site-AEO · 새 remote · 공개 주소 절 ·
init.sql 이 DB 최초 생성 때만 돈다는 함정
- docs/DEVLOG.md: 항목 추가
테스트 픽스처의 w4ai.o2o.kr 은 그대로 뒀다 — 자기가 넣은 값을 자기가 검증해서
기본 호스트와 무관하다.
tsc·eslint 통과. vite build 는 도커에서 확인(로컬 node_modules 의 rollup 네이티브 누락).
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.
- shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 —
9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다
- site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden).
① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서,
계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법)
② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다
지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다
- frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와
"손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다
tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은
hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
아이템 넷(가요·일력·승차권·스케줄)만 있었고, 그마저 **발행본에는 하나도 안 나갔다.**
`SectionSetting` 계약에 data 가 없어 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 —
소개문 body 와 같은 사연이다. 빌더에서는 보이는데 발행하면 없는 섹션이었다.
그리고 아이템 전부가 갱지색·주(朱)잉크·간판체를 hex 로 박고 있어, 템플릿을 매거진으로 바꿔도
아이템 섹션만 레트로로 남았다. 발행본은 색만 템플릿을 따랐다(계약에 생김새가 없었다).
- shared/section-data: 읽는 쪽 계약을 계약 패키지로 — 항목 타입 · parseSectionData.
같은 JSON 을 빌더와 발행본이 읽는다. 파서가 두 벌이면 슬러그 규칙처럼 조용히 어긋난다
- frontend/dataSpec: 아이템 6종 추가 — 인물 열전 · 시간의 골목 · 문학 서가 · 오늘의 엽서 ·
뒤집어 보는 질문 · 계절별 추천 하루. [+ 섹션 추가] 목록은 dataSpec 에서 파생돼 손댈 곳이 없다
- shared/planDay: 계절별 추천 하루는 시각을 **계산한다**. schedule 과 축이 다르다 —
저쪽은 사장님이 시각을 적고 여기는 출발 시각·소요 분에서 시각을 만든다.
조립 규칙을 shared 에 둔 이유는 파서와 같다(빌더와 발행본이 같은 시각을 내야 한다).
21시를 넘기는 칸은 넣지 않고 뺐다고 화면에 밝힌다 — 숨기면 왜 없는지 사장님이 모른다
- shared/site-payload: SectionSetting.data · SiteTheme.look 추가. backend/site_payload 는
해석 없이 싣는다 — 모양을 검사하면 프론트가 필드를 늘린 날 조용히 떨어뜨린다
- site/sections/items: 발행본 아이템 10종. **인터랙션은 옮기지 않았다** — 캔버스의 턴테이블은
'지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. 인용이 이 사이트의 존재 이유다
- site/prerender: 아이템 항목을 고유 콘텐츠로 계수. 안 세면 "곡을 여덟 개 채웠는데 0건으로
발행이 막힌다"가 된다(intro.body 와 같은 구멍). 백엔드 fake 도 같은 규칙으로 맞췄다
- 아이템 색·서체를 전부 --tpl-* 토큰으로. retro/common → items/common, RETRO_* → ITEM_*.
글자 단계는 stone-400/500/600 대신 불투명도로 만든다 — 팔레트가 바뀌어도 위계가 남는다
- site/seo/head: look 을 --tpl-* 로 심고, 웹폰트는 템플릿이 쓰는 것만 내려보낸다.
전부 항상 실으면 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다
- shared/color: deriveSurfaces 를 계약 패키지로. 캔버스·쇼케이스·발행본이 같은 식을 써야
미리보기가 거짓말을 하지 않는다. 프론트 lib/color 는 재수출만 남겼다
밟은 함정: 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 순위 배지가 사라진다(연한 accent +
밝은 바탕). color-mix(accent 70%, currentColor) 로 색조는 남기고 대비만 확보했다.
'확인/확인필요' 배지는 디자인이 아니라 신호라 신호색을 지키되 둘레 글자색만 섞는다.
tsc·eslint·vite build 통과(frontend·admin·site), site 테스트 17 passed.
실물 프리렌더(레트로 look + 아이템): 열 섹션과 본문 문장 전부 포함, --tpl-font-heading 'Gugi' ·
border-width 2px, family=Gugi&Gowun+Batang 링크, 계절 묶음·순위·계산된 시각(09:30 출발 →
09:45 도착 → 11:15 → 11:25) 확인. 고유 콘텐츠 12건 ok=true.
옛 payload(look 없음)로 다시 구워 예전과 동일하게 나오는 것까지 확인.
백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — _theme·_sections 는 함수 단위로 확인.
## 업종 교체 (tour → clinic)
PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.
- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
test_site_theme 이 이 둘을 대조한다.
## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)
두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.
## 그 밖
- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.
검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다.
본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가
저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다.
그래서 발행본에 안 나오고, 계수에도 안 잡혔다.
거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의
mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는
"구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다.
- shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연
- backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다
- site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다
- site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수
- site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게.
계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다)
- backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다.
evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다
- backend/router: theme API 설명에 body 반영
테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다
(test_default_sections_match_the_editor 의 solution/front 경로 오타 등).
tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1,
발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부.
지운 것 — 앞으로의 개발에 쓸 데가 없다.
- solution/backend/demo_site.html: 어떤 스크립트도 만들지 않는 고아 산출물이고,
손으로 쓴 HTML 이라 "백엔드는 HTML 을 만들지 않는다" 와도 어긋난다.
- solution/README.md: front/ · admin/.env · 사이트당 rooms/index.html·sitemap.xml 처럼
지금은 전부 틀린 서술이었다. 살아 있는 두 가지(빌더 CSR vs 발행물 SSG 대비표,
하이드레이션 블롭에 미검증 값이 샜던 실측)는 ARCHITECTURE 로 옮겼다.
- docs/API_USAGE.md 의 Claude 개발비 집계: 2026-08-27 스냅샷과 재집계 스크립트는
일회성 지출 기록이라 제품 원가와 성격이 다르다. 문서를 외부 API 원가 하나로 좁혔다.
고친 것 — 코드를 따라가지 못하던 서술.
- 코드 경로가 solution/backend 로 옮겨진 뒤 `backend/...` 로 남아 있던 포인터 전부.
가리키는 자리가 없는 경로는 문서가 아니라 함정이다.
- ARCHITECTURE: 트리의 front→frontend, 컨테이너 표에 api-admin(:9801)·admin(:3002) 추가.
- ★ ARCHITECTURE·AGENTS 의 "admin 전용 라우터가 0개" 는 사실이 아니었다.
/v1/admin/local-content 가 admin 전용인데 :9800 에도 마운트돼 있다 —
포트를 가른 논리에 아직 남은 구멍이라 그렇게 적었다.
- DECISIONS: 결론난 것을 미결로 두면 함정이 된다. 작업 큐(2026-08-27 결론),
날씨 캐시 TTL 1시간, jobs 테이블, media 조회 API, 수집 체인을 결론으로 옮기고
네이버 플레이스 대 TourAPI 실측(2026-08-31)을 1-1 에 이었다.
- API_USAGE: 어댑터가 다 붙고 TourAPI 키도 나왔다. "실호출 0건" 은 낡은 서술이었다.
- backend/README: 16→17 테이블(jobs), 없어진 alters/, MockAdapter 만이라는 서술,
cd backend 경로, media·local 라우터 누락.
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC