o2o-site-AEO/docker-compose.yml
Mina Choi 238d4c25c4 [feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- 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 인증이 실패한다.
2026-09-03 16:37:12 +09:00

292 lines
13 KiB
YAML

# ★ 프로젝트 이름을 못 박는다 — 안 적으면 compose 가 디렉터리 이름을 쓰고, 폴더를 옮기는 순간
# 컨테이너와 볼륨이 통째로 새로 생긴다.
name: o2o-web4ai
# DB 는 compose 밖이다(호스트 PostgreSQL).
# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지
# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 우리 것이 아니다.
# 최초 1회 — 컨테이너 이름은 환경마다 다르다(로컬 negosium-db · 킹서버 king_postgres_container):
# docker exec -i -e PGPASSWORD=password <postgres 컨테이너> \
# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql
x-common-env: &common-env
APP_ENV: local
PYTHONUNBUFFERED: "1"
# 설정 파일은 없다 — 값은 전부 여기(=최상위 .env)서 온다. 비우면 config_models 의 기본값이다.
DB_HOST: ${DB_HOST:-host.docker.internal}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_NAME: ${DB_NAME:-web4ai_db}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
# ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
services:
solution-backend:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
container_name: o2o-web4ai-solution-backend
command: ["python", "web_main.py"]
env_file:
- .env
environment:
<<: *common-env
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
ports:
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
solution-worker:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
container_name: o2o-web4ai-solution-worker
command: ["python", "worker_main.py"]
env_file:
- .env
environment:
<<: *common-env
SCHEDULER_ENABLED: "0"
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
healthcheck:
disable: true
volumes:
- ./solution/site/payloads:/app/out/payloads
- site-out:/app/out/sites:ro
extra_hosts:
- "host.docker.internal:host-gateway"
stop_grace_period: 300s
depends_on:
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 어드민 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
# solution/backend 것을 PYTHONPATH 로 그대로 쓴다 — place·fact 를 두 번 구현하지 않으면서
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
admin-backend:
image: o2o-web4ai-backend
container_name: o2o-web4ai-admin-backend
# ★ 기본으로 뜨지 않는다. 어드민 화면을 안 쓰는 동안은 이 API 도 부를 사람이 없다.
# 켤 때: docker compose --profile admin up -d
profiles: ["admin"]
working_dir: /app/admin/backend
command: ["python", "main.py"]
env_file:
- .env
environment:
<<: *common-env
SCHEDULER_ENABLED: "0"
ADMIN_API_PORT: "9801"
# ★ 이미지의 HEALTHCHECK 는 :9800 을 찌른다(api 용). 이 컨테이너는 9801 이라
# 그대로 두면 멀쩡히 돌면서 영원히 unhealthy 다 — 포트만 바꿔 다시 건다.
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9801/healthz', timeout=4).status==200 else 1)"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
volumes:
- ./solution/site/payloads:/app/out/payloads
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 발행 사이트를 굽는다. **굽기만 한다** — 서빙은 solution-site(nginx)가 맡는다.
#
# ★ 사장님 앱 dev 서버는 여기서 빠졌다. 운영에 `vite dev` 를 띄우면 요청마다 트랜스파일하고
# 기동이 npm install 네트워크에 의존하고 /src 원본이 그대로 나간다. 번들은 solution-site
# 이미지가 굽는다(nginx/Dockerfile). 로컬에서 HMR 이 필요하면 `--profile dev`.
solution-prerender:
image: node:24-alpine
container_name: o2o-web4ai-solution-prerender
working_dir: /app
command:
- sh
- -c
- |
cd /app
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
[ -x node_modules/.bin/vite ] || npm install
exec node solution/site/scripts/watch-payloads.mjs
environment:
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
- ./tsconfig.base.json:/app/tsconfig.base.json
- ./solution:/app/solution
- ./admin:/app/admin
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/frontend/node_modules
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
- site-out:/app/solution/site/out
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
solution-frontend:
image: node:24-alpine
container_name: o2o-web4ai-solution-frontend
profiles: ["dev"]
working_dir: /app
command:
- sh
- -c
- |
cd /app
[ -x node_modules/.bin/vite ] || npm install
node solution/site/scripts/serve-sites.mjs &
exec npm run dev -w @o2o/frontend
environment:
PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
- ./tsconfig.base.json:/app/tsconfig.base.json
- ./solution:/app/solution
- ./admin:/app/admin
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/frontend/node_modules
- site-out:/app/solution/site/out
ports:
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-3000}:3000"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 앱(:3002). 사장님 번들과 갈라 두는 것이 이 서비스의 존재 이유다.
admin-frontend:
image: node:24-alpine
container_name: o2o-web4ai-admin-frontend
profiles: ["admin"]
working_dir: /app
command:
- sh
- -c
- |
cd /app
[ -x node_modules/.bin/vite ] || npm install
exec npm run dev -w @o2o/admin
environment:
# ★ 어드민 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
- ./tsconfig.base.json:/app/tsconfig.base.json
- ./solution:/app/solution
- ./admin:/app/admin
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/frontend/node_modules
ports:
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- "${ADMIN_BIND:-127.0.0.1}:${ADMIN_PORT:-3002}:3002"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 공개 진입점 하나. 사장님 앱(구운 번들) · 발행 사이트(볼륨) · API 프록시를 한 오리진으로 준다.
solution-site:
build:
context: .
dockerfile: nginx/Dockerfile
args:
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
image: o2o-web4ai-solution-site
container_name: o2o-web4ai-solution-site
volumes:
- site-out:/srv/sites:ro
# ★ 클론 직후: cp nginx/site.conf.example nginx/site.conf
# 파일이 없으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
ports:
# ★ :80 이 비어 있다는 보장이 없다(킹서버는 호스트 nginx 가 물고 있다).
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
depends_on:
- solution-prerender
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
volumes:
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
site-out: