o2o-site-AEO/docs/DEVLOG.md
Mina Choi 01c252287e Merge branch 'main' into feature/auth — 관문은 main 의 에디터 진입으로
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 통과.
2026-09-02 21:33:31 +09:00

12 KiB

개발 일지

무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 위에 추가한다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.


2026-09-02 — 회원가입과 구글 로그인

한 일

  • POST /v1/auth/signup(id/pw) · POST /v1/auth/google 추가. 로그인 화면에 구글 버튼과 가입 링크, /signup 화면. 내부 운영 화면은 selfServe={false} 로 둘 다 안 뜬다.
  • company.usersprovider(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 이 난다.
  • providerserver_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)는 variantIddata 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다.
  • 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다).

검증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


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 가 없어 인프라 몫이다.