/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.
★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.
★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
"요청은 성공했는데 결과가 영영 안 옴" 이 된다.
- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
(발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL
검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nginx/site.conf 는 /v1 을 같은 오리진으로 프록시하고 주석에도 "앱과 같은 오리진이라
프리플라이트가 아예 발생하지 않는다" 고 적혀 있는데, compose 빌드 인자 기본값이
VITE_API_BASE_URL=http://localhost:9800 이었다. :80 으로 앱을 열면 번들이 :9800 을 부르므로
스스로 크로스 오리진이 되고, CLIENT_URL 기본값(3000~3005)에 http://localhost 가 없어
로그인만 실패한다.
증상이 사람을 속인다 — 서버는 200 에 토큰까지 내려보내고 브라우저가 allow-origin 이 없어
그 응답을 버리므로, 화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다.
- docker-compose.yml · .env.example: 기본값을 앱과 같은 오리진(http://localhost)으로.
CORS 를 허용해 뚫는 게 아니라 크로스 오리진을 만들지 않는다
- .env.example: PUBLIC_API_BASE_URL 을 주석이 아니라 값으로 내놨다 — 주석으로 두면
compose 기본값이 이기고, 그 기본값이 문제였다
검증: down -v 후 up -d --build → 번들의 localhost:9800 참조 0건 ·
POST http://localhost/v1/auth/login 200(프리플라이트 없음) · 프리렌더 재굽기 2건 ·
/ · /s/ · /s/<slug> 전부 200.
문서대로 `cp .env.example .env` → `docker compose up -d` → 발행을 걸면 게이트는 통과하고
발행만 실패한다. 두 함정이 겹쳐 있었다(둘 다 실측).
- DB_HOST=127.0.0.1: 컨테이너 안의 127.0.0.1 은 그 컨테이너다. 증상이 고약하다 — API 는
/healthz 가 DB 를 안 보므로 200 healthy 로 뜨고 워커만 조용히 재시작을 반복한다.
compose 기본값(host.docker.internal)을 기본으로 올리고, 127.0.0.1 은 네이티브 실행용이라고
적었다
- 줄 끝 주석이 값이 된다: env_file 은 `KEY= # 설명` 을 빈 값으로 읽지 않는다. Azure 를 끈
로컬에서 is_configured() 가 참이 되어 발행 잡이 업로드를 시도하고 죽었다
(Connection string is either blank or malformed). 같은 모양 5개를 윗줄로 올리고,
파일 머리에 규칙을 근거와 함께 박았다
검증: web4ai_db 신규 생성 + init.sql → compose up → demo_build.py 발행 게이트 통과 ·
published:true · http://localhost/s/<slug> 200.
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).
- 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 인증이 실패한다.
옛 주소 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 네이티브 누락).
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.
- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
(admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다
★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).
pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
9b4fe40 이 개발 전용 자동 로그인을 지우면서 빌더 2단계가 막다른 길이 됐다.
로그인한 적도 없는 사람에게 "로그인이 만료되었습니다" 라고 쓰고, 그 화면에는
로그인으로 갈 링크가 없다. 실측(킹서버): 계정이 DB 에 0개라 아무도 통과 못 했다.
- lib/autoSession.ts: 옛 devSession 을 되살리되 `import.meta.env.DEV` 게이트를 뺐다.
운영 번들에서도 돌아야 한다 — 대신 VITE_AUTO_LOGIN_ID·PW 가 **둘 다** 있을 때만
움직이고 기본값은 없다. 진행 중인 로그인을 하나의 약속으로 공유하는 구조는 그대로
가져왔다: 훅 안에만 두면 로그인 전에 누른 검색이 토큰 없이 나가 '만료'로 떨어진다.
- usePlaceSearch: 검색 전에 그 약속을 기다린다(경합을 만료로 오인하던 자리).
- Dockerfile·compose: VITE_* 는 번들에 구워지므로 build args 다. 값을 바꾸면
`./deploy.sh solution-site` 로 다시 굽는다.
⚠️ 계정이 번들에 그대로 들어간다. 내부 테스트 호스트 전용이고, 사장님에게 열기 전에
AUTO_LOGIN_* 을 비우고 재빌드해야 한다.
tsc --noEmit · eslint 통과
킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.
- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다
설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다
배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설
정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시
검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
최초 커밋에서 CLAUDE.md 가 빠져 있었다. 레포 .gitignore 에서는 뺐지만
이 머신의 ~/.gitignore_global 5번 줄이 CLAUDE.md 를 전역으로 무시한다.
전역 설정은 사람마다 달라 레포가 의존할 수 없으므로 `!CLAUDE.md` 로
레포가 스스로 되살린다.
내용 자체는 AGENTS.md 로 이미 커밋돼 있었다 — 빠진 건 링크뿐이다.
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.
신설
README.md 레포 진입점 + 문서 지도 + 문서 규칙 4가지
AGENTS.md 에이전트·신규 합류자용 함정 목록과 규약
(CLAUDE.md 는 여기로 걸린 심볼릭 링크)
docs/PRODUCT.md 제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
docs/ARCHITECTURE.md payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계
이동
backend/docs/DECISIONS.md → docs/DECISIONS.md
백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
`docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.
갱신
docs/DEPLOY.md 서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
Azure 는 나중에 켤 때(4절)로 분리
docs/ARCHITECTURE.md 사이트 = 한 장(2026-08-31) 구조 반영
docs/COLLECTION_SEO_AEO_FLOW.md
robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
frontend/site/scripts/prerender.ts
헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정
.gitignore
★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
"배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.