숙박으로 발행하면 서버 기본표가 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 는 같은 방식으로 손대조했다.
43 KiB
개발 일지
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 위에 추가한다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
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 가 없어 인프라 몫이다.