refactor: 프론트·API 를 같은 도메인(/api)으로 통합

브라우저가 프론트와 API 를 서로 다른 도메인으로 호출하던 구조를 같은 도메인의 /api 경로로 통합한다.
운영은 Nginx Proxy Manager 의 Proxy Host(/ → frontend:8080)와 Custom location(/api → backend:3000)이,
개발은 vite dev 프록시가 같은 규칙으로 백엔드에 넘긴다.

- 같은 출처 호출이라 CORS 설정이 필요 없고 CSP connect-src 'self' 가 그대로 동작한다
  (기존 예시대로 별도 도메인에 배포하면 CSP 에 막혀 API 호출이 실패했다)
- 프론트 번들의 baseURL 이 /api 상대경로라 도메인이 바뀌어도 다시 빌드할 필요가 없다
- NPM 설정 절차와 주의점(Cache Assets 끄기, proxy 네트워크 생성)을 README "운영 배포"에 정리했다
- 프록시 설정이 빠지면 frontend nginx 가 /api 를 404 로 막는다 — index.html(200)로 조용히 실패하지 않게

함께 고친 CORS 문제:
- CORS_ORIGIN 에 콤마로 여러 오리진을 적으면 문자열 전체를 하나로 비교해 모두 막히던 버그
- 운영에서도 localhost:5173·localhost:3000 이 credentials 포함으로 항상 허용되던 문제
- 형식이 잘못된 오리진(경로 포함 등)은 기동 시점에 실패하도록 검증 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:04:03 +09:00
parent 2f94b6db12
commit 6dcbb45eb9
26 changed files with 456 additions and 90 deletions
+4
View File
@@ -5,6 +5,10 @@
## 경로 · 메서드
- 모든 라우트는 글로벌 prefix `/api` 아래에 놓인다 (`main.ts`). 클라이언트 baseURL 도 `/api` 를 포함한다.
- 웹은 프론트와 **같은 도메인**의 `/api` 상대경로로 호출한다. 리버스 프록시(운영: Nginx Proxy Manager 의
Custom location, 개발: vite dev 프록시)가 `/api` 로 **시작하는** 요청을 경로 그대로 백엔드에 넘긴다.
- 그래서 **`/api` 로 시작하는 경로는 프론트엔드 라우트로 쓰지 않는다** (`/api-keys` 같은 화면도 백엔드로 간다).
- 모바일 앱은 같은 주소를 절대 URL 로 쓴다 (`https://<도메인>/api`).
- 리소스는 **복수형 명사**, 소문자 kebab-case: `/users`, `/order-items`
- 동사를 경로에 넣지 않는다. 상태 변경이 필요하면 하위 리소스로 표현한다.
- ❌ `POST /users/1/doDeactivate`
+4 -1
View File
@@ -8,7 +8,10 @@
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
`BACKEND_API_URL=/api`·`CORS_ORIGIN` 비움은 그대로 둔다 — 프론트와 API 는 같은 도메인을 쓴다.
API 를 별도 도메인으로 분리할 때만 실제 도메인을 적는다 (`localhost`/`example.com` 이 남으면 기동 실패)
- [ ] 운영 서버: NPM 과 공유하는 외부 네트워크 생성 `docker network create proxy` (없으면 compose 가 기동하지 않는다)
→ NPM 에 Proxy Host(`/` → frontend:8080) + Custom location(`/api` → backend:3000) 설정, **Cache Assets 끔** (README "운영 배포")
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
+4 -1
View File
@@ -13,7 +13,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
│ (docker compose --env-file 로 로드)
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
├─▶ frontend : BACKEND_API_URL → build arg(VITE_API_BASE_URL) 로 주입
├─▶ frontend : BACKEND_API_URL(기본 /api — 같은 도메인) → build arg(VITE_API_BASE_URL) 로 주입
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
@@ -37,10 +37,13 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- `CORS_ORIGIN` 은 각 항목이 오리진 형식(`scheme://host[:port]`, 경로 없음)이어야 한다 (모든 환경)
— 경로가 붙으면 브라우저 Origin 헤더와 절대 일치하지 않아 조용히 막힌다
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
같은 도메인 기본 구성(`/api`·CORS 비움)은 걸릴 값이 없고, API 를 별도 도메인으로 분리했을 때의 실수를 잡는다.
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
(`BACKEND_API_URL` 은 앱이 쓰지 않지만 이 검사를 위해 compose 가 backend 에 주입한다)
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
+4 -1
View File
@@ -45,6 +45,9 @@
- 보안 헤더(`helmet`) 적용
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
- 프론트와 API 가 **같은 도메인**이 기본이라 화이트리스트는 비어 있다 (같은 출처 요청에는 CORS 가 적용되지 않는다).
다른 오리진이 필요할 때만 루트 `CORS_ORIGIN` 에 콤마로 나열한다 (`shared/config/cors.config.ts`)
- 개발용 `localhost` 를 코드에 기본 허용으로 박지 않는다 — 운영에서도 그대로 열린다
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
@@ -65,7 +68,7 @@
```
- **보안 헤더는 CSP 까지** 설정한다 (`frontend/nginx.conf`). 외부 CDN·폰트·분석 스크립트를 추가하면
해당 도메인을 CSP 에 명시한다. `script-src` 에 `'unsafe-inline'`/`'unsafe-eval'` 을 넣으면 CSP 의 의미가 사라진다.
API 도메인이 다르면 `connect-src` 에 추가해야 요청이 차단되지 않는다.
API 는 같은 도메인의 `/api` 라 `connect-src 'self'` 로 충분하다. API 를 별도 도메인으로 분리하면 그 오리진을 추가해야 요청이 차단되지 않는다.
## 의존성