백엔드: 내부 운영 API 를 :9801 진입점으로 가른다

admin 이 사장님 API(:9800)를 그대로 보고 있었다. 화면만 갈라 두면 내부 요청이
사장님이 닿는 서버로 나가고, 권한도 엔드포인트마다 흩어진 채로 남는다.

## 코드는 한 벌, 진입점만 둘

  web_main.py   → router/router.py         :9800  사장님
  admin_main.py → router/admin_router.py   :9801  내부

services·crud·models 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다:
admin 화면이 부르는 훅(useGetPlace·useListPlaces·useListLinks·useConfirmLink·
useListFacts·useGetSchema·useTransitionFact)이 전부 place·fact 라우터이고,
그 둘은 사장님 빌더도 쓴다. 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌
구현하는 것뿐이라, 같은 router 객체를 다시 마운트하고 앱 단위로 권한만 덧걸었다.

## 왜 경로 접두어가 아니라 포트인가

/v1/admin/... 는 같은 프로세스 안이라 **사장님이 닿는 서버에 내부 엔드포인트가 존재한다.**
포트를 가르면 사장님이 닿는 네트워크에 아예 없다. compose 에서 이 포트는 127.0.0.1
에만 연다(ADMIN_API_BIND) — 0.0.0.0 으로 열면 가른 의미가 없다.

## 권한

RequireDeveloper 를 앱 단위로 건다. auth 라우터만 게이트 밖이다 —
로그인 자체를 막으면 아무도 들어올 수 없다.

  검증 /v1/place/list :  USER(1) 403 · OWNER(2) 403 · DEVELOPER(3) 200

OWNER 가 막히는 게 핵심이다. 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.

## 그 밖

- 이미지의 HEALTHCHECK 는 :9800 을 찌른다. 그대로 두면 이 컨테이너가 멀쩡히 돌면서
  영원히 unhealthy 라, 포트만 바꿔 다시 걸었다.
- compose 주석에 negosium-db 가 나오는 이유를 적었다 — 베낀 흔적이 아니라 DB 인스턴스를
  따로 안 띄우고 그 postgres 안에 web4ai_db 만 만들어 쓰기 때문이다(DECISIONS.md 3절).
  줄이면서 이유를 날려 읽는 사람이 오해하게 만들었다.

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 15:58:50 +09:00
parent c85c577349
commit c6b45fdda4
8 changed files with 228 additions and 68 deletions

View File

@ -61,24 +61,38 @@
## 레포 구조
```
solution/ 사장님 — backend(FastAPI+워커) · front(빌더) · site(발행물) · shared(계약)
admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — 전체 사이트 운영. 프론트만. API 는 아래 :9801 을 본다
```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
**의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 이 수집 배선·UI 를 재수출로 가져다 쓴다
(`admin/src/**` 의 얇은 파일들). 반대 방향이 생기면 번들을 가른 의미가 사라진다.
**의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src`
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 사장님 API | 9800 | `web_main.py``router/router.py` | 엔드포인트별 |
| 내부 API | 9801 | `admin_main.py``router/admin_router.py` | **앱 전체 role >= DEVELOPER** |
`services`·`crud`·`models` 은 그대로 공유한다. admin 전용 라우터가 0개라(세어봤다:
admin 화면이 부르는 건 전부 place·fact 다) 도메인을 복제하지 않고 **같은 router 객체를
다시 마운트하면서 앱 단위로 권한만 덧건다.**
경로 접두어(`/v1/admin/...`)가 아니라 **포트**를 가른 이유: 접두어는 같은 프로세스라
사장님이 닿는 서버에 내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
`ADMIN_API_BIND` 기본값이 `127.0.0.1` 인 것도 같은 이유다 — 0.0.0.0 으로 열면 무의미하다.
## 실행
```bash
docker compose up -d # API :9800 · 워커 · web :3000 · admin :3002 · nginx :80
docker compose up -d # api :9800 · api-admin :9801 · 워커 · web :3000 · admin :3002 · nginx :80
docker compose logs -f worker
```
- 사장님 앱: `http://localhost:3000` · 내부 운영: `http://localhost:3002`
- 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림)
- 발행 사이트: `http://localhost:3000/s/<slug>` (front Vite 가 :3001 정적서버로 프록시)
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
@ -98,7 +112,7 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래
| 파일 | 담는 것 |
|---|---|
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` |
| `solution/frontend/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*` |
| `solution/frontend/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 |
**두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST`
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.

View File

@ -31,11 +31,11 @@ DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
```
solution/ 사장님 — 사이트 만들기·관리
backend/ FastAPI + 워커. HTML 을 만들지 않는다 — payload JSON 만 떨어뜨린다
front/ 빌더(위저드 + 에디터 + 발행 게이트)
backend/ FastAPI + 워커. 진입점 둘 — web_main(:9800 사장님) / admin_main(:9801 내부)
frontend/ 빌더(위저드 + 에디터 + 발행 게이트)
site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버
shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug)
admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
admin/ 우리 — 전체 사이트 운영 (프론트). API 는 solution/backend 의 :9801 진입점
docs/ 아래 표
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다)
postgres-init/ 스키마 DDL (init.sql 한 벌)

