구조: admin 을 backend/frontend 로 가른다

admin/ 이 프론트 파일만 널려 있는 폴더였다. :9801 을 띄우는 코드도 solution/backend 안에
얹혀 있어서, 폴더만 봐서는 admin 에 백엔드가 있다는 걸 몰랐다.

  admin/backend/   main.py · app.py   (:9801 진입점. 도메인 코드는 PYTHONPATH 로 solution/backend)
  admin/frontend/  운영 화면

패키지 이름도 폴더에 맞췄다: @o2o/front → @o2o/frontend.
이미지 빌드 컨텍스트를 레포 루트로 올렸다 — 진입점(admin/)과 도메인 코드(solution/)가
한 이미지에 들어와야 한다. 루트 .dockerignore 로 프론트·문서·시크릿을 잘라냈다.

★ 실측으로 잡은 것: 패키지명을 바꾸면서 compose 의 `-w @o2o/front` 를 안 고쳐
  web 컨테이너가 `No workspaces found` 로 재기동 루프에 빠져 있었다.

주석은 짧게 줄였다.

검증: lint·build 6개 전부 0. 컨테이너 5개 엔드포인트(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:22:29 +09:00
parent cabcdeaacc
commit 374a3a4a6e
24 changed files with 110 additions and 111 deletions

View File

@ -15,7 +15,7 @@ node_modules/
solution/frontend/
solution/site/
solution/shared/
admin/src/
admin/frontend/
docs/
nginx/
postgres-init/

View File

@ -1,23 +1,9 @@
"""내부 운영 API. 사장님 API(:9800)와 **프로세스와 포트가 갈린다.**
"""내부 운영 API. 사장님 API(:9800)와 프로세스·포트가 갈린다.
파일은 `admin/` 안에 있지만 도메인 코드는 `solution/backend` 것을 그대로 쓴다.
PYTHONPATH `solution/backend` 들어 있어서 `common.*` `router.*` `services.*`
그대로 import 된다(compose api-admin 서비스 · Dockerfile 참조).
복제하지 않는지는 아래 번째 .
라우터를 새로 쓰지 않고 같은 것을 다시 마운트하나
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 주석 참조). 예외를 곳이라도 두면 예외가 기본값이 된다.
도메인 코드는 solution/backend 것을 PYTHONPATH 쓴다 admin 전용 라우터가 0개라
(전부 place·fact) 새로 쓰면 같은 테이블을 구현하는 것뿐이다.
경로 접두어가 아니라 포트를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에
내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
"""
import time
@ -45,8 +31,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# ★ 스케줄러를 여기서 기동하지 않는다. 크론은 :9800 컨테이너 담당이고,
# 두 프로세스가 같이 돌면 같은 시각에 중복 실행된다.
# 크론은 :9800 담당. 여기서도 돌리면 같은 시각에 중복 실행된다.
yield
await DB_SESSION_MNG.dispose_all()
@ -89,11 +74,10 @@ async def healthz():
return API_SERVER_START_TIME
# ★ auth 만 게이트 밖이다 — 로그인 자체를 막으면 아무도 들어올 수 없다.
# (로그인은 되지만 role 이 낮으면 아래 라우터가 전부 403 이다.)
# auth 만 게이트 밖 — 로그인까지 막으면 아무도 못 들어온다.
app.include_router(router.v1.auth.account.router)
# 그 밖의 전부: 앱 단위 DEVELOPER 게이트.
# 나머지 전부 role >= DEVELOPER.
_gate = [Depends(RequireDeveloper)]
app.include_router(router.v1.place.place.router, dependencies=_gate)
app.include_router(router.v1.fact.fact.router, dependencies=_gate)

View File

@ -1,11 +1,5 @@
# 내부 운영 API 서버. 사장님 API(solution/backend/web_main.py, :9800)와
# **다른 프로세스·다른 포트**다.
#
# 내부 운영 API 서버 (:9801). 근거는 app.py 주석.
# PYTHONPATH=../../solution/backend python main.py
#
# ★ 코드는 나누지 않는다. services/crud/models 를 web_main 과 그대로 공유하고
# 진입점만 둘이다 — 도메인을 두 번 구현하지 않으면서 프로세스·네트워크만 가른다.
# 왜 그래야 하는지는 app.py 주석에 있다.
import os

View File

@ -0,0 +1,10 @@
/**
* . (:3000 vs :3002) react-router Link
* admin 404 . URL + .
*/
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {
if (params?.placeId) return `${ORIGIN}/builder?placeId=${encodeURIComponent(params.placeId)}`;
return `${ORIGIN}/builder?new=1`;
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["vite/client", "node"],
"paths": {
"@admin/*": ["./src/*"],
"@/*": ["../../solution/frontend/src/*"],
"@o2o/shared": ["../../solution/shared/src/index.ts"],
"@o2o/shared/*": ["../../solution/shared/src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,28 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
/**
* . (solution/frontend) .
*
* `@` src API·UI·
* `@/...` ( src TS2307 14).
* `@admin`. admin solution .
*/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@admin': path.resolve(__dirname, 'src'),
'@': path.resolve(__dirname, '../../solution/frontend/src'),
// css 별칭이 먼저 와야 한다 — 접두어가 먼저 걸리면 없는 경로로 떨어진다.
'@o2o/shared/tokens.css': path.resolve(__dirname, '../../solution/shared/src/styles/tokens.css'),
'@o2o/shared/base.css': path.resolve(__dirname, '../../solution/shared/src/styles/base.css'),
'@o2o/shared': path.resolve(__dirname, '../../solution/shared/src'),
},
},
server: {
watch: {usePolling: true, interval: 300},
},
});

View File

@ -1,14 +0,0 @@
/**
* (`solution/frontend`) .
*
* ** **(로컬 :3000 / 내부 :3002).
* react-router `<Link to="/builder">` admin 404 .
* URL .
* ( admin . admin .)
*/
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {
if (params?.placeId) return `${ORIGIN}/builder?placeId=${encodeURIComponent(params.placeId)}`;
return `${ORIGIN}/builder?new=1`;
}

View File

@ -1,13 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"types": ["vite/client", "node"],
"paths": {
"@admin/*": ["./src/*"],
"@/*": ["../solution/frontend/src/*"],
"@o2o/shared": ["../solution/shared/src/index.ts"],
"@o2o/shared/*": ["../solution/shared/src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View File

@ -1,36 +0,0 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
/**
* ( ). (`solution/frontend`) ** **
* . .
*
* `@` ** src** .
* API ·UI · ,
* `@/...` . `@` admin/src
* (실측: TS2307 14).
* RecollectPanel
* "수집 경로를 두 벌 만들면 확정 게이트" .
*
* `@admin` . admin solution ,
* .
*/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@admin': path.resolve(__dirname, 'src'),
'@': path.resolve(__dirname, '../solution/frontend/src'),
// css 는 더 구체적인 별칭이 먼저 와야 한다 — '@o2o/shared' 접두어가 먼저 걸리면
// 없는 경로(shared/src/tokens.css)로 떨어진다.
'@o2o/shared/tokens.css': path.resolve(__dirname, '../solution/shared/src/styles/tokens.css'),
'@o2o/shared/base.css': path.resolve(__dirname, '../solution/shared/src/styles/base.css'),
'@o2o/shared': path.resolve(__dirname, '../solution/shared/src'),
},
},
server: {
watch: {usePolling: true, interval: 300},
},
});

View File

@ -134,7 +134,7 @@ services:
[ -x node_modules/.bin/vite ] || npm install
node solution/site/scripts/serve-sites.mjs &
node solution/site/scripts/watch-payloads.mjs &
exec npm run dev -w @o2o/front
exec npm run dev -w @o2o/frontend
environment:
PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
@ -152,7 +152,7 @@ services:
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/node_modules
- /app/admin/frontend/node_modules
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
- site-out:/app/solution/site/out
ports:
@ -186,7 +186,7 @@ services:
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/node_modules
- /app/admin/frontend/node_modules
ports:
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- "${ADMIN_BIND:-127.0.0.1}:3002:3002"

41
package-lock.json generated
View File

@ -11,13 +11,46 @@
"solution/shared",
"solution/frontend",
"solution/site",
"admin"
"admin/frontend"
],
"engines": {
"node": ">=20"
}
},
"admin": {
"name": "@o2o/admin",
"version": "0.0.0",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@o2o/shared": "*",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-router": "^7.17.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
"zustand": "^5.0.2"
},
"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"
}
},
"admin/frontend": {
"name": "@o2o/admin",
"version": "0.0.0",
"dependencies": {
@ -1450,10 +1483,10 @@
}
},
"node_modules/@o2o/admin": {
"resolved": "admin",
"resolved": "admin/frontend",
"link": true
},
"node_modules/@o2o/front": {
"node_modules/@o2o/frontend": {
"resolved": "solution/frontend",
"link": true
},
@ -8750,7 +8783,7 @@
}
},
"solution/frontend": {
"name": "@o2o/front",
"name": "@o2o/frontend",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@ -7,21 +7,21 @@
"solution/shared",
"solution/frontend",
"solution/site",
"admin"
"admin/frontend"
],
"scripts": {
"dev": "npm run dev -w @o2o/front",
"dev:front": "npm run dev -w @o2o/front",
"dev": "npm run dev -w @o2o/frontend",
"dev:frontend": "npm run dev -w @o2o/frontend",
"dev:admin": "npm run dev -w @o2o/admin",
"dev:site": "npm run dev -w @o2o/site",
"build": "npm run build -w @o2o/front && npm run build -w @o2o/admin && npm run build -w @o2o/site",
"build:front": "npm run build -w @o2o/front",
"build": "npm run build -w @o2o/frontend && npm run build -w @o2o/admin && npm run build -w @o2o/site",
"build:frontend": "npm run build -w @o2o/frontend",
"build:admin": "npm run build -w @o2o/admin",
"build:site": "npm run build -w @o2o/site",
"prerender": "npm run prerender -w @o2o/site",
"lint": "npm run lint -w @o2o/front && npm run lint -w @o2o/admin && npm run lint -w @o2o/site",
"orval": "npm run orval -w @o2o/front",
"clean": "rm -rf solution/frontend/dist admin/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite"
"lint": "npm run lint -w @o2o/frontend && npm run lint -w @o2o/admin && npm run lint -w @o2o/site",
"orval": "npm run orval -w @o2o/frontend",
"clean": "rm -rf solution/frontend/dist admin/frontend/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite"
},
"engines": {
"node": ">=20"

View File

@ -1,5 +1,5 @@
{
"name": "@o2o/front",
"name": "@o2o/frontend",
"private": true,
"version": "0.0.0",
"type": "module",