- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story · section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다 - 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) · Festival · LocalGuide · Weather · Gallery · Header/Footer - 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게 - DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트 - 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%. 실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다 검증: site·frontend 빌드 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
965 lines
73 KiB
Markdown
965 lines
73 KiB
Markdown
# 개발 일지
|
||
|
||
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
|
||
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
|
||
|
||
---
|
||
|
||
## 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.ts` `sectionItems`).
|
||
- **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절](DECISIONS.md).
|
||
|
||
**검증** — `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.ts` `indexUrl`: `+ '/'` 제거. 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.ts` `BOOKING_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절](PRODUCT.md)
|
||
— "사이트는 예약 채널로 보낸다"). 날짜 선택기·예약 폼을 그리지 않았다 — 없는 기능을 화면으로
|
||
흉내내면 손님은 예약한 줄 알고 안 오고, 그 전화는 사장님이 받는다. 대신 **예약에 필요한 사실 +
|
||
실제로 예약이 되는 창구**를 한자리에 모았고, "여기서 결제되지 않는다"를 화면 맨 앞과 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 에서 재현 확인).
|
||
1. `verify.ts` 가 URL 을 **원본 HTML 문자열**에서 찾았다. 속성으로 나갈 때 `&` 가 `&` 로
|
||
이스케이프되므로 쿼리스트링 있는 이미지 URL 은 **화면에 있는데도** 절대 안 찾아진다.
|
||
→ 엔티티를 되돌린 사본에서도 찾아본다. 표기 차이는 거짓이 아니다(숫자 `asShown()` 과 같은 이유).
|
||
되돌린 사본에서도 못 찾으면 그대로 실패다 — 느슨해지지 않았다.
|
||
2. `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.ts` allowedHosts · `.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`: 새 호스트로 다시 받은 구글 소유확인
|
||
|
||
**밟은 함정 둘**
|
||
1. **`origin` 은 payload JSON 에 구워진다.** `.env` 만 고치고 프리렌더를 돌리면 안 바뀐다 —
|
||
백엔드에서 재발행하거나 payload 의 `origin` 을 직접 고쳐야 한다.
|
||
2. **`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](DECISIONS.md)),
|
||
워커(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.ts `NoUniqueContentError`), 대역이 그 실패를 흉내내지 않아
|
||
백엔드가 그 사유를 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](DECISIONS.md)
|
||
- 소셜 계정은 `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.ts` 537줄 → 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.py` 107줄 → 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](DECISIONS.md)
|
||
|
||
---
|
||
|
||
## 2026-08-31 — 킹서버 최초 배포
|
||
|
||
**한 일**
|
||
- `~/data2/o2o-web4ai` 에 배포. DB(`web4ai_db`) 생성 + `init.sql` 적용.
|
||
- 컴포즈 포트를 전부 `.env` 변수로 뽑았다. 로컬 기본값은 그대로다.
|
||
- `deploy.sh` · `log.sh` 추가.
|
||
|
||
**왜 포트를 뽑았나**
|
||
킹서버는 `:80` 을 호스트 nginx 가 이미 물고 있다. 사내망에 열려 있는 건 30xxx 대역뿐이라
|
||
그 안에서 자리를 잡아야 했다. → [SERVERS.md](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 가 없어 인프라 몫이다.
|