인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.
- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
(line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
빌드 스크립트 추가(다른 세션 작업)
테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
115 KiB
개발 일지
2026-09-17 — 미니 블로그 — 지금 생성하기에 구간(시작~끝) 지정, 실배포 E2E 로 잡은 버그 1건
한 일
- "지금 생성하기"가 구간을 받는다(사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를
정해야하지 않을까" → "캘린더 UI로 날짜받게").
POST .../post/generate?start=&end=(blog_jobs.generate_range) — 개별 생성과 같은 이유로 재고 상한(REFILL_BELOW)을 안 보고, 이미 글이 있는 날짜는 LLM 호출 없이 건너뛰고, 소재가 떨어지면 그 자리에서 멈춘다. 응답에requested/created를 같이 줘서 "N일 중 M일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는 버튼을 누르면 시작·끝일을<input type="date">두 개로 받는 다이얼로그가 뜬다. - 기존
blog_jobs.generate_now(재고 상한 기반, "다음 빈 날부터 순서대로")는 삭제하고generate_range로 교체 — 호출부가 이 엔드포인트 하나뿐이라 하위호환 어댑터 없이 바로 바꿨다.
실배포로 E2E 를 돌리다 잡은 버그 — blog_service.generate_one 의 죽은 import
사장님이 "테스트하고 결과 알려줘"로 시켜서 로컬 docker 를 재배포하고 실제 API 로 전체 플로우를
돌렸더니(회원가입→사업장→발행 시드→생성→개별생성→승인), "지금 생성하기"가 500 으로 죽었다.
원인: from services.external.gemini_text import DEFAULT_TEXT_MODEL, is_configured —
DEFAULT_TEXT_MODEL 은 애초에 그 모듈에 있던 적이 없다(LLM 공급자를 gemini/openai 로 가르는
리팩터로 services/external/gemini_text.py 가 "소개문·FAQ 조립" 전용으로 바뀌면서, 모델
상수·is_configured는 services/llm/gemini.py(DEFAULT_MODEL)로 옮겨갔다). pytest 는 이
함수를 통째로 monkeypatch 하는 테스트뿐이라 이 import 자체가 실행된 적이 없어 26 passed 로도
안 잡혔다 — "단위 테스트가 초록"과 "실제로 돈다"는 다른 것이라는 걸 이번에 실측으로
확인했다. 고침: services.llm.gemini 에서 DEFAULT_MODEL·is_configured 를 가져오도록
import 한 줄만 수정.
검증 — test_blog_post.py·test_blog_owner.py 27 passed(신규: 구간 생성 성공/거절).
전체 백엔드 753 passed(기존에 깨져 있던 test_gemini*·test_search_console_service.py
44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈, 앞선 라운드에서도 확인). npm run build -w @o2o/frontend 통과. 로컬 docker 재배포 후 실제 API 로 회원가입→생성→개별생성→
구간생성→승인→BUILD 잡 큐잉까지 end-to-end 확인(진짜 Gemini 호출 포함, 브라우저 확장이
연결되지 않아 화면 클릭 대신 API 레벨로 돌렸다). → MINI_BLOG.md
2026-09-17 — 미니 블로그 — 탭 3개→2개로 되돌림, 생성 이력에 모델명, 빈 날짜 개별 생성
한 일
- 탭을 3개(이번 주·달력·생성 이력)에서 2개(블로그·생성 이력)로 되돌렸다. 지난 라운드에서
카로셀·달력을 각자 탭으로 쪼갠 게 오독이었다(사장님 지시: "탭을 왜 이번주 달력 이렇게
나누고 지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지") — 원래
요청은 "달력 위에 카로셀"이지 "카로셀 따로, 달력 따로"가 아니었다. 생성 이력만 별도 탭으로
남긴다(
BlogPostsPage.tsxTab = 'main' | 'history'). - 달력 칸 배지 문구 "메일 발송됨" → "발송완료"(사장님 지시: "달력에 발송완료 된거는
되었다고 적으라고",
publishBadge). - 생성 이력에 어느 모델을 썼는지 추가(사장님 지시: "생성이력도 상세하게 기록해놓으셈
어느 모델썼는지 등등"). 새 컬럼을 늘리는 대신
place_posts.generation_meta(jsonb) 한 칸에{"model": "..."}로 담는다(사장님 지시: "Jsonb 하나팟거 컬럼",migrations/0020_place_posts_generation_meta.sql).blog_service.generate_one()반환값을str | None→tuple[str, str] | None(본문, 모델명)으로 바꾸고,PostCRUD.generation_batches가 회차별 대표 모델(MAX(generation_meta->>'model'))을 같이 뽑는다. - 빈 날짜 하나만 콕 집어 생성(사장님 지시: "그리고 개별적으로 새로 만들수있게 해줘").
POST /v1/place/{place_id}/post/generate-one?date=(PostService.generate_for_date→blog_jobs.generate_one_for_date) — 재고 상한(REFILL_BELOW)을 안 본다, 콕 집은 날짜라 상한이 끼어들 자리가 아니다. 프론트는 달력에서 오늘 이후의 빈 칸만 누르면 그 날짜로 요청하고, 성공하면 그 자리에서 모달을 연다(CalendaronGenerateDay/generatingDay). 지난 날짜 칸은 클릭을 막는다.
밟은 함정 — ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다
PostCRUD.add_one을 처음엔 ORM 객체(place_posts(**row))를 그대로 돌려주게 짰다.
execute_lambda_write는 func(s) 실행 뒤 commit까지 하고 값을 돌려주므로,
호출측이 그 객체의 속성(post_id 등)을 읽는 시점엔 세션이 이미 끝나 DetachedInstanceError
가 날 자리였다. post_id·status(둘 다 Python 쪽 default)는 flush() 직후엔 이미
채워져 있으므로, flush 직후 세션이 살아있을 때 값만 plain dict 로 뽑아 돌려주게 고쳤다
— ORM 객체 자체를 세션 밖으로 내보내지 않는다.
검증 — test_blog_post.py·test_blog_owner.py 26 passed(신규 3건: 개별 생성 성공·날짜
중복 실패·소유권 스코프). 전체 백엔드 753 passed(기존에 깨져 있던 test_gemini*·
test_search_console_service.py 44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈).
npm run build -w @o2o/frontend 통과. → MINI_BLOG.md
2026-09-17 — 미니 블로그 메일 — 승인 즉시 처리 + 수정 자동 로그인, 화면 탭 3개로
한 일
- 메일 승인 링크: GET 이 확인 화면 없이 즉시 승인(
router/v1/site/post.py). 메일 프리페치에 노출된다는 걸 알고도 사장님이 택한 것 — POST/approve, GET/POST/v1/site/post/edit(공개 편집 화면) 전부 삭제,PostService.edit도 같이 지웠다. - 메일 수정 링크: 이제 로그인 흐름이다.
CreateDayPassToken(그날 자정 KST 까지만 사는 접근 토큰,router/v1/validator/dependencies.py)을 실은/blog?placeId=&postId=&auto=로 간다. 빌더 앱이 그 토큰으로 로그인해 편집 모달을 바로 연다. - 승인·수정 링크 둘 다 그날 자정(KST) 만료로 통일(
blog_service.issue_token, 예전 14일 → 자정). 그 뒤엔 로그인해서 빌더 앱에서 처리한다. - 신규 엔드포인트:
GET .../post/{post_id}(메일 수정 링크 전용 단건 조회),GET .../post/history(생성 이력 — 언제 몇 건, 새 컬럼 없이created_at회차로 묶음). BlogPostsPage.tsx를 탭 셋으로 재구성 — 이번 주 · 달력 · 생성 이력. 카로셀 카드를 누르면 그 자리에서 고치는 대신 모달을 연다(미리보기용PostPreviewCard와 실제 편집용PostCard분리). 달력 칸엔 발행완료/발행실패에 메일 발송됨 배지를 추가했다(크론잡이 실제로 돌았다는 확인). 이전 달/월/다음 달을 달력 탭 안, 달력 바로 위로 옮겼다.
밟은 함정 — 세션 복구보다 늦게 로그인시키면 이미 늦다
BlogPostsPage 안에서 auto 토큰으로 로그인시켰더니 "메일온거 클릭했더니 로그인하라고
뜨는데?" — RequireAuth 는 라우트 렌더링 시점에 isRestoring/user 를 보고 그 자리에서
/login 으로 튕긴다. 페이지 컴포넌트는 그 판정 이후에만 마운트되므로, 컴포넌트 안의
useEffect 로 로그인시키는 건 이미 늦다. auto 파라미터 처리를 세션 복구
(app/provider.tsx useRestoreSession) 안으로 옮겨서 고쳤다 — JWT sub 클레임을
그대로 디코드해(lib/jwt.ts, 서명 검증은 이미 서버가 함) useAuthStore 를 채운다.
밟은 함정 — raw SQL 로 timestamptz 에 naive UTC 를 바인딩하면 로컬 시간대로 샌다
자정 만료로 정밀해지자 테스트 3개가 "이미 만료됨"으로 죽었다. 원인: 테스트 시더가
text() 로 token_expires_at 에 naive datetime(GTime.UTC() 류)을 직접 바인딩하는데,
컬럼 타입 정보가 없는 raw 바인딩은 asyncpg 가 드라이버 프로세스의 로컬 시스템 시간대로
해석한다 — 이 개발 머신은 KST(UTC+9) 라 9시간이 밀렸다. 예전엔 14일짜리 만료값이라 9시간
밀려도 부호가 안 바뀌어 안 드러났을 뿐이다. ORM 경로(update()/insert())는 컬럼의
DateTime(timezone=True) 프로세서를 타서 이 문제가 없다 — 실제 운영 코드(mark_sent)는
전부 ORM 이라 안전했다. 고침: 테스트 시더에서 바인딩 직전에 .replace(tzinfo=timezone.utc)
로 명시(tests/test_blog_post.py). raw text() 로 timestamptz 컬럼에 naive datetime 을
바인딩하는 코드를 다시 보면, 반드시 이 함정을 의심한다.
검증 — test_blog_post.py·test_blog_owner.py 23 passed. npm run build -w @o2o/frontend 통과. mnchoi@o2o.kr 로 실제 메일 미리보기 발송 확인(가짜 place/post 라
링크 자체는 동작하지 않음, 형식만 확인). → MINI_BLOG.md
2026-09-17 — 미니 블로그 빌더 화면 — 카로셀은 일주일치·달력은 모달, scheduled_date NULL 백필
한 일
GET /v1/place/{place_id}/post/upcoming?days=7신설(PostService.list_upcoming) — 카로셀은 이제 브라우징 중인 달과 무관하게 항상 오늘부터 7일치만, 날짜 오름차순으로 본다. 기존list_for_placeCRUD 를 월 경계 대신 (오늘, 오늘+N) 경계로 그대로 재사용했다.- 카로셀 카드에 배정일 전부 표시 + 오늘/내일 카드에 chip. 마우스 오버 시 z-index 를
최상단으로 올려 겹친 카드가 안 가리게 했다(
PostCarouselhover 상태). - 달력 칸 클릭이 "카로셀로 스크롤"에서 모달(
Dialog, 기존components/ui/dialog.tsx재사용)로 바뀌었다 — 그 날짜의 글 전체 내용 + 수정·바로 발행 버튼을 그 자리에서 보여준다. - 달력 이전/다음 달 이동을 이번 달 ~ 1년 뒤로 제한(
minMonth/maxMonth, 문자열 비교로 버튼 비활성화). 그 밖의 달은 볼 이유가 없다(과거는 비어 있고, 미래는 아직 아무것도 배정 안 됨).
밟은 함정 — scheduled_date NULL 백필
배포 직후 사장님이 "지금 생성하기"로 실제 만든 글 13건이 화면에서 통째로 사라져 보였다.
원인: 그 글들은 scheduled_date 컬럼이 생기기 전에 만들어져 값이 비어 있었는데,
월별·주간 조회 둘 다 이제 scheduled_date 로 거르는 바람에 IS NULL 행이 조용히
빠졌다(SQL 에서 NULL <= x 는 항상 unknown). 실서버 DB 에 1회성 SQL 로 백필했다 —
업장별 created_at 순서를 살려 오늘부터 하루씩 순서대로 채움. 새 컬럼을 추가하는
마이그레이션은 앞으로도 기존 행에 값이 없을 때 조회에서 조용히 빠지는지를 먼저
따져야 한다.
검증 — test_blog_post.py·test_blog_owner.py 23 passed(upcoming 엔드포인트 날짜
필터·정렬 회귀 테스트 포함). npm run build -w @o2o/frontend 통과. → MINI_BLOG.md
2026-09-17 — 미니 블로그 빌더 화면 — 카로셀(편집) + 달력(발행완료/실패만 표시)
한 일
BlogPostsPage.tsx를 "리스트 + 달력 클릭 시 펼침" 구조에서 카로셀(위) + 달력(아래) 둘로 나눴다. 카로셀(PostCarousel)은 이 달 글 카드를 겹쳐 쌓아 가로로 넘기는 형태고, 편집·바로 발행 버튼은 이제 여기에만 있다. 달력(Calendar)은 보기 전용 — 칸마다 본문 앞부분 스니펫과 발행완료/발행실패 배지만 단다. 검수 대기·메일 발송 같은 발행 전 상태는 아무 배지도 안 단다. 칸을 누르면 카로셀의 해당 카드로 스크롤한다.PostData에build_failed(bool) 추가.PostService._latest_build_failed가 그 업장의 가장 최근 BUILD 잡이JobStatus.DEAD인지 보고, APPROVED 인데 아직 안 나간 글에만 단다 — BUILD 잡 하나가 업장 승인분 전체를 한 번에 굽는 구조라 글 단위가 아니라 "이 업장 재발행이 막혀 있나" 를 보는 것이다.
왜 사장님 지시: "위에 겹치는 카로셀로 글들의 카드가 보이는거고 밑에는 달력에 내용앞부분 약간이랑 발행되었는지 안되었는지 여부 이렇게 표시하면됨 발행전인건 표시하지 말고 발행완료/발행실패 이것만 표시하면 될듯" — 앞서 만든 "오늘 게재됨/검토 대기" 요약 카드 2장은 이 의도와 달랐다(집계 카드였지 개별 글 카로셀이 아니었다).
검증 — test_blog_post.py·test_blog_owner.py 22 passed(발행실패 판정 회귀 테스트
2건 포함). npm run build -w @o2o/frontend 통과. → MINI_BLOG.md
2026-09-17 — 미니 블로그 빌더 화면 — 달력 + 배정일(scheduled_date) + 즉시 생성·바로 발행
한 일
place_posts.scheduled_date(date) 추가(migrations/0019_place_posts_scheduled_date.sql,init.sql,models.py).(place_id, scheduled_date)유니크 — 업장 하나가 같은 날짜를 두 번 못 쓴다. 생성 시 그 업장의MAX(scheduled_date)다음날(없으면 오늘, KST)부터 하루 한 건씩 순서대로 배정한다(blog_jobs._generate_for_place).PostCRUD.due_for_mail이 이제scheduled_date <= 오늘인 것만 고른다 — 미래 배정 글이 그날 되기 전에 새는 것을 막는다.list_for_place(빌더 화면 월별 조회)도created_at대신scheduled_date기준으로 바꿨다.BlogPostsPage.tsx를 리스트에서 달력으로 바꿨다 — 글이 0건이어도 달력 칸 자체는 항상 뜬다. 위에 오늘 게재됨 · 검토 대기 요약 카드 두 장을 살짝 겹쳐서 배치했다.- 지금 생성하기(
POST .../post/generate) — 새벽 04:10 크론을 안 기다리고 그 자리에서 만든다. 바로 발행(POST .../post/{post_id}/approve) — 안 고치고 그대로 승인. SitesPage.tsx카드의 "더보기" 메뉴에 디자인·컨텐츠 관리 / 미니블로그 관리 / 예약요청 관리 세 항목을 얹었다(탭이 아니라 메뉴 — 사장님 지시). 예약요청은 아직 화면이 없다 —booking_request.py가 요청을 DB 에 남기지 않기로 한 결정(2026-09-16)과 부딪혀서 안내만 띄운다.
왜
사장님 요청: "포스트들이 다 날짜가 정해져야하는데" — created_at(만들어진 시각)만 있고
"언제 낼 것인가"가 없어서, 달력을 만들려면 화면이 근거 없는 날짜를 지어내야 했다. 또
"생성된 포스트가 없어도 달력은 계속 떠야지" — 목록이 비면 화면이 통째로 빈 상태 문구로
바뀌던 걸 고쳤다.
밟은 함정 — PostCRUD.due_for_mail/list_for_place 시그니처가 바뀌어(today/날짜
경계 타입) 호출부를 같이 안 고치면 조용히 옛 컬럼을 봤을 것 — _month_range 를
UTC datetime 경계에서 KST 순수 date 경계로 바꿔 타임존 변환 자체를 없앴다(scheduled_date 는
timestamptz 가 아니라 DATE 라 변환이 필요 없다).
검증 — test_blog_post.py·test_blog_owner.py 20 passed(배정일 순서·업장당 하루 한 통
회귀 테스트 포함). npm run build -w @o2o/frontend 통과(typegen·tsc·eslint·vite build).
→ MINI_BLOG.md
2026-09-17 — 미니 블로그 팀 사전검수 폐지 — 검수는 사장님이, 빌더 앱에 로그인 화면 추가
한 일
router/v1/site/blog_admin.py·services/blog_review_service.py·admin/frontend BlogReviewPage삭제. 생성분은 금칙 필터(is_publishable_body)만 통과하면 곧장REVIEWED로 쌓여 팀 개입 없이 발송 대상이 된다(blog_service.filter_drafts).blog_jobs.pyBATCH_SIZE·REFILL_BELOW25/40 → 30/30(한 달치).send_reviewed()가PostCRUD.due_for_mail(DISTINCT ON (place_id))을 써서 업장당 하루 한 통만 보낸다 — 전엔 전체 업장을 섞어 오래된 순으로 뽑아 밀린 업장이 하루에 두 통 이상 받을 수 있었다.- 메일 확인 화면에 수정해서 올리기 버튼 추가.
GET/POST /v1/site/post/edit신설 — 저장하면 금칙 필터를 다시 타고, 통과하면 본문 갱신 + 그대로 승인. router/v1/site/post.py에owner_router(/v1/place/{place_id}/post) 신설 — 로그인 세션으로 이번 달 생성된 글을 보고, 메일이 아직 안 나간REVIEWED글도 바로 수정·승인.solution/frontend/src/pages/BlogPostsPage.tsx+SitesPage카드의 "관리" 메뉴에 진입점 추가.
왜 2026-09-16 기획은 "팀이 먼저 거르고 사장님은 메일 클릭만" 이었는데, 다시 논의하면서 최종 판단을 사장님에게 넘기기로 했다 — 팀 검수 단계가 병목이고, 사장님이 자기 사이트 콘텐츠를 직접 못 보는 것도 이상했다.
하는 김에 잡은 버그
services/post_service.py 의 승인 처리가 BUILD 잡 payload 에 owner_user_id 를 안 채우고
있었다. build_service.run_build:141 은 payload["owner_user_id"] 를 무조건 읽으므로 —
이메일 승인 클릭이 실제로는 사이트를 재발행하지 못하고 있었을 가능성이 높다(잡은
큐에 들어가지만 워커가 돌릴 때 KeyError). place_id 로 owner_user_id 를 직접 조회해
채우도록 고쳤다. 회귀 테스트: test_blog_post.py test_approve_enqueues_build_with_owner_user_id.
결과 — solution/backend 전체 pytest 784 passed(기존에도 실패하던 search_console
스케줄러 잡 개수 검증 2건은 이번 변경과 무관 — blog-drafts·blog-mail 상시 잡이 늘어난
탓, 별도 수정 필요). tsc 통과(solution/frontend · admin/frontend). → MINI_BLOG.md
2026-09-16 — Teams 웹훅 수신자 고장 — 플로우 재생성으로 해결
원인: 플로우의 body/recipient 가 "48:notes"(Teams 예약값, 실제 채팅 아님)로 박혀 있어
PostCardToConversation 호출마다 BadRequest. 플로우 재생성(웹훅 템플릿) + 채널로 지정해서
해결, 실제 채널 게시 확인함. TEAMS_WEBHOOK_URL 갱신함(.env, 커밋 안 됨).
2026-09-16 — 크롤링 실패를 jobs.result 에 구조화해서 싣는다
common/collect_diagnostics.py(신규) + collect_service.py 채널별 실패 10곳 연결.
전엔 로그 한 줄로만 남아 원인 확인하려면 워커 로그를 grep 해야 했다 — 이제 잡 결과에도 남는다.
검증 — python3 ast 파싱, 수동 실행 확인.
2026-09-16 — Gemini 호출 실패가 온보딩 생성 잡을 죽이지 않게
한 일
services/copy_service.py— 소개문·FAQ 생성(generate단계)에서GeminiError가 나면 잡을 실패시키지 않고generate를 건너뛴 것으로 기록한 뒤 fact 만으로 저장까지 계속한다. 프론트 사유 라벨:generationLabels.tsSKIP_REASONS.generation_failed.common/database/db_session_manager.py— 유니크 제약 충돌(IntegrityError) 로그를 ERROR → WARN. 재수집 시 이미 등록된 링크를 다시 넣으려는 정상 경로라services/collect_service.py_add_link가 이미 "이미 있으면 그만" 으로 처리한다.
왜
API 키가 아예 없을 때는 이미 generate 를 건너뛰고 fact 만으로 계속하면서, 키는 있는데
호출이 실패할 때만 잡 전체를 DEAD 로 보내는 건 일관성이 없었다. 발행도 고유 콘텐츠
0건으로 막지 않고(publish_gate.check_unique_content — "얇은 콘텐츠로 발행을 막지 않기로
했다"), 다른 곁들이 콘텐츠(자작곡 등, build_service.py)도 실패하면 로그만 남기고 계속
진행한다 — 이 갈래만 예외였다.
실측(2026-09-15 밤, 킹서버): 사진분석(VISION) 배치가 Gemini 분당 쿼터를 다 써서, 같은 키를 쓰는 온보딩 COPY 잡의 생성 호출도 429 를 맞고 재시도(총 20초 안팎)를 소진해 DEAD 로 갔다. 화면엔 "콘텐츠 생성을 완료하지 못했습니다" 로 떴다 — fact 만으로도 편집·발행이 되는데 잡을 죽일 이유가 없었다.
유니크 제약 쪽은 별개로, 이 로그가 ERROR 레벨이라 킹서버 워커 로그를 보면 크롤링이 계속 오류나는 것처럼 보였다(실제로는 매 재수집마다 정상적으로 나는 로그).
남은 것 — Gemini 429 자체의 재시도 대기시간은 아직 안 늘렸다(호출 내 최대 8초 백오프 · 잡 재시도 5초/10초). 분당 쿼터가 다 찬 상황을 실제로 견디려면 더 길게 기다려야 하는데, 그만큼 워커 슬롯을 오래 묶어 두는 트레이드오프가 있어 다음 작업으로 미룬다.
2026-09-15 — 장애 알림(잡 dead-letter·발행 실패·큐 정체) + /readyz
- alert_outbox(마이그레이션 0016) + services/alert_service.py — 영구 저장 + 재시도(최대 5회, job_crud 와 같은 백오프) + dedupe_key 로 중복 스팸 억제 + 복구 알림. 전용 컨테이너 없이 기존 스케줄러(API 컨테이너, 1분·5분 스윕)와 워커 코드 안 후크로 돈다.
- 알리는 지점: 잡이 DEAD 로 떨어질 때(worker/runner.py), BUILD·ROLLBACK 이 게이트 반려가 아닌 렌더·인프라 실패로 끝날 때, 노래 등 부분 실패, 잡 큐 정체(dead-letter 누적·좀비 실행·PENDING 정체). 게이트 반려(사장님 쪽 문제)는 알리지 않는다.
- services/teams_webhook.py — Teams Workflows 수신 webhook 어댑터(일반화, search_console_alerts.py 와는 별도). TEAMS_WEBHOOK_URL 미설정이면 적재만 되고 전송은 안 나간다.
- detail 은 저장 전에 마스킹된다(쿼리스트링 키·Bearer 토큰·password=·이메일).
/readyz추가 —/healthz(프로세스 생존)와 달리 DB 에 실제로 SELECT 1 을 던져 본다. 서버·DB 가 통째로 죽으면 이 알림 체계도 자기 장애를 못 알리므로, 외부 uptime 모니터가 이 경로를 봐야 한다(docs/ALERTS.md — 실제 외부 연결은 이 세션에서 하지 않았다).- ★ 버그 하나 잡음: alert_crud.due_pending 이 파이썬에서 계산한 시각과 DB 의 next_attempt_at
을 비교했는데, 앱·DB 서버 시계가 몇 십 ms 만 어긋나도(실측: 로컬에서 재현) send_alert
직후 process_outbox 를 부르는 자리에서 방금 넣은 알림이 안 잡혔다.
func.now()(DB 쪽 시계)로 비교하도록 고쳤다. - 검증: tests/test_alert_service.py(신규 17건) · test_job_queue.py(dead-letter 알림 1건 추가, 16건) · test_build_publish.py(게이트 반려/업무 실패 구분 확인 추가, 15건) · test_healthz.py (readyz 1건 추가, 2건) 전부 통과.
- 운영 미적용: 실제 Teams webhook 생성·채널 지정, 외부 uptime 모니터 연결, 마이그레이션 0016 서버 적용 — 전부 사용자 승인 후 별도 진행.
2026-09-15 — 운영 번들의 자동 로그인 자격증명 제거 · refresh 토큰 무효화
docker-compose.ymlsolution-site(운영 진입점) 빌드에서VITE_AUTO_LOGIN_ID·PWbuild arg 를 없앴다 — 채워진 채로 배포하면 사장님이 여는 번들에 그대로 구워져 누구나 JS 에서 읽을 수 있었다.nginx/Dockerfile도 그 ARG 자체를 안 받는다.lib/autoSession.ts에import.meta.env.DEV가드를 더했다(둘째 안전판) — 운영 빌드는 이 분기가 죽은 코드로 접혀 번들에서 통째로 빠진다. 실측: 자격증명 값을 채운 채로 운영 빌드를 돌려도build/client어디에도 그 문자열이 없는 것을 확인했다.users.token_version(마이그레이션 0015) 추가 —refresh_token()이 지금까지 서명·만료만 보고 DB 를 한 번도 안 읽었다. 비밀번호를 바꿔도 이미 나간 refresh 토큰(7일)은 만료 전까지 계속 새 access 토큰을 찍어냈다. 이제 재발급마다 DB 의 token_version 을 대조하고, 비밀번호 변경이 그 값을 올린다(그 전 refresh 토큰은 다음 재발급부터 거절).- 검증:
tests/test_auth.py16건 통과(신규 3건 — 정상 재발급·비번 변경 후 거절·계정 차단 후 거절).tests/test_schema_ddl.py통과(ORM ↔ init.sql 일치). - 운영 미적용: 실제 서버
.env의AUTO_LOGIN_ID·PW값 확인·제거와 마이그레이션 적용은 이 세션에서 하지 않았다 — 서버 접속·DB 변경은 사용자 승인 후 별도로 진행한다.
2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기
- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다.
- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다.
- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다.
- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: PUBLISH_VERSION.md.
- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다.
- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다.
- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과.
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 위에 추가한다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
2026-09-15 — Google 사이트맵 자동 제출·색인 관측
- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림.
- 관측값·재시도·알림 시각은
site_search_status에 보관. 발행 잡/상태는 건드리지 않는다. - API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다.
- 설정/적용/관측 의미: SEARCH_CONSOLE.md. 운영 배포·권한 부여는 미실행.
검증 — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구
- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거.
- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지.
- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리.
- 구조·적용 순서: GENERATION_FLOW.md.
검증 — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과.
2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
무슨 일 — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
발행본 컴포넌트로 옮겼다. 그리기 규칙은 site/src/lib/postcard-canvas.ts 한 곳에 두고,
화면·입력·공유는 sections/items/PostcardMakerSection.tsx 가 맡는다. 사진이 있는 사이트면 나간다.
★ 저장·공유가 사진 출처에 걸린다 — 캔버스는 남의 도메인 사진을 그리면 오염돼서(tainted)
toBlob 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다.
CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(*.pstatic.net)에 있고
그쪽은 Access-Control-Allow-Origin 을 주지 않는다 — curl -I 로 확인했다.
→ 지금은 정직하게 막는다. CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고
저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다.
→ 근본 해결은 사진을 우리 오리진으로 옮기는 것이다. 시연본이 img/mirror/ 로 그렇게 하고 있고,
발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 out/s/<slug>/img/ 에 두고 payload 주소를 바꾼다)
저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. 아직 안 했다.
2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
무슨 일 — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 겹치지 않는 질문을 골라 문의 안내 답으로 채운다.
생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분)
→ 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀
→ "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED)
왜 — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건).
★ 공통 답에 값을 적지 않는다 — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면 업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 화면에만 나간다 — FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다.
바꾼 곳
common/faq_catalog/(신규): 카탈로그 로더 +resources/pension.json. fact_keys 가 업종 스키마에 없으면 로드 시 예외.services/faq_fill.py(신규): 고르기 규칙(순수 함수).copy_service._fill_faqs가 부른다.SourceType.TEMPLATE = 5(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(fact_service규칙 4).postgres-init/migrations/0012_place_faqs_template_source.sql+init.sql: 컬럼 변경은 없다(CHECK 없는 SMALLINT).generated_by·source_fact_ids에 코드값 뜻을COMMENT ON으로 남긴다. 0012 는 컬럼이 있을 때만 단다(DO $$ IF EXISTS). init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은COMMENT ON을 붙였다.faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다.- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가 (노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다).
- ★ fact 0건이어도 20개:
start_copy는 카탈로그가 있으면 잡을 만들고(FAQ_UNGROUNDED는 카탈로그 없는 업종만),run_copy는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(notifyCopy)도faq_fill을 본다. - 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다.
- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시.
남은 것 — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는 fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 DECISIONS 8절.
검증 — 백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건, 기존 2건은 fact 0건 경로에 맞게 고침).
실패 2건(test_place_search::test_rate_limit_closes_the_tap · test_site_thumbnail 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다.
site·frontend·admin tsc --noEmit 통과 · site vitest 63 passed.
로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0.
0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과.
2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다
무슨 일 — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 거른 결과를 <meta name="keywords"> 와 제목 업종어 자리에 싣는다.
스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지)
→ POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id)
→ 거르기 → snapshot["seo"] → payload.seo
→ <title>스테이,머뭄 · 군산 독채펜션</title> · <meta name="keywords" content="군산 펜션 독채, …">
★ 거르기가 필요한 이유 (실측) — 스테이머뭄 프로필로 받은 추천 10건 중 군산 독채 마당 펜션·
군산 독채 복층 펜션·군산 커플 프라이빗 펜션 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과
일부 시설만 보기 때문이다. 사전에는 선유도 독채펜션(다른 권역)·군산 펜션 최저가(가격 주장)도 있다.
→ 키워드의 모든 낱말이 이 가게 자료에 있어야 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다.
제목에는 예약·추천 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 services/seo_keywords.py.
★ SiteOntology 쪽 함정 (실측)
- region 표에 없는
regionId를 보내면 500(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다 → 500 이면 지역 없이 한 번 더 보낸다. - 해석되지 않은
query에도 201 로 입력 문자열 검색 결과를 준다(나운동 숙소…) →resolved가 우리 place_id 가 아니면 버린다.
경계 — SiteOntology 는 수정하지 않았다. 설정(SITE_ONTOLOGY_URL)이 비면 호출하지 않고, 실패하면
키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 site_versions.snapshot 이 곧 발행 기록이다.
남은 것 — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만, 운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다.
2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
무슨 일 — /s/stay 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
흐름 — ★ 발행이 노래를 기다린다.
발행 누름 → BUILD 잡
1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분)
3. mp3 를 out/songs/ 에 보관
4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다
프리렌더가 mp3 를 사이트 디렉토리로 복사
왜 기다리나 — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의
사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 첫 화면에
그 기능이 빠져 있다. 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다.
★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면
노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.
★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다.
왜 가사를 우리가 쓰나 — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다.
그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 —
사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다.
→ 가사는 소개문과 같은 재료(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고,
Suno 는 곡만 붙인다. 프롬프트가 없는 시설·숫자를 말하지 말라고 못 박는다
(요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다).
★ 가사에는 ground_check 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는
없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다.
★ Suno 주소를 그대로 싣지 않는다
Suno 가 주는 audio_url 은 만료된다. payload 에 그 주소를 실으면 발행 직후에는 재생되고
몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고
우리 경로(/s/<slug>/<song_id>.mp3)만 발행본에 내보낸다.
★ 콜백이 아니라 폴링이다
Suno 는 callBackUrl 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다.
이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면
"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다.
(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.)
경계는 그대로다 — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로
out/songs/<song_id>.mp3 에 떨구고, 굽는 쪽인 프리렌더가 out/s/<slug>/ 로 복사한다.
프리렌더는 복사하면서 지난 발행의 mp3 를 치운다 — 발행마다 새 곡이라 안 치우면 1MB 짜리가
발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다.
화면 — 헤더의 작은 플레이어(SongPlayer). 곡이 없으면 아무것도 그리지 않는다 —
노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다.
자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다
(오디오 안의 말은 크롤러가 못 듣는다).
표 — place_songs. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다).
상태는 GENERATING·READY·FAILED 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면
직전 곡이 그대로 남는다. → DATA_MODEL.md
검증 — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic
ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → 그 다음 스냅샷(노래 1) → 발행 완료.
/s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·재생 주소 확인.
지난 발행의 곡은 404 로 치워졌다. tsc --noEmit · eslint · vitest 55건 통과(신규 4건).
2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
무슨 일 — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 [copy] 소개문 O,
DB 에도 문장이 있는데 status=1(UNVERIFIED) 이라 스냅샷이 담지 않았다. 그 자리는 fact 로 조립한
한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있어서, 화면만 보면 생성이 실패한
것처럼 보이지도 않았다.
왜 승인이 안 됐나 — 승인할 화면이 없었다.
07:29:04 수집 완료 → 여기서 사장님이 [맞아요] 를 눌러 fact 가 VERIFIED 가 된다
07:31:11 ★ 소개문 도착 — 2분 늦게. 확인 화면은 이미 지나갔다
한 일
fact_service.upsert_fact: LLM 출처는 후보가 아니라 노출값으로 앉힌다. 자동 출처(API·CRAWL)는 그대로 후보다. 게이트는 앞에 있다 — 입력이 확인된 fact 뿐이라 이미 승인된 사실로 쓴 문장이다.copy_service: 생성 FAQ 를VERIFIED로 저장한다. 근거 없는 FAQ 는 여전히 저장하지 않는다.- 잠금은 명시적으로 다시 걸었다. 사장님이 고친 문장(
CORRECTED)은 LLM 이 못 덮는다. 지금까지 이 보호는 "자동 출처는 노출값 경로로 못 간다" 는 경로가 대신 해 주고 있었다 — LLM 만 경로를 바꾸면 그 보호가 조용히 사라진다(절대규칙 6). faq_crud.expire_generated: 재생성 대상을 status 가 아니라generated_by로 가른다. 생성분이 VERIFIED 로 들어가면 status 로는 사람이 손댔는지 알 수 없다. 그대로 뒀다면 재생성이 옛 FAQ 를 못 내려 같은 질문이 쌓였을 것이다.- 결론과 근거는 DECISIONS.md 7절. 6-2 의 "FAQ 에는 넓히지 않는다" 도 함께 고쳤다.
곁다리로 잡은 것 — 테스트가 통째로 막혀 있던 진짜 이유
ORM 의 TIMESTAMPTZ 기본값이 (now() AT TIME ZONE 'utc') 였다. timestamptz 에 이걸 쓰면 값이
시간대 없는 벽시계로 떨어졌다가 세션 시간대로 다시 해석돼 서버 시간대만큼 미래로 밀린다.
실측: 잡의 run_after 가 7시간 뒤로 박혀 claim(run_after <= now())에 영영 안 걸렸고,
COPY 관련 테스트가 "잡이 PENDING 인 채" 무더기로 실패했다. 원인이 코드가 아니라 스키마라
읽히지 않는 종류다. 운영은 멀쩡했다 — 운영 DB 는 init.sql(DEFAULT now())로 만들어지고
이 기본값은 ORM 이 스키마를 만들 때만, 즉 테스트 DB 에서만 쓰인다.
→ init.sql 과 같은 now() 로 맞췄다. 스키마는 init.sql 이 단일 출처다.
검증 — fact·copy·faq 35건 통과(신규 2건: LLM 문장이 승인 없이 노출값이 되는지 · CORRECTED 를 못 덮는지).
2026-09-10 — 옛 항구 템플릿을 /s/stay 시안에 맞춘다 (렌더러 이식)
무슨 일 — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라
stay-mockup 워크트리의 커밋되지 않은 작업본이었다(19파일, +601/−275). 거기서만 살아 있던
변경이 이 브랜치로 넘어오지 않아, 같은 payload 를 같은 템플릿으로 구워도 화면이 갈렸다.
대조 방법 — 시안 HTML 에 박힌 window.__SITE_PAYLOAD__ 를 떼어 현재 렌더러로 다시 구워
마크업을 태그 단위로 diff 했다. 페이로드가 같으니 남는 차이는 전부 렌더러 차이다.
착수 시 실질 diff 129줄 → 이식 뒤 4줄.
옮긴 것
lib/ui/Carousel.tsx+use-rail-autoplay.ts(신규): 자동 넘김을 훅 한 벌로. 한 번 훑고 멈춘다 — 되감기(loop)를 빼야 embla 가 슬라이드를 개별 transform 으로 옮기지 않아 이음매 간격이 안 붙는다FestivalSection: 격자 → 계절별 캐러셀 4개(봄·여름·가을·겨울)ItinerarySection+items/common.tsx: 코스마다 레일을 쌓던 것을 탭 하나 = 레일 하나로. 실측 payload 에서 캐러셀 20개 → 2개(1박2일·2박3일)lib/format.ts: 지도 주소에서 쉼표를 뺀다. 카카오link/to/{이름},{위도},{경도}는 쉼표로 칸을 가르는데 상호가 "스테이,머뭄" 이면 위도 자리에서 "머뭄" 을 읽고 목적지를 통째로 버린다 — 길찾기가 현위치만 뜨던 원인seo/verify.ts: JSON-LD 이미지가 절대 URL, HTML 은 루트 절대경로(/assets/…)라 경로로도 대조한다. 이게 없어서 사진을 미러한 사이트는 발행 게이트가 통째로 막혔다(시안 payload 재굽기가 9건으로 실패)- 그 밖에
GallerySection(간격) ·VideoSection·UnitsTabs·UnitsBands·LocalGuideSection(레일 간격) ·WeatherSection+WeatherBand/tempNotes(기온대별 한 줄) ·SiteHeader(safe-t) ·seo/jsonld·head
이 브랜치 것을 지킨 자리 — 충돌 6곳은 손으로 갈랐다.
ItinerarySection: 사장님 일정이 없으면 서버 조립분(local.itineraries)을 쓰는 폴백을 유지seo/verify.ts: 이 브랜치의unescaped대조와 시안의 경로 대조를 둘 다 본다- 예약 버튼 문구는 시안(
{채널}로 예약)이 아니라 이 브랜치의bookingActionLabel을 남겼다 — 네이버 예약 채널에서 "네이버 예약로 예약" 이 되는 것을 막는 쪽이 맞다. 남은 diff 4줄이 이것이다
템플릿 쪽 — TemplateItem 에 defaultVariants 를 더하고 옛 항구에 photos: 'photos.carousel' 을 건다.
시안의 사진 갤러리가 캐러셀인데 템플릿이 배리에이션을 지정할 자리가 없어 늘 기본으로 나갔다.
disabledSectionTypes(끄고 시작할 섹션) 기구도 함께 두되 옛 항구에는 쓰지 않는다 — 예약 안내는 나간다.
검증 — tsc(shared·site·frontend·admin) · eslint 통과. 시안 payload 를 현재 렌더러로 프리렌더 →
검증 게이트 통과, 캐러셀 11개가 시안과 같은 구성·순서. site vitest 는 7 failed / 44 passed 로
착수 전과 같다(stay-booking 7건은 이 작업 이전부터 실패).
⚠️ /s/stay 는 건드리지 않았다. 다만 solution/site/payloads/stay.json 이 남아 있는 한
프리렌더 컨테이너가 기동할 때마다 목업이 그 payload 로 덮인다(watch-payloads.mjs 의 기동 전체 재굽기).
목업은 payload 가 없어야 안전하다 — stay2·stay3 가 무사한 이유가 그것이다.
2026-09-10 — 일력(오늘의 한 장)을 서버 생성에 붙인다 · 종류가 늘어도 기존 지역이 따라온다
무슨 일 — '옛 항구' 템플릿을 골라도 /s/stay 시안처럼 안 되는 자리를 따라갔더니 하나가
코드 문제였다. 일력만 서버가 만들지 않는다. 렌더러에는 '오늘의 한 장' 탭이 있고
(StorySection 다섯 탭 중 둘째) 템플릿 설명도 "도넛판·일력·승차권"이라고 약속하는데,
프롬프트가 빌더(canvas/dataSpec.ts)에만 손으로 적혀 있어 shared/section-prompts.ts 에
없었다 — 서버는 그 종류가 있는 줄도 몰랐다. 시안에 일력이 있는 건 그때 손으로 넣었기 때문이다.
같이 나온 두 번째 함정 — 목록이 두 벌이었다. export-prompts.mjs 가 종류 배열을
손으로 한 벌 더 들고 있어서, STORY_KINDS 에 하나를 늘려도 뽑히지 않는다.
프론트는 아는데 서버만 모르는 상태가 되고, 그 종류의 탭은 조용히 빈칸으로 남는다.
세 번째 — 가드가 정확히 반대로 돈다 — has_stories() 는 "한 건이라도 있으면 다시 안 부른다"
였다. "같은 지역 두 번째 숙소"만 생각한 가드라, 종류가 늘어난 날 이미 다섯이 든 지역
(52군산시)은 여섯 번째를 영영 못 받는다. 새 지역만 여섯이 되고 기존 지역은 다섯에 멈춰,
같은 템플릿을 골라도 지역에 따라 탭 수가 다른 상태가 된다.
shared/section-prompts.ts:daily스펙 추가(maxItems 30) ·STORY_KINDS를 발행본 탭 순서로shared/scripts/export-prompts.mjs: 종류 목록을 손으로 적지 않고STORY_KINDS에서 읽는다frontend/canvas/dataSpec.ts: 일력의 task·rules 를 shared 참조로 — 다섯과 같은 모양이 됐다backend/story_service.py:has_stories→missing_kinds— 없는 종류만 부른다. 요금 가드는 그대로다(있는 종류는 여전히 한 번도 다시 안 부른다). 읽기 실패는 "없다"로 치지 않는다 — 모르는 상태로 유료 호출을 걸지 않는다backend/enums.py·grounding/story.py·init-data/init.sql: 여섯으로 맞춤
검증 — tsc --noEmit(shared·frontend·site) · eslint 통과. 프롬프트 계약 테스트 2건 추가.
실제 payload(stttt)의 local.story.daily 에 두 건을 넣고 구워, '오늘의 한 장' 탭이
다섯 번째로 서는 것까지 확인했다.
⚠️ pytest 전체는 이 브랜치 이전부터 로컬 Postgres 인증 실패로 막혀 있다 — 새 테스트는 DB 를
안 쓰지만 세션 픽스처가 먼저 걸린다. 개별 함수를 직접 호출해 통과를 확인했다.
아직 남은 것(코드가 아니라 데이터) — /s/stay-mumum-gunsan 이 시안과 다른 나머지는
소개·객실·FAQ·영상·소식과 fact 8건이 비어서다. 사장님이 채우거나 수집이 가져와야 한다.
2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈)
무슨 일 — 이 다섯은 생성기가 없어 사람이 손으로 넣지 않으면 영영 빈칸이었다.
/s/stay 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 이제 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다.
- 키는 지역이다.
area_contents(region_code × kind) 에 종류당 한 행,body.items에 항목들. 사이트별sections[].data로 복사하지 않는다 — 화면이 읽는 순간에만 사장님이 붙여넣은 것과 한 배열로 잇는다(site/src/lib/derive.tssectionItems). - Perplexity 종류당 1회. 출처(
search_results)가 함께 오는 유일한 통로다. 항목에 출처가 없으면 버리고, 검색 출처로 때운 항목은확인이라 우겨도확인필요로 내린다. - 프롬프트는 한 벌. 사장님이 [콘텐츠] 탭에서 복사해 가던 그 문장을 그대로 쓴다 —
shared/lib/section-prompts.ts가 단일 출처,npm run export:prompts로 백엔드용 JSON 을 뽑는다. - 트리거는 cache-aside. 에디터 캔버스가 주변 정보를 처음 부를 때 지역 이야기 생성 잡
(
JobType.LOCAL_SYNC, 선언만 있고 미배선이던 것)을 하나 넣는다.dedupe_key = story:{region_code}라 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다. - 검수 게이트는 두지 않는다 — 결론과 근거는 DECISIONS.md 6절.
검증 — tsc -b 통과 · 지역 이야기 단위 테스트 12건 통과.
⚠️ 이 레포의 pytest 전체는 이 브랜치 이전부터 로컬 Postgres 인증 실패로 569건 전부 error 다
(password authentication failed for user "postgres"). 새 테스트는 DB 를 안 쓰는데 세션 픽스처가
DB 를 먼저 세워서 함께 막힌다 — 환경 문제이고 별건이다.
2026-09-09 — 예약 안내 안에 날짜·시간 목업을 넣는다 (연동 없음)
무슨 일 — 예약 흐름을 화면으로 보기 위해 StayBookingDemo 를 예약 안내 섹션 안에 넣었다.
날짜(2주) · 도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고, 거기서 전화로 잇는다.
어디에도 연동하지 않는다 — 재고 조회도 접수도 결제도 없다(PRODUCT.md 6절은 그대로다).
목업이라도 지킨 선
- "마감/잔여" 를 만들지 않는다. 우리는 그 값을 모른다. 그럴듯하게 지어내면 목업이 아니라 거짓말이고, 손님은 그 표시를 보고 다른 날을 고른다
- 시간 후보를 임의로 늘어놓지 않는다. 체크인 fact(16:00)에서 시작해 5칸을 만든다 — fact 가 없으면 시간 선택을 아예 내지 않는다. 확인된 값과 어긋나는 선택지는 만들지 않는다
- 요금은 요금표·JSON-LD 와 같은 출처(
unitBaseRate)를 쓴다. 데모라고 다른 숫자를 보이면 같은 페이지가 두 값을 말하게 된다 - 확인 화면은 "접수됐다" 고 쓰지 않는다 — 어디에도 보내지 않으므로 사실이 아니다. 반대로 "접수되지 않았다" 는 경고도 두지 않는다(2026-09-09 결정: 흐름을 보는 화면이라 경고문이 흐름을 가린다). 선택 내용 확인까지만 말하고 전화로 잇는다
★ 날짜는 브라우저에서 만든다 (mounted 게이트) 프리렌더가 서버에서 날짜를 구우면 발행 시각의 날짜가 정적 HTML 에 박힌다. 한 달 뒤 크롤러가 그 페이지를 읽으면 지난 날짜가 예약 가능일로 적혀 있다 — 화면은 멀쩡한데 기계가 읽는 값만 틀리는, 이 레포가 가장 자주 밟은 종류다. 그래서 서버 렌더에서는 달력을 그리지 않고 안내 한 줄만 내보내고, 달력은 하이드레이션 후에 그린다. 자바스크립트가 꺼진 크롤러가 보는 것은 "실제 예약 가능 여부와 결제는 아래 예약 창구에서" 뿐이다.
구조화 데이터는 건드리지 않았다. 데모는 JSON-LD 에도 llms.txt 에도 나가지 않는다 —
makesOffer.availability 는 여전히 없고(빈 방을 모른다), llms.txt 는 "이 홈페이지는 빈 방
재고와 결제를 처리하지 않습니다" 를 그대로 말한다. 목업을 AI 에게 예약 창구로 소개하면
그때부터는 목업이 아니다.
연동을 붙일 자리 — ConfirmPanel 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다.
빌더 캔버스도 같이 맞췄다 — 사장님 편집 화면은 여전히 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약" 을 그리고 있었다. 우리는 실시간 예약을 하지 않는데다, 에디터에서 본 것과 발행된 사이트가 서로 다른 물건이었다.
booking/BookingCard: 발행본 구성(날짜 칩 · 도착 시간 · 인원 · 예약 요청 · 전화 창구)의 미리보기로 갈아엎었다. 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 상태를 두지 않고 첫 칸이 골라진 모습으로 고정한다. 시간 칸은 발행본과 같은 규칙으로 체크인 fact 가 있을 때만 그린다booking/BookingBanner"실시간 캘린더" → "날짜와 시간을 고르고 예약 창구로 이어집니다",rooms/RoomCard"실시간 예약 신청" → "예약 안내 보기",hero/HeroEditorial"실시간 예약" → "예약 안내"LinkChannel.NAVER_BOOKING을 orval 생성물에 반영. ★npm run orval을 그대로 돌리면 141파일 6,400줄이 바뀐다 — 전부 따옴표·줄바꿈 포매팅 드리프트고 스펙 변경은 enum 한 줄뿐이다. 그래서 생성물을 되돌리고 그 한 줄만 남겼다(실측 2026-09-09)
검증 — tsc·eslint 통과, vitest 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 ·
JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후
/s/<slug> 에서 데모 껍데기와 안내 문구 확인.
2026-09-08 — 가짜 발행을 없앴다 — 굽지도 않고 [사이트 열기] 를 그렸다
무슨 일 발행 모달에서 [발행하기] 를 누르면 "발행 준비가 끝났습니다" 토스트가 뜨고 [사이트 열기] 버튼이 생겼다. 서버를 한 번도 안 불렀고, 그 주소는 404 다. 목록에도 안 생긴다. 사장님은 발행됐다고 믿는다.
왜
PublishModal.handlePublish 가 publisher.isLive(= placeId + 토큰)가 거짓이면 서버 호출을
건너뛰고 setPublishedUrl(url) 로 스토어에 주소를 박았다. 그러면 isDone 이 참이 되어 완료
화면이 그려진다. 데모 경로를 위해 둔 분기인데 로그인한 사장님도 이 길로 온다 — 3단계의
[수집 없이 다음 단계로](직접 입력)로 나가면 서버에 사업장이 없는 채 에디터까지 가고,
거기서 로그인해도 placeId 는 여전히 없다.
고친 것
- 가짜 분기 삭제.
isDone은state.phase === 'published'하나로 줄였다 — 굽지 않은 주소에 [사이트 열기] 가 붙던 자리가 여기다 - 발행 불가 사유를
PublishBlocker(signin·place)로 갈라 모달 안에서 말한다. blocker 가 있으면 주소칸·점검·발행 버튼을 아예 그리지 않는다 - 비로그인:
/login으로 튕기지 않고 모달 안에 로그인 폼을 둔다 — 빌더 스토어는 비영속이라 튕기면 만들던 게 날아간다(EditorSignInGate와 같은 이유) - 로그인 O + 사업장 X: 이유를 말하고 [내 가게 확인하러 가기] →
/builder?step=search. 여기서 사업장을 몰래 만들지 않는다 — 생성·검증 순서는ensureServerPlace한 곳이 소유한다 - 3단계 버튼을 [발행 없이 화면만 둘러보기] 로 바꾸고 "이 길로 가면 발행이 안 된다" 를 붙였다. 버튼은 남긴다 — 검증을 못 통과한 사람이 화면을 구경할 길까지 막을 이유는 없다
검증 — 프론트 tsc+eslint 통과. 백엔드가 같은 상황을 어떻게 거절하는지도 확인했다:
검증 안 된 사업장으로 발행하면 PLACE_NOT_VERIFIED 다. 서버는 이렇게 분명히 막는데
프론트만 서버를 안 부르고 성공을 말하고 있었다.
⚠️ 이 변경의 코드는 f2dad65 에 섞여 들어갔다 — 같은 레포를 동시에 작업하던 다른 세션이
커밋할 때 스테이지에 올려 둔 PublishModal.tsx·Step3DataReview.tsx 를 같이 담았다.
그 커밋 제목은 발행본 목록 주소 얘기라 이 변경을 가리키지 않는다. 기록은 여기에 남긴다.
2026-09-08 — 발행본 목록의 정본 주소를 /s 로 — /s 가 앱 셸을 200 으로 주고 있었다
무슨 일
사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
(/s/<slug>)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(/)와 목록 페이지(/s/) 둘뿐이다.
목록만 형태가 다른 이유는 nginx 였다 — location ^~ /s/ 는 슬래시로 시작하는 것만 잡고,
/s 는 맨 아래 location / 로 떨어진다.
그런데 그게 404 가 아니었다. /s 는 200 을 주고 있었고 내용이 빌더 SPA 셸이다
(실측: /s 3.1KB <title>Web4Ai</title> · /s/ 6.7KB 목록). 크롤러 입장에서는 404 도
목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
바꾼 것
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 로 내려가는 301 이 나간다prerender.tsindexUrl:+ '/'제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를 쓰므로 전부 같이 따라온다
왜 형태를 맞추나
색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준
태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번
밟은 함정이고(prerender.ts 주석), 목록만 반대 형태로 남아 있었다.
남은 것
/s/<slug>/ 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서
크롤러가 스스로 만들어낼 주소는 아니다.
2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
무슨 일
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at·
published_at 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게
어느 건지 가릴 단서가 화면에 하나도 없다.
리서치 (Wix · 아임웹)
- Wix
My Sites줄에 보이는 건 이름·URL·Premium·협업자뿐이고 썸네일도 수정일도 없다. 대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은displayName · thumbnail · viewUrl · editUrl이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다. - 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전), 리셀러 목록은 만료일을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라 사이트 안 대시보드에 있다.
- 공통: 목록은 구분 · 상태 · 여는 길 셋만 한다. 그리고 둘 다 생성일을 안 쓴다 — 구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다.
바꾼 것
MySiteData.thumbnail_url추가(site_service._my_site_row). 목록이 사이트 행을 이미 조인해 읽고 있어서 쿼리는 그대로다- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 — 블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다
- 줄 아래 한 칸:
도로명 주소 · 시각. 시각은 발행됐으면 발행일, 아니면 만든 날 하나만 쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다
썸네일이 발행마다 바뀌게 (site_thumbnail.public_url)
블롭 이름은 thumbs/<slug>.<ext> 로 고정이고 내용만 overwrite=True 로 덮어쓴다. 그래서
주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 캐시에 남은 지난 그림이 계속 보였다
(CACHE_CONTROL 60초만으로는 그 60초를 못 막는다). 주소에 ?v=<발행 버전> 을 붙인다.
→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다.
→ scripts/backfill_thumbnails.py 처럼 그 시점 버전이 없는 경로는 version=None 으로
그냥 붙이지 않는다.
아직 그림이 한 장도 없다 — 로컬·현재 DB 의 사이트 39개 전부 thumbnail_url 이 NULL 이다.
버그가 아니라 AZURE_STORAGE_CONNECTION_STRING 이 비어 site_thumbnail.is_configured() 가
False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다.
검증 — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에
thumbnail_url 키가 아예 없는지, 재발행하면 ?v=1 → ?v=2 로 주소가 바뀌는지 4건 추가.
프론트 tsc + eslint 통과.
2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다
무슨 일 사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고, 에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데.
왜 그랬나 보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다.
바꾼 것
- 스코프 키가
company_id→places.owner_user_id다.UserInfo에서company_id를 뺐고 (JWT 클레임도 같이 사라진다),place_crud·site_crud의 WHERE 가 전부 주인으로 바뀌었다 - 주인은 토큰이 정한다.
Req_CreatePlace.owner_user_id를 없앴다 — body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고, 스코프는 회사가 대신 하고 있었다 - 잡 페이로드 키
company_id→owner_user_id. 워커가 세우는UserInfo.user_id는 이제 사업장 주인이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다 company.companies테이블 ·users.company_id·Res_Me.company· 가입 폼의 상호 칸 삭제- 테스트:
company_id/other_company_id픽스처 →owner_id하나. 격리 테스트는auth_headers("o2")를 한 번 더 부르면 그게 남이다
마이그레이션 (init.sql 끝, 재실행 안전)
백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 가장 먼저 만든 계정에게
몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다.
실측(로컬): 92건 → 91건(고아 1건 삭제), demoebf050 56 · test 35.
남긴 것 — DB 스키마 이름 company 는 그대로다. rename 은 모든 모델의 __table_args__ 를
건드려야 해서 이번 변경에 섞지 않았다.
2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다
무슨 일
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/…(검색 결과 주소)인 사장님은
예약하려고 눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.
근거 — 주소를 지어내지 않아도 된다
플레이스 모바일 응답(__APOLLO_STATE__)의 ROOT_QUERY.placeDetail(...).naverBooking 에
네이버가 예약 주소를 직접 준다(실측 2026-09-08, place 1273971279):
naverBookingUrl : "https://m.booking.naver.com/booking/6/bizes/1067685"
tabs : [home, feed, menu, booking(예약), review, …]
★ bookingBusinessId(1067685)와 businessTypeId(6)로 주소를 조립하지 않는다. 조립하면
예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌러서 빈 화면을 본 손님은 그 가게가 예약을
안 받는 줄로 읽는다. 응답이 naverBookingUrl 을 줄 때만 준 그대로 쓴다(미사용 업소는 null).
바꾼 것
LinkChannel.NAVER_BOOKING = 7(백엔드 enum · shared enum · init.sql 주석). 플레이스와 가른 이유는 성격이 다르기 때문이다 — 이건 예약 화면 그 자체다collector/base.py:RawSource.booking_url— 채널이 스스로 알려준 예약 주소를 싣는 자리naver_place_adapter._booking_url(): 위 노드에서 읽는다. 키에 질의 인자가 통째로 박혀 있어 (placeDetail({"input":…})) 이름으로 못 찾으므로 접두사로 찾는다collect_service._store_booking_link(): 예약 채널 링크로 등록하고 자동 확정한다. 근거는discover_naver_place와 같다 — 이미 확정된 플레이스가 자기 예약 주소로 내놓은 값이라 남의 가게가 섞일 경로가 없다. 여기서 클릭을 한 번 더 받으면 그 사이 예약 버튼은 계속 검색 화면으로 간다site/seo/jsonld.tsBOOKING_CHANNELS: 순서가 우선순위가 됐다(네이버 예약 → 야놀자 → 여기어때 → 플레이스).bookingChannelUrl이 이 순서로 고르므로 화면 버튼과makesOffer.url·potentialAction이 같은 곳을 가리킨다site/lib/derive.ts: 예약 버튼을 같은 순서로 정렬하고, 검색 결과 주소는 뺀다 — 예약하러 온 사람에게 검색 화면을 주는 건 링크가 없는 것보다 나쁘다. 링크가 하나도 없으면 "온라인 예약 채널은 등록되지 않았습니다" 로 전화만 남는다는 것을 말해 준다bookingCtaLabel():${채널}에서 예약을 일괄로 쓰면 "네이버 예약에서 예약" 이 된다. 그리고 이 채널만 누르는 즉시 예약 화면이므로 버튼이 그 차이를 말해야 한다 — "네이버 예약으로 바로 예약하기"- 빌더도 이 채널을 안다(
useCollectFlow라벨,ChannelUrlInput의 호스트 판정)
검증 — 실제 네이버 응답으로 어댑터 확인: RawSource.booking_url = https://m.booking.naver.com/booking/6/bizes/1067685 · 예약 노드가 없는 응답에서는 None.
tsc·eslint 통과, vitest 47 passed(신규 4건: 채널 우선순위 · 버튼 문구 · JSON-LD 대상 ·
검색 URL 배제).
2026-09-07 — .env.example 그대로 쓰면 로컬 발행이 안 됐다 — 함정 둘
클론 직후 문서대로 cp .env.example .env 하고 docker compose up -d 한 다음 발행을 걸어 봤다.
게이트는 통과하는데 발행만 실패한다. 두 가지가 겹쳐 있었다.
1) DB_HOST=127.0.0.1 — 컨테이너 안의 127.0.0.1 은 그 컨테이너다. compose 기본값은
host.docker.internal 인데 .env 가 그걸 덮어쓴다. 증상이 고약하다: API 는 /healthz 가
DB 를 안 보므로 200 healthy 로 뜨고, 워커만 조용히 재시작을 반복한다 — 화면은 멀쩡하고
발행 잡만 영원히 안 돈다.
2) 줄 끝 주석이 값이 된다. compose 의 env_file 은 KEY= # 설명 을 "빈 값"으로 읽지
않는다 — 값이 "# 설명" 이다. 그래서 Azure 를 끈 로컬에서 is_configured() 가 참이 되고
발행 잡이 업로드를 시도해 Connection string is either blank or malformed 로 죽었다.
같은 모양이 5개였다: COLLECT_USE_PERPLEXITY(값 0 이 "0 # ..." 가 된다) ·
KAKAO_REST_API_KEY · TOUR_API_KEY · INDEXNOW_KEY · AZURE_STORAGE_CONNECTION_STRING.
3) 앱이 스스로 크로스 오리진을 만든다. nginx/site.conf 는 /v1 을 같은 오리진으로
프록시하고 주석에도 "앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다" 고 적혀
있는데, compose 의 빌드 인자 기본값이 VITE_API_BASE_URL=http://localhost:9800 이었다.
:80 으로 앱을 열면 번들이 :9800 을 부르므로 크로스 오리진이 되고, CLIENT_URL 기본값
(3000~3005)에 http://localhost 가 없어 로그인만 계속 실패한다. 증상이 사람을 속인다 —
서버는 200 에 토큰까지 내려보내고, 브라우저가 allow-origin 이 없어 그 응답을 버리므로
화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다.
고친 것
.env.example:DB_HOST기본값을host.docker.internal로. 값 뒤 주석은 전부 윗줄로 올리고, 파일 머리에 "값 뒤에 주석을 붙이지 않는다" 를 근거와 함께 박았다.env.example·docker-compose.yml: 앱이 부르는 API 주소 기본값을 앱과 같은 오리진 (http://localhost)으로. CORS 를 허용해서 뚫는 게 아니라 크로스 오리진을 만들지 않는다 — nginx 가 이미 같은 오리진으로 프록시하고 있었다.PUBLIC_API_BASE_URL을 주석이 아니라 값으로 내놨다(주석으로 두면 compose 기본값이 이기고, 그 기본값이 문제였다)
검증 — 새 DB(web4ai_db)에 init.sql 적용 → docker compose down -v 후 up -d --build →
번들에 localhost:9800 참조 0건 · POST http://localhost/v1/auth/login 200(프리플라이트 없음) ·
프리렌더가 기동하며 payload 2건 재굽기 → / /s/ /s/<slug> 전부 200. 그리고 →
scripts/demo_build.py 로 발행: 게이트 통과 · published: true · 프리렌더가 굽고
http://localhost/s/<slug> 200. ★ 참고로 demo_build.py 는 자기 안에서 워커를 한 번 돌리는데,
compose 워커가 잡을 먼저 집어가므로 스크립트 출력은 "게이트 거부"로 보인다 — 실제 결과는
job.jobs.result 와 워커 로그에 있다.
2026-09-07 — 숙박 예약 구성 — "실시간 예약" 섹션이 전화번호 한 줄이었다
왜
숙박으로 발행하면 서버 기본표(site_payload._DEFAULT_THEME)가 booking 섹션을 켠다. 그런데
발행본의 BookingSection 이 읽는 fact 는 reservation_required·reservation_channel 두 개이고,
둘 다 숙박 스키마(lodging.json)에 없다. 그래서 펜션·민박 페이지의 "실시간 예약" 섹션에는
전화번호 한 줄만 남았다 — 요금도, 인원도, 취소 규정도, 예약 창구도 없었다. 숙박은 예약이 곧
매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분인데, 그 답의 근거가
페이지에 없으면 AI 는 OTA 후기에서 추측한다.
★ 예약을 처리하게 만든 게 아니다. 빈 방 재고도 결제도 갖지 않는다(PRODUCT.md 6절 — "사이트는 예약 채널로 보낸다"). 날짜 선택기·예약 폼을 그리지 않았다 — 없는 기능을 화면으로 흉내내면 손님은 예약한 줄 알고 안 오고, 그 전화는 사장님이 받는다. 대신 예약에 필요한 사실 + 실제로 예약이 되는 창구를 한자리에 모았고, "여기서 결제되지 않는다"를 화면 맨 앞과 llms.txt 에 명시했다.
바꾼 것
site/src/sections/StayBookingSection.tsx(신규) — 객실별 요금·인원 / 예약 창구(전화 + 확정 채널) / 예약 전 확인(체크인·체크아웃·취소환불·추가인원·프런트 시간·취사·반려동물·흡연). 근거가 하나도 없으면 섹션째 안 나간다site/src/lib/derive.ts—stayBookingView()가 그릴지 말지까지 판단한다. 상단 내비·하단 탭이 같은 함수를 본다 — 세 곳이 각자 판단하면 눌러도 아무 일 없는 "예약" 탭이 생긴다. 예약 창구로 나가는 채널은 문의 목록에서 뺀다(네이버 플레이스가 두 번 보였다)site/src/seo/jsonld.ts—unitBaseRate()를 요금 숫자의 단일 출처로 만들고 화면과makesOffer.price가 같이 쓴다(각자 계산하면 절대규칙 3 위반으로 발행이 멈춘다).makesOffer(객실별 1박 요금) ·potentialAction: ReserveAction(확정 채널만) 추가.availability는 넣지 않았다 — 빈 방을 모르는데 InStock 을 주장하면 그게 거짓이다site/src/seo/llms.ts— 숙박## 예약블록. LLM 은 위에서부터 읽는다. 예약 경로가 "공식 채널" 절 맨 아래에만 있으면 답에 안 실린다frontend/src/data/industryData.ts·backend/services/site_payload.py— 숙박 기본 섹션 이름을 "실시간 예약" → "예약 안내". 실시간 예약을 하지 않는데 제목이 그렇게 말하고 있었다. 두 파일은tests/test_site_theme.py가 1:1 로 묶어 두므로 같이 고쳤다- 데모 fixture 의 theme 에
rules·booking을 넣었다 — 서버 기본표에는 있는데 fixture 에만 없어서, 개발 서버로는 이 두 섹션을 아예 볼 수 없었다
곁에서 나온 것 — 데모 payload 는 원래 굽히지 않았다
npm run prerender(payload 미지정 = 데모)가 절대규칙 3 대조 9건으로 실패하고 있었다.
내 변경 전에도 같은 건수로 실패했다(main 에서 재현 확인).
verify.ts가 URL 을 원본 HTML 문자열에서 찾았다. 속성으로 나갈 때&가&로 이스케이프되므로 쿼리스트링 있는 이미지 URL 은 화면에 있는데도 절대 안 찾아진다. → 엔티티를 되돌린 사본에서도 찾아본다. 표기 차이는 거짓이 아니다(숫자asShown()과 같은 이유). 되돌린 사본에서도 못 찾으면 그대로 실패다 — 느슨해지지 않았다.unitCode: 'MTK'(㎡ 의 UN/CEFACT 코드)를 본문에서 찾고 있었다. 한국어 페이지에 'MTK' 가 찍힐 일은 없다 —priceCurrency('KRW')와 같은 종류의 메타값이라STRUCTURAL로 옮겼다. ★ 사람이 읽는unitText는 옮기지 않았다 — 그건 화면에 있어야 하는 말이다.
검증 — tsc·eslint 통과, vitest 43 passed(신규 21건: 예약 뷰·발행 HTML·JSON-LD 대조·llms.txt).
데모 payload 재굽기 성공(1개 중 1개) → npm run serve 로 /s/moonlight-stay-jeju 200 확인.
백엔드 pytest 는 이 환경에 venv 가 없어 못 돌렸다 — 에디터↔서버 섹션표 parity 는 그 테스트와
같은 방식으로 손으로 대조했다(stay: 예약 안내 양쪽 일치).
2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
무슨 일
/s/stay · /s/stay2 · /s/stay3 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도
살아나지 않았다.
왜
out/s/ 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 손으로 넣은 목업이고,
프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 재굽기 대상이 아니다. 그래서 번들
해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가
한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다.
고친 것 (scripts/prerender.ts)
referencedAssets()— 굽기 전에out/s/**/index.html을 훑어/assets/…참조를 모은다pruneAssets가 그 목록을 절대 지우지 않는다. 보관 기간보다 우선한다 — 기간으로 막으면 30일 뒤에 똑같은 사고가 난다- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다
복구 — 지워진 파일은 stay-mockup 워크트리(solution/site/out/assets)에 남아 있어서
서버 볼륨에 손으로 되돌려 넣었다. docker cp → out/assets.
검증 — 목업 상황 재현: payload 없는 out/s/mock/index.html 이 옛 해시를 가리키게 두고
재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다.
2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다
무슨 일 바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 기존 사이트의 CSS·JS 가 전부 404 가 됐다. 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
왜
pruneAssets 가 "대장(.builds.json)에 없는 파일" 을 만료로 보고 지웠다. 그런데 대장은 이
기능과 함께 처음 생긴다 — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이
전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다.
놓친 것 — 검증을 out/ 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라
"옛 자산은 있는데 대장은 없는" 상태, 즉 실제 배포 직전의 서버 모습이었다.
고친 것 (scripts/prerender.ts pruneAssets)
- 대장에 없는 파일은 지우지 않고 "지금 처음 본 것" 으로 입양해 보관 기간을 새로 준다
- 규칙으로 굳혀 둔다: "기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다(AGENTS.md 함정 목록)
복구 — docker compose restart solution-prerender (기동하며 전체 재굽기 → HTML 이 새 해시를
가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다.
검증 — 배포 직전 상태를 재현: out/assets 에 옛 해시 파일만 두고 대장 없이 첫 실행 →
옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다.
2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다
왜
writeSharedAssets 가 빌드마다 out/assets 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 아직 다시 굽지 않은 사이트는 전부
CSS·JS 404 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 규칙
으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.
진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 나중에 돌린다. 그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고 (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다.
바꾼 것 (scripts/prerender.ts)
assets/를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데copyDirectoryFiles가 파일마다 먼저rmSync하므로 그 문제는 그대로 해결된다ASSET_RETENTION_DAYS(30일) ·ASSET_MIN_BUILDS(2) — 기간이 지나도 직전 빌드는 남는다out/assets/.builds.json대장: 어떤 빌드가 어떤 파일을 깔았는지. mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다. 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다- 점(.)으로 시작해
azure_static의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다
얻은 것 — 프론트 배포와 전체 재굽기가 분리된다. 재굽기를 안 하면 그 사이트만 옛 디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서 "안 하면 반영이 안 된다" 로 내려온다.
남은 것 — azure_static._upload_shared 가 매 발행마다 assets/ 전체를 올린다. 보관 기간만큼
업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다.
검증 — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로
다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의
파일 3개만 정리됐다. tsc·eslint 통과, vitest 22 passed.
2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다
왜
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— 구운 HTML 에서 목록·사이트맵 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한dateModified(=payload.site.updatedAt) 그 값 그대로다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다scripts/prerender.ts:readTitle을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은dateModified메타가 없던 시절의 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다seo/directory.test.ts: head.ts 의 메타와 파서의 커플링을 고정한다. 태그 모양이 바뀌면 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다
검증 — tsc·eslint 통과, vitest 22 passed (신규 5건).
2026-09-03 — 레포·발행 호스트 교체 — o2o-site-AEO / web4ai.o2osolution.ai
왜
레포를 castad/o2o-web4ai → Web4ai/o2o-site-AEO 로, 공개 주소를 w4ai.o2o.kr →
web4ai.o2osolution.ai 로 옮겼다. 옛 주소는 앞단에 vhost 가 없어 전 경로가 Apache 404 였다 —
그런데 canonical·og:url·sitemap 이 전부 그 주소를 가리키고 있었다. 화면은 멀쩡하고 기계가
읽는 값만 틀린 상태라, 검색엔진 등록을 아무리 해도 색인이 안 되는 종류다.
바꾼 것
- 기본 호스트를 쓰는 자리 전부(
site_payload.DEFAULT_HOST· compose 의:-기본값 4곳 ·vite.config.tsallowedHosts ·.env.example둘 ·check_search_ready.py· 데모 픽스처) docs/SERVERS.md: 배포 경로~/data2/o2o-site-AEO· 새 remote · 공개 주소 절init.sql:site.sites.thumbnail_url을 ALTER 절에 추가 — 아래 참조solution/frontend/public/google60b514c02fd6af4e.html: 새 호스트로 다시 받은 구글 소유확인
밟은 함정 둘
origin은 payload JSON 에 구워진다..env만 고치고 프리렌더를 돌리면 안 바뀐다 — 백엔드에서 재발행하거나 payload 의origin을 직접 고쳐야 한다.init.sql은 DB 최초 생성 때만 돈다. 41커밋을 건너뛰며 배포했더니users.provider와sites.thumbnail_url이 없어 로그인·쇼케이스가 통째로 죽었는데 HTTP 는 200 이었다.thumbnail_url은CREATE TABLE에만 추가돼 있어서 새 DB 는 되고 기존 DB 만 깨졌다.
검증 — 새 호스트로 canonical·og:url·robots.txt·sitemap 3건 전부 확인, 로그인·쇼케이스· 장소검색 정상, 스키마 드리프트 0.
2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
왜
/ 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.
한 일
/는 비로그인이면 랜딩, 로그인이면/sites./pricing·/showcase신설MarketingShell— 사이드바 없는 문서형 껍데기.AppShell은 작업 화면이라 나눴다 (b07ade2가 온보딩에서 사이드바를 뺀 것과 같은 판단)- 랜딩 상단은 상호명 한 칸이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고 고르지 않아도 된다 — 업종은 검색 결과가 정한다
- 쇼케이스는 발행 썸네일을 그대로 건다. 예시 데이터로 채우지 않는다 — 이 섹션이 파는 건 "진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다
- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다
검증 — tsc·eslint·vite build 통과.
2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
왜
랜딩 첫 화면에서 상호명을 치게 하려면 검색이 로그인 앞에 있어야 하는데,
후보 조회는 place_id 와 토큰을 둘 다 요구했다(place.py 확정 경로). 로그인 관문을
에디터 진입 하나로 되돌려 놓고도 API 는 그대로였다.
그리고 업종은 사장님에게 고르게 하고 있었는데 — 경계(베이커리 카페, 브런치집)에서 멈춘다.
한 일
GET /v1/place/search신설(인증 없음). 사업장을 만들지도, 우리 DB 를 읽지도 않는다. 확정 경로(/{place_id}/verify/candidates)는 인증을 그대로 둔다 — 남의 place_id 존재 여부까지 열 이유가 없다place_category.guess_category(): 카카오category_group_code(AD5·CE7·FD6) 우선, 없으면 분류 문자열. LLM 호출 0건 — 상호명 검색 응답에 이미 들어 있던 값이다- 못 정하면
None. 억지로 고르지 않는다 — 업종은 수집 스키마와 JSON-LD 타입을 통째로 정해서 틀리면 되돌리는 비용이 크다. HP8(병원)은 피부과·성형외과일 때만 받는다 rate_limit: 인증 없이 유료 외부 API 를 부르는 경로라 IP 당 분당 20회. 프로세스 메모리라 완전하지 않다(앞단 nginx 가 제대로 된 자리)
검증 — 전체 562 passed. 공개 응답에 place_id·전화·좌표가 안 나가는 것, 검색만으로 사업장이 생기지 않는 것을 테스트로 고정.
2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용)
왜 랜딩에 "이렇게 만들어졌습니다" 를 보여줄 그림이 없었다. 사이트는 발행되는데 그 결과물을 가리킬 이미지가 어디에도 저장되지 않아, 쇼케이스를 만들려면 매번 사람이 캡처를 떠야 했다.
썸네일은 스크린샷이 아니라 그 사이트의 대표 사진이다
헤드리스 브라우저는 봇 탐지 우회 우려로 영구 금지고(DECISIONS 1-1),
워커(python:3.12-slim)·프리렌더(node:24-alpine) 어디에도 Chromium 이 없다. 넣으면 이미지가
수백 MB 늘고 금지해 둔 도구를 상비하게 된다. 대신 og:image 로 나가는 대표 사진을 그대로
옮긴다 — 검색 결과에 뜨는 그림과 쇼케이스 카드가 같아진다. 대표 사진 선정 규칙은
site_payload.primary_media() 한 곳뿐이라 두 곳이 갈릴 수 없다.
한 일
services/site_thumbnail.py신설. 대표 사진을 httpx 로 받아(10초 상한 · 리다이렉트 3회 · image/* 만 · 5MB 상한)<prefix>/thumbs/<slug>.<ext>로 올린다. 기존AZURE_STORAGE_CONNECTION_STRING을 그대로 쓴다 — 새 자격증명 체계를 들이지 않았다. ★ 사이트 경로(s/<slug>/) 안에 두지 않는다:azure_static._remove_stale_site_files()가 매 발행마다 그 경로를 통째로 교체하므로 다음 발행에서 조용히 사라진다.build_service:azure_static.publish()직후 · IndexNow 통보 전에 저장하고, 발행 상태 전이 UPDATE 에thumbnail_url을 실어 보낸다(UPDATE 는 그대로 한 번). 실패해도 발행을 되돌리지 않는다 — 정적 파일은 이미 올라갔다(emit_payload와 같은 원칙). 못 만들면 키를 넣지 않아 지난 발행의 그림이 남는다.GET /v1/showcase신설(인증 없음, 랜딩이 부른다). 발행된 사이트만 최신순, 기본 12건·상한 48건. 나가는 것은 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — place_id·company_id·전화번호·상세 주소는 싣지 않는다. 무엇을 내보낼지 고르는 자리를services/showcase_service.py한 곳에 모아 경계를 눈에 보이게 뒀다. 어드민 진입점(:9801)에는 마운트하지 않는다.
곁가지로 고친 것 — 브랜치에 이미 깨져 있던 테스트 4건
conftest.fake_renderer가 늘ok=True를 돌려줬다. 진짜 렌더러는 고유 콘텐츠 0건이면 페이지를 쓰지 않는데(prerender.tsNoUniqueContentError), 대역이 그 실패를 흉내내지 않아 백엔드가 그 사유를 NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 안 돌고 있었다.test_snapshot이 "region_code 가 없으면 지역 정보 없음" 을 기대했다. 지금은 도로명주소에서 유도한다(snapshot._local_contents) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다.
검증 — pytest 전체 552 passed.
2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거
왜
로그인해도 갈 곳이 없었다. / 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로
나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이
/builder?placeId=<uuid> 를 기억하는 것이었다.
아임웹을 보면 계층이 둘로 갈려 있다 — 계정 레벨(내사이트 목록 · 마이페이지)과 사이트 레벨(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로 비어 있던 것은 계정 레벨이다. 그리고 아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다 — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다.
한 일
GET /v1/site/list— places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이다. 사이트가 아직 없는 사업장도 내려간다 — 빠지면 위저드를 걸어오다 만 가게를 다시 찾을 길이 없다.render(정적 파일이 실제로 있는지)는 넣지 않았다 — 보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다./sites내 사이트 ·/account내 정보./는 로그인 여부로 갈린다(비로그인은 그대로 위저드).- ⋯ 메뉴는 [발행 내리기] 하나다. 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다.
- 위저드에서
AppShell(사이드바)을 걷어내고 얇은 상단 바로 바꿨다. 사이드바는 계정 메뉴라, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 진행은WizardSteps가 이미 보여주므로 거기 필요한 건 로고와 나가는 길 하나다. - 에디터 헤더에 [← 내 사이트].
BuilderPage가 "내 사이트 관리가 생기면 그때 잇는다"고 비워 뒀던 자리다.
검증 — 백엔드 테스트 5건 추가(사이트 없는 사업장 · 조인 · 회사 격리 · 재빌드 판정이 단건과
일치 · 비로그인 401), 539 passed. tsc·eslint·vite build 통과(frontend·admin).
위저드에 사이드바가 사라진 것은 브라우저에서 확인.
2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절
왜 네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다. 12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.
한 일
shared/currentSeasons()— 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다. 9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다.- 발행본은 HTML 에 전 계절을 굽고 화면에서만 접는다(
hidden). 두 가지 이유다 — ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다. 그래서 계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법). ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다. - 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다.
- 빌더는 탭을 그대로 두되 지금 계절로 열리고, 탭에 '·지금' 표시와 "손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
검증 — tsc·eslint 통과(frontend·site). 경계 12일자 단위 확인
(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘(9/2) 여름·가을만 보이고
봄·겨울은 hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
왜
아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 박고 있었다. 사장님이 템플릿을
매거진으로 바꿔도 아이템 섹션만 레트로로 남아 화면이 두 벌로 보였다. 아이템은 레트로 전용
부품이 아니라 어느 템플릿에나 들어가는 섹션이다.
그리고 발행본은 색만 템플릿을 따랐다 — theme 계약에 생김새(look)가 없어서, 레트로를 골라도
발행 페이지는 늘 같은 고딕으로 나갔다. 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
한 일
- 아이템 1종 추가 — 계절별 추천 하루(
planner.podium). 계절 탭 + 1·2·3위 카드. 기존schedule과 축이 다르다: 저쪽은 사장님이 시각을 적고, 여기는 시각을 계산한다. 사장님은 출발 시각과 "몇 분 걸리나"만 적고, 출발을 당기면 하루가 통째로 밀린다. 조립 규칙(planDay·plannerTop·plannerSeasons)은 파서와 같은 이유로@o2o/shared한 벌이다 — 빌더와 발행본이 같은 조건에서 같은 시각을 내야 한다. 밤 9시를 넘기는 칸은 넣지 않고 뺐다고 화면에 밝힌다(숨기면 사장님은 왜 없는지 모른다). - 아이템 색·서체를 전부 템플릿 토큰(
--tpl-*)으로.retro/common.tsx→items/common.tsx,RETRO_*상수 →ITEM_*토큰. 글자 단계는 stone-400/500/600 대신 불투명도로 만든다 — 팔레트가 바뀌어도 위계가 유지된다. 질감(도넛판 홈·톱니·필름 구멍)도currentColor로 판다. SiteTheme.look계약 추가 — 서체·모서리·테두리 두께·그림자·섹션 여백이 발행본까지 간다. 프론트가 저장하고(toThemePayload), 서버는 해석 없이 싣고(_theme),seo/head.ts가--tpl-*로 심는다. 발행본.serif·body도 이 토큰을 읽는다.- 웹폰트는 템플릿이 쓰는 것만 내려보낸다(서체 스택을 훑어 아는 것만). 전부 항상 실으면 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다.
- 색 유도식(
deriveSurfaces)을@o2o/shared로. 캔버스·쇼케이스·발행본이 같은 식을 써야 미리보기가 거짓말을 하지 않는다. 프론트lib/color.ts는 재수출만 남겼다.
밟은 함정
- 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 배지가 사라진다(연한 accent + 밝은 바탕).
→
color-mix(accent 70%, currentColor)— 색조는 남고 대비만 확보된다. 어두운 면에서는 밝은 쪽으로 붙는다. - 순위 배지를 accent 로 채웠더니 같은 이유로 글자가 안 보였다. 1위만 글자색으로 채운다.
- '확인/확인필요' 배지는 디자인이 아니라 신호다. 신호색은 지키되 둘레 글자색을 섞어 대비만 맞춘다.
검증 — tsc·eslint·vite build 통과(frontend·admin·site), site 테스트 17 passed.
쇼케이스에서 팔레트를 바꿔 제목 서체·날짜 색이 함께 바뀌는 것 확인.
실물 프리렌더(레트로 look + planner): <head> 에 --tpl-font-heading: 'Gugi'…·--tpl-border-width: 2px,
family=Gugi&family=Gowun+Batang 링크, 계절 묶음 여름·가을, 순위 1·2위,
계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25…) 확인. 고유 콘텐츠 12건 · ok=true.
옛 payload(look 없음) 로 다시 구워 서체 링크가 예전 두 벌 그대로이고 look 토큰이 안 실리는 것까지 확인.
백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — _theme·_sections 는 함수 단위로 직접 확인했다.
2026-09-02 — 붙여넣기 아이템 다섯을 더하고, 아홉 개를 발행 사이트까지 내보낸다
왜
아이템 카탈로그에서 고른 여덟 중 넷(가요·일력·승차권 + 스케줄)만 있었다. 나머지 다섯은
빌더에 칸 자체가 없었다. 더 큰 구멍은 그 아래에 있었다 — 아홉 개 전부 발행본에 안 나갔다.
SectionSetting 계약에 data 가 없어서, 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다
(소개문 body 와 같은 사연). 빌더에서는 보이는데 발행하면 없는 섹션이었다.
한 일
- 아이템 5종 추가 — 인물 열전(필름 스트립) · 시간의 골목(가로 연표) · 문학 서가(책등·세로쓰기) ·
오늘의 엽서(엽서 뒷면) · 뒤집어 보는 질문(갱지 시험지 플립).
dataSpec에 스키마·프롬프트·예시,registry에 배리에이션 한 줄씩. [+ 섹션 추가] 목록은dataSpec에서 파생돼(addable.ts) 따로 손댈 곳이 없다. - 읽는 쪽 계약을
@o2o/shared로 옮겼다(lib/section-data.ts) — 항목 타입 ·parseSectionData. 같은 JSON 을 빌더와 발행 사이트가 함께 읽는다. 파서를 각자 두면 슬러그 규칙처럼 조용히 어긋난다. 빌더에는 쓰는 쪽(프롬프트·예시·라벨)만 남았다. SectionSetting.data계약 추가 ·site_payload._sections()가 그대로 실어 보낸다(서버는 파싱하지 않는다).- 발행 사이트에 아이템 섹션 아홉(
site/src/sections/items/). 인터랙션은 옮기지 않았다 — 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. 이 사이트의 존재 이유가 AI·검색의 인용이라 발행본은 전 항목을 펴고 가로로만 민다. - 프리렌더 고유 콘텐츠 계수에 아이템 항목을 넣었다. 안 세면 "곡을 여덟 개 채웠는데
고유 콘텐츠 0건으로 발행이 막힌다"가 된다 —
intro.body와 같은 구멍이다(백엔드 fake 도 같이). - 간판체(Gugi)는 아이템을 실제로 쓰는 사이트에만
<head>로 내려보낸다. 서체 하나가 모든 발행 사이트의 첫 렌더를 늦출 이유가 없다.
안 한 것
레트로 템플릿 시드(defaultSectionTypes)는 넷 그대로 뒀다. 붙여넣기 아이템은 내용이 없으면
빈 섹션이라, 아홉을 시드에 박으면 아무도 안 쓰는 칸이 늘 붙어 있게 된다(addable.ts 의 근거).
검증 — tsc·eslint·vite build 통과(frontend·admin·site), site 테스트 17 passed.
실물 프리렌더: 아이템 아홉이 든 payload → ok=true, 고유 콘텐츠 18건, 발행 HTML 에 아홉 섹션과
본문 문장 전부 포함, family=Gugi 링크 있음. 같은 payload 에서 아이템을 빼면 8건 · Gugi 링크 없음.
백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 —
바꾼 _sections() 와 conftest 계수는 함수 단위로 직접 돌려 확인했다.
2026-09-02 — 회원가입과 구글 로그인
한 일
POST /v1/auth/signup(id/pw) ·POST /v1/auth/google추가. 로그인 화면에 구글 버튼과 가입 링크,/signup화면. 내부 운영 화면은selfServe={false}로 둘 다 안 뜬다.company.users에provider(AuthProvider) ·provider_uid(구글 sub).password는 NULL 허용(소셜 계정),id는 20 → 64자(google_<sub>가 20자를 넘는다).- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가 들고 있었는데 에디터는 전체 화면이라 신원도 나가는 길도 화면에서 사라져 있었다.
왜 가입부터 만들었나
계정 생성 API 가 아예 없었다 — 그동안 users 를 손으로 INSERT 했다. 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = 새 회사(테넌트) 1개 + 첫 계정 1개
로 정의했다. users.company_id 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
로그인 관문은 에디터 진입 그대로다
한때 /builder 를 통째로 RequireAuth 뒤로 옮겼다가 되돌렸다(5ef3e5a). / 가 자기 화면 없이
/builder 로 넘기기만 하므로 문 앞 가드는 곧 루트 가드이고, 앱을 열자마자 로그인 화면이 된다.
관문은 EditorSignInGate(969fb67) 한 자리다.
밟기 쉬운 자리
GOOGLE_CLIENT_ID는 백엔드와 프론트가 같아야 한다. 백엔드는 이 값으로 구글 토큰의 수신자(aud)를 대조한다 — 이 검사가 없으면 다른 서비스에 발급된 진짜 구글 토큰으로 우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.- 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다. 이으면 계정 선점이 된다 → DECISIONS.md 1-5
- 소셜 계정은
password가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이 None 을 만나 500 이 난다. provider에server_default를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서 NOT NULL 컬럼이면 그 경로가 통째로 깨진다.- init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다. 인덱스 절이 ALTER 보다 위라, 기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
이미 도는 DB 가 있으면 postgres-init/init-data/init.sql 을 다시 적용한다.
아직 못 한 것 — 실제 구글 계정 로그인. GOOGLE_CLIENT_ID 가 있어야 버튼이 뜬다.
버튼 렌더까지는 확인했다(빌려온 client_id 로).
검증 — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
email_verified·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. tsc·eslint·vite build 통과.
2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
왜
에디터의 소개 섹션 본문은 sites.theme 에 저장됐지만 발행 payload 경계에서 버려졌고,
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
한 일
SectionSetting.body계약을 추가하고 저장값을 payload 까지 전달- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
검증 — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
왜 업종마다 템플릿이 다섯이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. 고르는 화면의 미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — 사장님은 뭐가 다른지 알 수 없으니 아무거나 골랐다. 사용자 말: "가라 UI 로 되어 있어서 뭐가뭔지 모르겠음".
한 일
TemplateItem.look(TemplateLook) 신설: 제목·본문 서체, 모서리, 테두리 두께, 그림자, 제목 자간·굵기, 섹션 여백. CSS 에 그대로 들어가는 문자열로 들고 있다 — 숫자로 두면 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다.- 업종당 3개로 정리: 심플(고딕·둥근·그림자) · 매거진(명조 제목·각짐·그림자 없음·여백 큼) ·
레트로(간판체·2px 테두리·오프셋 그림자·갱지).
templatesFor()팩토리 하나가 찍어내고 업종은 accent 하나만 바꾼다 — 생김새는 업종이 아니라 취향의 문제다.industryData.ts537줄 → 237줄. - 고르는 화면의 미리보기를 그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다(
TemplatePreview).
핵심 수법 — Tailwind 테마 변수를 캔버스 안에서만 덮는다
.site-canvas 에 --radius-* · --shadow-* 를 내려보내면, 변이 파일 40여 개에 흩어진
rounded-* · shadow-* 를 한 줄도 안 고치고 전부 템플릿을 따르게 된다.
배수는 Tailwind 기본 비율을 그대로 옮겨, 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다.
밟은 함정
.site-canvas는 이미--tpl-font-heading/body를 읽고 있었는데 아무도 넣지 않았다. 서체가 갈리지 않던 진짜 이유가 이 빠진 고리였다.- 간판체(Gugi)는 굵기가 한 벌뿐이라
font-weight:700을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. →--tpl-heading-weight로 템플릿이 400 을 지정할 수 있게 했다. Noto Serif KR을 안 불러오고 있었다. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다.- 옛 템플릿 id(
stay-warm-wood등)가 DB 에 남아 있어도resolveTemplate이 첫 템플릿으로 떨어뜨린다.
검증 — tsc·eslint·vite build 통과(frontend·admin·site). 템플릿 12벌의 look 전량 대조,
옛 id 폴백·레트로만 아이템을 데려오는지 확인.
2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책
한 일
- 섹션 타입 3개 추가(
songs·daily·course). 데이터가 수집(fact)이 아니라 사장님이 붙여넣은 JSON 에서 온다 — 새 갈래다. canvas/dataSpec.ts신설: 스키마·예시·프롬프트가 한 표에 모인다. 배리에이션 레지스트리와 같은 결이라 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.SectionItem.data?: string추가. 파싱본이 아니라 원문 문자열을 담는다.- [콘텐츠] 탭에 JSON 칸 + [프롬프트 복사] [프롬프트 보기] [예시 넣기] [줄맞춤].
- 업종 시드 넷 모두에 세 섹션을 꺼진 채로 넣었다.
왜 이 모양인가
gunsan_365_story_db.xlsx(365행)를 분석한 결과 고유 주제는 52개고 한 주제가 7회씩 돈다
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고, 날짜는 일력 한 장에만 썼다.
같은 시트 DB_Guide 가 가사·현대문학 원문 전재를 금지해서 가요 스키마에 lyrics 필드를
아예 두지 않았다. 없는 칸은 채울 수 없다.
밟은 함정
- 테마 상한 64KB(
site_service._THEME_MAX_BYTES). 세 섹션이 각자 JSON 을 채우면 넘고, 거절은 발행 직전에야 드러난다. →SECTION_DATA_MAX_CHARS(12,000자)로 화면에서 먼저 끊는다. JSON.parse오류 메시지가 두 형식이다.position N (line L column C)형과, 위치 없이 깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다.- 파싱은 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
섹션 관리에 붙인 것
- 좌측 패널 하단 [+ 섹션 추가] → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는, 붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다.
- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다).
- 레트로 템플릿(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다.
TemplateItem.defaultSectionTypes가 그 계약이고, 넣기만 하고 빼지 않는다 — 템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다. - 저장 payload 에
type을 실었다. 시드에 없는 섹션은 복원 때id로 못 찾아 통째로 버려졌다 (사장님이 채운 JSON 까지 같이). 이제type으로 되살린다.
아직 안 한 것
- 발행 사이트(
solution/site)는variantId도data도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다. - 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다).
검증 — tsc --noEmit · eslint · vite build 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인,
파서 경계 12건 + 추가·삭제·템플릿·저장복원 왕복 12건 확인.
2026-09-01 — 설정을 .env 하나로 모았다
한 일
- 백엔드 설정을 toml →
pydantic-settings(FastAPI 공식 방식)로 옮겼다. config_loader.py·config.local.toml.example·config.test.toml.example삭제.server_configs.py107줄 → 26줄._apply_*_env_override함수 4개 제거.- 호출부 21개 파일은 안 건드렸다 — 같은 이름을 그대로 내보낸다.
왜
키마다 if os.environ.get(...) 를 손으로 나열하는 구조였다. 하나 빠뜨리면 조용히 틀리는데,
실제로 client_url 이 빠져 있어 배포 주소의 API 호출이 전부 CORS 로 막혔다.
BaseSettings 는 필드를 선언하면 환경변수가 자동으로 들어와 이 사고가 구조적으로 안 난다.
하는 김에 잡은 잠재 버그
.env경로가 세 단계라solution/.env(없는 파일)를 보고 있었다. 백엔드를solution/아래로 옮길 때 안 고쳐진 자리다. toml 이 값을 들고 있어 로컬에서 안 드러났고, 도커는 compose 가 환경변수를 직접 넣어 역시 멀쩡했다. toml 을 없앤 지금은 유일한 공급원이라 치명적이었다.- 환경변수 이름을
validation_alias로 못 박았다. 안 그러면port필드가 흔한PORT를 주워 먹어 엉뚱한 포트로 뜬다.
결과 — 백엔드 설정 파일은 최상위 .env 하나뿐이다. → DECISIONS.md
2026-08-31 — 킹서버 최초 배포
한 일
~/data2/o2o-web4ai에 배포. DB(web4ai_db) 생성 +init.sql적용.- 컴포즈 포트를 전부
.env변수로 뽑았다. 로컬 기본값은 그대로다. deploy.sh·log.sh추가.
왜 포트를 뽑았나
킹서버는 :80 을 호스트 nginx 가 이미 물고 있다. 사내망에 열려 있는 건 30xxx 대역뿐이라
그 안에서 자리를 잡아야 했다. → SERVERS.md
밟은 함정
PUBLIC_API_BASE_URL은 브라우저가 부르는 주소다.localhost로 두면 화면은 뜨고 API 만 죽는다 — 콘솔을 열기 전엔 안 보인다.- 내부 화면의 "빌더 열기" 가
VITE_SOLUTION_URL미주입으로 죽은 링크였다. 로컬에서는 기본값이 맞는 주소라 서버에 올리기 전까지 드러나지 않았다. deploy.sh api는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰는데 하나만 바꾸면 옛 코드로 도는 컨테이너가 남고,ps로는 셋 다 살아 있어 구분이 안 된다.
남은 것 — w4ai.o2o.kr DNS + 앞단(59.14.81.3) 포워딩. 서버에 sudo 가 없어 인프라 몫이다.