# 개발 일지 무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다. --- ## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거 **왜** 로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로 나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이 `/builder?placeId=` 를 기억하는 것이었다. 아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과 **사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로 비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다. **한 일** - `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): `` 에 `--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)는 **아이템을 실제로 쓰는 사이트에만** `` 로 내려보낸다. 서체 하나가 모든 발행 사이트의 첫 렌더를 늦출 이유가 없다. **안 한 것** 레트로 템플릿 시드(`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_` 가 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 가 없어 인프라 몫이다.