Compare commits

..

6 Commits

Author SHA1 Message Date
ttipo 3f703b10f4 ci(backend): 테스트 파일까지 타입 검사 추가
빌드(tsconfig.build.json)는 spec 을 제외하고 ts-jest 는 isolatedModules 로 타입을 보지 않아,
테스트 파일의 타입 오류가 어떤 검사에도 걸리지 않았다. 타입이 틀린 목·픽스처로도 테스트가 통과한다.

- backend 에 type-check 스크립트(tsc --noEmit) 추가 — src·test·scripts 를 모두 본다
- CI backend 잡에 Type check 단계 추가, 커밋 전 점검·역할 문서와 PR 템플릿에 반영
- 이 검사로 드러난 기존 계약 테스트(error-codes.spec.ts)의 타입 오류 수정 — 테스트 의미는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:07:53 +09:00
ttipo 7897f2de20 fix: 템플릿 점검 결과 반영 — Node 24·이미지 위생·운영 노출 최소화
템플릿 점검에서 나온 항목을 한 번에 반영한다. 항목별 이유:

- Node 20 → 24 LTS (Dockerfile·CI): Node 20 은 2026-04-30 에 지원이 끝나 보안 패치가 없다
- backend·frontend .dockerignore 추가: `COPY . .` 가 호스트 node_modules(Windows 바이너리)·dist·
  .env*(시크릿)를 이미지에 넣고 있었다
- 업로드 디렉터리 쓰기 검사(fail-fast): Linux 에서 Docker 가 root 소유로 만든 바인드 마운트에
  비-root 앱이 쓰지 못해 첫 업로드에서야 SYS_001 로 터졌다. 기동 시 해결 명령을 안내한다
- DB 커넥션 풀 100 → 10 (DB_POOL_MAX): PostgreSQL 기본 max_connections(100)를 앱 하나가 다 잡았다
- Swagger 운영 기본 비활성 (SWAGGER_ENABLED): API 구조 전체가 공개 도메인에 노출됐다
- 헬스체크 운영 응답에서 오류 상세 제거: 드라이버 오류 문구에 내부 주소·DB 계정명이 담긴다.
  원인은 error 로그로 남긴다
- nginx: location 의 add_header 때문에 정적 자원에서 보안 헤더가 빠지던 문제와,
  index.html 이 캐시돼 배포 직후 빈 화면이 날 수 있던 문제를 고친다
- vite allowedHosts: true 제거 (DEV_ALLOWED_HOSTS): DNS rebinding 보호가 꺼져 있었다
- 전역 설정을 configureApp() 으로 분리해 main.ts 와 e2e 가 공유: e2e 가 설정을 복사해 재현해
  실제 설정과 어긋나도 통과할 수 있었다. PORT 도 빈 값을 방어하는 파서를 거친다
- 쓰이지 않던 WEBSOCKET_PORT 제거: 별도 포트는 같은 도메인 라우팅과 맞지 않는다
- 예제 useUser 가 메시지 대신 에러 코드를 보관하도록 수정, testing.md 표 깨짐 수정,
  settings.json 허용 목록에서 npm install 제거 (Windows lock 파일 규칙과 충돌)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:06:02 +09:00
ttipo 6dcbb45eb9 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>
2026-11-08 00:04:03 +09:00
ttipo 2f94b6db12 docs(rules): 에이전트 팀원 역할 규칙 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
지금까지 .claude 에는 "무엇을 지키는가"(rules)와 "어떻게 만드는가"(skills)만 있었고
"누가 어디까지 책임지는가"를 적을 자리가 없었다.

역할 파일은 규칙을 복사하지 않고 참조만 한다. 구현 규칙을 옮겨 적으면 스킬과
진실 공급원이 둘로 갈라져, 한쪽만 고쳐졌을 때 어느 쪽이 맞는지 알 수 없게 된다.
역할 고유의 것(담당 범위·경계·완료 기준·인계 형식)만 담는다.

리뷰어와 페르소나 둘은 frontmatter 에서 Edit/Write 를 빼 도구 수준으로 읽기 전용이다.
CLAUDE.md 에는 @ import 하지 않는다 — 호출 시점에 로드되는 역할 정의다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:44:56 +09:00
ttipo 52e671cedb fix(backend): 운영 공개 URL 에 로컬 주소가 남으면 기동을 실패시킴
.env.production.example 이 개발용 값(localhost)을 그대로 담고 있어, 복사해 배포하면
서버는 정상 기동하지만 CORS 화이트리스트가 실제 도메인을 막고 프론트 번들은
localhost 를 호출한다 — 브라우저에서만 조용히 실패해 원인을 찾기 어렵다.

- CORS_ORIGIN·BACKEND_API_URL 에 로컬 주소나 예시 도메인이 있으면 운영에서 기동 실패
- 예제 값을 도메인 형태로 교체 (그대로 두면 이 검사에 걸린다)
- BACKEND_API_URL 은 앱이 쓰지 않지만 검사를 위해 compose 가 backend 에 주입한다
- 로컬에서 운영 이미지를 검증할 때만 ALLOW_LOCALHOST_URL=true 로 건너뛴다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:42:23 +09:00
ttipo 94357d26bd perf(docker): 개발 프론트엔드를 컨테이너에서 호스트 vite 로 전환
Windows 바인드 마운트(./frontend:/app) 너머의 소스를 vite 가 모듈마다 읽는 데다
폴링 감시까지 항상 켜져 있어 첫 서빙에 수 분이 걸렸다. 호스트에서 띄우면 2초다.

- dev 오버라이드의 frontend 를 compose profile `frontend` 뒤로 숨긴다 (운영은 그대로)
- 폴링 감시는 CHOKIDAR_USEPOLLING 으로 컨테이너에서만 켠다
- dev 서버 포트·프록시 대상을 루트 .env.{mode} 에서 읽는다 (포트 SSOT 유지)
  포트 충돌 시 조용히 옮기지 않도록 strictPort — CORS_ORIGIN·포트 매핑이 묶여 있다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:41:19 +09:00
