구조: 내부 API 진입점을 admin/backend 로 옮긴다

admin/ 이 프론트만 있는 폴더였다. :9801 을 띄우는 코드는 solution/backend 안에
admin_main.py 로 얹혀 있었는데, 그러면 폴더만 봐서는 admin 에 백엔드가 있다는 걸 모른다.

  solution/backend/admin_main.py         → admin/backend/main.py
  solution/backend/router/admin_router.py → admin/backend/app.py

도메인 코드는 여전히 복제하지 않는다 — `PYTHONPATH=/app/solution/backend` 한 줄이
두 폴더를 잇는다. admin/backend 에 있는 건 진입점 두 파일뿐이다.

## 이미지 빌드 컨텍스트를 레포 루트로 올렸다

진입점이 admin/ 에, 도메인 코드가 solution/ 에 있어서 한 이미지에 둘 다 들어와야 한다.
나누면 requirements 를 두 번 설치하게 된다. 컨텍스트가 넓어진 만큼 루트 .dockerignore 로
프론트·문서·테스트·시크릿을 잘라냈다(옛 solution/backend/.dockerignore 대체).

이미지 배치:
  /app/solution/backend   ← 도메인 코드. api·worker 의 working_dir
  /app/admin/backend      ← 내부 API 진입점. api-admin 의 working_dir

검증: api·api-admin 둘 다 healthy, 다섯 엔드포인트(9800·9801·3000·3002·80) 전부 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
This commit is contained in:
Mina Choi 2026-08-31 16:14:19 +09:00
parent c6b45fdda4
commit cabcdeaacc
9 changed files with 84 additions and 59 deletions

31
.dockerignore Normal file
View File

@ -0,0 +1,31 @@
# ★ 빌드 컨텍스트가 레포 루트다(solution/backend/Dockerfile 주석 참조).
# 컨텍스트가 넓어진 만큼 여기서 확실히 잘라내야 이미지가 붓지 않는다.
**/__pycache__/
*.pyc
**/.pytest_cache/
.git/
.venv/
**/.venv/
node_modules/
**/node_modules/
**/dist/
**/.vite/
# 프론트·문서는 백엔드 이미지에 들어갈 이유가 없다.
solution/frontend/
solution/site/
solution/shared/
admin/src/
docs/
nginx/
postgres-init/
**/*.md
solution/backend/tests/
solution/backend/loadtest/
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다.
**/config.local.toml
**/config.test.toml
.env
.env.*
!.env.example

View File

@ -62,7 +62,7 @@
``` ```
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약) solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — 전체 사이트 운영. 프론트만. API 는 아래 :9801 을 본다 admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
``` ```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단 최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
@ -75,8 +75,8 @@ admin/ 우리 — 전체 사이트 운영. 프론트만. API 는 아래 :9
| | 포트 | 진입점 | 권한 | | | 포트 | 진입점 | 권한 |
|---|---|---|---| |---|---|---|---|
| 사장님 API | 9800 | `web_main.py` → `router/router.py` | 엔드포인트별 | | 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 내부 API | 9801 | `admin_main.py` → `router/admin_router.py` | **앱 전체 role >= DEVELOPER** | | 내부 API | 9801 | `admin/backend/main.py` → `app.py` | **앱 전체 role >= DEVELOPER** |
`services`·`crud`·`models` 은 그대로 공유한다. admin 전용 라우터가 0개라(세어봤다: `services`·`crud`·`models` 은 그대로 공유한다. admin 전용 라우터가 0개라(세어봤다:
admin 화면이 부르는 건 전부 place·fact 다) 도메인을 복제하지 않고 **같은 router 객체를 admin 화면이 부르는 건 전부 place·fact 다) 도메인을 복제하지 않고 **같은 router 객체를

View File

@ -35,7 +35,9 @@ solution/ 사장님 — 사이트 만들기·관리
frontend/ 빌더(위저드 + 에디터 + 발행 게이트) frontend/ 빌더(위저드 + 에디터 + 발행 게이트)
site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버 site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버
shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug) shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug)
admin/ 우리 — 전체 사이트 운영 (프론트). API 는 solution/backend 의 :9801 진입점 admin/ 우리 — 전체 사이트 운영
backend/ 내부 API 진입점(:9801). 도메인 코드는 solution/backend 를 PYTHONPATH 로 쓴다
frontend/ 내부 운영 화면
docs/ 아래 표 docs/ 아래 표
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다) nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다)
postgres-init/ 스키마 DDL (init.sql 한 벌) postgres-init/ 스키마 DDL (init.sql 한 벌)

View File