View File

@ -1,4 +1,4 @@
# 내부 운영 앱. Vite 는 .env 를 **자기 디렉토리에서만** 읽으므로 여기 둔다.
# ★ 사장님 앱과 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — 루트 .env 가 단일 출처이고
# compose 가 주입한다. 두 곳에 적으면 언젠가 갈라진다.
VITE_API_BASE_URL=http://localhost:9800
VITE_API_BASE_URL=http://localhost:9801

View File

@ -2,7 +2,11 @@
# 컨테이너와 볼륨이 통째로 새로 생긴다.
name: o2o-web4ai
# DB 는 compose 밖이다(호스트 PostgreSQL). 최초 1회:
# DB 는 compose 밖이다(호스트 PostgreSQL).
# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지
# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 negosium-db 다.
# 최초 1회:
# docker exec -i -e PGPASSWORD=password negosium-db \
# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql
@ -79,6 +83,41 @@ services:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 API(:9801). 코드는 api 와 같은 이미지·같은 소스이고 진입점만 다르다
# (admin_main.py → router/admin_router.py). place·fact 를 두 번 구현하지 않으면서
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
api-admin:
image: o2o-web4ai-backend
container_name: o2o-web4ai-api-admin
command: ["python", "admin_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}:9801:9801"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시)
web:
image: node:24-alpine
@ -135,7 +174,8 @@ services:
[ -x node_modules/.bin/vite ] || npm install
exec npm run dev -w @o2o/admin
environment:
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9800}
# ★ 내부 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json

View File

@ -99,7 +99,7 @@ o2o-web4ai/
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰)
├─ admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
├─ admin/ 우리 — 전체 사이트 운영 (프론트). API 는 :9801
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
```
@ -122,14 +122,40 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### admin 에 백엔드를 두지 않은 이유
### 백엔드 — 코드 한 벌, 진입점 둘
내부 화면이 부르는 것이 전부 지금 백엔드에 이미 있다 — `useGetPlace` `useListPlaces`
`useListFacts` `useListLinks` `useGetSchema` `useConfirmLink` `useTransitionFact`
`router/v1/{place, fact, local, validator}`. 새로 만들 게 없고, 자체 백엔드를 두면
`place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다.
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 사장님 API | 9800 | `web_main.py``router/router.py` | 엔드포인트별 |
| 내부 API | **9801** | `admin_main.py``router/admin_router.py` | **앱 전체 `role >= DEVELOPER`** |
→ 대가: `solution/backend` 가 죽으면 admin 도 멈춘다. **내부 도구라 감수한다.**
`services`·`crud`·`models` 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다:
```
useGetPlace · useListPlaces · useListLinks · useConfirmLink → router/v1/place
useListFacts · useGetSchema · useTransitionFact → router/v1/fact
```
admin 화면이 부르는 게 전부 `place`·`fact` 이고, 그 둘은 사장님 빌더도 쓴다. 자체 백엔드에
엔드포인트를 새로 쓰면 **같은 DB 의 같은 테이블을 두 벌** 구현하는 것뿐이다.
그래서 같은 router 객체를 다시 마운트하고 **앱 단위로 권한만 덧건다.**
**왜 경로 접두어(`/v1/admin/...`)가 아니라 포트인가.** 접두어는 같은 프로세스 안이라
사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.** 포트를 가르면 사장님이 닿는
네트워크에 아예 없다 — 가드보다 강하다. compose 의 `ADMIN_API_BIND` 기본값이
`127.0.0.1` 인 것도 같은 이유다. **0.0.0.0 으로 열면 가른 의미가 없다.**
검증(role 별 `/v1/place/list`):
```
USER role=1 → 403 DEVELOPER role=3 → 200
OWNER role=2 → 403
```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
→ 대가: `solution/backend` 의 코드에 묶인다. 배포는 갈리지만 소스는 한 벌이다.
### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다
@ -151,15 +177,8 @@ admin 자기 파일만 `@admin` 이다.
정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로 갈려 있어 **나중에 진짜 나눠야 할 때
그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다.
남은 일은 **라우터 표면을 청중별로 가르는 것**뿐이다:
```
/api/v1/... 사장님 (solution/frontend) — 자기 리소스만
/api/v1/admin/... 내부 (admin) — 라우터 레벨에서 role >= DEVELOPER 강제
```
엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.**
`common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니 라우터 의존성으로 올린다.
청중별로 가르는 일은 **포트로 끝냈다**(위 표). 엔드포인트마다 `if role >= ...`
흩뿌리지 않고 `RequireDeveloper` 의존성 하나를 앱에 건다.
⚠️ 그 함수 이름의 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다. 최상단 폴더
`admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때
@ -175,7 +194,6 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것
- `/api/v1/admin/*` 라우터 분리 + role 의존성 (위)
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
그때 `solution/frontend` 의 인증 정책을 다시 본다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을