63 changed files with 1987 additions and 249 deletions
+56
View File
@@ -0,0 +1,56 @@
# 에이전트 팀원 규칙
이 디렉터리는 **역할(누가 무엇까지 책임지는가)** 을 정의한다.
**어떻게 만드는가**는 `.claude/skills/`, **무엇을 지키는가**는 `.claude/rules/` 가 담당한다.
```
.claude/rules/ 무엇을 지키는가 (응답 포맷·에러 코드·보안 …) — 전원 공통
.claude/skills/ 어떻게 만드는가 (nestjs·vue3·flutter·docker) — 스택별
.claude/agents/ 누가 맡는가 (이 디렉터리) — 역할별
```
## 역할 목록
| 역할 | 파일 | 주 책임 | 코드 수정 |
| --- | --- | --- | --- |
| 백엔드 개발 | `backend-dev.md` | `backend/` API·도메인 로직 | O |
| 프론트엔드 개발 | `frontend-dev.md` | `frontend/` 화면·상태·API 연동 | O |
| 모바일 개발 | `mobile-dev.md` | `flutter/` 화면·상태·API 연동 | O |
| 테스터 | `tester.md` | 테스트 작성·실행, 계약 테스트 유지 | O (테스트 한정) |
| 리뷰어 | `reviewer.md` | 공유 계약 위반·규칙 드리프트 점검 | **X** |
| 인프라/릴리스 | `release-ops.md` | compose·env·마이그레이션·배포 | O (인프라 한정) |
| 사용자 관점 | `persona-user.md` | 일반 사용자 입장의 수용 기준 검토 | **X** |
| 관리자 관점 | `persona-admin.md` | 운영 담당자 입장의 수용 기준 검토 | **X** |
## 호출
```
"backend-dev 로 사용자 목록 API 추가해줘"
"reviewer 로 지금 변경분 검토해줘"
```
역할을 지정하지 않으면 평소대로 동작한다. 역할은 **필요할 때 부르는 것**이지, 모든 작업을 역할로 나눠야 하는 것은 아니다.
## 전원 공통 규칙
1. **규칙을 복사하지 않는다.** 역할 파일은 규칙을 **참조**만 한다.
응답 포맷·에러 코드·코딩 규칙의 SSOT 는 `.claude/rules/` 와 `.claude/skills/` 다.
역할 파일에 같은 내용을 옮겨 적으면 진실 공급원이 둘로 갈라진다 ([[error-codes]] 와 같은 이유).
2. **경계를 넘지 않는다.** 담당 범위 밖의 파일이 고쳐져야 한다면, 직접 고치지 말고
**무엇이 왜 필요한지 적어 인계**한다. 남의 영역을 조용히 고치면 그 영역의 규칙 검토를 건너뛰게 된다.
3. **공유 계약은 단독으로 바꾸지 않는다.** 응답 포맷·에러 코드·API 규약은 세 클라이언트가 함께 쓰는 계약이다.
변경이 필요하면 [[checklist]] 5절 절차를 따르고 영향 범위를 보고한다.
4. **완료를 스스로 판정하지 않는다.** 각 역할의 "완료 기준"에 적힌 명령을 **실제로 실행**하고
그 결과를 보고한다. 실행하지 않았으면 "실행하지 않음"이라고 적는다.
5. 모든 산출물(주석·커밋·보고)은 **한국어** ([[coding-common]]).
## 인계 형식
작업을 마치면 아래 4줄로 보고한다. 길게 쓰지 않는다.
```
- 한 일:
- 건드린 파일:
- 확인한 것: (실행한 명령과 결과. 실행 못 했으면 그 이유)
- 남은 것/인계: (다음 역할과 넘기는 이유)
```
+42
View File
@@ -0,0 +1,42 @@
---
name: backend-dev
description: NestJS 백엔드(backend/) 담당. REST API·모듈·DTO·서비스·에러 코드·마이그레이션 작성. backend/ 의 기능 추가나 수정이 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
`backend/` 를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/nestjs` 스킬을 로드한다. 구현 규칙은 전부 거기에 있다 — 여기에 다시 적지 않는다.
## 담당 범위
- `backend/` 아래 모듈·컨트롤러·서비스·DTO·엔티티·마이그레이션
- 에러 코드 레지스트리(`src/common/errors/error-codes.ts`)와 `npm run sync:error-codes` 실행
- 백엔드 단위 테스트(`*.spec.ts`) — 새 로직에는 최소한의 테스트를 함께 쓴다
## 하지 않는 것
- **클라이언트 코드 수정** (`frontend/`·`flutter/`). 에러 코드를 추가해 생성 파일이 바뀌는 것은 정상이지만,
클라이언트가 그 코드로 분기하도록 고치는 것은 해당 역할의 몫이다 — 인계한다.
- **compose·루트 env 수정** → `release-ops` 에 인계. 단 새 필수 env 가 생기면
`env.validation.ts` 의 검증 규칙과 테스트는 **직접** 추가한다 ([[env-structure]]).
- 응답 포맷·API 규약의 단독 변경 ([[checklist]] 5절)
## 작업 순서
1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]).
3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]).
4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]).
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd backend && npm run lint && npm run type-check && npm run test && npm run build
```
- 에러 코드를 건드렸다면 `npm run sync:error-codes` 를 실행하고 **생성 파일까지 함께** 커밋 대상에 넣는다.
- 엔티티를 바꿨다면 마이그레이션을 생성하고 **SQL 을 눈으로 확인**한다 ([[checklist]] 4절).
- 의존성을 바꿨다면 호스트가 아니라 **컨테이너 안에서** 설치했는지 확인한다 ([[nestjs]]).
+39
View File
@@ -0,0 +1,39 @@
---
name: frontend-dev
description: Vue 3 프론트엔드(frontend/) 담당. 컴포넌트·페이지·Pinia 스토어·컴포저블·라우터 작성. frontend/ 의 화면이나 API 연동이 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
`frontend/` 를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/vue3` 스킬을 로드한다. 구현 규칙은 전부 거기에 있다 — 여기에 다시 적지 않는다.
## 담당 범위
- `frontend/src/` 아래 컴포넌트·페이지·스토어·컴포저블·라우터·타입
- 프론트 단위 테스트(`*.spec.ts`)
## 하지 않는 것
- **백엔드 API 스펙 변경** — 필요한 엔드포인트가 없으면 만들지 말고 `backend-dev` 에 인계한다.
"어떤 경로·메서드·응답이 필요한지"를 적어 넘긴다.
- **`error-codes.generated.ts` 직접 수정** — 자동 생성 파일이다. 코드가 더 필요하면 인계한다 ([[error-codes]]).
- **`vite.config.ts` 에 포트 하드코딩** — 포트 SSOT 는 루트 `.env` 다 ([[env-structure]]).
## 작업 순서
1. `components/BaseButton.vue`·`composables/useUser.ts`·`stores/user.store.ts` 의 기존 패턴을 먼저 본다.
2. API 호출은 `composables/` 경유. 컴포넌트에서 `axios` 직접 호출 금지.
3. 에러 처리는 인터셉터에 위임한다. 분기가 필요하면 `message` 가 아니라 **`error.code`** 로 한다.
4. 목록 화면은 `{items, pageInfo}` 타입을 쓴다 ([[response-format]]).
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd frontend && npm run lint && npm run type-check && npm run test:unit
```
- 화면을 실제로 확인했다면 어떻게 띄웠는지(호스트 `npm run dev`) 적는다. 못 띄웠으면 그렇게 적는다.
- API 응답이 JSON 이 아니라 HTML 로 오면 vite 프록시 문제다 ([[vue3]] 체크리스트).
+38
View File
@@ -0,0 +1,38 @@
---
name: mobile-dev
description: Flutter 모바일 앱(flutter/) 담당. 위젯·화면·Riverpod 프로바이더·모델·리포지토리 작성. flutter/ 작업이 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
`flutter/` 를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/flutter` 스킬을 로드한다. 구현 규칙은 전부 거기에 있다 — 여기에 다시 적지 않는다.
## 담당 범위
- `flutter/lib/` 아래 feature(위젯·프로바이더·모델·리포지토리)와 `core/`
- `flutter/test/` 테스트
## 하지 않는 것
- **백엔드 API 스펙 변경** → `backend-dev` 에 인계 (필요한 경로·응답을 적어서)
- **`error_lexicon.g.dart` 직접 수정** — 자동 생성 파일이다 ([[error-codes]])
- `flutter/.env` 의 `API_URL` 은 독립 관리 대상이므로 루트 env 구조를 따라가려 하지 않는다 ([[env-structure]])
## 작업 순서
1. `lib/features/user/` 의 model → repository → provider 수직 슬라이스를 복제한다.
2. 네트워크는 `core/api/dio_client.dart` 의 인터셉터 패턴을 재사용한다. 새 클라이언트를 만들지 않는다.
3. 에러 분기는 `e.error as ApiException` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다.
4. 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` 을 쓴다.
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd flutter && flutter analyze && flutter test
```
- `flutter/.env` 가 없으면 analyze 가 asset 경고를 낸다 — `.env.example` 복사 여부를 먼저 확인한다.
- CI(`ci-flutter.yml`)는 `flutter/` 가 바뀐 커밋에서만 돈다는 점을 보고에 적는다.
+56
View File
@@ -0,0 +1,56 @@
---
name: persona-admin
description: 운영 담당자 관점의 검토 담당. 장애 대응·문의 추적·권한·데이터 보존이 가능한지 따진다. 운영에 넘기기 전 점검이나 운영 요구사항 정리에 사용한다.
tools: Read, Grep, Glob
---
**서비스를 운영하는 담당자**의 입장에서 검토하는 역할이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
기능이 동작하는지가 아니라 **문제가 생겼을 때 대응할 수 있는지**를 본다.
코드를 수정하지 않는다 — 문제를 적어 담당 역할에 인계한다.
## 무엇을 보는가
**1. 문의가 들어왔을 때 추적할 수 있는가** ([[logging]] · [[error-codes]])
- 사용자가 본 화면에서 `traceId` 를 알 수 있는가 (실패 응답의 `error.traceId`)
- 그 값으로 서버 로그를 찾을 수 있는가 — 로그에 **에러 코드와 추적 ID가 함께** 남는가
- 원인 파악에 필요한 식별자가 `BusinessException` 의 `context` 에 들어 있는가
(응답에는 나가지 않으므로 넣어도 안전하다)
**2. 장애를 알아차릴 수 있는가**
- `/api/health` 가 **어느 구성요소가 죽었는지** 알려주는가. 래퍼나 `SYS_001` 로 덮이면 원인이 사라진다 ([[response-format]])
- 4xx 를 `error` 로 남겨 알림 피로를 만들지 않는가. 반대로 5xx 가 조용히 지나가지 않는가
**3. 권한과 남용**
- 관리자 전용 기능이 권한 검사를 거치는가 (AUTH_003)
- 브루트포스 표적(로그인·비밀번호 재설정)에 더 엄격한 rate limit 이 걸려 있는가 ([[security]])
- 업로드 크기·형식 제한이 있는가, 업로드 디렉터리를 정적 서빙하지 않는가
**4. 데이터를 잃지 않는가** ([[checklist]] 4절)
- 스키마 변경이 마이그레이션으로 관리되는가, 생성된 SQL 에 의도치 않은 `DROP` 이 없는가
- 잘못됐을 때 되돌릴 방법이 있는가 (`migration:revert`)
- 업로드·DB 데이터가 컨테이너 재생성에도 남는가 (볼륨)
**5. 배포할 수 있는가** ([[env-structure]])
- 새 설정값이 루트 `.env.{env}` 와 `*.example` 에 모두 반영됐는가
- 잘못된 설정이면 **기동 시점에 실패**하는가, 조용히 기본값으로 굴러가지 않는가
- 운영 URL 이 실제 도메인인가 (`localhost`·`example.com` 이 남으면 기동 실패한다)
## 하지 않는 것
- 코드 품질·구조 지적 (→ `reviewer`)
- 최종 사용자 경험 (→ `persona-user`)
- 실제 배포·컨테이너 조작 (→ `release-ops`)
## 완료 기준
각 항목을 이렇게 적는다.
```
- 상황: (어떤 장애·문의·배포 상황을 가정했는가)
- 가능한가: (지금 구성으로 대응 가능한지. 근거 파일 위치)
- 부족한 것:
- 인계: (어느 역할)
```
문제가 없으면 "없음"이라고 적는다. 가정만으로 판단한 항목은 **확인하지 않았다**고 밝힌다.
+51
View File
@@ -0,0 +1,51 @@
---
name: persona-user
description: 일반 사용자 관점의 검토 담당. 화면 흐름·문구·실패 상황이 사용자에게 어떻게 보이는지 따진다. 기능을 만들기 전 수용 기준을 정하거나, 만든 뒤 사용자 관점으로 점검할 때 사용한다.
tools: Read, Grep, Glob
---
**서비스를 쓰는 일반 사용자**의 입장에서 검토하는 역할이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
개발자가 아니다. 코드가 올바른지가 아니라 **쓰는 사람이 막히지 않는지**를 본다.
코드를 수정하지 않는다 — 문제를 적어 담당 역할에 인계한다.
## 무엇을 보는가
**1. 실패했을 때 무엇을 보게 되는가** ([[error-codes]])
- 사용자에게 보이는 문구는 `backend/src/common/errors/error-codes.ts` 의 메시지가 전부다. 그 문구를 읽고 판단한다.
- 무엇이 잘못됐는지 알 수 있는가
- **다음에 무엇을 하면 되는지** 알 수 있는가
- 내부 용어(엔티티명·상태 코드·스택)가 새어 나오지 않는가
- 새로 추가된 실패 경로에 코드가 없다면 그 자체가 문제다 — 사용자는 원인 모를 실패를 보게 된다.
- 같은 알림이 연달아 뜨지 않는가 (인터셉터가 억제하게 되어 있다)
**2. 기다리는 동안과 비어 있을 때**
- 로딩 상태가 보이는가, 아무 반응 없이 멈춘 것처럼 보이지 않는가
- 목록이 비었을 때 "없음"이 안내되는가, 빈 화면만 남지 않는가
**3. 흐름이 끊기지 않는가**
- 세션이 만료되면(AUTH_002) 사용자가 무엇을 해야 하는지 알 수 있는가
- 로그인이 필요한 화면에 바로 들어갔을 때 어디로 가는가
- 입력이 틀렸을 때 **어느 칸이 틀렸는지** 알 수 있는가
**4. 접근성** ([[vue3]])
- 버튼·입력에 이름이 붙어 있는가 (`aria-label`·`role`), 키보드만으로 쓸 수 있는가
## 하지 않는 것
- 코드 품질·구조·성능 지적 (→ `reviewer`)
- 운영·모니터링 관점 (→ `persona-admin`)
- 구현 방법 제안. **무엇이 불편한지**까지만 말하고 해결 방법은 담당 역할에 맡긴다.
## 완료 기준
각 항목을 이렇게 적는다.
```
- 상황: (사용자가 무엇을 하다가)
- 보게 되는 것: (실제 문구·화면. 근거 파일 위치)
- 문제: (왜 막히는가)
- 인계: (어느 역할)
```
문제가 없으면 "없음"이라고 적는다. 확인하지 못한 화면이 있으면 **확인하지 못했다**고 적는다.
+51
View File
@@ -0,0 +1,51 @@
---
name: release-ops
description: 인프라·릴리스 담당. docker compose, 루트 env, DB 마이그레이션 적용, 배포 전 설정 점검. 컨테이너 구성이나 배포 준비가 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
인프라와 릴리스를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/docker` 스킬을 로드한다. 실행 명령과 옵션 규칙은 전부 거기에 있다.
## 담당 범위
- `docker-compose.yml` · `docker-compose.dev.yml` · 각 `Dockerfile` · `nginx.conf`
- 루트 `.env.{env}` 구조와 `*.example` ([[env-structure]])
- 마이그레이션 **적용**(`migration:run`)과 배포 전 점검
- `.github/workflows/` 와 `ci/` 러너
## 하지 않는 것
- **애플리케이션 로직 수정** → 해당 개발 역할에 인계
- **마이그레이션 파일 작성** — 엔티티를 아는 `backend-dev` 가 생성한다. 이 역할은 적용과 SQL 검토를 맡는다.
- 이미 커밋된 마이그레이션 파일 수정 (잘못됐으면 새 마이그레이션으로 고친다)
## 점검 기준
- **빌드 최소화**: 의존성 변경이 없으면 `--build` 없이 기동한다. 의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.
- **포트 노출**: DB·Redis 호스트 포트는 dev 에서만, 반드시 `127.0.0.1` 로 한정 ([[security]]).
- **비-root 구동**: dev 오버라이드를 포함해 모든 컨테이너가 비-root 여야 한다.
- **env 파일 존재**: 루트 `.env.{APP_ENV}` 와 `backend/.env.{APP_ENV}` 가 둘 다 있어야 기동한다.
- **운영 배포 전**: 프론트와 API 는 같은 도메인을 쓴다. 외부 네트워크 `proxy` 가 있는지, NPM Proxy Host 에
Custom location `/api` → `${PROJECT_NAME}-backend:${BACKEND_PORT}` 가 있고 **Cache Assets 가 꺼져 있는지** 확인한다 (README "운영 배포").
`https://<도메인>/api/health` 가 404 면 custom location 이 빠진 것이다.
API 를 별도 도메인으로 분리한 구성이라면 `BACKEND_API_URL`·`CORS_ORIGIN` 이 실제 도메인인지 확인한다 —
`localhost`·`example.com` 이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
- **프론트엔드**: 개발에서는 컨테이너로 띄우지 않는다(profile `frontend` 뒤에 있다). 운영만 nginx 정적 호스팅이다.
- **업로드 디렉터리 (Linux 운영)**: 앱은 uid 1000 으로 돈다. `data/uploads` 소유자가 root 면 백엔드가 기동에 실패한다 —
`sudo chown 1000:1000 data/uploads` (UPLOAD_DATA_DIR 을 바꿨다면 그 경로).
- **빌드 컨텍스트**: `backend/`·`frontend/` 의 `.dockerignore` 가 `node_modules`·`dist`·`.env*` 를 뺀다. 지우지 않는다 ([[security]]).
## 완료 기준 (실행하고 결과를 보고한다)
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development config # 파싱·변수 치환 확인
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development ps # 상태 확인
```
- 컨테이너를 기동·종료했다면 **무엇을 띄웠고 무엇을 정리했는지** 적는다.
- `down -v` 는 데이터 볼륨까지 지운다. 실행 전 반드시 확인을 받는다.
- 정상 종료 시 backend 컨테이너 종료 코드 143 은 오류가 아니라 SIGTERM 에 의한 정상 종료다.
+57
View File
@@ -0,0 +1,57 @@
---
name: reviewer
description: 코드 리뷰 담당. 이 프로젝트 고유의 공유 계약 위반과 규칙 드리프트를 점검한다. 커밋·PR 전 변경분 검토가 필요할 때 사용한다.
tools: Read, Grep, Glob, Bash
---
리뷰를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
> **일반적인 버그 사냥은 내장 `/code-review` 의 몫이다.** 이 역할은 그것이 잡지 못하는
> **이 저장소 고유의 계약**을 본다. 범위를 넓히지 말고 아래 목록에 집중한다.
## 코드를 고치지 않는다
수정 권한 없이 **지적만** 한다. 파일·줄 위치와 근거 규칙을 함께 적어 담당 역할에 인계한다.
고쳐야 할 것이 명백해도 직접 고치지 않는다 — 그 영역의 완료 기준(lint·test)을 건너뛰게 된다.
## 점검 목록
**1. 응답 계약** ([[response-format]])
- 컨트롤러가 `{success,data}` 를 직접 래핑하지 않는가 (인터셉터 담당)
- 목록 응답이 배열이 아니라 `{items, pageInfo}` 인가
- 스트림·204·헬스체크에 `@SkipTransform()` 이 붙어 있는가
**2. 에러 코드 SSOT 드리프트** ([[error-codes]])
- `error-codes.ts` 를 고쳤는데 `npm run sync:error-codes` 결과가 빠지지 않았는가
(CI 의 `error-codes-sync` 잡이 같은 검사를 한다 — 여기서 먼저 잡는다)
- 생성 파일(`*.generated.ts`·`*.g.dart`)을 손으로 고치지 않았는가
- 코드 없는 실패(`BusinessException` 없이 던진 실패)가 없는가
- 클라이언트가 `message` 로 분기하지 않는가 (`code` 로 해야 한다)
**3. 경계 위반**
- 한 역할의 변경이 남의 영역 파일까지 조용히 고치지 않았는가
- 자동 생성 파일·계약 테스트가 삭제되거나 skip 되지 않았는가
**4. 설정·보안** ([[security]] · [[env-structure]])
- `.env*` 실값이 커밋에 섞이지 않았는가, `*.example` 값이 placeholder 인가
- 새 필수 env 에 `env.validation.ts` 검증과 테스트가 함께 왔는가
- 로그에 토큰·비밀번호·요청 본문 전체가 남지 않는가 ([[logging]])
**5. 커밋 위생** ([[git-convention]])
- 메시지가 `<type>(<scope>): <한국어 제목>` 형식인가, 한 커밋에 한 변경인가
## 작업 순서
```bash
git status --short && git diff # 무엇이 바뀌었는지부터 본다
```
1. 변경 파일 목록에서 **어느 역할의 영역이 섞였는지** 먼저 확인한다.
2. 위 목록 순서대로 본다. 목록 밖의 취향 문제는 지적하지 않는다.
3. 지적은 **근거 규칙과 함께** 적는다. 근거를 댈 수 없으면 지적하지 않는다.
## 완료 기준
- 지적 사항마다 `파일:줄` · 무엇이 · 어느 규칙 위반인지 · 누구에게 넘기는지를 적는다.
- 지적할 것이 없으면 "없음"이라고 명확히 적는다. 억지로 만들지 않는다.
- [[checklist]] 2절(커밋 전 공통 점검) 통과 여부를 함께 보고한다.
+52
View File
@@ -0,0 +1,52 @@
---
name: tester
description: 테스트 담당. 변경 영역의 단위·계약 테스트 작성과 실행, 세 스택 테스트 수트 유지. 테스트가 빠졌거나 계약 테스트가 깨졌을 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
테스트를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
[[testing]] 규칙을 기준으로 삼는다. 스택별 실행 방법은 해당 스킬을 로드해 확인한다.
## 담당 범위
- 변경된 로직에 대한 단위 테스트 (backend `*.spec.ts` / frontend `*.spec.ts` / flutter `*_test.dart`)
- **계약 테스트 유지** — 세 스택이 공유하는 계약은 어긋나도 컴파일 에러가 나지 않는다. 이 테스트가 유일한 안전망이다.
| 대상 | 위치 |
| --- | --- |
| 에러 코드 레지스트리 | `backend/src/common/errors/error-codes.spec.ts` |
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` |
## 하지 않는 것
- **계약 테스트를 삭제하거나 skip 하지 않는다.** 깨졌다면 테스트가 아니라 **구현이 계약을 벗어난 것**이다.
구현을 고칠 권한이 없으면 무엇이 어떻게 어긋났는지 적어 해당 개발 역할에 인계한다.
- **테스트를 통과시키기 위한 구현 수정** — 테스트 외 파일을 고쳐야 한다면 인계한다.
- 프레임워크 기본 동작(라우팅·DI·ORM 쿼리 빌더)이나 스타일 값 테스트 ([[testing]])
## 작업 순서
1. 무엇이 바뀌었는지 먼저 본다 (`git diff`). 바뀐 로직에만 테스트를 붙인다.
2. 순수 함수 → 서비스/비즈니스 로직 → 에러 코드 분기 순으로 우선한다.
3. 외부 네트워크·시간·랜덤은 모킹해 **결정적**으로 만든다.
4. 테스트 제목은 한국어로 "무엇을 검증하는지" 적는다.
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd backend && npm run type-check && npm run test
cd frontend && npm run type-check && npm run test:unit
cd flutter && flutter test
```
- 테스트 파일도 타입 검사를 통과해야 한다. backend 의 jest(ts-jest)는 타입을 보지 않아 타입이 틀린 목·픽스처로도
테스트가 통과해 버린다 — `type-check` 가 그 차이를 잡는다 ([[testing]]).
- e2e 는 DB 가 필요하다. 로컬이면 개발 스택을 띄운 뒤 컨테이너 안에서 실행한다 ([[testing]]).
- 실행하지 못한 수트가 있으면 **어느 것을 왜 못 돌렸는지** 반드시 적는다. 통과했다고 뭉뚱그리지 않는다.
+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`
+12 -3
View File
@@ -8,20 +8,29 @@
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
`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 "운영 배포")
- [ ] 운영 서버(Linux): 업로드 디렉터리 소유자를 앱 사용자(uid 1000)로 — `mkdir -p data/uploads && sudo chown 1000:1000 data/uploads`
(Docker 가 root 소유로 만들면 백엔드가 **기동에 실패**하고 해결 명령을 로그로 알려준다)
- [ ] `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 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
- [ ] Docker 개발 환경 기동으로 동작 확인 ([[docker]])
(Redis 는 기본 비활성이다 — 필요하면 루트 `.env` 에 `COMPOSE_PROFILES=redis`)
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db ([[docker]])
(Redis 와 프론트엔드 컨테이너는 기본 비활성이다 — 필요하면 루트 `.env` 에 `COMPOSE_PROFILES=redis,frontend`)
- [ ] 프론트엔드 dev 서버는 **호스트에서** 기동 — `cd frontend && npm run dev` (→ <http://localhost:5173>)
컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸려 profile 뒤로 숨겨 두었다 ([[docker]])
## 2. 커밋 전 공통 점검 (매 작업)
> 아래 항목은 `.github/workflows/ci.yml` 이 PR 마다 자동 검사한다. 로컬에서 먼저 통과시키면 CI 도 통과한다.
- [ ] 주석은 한국어, 매직 넘버·하드코딩 문자열 없음 ([[coding-common]])
- [ ] 린트/분석 통과 — backend `npm run lint`, frontend `npm run lint && npm run type-check`, flutter `flutter analyze`
- [ ] 린트/분석 통과 — backend `npm run lint && npm run type-check`, frontend `npm run lint && npm run type-check`, flutter `flutter analyze`
(backend 의 `type-check` 는 테스트 파일까지 본다 — 빌드는 spec 을 제외하고 jest 는 타입을 보지 않는다)
(lint 스크립트에 `--max-warnings 0` 이 걸려 있어 **Warning 하나만 있어도 실패**한다. 자동 수정은 `npm run lint:fix`)
- [ ] 변경 영역 테스트 통과 — backend `npm run test`, frontend `npm run test:unit`, flutter `flutter test` ([[testing]])
- [ ] 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로 없음
+17 -3
View File
@@ -13,7 +13,9 @@ 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 볼륨
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
```
@@ -26,15 +28,27 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **프론트엔드 dev 서버는 compose 를 거치지 않는다** — 호스트에서 직접 띄우므로([[docker]])
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 숫자 항목(`PORT`·`DB_PORT`·`DB_POOL_MAX`·`THROTTLE_*`)은 형식 검사 (모든 환경)
- 참/거짓 항목(`SWAGGER_ENABLED`)은 `true`/`false` 만 허용 (모든 환경) — 오타가 조용히 기본값으로 묻히지 않게
- `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 에 주입한다)
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`. 실제 목록은 `.env.development.example` 참조.
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `SWAGGER_ENABLED`, `DB_*`/`DB_DATA_DIR`/`DB_POOL_MAX`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `DEV_ALLOWED_HOSTS`(호스트 vite 전용). 실제 목록은 `.env.development.example` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+8 -1
View File
@@ -43,14 +43,21 @@ exportCsv(@Res() res: Response) { /* ... */ }
// 200 — 정상
{ "status": "ok", "info": { "database": { "status": "up" } }, "error": {}, "details": { ... } }
// 503 — 실패 (원인이 보인다)
// 503 — 실패 (어느 구성요소가 죽었는지 보인다)
{ "status": "error", "info": {}, "error": { "database": { "status": "down", "message": "..." } }, ... }
// 503 — 운영(NODE_ENV=production): 구성요소별 status 만 남기고 message 등 상세는 뺀다
{ "status": "error", "info": {}, "error": { "database": { "status": "down" } }, "details": { "database": { "status": "down" } } }
```
구현: `HealthController` 에 `@SkipTransform()` 을 붙이고, terminus 의 `ServiceUnavailableException` 은
컨트롤러에서 직접 잡아 503 + 원본 본문을 반환한다(전역 필터까지 가지 않게 한다).
같은 성격의 엔드포인트를 추가할 때 이 패턴을 재사용한다.
운영에서 상세를 빼는 이유: 같은 도메인 구성에서 `/api/health` 는 공개 주소로 열려 있고,
드라이버 오류 문구에는 내부 주소·DB 계정명이 담길 수 있다(`connect ECONNREFUSED 172.18.0.2:5432`).
원인은 응답 대신 **서버 로그(error)** 로 남긴다 ([[security]] — 응답에 내부 호스트명을 싣지 않는다).
## 목록 응답 (페이지네이션)
목록 API 의 `data` 는 **항상 아래 구조**를 가진다. 배열을 그대로 반환하지 않는다.
+15 -1
View File
@@ -8,6 +8,8 @@
- ✅ `DB_PASSWORD=change-me-in-local-env`
- 시크릿이 한 번이라도 커밋되었다면 **값을 교체(rotate)** 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다.
- 로그·에러 응답에 시크릿을 남기지 않는다 ([[logging]]).
- **이미지에도 굽지 않는다.** `backend/`·`frontend/` 의 `.dockerignore` 가 `.env*` 를 빌드 컨텍스트에서 뺀다 —
값은 실행 시 compose 의 `env_file`/`environment` 로만 들어간다. `.dockerignore` 를 지우거나 `.env*` 를 다시 넣지 않는다.
## 인증 · 토큰
@@ -21,6 +23,10 @@
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
- 헬스체크(`/api/health`)도 운영에서는 구성요소별 status 만 내보낸다 — 드라이버 오류 문구에 내부 주소가 담긴다 ([[response-format]])
- **Swagger 는 운영에서 기본으로 끈다** (`SWAGGER_ENABLED` 비움 = 운영에서만 off). API 구조 전체를 공개하기 때문이다.
운영에서 켜야 하면 `SWAGGER_ENABLED=true` + NPM Access List 등으로 접근을 제한한다.
### 파일 업로드
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
@@ -45,6 +51,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 +74,12 @@
```
- **보안 헤더는 CSP 까지** 설정한다 (`frontend/nginx.conf`). 외부 CDN·폰트·분석 스크립트를 추가하면
해당 도메인을 CSP 에 명시한다. `script-src` 에 `'unsafe-inline'`/`'unsafe-eval'` 을 넣으면 CSP 의 의미가 사라진다.
API 도메인이 다르면 `connect-src` 에 추가해야 요청이 차단되지 않는다.
API 는 같은 도메인의 `/api` 라 `connect-src 'self'` 로 충분하다. API 를 별도 도메인으로 분리하면 그 오리진을 추가해야 요청이 차단되지 않는다.
- ⚠️ nginx 는 location 에 `add_header` 가 하나라도 있으면 server 레벨의 보안 헤더를 **전부 상속하지 않는다.**
location 의 캐시 정책은 `add_header` 대신 `expires` 로 지정한다 (`frontend/nginx.conf` 주석 참조).
- **vite dev 서버의 `allowedHosts` 를 `true` 로 열지 않는다** — DNS rebinding 보호가 꺼져 악성 사이트가
개발자 브라우저를 거쳐 dev 서버(소스)와 `/api` 프록시(로컬 백엔드)에 닿을 수 있다.
터널(ngrok 등)이 필요하면 루트 `DEV_ALLOWED_HOSTS` 에 그 도메인만 적는다.
## 의존성
+7 -2
View File
@@ -14,7 +14,9 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·type-check·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- **테스트 파일도 타입 검사를 통과해야 한다.** backend 의 ts-jest 는 타입을 보지 않고(isolatedModules) 빌드는 spec 을 제외하므로,
타입이 틀린 목·픽스처로도 테스트가 통과해 버린다 — `npm run type-check` 가 테스트·e2e 까지 검사한다.
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
## 스택별 위치·실행
@@ -22,7 +24,6 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
| 스택 | 러너 | 위치·네이밍 | 실행 |
| --- | --- | --- | --- |
| backend | Jest | 단위 `*.spec.ts` (소스 옆), e2e `test/*.e2e-spec.ts` | `npm run test`, `npm run test:e2e` |
| frontend | Vitest | `*.spec.ts` / `*.test.ts` (대상 옆 또는 `__tests__/`) | `npm run test:unit` |
| flutter | flutter_test | `test/` 하위, `*_test.dart` | `flutter test` |
@@ -51,8 +52,12 @@ describe('formatSomething', () => {
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` | 코드/상태/traceId, **내부 정보 미노출**, 검증 상세 사유 미노출 |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 전역 설정 | `backend/src/app.setup.spec.ts` | main.ts·e2e 가 공유하는 `configureApp()` 의 prefix·응답 래퍼·에러 필터·보안 헤더·CORS (DB 불필요) |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 |
> **e2e 는 main.ts 와 같은 `configureApp()` 을 쓴다.** 테스트에서 전역 설정을 따로 흉내 내지 않는다 —
> 흉내 낸 설정은 실제와 어긋나도 테스트가 통과해 버린다.
> **e2e 는 DB 가 필요하다** (AppModule 이 TypeORM 을 초기화한다).
> 로컬에서는 개발 스택을 띄운 뒤 컨테이너 안에서 실행하는 것이 가장 간단하다.
> ```bash
-1
View File
@@ -10,7 +10,6 @@
"Bash(npm run sync:error-codes)",
"Bash(npm run start:*)",
"Bash(npm ci)",
"Bash(npm install)",
"Bash(pnpm lint:*)",
"Bash(pnpm build:*)",
"Bash(flutter analyze:*)",
+65 -1
View File
@@ -12,8 +12,12 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
## 개발 환경
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않는다.** compose profile `frontend` 뒤에 숨겨져 있다.
> vite 를 컨테이너에서 돌리면 소스가 바인드 마운트(`./frontend:/app`) 너머라 첫 서빙까지 수 분이 걸린다.
> 프론트엔드는 호스트에서 `cd frontend && npm run dev` 로 띄운다 (아래 "프론트엔드는 호스트에서" 참조).
```bash
# 코드만 수정했을 때 (의존성 변경 없음)
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
@@ -25,6 +29,23 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
## 운영 환경
> **프론트와 API 는 같은 도메인을 쓴다.** 브라우저는 `/api` 상대경로로만 호출하고,
> 리버스 프록시(Nginx Proxy Manager)가 경로로 나눠 보낸다. 설정 절차는 README "운영 배포" 참조.
>
> | 경로 | 대상 (proxy 네트워크) | NPM 설정 |
> | --- | --- | --- |
> | `/` | `${PROJECT_NAME}-frontend:8080` | Proxy Host 의 기본 대상 |
> | `/api` | `${PROJECT_NAME}-backend:${BACKEND_PORT}` | 같은 Proxy Host 의 Custom location |
>
> - NPM 과 공유하는 외부 네트워크 `proxy` 가 있어야 기동한다 — 서버당 한 번 `docker network create proxy`.
> - Proxy Host 의 **Cache Assets 는 끈다.** 켜면 `/api/…png` 처럼 정적 확장자로 끝나는 요청이 custom location 대신 frontend 로 간다.
> - location 은 `/api`(끝 슬래시 없음), Forward Hostname 에는 경로를 붙이지 않는다 — 경로가 그대로 넘어가야 백엔드 prefix 와 맞는다.
> - `BACKEND_API_URL=/api`·`CORS_ORIGIN` 비움이 기본이다. API 를 별도 도메인으로 분리할 때만 실제 도메인을 적는다 —
> 그때 `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
> - **Linux 서버는 업로드 디렉터리 소유자를 먼저 맞춘다** — 앱은 uid 1000 으로 도는데 Docker 가 없는 디렉터리를 root 소유로 만든다.
> `mkdir -p data/uploads && sudo chown 1000:1000 data/uploads` (안 하면 백엔드가 기동에 실패하며 같은 명령을 안내한다)
> - Swagger(`/api-docs`)는 운영에서 기본으로 꺼진다 (`SWAGGER_ENABLED`).
```bash
# 운영 환경 배포
sudo docker compose --env-file .env.production up -d --build
@@ -35,6 +56,46 @@ sudo docker compose --env-file .env.production logs -f backend
---
## 프론트엔드는 호스트에서 (개발 환경)
```bash
cd frontend && npm install && npm run dev # → http://localhost:5173
```
- **포트는 루트 `.env.{APP_ENV}` 가 SSOT 다** ([[env-structure]]). `vite.config.ts` 가 루트 env 를 직접 읽어
`FRONTEND_PORT` 로 열고, `/api` 프록시 대상은 `http://localhost:${BACKEND_PORT}`(백엔드 컨테이너가
호스트에 노출한 포트)로 잡는다. 포트를 바꿀 때 루트 env 한 줄만 고치면 된다.
백엔드를 아예 다른 주소에 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 덮어쓴다.
- `strictPort: true` 라 포트가 이미 사용 중이면 **다음 포트로 옮기지 않고 실패**한다.
대개 다른 vite(컨테이너 vite 포함)가 이미 그 포트를 쓰고 있다는 뜻이라, 조용히 5174 로 옮겨가면
브라우저는 여전히 5173 의 엉뚱한 서버를 보게 된다 (fail-fast).
- 브라우저는 vite 와 **같은 출처**의 `/api` 를 호출하고 vite 프록시가 백엔드로 넘긴다 —
운영의 NPM Custom location 과 같은 구조라 개발에서도 CORS 설정이 필요 없다.
- 폴링 감시(`watch.usePolling`)는 **컨테이너에서만** 켜진다 (`CHOKIDAR_USEPOLLING` 로 판별).
호스트에서는 OS 파일 감시 이벤트로 충분하며, 폴링을 켜면 기동이 느려지고 CPU 만 쓴다.
- 의존성 설치는 호스트에서 해도 된다 — 프론트엔드 dev 는 컨테이너를 쓰지 않으므로
lock 파일이 깨지는 문제([[nestjs]] 의 Windows 주의 항목)는 **backend 에만** 해당한다.
단 `frontend` 프로필이나 운영 이미지를 빌드할 계획이라면 같은 주의가 적용된다.
### 컨테이너로 띄워야 할 때 (선택)
호스트에 Node 를 둘 수 없는 환경 등에서만 쓴다. 느린 것은 정상이다.
```bash
# 일회성
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development --profile frontend up -d
# 상시 — 루트 .env.development 에 (redis 와 함께 켤 때는 frontend,redis)
COMPOSE_PROFILES=frontend
```
- 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트(`FRONTEND_PORT`)를 쓴다 — **동시에 띄우지 말 것.**
- 컨테이너 **내부** 포트는 5173 고정이다 (compose 의 `--port`·healthcheck·포트 매핑이 함께 묶여 있다).
루트 env 는 컨테이너 안에 없으므로 `vite.config.ts` 는 기본값으로 떨어지고, compose 의 `--port` 가 이긴다.
- 운영(`docker-compose.yml` 단독)에는 profile 이 없으므로 nginx 정적 호스팅이 그대로 동작한다.
---
## 자주 쓰는 보조 명령
> 개발 prefix가 길어 아래는 다음 별칭으로 줄여 표기한다.
@@ -103,6 +164,7 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
```bash
# 켜기 — 루트 .env.{APP_ENV} 한 줄 수정 (이후 평소 명령 그대로)
COMPOSE_PROFILES=redis
# (프론트엔드 컨테이너도 함께 켤 때는 COMPOSE_PROFILES=redis,frontend)
# 또는 일회성
dc --profile redis up -d
@@ -136,6 +198,8 @@ dc --profile redis up -d
- 정상 종료 시 컨테이너 **종료 코드는 143**(=128+SIGTERM)이다. 오류가 아니라 시그널에 의한 정상 종료다.
- ⚠️ dev 오버라이드는 PID 1 이 `npm` 이라 SIGTERM 이 앱까지 전달되지 않는다(개발 편의상 허용).
graceful shutdown 동작 확인은 **운영 이미지**(`--target production`)로 해야 한다.
같은 도메인 기본 구성(`BACKEND_API_URL=/api`·`CORS_ORIGIN` 비움)이면 공개 주소 검사에 걸릴 값이 없다.
별도 도메인 구성을 localhost 로 검증할 때만 `ALLOW_LOCALHOST_URL=true` 로 그 검사를 건너뛴다 ([[env-structure]]).
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 로 실행한다 — **dev 오버라이드도 포함** ([[security]] 참조).
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
+19 -10
View File
@@ -17,23 +17,25 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| --- | --- | --- |
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| **전역 설정** | `src/app.setup.ts` 의 `configureApp()` | prefix·helmet·CORS·ValidationPipe·필터·인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** — 전역 설정은 여기에만 추가한다 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | `configureApp()` 에서 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
| 전역 ValidationPipe | `app.setup.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `shared/config/swagger.config.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. **운영에서는 기본 비활성** — `SWAGGER_ENABLED=true/false` 로 강제 |
| 보안 미들웨어 | `app.setup.ts` | helmet · CORS 화이트리스트(`shared/config/cors.config.ts` — 같은 도메인 구성이 기본이라 비어 있다. 다른 오리진만 `CORS_ORIGIN` 에 콤마로) |
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off. 풀 크기 `DB_POOL_MAX`(기본 10 — 인스턴스 수 × 풀 크기가 DB `max_connections` 를 넘지 않게) |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 컨테이너 빌드 | `Dockerfile`(Node 24 LTS) + `.dockerignore` | 호스트 `node_modules`·`dist`·`.env*` 는 빌드 컨텍스트에서 빠진다 — 시크릿을 이미지에 굽지 않는다 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 저장 디렉터리에 쓸 수 없으면 **기동 실패** |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴. 운영에서는 구성요소별 status 만 노출 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
@@ -42,6 +44,9 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - **WebSocket**: 별도 포트를 열지 않는다. 게이트웨이를 HTTP 서버에 붙이고 경로를 `/api` 아래에 둔다
> (socket.io 면 `@WebSocketGateway({ path: '/api/socket.io' })`). 그래야 같은 도메인의 NPM location `/api` 를 그대로 탄다.
> NPM Proxy Host 의 **Websockets Support** 를 켜고, 개발용 vite 프록시(`/api`)에 `ws: true` 를 추가한다.
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
@@ -127,7 +132,8 @@ export class TransformInterceptor<T> implements NestInterceptor {
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- `app.setup.ts` 의 `configureApp()` 에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)))`
— main.ts 와 e2e 가 이 함수를 공유한다. 전역 설정을 main.ts 에 직접 추가하지 말 것 (테스트가 실제 설정과 어긋난다)
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
- 응답 포맷 SSOT는 [[response-format]] 참조.
@@ -163,7 +169,7 @@ throw new BusinessException('USER_001', {
## Swagger 필수 적용
```typescript
// main.ts
// shared/config/swagger.config.ts — main.ts 는 isSwaggerEnabled() 일 때만 setupSwagger(app) 를 호출한다
const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
@@ -175,6 +181,8 @@ const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
```
- 문서는 API 구조 전체를 공개하므로 **운영에서는 기본으로 꺼진다.** 필요하면 `SWAGGER_ENABLED=true` 로 켜고
NPM Access List 등으로 접근을 제한한다 (`SWAGGER_ENABLED=false` 는 개발에서도 끈다).
- 모든 Controller에 `@ApiTags()` 적용
- 모든 엔드포인트에 `@ApiOperation()` 작성
- **응답은 `@ApiResponse` 대신 래퍼 데코레이터를 쓴다** — 컨트롤러는 raw 를 반환하지만 실응답은 `{success,data}` 라 문서가 어긋난다.
@@ -313,7 +321,8 @@ dc exec backend npm uninstall <패키지>
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
`type-check` 는 테스트·e2e·스크립트까지 본다 — 빌드는 spec 을 제외하고 ts-jest 는 타입을 보지 않으므로(isolatedModules) 테스트 파일의 타입 오류는 여기서만 잡힌다.
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
+24 -7
View File
@@ -15,7 +15,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`(비면 같은 도메인의 `/api`), `withCredentials` |
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 정규화(`ApiError`), 중복 알림 억제 |
| 에러 코드 사전 | `src/shared/constants/error-codes.generated.ts` | **자동 생성 파일 — 직접 수정 금지** ([[error-codes]]) |
| 세션 만료 처리 | `useApi.ts` 의 `toAuthCode()` | 로그인 상태에서 401 → `AUTH_002` 승격 + `authStore.clearAuth()` |
@@ -27,7 +27,14 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `src/router/index.ts` | lazy import + `auth.store` 연동 `requiresAuth` 가드 |
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드 (운영 NPM Custom location 과 같은 역할). 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
| dev 서버 허용 호스트 | `vite.config.ts` 의 `allowedHosts` | 기본은 localhost·IP 만 (DNS rebinding 보호). 터널은 루트 `DEV_ALLOWED_HOSTS` 에 콤마로 — `true` 로 열지 않는다 ([[security]]) |
> **개발 서버는 호스트에서 띄운다** — `cd frontend && npm run dev` (→ `http://localhost:${FRONTEND_PORT}`).
> 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙에 수 분이 걸려 compose profile `frontend` 뒤로 숨겼다 ([[docker]]).
> 백엔드·DB 는 평소대로 컨테이너로 띄우고, `/api` 는 vite 프록시가 `localhost:${BACKEND_PORT}` 로 넘긴다.
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
@@ -117,7 +124,8 @@ export const useUserStore = defineStore('user', () => {
```typescript
// composables/useApi.ts — 중앙 Axios 인스턴스 (구조 요약)
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // env.d.ts 에 타입 선언
// 비어 있으면 같은 도메인의 /api — 운영은 NPM Custom location, 개발은 vite 프록시가 백엔드로 넘긴다
baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
withCredentials: true, // HttpOnly 쿠키 전송
})
@@ -146,18 +154,23 @@ try {
// composables/useUser.ts — 도메인별 API Composable
export function useUser() {
const loading = ref(false)
const error = ref<string | null>(null)
// 실패 시 **에러 코드만** 보관한다 — 화면은 code 로 분기하고, 알림 문구는 인터셉터가 이미 띄웠다
const errorCode = ref<ErrorCode | null>(null)
async function getUser(id: number) {
loading.value = true
errorCode.value = null
try {
return await api.get(`/users/${id}`)
} catch (e) {
errorCode.value = isApiError(e) ? e.code : FALLBACK_ERROR_CODE // 메시지를 새로 만들지 않는다
return null
} finally {
loading.value = false
}
}
return { loading, error, getUser }
return { loading, errorCode, getUser }
}
```
@@ -212,10 +225,14 @@ router.beforeEach((to) => {
- [ ] **목록 화면**: 응답은 `{items, pageInfo}` — `shared/types/pagination.ts` 타입 사용.
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`. `/api` 로 시작하는 경로는 쓰지 않는다 (백엔드로 간다 — [[api-design]]).
- [ ] **auth 가드**: `router/index.ts` 가드는 `auth.store.ts` 의 `isLoggedIn` 과 이미 연결되어 있다. 로그인 페이지 추가 시 리다이렉트 대상을 `{ name: 'login' }` 으로 바꾼다.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL` 에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL`(기본 `/api` — 같은 도메인)에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
절대 URL 로 바꾸면 다른 오리진 호출이 되므로 백엔드 `CORS_ORIGIN` 과 `nginx.conf` 의 CSP `connect-src` 도 함께 바꿔야 한다.
- [ ] **dev 서버**: 프론트엔드는 호스트에서 `npm run dev` 로 띄운다 (컨테이너는 `--profile frontend` 선택, [[docker]]).
- [ ] **포트**: `vite.config.ts` 에 포트를 새로 하드코딩하지 않는다 — 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 가 SSOT ([[env-structure]]).
- [ ] **dev 에서 API 가 안 될 때**: 응답이 JSON 이 아니라 HTML 이면 vite 프록시(`server.proxy`) 문제다.
`baseURL` 이 `/api` 상대경로라 프록시가 없으면 index.html 이 200 으로 돌아온다.
백엔드 컨테이너가 떠 있는지(`docker compose ... ps`), 프록시 대상 포트가 맞는지 확인한다.
+41 -7
View File
@@ -9,13 +9,25 @@ BACKEND_DIR=backend
# ==========================================
# 포트 설정
#
# 컨테이너 포트 매핑뿐 아니라 **호스트에서 띄우는 vite dev 서버**도 이 값을 따른다.
# (frontend/vite.config.ts 가 이 파일을 직접 읽는다 — FRONTEND_PORT 로 열고
# /api 프록시는 BACKEND_PORT 로 보낸다)
# ==========================================
FRONTEND_PORT=5173
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# vite dev 서버 접근 허용 호스트 (호스트에서 띄우는 프론트엔드 전용)
#
# 기본(비어 있음)은 localhost·IP 접근만 허용한다 — vite 의 DNS rebinding 보호가 켜진 상태다.
# ngrok 같은 터널로 외부에서 dev 서버를 열 때만 도메인을 콤마로 적는다.
# 점으로 시작하면 하위 도메인 전체를 허용한다. 예) DEV_ALLOWED_HOSTS=.ngrok-free.app
# ==========================================
DEV_ALLOWED_HOSTS=
# ==========================================
# Database 설정
# ==========================================
@@ -24,6 +36,8 @@ DB_USER=app_user
DB_PASSWORD=change-me-in-local-env
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
# 앱 인스턴스 하나의 커넥션 풀 크기 (PostgreSQL 기본 max_connections 100 을 넘지 않게 — 인스턴스 수 × 이 값 + 여유)
DB_POOL_MAX=10
# ==========================================
# Redis 설정
@@ -33,10 +47,24 @@ REDIS_PASSWORD=change-me-in-local-env
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로 · CORS — 프론트와 API 는 같은 도메인을 쓴다
#
# 브라우저는 /api 상대경로로만 호출하고, 개발에서는 vite dev 프록시가
# 운영에서는 Nginx Proxy Manager 가 백엔드로 넘긴다 (운영과 같은 구조).
#
# BACKEND_API_URL : 프론트 번들에 박히는 API 경로 (compose 로 프론트 컨테이너를 빌드할 때 쓰인다)
# CORS_ORIGIN : 다른 오리진에서 API 를 호출할 때만 작성 (scheme://host[:port], 콤마 구분)
# 같은 출처 요청에는 CORS 가 적용되지 않으므로 기본은 비워 둔다.
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
BACKEND_API_URL=/api
CORS_ORIGIN=
# ==========================================
# Swagger API 문서 (/api-docs)
#
# 비어 있으면 운영에서만 끈다 (개발에서는 켜진다). true/false 로 강제할 수 있다.
# ==========================================
SWAGGER_ENABLED=
# ==========================================
# Rate Limiting (API 남용 방지)
@@ -57,10 +85,16 @@ DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = redis 를 기동하지 않는다.
# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다.
# COMPOSE_PROFILES=redis
# 기본값(비어 있음) = redis 와 frontend 컨테이너를 기동하지 않는다.
#
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=redis
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=frontend
# 둘 다 → COMPOSE_PROFILES=redis,frontend
#
# 일회성으로 켜려면: docker compose --profile redis ... up -d
#
# ⚠️ 개발 환경의 프론트엔드는 호스트에서 띄우는 것이 기본이다 (`cd frontend && npm run dev`).
# 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸린다.
# ==========================================
COMPOSE_PROFILES=
+36 -5
View File
@@ -9,10 +9,15 @@ BACKEND_DIR=backend
# ==========================================
# 포트 설정
#
# 운영에서는 컨테이너 포트를 호스트에 열지 않는다.
# Nginx Proxy Manager 가 proxy 네트워크로 컨테이너에 직접 붙는다 (README "운영 배포"):
# frontend → ${PROJECT_NAME}-frontend:8080 (nginx-unprivileged 고정 포트)
# backend → ${PROJECT_NAME}-backend:${BACKEND_PORT}
# FRONTEND_PORT·DB_PORT·REDIS_PORT 는 개발 환경의 호스트 포트 매핑에만 쓰인다.
# ==========================================
FRONTEND_PORT=80
FRONTEND_PORT=5173
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
@@ -24,6 +29,8 @@ DB_USER=app_user
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
# 앱 인스턴스 하나의 커넥션 풀 크기 (PostgreSQL 기본 max_connections 100 을 넘지 않게 — 인스턴스 수 × 이 값 + 여유)
DB_POOL_MAX=10
# ==========================================
# Redis 설정
@@ -33,10 +40,34 @@ REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로 · CORS — 프론트와 API 는 **같은 도메인**을 쓴다 (기본 구성)
#
# https://<도메인>/ → frontend 컨테이너 (NPM Proxy Host 의 기본 대상)
# https://<도메인>/api/... → backend 컨테이너 (같은 Proxy Host 의 Custom location /api)
#
# BACKEND_API_URL : 브라우저가 호출할 API 경로 (프론트엔드 번들에 그대로 박힌다)
# /api 그대로 두면 도메인이 바뀌어도 다시 빌드할 필요가 없다.
# CORS_ORIGIN : 다른 오리진에서 API 를 호출할 때만 작성 (scheme://host[:port], 콤마 구분)
# 같은 출처 요청에는 CORS 가 적용되지 않으므로 기본은 비워 둔다.
#
# API 를 별도 도메인(api.<도메인>)으로 분리할 때만:
# BACKEND_API_URL=https://api.<도메인>/api · CORS_ORIGIN=https://<도메인> 로 바꾸고
# frontend/nginx.conf 의 CSP connect-src 에 API 오리진을 추가한다.
# 이때 localhost 나 example.com 이 남아 있으면 **백엔드가 기동에 실패한다**
# (backend/src/shared/config/env.validation.ts — fail-fast).
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
BACKEND_API_URL=/api
CORS_ORIGIN=
# ==========================================
# Swagger API 문서 (/api-docs)
#
# 비어 있으면 운영에서는 꺼진다 — 문서가 API 구조 전체(경로·파라미터·스키마)를 공개하기 때문이다.
# 운영에서도 필요하면 true 로 켜고 NPM 의 Access List 등으로 접근을 제한한다.
# ==========================================
SWAGGER_ENABLED=
# ==========================================
# Rate Limiting (API 남용 방지)
+1 -1
View File
@@ -21,7 +21,7 @@
<!-- 자세한 항목은 .claude/rules/checklist.md 참조 -->
- [ ] 린트/분석 통과 (Warning 0) · 변경 영역 테스트 통과
- [ ] 린트/분석·타입 검사 통과 (Warning 0) · 변경 영역 테스트 통과
- [ ] 주석 한국어, 매직 넘버·하드코딩 문자열 없음
- [ ] 사용자 노출 실패는 모두 에러 코드와 함께 처리 (`BusinessException` / `error.code` 분기)
- [ ] 에러 코드를 바꿨다면 `npm run sync:error-codes` 실행 후 생성 파일까지 커밋
+10 -4
View File
@@ -12,7 +12,7 @@ concurrency:
jobs:
backend:
name: Backend (lint · test · e2e · build)
name: Backend (lint · type-check · test · e2e · build)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 20
@@ -43,7 +43,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
cache: npm
cache-dependency-path: backend/package-lock.json
@@ -53,6 +53,12 @@ jobs:
- name: Lint
run: npm run lint
# 테스트·e2e·스크립트까지 타입 검사한다.
# 빌드(tsconfig.build.json)는 spec 을 제외하고 ts-jest 는 타입을 보지 않아(isolatedModules)
# 이 단계가 없으면 테스트 파일의 타입 오류가 어디에서도 걸리지 않는다.
- name: Type check
run: npm run type-check
- name: Test
run: npm run test
@@ -84,7 +90,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
cache: npm
cache-dependency-path: frontend/package-lock.json
@@ -109,7 +115,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
cache: npm
cache-dependency-path: backend/package-lock.json
+28
View File
@@ -15,6 +15,8 @@
> - `.claude/rules/` — 세 플랫폼 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·커밋·보안·환경변수·테스트)
> 새 규칙 파일을 추가하면 **반드시 아래 `@` import 목록에도 등록**한다(등록하지 않으면 로드되지 않는다).
> - `.claude/skills/` — 도메인별 개발 규칙 (스킬). 슬래시 명령(`/nestjs` 등) 또는 해당 디렉터리 작업 시 자동 적용
> - `.claude/agents/` — 역할별 규칙 (에이전트 팀원). 역할을 지정해 호출할 때 적용
> **`@` import 하지 않는다** — 규칙이 아니라 호출 시점에 로드되는 역할 정의다 (아래 목록 참조)
> - `.claude/settings.json` — Claude Code 권한·환경 설정
---
@@ -40,3 +42,29 @@
- 프론트엔드(Vue 3): @.claude/skills/vue3/SKILL.md
- 모바일(Flutter): @.claude/skills/flutter/SKILL.md
- 실행/배포(Docker): @.claude/skills/docker/SKILL.md
---
## 역할별 규칙 (에이전트 팀원)
세 층의 역할 분담은 다음과 같다. **규칙은 한 곳에만 적는다** — 역할 파일은 규칙을 참조만 한다.
```
.claude/rules/ 무엇을 지키는가 — 전원 공통 (위 @import 목록)
.claude/skills/ 어떻게 만드는가 — 스택별
.claude/agents/ 누가 맡는가 — 역할별
```
| 역할 | 파일 | 코드 수정 |
| --- | --- | --- |
| 백엔드 개발 | `.claude/agents/backend-dev.md` | O |
| 프론트엔드 개발 | `.claude/agents/frontend-dev.md` | O |
| 모바일 개발 | `.claude/agents/mobile-dev.md` | O |
| 테스터 | `.claude/agents/tester.md` | O (테스트 한정) |
| 리뷰어 | `.claude/agents/reviewer.md` | X |
| 인프라/릴리스 | `.claude/agents/release-ops.md` | O (인프라 한정) |
| 사용자 관점 | `.claude/agents/persona-user.md` | X |
| 관리자 관점 | `.claude/agents/persona-admin.md` | X |
공통 계약(경계·인계 형식·완료 기준)은 [`.claude/agents/README.md`](.claude/agents/README.md) 참조.
역할을 지정하지 않으면 평소대로 동작한다 — 모든 작업을 역할로 나눌 필요는 없다.
+112 -10
View File
@@ -15,7 +15,8 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
## 사전 요구사항
- Docker / Docker Compose — backend·frontend·db·redis 구동
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너는 Node 24 LTS 이미지를 쓴다)
- Node.js 24 LTS 권장 (최소 22.12) — 호스트에서 띄우는 vite dev 서버와 lint·test 용
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
## 빠른 시작 (Docker)
@@ -28,23 +29,121 @@ cp .env.development.example .env.development # ← 여기
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
# 2) 개발 환경 기동 (코드만 수정 시)
# 2) 백엔드 스택 기동 (backend + db)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 3) 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다)
cd frontend && npm install && npm run dev
```
기동 후:
- 웹: <http://localhost:5173>
- API: <http://localhost:3000/api>
- Swagger 문서: <http://localhost:3000/api-docs>
- API: <http://localhost:5173/api> — 브라우저가 쓰는 경로 (vite 프록시가 백엔드로 넘긴다, 운영과 같은 구조)
· 백엔드 직접: <http://localhost:3000/api>
- Swagger 문서: <http://localhost:3000/api-docs> — 운영에서는 기본으로 꺼진다 (`SWAGGER_ENABLED`)
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
### 프론트엔드 실행 방식
| 방식 | 명령 | 첫 서빙 | 용도 |
| --- | --- | --- | --- |
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.**
> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고,
> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다.
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
> (대개 다른 vite 가 이미 그 포트에 떠 있다는 뜻이라, 말없이 옮겨가면 브라우저가 엉뚱한 서버를 보게 됩니다).
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
## 운영 배포 — 프론트·API 를 같은 도메인으로 (Nginx Proxy Manager)
브라우저는 **도메인 하나만** 봅니다. 경로로 프론트와 백엔드를 나눕니다.
```
https://app.your-domain.com/ → <PROJECT_NAME>-frontend:8080 nginx 정적 호스팅 (Proxy Host 기본 대상)
https://app.your-domain.com/api/... → <PROJECT_NAME>-backend:3000 NestJS (Custom location /api)
```
- 프론트 번들은 API 를 `/api` **상대경로**로 호출합니다 — 도메인이 바뀌어도 다시 빌드할 필요가 없습니다.
- 같은 출처 요청이라 **CORS 설정이 필요 없고**, CSP `connect-src 'self'` 가 그대로 동작하며, 인증 쿠키도 같은 사이트로 다룹니다.
- 개발 환경도 같은 구조입니다 — vite dev 서버가 `/api` 를 백엔드로 프록시합니다.
### 1) 공유 네트워크 (서버당 1회)
NPM 과 앱 컨테이너가 외부 네트워크 `proxy` 를 공유해야 NPM 이 컨테이너 이름으로 찾아갈 수 있습니다.
이 네트워크가 없으면 compose 가 기동하지 않습니다.
```bash
docker network create proxy # 이미 있으면 생략
docker network connect proxy <NPM 컨테이너 이름> # NPM 이 아직 proxy 네트워크에 없다면
```
> NPM 은 `<PROJECT_NAME>-frontend` 같은 **컨테이너 이름**으로 접근하므로, 같은 서버에 여러 프로젝트를 올린다면 `PROJECT_NAME` 이 겹치지 않게 합니다.
### 2) 앱 기동
```bash
cp .env.production.example .env.production # placeholder 교체 — BACKEND_API_URL=/api 는 그대로 둔다
cp backend/.env.production.example backend/.env.production
# 업로드 디렉터리 소유자를 앱 사용자(node, uid 1000)로 — Docker 가 root 소유로 만들면 백엔드가 기동에 실패한다
mkdir -p data/uploads && sudo chown 1000:1000 data/uploads
sudo docker compose --env-file .env.production up -d --build
```
### 3) NPM 설정 — Proxy Host 하나 + Custom location 하나
| 탭 | 항목 | 값 |
| --- | --- | --- |
| Details | Domain Names | `app.your-domain.com` |
| | Scheme · Forward Hostname · Port | `http` · `<PROJECT_NAME>-frontend` · `8080` |
| | Cache Assets | **끔** (아래 주의 참조) |
| | Block Common Exploits | 켬 (선택) |
| Custom locations | location | `/api` |
| | Scheme · Forward Hostname · Port | `http` · `<PROJECT_NAME>-backend` · `3000` (`BACKEND_PORT`) |
| SSL | | 인증서 발급 + Force SSL |
> ⚠️ **Cache Assets 는 끕니다.** 켜면 NPM 이 `.png`·`.js`·`.css` 등으로 끝나는 요청을 custom location 안에서도 가로채
> **Proxy Host 의 기본 대상(frontend)** 으로 보냅니다. `/api/files/abc.png` 같은 API 가 프론트로 가 버립니다.
> 프론트 정적 자원의 캐시 헤더는 frontend nginx 가 이미 붙입니다.
> - location 은 `/api` (끝 슬래시 없음) — 접두어 매칭이라 Swagger(`/api-docs`)까지 백엔드로 갑니다. 개발의 vite 프록시와 같은 규칙입니다.
> (Swagger 는 운영에서 기본으로 꺼져 있습니다. 켜려면 `SWAGGER_ENABLED=true` + NPM Access List 로 접근을 제한하세요)
> - Forward Hostname 에 경로를 붙이지 않습니다. 요청 경로가 그대로 넘어가야 백엔드의 `/api` prefix 와 맞습니다.
> - **`/api` 로 시작하는 경로는 프론트 라우트로 쓰지 않습니다** (전부 백엔드로 갑니다).
### 4) 확인
```bash
curl -i https://app.your-domain.com/api/health # 200 {"status":"ok",...} ← 백엔드가 응답
curl -I https://app.your-domain.com/ # 200 text/html ← 프론트가 응답
```
`/api/health` 가 **404** 면 custom location 이 빠진 것입니다 —
frontend nginx 는 자기에게 떨어진 `/api` 요청을 404 로 막아 둡니다 (index.html 로 폴백해 조용히 실패하지 않도록).
> API 를 별도 도메인(`api.your-domain.com`)에 두는 구성이 필요하다면 `.env.production.example` 의 안내대로
> `BACKEND_API_URL`·`CORS_ORIGIN` 과 `frontend/nginx.conf` 의 CSP `connect-src` 를 함께 바꿉니다.
## 모바일 앱 실행 (Flutter)
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
@@ -53,7 +152,8 @@ Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합
cd flutter && cp .env.example .env && flutter pub get && flutter run
```
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`)
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`,
> 운영은 웹과 같은 도메인의 `https://app.your-domain.com/api`)
## 환경변수
@@ -66,7 +166,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL` 주입 | 작성 불필요 |
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL`(기본 `/api`) 주입 | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
@@ -112,7 +212,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
cd backend && npm ci && npm run start:dev
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
cd frontend && npm ci && npm run dev
# 4) flutter
@@ -125,7 +225,7 @@ cd flutter && flutter pub get && flutter run
### 커밋 전 검사
```bash
cd backend && npm run lint && npm run test && npm run build
cd backend && npm run lint && npm run type-check && npm run test && npm run build
cd frontend && npm run lint && npm run type-check && npm run test:unit
cd flutter && flutter analyze && flutter test
```
@@ -152,6 +252,7 @@ lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다.
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
## 디렉터리 구조
@@ -164,11 +265,12 @@ lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다.
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
├── .github/
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 검사
│ ├── workflows/ci.yml # lint·type-check·test·build + 에러 코드 동기화 검사
│ └── pull_request_template.md
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
└── .claude/
├── settings.json # Claude Code 권한·환경 설정
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
└── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
```
+25
View File
@@ -0,0 +1,25 @@
# ==========================================
# 빌드 컨텍스트에서 제외할 파일
#
# Dockerfile 의 `COPY . .` 가 아래 항목까지 이미지에 넣지 않도록 막는다.
# ==========================================
# 의존성·빌드 산출물 — 컨테이너 안에서 npm ci·빌드를 다시 한다.
# 호스트(Windows) node_modules 가 섞이면 네이티브 바이너리가 리눅스용을 덮어써 컨테이너가 깨진다.
node_modules
dist
coverage
# 시크릿 — 이미지에 굽지 않는다. 실행 시 compose 의 env_file/environment 로 주입된다.
.env
.env.*
# 런타임 데이터·로그
uploads
logs
*.log
# 에디터·OS
.vscode
.idea
.DS_Store
+10 -5
View File
@@ -1,8 +1,8 @@
FROM node:20-alpine AS builder
FROM node:24-alpine AS builder
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 고정
# (베이스 이미지의 번들 npm 이 바뀌어도 lock 형식이 어긋나지 않게 한다. 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 개발 환경(docker-compose.dev.yml)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다.
@@ -14,10 +14,11 @@ USER node
COPY --chown=node:node package*.json ./
RUN npm ci
# 호스트의 node_modules·dist·.env 는 .dockerignore 로 제외된다
COPY --chown=node:node . .
RUN npm run build
FROM node:20-alpine AS production
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
@@ -31,9 +32,13 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업로드 디렉터리를 node 소유로 미리 만든다 — 볼륨 없이 띄우거나 named volume 을 붙여도 비-root 앱이 쓸 수 있다.
# 호스트 디렉터리를 바인드 마운트하면 호스트 쪽 소유권을 따른다 (README "운영 배포" 의 chown 참조)
RUN mkdir -p /app/uploads && chown node:node /app/uploads
EXPOSE 3000
# 비-root 실행 (보안 규칙)
USER node
CMD ["node", "dist/main"]
CMD ["node", "dist/main"]
+1
View File
@@ -14,6 +14,7 @@
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --max-warnings 0",
"lint:fix": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --fix",
"type-check": "tsc --noEmit --incremental false -p tsconfig.json",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
+88
View File
@@ -0,0 +1,88 @@
import { Controller, Get, INestApplication, Logger } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { configureApp } from '@/app.setup';
import { BusinessException } from '@/common/errors/business.exception';
/** 전역 설정만 검증하기 위한 최소 컨트롤러 — DB 가 필요 없다 */
@Controller('ping')
class PingController {
@Get()
ping(): { pong: boolean } {
return { pong: true };
}
@Get('missing')
missing(): never {
throw new BusinessException('RES_001');
}
}
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
interface ContractBody {
success?: boolean;
data?: unknown;
error?: { code?: string };
}
const body = (res: Response): ContractBody => res.body as ContractBody;
describe('configureApp (main.ts·e2e 공유 전역 설정)', () => {
let app: INestApplication<App>;
beforeAll(async () => {
// 의도된 실패 응답의 경고 로그가 테스트 출력을 오염시키지 않도록 억제
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const moduleRef = await Test.createTestingModule({
controllers: [PingController],
}).compile();
app = moduleRef.createNestApplication();
configureApp(app);
await app.init();
});
afterAll(async () => {
await app.close();
jest.restoreAllMocks();
});
it('라우트는 /api 아래에 놓이고 성공 응답은 { success, data } 로 감싸진다', async () => {
const res = await request(app.getHttpServer()).get('/api/ping').expect(200);
expect(body(res)).toEqual({ success: true, data: { pong: true } });
});
it('prefix 밖의 루트(/)는 상태 확인 텍스트를 돌려준다 (docker healthcheck)', async () => {
const res = await request(app.getHttpServer()).get('/').expect(200);
expect(res.text).toContain('정상 구동');
});
it('실패는 전역 필터가 에러 코드 응답으로 바꾼다', async () => {
const res = await request(app.getHttpServer())
.get('/api/ping/missing')
.expect(404);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('RES_001');
});
it('보안 헤더(helmet)가 붙는다', async () => {
const res = await request(app.getHttpServer()).get('/api/ping');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('화이트리스트 밖 오리진에는 CORS 헤더를 주지 않는다', async () => {
const res = await request(app.getHttpServer())
.get('/api/ping')
.set('Origin', 'https://other.io');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});
+67
View File
@@ -0,0 +1,67 @@
import {
INestApplication,
RequestMethod,
ValidationPipe,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import helmet from 'helmet';
import type {
Express,
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { buildCorsOptions } from '@/shared/config/cors.config';
/** 모든 라우트의 공통 prefix — Flutter·Web 의 baseURL, 리버스 프록시의 location /api 와 맞물려 있다 */
export const GLOBAL_PREFIX = 'api';
/**
* 앱 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다.
* 테스트가 설정을 따로 흉내 내면 둘이 어긋나도 테스트는 통과해 버린다.
* 리스닝·Swagger·종료 훅처럼 실행 환경에만 필요한 것은 main.ts 에 둔다.
*/
export function configureApp(app: INestApplication): void {
// 리버스 프록시 뒤에서 실제 클라이언트 IP 를 식별한다 (rate limit 이 IP 기준이다).
// 운영은 Nginx Proxy Manager → backend 로 한 번 거친다 (Custom location 이 X-Forwarded-For 에 클라이언트 IP 를 싣는다).
const expressApp = app.getHttpAdapter().getInstance() as Express;
expressApp.set('trust proxy', 1);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
// 리버스 프록시(운영 NPM · 개발 vite)는 /api 로 시작하는 요청을 경로 그대로 넘기므로 이 prefix 와 맞물려 있다.
app.setGlobalPrefix(GLOBAL_PREFIX, {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 — docker healthcheck 가 사용한다
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 2. CORS — 기본 구성은 프론트와 API 가 같은 도메인이라(브라우저는 /api 상대경로로 호출) 필요 없다.
// 다른 오리진에서 호출해야 할 때만 루트 .env 의 CORS_ORIGIN 에 콤마로 나열한다 (shared/config/cors.config.ts)
app.enableCors(buildCorsOptions());
// 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다.
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
}
@@ -2,6 +2,7 @@ import { HttpStatus } from '@nestjs/common';
import {
ERROR_CODES,
ErrorCodeDefinition,
FALLBACK_ERROR_CODE,
getErrorDefinition,
isErrorCode,
@@ -45,7 +46,10 @@ describe('에러 코드 레지스트리', () => {
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
// ERROR_CODES 는 as const 라 clientOnly 가 없는 항목의 타입에는 그 키가 없다 — 공통 정의 타입으로 읽는다
const definitions: [string, ErrorCodeDefinition][] =
Object.entries(ERROR_CODES);
const clientOnlyCodes = definitions
.filter(([, definition]) => definition.clientOnly)
.map(([code]) => code);
+29 -86
View File
@@ -1,109 +1,52 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import helmet from 'helmet';
import type {
Express,
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { configureApp, GLOBAL_PREFIX } from '@/app.setup';
import {
isSwaggerEnabled,
setupSwagger,
SWAGGER_PATH,
} from '@/shared/config/swagger.config';
import { winstonConfig } from '@/shared/logger/winston.config';
import { parsePositiveInt } from '@/shared/utils/env';
/** 루트 .env 에 BACKEND_PORT 가 없을 때만 쓰는 기본 포트 */
const DEFAULT_PORT = 3000;
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
});
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
const expressApp = app.getHttpAdapter().getInstance() as Express;
expressApp.set('trust proxy', 1);
// 보안 헤더·CORS·prefix·검증 파이프·응답 래퍼 등 전역 설정 — e2e 테스트와 같은 함수를 쓴다 (app.setup.ts)
configureApp(app);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
app.setGlobalPrefix('api', {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// Swagger API 문서 — 운영에서는 기본으로 끈다 (shared/config/swagger.config.ts)
const swaggerEnabled = isSwaggerEnabled();
if (swaggerEnabled) setupSwagger(app);
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 2. 화이트리스트 기반 CORS 정책
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
app.enableCors({
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
// CORS 헤더를 부여하지 않아 브라우저가 차단하도록 한다.
callback(null, false);
}
},
credentials: true, // 쿠키 교환 허용
});
// 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다.
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// 6. Swagger API 문서 설정
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
// Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
app.enableShutdownHooks();
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
const port = Number(process.env.PORT ?? 3000);
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다 (Number('') 는 0 → 임의 포트)
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
await app.listen(port, '0.0.0.0');
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
Logger.log(
`📘 Swagger docs at http://localhost:${port}/api-docs`,
`🚀 Backend running on http://localhost:${port}/${GLOBAL_PREFIX}`,
'Bootstrap',
);
if (swaggerEnabled) {
Logger.log(
`📘 Swagger docs at http://localhost:${port}/${SWAGGER_PATH}`,
'Bootstrap',
);
}
}
void bootstrap();
@@ -1,4 +1,8 @@
import { HttpStatus, ServiceUnavailableException } from '@nestjs/common';
import {
HttpStatus,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import {
HealthCheckResult,
@@ -29,6 +33,15 @@ describe('HealthController', () => {
let check: jest.Mock;
let res: { status: jest.Mock };
beforeAll(() => {
// 의도된 실패의 error 로그가 테스트 출력을 오염시키지 않도록 억제
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(async () => {
check = jest.fn();
res = { status: jest.fn() };
@@ -74,6 +87,28 @@ describe('HealthController', () => {
expect(JSON.stringify(result)).toContain('database');
});
it('운영에서는 구성요소별 status 만 노출한다 (오류 문구에 내부 주소·계정이 담길 수 있다)', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
check.mockRejectedValue(new ServiceUnavailableException(downResult));
try {
const result = await controller.check(res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
// 어느 구성요소가 죽었는지는 그대로 보인다
expect(result).toEqual({
status: 'error',
info: {},
error: { database: { status: 'down' } },
details: { database: { status: 'down' } },
});
expect(JSON.stringify(result)).not.toContain('timeout');
} finally {
process.env.NODE_ENV = originalEnv;
}
});
it('terminus 예외가 아닌 오류는 전역 필터로 넘긴다', async () => {
check.mockRejectedValue(new Error('예상치 못한 오류'));
@@ -2,6 +2,7 @@ import {
Controller,
Get,
HttpStatus,
Logger,
Res,
ServiceUnavailableException,
} from '@nestjs/common';
@@ -11,17 +12,46 @@ import {
HealthCheck,
HealthCheckResult,
HealthCheckService,
HealthIndicatorResult,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import type { Response } from 'express';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { isProductionEnv } from '@/shared/utils/env';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
// 커넥션 풀의 유휴 커넥션이 정리된 뒤(idleTimeoutMillis) 재연결하는 경우 1초를 넘겨
// 정상인 DB 를 down 으로 오판하고 헬스체크가 간헐적으로 실패한다.
const DB_PING_TIMEOUT_MS = 5_000;
/** 구성요소별 결과에서 status 만 남긴다 */
function keepStatusOnly(
section: Partial<HealthIndicatorResult> | undefined,
): HealthIndicatorResult {
return Object.fromEntries(
Object.entries(section ?? {}).flatMap(([key, value]) =>
value ? [[key, { status: value.status }]] : [],
),
);
}
/**
* 실패 결과에서 구성요소별 status 만 남기고 오류 문구(message 등)는 뺀다.
* 드라이버 오류 문구에는 내부 주소·DB 계정명이 담길 수 있다 ("connect ECONNREFUSED 172.18.0.2:5432").
* 어느 구성요소가 죽었는지는 그대로 보이므로 모니터링에는 지장이 없다. 원인은 서버 로그에 남는다.
*/
export function hideFailureDetails(
result: HealthCheckResult,
): HealthCheckResult {
return {
status: result.status,
info: keepStatusOnly(result.info),
error: keepStatusOnly(result.error),
details: keepStatusOnly(result.details),
};
}
/**
* 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템이 읽는다.
*
@@ -30,6 +60,7 @@ const DB_PING_TIMEOUT_MS = 5_000;
*
* - 성공 200: `{ status: 'ok', info: { database: { status: 'up' } }, ... }`
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
* (운영에서는 message 같은 상세를 빼고 구성요소별 status 만 노출한다 — 원인은 error 로그로 남긴다)
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
*/
@@ -38,6 +69,8 @@ const DB_PING_TIMEOUT_MS = 5_000;
@ApiTags('Health')
@Controller('health')
export class HealthController {
private readonly logger = new Logger(HealthController.name);
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
@@ -64,8 +97,13 @@ export class HealthController {
// terminus 는 실패 시 ServiceUnavailableException 을 던지며 본문에 상세 결과를 담는다.
// 전역 필터가 SYS_001 로 덮어쓰지 않도록 여기서 직접 503 과 원본 본문을 반환한다.
if (error instanceof ServiceUnavailableException) {
const result = error.getResponse() as HealthCheckResult;
// 서버 책임 장애(DB 접속 실패 등)이므로 error 레벨 — 운영에서 응답에서 뺀 원인을 여기서 추적한다
this.logger.error(`헬스체크 실패: ${JSON.stringify(result.error)}`);
res.status(HttpStatus.SERVICE_UNAVAILABLE);
return error.getResponse() as HealthCheckResult;
return isProductionEnv() ? hideFailureDetails(result) : result;
}
throw error;
}
@@ -1,4 +1,42 @@
import { allowedExtensionList, isAllowedFile } from './upload.config';
import fs, { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
allowedExtensionList,
ensureUploadDir,
isAllowedFile,
} from './upload.config';
describe('업로드 디렉터리 준비', () => {
let base: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'upload-dir-'));
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(base, { recursive: true, force: true });
});
it('없는 디렉터리는 하위 경로까지 만든다', () => {
const dir = join(base, 'nested', 'uploads');
ensureUploadDir(dir);
expect(existsSync(dir)).toBe(true);
});
it('쓸 수 없으면 소유권을 맞추는 방법과 함께 기동을 멈춘다', () => {
// Linux 에서 root 소유로 만들어진 바인드 마운트를 흉내 낸다 (Windows 는 디렉터리 권한을 재현할 수 없다)
jest.spyOn(fs, 'accessSync').mockImplementation(() => {
throw new Error('EACCES: permission denied');
});
expect(() => ensureUploadDir(base)).toThrow(/sudo chown -R 1000:1000/);
});
});
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
@@ -1,3 +1,4 @@
import { accessSync, constants, mkdirSync } from 'fs';
import { extname } from 'path';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -12,6 +13,29 @@ import { parsePositiveInt } from '@/shared/utils/env';
/** 업로드 파일 저장 디렉터리 — 컨테이너에서는 compose 볼륨이 연결된다 */
export const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
/**
* 업로드 디렉터리를 만들고 **쓰기 가능한지** 확인한다 — 안 되면 기동을 멈춘다 (fail-fast).
*
* 컨테이너의 앱은 비-root(node, uid 1000)로 실행된다. Linux 서버에서 compose 가
* 없는 호스트 디렉터리를 바인드 마운트하면 root 소유로 만들어지므로,
* 그대로 두면 서버는 뜨지만 첫 업로드에서야 SYS_001 로 터진다.
*/
export function ensureUploadDir(dir: string): void {
try {
mkdirSync(dir, { recursive: true });
accessSync(dir, constants.W_OK);
} catch (error) {
throw new Error(
[
`업로드 디렉터리에 쓸 수 없어 서버를 시작할 수 없습니다: ${dir}`,
` (${error instanceof Error ? error.message : String(error)})`,
'컨테이너의 앱은 node(uid 1000)로 실행됩니다. 호스트의 업로드 디렉터리 소유자를 맞춰 주세요:',
' sudo chown -R 1000:1000 ./data/uploads # UPLOAD_DATA_DIR 을 바꿨다면 그 경로',
].join('\n'),
);
}
}
const MEGABYTE = 1024 * 1024;
/** 파일 1개 최대 크기 (기본 5MB) */
+3 -3
View File
@@ -2,20 +2,20 @@ import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { mkdirSync } from 'fs';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
import {
MAX_FILE_SIZE_BYTES,
UPLOAD_DIR,
ensureUploadDir,
isAllowedFile,
} from './upload.config';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
mkdirSync(UPLOAD_DIR, { recursive: true });
// 저장 디렉터리는 기동 시 한 번 보장한다 (없거나 쓸 수 없으면 multer 가 매 요청 실패한다)
ensureUploadDir(UPLOAD_DIR);
@Module({
imports: [
@@ -0,0 +1,88 @@
import {
buildCorsOptions,
isValidOrigin,
parseCorsOrigins,
} from './cors.config';
/**
* enableCors 의 origin 콜백을 실행해 CORS 헤더를 부여하는지 돌려받는다.
* 차단할 때도 에러를 넘기지 않아야 한다 (에러는 500 이 되어 원인을 가린다).
*/
function isAllowed(
corsOrigin: string | undefined,
requestOrigin: string | undefined,
): boolean {
const { origin } = buildCorsOptions(corsOrigin);
if (typeof origin !== 'function') {
throw new Error('origin 은 콜백이어야 한다');
}
let allowed: unknown;
origin(requestOrigin, (error, result) => {
expect(error).toBeNull();
allowed = result;
});
return allowed === true;
}
describe('parseCorsOrigins', () => {
it('비어 있으면 빈 목록이다 (같은 도메인 기본 구성)', () => {
expect(parseCorsOrigins(undefined)).toEqual([]);
expect(parseCorsOrigins('')).toEqual([]);
expect(parseCorsOrigins(' , ')).toEqual([]);
});
it('콤마로 나누고 공백·끝 슬래시를 제거한다', () => {
expect(
parseCorsOrigins('https://a.myservice.io, https://b.myservice.io/ ,'),
).toEqual(['https://a.myservice.io', 'https://b.myservice.io']);
});
it('브라우저 Origin 헤더처럼 소문자로 맞춘다', () => {
expect(parseCorsOrigins('https://App.MyService.io')).toEqual([
'https://app.myservice.io',
]);
});
});
describe('isValidOrigin', () => {
it('scheme://host[:port] 만 오리진으로 인정한다', () => {
expect(isValidOrigin('https://app.myservice.io')).toBe(true);
expect(isValidOrigin('http://localhost:5173')).toBe(true);
});
it('경로가 붙거나 scheme 이 없거나 와일드카드면 오리진이 아니다', () => {
expect(isValidOrigin('https://app.myservice.io/api')).toBe(false);
expect(isValidOrigin('app.myservice.io')).toBe(false);
expect(isValidOrigin('*')).toBe(false);
});
});
describe('buildCorsOptions', () => {
it('화이트리스트가 비어 있으면 다른 오리진에 CORS 헤더를 주지 않는다', () => {
expect(isAllowed(undefined, 'https://other.io')).toBe(false);
expect(isAllowed('', 'https://other.io')).toBe(false);
});
it('Origin 헤더가 없는 요청(서버 간 통신)은 허용한다', () => {
expect(isAllowed(undefined, undefined)).toBe(true);
});
it('콤마로 나열한 오리진을 각각 허용한다', () => {
const corsOrigin = 'https://app.myservice.io, https://admin.myservice.io';
expect(isAllowed(corsOrigin, 'https://app.myservice.io')).toBe(true);
expect(isAllowed(corsOrigin, 'https://admin.myservice.io')).toBe(true);
expect(isAllowed(corsOrigin, 'https://other.io')).toBe(false);
});
it('개발용 localhost 를 암묵적으로 허용하지 않는다 (운영에서도 열려 버린다)', () => {
expect(isAllowed('https://app.myservice.io', 'http://localhost:5173')).toBe(
false,
);
});
it('인증 쿠키를 주고받을 수 있게 credentials 를 켠다', () => {
expect(buildCorsOptions('').credentials).toBe(true);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
/**
* CORS 설정 — main.ts(런타임)와 env.validation.ts(기동 검증)가 같은 파서를 공유한다.
*
* 기본 구성은 프론트와 API 가 **같은 도메인**이다. 브라우저는 `/api` 상대경로로만 호출하고
* 리버스 프록시(운영: Nginx Proxy Manager 의 Custom location, 개발: vite dev 프록시)가 백엔드로 넘긴다.
* 같은 출처 요청에는 CORS 가 적용되지 않으므로 **화이트리스트가 비어 있는 것이 정상**이다.
*
* 다른 오리진(별도 도메인의 프론트, Flutter Web 등)에서 호출해야 할 때만
* 루트 .env 의 CORS_ORIGIN 에 콤마로 나열한다.
*/
/** 오리진 형식 — `scheme://host[:port]`. 경로·쿼리가 붙으면 브라우저의 Origin 헤더와 절대 일치하지 않는다 */
const ORIGIN_PATTERN = /^https?:\/\/[^\s/?#]+$/;
/**
* CORS_ORIGIN 을 오리진 목록으로 파싱한다.
* 콤마로 나누고 공백·끝 슬래시를 제거한다. 브라우저는 Origin 을 소문자로 보내므로 소문자로 맞춘다.
*/
export function parseCorsOrigins(value: string | undefined): string[] {
if (value === undefined) return [];
return value
.split(',')
.map((entry) => entry.trim().replace(/\/+$/, '').toLowerCase())
.filter((entry) => entry !== '');
}
/** 브라우저가 보내는 Origin 헤더와 비교할 수 있는 형식인지 검사한다 */
export function isValidOrigin(origin: string): boolean {
return ORIGIN_PATTERN.test(origin);
}
/**
* `app.enableCors()` 옵션을 만든다.
*
* - Origin 헤더가 없는 요청(서버 간 통신·헬스체크)은 CORS 대상이 아니므로 허용한다.
* - 화이트리스트 밖이면 Error 대신 `false` 를 넘긴다. Error 는 500(SYS_001)이 되어 원인을 가리고,
* `false` 는 CORS 헤더만 빼서 **브라우저가** 막게 한다.
* 같은 출처 요청은 이 헤더 없이도 통과하므로 기본 구성(같은 도메인)에는 영향이 없다.
* - 개발용 localhost 를 여기에 하드코딩하지 않는다 — 운영에서도 그대로 열려 버린다.
*/
export function buildCorsOptions(
corsOrigin: string | undefined = process.env.CORS_ORIGIN,
): CorsOptions {
const allowedOrigins = new Set(parseCorsOrigins(corsOrigin));
return {
origin: (requestOrigin, callback) => {
callback(
null,
requestOrigin === undefined || allowedOrigins.has(requestOrigin),
);
},
// 다른 오리진을 허용한 경우에도 인증 쿠키를 주고받을 수 있게 한다
credentials: true,
};
}
@@ -0,0 +1,32 @@
import { buildDatabaseOptions } from './database.config';
/** 풀 설정은 드라이버 옵션(extra)으로 넘어간다 */
const poolMax = (): number =>
(buildDatabaseOptions().extra as { max: number }).max;
describe('buildDatabaseOptions — 커넥션 풀 크기', () => {
const original = process.env.DB_POOL_MAX;
afterEach(() => {
if (original === undefined) {
delete process.env.DB_POOL_MAX;
} else {
process.env.DB_POOL_MAX = original;
}
});
it('기본값은 10 이다 (PostgreSQL 기본 max_connections 100 을 앱 하나가 다 잡지 않게)', () => {
delete process.env.DB_POOL_MAX;
expect(poolMax()).toBe(10);
});
it('DB_POOL_MAX 로 조정할 수 있다', () => {
process.env.DB_POOL_MAX = '25';
expect(poolMax()).toBe(25);
});
it('빈 값이면 기본값을 쓴다 (compose 가 미정의 변수를 빈 문자열로 넘긴다)', () => {
process.env.DB_POOL_MAX = '';
expect(poolMax()).toBe(10);
});
});
+17 -9
View File
@@ -1,7 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { parsePositiveInt } from '@/shared/utils/env';
import { isProductionEnv, parsePositiveInt } from '@/shared/utils/env';
/**
* DB 접속 설정 — 앱(TypeOrmModule)과 CLI(data-source.ts)가 **같은 설정을 공유**한다.
@@ -19,17 +19,21 @@ const DEV_DB_DEFAULTS = {
database: 'project_db',
} as const;
/** 커넥션 풀 설정 */
const POOL_OPTIONS = {
max: 100,
/**
* 커넥션 풀 크기 기본값 — node-postgres 의 기본값과 같다.
*
* PostgreSQL 의 기본 max_connections 는 100 이다. 풀을 100 으로 두면 앱 하나가 접속을 다 잡아
* 마이그레이션 CLI·psql·두 번째 인스턴스가 "too many clients" 로 붙지 못한다.
* 인스턴스 수 × DB_POOL_MAX 에 여유분을 더한 값이 max_connections 를 넘지 않게 루트 .env 에서 조정한다.
*/
const DEFAULT_POOL_MAX = 10;
/** 커넥션 풀 타임아웃 */
const POOL_TIMEOUTS = {
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
} as const;
export function isProductionEnv(): boolean {
return process.env.NODE_ENV === 'production';
}
/**
* 스키마 자동 동기화 사용 여부.
*
@@ -72,7 +76,11 @@ export function buildDatabaseOptions(): DataSourceOptions {
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
logging: !isProduction,
extra: { ...POOL_OPTIONS },
extra: {
...POOL_TIMEOUTS,
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다
max: parsePositiveInt(process.env.DB_POOL_MAX, DEFAULT_POOL_MAX),
},
entities: paths.entities,
migrations: paths.migrations,
};
@@ -32,6 +32,52 @@ describe('validateEnv', () => {
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
it('DB 커넥션 풀 크기도 양의 정수여야 한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', DB_POOL_MAX: '0' }),
).toThrow(/DB_POOL_MAX 는 양의 정수/);
});
it('SWAGGER_ENABLED 는 true/false 만 받는다 (오타가 기본값으로 묻히지 않게)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', SWAGGER_ENABLED: 'ture' }),
).toThrow(/SWAGGER_ENABLED 는 true 또는 false/);
expect(() =>
validateEnv({ NODE_ENV: 'development', SWAGGER_ENABLED: '' }),
).not.toThrow();
});
});
describe('CORS_ORIGIN 형식 (모든 환경)', () => {
const dev = (corsOrigin: string) => ({
NODE_ENV: 'development',
CORS_ORIGIN: corsOrigin,
});
it('비어 있으면 통과한다 (같은 도메인 기본 구성)', () => {
expect(() => validateEnv(dev(''))).not.toThrow();
});
it('콤마로 여러 오리진을 적을 수 있다 (끝 슬래시는 허용)', () => {
expect(() =>
validateEnv(dev('http://localhost:5173, https://admin.myservice.io/')),
).not.toThrow();
});
it('경로가 붙은 값은 Origin 헤더와 일치할 수 없으므로 실패한다', () => {
expect(() => validateEnv(dev('https://app.myservice.io/api'))).toThrow(
/CORS_ORIGIN 의 "https:\/\/app\.myservice\.io\/api" 는 오리진 형식이 아닙니다/,
);
});
it('scheme 이 없거나 와일드카드면 실패한다', () => {
expect(() => validateEnv(dev('app.myservice.io'))).toThrow(
/오리진 형식이 아닙니다/,
);
expect(() => validateEnv(dev('*'))).toThrow(/오리진 형식이 아닙니다/);
});
});
describe('운영 환경', () => {
@@ -39,6 +85,12 @@ describe('validateEnv', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('같은 도메인 기본 구성(BACKEND_API_URL=/api, CORS_ORIGIN 비움)은 통과한다', () => {
expect(() =>
validateEnv(prod({ BACKEND_API_URL: '/api', CORS_ORIGIN: '' })),
).not.toThrow();
});
it('필수 DB 설정이 없으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
/DB_PASSWORD 가 설정되지 않았습니다/,
@@ -61,6 +113,55 @@ describe('validateEnv', () => {
);
});
it('공개 주소에 로컬 주소가 남아 있으면 실패한다', () => {
expect(() =>
validateEnv(prod({ CORS_ORIGIN: 'http://localhost:5173' })),
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
expect(() =>
validateEnv(prod({ BACKEND_API_URL: 'http://127.0.0.1:3000/api' })),
).toThrow(/BACKEND_API_URL 가 운영에서 쓸 수 없는 주소입니다/);
});
it('예시 파일의 example.com 이 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ CORS_ORIGIN: 'https://app.example.com' })),
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
});
it('실제 도메인이면 통과한다', () => {
expect(() =>
validateEnv(
prod({
CORS_ORIGIN: 'https://app.myservice.io',
BACKEND_API_URL: 'https://api.myservice.io/api',
}),
),
).not.toThrow();
});
it('ALLOW_LOCALHOST_URL=true 면 공개 주소 검사를 건너뛴다 (운영 이미지 로컬 검증용)', () => {
expect(() =>
validateEnv(
prod({
CORS_ORIGIN: 'http://localhost:5173',
BACKEND_API_URL: 'http://localhost:3000/api',
ALLOW_LOCALHOST_URL: 'true',
}),
),
).not.toThrow();
});
it('공개 주소 검사는 운영 환경에서만 동작한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'http://localhost:5173',
BACKEND_API_URL: 'http://localhost:3000/api',
}),
).not.toThrow();
});
it('문제가 여러 개면 한 번에 모두 보여준다', () => {
const partial = omit(prod(), 'DB_HOST', 'DB_NAME');
+72 -2
View File
@@ -7,6 +7,8 @@
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { isValidOrigin, parseCorsOrigins } from './cors.config';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
@@ -22,10 +24,14 @@ const REQUIRED_IN_PRODUCTION = [
const POSITIVE_INT_KEYS = [
'PORT',
'DB_PORT',
'DB_POOL_MAX',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
] as const;
/** 값이 있다면 true/false 여야 하는 키 — 오타(ture 등)가 조용히 기본값으로 처리되지 않게 한다 */
const BOOLEAN_KEYS = ['SWAGGER_ENABLED'] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
@@ -33,6 +39,31 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [
'JWT_SECRET',
] as const;
/**
* 운영에서 **공개 주소**여야 하는 키 (값이 있을 때만 검사한다).
*
* 기본 구성(같은 도메인)은 BACKEND_API_URL=/api · CORS_ORIGIN 비움이라 걸릴 값이 없다.
* API 를 별도 도메인으로 분리한 뒤 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
* 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다 — 그 실수를 잡는다.
*/
const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const;
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
const NON_PUBLIC_URL_PATTERNS = [
/localhost/i,
/127\.0\.0\.1/,
/\[::1\]/,
/0\.0\.0\.0/,
/example\.com/i,
];
/**
* 위 공개 주소 검사를 건너뛰는 탈출구.
* 로컬에서 **운영 이미지를 검증**할 때만 쓴다 (예: graceful shutdown 확인 — .claude/skills/docker/SKILL.md).
* 실제 배포에서는 절대 설정하지 않는다.
*/
const ALLOW_LOCALHOST_URL_KEY = 'ALLOW_LOCALHOST_URL';
function readString(
config: Record<string, unknown>,
key: string,
@@ -47,6 +78,10 @@ function looksLikePlaceholder(value: string): boolean {
return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
}
function looksNonPublic(value: string): boolean {
return NON_PUBLIC_URL_PATTERNS.some((pattern) => pattern.test(value));
}
/**
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
@@ -68,15 +103,34 @@ export function validateEnv(
}
}
// 2. 참/거짓 값 — 비어 있으면 코드의 기본 동작을 따른다
for (const key of BOOLEAN_KEYS) {
const value = readString(config, key);
if (value !== undefined && value !== 'true' && value !== 'false') {
errors.push(
`${key} 는 true 또는 false 여야 합니다. (현재 값: "${value}")`,
);
}
}
// 3. CORS 오리진 형식 — 브라우저 Origin 헤더와 모양이 다르면 절대 일치하지 않아 **조용히** 막힌다
for (const origin of parseCorsOrigins(readString(config, 'CORS_ORIGIN'))) {
if (!isValidOrigin(origin)) {
errors.push(
`CORS_ORIGIN 의 "${origin}" 는 오리진 형식이 아닙니다. scheme://host[:port] 형태로 경로 없이 적어 주세요. (여러 개면 콤마로 구분)`,
);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
// 4. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
if (readString(config, key) === undefined) {
errors.push(`${key} 가 설정되지 않았습니다. (운영 환경 필수)`);
}
}
// 3. 예시 파일의 placeholder 가 그대로 배포되는 것을 막는다
// 5. 예시 파일의 placeholder 가 그대로 배포되는 것을 막는다
for (const key of NO_PLACEHOLDER_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksLikePlaceholder(value)) {
@@ -85,6 +139,22 @@ export function validateEnv(
);
}
}
// 6. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
if (!allowLocalhostUrl) {
for (const key of PUBLIC_URL_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksNonPublic(value)) {
errors.push(
`${key} 가 운영에서 쓸 수 없는 주소입니다. 실제 도메인으로 교체해 주세요. (현재 값: "${value}")` +
` — 로컬에서 운영 이미지를 검증하는 중이라면 ${ALLOW_LOCALHOST_URL_KEY}=true`,
);
}
}
}
}
if (errors.length > 0) {
@@ -0,0 +1,24 @@
import { isSwaggerEnabled } from './swagger.config';
describe('isSwaggerEnabled', () => {
it('운영에서는 기본으로 끈다 (API 구조 전체가 공개되므로)', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'production' })).toBe(false);
expect(
isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: '' }),
).toBe(false);
});
it('운영이 아니면 기본으로 켠다', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'development' })).toBe(true);
expect(isSwaggerEnabled({})).toBe(true);
});
it('SWAGGER_ENABLED 를 명시하면 환경과 무관하게 따른다', () => {
expect(
isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: 'true' }),
).toBe(true);
expect(
isSwaggerEnabled({ NODE_ENV: 'development', SWAGGER_ENABLED: 'false' }),
).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { isProductionEnv } from '@/shared/utils/env';
/**
* Swagger UI 경로.
* 같은 도메인 구성에서는 리버스 프록시의 location /api 접두어 매칭에 함께 걸려 백엔드로 간다.
*/
export const SWAGGER_PATH = 'api-docs';
/**
* Swagger 문서 노출 여부.
* 문서는 API 구조 전체(경로·파라미터·스키마)를 보여주므로 **운영에서는 기본으로 끈다.**
*
* - SWAGGER_ENABLED=true → 항상 켠다 (운영에서 켤 때는 NPM Access List 등으로 접근을 제한한다)
* - SWAGGER_ENABLED=false → 항상 끈다
* - 비어 있음 → 운영이 아니면 켠다
*/
export function isSwaggerEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
const flag = env.SWAGGER_ENABLED?.trim();
if (flag === 'true') return true;
if (flag === 'false') return false;
return !isProductionEnv(env);
}
/** Swagger 문서를 SWAGGER_PATH 에 붙인다 */
export function setupSwagger(app: INestApplication): void {
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup(SWAGGER_PATH, app, document);
}
+8
View File
@@ -20,3 +20,11 @@ export function parsePositiveInt(
return parsed;
}
/**
* 운영 환경인지 확인한다.
* NODE_ENV 는 compose 가 루트 APP_ENV 로 주입한다 (development | production).
*/
export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'production';
}
+6 -17
View File
@@ -1,14 +1,11 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '@/app.module';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { configureApp } from '@/app.setup';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
@@ -44,18 +41,10 @@ describe('응답 계약 (e2e)', () => {
app = moduleFixture.createNestApplication();
// main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다
app.setGlobalPrefix('api');
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// main.ts 와 같은 전역 설정 함수를 그대로 쓴다 (app.setup.ts).
// 여기서 설정을 따로 흉내 내면 둘이 어긋나도 테스트는 통과해 버린다.
// 추적 ID 는 AppModule 의 RequestIdMiddleware 가 붙인다 — 운영과 같은 경로다.
configureApp(app);
await app.init();
});
+1 -1
View File
@@ -63,7 +63,7 @@ GitHub Actions 는 잡이 VM 위에서 직접 돌기 때문에 관례가 다르
**`ci.yml`** — 모든 푸시·PR 에서 실행
- `backend` — postgres 서비스 컨테이너를 띄워 단위 테스트와 e2e 를 실행
- `backend` — lint·타입 검사 후 postgres 서비스 컨테이너를 띄워 단위 테스트와 e2e 를 실행
- `frontend` — lint·type-check·테스트
- `error-codes-sync` — 에러 코드 생성 파일 드리프트 검사
+18 -2
View File
@@ -1,5 +1,20 @@
services:
# ----------------------------------------
# frontend — dev 에서는 기본 기동하지 않는다 (profile 로 숨김)
#
# vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트(./frontend:/app) 너머에 있어
# 수천 개 파일을 매 요청마다 가상 파일시스템으로 읽는다. 여기에 폴링 감시까지 겹쳐
# 첫 서빙까지 수 분이 걸린다. 호스트에서 직접 띄우면 같은 작업이 1~2초다.
#
# cd frontend && npm install && npm run dev # → http://localhost:5173
#
# vite dev 프록시가 /api 를 localhost:${BACKEND_PORT} 로 넘기므로 백엔드는 컨테이너 그대로 쓴다.
# 그래도 컨테이너로 띄워야 한다면 (호스트에 Node 를 깔 수 없는 환경 등):
# docker compose ... --profile frontend up -d
# 또는 루트 .env.development 에 COMPOSE_PROFILES=frontend
# ----------------------------------------
frontend:
profiles: ["frontend"]
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
restart: "no"
build:
@@ -12,6 +27,8 @@ services:
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ || exit 1"]
environment:
# 바인드 마운트 너머의 변경은 inotify 가 잡지 못하므로 컨테이너에서만 폴링을 켠다
# (vite.config.ts 가 이 값을 보고 watch.usePolling 을 결정한다 — 호스트 실행 시엔 꺼진다)
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
# vite dev 서버가 /api 요청을 백엔드 컨테이너로 프록시한다 (vite.config.ts)
@@ -31,7 +48,6 @@ services:
command: npm run start:dev
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
- "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}"
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
@@ -60,4 +76,4 @@ services:
# base의 proxy 외부 네트워크 의존을 dev에서 제거
networks:
proxy: !reset null
proxy: !reset null
+33 -6
View File
@@ -1,6 +1,16 @@
# ==========================================
# 운영 라우팅 — 프론트와 API 는 같은 도메인을 쓴다
#
# https://<도메인>/ → frontend:8080 (Nginx Proxy Manager Proxy Host 의 기본 대상)
# https://<도메인>/api/... → backend:${BACKEND_PORT} (같은 Proxy Host 의 Custom location /api)
#
# 브라우저는 /api 상대경로로만 호출하므로 CORS 가 필요 없다.
# NPM 은 아래 proxy 외부 네트워크로 컨테이너 이름(${PROJECT_NAME}-frontend / -backend)에 접근한다.
# 설정 절차는 README "운영 배포" 참조.
# ==========================================
services:
# ----------------------------------------
# 1. Frontend (Nginx 정적 호스팅)
# 1. Frontend (Nginx 정적 호스팅) — NPM Proxy Host 의 기본 대상(/)
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
@@ -9,7 +19,8 @@ services:
dockerfile: Dockerfile
args:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
# 같은 도메인 구성이면 /api 그대로 — 도메인이 바뀌어도 이미지를 다시 빌드할 필요가 없다
- VITE_API_BASE_URL=${BACKEND_API_URL:-/api}
expose:
- "8080"
environment:
@@ -34,7 +45,10 @@ services:
memory: 512m
# ----------------------------------------
# 2. Backend (HTTP API & WebSocket 통합)
# 2. Backend (HTTP API) — NPM Custom location /api 의 대상
#
# WebSocket 을 도입할 때도 별도 포트를 열지 않는다. 게이트웨이를 HTTP 서버에 붙이고
# 경로를 /api 아래에 두면 같은 도메인·같은 location 을 그대로 탄다 (.claude/skills/nestjs/SKILL.md)
# ----------------------------------------
backend:
container_name: ${PROJECT_NAME}-backend
@@ -43,7 +57,6 @@ services:
dockerfile: Dockerfile
expose:
- "${BACKEND_PORT}"
- "${WEBSOCKET_PORT}"
# PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown)
init: true
# 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초)
@@ -53,8 +66,15 @@ services:
environment:
- NODE_ENV=${APP_ENV}
- PORT=${BACKEND_PORT}
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
- CORS_ORIGIN=${CORS_ORIGIN}
# 비어 있으면 운영에서만 Swagger(/api-docs)를 끈다 — true/false 로 강제할 수 있다
- SWAGGER_ENABLED=${SWAGGER_ENABLED:-}
# 같은 도메인 구성에서는 비어 있다 — 다른 오리진에서 호출할 때만 콤마로 나열
- CORS_ORIGIN=${CORS_ORIGIN:-}
# 앱이 직접 쓰지는 않는다. API 를 별도 도메인으로 분리했을 때 로컬 주소가 남은 채
# 운영에 배포되는 것을 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts)
- BACKEND_API_URL=${BACKEND_API_URL:-/api}
# 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true
- ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-}
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000}
- THROTTLE_LIMIT=${THROTTLE_LIMIT:-100}
- DB_HOST=db
@@ -62,6 +82,7 @@ services:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_POOL_MAX=${DB_POOL_MAX:-10}
- DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false}
- UPLOAD_DIR=/app/uploads
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
@@ -69,6 +90,9 @@ services:
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다
# ⚠️ Linux 서버: 앱은 node(uid 1000)로 돈다. 호스트 디렉터리가 없으면 Docker 가 root 소유로 만들어
# 기동이 실패하므로(업로드 디렉터리 쓰기 검사) 처음 한 번 소유자를 맞춘다:
# mkdir -p data/uploads && sudo chown 1000:1000 data/uploads
volumes:
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
restart: unless-stopped
@@ -162,5 +186,8 @@ services:
networks:
app-network:
driver: bridge
# 리버스 프록시(Nginx Proxy Manager)와 공유하는 외부 네트워크 — frontend·backend 만 붙는다 (db·redis 는 내부 전용)
# compose 가 만들지 않으므로 서버에서 한 번 만들어 두어야 기동한다: docker network create proxy
# NPM 은 컨테이너 이름으로 찾아오므로 같은 서버의 프로젝트끼리 PROJECT_NAME 이 겹치면 안 된다.
proxy:
external: true
+1
View File
@@ -9,4 +9,5 @@
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함)
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api
# 운영: 웹과 같은 도메인의 /api — 예) https://app.your-domain.com/api
API_URL=http://localhost:3000/api
+25
View File
@@ -0,0 +1,25 @@
# ==========================================
# 빌드 컨텍스트에서 제외할 파일
#
# Dockerfile 의 `COPY . .` 가 아래 항목까지 이미지에 넣지 않도록 막는다.
# ==========================================
# 의존성·빌드 산출물 — 컨테이너 안에서 npm ci·빌드를 다시 한다.
# 호스트(Windows) node_modules 가 섞이면 네이티브 바이너리가 리눅스용을 덮어써 빌드가 깨진다.
node_modules
dist
coverage
# 호스트 빌드용 env — 컨테이너 빌드의 값은 루트 .env 가 build arg 로만 넣는다 (SSOT: .claude/rules/env-structure.md)
# 여기서 막지 않으면 호스트에만 둔 로컬 설정이 운영 번들에 섞여 들어간다.
.env
.env.*
# 로그
logs
*.log
# 에디터·OS (.vscode/extensions.json 은 빌드에 쓰이지 않는다)
.vscode
.idea
.DS_Store
+6 -4
View File
@@ -1,15 +1,17 @@
# ==========================================
# 프론트엔드 환경변수 (development)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.development 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# ⚠️ 보통은 이 파일이 필요 없다.
# API 는 같은 도메인의 /api 로 호출하고(useApi.ts 기본값), vite dev 프록시가 백엔드로 넘긴다.
# 컨테이너 빌드 때는 루트 .env.development 의 BACKEND_API_URL 이 build arg 로 주입된다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# 백엔드를 프록시 없이 직접 호출해야 하는 특수한 경우에만 복사해 쓴다.
# cp .env.development.example .env.development
# → 절대 URL 로 바꾸면 다른 오리진 호출이 되므로 루트 CORS_ORIGIN 에 이 프론트 주소를 추가해야 한다.
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
# VITE_API_BASE_URL=/api
+6 -5
View File
@@ -1,15 +1,16 @@
# ==========================================
# 프론트엔드 환경변수 (production)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.production 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
# ⚠️ 보통은 이 파일이 필요 없다.
# API 는 같은 도메인의 /api 로 호출하고(useApi.ts 기본값), 리버스 프록시(Nginx Proxy Manager 의
# Custom location)가 백엔드로 넘긴다. 컨테이너 빌드 때는 루트 .env.production 의 BACKEND_API_URL 이
# build arg 로 주입된다. (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# compose 를 거치지 않고 호스트에서 직접 빌드할 때만 복사해 쓴다.
# cp .env.production.example .env.production
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
# VITE_API_BASE_URL=/api
+7 -4
View File
@@ -1,8 +1,8 @@
FROM node:20-alpine AS build-stage
FROM node:24-alpine AS build-stage
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 고정
# (베이스 이미지의 번들 npm 이 바뀌어도 lock 형식이 어긋나지 않게 한다. 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 개발 환경(docker-compose.dev.yml)은 이 스테이지에서 vite dev 서버를 구동하므로 비-root 로 전환한다.
@@ -13,10 +13,13 @@ USER node
COPY --chown=node:node package*.json ./
RUN npm ci
# 호스트의 node_modules·dist·.env 는 .dockerignore 로 제외된다
COPY --chown=node:node . .
ARG BUILD_MODE=production
ARG VITE_API_BASE_URL
# 브라우저가 호출할 API 경로 — 기본은 같은 도메인의 /api (리버스 프록시가 백엔드로 넘긴다).
# compose 가 루트 BACKEND_API_URL 을 넘긴다. API 를 별도 도메인에 둘 때만 절대 URL 이 들어온다.
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
+2 -1
View File
@@ -2,7 +2,8 @@
// Vite 환경변수 타입 정의 — VITE_ prefix 만 클라이언트 번들에 포함됨
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
/** API baseURL — 비어 있으면 같은 도메인의 /api (useApi.ts). 별도 도메인에 API 를 둘 때만 절대 URL */
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
+40 -8
View File
@@ -3,8 +3,15 @@ server {
listen 8080;
server_name localhost;
# 빌드 산출물 위치 — 모든 location 이 공유한다
root /usr/share/nginx/html;
# ----------------------------------------
# 보안 헤더
#
# ⚠️ nginx 는 location 안에 add_header 가 하나라도 있으면 여기(server)의 add_header 를
# **하나도 상속하지 않는다.** 그래서 아래 location 들은 캐시 정책을 add_header 대신
# expires 로 지정한다 — location 에 add_header 를 넣으면 그 응답에서 보안 헤더가 전부 사라진다.
# ----------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
@@ -16,7 +23,8 @@ server {
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : Vue 의 동적 스타일 바인딩(:style)이 인라인 스타일을 생성하므로 필요
# - connect-src 'self' : API 가 다른 도메인이면 그 origin 을 반드시 추가할 것
# - connect-src 'self' : API 는 같은 도메인의 /api 로 호출하므로 'self' 로 충분하다.
# API 를 별도 도메인에 둘 때만 그 origin 을 추가한다
# (예: connect-src 'self' https://api.example.com)
# - img-src data: blob: : 인라인 아이콘·캔버스/파일 미리보기 대응
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
@@ -33,19 +41,43 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ----------------------------------------
# /api 는 백엔드 전용 경로 — 여기까지 왔다면 리버스 프록시 설정이 빠진 것이다
#
# 운영에서는 Nginx Proxy Manager 의 Custom location(/api)이 이 경로를 backend 로 보내므로
# 이 컨테이너에는 도달하지 않는다. 그 설정이 빠지면 아래 SPA 폴백이 index.html 을 200 으로 돌려줘
# API 호출이 **조용히** HTML 을 받아 실패한다 — 404 로 막아 문제가 바로 드러나게 한다.
# (/api 로 시작하는 경로는 프론트엔드 라우트로 쓰지 않는다 — .claude/rules/api-design.md)
# ----------------------------------------
location ^~ /api {
return 404;
}
# ----------------------------------------
# 캐시 정책 — add_header 대신 expires 를 쓴다 (위 보안 헤더 주의 참조)
# ----------------------------------------
# SPA 진입점 — 매번 재검증한다 (Cache-Control: no-cache).
# 배포 직후 옛 index.html 이 캐시에 남으면 이미 지워진 해시 파일을 요청해 빈 화면이 된다.
# (/ 접속과 SPA 폴백도 내부적으로 /index.html 로 넘어오므로 모두 여기에 걸린다)
location = /index.html {
expires -1;
}
# 빌드 산출물(vite 의 assets 디렉터리) — 파일명에 내용 해시가 붙어 내용이 바뀌면 이름도 바뀌므로 오래 캐시해도 안전하다.
# 없는 파일은 index.html 로 폴백하지 않고 404 — HTML 을 JS 로 받아 생기는 MIME 오류를 막는다.
# (public/ 에서 복사되는 favicon 등은 해시가 없으므로 여기에 넣지 않는다)
location /assets/ {
expires 1y;
try_files $uri =404;
}
# ----------------------------------------
# SPA 라우팅
# ----------------------------------------
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
# 정적 자원 캐싱 (SPA 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
error_page 500 502 503 504 /50x.html;
+16 -1
View File
@@ -4,7 +4,22 @@ import {
ERROR_CODE_MESSAGES,
FALLBACK_ERROR_CODE,
} from '@/shared/constants/error-codes.generated'
import { getErrorMessage } from './useApi'
import { getErrorMessage, resolveApiBaseUrl } from './useApi'
describe('API 기본 경로', () => {
it('설정이 없으면 같은 도메인의 /api 로 호출한다 (리버스 프록시가 백엔드로 넘긴다)', () => {
expect(resolveApiBaseUrl(undefined)).toBe('/api')
// compose 는 정의되지 않은 변수를 빈 문자열로 넘긴다
expect(resolveApiBaseUrl('')).toBe('/api')
expect(resolveApiBaseUrl(' ')).toBe('/api')
})
it('API 를 별도 도메인에 둔 경우에만 절대 URL 을 그대로 쓴다', () => {
expect(resolveApiBaseUrl('https://api.myservice.io/api')).toBe(
'https://api.myservice.io/api',
)
})
})
describe('에러 코드 사전', () => {
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
+32 -1
View File
@@ -35,10 +35,41 @@ const API_TIMEOUT_MS = 10000
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
const DUPLICATE_ALERT_WINDOW_MS = 3000
/**
* API 기본 경로 — 프론트와 같은 도메인의 /api.
* 운영은 리버스 프록시(Nginx Proxy Manager 의 Custom location)가, 개발은 vite dev 프록시가
* 이 경로를 백엔드로 넘긴다. 같은 출처라 CORS 가 필요 없고, 도메인이 바뀌어도 다시 빌드하지 않는다.
*/
export const DEFAULT_API_BASE_URL = '/api'
/**
* 빌드 시 주입된 baseURL 을 확정한다.
* 비어 있으면(compose 가 빈 문자열을 넘기는 경우 포함) 같은 도메인의 /api 를 쓴다.
* API 를 별도 도메인에 둘 때만 절대 URL 이 들어온다 — 그때는 백엔드 CORS_ORIGIN 과 nginx CSP 도 함께 바꾼다.
*/
export function resolveApiBaseUrl(configured: string | undefined): string {
const trimmed = configured?.trim()
return trimmed ? trimmed : DEFAULT_API_BASE_URL
}
function isErrorCode(code: string): code is ErrorCode {
return code in ERROR_CODE_MESSAGES
}
/**
* 인터셉터가 정규화한 ApiError 인지 확인한다.
* 호출부의 catch 에서 code 로 분기하기 전에 쓴다 (API 가 아닌 오류도 catch 로 들어올 수 있다).
*/
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string' &&
isErrorCode(error.code)
)
}
/** 에러 코드 → 사용자 노출 문구. 사용자 문구는 이 사전에만 존재한다. */
export function getErrorMessage(code: string): string {
return isErrorCode(code)
@@ -59,7 +90,7 @@ const showErrorUI = (code: ErrorCode, message: string) => {
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
timeout: API_TIMEOUT_MS,
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
withCredentials: true,
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import api, { type ApiError } from './useApi'
import { useUser } from './useUser'
describe('useUser (도메인 composable 패턴)', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('성공하면 언래핑된 데이터를 돌려주고 에러 코드를 비운다', async () => {
const user = { id: 1, email: 'user@example.com', name: '홍길동' }
// 인터셉터가 { success, data } 를 벗긴 뒤의 값을 흉내 낸다 (axios 타입상 AxiosResponse 라 단언이 필요하다)
vi.spyOn(api, 'get').mockResolvedValue(user as never)
const { getUser, errorCode, loading } = useUser()
await expect(getUser(1)).resolves.toEqual(user)
expect(errorCode.value).toBeNull()
expect(loading.value).toBe(false)
})
it('실패하면 메시지가 아니라 에러 코드를 보관한다', async () => {
const error: ApiError = {
code: 'RES_001',
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
status: 404,
}
vi.spyOn(api, 'get').mockRejectedValue(error)
const { getUser, errorCode } = useUser()
await expect(getUser(999)).resolves.toBeNull()
expect(errorCode.value).toBe('RES_001')
})
it('API 가 아닌 오류는 폴백 코드(SYS_001)로 보관한다', async () => {
vi.spyOn(api, 'get').mockRejectedValue(new Error('예상치 못한 오류'))
const { getUser, errorCode } = useUser()
await expect(getUser(1)).resolves.toBeNull()
expect(errorCode.value).toBe('SYS_001')
})
})
+11 -5
View File
@@ -1,5 +1,9 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { isApiError, useApi } from './useApi'
import {
FALLBACK_ERROR_CODE,
type ErrorCode,
} from '@/shared/constants/error-codes.generated'
// 도메인별 API Composable 샘플 — 실제 도메인이 추가되면 이 패턴을 복제
export interface User {
@@ -11,21 +15,23 @@ export interface User {
export function useUser() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
// 실패 시 에러 코드만 보관한다 — 화면은 code 로 분기하고, 알림 문구는 인터셉터가 사전에서 이미 띄웠다
const errorCode = ref<ErrorCode | null>(null)
async function getUser(id: number): Promise<User | null> {
loading.value = true
error.value = null
errorCode.value = null
try {
// 인터셉터에서 success/data 언래핑 후 데이터만 반환
return (await api.get<User>(`/users/${id}`)) as unknown as User
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
// 여기서 메시지를 새로 만들지 않는다 (.claude/rules/error-codes.md)
errorCode.value = isApiError(e) ? e.code : FALLBACK_ERROR_CODE
return null
} finally {
loading.value = false
}
}
return { loading, error, getUser }
return { loading, errorCode, getUser }
}
+85 -28
View File
@@ -1,39 +1,96 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
/** 루트 .env 에 값이 없을 때만 쓰는 기본 포트 (.env.*.example 과 같은 값) */
const DEFAULT_FRONTEND_PORT = 5173
const DEFAULT_BACKEND_PORT = 3000
/**
* 환경변수를 양의 정수로 파싱한다.
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
* (compose 는 정의되지 않은 변수를 빈 문자열로 주입한다 — `Number('') === 0` 방어.
* backend 의 `shared/utils/env.ts` 와 같은 규칙)
*/
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === '') return fallback
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) return fallback
return parsed
}
/** 콤마로 구분된 목록을 파싱한다 (공백·빈 항목 제거) */
function parseList(value: string | undefined): string[] {
if (value === undefined) return []
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry !== '')
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
export default defineConfig(({ mode }) => {
// 포트·주소의 SSOT 는 루트 `.env.{mode}` 다 (.claude/rules/env-structure.md).
// 프론트엔드 디렉터리 밖이라 vite 가 자동으로 읽지 않으므로 여기서 직접 로드한다.
// 접두어 ''(전체)로 읽지만, 읽은 값은 **dev 서버 설정에만** 쓰고 클라이언트 번들에는 넣지 않는다
// (VITE_ 접두어가 없는 값을 번들에 넣으면 시크릿이 노출된다 — .claude/rules/security.md).
// 파일이 없으면(클론 직후 등) process.env 만 반영되고 아래 기본값으로 떨어진다.
const rootEnv = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
const frontendPort = parsePositiveInt(rootEnv.FRONTEND_PORT, DEFAULT_FRONTEND_PORT)
const backendPort = parsePositiveInt(rootEnv.BACKEND_PORT, DEFAULT_BACKEND_PORT)
// 컨테이너(바인드 마운트) 환경에서만 true — docker-compose.dev.yml 이 주입한다.
// 호스트에서 직접 `npm run dev` 로 띄울 때는 OS 파일 감시 이벤트가 정상 동작하므로
// 폴링을 켜면 CPU 만 쓰고 기동이 느려진다.
const usePolling = rootEnv.CHOKIDAR_USEPOLLING === 'true'
return {
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
},
server: {
// ngrok 등 외부 도메인 접근 허용 (DNS Rebinding 보호 비활성화)
allowedHosts: true,
// 도커/윈도우 환경 파일 핫 리로딩 강제 활성화
watch: {
usePolling: true,
},
// 개발 서버 API 프록시.
// useApi 의 baseURL 은 '/api'(상대경로)라 프록시가 없으면 vite 가 SPA 폴백으로
// index.html 을 200 으로 돌려준다 — 요청이 조용히 HTML 을 받아 실패한다.
//
// target 기본값은 호스트에서 직접 띄울 때 기준이고,
// docker 개발 환경에서는 compose 가 VITE_DEV_API_PROXY=http://backend:3000 을 주입한다.
proxy: {
'/api': {
target: process.env.VITE_DEV_API_PROXY || 'http://localhost:3000',
changeOrigin: true,
server: {
// dev 서버에 접근을 허용할 호스트. 기본(빈 목록)은 localhost·IP 접근만 허용한다.
// true 로 열면 vite 의 DNS rebinding 보호가 꺼져, 악성 사이트가 개발자의 브라우저를 거쳐
// dev 서버(소스)와 /api 프록시(로컬 백엔드)에 닿을 수 있다.
// ngrok 같은 터널을 쓸 때만 루트 .env 의 DEV_ALLOWED_HOSTS 에 콤마로 적는다 (예: .ngrok-free.app)
allowedHosts: parseList(rootEnv.DEV_ALLOWED_HOSTS),
// 루트 .env 의 FRONTEND_PORT 를 따른다.
// 컨테이너로 띄울 때는 compose 의 `--port`(컨테이너 내부 고정 포트)가 이 값을 덮어쓴다.
port: frontendPort,
// 포트가 이미 사용 중이면 조용히 다음 포트로 옮기지 않고 실패한다.
// 대개 다른 vite(컨테이너 vite 포함 — compose 가 같은 FRONTEND_PORT 를 매핑한다)가 이미 떠 있다는 뜻이라,
// 말없이 5174 로 옮겨가면 브라우저는 여전히 5173 의 엉뚱한 서버를 보게 된다 (fail-fast).
strictPort: true,
// 도커 바인드 마운트에서는 inotify 가 호스트 쪽 변경을 잡지 못해 폴링이 필요하다
watch: usePolling ? { usePolling: true } : undefined,
// 개발 서버 API 프록시 — 운영 리버스 프록시(Nginx Proxy Manager 의 Custom location /api)와 같은 역할.
// 브라우저는 개발·운영 모두 같은 출처의 /api 로만 호출하므로 CORS 가 끼어들지 않는다.
// 접두어 매칭이라 /api-docs(Swagger)도 백엔드로 간다 — 운영의 location /api 와 같은 규칙이다.
//
// useApi 의 baseURL 은 '/api'(상대경로)라 프록시가 없으면 vite 가 SPA 폴백으로
// index.html 을 200 으로 돌려준다 — 요청이 조용히 HTML 을 받아 실패한다.
//
// 기본 대상은 호스트에서 띄울 때 기준이고(백엔드 컨테이너가 BACKEND_PORT 를 호스트에 노출한다),
// 프론트까지 docker 로 띄울 때는 compose 가 VITE_DEV_API_PROXY=http://backend:${BACKEND_PORT} 를 주입한다.
proxy: {
'/api': {
target: rootEnv.VITE_DEV_API_PROXY || `http://localhost:${backendPort}`,
changeOrigin: true,
},
},
},
}
+4 -1
View File
@@ -2,8 +2,11 @@ import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
// vite.config 는 mode 별 설정(포트·프록시)을 계산하는 함수다.
// mergeConfig 는 함수를 병합하지 못하므로 테스트용 ConfigEnv 로 먼저 평가한다.
// (테스트는 dev 서버를 띄우지 않아 포트·프록시 값에 영향받지 않는다)
export default mergeConfig(
viteConfig,
viteConfig({ command: 'serve', mode: 'test' }),
defineConfig({
test: {
environment: 'jsdom',