@ -1,4 +1,9 @@
"""내부 운영 API (admin 앱 전용). 사장님 API(:9800)와 **프로세스와 포트가 갈린다.** """내부 운영 API. 사장님 API(:9800)와 **프로세스와 포트가 갈린다.**
파일은 `admin/` 안에 있지만 도메인 코드는 `solution/backend` 것을 그대로 쓴다.
PYTHONPATH `solution/backend` 들어 있어서 `common.*` `router.*` `services.*`
그대로 import 된다(compose api-admin 서비스 · Dockerfile 참조).
복제하지 않는지는 아래 번째 .
라우터를 새로 쓰지 않고 같은 것을 다시 마운트하나 라우터를 새로 쓰지 않고 같은 것을 다시 마운트하나
admin 화면이 부르는 API 전부 place·fact 라우터에 이미 있다(세어봤다: admin 전용 admin 화면이 부르는 API 전부 place·fact 라우터에 이미 있다(세어봤다: admin 전용

View File

@ -1,10 +1,11 @@
# 내부 운영 API 서버. 사장님 API(web_main.py, :9800)와 **다른 프로세스·다른 포트**다. # 내부 운영 API 서버. 사장님 API(solution/backend/web_main.py, :9800)와
# **다른 프로세스·다른 포트**다.
# #
# python admin_main.py # PYTHONPATH=../../solution/backend python main.py
# #
# ★ 코드는 나누지 않는다. services/crud/models 를 web_main 과 그대로 공유하고 # ★ 코드는 나누지 않는다. services/crud/models 를 web_main 과 그대로 공유하고
# 진입점만 둘이다 — 도메인을 두 번 구현하지 않으면서 프로세스·네트워크만 가른다. # 진입점만 둘이다 — 도메인을 두 번 구현하지 않으면서 프로세스·네트워크만 가른다.
# 왜 그래야 하는지는 router/admin_router.py 주석에 있다. # 왜 그래야 하는지는 app.py 주석에 있다.
import os import os
@ -15,7 +16,7 @@ from config.server_configs import web_server_config
LOG.SetPrefix(f"{web_server_config.server_name}-admin") LOG.SetPrefix(f"{web_server_config.server_name}-admin")
import router.admin_router import app # noqa: F401 (import 시점에 FastAPI app · DB 세션 매니저가 선다)
ADMIN_PORT = int(os.environ.get("ADMIN_API_PORT", "9801")) ADMIN_PORT = int(os.environ.get("ADMIN_API_PORT", "9801"))
@ -26,4 +27,4 @@ if __name__ == "__main__":
run_kwargs["reload"] = True run_kwargs["reload"] = True
else: else:
run_kwargs["workers"] = 1 run_kwargs["workers"] = 1
uvicorn.run("router.admin_router:app", **run_kwargs) uvicorn.run("app:app", **run_kwargs)

View File

@ -31,8 +31,8 @@ x-common-env: &common-env
services: services:
api: api:
build: build:
context: ./solution/backend context: .
dockerfile: Dockerfile dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend image: o2o-web4ai-backend
container_name: o2o-web4ai-api container_name: o2o-web4ai-api
command: ["python", "web_main.py"] command: ["python", "web_main.py"]
@ -55,8 +55,8 @@ services:
worker: worker:
build: build:
context: ./solution/backend context: .
dockerfile: Dockerfile dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend image: o2o-web4ai-backend
command: ["python", "worker_main.py"] command: ["python", "worker_main.py"]
env_file: env_file:
@ -83,13 +83,14 @@ services:
driver: json-file driver: json-file
options: { max-size: "10m", max-file: "5" } options: { max-size: "10m", max-file: "5" }
# 내부 운영 API(:9801). 코드는 api 와 같은 이미지·같은 소스이고 진입점만 다르다 # 내부 운영 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
# (admin_main.py → router/admin_router.py). place·fact 를 두 번 구현하지 않으면서 # solution/backend 것을 PYTHONPATH 로 그대로 쓴다 — place·fact 를 두 번 구현하지 않으면서
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다. # 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
api-admin: api-admin:
image: o2o-web4ai-backend image: o2o-web4ai-backend
container_name: o2o-web4ai-api-admin container_name: o2o-web4ai-api-admin
command: ["python", "admin_main.py"] working_dir: /app/admin/backend
command: ["python", "main.py"]
env_file: env_file:
- .env - .env
environment: environment:

View File

@ -99,7 +99,9 @@ o2o-web4ai/
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰) │ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰)
├─ admin/ 우리 — 전체 사이트 운영 (프론트). API 는 :9801 ├─ admin/ 우리 — 전체 사이트 운영
│ ├─ backend/ 내부 API 진입점(:9801). 도메인 코드는 solution/backend
│ └─ frontend/ 내부 운영 화면
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트) ├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
``` ```
@ -126,10 +128,11 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
| | 포트 | 진입점 | 권한 | | | 포트 | 진입점 | 권한 |
|---|---|---|---| |---|---|---|---|
| 사장님 API | 9800 | `web_main.py` → `router/router.py` | 엔드포인트별 | | 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 내부 API | **9801** | `admin_main.py` → `router/admin_router.py` | **앱 전체 `role >= DEVELOPER`** | | 내부 API | **9801** | `admin/backend/main.py` → `app.py` | **앱 전체 `role >= DEVELOPER`** |
`services`·`crud`·`models` 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다: `services`·`crud`·`models` 은 공유한다 — `admin/backend` 는 진입점 두 파일뿐이고,
도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다:
``` ```
useGetPlace · useListPlaces · useListLinks · useConfirmLink → router/v1/place useGetPlace · useListPlaces · useListLinks · useConfirmLink → router/v1/place

