숙박 예약 구성(요금·인원·창구) · 네이버 예약 딥링크 · 날짜/시간 목업 · 로컬 발행 함정 셋.
충돌 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 확인.
편집 화면이 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약하실 수 있습니다" 를
그리고 있었다. 우리는 실시간 재고를 갖지 않고(PRODUCT.md 6절), 발행본은 날짜·시간을 고르는
화면이다 — 사장님이 에디터에서 본 것과 발행된 사이트가 서로 다른 물건이었다.
에디터가 보여주는 것이 곧 발행될 것이어야 한다.
- booking/BookingCard: 발행본 구성(날짜 칩·도착 시간·인원·예약 요청·전화 창구)의
미리보기로 교체. 캔버스 클릭은 섹션 선택이라 상태를 두지 않고 첫 칸 선택 모습으로 고정.
시간 칸은 발행본과 같은 규칙 — 체크인 fact 가 있을 때만 그린다
- booking/BookingBanner: "실시간 캘린더에서 남은 날짜" → "날짜와 시간을 고르고 예약 창구로"
- rooms/RoomCard: "실시간 예약 신청" → "예약 안내 보기"
- hero/HeroEditorial: "실시간 예약" → "예약 안내"
- api/generated linkChannel: NAVER_BOOKING=7 추가. ★ npm run orval 을 그대로 돌리면
141파일 6,400줄이 바뀌는데 전부 따옴표·줄바꿈 포매팅 드리프트다 — 생성물을 되돌리고
스펙 변경분 한 줄만 남겼다
tsc·eslint 통과(frontend·site), vitest 51 passed. solution-site 재빌드 후 번들에서
옛 문구 0건 확인.
예약 흐름을 눈으로 보려고 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
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at·
published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고
같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다.
리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다.
Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다.
아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일,
상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다.
공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다.
- protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해
읽고 있어서 쿼리는 그대로다
- site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로
주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다
(CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는
사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다
- site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가
사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다
(사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다)
- SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를
1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸
`전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면
건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 —
35개 있는데 "아직 없습니다" 라고 말하던 자리다
- 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼
위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다
아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다.
검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에
`thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가.
프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.
- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
`places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
스코프가 없으면 아무에게도 안 보이는 유령이다.
실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다
남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.
검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 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
로그인하면 '/' 가 무조건 /sites 로 튕겼다. 그래서 **로고를 눌러도 랜딩이 안 뜨고**,
/pricing 은 살아 있는데 링크가 랜딩과 MarketingShell 에만 있어 로그인한 사장님은
주소를 직접 쳐야 했다. 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.
- app/router.tsx: Home 의 리다이렉트 제거. '/' 는 누구에게나 랜딩이다.
"로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다.
- pages/LoginPage.tsx: 도착지를 `homePath` prop 으로 받는다. 기본값 '/' 라
내부 운영 앱(자기 '/' 가 사업장 목록으로 간다)은 그대로다. 사장님 앱만 '/sites'.
- pages/SignupPage.tsx: 가입 후 '/' → '/sites'. 안 그러면 관문이 없어진 지금 랜딩에 떨어진다.
- 로그인·가입 화면 로고에 '/' 링크. 그 화면에서 빠져나갈 길이 하나도 없었다.
- layout/AppShell.tsx: 로그아웃 → '/login' 이 아니라 '/'. 나간 사람에게 로그인 폼을
다시 들이밀지 않는다. 사이드바에 [요금] 추가.
- layout/MarketingShell.tsx: 헤더 [무료로 만들기] 제거 — 히어로 입력 카드가 이미 그 자리다.
시작하는 문이 한 화면에 둘이면 어느 쪽이 진짜인지 고르게 만든다.
검증: tsc·eslint 통과. docker compose up -d --build solution-site 로 띄워 눌러 확인 —
로그인 상태에서 로고 → 랜딩, 헤더는 [이렇게 나옵니다 · 요금 · 내 사이트].
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).
- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다
검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
네이버 서치어드바이저는 DNS TXT 를 안 받는다. HTML 파일 아니면 메타태그뿐이라
구글·빙과 같은 자리에 둔다.
- public/naver71762ee96e2e126623dc1da07ecae493.html
★ 확인은 상태코드가 아니라 **내용**으로 한다 — nginx 의 `try_files ... /index.html` 이
없는 경로에 SPA 를 200 으로 돌려준다.
옛 주소 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 네이티브 누락).
발행 호스트를 web4ai.o2osolution.ai 로 옮기면서 w4ai.o2o.kr 로 받아 둔 소유확인이
전부 무효가 됐다. HTML 파일 방식으로 다시 받았다.
- public/google60b514c02fd6af4e.html: Bing 것과 같은 자리다. 이미지에 구워야
컨테이너 재생성에도 살아남는다(docker cp 로 넣으면 다음 배포에 사라진다)
확인은 상태코드가 아니라 **내용**으로 한다 — nginx 가 없는 경로를 index.html 로
떨어뜨려 200 을 준다.
**애니메이션이 안 보이던 진짜 이유** — `.o2o-rotator-track` 이 <span> 이라 display:inline 이었다.
인라인 요소에는 transform 이 적용되지 않는다. 애니메이션은 걸려 있고 화면만 정지였다.
(computed style 로는 animationName 이 보여서 더 헷갈린다.)
- 히어로를 아임웹 구조로: 한 줄 입력창이 아니라 **큰 입력 카드**, 발행 사이트 띠가 그 카드
**뒤로 full-bleed** 로 지나간다. 카드 아래 따로 두면 첫 화면이 세로로 길어진다
- 카드 안: 브랜드 라벨 + [업종부터 고르기] · 큰 입력 · 하단 업종 안내 + 원형 제출 버튼
- 제목 sm:text-6xl lg:text-7xl, leading 1.15
- 헤더 h-14→h-16, 로고 h-6→h-8, 메뉴 14px, 로그인도 버튼(맨 텍스트면 눌리는 걸로 안 보인다)
- 헤더가 커진 만큼 히어로 높이 계산도 4rem 으로 — 안 바꾸면 스크롤바가 생긴다
콘텐츠가 세로로 퍼져서 첫 화면에 임팩트가 없었다.
- 히어로 min-h-[calc(100dvh-3.5rem)] + 세로 가운데. 헤더(h-14)를 빼야 스크롤바가 안 생기고,
vh 가 아니라 dvh 인 이유는 모바일에서 주소창이 접혔다 펴져 vh 가 흔들리기 때문
- 검색창·버튼을 rounded-full h-13 로. 아이콘 여백도 같이 밀었다
- 마퀴 카드 w-52 → w-40, 간격·글자 축소. 히어로 안에 들어와야 한 화면에 다 담긴다
앞말이 넷이라 금방 반복됐고, 그 아래 보조 문구 세 줄이 히어로를 세로로 늘려 임팩트를 깎았다.
- 앞말 6개: SEO · AEO 최적화 / AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 /
손님이 먼저 만나는 / 우리 가게가 직접 말하는
- ★ 문구 개수와 index.css 의 o2o-rotate-6 키프레임은 한 몸이다. 늘리면 stop 도 고쳐야 한다 —
안 고치면 뒤쪽이 영영 안 보이거나 빈 줄이 지나간다
- 보조 문구 3줄 삭제, 제목 sm:text-6xl 로 키우고 세로 여백 축소
"SEO · AEO 되는 웹사이트" 는 규격 이름이지 문구가 아니었다. 아임웹처럼
[형용사절] + [명사] 구조로 바꾸고, 명사를 고정한 채 앞말만 갈아 끼운다.
- AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 / 손님이 먼저 만나는
- CSS 만으로 돈다(마퀴와 같은 이유 — 타이머는 백그라운드 탭에서 밀린다)
- 문구를 다섯 줄 쌓고 마지막을 첫 줄의 복제로 둔다. -80% 에서 0% 로 되감을 때 글자가 안 튄다
- 줄 높이를 1.25em 으로 못 박는다 — 창 1줄 · 트랙 5줄이라 한 칸이 정확히 20% 여야 한다
- 복제분은 aria-hidden. SEO·AEO 는 제목에서 빼 보조 문구로 내렸다
tsc·eslint·vite build 통과
상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다.
- CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때
프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다
- 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다
- 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration)
- 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게
- hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다
- CTA 문구를 [확인하기] 로(시안과 같게)
tsc·eslint·vite build 통과
썸네일은 발행 잡이 끝날 때 만들어진다. 그래서 이 기능이 들어오기 전에 발행된 사이트는
thumbnail_url 이 영영 NULL 이고 쇼케이스에서 글자 카드로만 나온다. 재발행을 시키면
채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이라 썸네일만 따로 만든다.
- 발행본 HTML 을 건드리지 않는다. 읽는 건 snapshot 의 사진 목록, 쓰는 건 thumbs/ 와 컬럼 한 칸
- --dry-run 은 Azure 설정 없이도 돈다(대상이 맞는지 먼저 봐야 한다)
- 한 건 실패가 나머지를 막지 않는다
로컬 dry-run: 발행 15건 전부 대표 사진 있음
`/` 가 곧장 위저드로 튀어서 이 제품이 무엇을 파는 물건인지 말할 자리가 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.
- router: `/` 는 비로그인 랜딩 · 로그인 /sites. /pricing · /showcase 추가
- MarketingShell: 사이드바 없는 문서형 껍데기. AppShell(작업 화면)과 나눴다
- 랜딩 상단은 상호명 한 칸. 문구는 SEO·AEO 축으로만 쓴다 —
'쉽게·빠르게'로 말하면 홈페이지 빌더와 같은 자리에서 비교당한다(PRODUCT 1절)
- ShowcaseGrid: 발행 썸네일을 그대로 건다. 예시 데이터로 채우지 않고, 없으면 섹션을 감춘다.
★ 생성 클라이언트를 안 쓴다 — 토큰 길목을 지나면 비로그인에서 못 부른다
- 요금은 플랜 하나(70만원/월) + 월 산출물. 비교표를 만들지 않는다
tsc·eslint·vite build 통과
업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.
- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다
tsc·eslint·vite build 통과
랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.
업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.
- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다
전체 562 passed
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.
★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
프리렌더(node:alpine) 어디에도 Chromium 이 없다.
- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
(payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)
전체 562 passed
화면은 http://localhost(:80) 인데 번들이 http://localhost:9800 을 직접 불렀다. 백엔드 허용
오리진 기본값은 :3000~3005 뿐이라 브라우저가 막았고, 화면에는 '로그인에 실패했습니다'
(네트워크 예외 문구)만 떴다 — 아이디·비번 문제로 보인다.
nginx 가 이미 /v1 을 프록시한다(site.conf). 그쪽으로 부르면 CORS 를 아예 안 탄다.
- .env: PUBLIC_API_BASE_URL=http://localhost — 번들이 같은 오리진을 보게 한다
- LoginPage: 개발 편의로 admin/1234 기본값. ★ 운영 전에 빈 문자열로 되돌릴 것
브라우저 확인(localhost:80): 로그인 → /builder, 사이드바 '관리자 · 데모대행사'.
사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 그걸 얹으면,
만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다.
- BuilderPage: 위저드를 AppShell 대신 얇은 상단 바(로고 + 나가는 길)로. 진행은 WizardSteps 가
이미 보여준다. 비로그인은 돌아갈 목록이 없어 그 자리에 [로그인] 을 둔다
- BuilderPage: 에디터 헤더에 [← 내 사이트] — "내 사이트 관리가 생기면 그때 잇는다"고
비워 뒀던 자리다
- DEVLOG: 계정 레벨/사이트 레벨을 가른 근거
검증 — tsc·eslint·vite build 통과. 위저드에 사이드바가 사라진 것은 브라우저에서 확인
로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가
없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과
가른다 — 우리는 그 사이트 레벨이 에디터다.
- pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기").
[사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다.
⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면
그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석)
- pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다
(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다
- router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 —
아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다
- AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구
검증 — tsc·eslint·vite build 통과(frontend·admin)
로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서
(site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다.
- site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번.
사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다
- protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 —
보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다
- site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다
- GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다
테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed
(기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1)
"구글 로그인 버튼이 없다" 는 지적이 맞았다. 버튼을 LoginPage·SignupPage 에만 붙여 뒀는데,
이 앱에서 사장님이 실제로 로그인 화면을 만나는 자리는 **에디터 진입 관문**(EditorSignInGate →
SignInForm)이다. 정작 거기엔 없었다.
- features/auth/SignInForm: 구글 버튼 추가. 관문·로그인 화면이 같은 폼을 쓰므로 한 곳만 고치면 된다
- lib/googleIdentity: 스크립트를 ?hl=ko 로 받는다. renderButton 의 locale 옵션은 안 먹었다 —
'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다(실측)
- 버튼 문구는 signin_with('Google 계정으로 로그인'). 가입 화면만 signup_with 로 둔다.
문구 자체는 고를 수 없다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다
브라우저 확인(localhost:80): 로그인 화면에 'Google 계정으로 로그인' 한글 노출.
tsc·eslint·vite build 통과.
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
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 에는 네 계절 전부 있는 것을 브라우저에서 확인.
위저드는 로그인 없이 열린다(관문은 에디터 진입이다). 그런데 사이드바는 로그인 여부와
무관하게 [로그아웃]만 그렸다 — 로그인한 적 없는 사람에게는 **이름이 빈 줄로 나오고,
버튼을 눌러도 지울 세션이 없어 아무 일도 일어나지 않았다.** "유저 정보가 어디에도 안 보인다"가
이것이다.
- 비로그인: '로그인하지 않았습니다' + [로그인] 링크
- 로그인: 이름 · 상호 + [로그아웃]. 로그아웃은 스토어만 비우면 화면이 그대로라 눌러도
아무 일이 없는 것처럼 보인다 — /login 으로 보낸다
브라우저 확인: 비로그인 위저드에서 안내와 [로그인] 노출 → 로그인 후 '김사장 · 달빛스테이' →
[로그아웃] 클릭 시 /login 이동. tsc·eslint·vite build 통과.
main 이 5ef3e5a 로 문 앞 게이트(d6a6c8e·b94daa9)를 되돌렸다. 근거가 내가 못 본 것이었다 —
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고,
앱을 열자마자 로그인 화면이 된다. 그 결정을 따르고, 되돌리기에 휩쓸린 것만 복구한다.
- app/router: `/signup` 라우트 복구. 라우터를 통째로 되돌리면서 같이 날아갔고,
그 결과 로그인 화면의 [회원가입] 링크가 404 였다
- features/auth/SignInForm: 토큰 심는 순서(signIn → me)를 lib/session 으로. 이 파일 맨 위
주석이 경고하던 그 중복이다 — 관문이 되살아나면서 사본도 같이 돌아왔다
- pages/BuilderPage: 에디터 관문(main)과 상단 바 사용자 표시(이쪽)를 함께 둔다.
충돌은 `authUser`/`user` 이름뿐이었다
- docs/DEVLOG: 인증 항목이 되돌리기에 휩쓸려 사라졌다. 지금 설계(에디터 진입 관문)에 맞춰
다시 썼다 — 문 앞 가드를 시도했다 되돌린 이력도 함께 남긴다
- docs/ARCHITECTURE: 인증 모델 서술과 '아직 안 한 것' 을 지금 상태로
브라우저 확인: 위저드는 로그인 없이 열림 → 가입 → 사이드바 '김사장 · 달빛스테이' →
에디터 상단 바 동일 표시 → 로그아웃. 구글 버튼 렌더까지 확인(실제 로그인은 client_id 필요).
pytest 534 passed / 4 failed(전부 기존 실패). tsc·eslint·vite build 통과.
아이템 넷(가요·일력·승차권·스케줄)만 있었고, 그마저 **발행본에는 하나도 안 나갔다.**
`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 는 함수 단위로 확인.
위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는
전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도,
나가는 방법도 화면에서 사라졌다.**
- BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가
- stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는
google_<sub> 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다
(기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다)
브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시,
[로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과.