38
package-lock.json generated
View File

@ -8749,44 +8749,6 @@
}
}
},
"shared": {
"name": "@o2o/shared",
"version": "0.0.0",
"extraneous": true,
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.6.0"
}
},
"site": {
"name": "@o2o/site",
"version": "0.0.0",
"extraneous": true,
"dependencies": {
"@o2o/shared": "*",
"@tailwindcss/vite": "^4.1.14",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-router": "^7.17.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^7.1.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"typescript-eslint": "^8.45.0",
"vite": "^6.2.3",
"vitest": "^4.1.11"
}
},
"solution/frontend": {
"name": "@o2o/front",
"version": "0.0.0",

View File

@ -0,0 +1,29 @@
# 내부 운영 API 서버. 사장님 API(web_main.py, :9800)와 **다른 프로세스·다른 포트**다.
#
# python admin_main.py
#
# ★ 코드는 나누지 않는다. services/crud/models 를 web_main 과 그대로 공유하고
# 진입점만 둘이다 — 도메인을 두 번 구현하지 않으면서 프로세스·네트워크만 가른다.
# 왜 그래야 하는지는 router/admin_router.py 주석에 있다.
import os
import uvicorn
from common.logger import LOG
from config.server_configs import web_server_config
LOG.SetPrefix(f"{web_server_config.server_name}-admin")
import router.admin_router
ADMIN_PORT = int(os.environ.get("ADMIN_API_PORT", "9801"))
if __name__ == "__main__":
LOG.i(f"Admin API Port : {ADMIN_PORT}")
run_kwargs = dict(host="0.0.0.0", port=ADMIN_PORT, access_log=False)
if os.environ.get("RELOAD") == "1":
run_kwargs["reload"] = True
else:
run_kwargs["workers"] = 1
uvicorn.run("router.admin_router:app", **run_kwargs)

View File

@ -0,0 +1,97 @@
"""내부 운영 API (admin 앱 전용). 사장님 API(:9800)와 **프로세스와 포트가 갈린다.**
라우터를 새로 쓰지 않고 같은 것을 다시 마운트하나
admin 화면이 부르는 API 전부 place·fact 라우터에 이미 있다(세어봤다: admin 전용
라우터는 0개다). 여기서 엔드포인트를 새로 쓰면 같은 DB 같은 테이블을
구현하게 된다. 그래서 **같은 router 객체를 그대로 include 하고, 단위로 권한만 덧건다.**
경로 접두어(/v1/admin/...) 아니라 포트를 가르나
접두어는 같은 프로세스 안에 있다 사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.**
포트를 가르면 내부 API 사장님이 닿는 네트워크에 아예 없다. 가드보다 강하다.
(compose 에서 포트는 127.0.0.1 에만 연다. 0.0.0.0 으로 열면 가른 의미가 없다.)
여기 붙는 모든 엔드포인트는 role >= DEVELOPER .
OWNER 자기 회사 최상위일 남의 회사를 권한이 아니라서 막힌다
(RequireDeveloper 주석 참조). 예외를 곳이라도 두면 예외가 기본값이 된다.
"""
import time
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from common.utils.gtime import GTime
from config.server_configs import web_server_config
from router.v1.validator.dependencies import RequireDeveloper
import router.v1.auth.account
import router.v1.fact.fact
import router.v1.job.job
import router.v1.local.local
import router.v1.place.place
import router.v1.site.site
from contextlib import asynccontextmanager
API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# ★ 스케줄러를 여기서 기동하지 않는다. 크론은 :9800 컨테이너 담당이고,
# 두 프로세스가 같이 돌면 같은 시각에 중복 실행된다.
yield
await DB_SESSION_MNG.dispose_all()
app = FastAPI(title="Web4Ai Admin API", lifespan=lifespan)
def _origins(*values: str) -> list[str]:
seen: list[str] = []
for value in values:
for origin in value.split(","):
origin = origin.strip().rstrip("/")
if origin and origin not in seen:
seen.append(origin)
return seen
ALLOWED_ORIGINS = _origins(web_server_config.client_url)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
@app.middleware("http")
async def log_time(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
LOG.d(f"[admin] {response.status_code} {request.method} {request.url.path} - {time.time() - start_time:.4f}s")
return response
@app.get(path="/healthz")
async def healthz():
return API_SERVER_START_TIME
# ★ auth 만 게이트 밖이다 — 로그인 자체를 막으면 아무도 들어올 수 없다.
# (로그인은 되지만 role 이 낮으면 아래 라우터가 전부 403 이다.)
app.include_router(router.v1.auth.account.router)
# 그 밖의 전부: 앱 단위 DEVELOPER 게이트.
_gate = [Depends(RequireDeveloper)]
app.include_router(router.v1.place.place.router, dependencies=_gate)
app.include_router(router.v1.fact.fact.router, dependencies=_gate)
app.include_router(router.v1.job.job.router, dependencies=_gate)
app.include_router(router.v1.site.site.router, dependencies=_gate)
app.include_router(router.v1.local.local.router, dependencies=_gate)