View File

@ -1,23 +0,0 @@
__pycache__/
# `__pycache__/` 는 최상위만 매칭한다 — 하위 디렉터리(config/, common/ …)까지 빼려면 ** 가 필요하다.
# (원본 보일러플레이트엔 이 줄이 없어 config/__pycache__ 가 이미지에 들어가 있었다.)
**/__pycache__/
*.pyc
.pytest_cache/
.git/
tests/
loadtest/
*.md
# 가상환경 — 호스트 전용(수백 MB). 컨테이너는 requirements.txt 로 새로 설치한다.
.venv/
venv/
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고,
# 실값은 compose env 로 주입한다(server_configs 의 env override).
config/config.local.toml
# 테스트 설정 — 컨테이너에서 pytest 를 돌리지 않는다(테스트는 호스트 venv 에서).
config/config.test.toml
# 레포 최상위 .env 는 빌드 컨텍스트(./backend) 밖이라 애초에 안 들어오지만, 방어적으로 막아둔다.
.env
.env.*

View File

@ -1,35 +1,40 @@
# o2o-web4ai 백엔드 이미지 — API 와 워커가 **같은 이미지**를 공유하고 command 로만 갈린다. # o2o-web4ai 백엔드 이미지 — 세 프로세스가 **같은 이미지**를 공유하고 command 로만 갈린다.
# API : python web_main.py (요청 접수/조회, :9800) # 사장님 API : python web_main.py (:9800)
# 워커 : python worker_main.py (수집·비전분석·빌드 잡 처리, 포트 없음) # 내부 API : python main.py (:9801, working_dir=/app/admin/backend)
# 워커 : python worker_main.py (수집·비전분석·빌드 잡, 포트 없음)
# #
# Phase 1 은 collector 가 MockAdapter 만 등록하므로 브라우저가 필요 없다 → 이 lean 이미지 하나면 된다. # ★ 빌드 컨텍스트가 **레포 루트**다(./solution/backend 가 아니다).
# 크롤링 법무 검토가 끝나 HeadlessAdapter 를 붙이면, 그때 Chrome+Xvfb 를 담은 별도 워커 이미지 # 내부 API 진입점이 admin/backend 에 있고 도메인 코드는 solution/backend 에 있어서,
# (Dockerfile.worker)를 만들어 갈라낸다 — 선례: o2o-negosium/lps/Dockerfile.worker # 한 이미지에 둘 다 들어와야 한다. 나누면 requirements 를 두 번 설치하게 된다.
# #
# 시크릿은 이미지에 굽지 않는다 — config 는 example(플레이스홀더)로 대체되고, # 시크릿은 이미지에 굽지 않는다 — config 는 example(플레이스홀더)로 대체되고,
# 실제 값은 compose 의 env(DB_USER/DB_PASSWORD, *_API_KEY 등)로 주입된다(server_configs override). # 실제 값은 compose 의 env 로 주입된다(server_configs override).
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# 의존성 먼저 설치 (레이어 캐시 활용) # 의존성 먼저 설치 (레이어 캐시 활용)
COPY requirements.txt . COPY solution/backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY solution/backend ./solution/backend
COPY admin/backend ./admin/backend
# 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체. # 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체.
# 실값은 env 주입: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME, RUN cp solution/backend/config/config.local.toml.example solution/backend/config/config.local.toml
# PERPLEXITY_API_KEY/KAKAO_REST_API_KEY/GEMINI_API_KEY/TOUR_API_KEY
RUN cp config/config.local.toml.example config/config.local.toml
# 항상 APP_ENV=local 로 실행 → config.local.toml(=example 사본) + env override.
ENV APP_ENV=local ENV APP_ENV=local
# ★ 내부 API(admin/backend)가 common·router·services 를 그대로 import 하는 경로.
# 도메인 코드를 복제하지 않는 대신 이 한 줄이 두 폴더를 잇는다.
ENV PYTHONPATH=/app/solution/backend
# 기본은 사장님 API. 내부 API·워커는 compose 가 working_dir·command 를 갈아끼운다.
WORKDIR /app/solution/backend
EXPOSE 9800 EXPOSE 9800
# API 컨테이너용. 워커는 command 를 갈아끼우고 이 헬스체크를 쓰지 않는다 # 사장님 API 컨테이너용. 워커는 포트가 없어 이 체크를 끄고, 내부 API 는 :9801 로 다시 건
# (워커는 포트가 없다 — 하트비트 파일 기반 체크는 필요해질 때 추가한다). # (둘 다 compose 에서 override).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9800/healthz', timeout=4).status==200 else 1)" CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9800/healthz', timeout=4).status==200 else 1)"