Initial commit

This commit is contained in:
2026-11-08 10:19:32 +09:00
commit 5db80e65c2
269 changed files with 33654 additions and 0 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 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`)
- 구현 방법 제안. **무엇이 불편한지**까지만 말하고 해결 방법은 담당 역할에 맡긴다.
## 완료 기준
각 항목을 이렇게 적는다.
```
- 상황: (사용자가 무엇을 하다가)
- 보게 되는 것: (실제 문구·화면. 근거 파일 위치)
- 문제: (왜 막히는가)
- 인계: (어느 역할)
```
문제가 없으면 "없음"이라고 적는다. 확인하지 못한 화면이 있으면 **확인하지 못했다**고 적는다.
+45
View File
@@ -0,0 +1,45 @@
---
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}` 가 둘 다 있어야 기동한다.
- **운영 배포 전**: `BACKEND_API_URL`·`CORS_ORIGIN` 이 실제 도메인인지 확인한다.
`localhost`·`example.com` 이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
- **프론트엔드**: 개발에서는 컨테이너로 띄우지 않는다(profile `frontend` 뒤에 있다). 운영만 nginx 정적 호스팅이다.
## 완료 기준 (실행하고 결과를 보고한다)
```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절(커밋 전 공통 점검) 통과 여부를 함께 보고한다.
+49
View File
@@ -0,0 +1,49 @@
---
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 test
cd frontend && npm run test:unit
cd flutter && flutter test
```
- e2e 는 DB 가 필요하다. 로컬이면 개발 스택을 띄운 뒤 컨테이너 안에서 실행한다 ([[testing]]).
- 실행하지 못한 수트가 있으면 **어느 것을 왜 못 돌렸는지** 반드시 적는다. 통과했다고 뭉뚱그리지 않는다.
+64
View File
@@ -0,0 +1,64 @@
# API 설계 규약
세 클라이언트가 공유하는 계약이므로, 아래 규약은 백엔드 단독으로 바꾸지 않는다.
## 경로 · 메서드
- 모든 라우트는 글로벌 prefix `/api` 아래에 놓인다 (`main.ts`). 클라이언트 baseURL 도 `/api` 를 포함한다.
- 리소스는 **복수형 명사**, 소문자 kebab-case: `/users`, `/order-items`
- 동사를 경로에 넣지 않는다. 상태 변경이 필요하면 하위 리소스로 표현한다.
- ❌ `POST /users/1/doDeactivate`
- ✅ `POST /users/1/deactivation`
| 메서드 | 용도 | 성공 상태 |
| --- | --- | --- |
| GET | 조회 (부수효과 없음) | 200 |
| POST | 생성 · 비멱등 작업 | 201 (생성) / 200 |
| PATCH | 부분 수정 | 200 |
| PUT | 전체 교체 | 200 |
| DELETE | 삭제 | 204 (`@SkipTransform()`) 또는 200 |
## 쿼리 파라미터
| 용도 | 파라미터 | 비고 |
| --- | --- | --- |
| 커서 페이지네이션 | `cursor`, `limit` | 기본 20 / 최대 100 ([[response-format]]) |
| 오프셋 페이지네이션 | `page`, `limit` | 전체 페이지 수가 UI 에 필요할 때만 |
| 정렬 | `sort=createdAt:desc` | `필드:방향`, 콤마로 다중 지정 |
| 검색 | `q` | 자유 텍스트 검색 |
| 필터 | 필드명 그대로 (`status=active`) | 화이트리스트 DTO 로만 수용 |
- 모든 쿼리는 DTO(`class-validator`)로 검증한다. `forbidNonWhitelisted` 때문에 미정의 파라미터는 400(VAL_001)이 된다.
## 데이터 표현
- **날짜/시각**: ISO 8601 UTC 문자열 (`2026-09-16T13:22:16.000Z`). 타임존 변환은 클라이언트 책임.
- **ID**: 노출용 식별자는 문자열로 직렬화해도 되지만 타입을 도메인 내에서 일관되게 유지한다.
- **금액**: 소수 오차를 피해 최소 단위 정수(원 단위)로 주고받는다.
- **불리언 필드**: `is`/`has` 접두어 (`isActive`, `hasNext`).
- **필드명**: JSON 은 camelCase. DB 컬럼명이 snake_case 여도 응답에서 변환한다.
- `null` 과 필드 생략을 혼용하지 않는다 — "값 없음"은 `null` 로 통일한다.
## 버전 관리
- 호환성을 깨는 변경(필드 삭제, 타입 변경, 의미 변경)이 필요하면 경로 버전을 올린다: `/api/v2/users`
- 필드 **추가**는 호환 변경이므로 버전을 올리지 않는다. 클라이언트는 모르는 필드를 무시해야 한다.
- 제거 예정 필드는 먼저 문서에 `deprecated` 로 표시하고, 클라이언트 전환 후 삭제한다.
## 문서화 (Swagger)
- 모든 컨트롤러에 `@ApiTags()`, 모든 엔드포인트에 `@ApiOperation()`.
- 성공 응답은 `@ApiStandardResponse()`, 실패 응답은 `@ApiErrorResponse('CODE')` 로 문서화한다
— 래퍼 구조와 에러 레지스트리가 자동 반영되어 문서와 구현이 어긋나지 않는다 ([[response-format]]).
- 모든 DTO 필드에 `@ApiProperty()` + 한국어 `description`.
## 신규 엔드포인트 체크
- [ ] 경로가 복수형 리소스 + 동사 없음
- [ ] 입력은 DTO 검증, 출력은 응답 DTO 정의
- [ ] 목록이면 페이지네이션 적용
- [ ] 실패 경로마다 에러 코드 지정 ([[error-codes]])
- [ ] Swagger 데코레이터 작성
- [ ] 세 클라이언트 중 사용하는 쪽의 타입/모델 갱신
> 응답 구조는 [[response-format]], 에러는 [[error-codes]], 구현 규칙은 [[nestjs]] 참조.
+86
View File
@@ -0,0 +1,86 @@
# 작업 점검 & 신규 프로젝트 셋업
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
---
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
- [ ] `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 개발 환경 기동으로 동작 확인 — 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`
(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:`), 깊은 상대경로 없음
- [ ] 민감정보(`.env`, 시크릿) 미커밋 — `*.example` 만 추적하며 그 값은 placeholder ([[security]])
- [ ] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음 ([[logging]])
- [ ] 커밋 메시지 `<type>(<scope>): <한국어 제목>` 형식, 한 커밋 한 변경 ([[git-convention]])
- [ ] **의존성을 바꿨다면** lock 파일이 Linux 빌드와 호환되는지 확인 — Windows 에서 `npm install` 하면
Linux 전용 optional 의존성이 lock 에서 잘려 Docker 의 `npm ci` 가 실패한다. 컨테이너 안에서 설치할 것 ([[nestjs]])
- [ ] **의존성을 바꿨다면** dev 컨테이너를 `--renew-anon-volumes` 로 재생성 — `--build` 만으로는
`node_modules` 익명 볼륨이 갱신되지 않아 새 패키지가 컨테이너에 반영되지 않는다 ([[docker]])
## 3. 에러 코드 변경 시 (SSOT 절차)
에러 코드/메시지는 **백엔드 레지스트리 한 파일**만 고친다. 세 파일을 손으로 맞추지 않는다.
- [ ] `backend/src/common/errors/error-codes.ts` 수정 (코드는 `DOMAIN_NNN`, 번호 재사용 금지)
- [ ] `cd backend && npm run sync:error-codes` 실행 → 클라이언트 사전 자동 생성
(`frontend/src/shared/constants/error-codes.generated.ts`, `flutter/lib/core/api/error_lexicon.g.dart` — **직접 수정 금지**)
- [ ] 세 스택 테스트 통과 확인 (`error-codes.spec.ts` · `useApi.spec.ts` · `error_lexicon_test.dart`)
— 계약 테스트는 **클라이언트가 실제로 참조하는 코드의 존재**와 네이밍 규약만 검사한다.
도메인 코드를 추가했다고 테스트를 고칠 필요는 없다.
단, 클라이언트가 그 코드로 분기한다면 해당 목록에 추가한다.
- [ ] 생성 파일까지 함께 커밋
(CI 의 `error-codes-sync` 잡이 `sync` 재실행 후 `git diff` 로 드리프트를 검사하므로, 빠뜨리면 PR 이 실패한다)
> `AUTH_002`(세션 만료)는 **클라이언트 전용** 코드로 서버 응답에는 나타나지 않는다. 자세한 내용은 [[error-codes]].
## 4. 엔티티(DB 스키마)를 바꿀 때
- [ ] 마이그레이션 생성 — `dc exec backend npm run migration:generate -- src/migrations/<이름>` ([[nestjs]])
- [ ] 생성된 SQL 검토 — 의도치 않은 `DROP` 이 없는지 **반드시 눈으로 확인**
- [ ] 적용 확인 — `migration:run` 후 의도한 스키마가 되었는지
- [ ] 엔티티와 마이그레이션 파일을 **함께 커밋** (하나만 올라가면 배포 시 깨진다)
- [ ] `DB_SYNCHRONIZE=true` 로 실험했다면 **끄고** 마이그레이션을 생성했는지 확인
## 5. 공유 계약을 바꿀 때 (응답 포맷 · 페이지네이션 · API 규약)
- [ ] 백엔드 구현 수정 ([[response-format]] · [[api-design]])
- [ ] 세 클라이언트의 인터셉터/타입 동기화 (`useApi.ts` · `dio_client.dart` · 공유 타입 파일)
- [ ] Swagger 데코레이터(`ApiStandardResponse` / `ApiErrorResponse`) 갱신 — 문서와 실응답 불일치 방지
- [ ] PR 본문에 영향 범위 명시 ([[git-convention]])
---
## 6. 기능 확장 시 따르는 순서
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]])
4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다
5. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
6. 커밋 전 위 2·3·4절 점검 통과
> 대표 확장 지점(각 스킬의 "확장 시 따르는 규칙" 참조):
> - 인증/권한 → [[nestjs]](guards·JWT) · [[vue3]](auth store + 라우터 가드)
> - 데이터 영속화 → [[nestjs]](TypeORM 엔티티/리포지토리 · 마이그레이션)
> - 상태·화면 계층 → [[flutter]](Riverpod 프로바이더 · features 구조)
+35
View File
@@ -0,0 +1,35 @@
---
name: coding-common
description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙 — 주석 언어, 타입 정책, 린트, 순수 유틸 분리, 절대경로 alias.
---
# 공통 코딩 규칙
- 모든 코드 주석은 **한국어**로 작성
- 웹(`frontend`)·서버(`backend`)는 100% TypeScript, 모바일(`flutter`)은 Dart Sound Null Safety 준수
- 컴파일러/린터 **Warning 방치 금지** — 커밋 전 각 프로젝트 `lint` / `analyze` 통과
(lint 스크립트에 `--max-warnings 0` 이 걸려 있다. 자동 수정은 `npm run lint:fix`)
- 아래 규칙은 **린트로 강제**되므로 위반 시 빌드가 실패한다:
`any`/`dynamic` 사용, `console`/`print` 출력, Vue 인라인 스타일·Options API,
Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용)
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
(반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`)
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
## 네이밍 규약
| 대상 | 규칙 | 예 |
| --- | --- | --- |
| TS 파일(일반) | kebab-case | `user.store.ts`, `use-api.ts` / 기존 composable 은 `useApi.ts` 형태 유지 |
| Vue 컴포넌트 파일 | PascalCase | `BaseButton.vue`, `UserCard.vue` |
| Dart 파일·디렉터리 | snake_case | `dio_client.dart`, `user_repository.dart` |
| 클래스/타입 | PascalCase | `UserModel`, `TransformInterceptor` |
| 변수/함수 | camelCase (Dart 포함) | `getUser`, `isLoggedIn` |
| 상수 | TS `UPPER_SNAKE` 또는 모듈 상수, Dart `lowerCamel` | `MAX_RETRY`, `defaultTimeout` |
| 에러 코드 | `DOMAIN_NNN` | `AUTH_001`, `VAL_001` ([[error-codes]]) |
| 자동 생성 파일 | TS `*.generated.ts`, Dart `*.g.dart` | `error-codes.generated.ts`, `error_lexicon.g.dart` |
> 도메인별 세부 규칙: [[nestjs]] · [[vue3]] · [[flutter]] · 테스트: [[testing]] · 로깅: [[logging]] · API 설계: [[api-design]]
+50
View File
@@ -0,0 +1,50 @@
---
name: env-structure
description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{APP_ENV} 가 모든 설정의 SSOT, docker-compose 가 서비스에 주입. 새 설정값 추가/서비스 env 작성 규칙.
---
# 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
```
루트 .env.{APP_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) 로 주입
│ (호스트에서 `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 볼륨
```
규칙:
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
숫자 설정값은 `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 })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
로컬에서 운영 이미지를 검증하느라 일부러 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` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+115
View File
@@ -0,0 +1,115 @@
# 에러 코드 규칙
## 핵심 원칙
> **사용자에게 노출되는 모든 실패는 반드시 에러 코드를 가진다.**
> 코드 없이 문자열 메시지만 던지는 실패는 허용하지 않는다.
- 사용자 노출 문구는 **에러 코드 사전에만 존재**한다. 컨트롤러·서비스·컴포넌트에 문구를 하드코딩하지 않는다.
- 클라이언트는 `message` 가 아니라 **`code` 로 분기**한다. (문구는 언제든 바뀔 수 있다)
- 서버 내부 정보(스택, 쿼리, 호스트명)는 절대 응답에 싣지 않는다 — 로그로만 남긴다 ([[logging]]).
## SSOT — 레지스트리 한 곳
에러 코드 ↔ 메시지 ↔ HTTP 상태는 **백엔드 레지스트리 한 파일**이 단일 진실 공급원이다.
```
backend/src/common/errors/error-codes.ts ← 여기만 수정한다
│ npm run sync:error-codes
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
```
- **생성 파일은 직접 수정 금지.** 레지스트리를 고치고 `cd backend && npm run sync:error-codes` 를 실행한다.
- 코드 추가/변경 후에는 세 스택의 코드 목록 일치 테스트가 통과해야 한다
(backend `error-codes.spec.ts` · frontend `useApi.spec.ts` · flutter `error_lexicon_test.dart`).
목록을 바꿨다면 이 테스트의 기대 배열도 함께 갱신한다.
## 공통 코드
| 코드 | HTTP | 상황 | 메시지 |
| --- | --- | --- | --- |
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." |
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
### AUTH_002 (클라이언트 전용)
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
## 도메인 코드 추가
1. `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 `DOMAIN_NNN` 형식으로 등록한다.
```typescript
USER_001: {
status: HttpStatus.CONFLICT,
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
},
```
2. `npm run sync:error-codes` 실행 → 클라이언트 사전 자동 갱신.
3. 세 스택의 코드 목록 테스트에 새 코드를 추가한다.
규칙:
- 번호는 **001 부터 순차 부여**하고, 한번 배포된 코드의 **의미는 바꾸지 않는다** (폐기 시 주석으로 `deprecated` 표시, 번호 재사용 금지).
- 도메인 접두어는 대문자 스네이크 (`USER`, `ORDER`, `PAYMENT`).
- 같은 의미의 실패에 새 코드를 만들지 말고 기존 코드를 재사용한다.
## 던지는 방법 (backend)
```typescript
import { BusinessException } from '@/common/errors/business.exception';
// 레지스트리 메시지 그대로 사용
throw new BusinessException('RES_001', { context: { userId: id } });
// 동적 값이 필요할 때만 메시지 재정의 (코드·상태는 레지스트리 값 유지)
throw new BusinessException('USER_001', {
message: `이미 가입된 이메일입니다. (${email})`,
context: { email },
});
```
- `context` 는 **로그 전용**이다. 응답 본문에는 포함되지 않으므로 식별자를 자유롭게 넣어도 된다.
- Nest 내장 예외(`NotFoundException` 등)를 던져도 필터가 상태 코드로 역매핑하지만, **새 코드에서는 `BusinessException` 을 쓴다** (코드가 명시되어 추적이 쉽다).
- `HttpExceptionFilter` 에 문구를 하드코딩하지 않는다 — 레지스트리 조회만 한다.
## 받는 방법 (client)
```typescript
// frontend — 인터셉터가 ApiError { code, message, traceId } 로 정규화해 reject
try {
await api.post('/users', payload)
} catch (e) {
const error = e as ApiError
if (error.code === 'USER_001') focusEmailField() // 코드로 분기
// 알림은 인터셉터가 이미 처리했으므로 여기서 다시 띄우지 않는다
}
```
```dart
// flutter — 인터셉터가 DioException.error 에 ApiException 을 실어준다
on DioException catch (e) {
final error = e.error as ApiException;
if (error.code == 'AUTH_002') redirectToLogin();
}
```
- 개별 `try/catch` 에서 **메시지를 새로 만들지 않는다.** 문구는 사전에서만 온다.
- 같은 코드의 알림이 연속으로 뜨지 않도록 인터셉터가 중복을 억제한다 — 호출부에서 다시 알림을 띄우지 말 것.
## 추적 (traceId)
- 모든 요청에 `x-request-id` 가 부여되고(`RequestIdMiddleware`), 실패 응답의 `error.traceId` 와 서버 로그에 같은 값이 남는다.
- 사용자 문의 대응 시 이 값으로 로그를 검색한다. 상세 화면·에러 페이지에 작게 노출하는 것을 권장한다.
> 응답 래퍼 구조는 [[response-format]], 로그 규칙은 [[logging]] 참조.
+38
View File
@@ -0,0 +1,38 @@
# Git 컨벤션
## 커밋 메시지
`<type>(<scope>): <제목>` 형식 — scope 는 선택.
- type: **feat / fix / refactor / docs / chore / perf / test / style**
- scope: 변경이 한 스택에 한정되면 명시 — `backend` / `frontend` / `flutter` / `docker` / `rules`
- 제목은 한국어, 명령형 현재 시제, 마침표 없음
- 한 커밋은 하나의 논리적 변경만 포함
```
feat(backend): 사용자 목록 커서 페이지네이션 추가
fix(frontend): 세션 만료 시 중복 알림 노출 수정
docs(rules): 에러 코드 SSOT 동기화 절차 정리
```
본문이 필요하면 제목 다음 줄을 비우고 **왜** 바꿨는지 적는다(무엇을 바꿨는지는 diff 가 말해준다).
## 브랜치
`<type>/<간단한-설명>` — 기본 브랜치는 `main`.
```
feat/user-pagination
fix/session-expired-alert
```
- 기본 브랜치에 직접 커밋하지 않는다. 작업은 브랜치에서 하고 PR 로 합친다.
- 브랜치는 머지 후 삭제한다. 오래 유지되는 브랜치를 만들지 않는다.
## PR
- 제목은 커밋 규칙과 동일한 형식
- 본문에 **무엇을 왜 바꿨는지**와 **확인 방법**을 적는다
- `.github/pull_request_template.md` 의 체크리스트를 채운다
- 머지 전 CI(`.github/workflows/ci.yml`)와 [[checklist]] 의 "커밋 전 공통 점검"을 통과해야 한다
- 에러 코드·응답 포맷 등 **공유 계약을 바꾸는 PR** 은 세 스택 영향 범위를 본문에 명시한다 ([[error-codes]] · [[response-format]])
+57
View File
@@ -0,0 +1,57 @@
# 로깅 규칙
`console.log` 사용 금지. 백엔드는 winston(`shared/logger/winston.config.ts`), Nest 내부는 `Logger` 를 쓴다.
## 레벨 기준
| 레벨 | 쓰는 상황 | 예 |
| --- | --- | --- |
| `error` | 서버 책임 오류(5xx), 즉시 조치가 필요한 실패 | 미정의 예외, DB 커넥션 실패 |
| `warn` | 사용자 책임 실패(4xx), 예상된 비정상 | 검증 실패, 권한 없음, Rate Limit 초과 |
| `log`(info) | 상태 변화가 있는 주요 이벤트 | 서버 기동, 가입 완료, 결제 승인 |
| `debug` | 개발 환경 진단 | 외부 API 요청/응답 요약 |
- 4xx 를 `error` 로 남기지 않는다 — 알림 피로도가 올라가 진짜 장애를 놓친다.
- 반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다.
## 필수 포함 정보
모든 에러 로그에는 **에러 코드**와 **추적 ID**를 함께 남긴다.
```
[RES_001][3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d] GET /api/users/1 {"userId":1}
```
- 추적 ID는 `RequestIdMiddleware` 가 발급하는 `x-request-id` 이며, 실패 응답의 `error.traceId` 와 같은 값이다.
- 사용자 문의가 들어오면 이 값으로 로그를 검색한다 ([[error-codes]]).
- 개발자용 컨텍스트는 `BusinessException` 의 `context` 에 담는다 — 로그에만 남고 응답에는 포함되지 않는다.
## 민감정보 마스킹 (필수)
아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.**
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
```typescript
// ❌ 금지
this.logger.log(`로그인 시도: ${JSON.stringify(dto)}`);
// ✅ 필요한 식별자만
this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`);
```
- 마스킹 유틸은 순수 함수로 `shared/utils/` 에 둔다 ([[coding-common]]).
## 클라이언트 로깅
- 프론트/앱에서 사용자 정보를 콘솔에 출력하지 않는다. 운영 빌드에는 디버그 로그를 남기지 않는다.
- 사용자 노출 문구는 로그가 아니라 에러 사전에서 온다 ([[error-codes]]).
## 운영 확장
- 운영 환경은 JSON 포맷 + 외부 수집기(Loki/ELK/CloudWatch) 연동을 권장한다.
- 파일로 남길 경우 로테이션(`winston-daily-rotate-file`)을 적용하고 보존 기간을 정한다.
> 보안 규칙은 [[security]], 테스트 규칙은 [[testing]] 참조.
+90
View File
@@ -0,0 +1,90 @@
# 표준 응답 포맷 (Global Response Wrapper)
모든 API 응답은 아래 구조로 통일한다.
SSOT: backend `TransformInterceptor` + `HttpExceptionFilter` ([[nestjs]] 참조)
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
```
- 컨트롤러는 **raw 데이터만 반환**한다. `{success,data}` 래핑은 인터셉터가 담당 — 직접 래핑 금지.
- 클라이언트 인터셉터는 `success: true` 면 `data` 만 언래핑해 반환한다.
- 실패는 **항상 `error.code` 로 분기**한다. `message` 로 분기하지 않는다 ([[error-codes]]).
- `traceId` 는 서버가 요청마다 발급하는 추적 ID(`x-request-id`)다. 응답 헤더로도 함께 내려간다.
## 비즈니스 실패도 4xx 로 내린다
- 백엔드는 `success: false` 를 **항상 4xx/5xx 와 함께** 내려보낸다. HTTP 200 + `success:false` 조합은 만들지 않는다.
- 단, 클라이언트 인터셉터는 외부 게이트웨이 등이 200 으로 감싸는 경우를 대비해 **방어적으로 이 분기를 유지**한다.
## 래핑 예외 (`@SkipTransform()`)
본문 구조를 바꾸면 안 되는 응답은 백엔드에서 `@SkipTransform()` 을 붙여 래핑을 건너뛴다.
```typescript
@Get('export')
@SkipTransform() // 파일 스트림 — { success, data } 로 감싸면 깨진다
exportCsv(@Res() res: Response) { /* ... */ }
```
적용 대상: 파일 다운로드·스트림, 리다이렉트, `204 No Content`, 외부 규격을 따라야 하는 웹훅 응답,
그리고 **기계가 소비하는 엔드포인트**(헬스체크 등).
### 헬스체크는 표준 포맷에서 제외한다
`/api/health` 는 사람이 아니라 모니터링 도구가 읽는다. 래퍼로 감싸거나 실패를 `SYS_001` 로 덮어쓰면
**어느 구성요소가 죽었는지가 사라진다.** 그래서 성공·실패 모두 terminus 원본 형식을 그대로 내보낸다.
```jsonc
// 200 — 정상
{ "status": "ok", "info": { "database": { "status": "up" } }, "error": {}, "details": { ... } }
// 503 — 실패 (원인이 보인다)
{ "status": "error", "info": {}, "error": { "database": { "status": "down", "message": "..." } }, ... }
```
구현: `HealthController` 에 `@SkipTransform()` 을 붙이고, terminus 의 `ServiceUnavailableException` 은
컨트롤러에서 직접 잡아 503 + 원본 본문을 반환한다(전역 필터까지 가지 않게 한다).
같은 성격의 엔드포인트를 추가할 때 이 패턴을 재사용한다.
## 목록 응답 (페이지네이션)
목록 API 의 `data` 는 **항상 아래 구조**를 가진다. 배열을 그대로 반환하지 않는다.
```jsonc
{
"success": true,
"data": {
"items": [ /* ... */ ],
"pageInfo": {
"hasNext": true,
"nextCursor": "eyJpZCI6MTQzfQ==", // 마지막 페이지면 null
"total": 143 // 계산 비용이 크면 null 허용
}
}
}
```
- 구현 SSOT: backend `common/dto/pagination.dto.ts`
(`CursorPaginationQueryDto` · `OffsetPaginationQueryDto` · `PageInfoDto` · `toPaginatedResult()`)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `UserRepository.getUsers()` 패턴
- **커서 기반이 기본**이다. 전체 페이지 수가 UI 에 필요한 경우에만 오프셋을 쓴다.
- `limit` 기본값 20, 최대 100 — 무제한 조회를 허용하지 않는다.
## Swagger 문서 일치
컨트롤러는 raw 를 반환하지만 실제 응답은 래핑되므로, `@ApiResponse({ type: Dto })` 를 그대로 쓰면 문서와 실응답이 어긋난다.
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
```
- `ApiStandardResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiErrorResponse` 는 상태 코드·메시지를 에러 레지스트리에서 가져오므로 문서가 구현과 자동으로 일치한다.
> 에러 코드 ↔ 메시지 매핑은 [[error-codes]], API 설계 규약은 [[api-design]] 참조.
+114
View File
@@ -0,0 +1,114 @@
# 보안
## 비밀 정보
- `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유
- **`*.example` 파일의 값은 반드시 더미 placeholder 여야 한다.** 실제로 쓰는(또는 쓸 법한) 비밀번호·키를 예시 파일에 적지 않는다.
- ❌ `DB_PASSWORD=dev_960426`
- ✅ `DB_PASSWORD=change-me-in-local-env`
- 시크릿이 한 번이라도 커밋되었다면 **값을 교체(rotate)** 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다.
- 로그·에러 응답에 시크릿을 남기지 않는다 ([[logging]]).
## 인증 · 토큰
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
## 입력 · 출력
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
### 파일 업로드
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
`../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
- 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413).
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
> ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.**
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
## 네트워크 · 인프라
- 보안 헤더(`helmet`) 적용
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
- 라우트 단위 조정: `@Throttle({ default: { ttl, limit } })` / 제외: `@SkipThrottle()`
- 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 **더 엄격한 한도**를 따로 건다
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
- **컨테이너 root 실행 금지 — 개발 환경 포함.** 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
- 프론트엔드 운영: `nginxinc/nginx-unprivileged` + `listen 8080` (비-root 는 1024 미만 포트 바인드 불가)
- Node 스테이지: `RUN chown node:node /app` → `USER node` → `COPY --chown=node:node`
(소유권을 먼저 넘겨야 익명 볼륨 `node_modules` 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
- DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다.
개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라
같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.
```yaml
ports:
- "127.0.0.1:${DB_PORT}:5432" # ✅
# - "${DB_PORT}:5432" # ❌ 외부 네트워크 노출
```
- **보안 헤더는 CSP 까지** 설정한다 (`frontend/nginx.conf`). 외부 CDN·폰트·분석 스크립트를 추가하면
해당 도메인을 CSP 에 명시한다. `script-src` 에 `'unsafe-inline'`/`'unsafe-eval'` 을 넣으면 CSP 의 의미가 사라진다.
API 도메인이 다르면 `connect-src` 에 추가해야 요청이 차단되지 않는다.
## 의존성
- 의존성 추가 시 유지보수 상태를 확인하고, 주기적으로 `npm audit` / `flutter pub outdated` 로 취약점을 점검한다
- **`npm audit fix` 는 반드시 컨테이너 안에서 실행**한다. 호스트(Windows)에서 실행하면 lock 파일이 깨진다 ([[nestjs]])
```bash
docker exec <프로젝트>-backend npm audit fix
```
- **`--force` 는 함부로 쓰지 않는다.** 메이저 버전을 올려 다른 패키지와의 호환이 깨질 수 있다.
실행 전 `npm audit fix --force --dry-run` 으로 어떤 패키지가 올라가는지 확인한다.
### 남아 있는 취약점 판단 기준
모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 **결정을 기록**한다.
1. **도달 가능한 코드 경로가 있는가** — 해당 기능을 쓰지 않으면 노출되지 않는다
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
### 전이 의존성 취약점은 overrides 로 끊는다
직접 의존성이 취약한 하위 패키지를 **정확 버전으로 고정**해 두면 `npm audit fix` 로는 해결되지 않는다.
상위 패키지의 메이저 업그레이드를 기다리지 말고 `package.json` 의 `overrides` 로 하위 패키지만 올린다.
```jsonc
// backend/package.json
"overrides": {
"multer": "^2.4.0"
}
```
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
규칙:
- override 는 **상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인**하고 적용한다
(위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
- 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
- 적용 후 **lint·테스트·빌드·런타임 기동까지 확인**한다. override 는 npm 이 검증해 주지 않는다.
- 상위 패키지를 정식으로 올릴 때 **override 를 제거**한다. 남겨두면 나중에 버전이 거꾸로 고정된다.
> 환경변수 관리 규칙은 [[env-structure]], 도메인별 적용은 [[nestjs]] · [[flutter]] 참조.
+72
View File
@@ -0,0 +1,72 @@
---
name: testing
description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍, 무엇을 테스트할지, 커밋 전 실행. backend Jest, frontend Vitest, flutter flutter_test 기준.
---
# 테스트 규칙
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
## 공통 원칙
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
## 스택별 위치·실행
| 스택 | 러너 | 위치·네이밍 | 실행 |
| --- | --- | --- | --- |
| 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` |
## 예시 (backend 순수 유틸)
```typescript
// shared/utils/format.spec.ts
import { formatSomething } from './format';
describe('formatSomething', () => {
it('빈 입력이면 빈 문자열을 반환한다', () => {
expect(formatSomething('')).toBe('');
});
});
```
---
## 계약 테스트 (삭제·비활성화 금지)
세 플랫폼이 공유하는 계약은 어긋나도 컴파일 에러가 나지 않는다. 아래 테스트가 그 안전망이다.
| 대상 | 위치 | 검증 내용 |
| --- | --- | --- |
| 에러 코드 레지스트리 | `backend/src/common/errors/error-codes.spec.ts` | 네이밍 규약, 상태 역매핑, 클라이언트 전용 코드가 서버에서 선택되지 않음 |
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` | 코드/상태/traceId, **내부 정보 미노출**, 검증 상세 사유 미노출 |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 |
> **e2e 는 DB 가 필요하다** (AppModule 이 TypeORM 을 초기화한다).
> 로컬에서는 개발 스택을 띄운 뒤 컨테이너 안에서 실행하는 것이 가장 간단하다.
> ```bash
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
> ```
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
## 테스트하지 않아도 되는 것
- 프레임워크가 보장하는 동작 (라우팅, DI 주입 자체, ORM 쿼리 빌더)
- 단순 getter/setter, 위임만 하는 컨트롤러 메서드
- 화면 스타일·레이아웃 값
> API 응답 래퍼/에러 코드는 세 플랫폼 공유 계약이다 ([[response-format]] · [[error-codes]]).
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Read(//**)",
"Bash(npm run lint:*)",
"Bash(npm run build:*)",
"Bash(npm run test:*)",
"Bash(npm run type-check:*)",
"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:*)",
"Bash(flutter test:*)",
"Bash(flutter pub get)",
"Bash(dart format:*)",
"Bash(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs:*)",
"Bash(docker compose --env-file .env.production logs:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git add:*)"
],
"ask": [
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(docker compose:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.development)",
"Read(./.env.production)",
"Read(./backend/.env)",
"Read(./backend/.env.development)",
"Read(./backend/.env.production)",
"Read(./frontend/.env)",
"Read(./frontend/.env.development)",
"Read(./frontend/.env.production)",
"Read(./flutter/.env)"
]
},
"env": {
"APP_ENV": "development"
}
}
+192
View File
@@ -0,0 +1,192 @@
---
name: docker
description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/배포 규칙 — 개발/운영 환경 기동, 빌드 여부 판단(--build), 로그 확인, 루트 .env SSOT 주입. 컨테이너를 띄우거나 배포·로그 확인·docker-compose 작업을 할 때 사용한다.
---
> 이 스킬은 `.claude/skills/docker/SKILL.md`에 위치한다.
> 환경변수 SSOT 구조는 [[env-structure]] 참조.
> `.env.development` / `.env.production` 는 커밋된 `.env.*.example` 를 복사해 생성한다.
> (`.gitignore` 는 `.env.*` 를 무시하되 `.env.*.example` 만 추적한다.)
---
## 개발 환경
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않는다.** 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
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 로그 확인 (예: backend)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```
## 운영 환경
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
```bash
# 운영 환경 배포
sudo docker compose --env-file .env.production up -d --build
# 로그 확인
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` 라 포트가 이미 사용 중이면 **다음 포트로 옮기지 않고 실패**한다.
`CORS_ORIGIN` 과 compose 포트 매핑이 그 포트에 묶여 있어, 조용히 5174 로 옮겨가면
API 요청이 원인 모르게 막힌다 (fail-fast).
- 폴링 감시(`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가 길어 아래는 다음 별칭으로 줄여 표기한다.
> 운영은 `docker compose --env-file .env.production` (필요 시 `sudo`) 으로 동일하게 사용한다.
>
> ```bash
> alias dc='docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development'
> ```
```bash
# 상태 확인
dc ps
# 종료 — 컨테이너 중지·삭제 (DB/Redis 데이터 볼륨은 보존)
dc down
# ⚠️ 데이터까지 삭제 — DB/Redis 볼륨 제거 (복구 불가, 신중히)
dc down -v
# 특정 서비스만 재빌드·기동 (예: 의존성 바뀐 backend)
dc up -d --build backend
# 특정 서비스 재시작 (코드 변경은 dev 핫리로드라 보통 불필요)
dc restart backend
# 전체 / 특정 서비스 로그
dc logs -f
dc logs -f backend
# 컨테이너 쉘 접속
dc exec backend sh
# PostgreSQL 접속 (<DB_USER>·<DB_NAME> 은 루트 .env 값으로 치환)
dc exec db psql -U <DB_USER> -d <DB_NAME>
# Redis 접속 (<REDIS_PASSWORD> 은 루트 .env 값)
dc exec redis redis-cli -a <REDIS_PASSWORD>
```
---
## CI 러너 (별도 스택)
`.github/workflows/ci.yml` 을 실행하는 Gitea Actions 러너는 **앱 스택과 생명주기가 다르다.**
앱은 개발할 때만 띄우지만 러너는 항상 켜져 있어야 하므로 `ci/` 에 별도 compose 로 분리했다.
```bash
cd ci
cp .env.example .env # 등록 토큰 입력 (커밋되지 않음)
docker compose -f docker-compose.runner.yml --env-file .env up -d
```
- 러너가 없으면 워크플로는 **"Waiting for a runner"** 로 대기만 한다.
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
---
## 선택적 서비스 (Redis)
**Redis 는 기본적으로 기동하지 않는다.** 단일 인스턴스에서는 in-memory 캐시로 충분하고,
쓰지 않는 컨테이너는 메모리만 차지하며 혼란을 준다.
```bash
# 켜기 — 루트 .env.{APP_ENV} 한 줄 수정 (이후 평소 명령 그대로)
COMPOSE_PROFILES=redis
# (프론트엔드 컨테이너도 함께 켤 때는 COMPOSE_PROFILES=redis,frontend)
# 또는 일회성
dc --profile redis up -d
```
- compose `profiles: ["redis"]` 로 숨겨져 있고, backend 의 의존성은 `required: false` 라
Redis 가 없어도 정상 기동한다.
- 데이터 볼륨(`${REDIS_DATA_DIR}`)과 `REDIS_*` 환경변수는 그대로 유지되므로,
켜면 이전 데이터와 설정이 그대로 이어진다.
- **애플리케이션 코드는 아직 Redis 를 사용하지 않는다.** 실제 연결 절차는 [[nestjs]] 참조.
- 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
---
## 규칙
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
즉 새 패키지가 이미지에는 들어가도 컨테이너에는 반영되지 않는다.
```bash
dc up -d --build --renew-anon-volumes backend
```
(`-V` 는 같은 옵션의 축약형. DB 볼륨은 named volume 이라 영향받지 않는다)
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]).
- **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고,
`stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다.
- 정상 종료 시 컨테이너 **종료 코드는 143**(=128+SIGTERM)이다. 오류가 아니라 시그널에 의한 정상 종료다.
- ⚠️ dev 오버라이드는 PID 1 이 `npm` 이라 SIGTERM 이 앱까지 전달되지 않는다(개발 편의상 허용).
graceful shutdown 동작 확인은 **운영 이미지**(`--target production`)로 해야 한다.
이때 로컬 주소를 쓰게 되므로 `ALLOW_LOCALHOST_URL=true` 로 공개 주소 검사를 건너뛴다 ([[env-structure]]).
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 로 실행한다 — **dev 오버라이드도 포함** ([[security]] 참조).
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
- 포트를 바꿀 때는 Dockerfile `EXPOSE` · `nginx.conf` 의 `listen` · compose `expose`/`healthcheck` 를 **함께** 고쳐야 한다.
- **DB/Redis 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
+257
View File
@@ -0,0 +1,257 @@
---
name: flutter
description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다.
> 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
> Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조).
---
## 템플릿 기본 제공 골격
> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
---
## 언어 & 기본 원칙
- **Dart Sound Null Safety** 완벽 준수 (`?`, `!`, `late` 올바르게 사용)
- `dynamic` 타입 사용 **지양** → 명시적 타입 선언
- 모든 주석은 **한국어**로 작성
---
## 프로젝트 구조
```
lib/
├── main.dart
├── app.dart ← MaterialApp / 라우터 설정
├── core/
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
│ ├── theme/ ← 앱 테마 정의
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
│ └── utils/ ← 순수 유틸 함수
├── features/
│ └── user/
│ ├── data/
│ │ ├── models/ ← JSON 직렬화 모델
│ │ └── repositories/ ← API 호출 구현체
│ ├── domain/
│ │ └── entities/ ← 순수 비즈니스 엔티티
│ └── presentation/
│ ├── pages/ ← 화면 단위 위젯
│ ├── widgets/ ← 재사용 위젯
│ └── providers/ ← 상태 관리
└── shared/
└── widgets/ ← 앱 공통 위젯 (생성 완료)
```
---
## 위젯 작성 원칙
```dart
// ✅ 좋은 예 — 작은 위젯으로 분리
class UserProfilePage extends StatelessWidget {
const UserProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const _AppBar(),
body: const _ProfileBody(),
);
}
}
class _ProfileBody extends StatelessWidget {
const _ProfileBody();
@override
Widget build(BuildContext context) {
return Column(
children: [
const _AvatarSection(),
const _InfoSection(),
],
);
}
}
```
- 위젯 트리 depth가 깊어지면 **커스텀 위젯으로 분리**
- `build()` 메서드 내 비즈니스 로직 작성 **금지**
- `const` 생성자 적극 활용 (불필요한 리빌드 방지)
---
## 반응형 UI
```dart
// ❌ 하드코딩 금지
Container(width: 375, height: 200)
// ✅ 반응형 처리
Container(
width: MediaQuery.of(context).size.width * 0.9,
child: ...,
)
// ✅ 유연한 레이아웃
Expanded(flex: 2, child: LeftPanel()),
Expanded(flex: 1, child: RightPanel()),
```
- `Expanded`, `Flexible`, `MediaQuery`, `LayoutBuilder` 적극 활용
- 하드코딩 픽셀값 사용 **금지**
- 폰트 크기는 `Theme.of(context).textTheme` 활용
---
## 상태 관리
```dart
// Riverpod 예시 (권장)
@riverpod
Future<User> fetchUser(FetchUserRef ref, int id) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getUser(id);
}
// 위젯에서 사용
class UserWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(fetchUserProvider(userId));
return userAsync.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const ErrorWidget(),
);
}
}
```
- **Riverpod** 권장 (Provider는 레거시로 간주)
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
---
## API 통신 (Dio + 인터셉터)
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
```dart
// core/api/dio_client.dart (실제 구조 요약)
class DioClient {
DioClient._internal() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// jwt_token 읽어 Authorization 헤더 첨부
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onResponse: (response, handler) {
// success==true → data 언래핑, false → ApiException 으로 reject
handler.next(response);
},
onError: (err, handler) async {
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
handler.next(err.copyWith(error: await _toApiException(err)));
},
));
}
}
```
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
---
## 보안 (토큰 저장)
```dart
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
await storage.write(key: kJwtTokenKey, value: token);
// 읽기
final token = await storage.read(key: kJwtTokenKey);
// 삭제 (로그아웃)
await storage.deleteAll();
```
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]])
- `SharedPreferences`에 토큰 저장 **금지**
- 추가 보안 규칙은 [[security]] 참조.
---
## 성능 & 최신 트렌드 추가 규칙
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
---
## 작업 점검 체크리스트
(전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용.
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`.
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과.
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
+322
View File
@@ -0,0 +1,322 @@
---
name: nestjs
description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스/DTO 관심사 분리, 표준 응답 인터셉터, 전역 예외 필터, Swagger, JWT 인증, 캐싱/Rate Limiting/페이지네이션. backend/ 에서 작업하거나 REST API·모듈·DTO·인터셉터·필터를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/nestjs/SKILL.md`에 위치한다.
> 사용법: `/nestjs [요청 내용]` 또는 backend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[logging]] · [[security]] · [[coding-common]]
---
## 템플릿 기본 제공 골격
> 이 템플릿은 아래 인프라를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| **에러 코드 레지스트리** | `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/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 화이트리스트 |
| 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 |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
## 아키텍처 원칙
### 관심사 분리 (Separation of Concerns)
```
Controller → HTTP 라우팅, 요청/응답 파싱만 담당
Service → 모든 비즈니스 로직 처리
Repository → 데이터 접근 계층 (TypeORM)
DTO → 요청/응답 데이터 형식 정의 및 검증
```
- `Controller`에 비즈니스 로직 작성 **금지**
- `new` 키워드로 수동 객체 생성 **지양** → 의존성 주입(DI) 활용
### 모듈 구조
```
src/
├── modules/
│ └── user/
│ ├── user.module.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ ├── user.repository.ts ← 커스텀 Repository (선택)
│ └── dto/
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── common/
│ ├── interceptors/ ← 응답 래핑, 에러 처리
│ ├── filters/ ← 전역 Exception Filter
│ ├── guards/ ← 인증/권한 Guard
│ ├── decorators/ ← 커스텀 데코레이터
│ └── pipes/ ← 전역 Validation Pipe
└── shared/
└── utils/ ← 순수 유틸 함수
```
---
## DTO 작성 규칙
```typescript
import { IsString, IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password: string;
}
```
- 모든 DTO는 `class-validator` + `class-transformer` 사용
- 에러 메시지는 **한국어**로 작성
- `@ApiProperty()` 반드시 포함 (Swagger 명세용)
---
## 표준 응답 인터셉터
```typescript
// common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data: data ?? null,
})),
);
}
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
- 응답 포맷 SSOT는 [[response-format]] 참조.
---
## 에러 처리 — 코드 없는 실패는 만들지 않는다
사용자에게 노출되는 모든 실패는 `BusinessException` 으로 던진다. 에러 코드가 **생성자 필수 인자**이므로 코드 누락이 구조적으로 불가능하다.
```typescript
import { BusinessException } from '@/common/errors/business.exception';
// 레지스트리의 상태 코드·메시지를 그대로 사용
throw new BusinessException('RES_001', { context: { userId: id } });
// 동적 값이 필요할 때만 메시지 재정의 (코드·HTTP 상태는 레지스트리 값 유지)
throw new BusinessException('USER_001', {
message: `이미 가입된 이메일입니다. (${email})`,
context: { email },
});
```
- `context` 는 **로그 전용** — 응답 본문에 포함되지 않으므로 식별자를 자유롭게 넣어도 된다 ([[logging]]).
- 새 코드는 `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록하고 `npm run sync:error-codes` 를 실행한다.
- `HttpExceptionFilter` 는 레지스트리를 **조회만** 한다. 필터에 문구를 하드코딩하지 말 것.
- Nest 내장 예외(`NotFoundException` 등)도 필터가 상태 코드로 역매핑해 주지만, 새 코드에서는 `BusinessException` 을 쓴다.
- 미정의 예외는 항상 `SYS_001` 로 수렴하며 내부 정보(스택·호스트명)는 응답에 노출되지 않는다.
전체 규칙과 도메인 코드 추가 절차는 [[error-codes]] 참조.
---
## Swagger 필수 적용
```typescript
// main.ts
const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
```
- 모든 Controller에 `@ApiTags()` 적용
- 모든 엔드포인트에 `@ApiOperation()` 작성
- **응답은 `@ApiResponse` 대신 래퍼 데코레이터를 쓴다** — 컨트롤러는 raw 를 반환하지만 실응답은 `{success,data}` 라 문서가 어긋난다.
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
- 모든 DTO에 `@ApiProperty()` + 한국어 `description` 작성
---
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
- 토큰 저장·전송 보안 규칙은 [[security]] 참조.
---
## 성능 & 최신 트렌드 추가 규칙
- **캐싱**: `@nestjs/cache-manager` 가 전역 등록되어 있으나 **현재 저장소는 in-memory** 다.
> 📐 **Redis 는 확장 지점이다.** compose 에서 **기본 비활성**(profile)이며 애플리케이션도 사용하지 않는다.
> 단일 인스턴스에서는 in-memory 로 충분하다.
>
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
> 3. `modules/health` 에 Redis 인디케이터 추가 (지금은 DB 만 검사한다)
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
>
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
(`shutdown.logger.ts` 가 참고 패턴).
- **Health Check**: `@nestjs/terminus` 로 구성. 주의점 두 가지 —
`pingCheck` 기본 타임아웃 1초는 짧아 유휴 커넥션 재연결 시 오판하므로 늘려서 쓰고,
표준 응답 래퍼/에러 코드에서 제외해 원인이 보이게 한다 ([[response-format]])
- **Versioning**: API 버전 관리 (`/v1/`, `/v2/`) 적용 고려
---
## DB 마이그레이션
스키마 변경은 **마이그레이션으로 관리**한다. `synchronize` 는 기본적으로 꺼져 있다.
> **왜 끄는가**: `synchronize: true` 는 엔티티를 DB 에 자동 반영하므로 개발 DB 가 항상 최신 상태가 된다.
> 그러면 `migration:generate` 가 "변경 없음"만 내놓고, 그 코드를 운영에 배포하면
> (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) **테이블이 만들어지지 않는다.**
> 개발과 운영이 같은 경로를 쓰도록 마이그레이션으로 통일한다.
### 엔티티 추가/변경 절차
```bash
# 0) 개발 스택이 떠 있어야 한다 (DB 접속 정보는 루트 .env → compose 주입)
# dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development
# 1) 엔티티 작성 (src/**/*.entity.ts)
# 2) 마이그레이션 생성 — 엔티티와 실제 DB 스키마의 차이를 자동으로 계산한다
dc exec backend npm run migration:generate -- src/migrations/AddUser
# 3) 생성된 SQL 을 반드시 눈으로 확인한다 (의도치 않은 DROP 이 없는지)
# 4) 적용
dc exec backend npm run migration:run
# 5) 엔티티와 마이그레이션 파일을 함께 커밋
```
| 명령 | 용도 |
| --- | --- |
| `npm run migration:generate -- src/migrations/<이름>` | 엔티티 ↔ DB 차이로 자동 생성 |
| `npm run migration:create -- src/migrations/<이름>` | 빈 마이그레이션 (데이터 이관 등 수동 SQL) |
| `npm run migration:run` | 미적용 마이그레이션 실행 |
| `npm run migration:revert` | 마지막 마이그레이션 롤백 |
| `npm run migration:show` | 적용 상태 확인 |
규칙:
- **명령은 컨테이너 안에서 실행**한다. DB 접속 정보가 compose 로 주입되기 때문이다.
- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다.
- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다.
- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다.
- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되,
**커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다.
- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유).
---
## ⚠️ 의존성 추가/제거 (Windows 개발 환경 주의)
**Windows 에서 `npm install` / `npm uninstall` 을 실행하면 lock 파일에서 Linux 전용 optional 의존성이 잘려나간다.**
로컬에서는 멀쩡하지만 Docker(Linux) 빌드의 `npm ci` 가 다음처럼 실패한다.
```
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: @emnapi/core@1.11.3 from lock file
```
그래서 의존성 변경은 **컨테이너 안에서** 수행한다 (lock 이 Linux 기준으로 생성된다).
```bash
# 개발 스택이 떠 있는 상태에서
dc exec backend npm install <패키지>
dc exec backend npm uninstall <패키지>
```
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
---
## 작업 점검 체크리스트
코드 작성/수정 후 아래를 점검한다. (전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **응답 포맷**: 컨트롤러는 raw 데이터만 반환 — `{success,data}` 래핑은 인터셉터가 담당. 직접 래핑 금지. 스트림/204 는 `@SkipTransform()`.
- [ ] **에러**: 사용자 노출 실패는 `BusinessException('CODE')` 로 던진다. 코드 없는 실패 금지.
- [ ] **에러 코드 추가 시**: `error-codes.ts` 만 수정 → `npm run sync:error-codes` 실행 → 세 스택 목록 테스트 갱신 ([[error-codes]]).
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
- [ ] **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`.
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+230
View File
@@ -0,0 +1,230 @@
---
name: vue3
description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API + <script setup>, Props/Emits 타입 정의, Pinia 스토어, Composables+Axios API 호출, Vue Router 코드 스플리팅·가드. frontend/ 에서 작업하거나 컴포넌트·스토어·컴포저블·라우터를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/vue3/SKILL.md`에 위치한다.
> 사용법: `/vue3 [요청 내용]` 또는 frontend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
---
## 템플릿 기본 제공 골격
> 이 템플릿은 아래를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 정규화(`ApiError`), 중복 알림 억제 |
| 에러 코드 사전 | `src/shared/constants/error-codes.generated.ts` | **자동 생성 파일 — 직접 수정 금지** ([[error-codes]]) |
| 세션 만료 처리 | `useApi.ts` 의 `toAuthCode()` | 로그인 상태에서 401 → `AUTH_002` 승격 + `authStore.clearAuth()` |
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
| 도메인 Composable 예제 | `src/composables/useUser.ts` | 새 도메인 API 패턴 참고 |
| 인증 Composable 예제 | `src/composables/useAuth.ts` | 로그인/로그아웃 흐름(쿠키 기반) 시작점 |
| Pinia 스토어 예제 | `src/stores/user.store.ts` | 새 스토어 패턴 참고 |
| 인증 스토어 | `src/stores/auth.store.ts` | 세션 인증 여부(`isLoggedIn`) 관리 |
| 공통 컴포넌트 예제 | `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 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
> **개발 서버는 호스트에서 띄운다** — `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()` 호출
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
---
## 컴포넌트 작성 원칙
### 필수 구조
```vue
<script setup lang="ts">
// 1. import
// 2. Props / Emits 정의
// 3. Store / Composable 호출
// 4. 반응형 상태 (ref, reactive, computed)
// 5. 함수 정의
// 6. Lifecycle Hooks
// 7. watch
</script>
<template>
<!-- 시맨틱 HTML 태그 사용 -->
</template>
<style scoped>
/* rem 단위 사용, 인라인 스타일 금지 */
</style>
```
- `Options API` 사용 **금지** → `Composition API` + `<script setup>` 필수
- 인라인 스타일(`style=""`) 사용 **금지** → `<style scoped>` 사용
- 모든 px 값은 **rem으로 변환** (기준: 16px = 1rem)
---
## Props / Emits 타입 정의
```typescript
// Props
interface Props {
title: string
count?: number
items: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
})
// Emits
interface Emits {
(e: 'update', value: string): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
```
---
## 상태 관리 (Pinia)
```typescript
// stores/user.store.ts
export const useUserStore = defineStore('user', () => {
// 상태
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!user.value)
// 액션
async function fetchUser(id: number) {
// API 호출
}
return { user, isLoggedIn, fetchUser }
})
```
- 도메인별 Store 파일 분리 (`user.store.ts`, `auth.store.ts` 등)
- Store 내 API 호출은 허용, 단 에러 처리는 인터셉터에 위임
---
## API 호출 (Composables + Axios)
```typescript
// composables/useApi.ts — 중앙 Axios 인스턴스 (구조 요약)
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // env.d.ts 에 타입 선언
withCredentials: true, // HttpOnly 쿠키 전송
})
// 응답 인터셉터 — 표준 응답 언래핑 + 에러 코드 전역 처리
// 실패 시 ApiError { code, message, traceId, status } 로 정규화해 reject 한다.
// 문구는 생성된 사전에서만 오고, 같은 코드의 알림은 중복 억제된다.
```
에러 처리 규칙:
- **개별 `try/catch` 에서 메시지를 만들지 않는다.** 알림은 인터셉터가 이미 처리했다.
- 호출부에서 분기가 필요하면 `message` 가 아니라 **`code` 로 분기**한다.
```typescript
try {
await api.post('/users', payload)
} catch (e) {
const error = e as ApiError
if (error.code === 'USER_001') focusEmailField()
}
```
- Toast 라이브러리를 도입하면 `useApi.ts` 의 `showErrorUI()` **한 함수만** 교체한다.
```typescript
// composables/useUser.ts — 도메인별 API Composable
export function useUser() {
const loading = ref(false)
const error = ref<string | null>(null)
async function getUser(id: number) {
loading.value = true
try {
return await api.get(`/users/${id}`)
} finally {
loading.value = false
}
}
return { loading, error, getUser }
}
```
- API 호출 로직은 반드시 `composables/` 폴더에 분리
- 컴포넌트 내 `axios.get()` 직접 호출 **금지**
- `ErrorCodeLexicon` 은 세 플랫폼 공유 에러 맵의 일부 — [[error-codes]] 참조.
---
## 라우터 (Vue Router)
```typescript
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import
const routes = [
{
path: '/dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { requiresAuth: true },
},
]
// 네비게이션 가드 — 인증 처리
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return '/login'
}
})
```
---
## 성능 & 최신 트렌드 추가 규칙
- **컴포넌트 분리**: 200줄 이상 컴포넌트는 분리 고려
- **`defineAsyncComponent`**: 무거운 컴포넌트는 비동기 로딩 적용
- **`v-memo`**: 반복 렌더링 최적화 필요 시 활용
- **`<Suspense>`**: 비동기 컴포넌트 로딩 상태 처리에 활용
- **환경변수**: 모든 설정값은 `import.meta.env.VITE_*` 형태로 관리 ([[env-structure]] 참조)
- **절대경로**: `@/` 경로 alias 사용 (상대경로 `../../` 지양)
- **접근성(a11y)**: `aria-label`, `role`, `tabindex` 등 기본 접근성 속성 포함
---
## 작업 점검 체크리스트
(전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **API 호출**: 컴포넌트에서 `axios` 직접 호출 금지 → `composables/` 경유.
- [ ] **에러 처리**: 개별 try/catch 로 메시지를 만들지 말고 `useApi` 인터셉터 + [[error-codes]] 에 위임. 분기는 `error.code` 로.
- [ ] **에러 사전**: `error-codes.generated.ts` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록 화면**: 응답은 `{items, pageInfo}` — `shared/types/pagination.ts` 타입 사용.
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
- [ ] **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` 에 별도 작성 불필요.
- [ ] **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`), 프록시 대상 포트가 맞는지 확인한다.
+22
View File
@@ -0,0 +1,22 @@
# 에디터 공통 포맷 — 3스택·여러 에디터에서 들여쓰기/인코딩 통일
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Dart (dart format 기본 2칸)
[*.dart]
indent_size = 2
# Markdown — 줄 끝 공백이 의미를 가질 수 있어 트림 제외
[*.md]
trim_trailing_whitespace = false
# Makefile 은 탭 필수
[Makefile]
indent_style = tab
+81
View File
@@ -0,0 +1,81 @@
# ==========================================
# 프로젝트 전역 설정
# ==========================================
PROJECT_NAME=project
APP_ENV=development
BUILD_MODE=development
FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
#
# 컨테이너 포트 매핑뿐 아니라 **호스트에서 띄우는 vite dev 서버**도 이 값을 따른다.
# (frontend/vite.config.ts 가 이 파일을 직접 읽는다 — FRONTEND_PORT 로 열고
# /api 프록시는 BACKEND_PORT 로 보낸다. 포트를 바꾸면 CORS_ORIGIN 도 함께 고칠 것)
# ==========================================
FRONTEND_PORT=5173
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=app_user
DB_PASSWORD=change-me-in-local-env
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-in-local-env
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
# ==========================================
# Rate Limiting (API 남용 방지)
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
# ==========================================
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
# ==========================================
# DB 스키마 동기화 (개발 전용, 기본 false)
# true 로 켜면 엔티티 변경이 DB 에 자동 반영되어 빠르게 실험할 수 있지만,
# 그 상태에서는 migration:generate 가 "변경 없음"만 내놓는다.
# 운영으로 갈 코드라면 꺼둔 채 마이그레이션으로 관리할 것.
# (운영 환경에서는 이 값과 무관하게 항상 비활성화된다)
# ==========================================
DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = 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=
# ==========================================
# 파일 업로드 (로컬 디스크)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+82
View File
@@ -0,0 +1,82 @@
# ==========================================
# 프로젝트 전역 설정
# ==========================================
PROJECT_NAME=project
APP_ENV=production
BUILD_MODE=production
FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# ==========================================
FRONTEND_PORT=80
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=app_user
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
#
# ⚠️ 운영에서는 **반드시 실제 공개 도메인**으로 교체한다.
# - BACKEND_API_URL : 브라우저가 호출할 API 주소 (프론트엔드 번들에 그대로 박힌다)
# - CORS_ORIGIN : 백엔드가 허용할 오리진 (scheme+host[+port], 여러 개면 콤마 구분)
#
# 아래 example.com 이나 localhost 가 남아 있으면 **백엔드가 기동에 실패한다**
# (backend/src/shared/config/env.validation.ts — fail-fast).
# 기동은 되는데 브라우저만 조용히 실패하는 상황을 막기 위한 검사다.
#
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
# ==========================================
BACKEND_API_URL=https://api.example.com/api
CORS_ORIGIN=https://app.example.com
# ==========================================
# Rate Limiting (API 남용 방지)
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
# ==========================================
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
# ==========================================
# DB 스키마 동기화 (개발 전용, 기본 false)
# true 로 켜면 엔티티 변경이 DB 에 자동 반영되어 빠르게 실험할 수 있지만,
# 그 상태에서는 migration:generate 가 "변경 없음"만 내놓는다.
# 운영으로 갈 코드라면 꺼둔 채 마이그레이션으로 관리할 것.
# (운영 환경에서는 이 값과 무관하게 항상 비활성화된다)
# ==========================================
DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = redis 를 기동하지 않는다.
# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다.
# COMPOSE_PROFILES=redis
# 일회성으로 켜려면: docker compose --profile redis ... up -d
# ==========================================
COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+24
View File
@@ -0,0 +1,24 @@
# 줄바꿈 정규화 — 어떤 OS에서 클론해도 저장소 내부는 LF로 통일 (Windows 작업 시 CRLF 변환 churn 방지)
* text=auto eol=lf
# Windows 전용 스크립트는 CRLF 유지
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# 셸 스크립트는 LF 강제
*.sh text eol=lf
# 바이너리 — 변환/diff 제외
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.jar binary
*.keystore binary
+29
View File
@@ -0,0 +1,29 @@
## 무엇을 · 왜
<!-- 무엇을 바꿨고 왜 필요한지 적습니다. "무엇을"은 diff 가 말해주므로 "왜"에 비중을 둡니다. -->
## 확인 방법
<!-- 리뷰어가 동작을 확인할 수 있는 절차나 명령을 적습니다. -->
## 영향 범위
- [ ] backend
- [ ] frontend
- [ ] flutter
- [ ] docker / 인프라
- [ ] 규칙 문서(.claude/)
> **공유 계약(에러 코드 · 응답 포맷 · 페이지네이션 · API 규약)을 바꿨다면**
> 세 스택에 미치는 영향을 아래에 구체적으로 적어 주세요.
## 체크리스트
<!-- 자세한 항목은 .claude/rules/checklist.md 참조 -->
- [ ] 린트/분석 통과 (Warning 0) · 변경 영역 테스트 통과
- [ ] 주석 한국어, 매직 넘버·하드코딩 문자열 없음
- [ ] 사용자 노출 실패는 모두 에러 코드와 함께 처리 (`BusinessException` / `error.code` 분기)
- [ ] 에러 코드를 바꿨다면 `npm run sync:error-codes` 실행 후 생성 파일까지 커밋
- [ ] 민감정보 미커밋 (`*.example` 값은 placeholder)
- [ ] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음
+57
View File
@@ -0,0 +1,57 @@
name: CI (Flutter)
# Flutter 는 기본 CI 에서 분리되어 있다.
#
# 이유: SDK 다운로드로 매 실행마다 수 분이 걸리는데,
# backend/frontend 만 고친 커밋에서는 검사할 것이 없다.
#
# 언제 도는가:
# - flutter/ 아래 파일이 바뀐 푸시·PR (= 실제로 Flutter 를 쓸 때 자동으로)
# - Actions 탭의 "Run workflow" 버튼으로 수동 실행
#
# 모바일 앱을 아예 쓰지 않는 프로젝트라면 이 파일을 지우면 된다.
on:
push:
branches: [main]
paths:
- 'flutter/**'
- '.github/workflows/ci-flutter.yml'
pull_request:
paths:
- 'flutter/**'
- '.github/workflows/ci-flutter.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
flutter:
name: Flutter (analyze · test)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 25
defaults:
run:
working-directory: flutter
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
# .env 는 pubspec 의 asset 이라 없으면 analyze 가 경고를 낸다
- name: Prepare .env
run: cp .env.example .env
- run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test
+135
View File
@@ -0,0 +1,135 @@
name: CI
on:
push:
branches: [main]
pull_request:
# 같은 브랜치에서 새 푸시가 오면 이전 실행을 취소한다
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend (lint · test · e2e · build)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 20
defaults:
run:
working-directory: backend
# e2e 는 응답 계약을 실제 HTTP 로 검증하므로 DB 가 필요하다.
#
# ⚠️ 호스트 포트 매핑(ports)을 두지 않는다.
# Gitea Actions 러너는 잡을 **컨테이너로** 실행하고 서비스와 같은 네트워크에 붙여준다.
# 따라서 서비스 이름(postgres)으로 바로 접근할 수 있고, 호스트 포트는 필요 없다.
# 오히려 개발 DB 가 이미 5432 를 쓰고 있으면 충돌해 잡 전체가 시작조차 못 한다.
# (GitHub Actions 는 잡이 VM 위에서 직접 돌아 localhost + ports 를 쓰는 점이 다르다)
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: ci_user
POSTGRES_PASSWORD: ci_password
POSTGRES_DB: ci_db
options: >-
--health-cmd "pg_isready -U ci_user -d ci_db"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
# Warning 0 (.claude/rules/checklist.md)
- name: Lint
run: npm run lint
- name: Test
run: npm run test
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
- name: E2E test
run: npm run test:e2e
env:
NODE_ENV: test
# 서비스 컨테이너의 네트워크 별칭 (localhost 는 잡 컨테이너 자신을 가리킨다)
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ci_user
DB_PASSWORD: ci_password
DB_NAME: ci_db
- name: Build
run: npm run build
frontend:
name: Frontend (lint · type-check · test)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 15
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Test
run: npm run test:unit -- --run
error-codes-sync:
name: 에러 코드 SSOT 동기화 검사
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: backend
run: npm ci
# 레지스트리만 고치고 sync 를 안 돌린 경우를 잡아낸다.
# 생성 파일이 최신이면 diff 가 비어 있어야 한다.
- name: Regenerate client error lexicons
working-directory: backend
run: npm run sync:error-codes
- name: Fail if generated files are stale
run: |
if ! git diff --exit-code -- \
frontend/src/shared/constants/error-codes.generated.ts \
flutter/lib/core/api/error_lexicon.g.dart; then
echo ""
echo "::error::에러 코드 생성 파일이 레지스트리와 어긋났습니다."
echo "backend 에서 'npm run sync:error-codes' 를 실행하고 생성 파일을 함께 커밋하세요."
exit 1
fi
+59
View File
@@ -0,0 +1,59 @@
# ==========================================
# 환경 변수 (민감정보) — 절대 커밋 금지
# ==========================================
.env
.env.*
!.env.example
!.env.*.example
# ==========================================
# 도커 영속 데이터
# ==========================================
# ⚠️ 루트 기준으로 고정한다(앞의 /). 'data/' 로 두면 모든 depth 의 data 디렉터리가 무시되어
# flutter/lib/features/<도메인>/data/ 같은 소스 코드까지 커밋되지 않는다.
/data/
!/data/.gitkeep
/db-data/
/redis-data/
# CI 러너 등록 정보
ci/runner-data/
# 업로드 파일 (로컬 저장) — 코드가 아니다
/data/uploads/
backend/uploads/
# ==========================================
# 의존성/빌드 산출물
# ==========================================
node_modules/
dist/
build/
coverage/
.tsbuildinfo
*.tsbuildinfo
# ==========================================
# 로그
# ==========================================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# ==========================================
# IDE / OS
# ==========================================
.idea/
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.DS_Store
Thumbs.db
# ==========================================
# 캐시
# ==========================================
.cache/
.eslintcache
.npm/
+70
View File
@@ -0,0 +1,70 @@
# 프로젝트 개요
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
| 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) |
| ----------- | -------------- | ------------------------ | ----------------------------------------- |
| `backend/` | NestJS (TS) | REST API 서버 | `/nestjs` → @.claude/skills/nestjs/SKILL.md |
| `frontend/` | Vue 3 (TS) | 웹 프론트엔드 | `/vue3` → @.claude/skills/vue3/SKILL.md |
| `flutter/` | Flutter (Dart) | 모바일 앱 | `/flutter` → @.claude/skills/flutter/SKILL.md |
| 루트 | Docker Compose | 로컬/운영 오케스트레이션 | `/docker` → @.claude/skills/docker/SKILL.md |
세 클라이언트는 동일한 **표준 응답 포맷**과 **에러 코드 맵**을 공유한다 (아래 공통 규칙 참조).
> 규칙은 `.claude/` 아래로 모듈화되어 있다.
> - `.claude/rules/` — 세 플랫폼 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·커밋·보안·환경변수·테스트)
> 새 규칙 파일을 추가하면 **반드시 아래 `@` import 목록에도 등록**한다(등록하지 않으면 로드되지 않는다).
> - `.claude/skills/` — 도메인별 개발 규칙 (스킬). 슬래시 명령(`/nestjs` 등) 또는 해당 디렉터리 작업 시 자동 적용
> - `.claude/agents/` — 역할별 규칙 (에이전트 팀원). 역할을 지정해 호출할 때 적용
> **`@` import 하지 않는다** — 규칙이 아니라 호출 시점에 로드되는 역할 정의다 (아래 목록 참조)
> - `.claude/settings.json` — Claude Code 권한·환경 설정
---
## 공통 규칙 (모든 디렉터리 적용)
@.claude/rules/coding-common.md
@.claude/rules/response-format.md
@.claude/rules/error-codes.md
@.claude/rules/api-design.md
@.claude/rules/logging.md
@.claude/rules/git-convention.md
@.claude/rules/security.md
@.claude/rules/env-structure.md
@.claude/rules/testing.md
@.claude/rules/checklist.md
---
## 도메인별 개발 규칙 (스킬)
- 백엔드(NestJS): @.claude/skills/nestjs/SKILL.md
- 프론트엔드(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) 참조.
역할을 지정하지 않으면 평소대로 동작한다 — 모든 작업을 역할로 나눌 필요는 없다.
+202
View File
@@ -0,0 +1,202 @@
# 풀스택 모노레포 기본 템플릿
NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다.
세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다.
## 구성
| 디렉터리 | 스택 | 역할 | 포트(기본) |
| ----------- | -------------- | ------------------------ | ---------- |
| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 |
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
| `db` | PostgreSQL | 데이터베이스 | 5432 |
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
## 사전 요구사항
- Docker / Docker Compose — backend·frontend·db·redis 구동
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
## 빠른 시작 (Docker)
```bash
# 1) 환경변수 파일 생성 (예제 복사)
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
cp .env.development.example .env.development # ← 여기에만 값 입력
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
# 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>
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
> 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 를 직접 읽습니다.
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
> (`CORS_ORIGIN` 과 compose 포트 매핑이 그 값에 묶여 있어, 말없이 옮겨가면 원인 모르게 어긋납니다).
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
## 모바일 앱 실행 (Flutter)
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
```bash
cd flutter && cp .env.example .env && flutter pub get && flutter run
```
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`)
## 환경변수
`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요.
**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가
루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조)
| 파일 | 용도 | 값 작성 위치 |
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL` 주입 | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
## 표준 응답 포맷
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
// 목록 — data 안의 형태가 고정되어 있다
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
```
`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다.
사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다.
> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다.
### 에러 코드는 한 곳에서 관리됩니다
```
backend/src/common/errors/error-codes.ts ← 여기만 수정
│ cd backend && npm run sync:error-codes
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
```
생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다.
자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조.
## 로컬 개발 (Docker 없이)
DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다.
```bash
# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
cd backend && npm ci && npm run start:dev
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
cd frontend && npm ci && npm run dev
# 4) flutter
cd flutter && flutter pub get && flutter run
```
> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다.
> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요.
### 커밋 전 검사
```bash
cd backend && npm run lint && 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
```
lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`.
같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다.
> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요.
> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다.
> ```bash
> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>
> ```
## 개발 규칙
| 영역 | 문서 |
| --------- | ------------------------------------------------- |
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
| 로깅 | [`logging.md`](./.claude/rules/logging.md) |
| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) |
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
| 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) |
## 디렉터리 구조
```
.
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
├── flutter/ # Flutter (core / features / shared)
├── docker-compose.yml # 베이스 정의
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
├── .github/
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 검사
│ └── pull_request_template.md
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
└── .claude/
├── settings.json # Claude Code 권한·환경 설정
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
```
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 백엔드 고유 시크릿 (development)
#
# ⚠️ DB / Redis / CORS / PORT 는 루트 .env.development 에서 docker-compose 가 주입한다.
# 여기에 중복 작성하지 말 것 (SSOT: 루트 .env — .claude/rules/env-structure.md).
# 이 파일에는 "루트에서 내려주지 않는 값"만 작성한다.
#
# 사용법: cp .env.development.example .env.development
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 백엔드 고유 시크릿 (production)
#
# ⚠️ DB / Redis / CORS / PORT 는 루트 .env.production 에서 docker-compose 가 주입한다.
# 여기에 중복 작성하지 말 것 (SSOT: 루트 .env — .claude/rules/env-structure.md).
# 이 파일에는 "루트에서 내려주지 않는 값"만 작성한다.
#
# 사용법: cp .env.production.example .env.production
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
+58
View File
@@ -0,0 +1,58 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+39
View File
@@ -0,0 +1,39 @@
FROM node:20-alpine AS builder
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 개발 환경(docker-compose.dev.yml)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다.
# /app 소유권을 넘겨야 익명 볼륨(node_modules)도 node 소유로 생성되어 캐시 쓰기가 가능하다.
RUN chown node:node /app
USER node
# 재현 가능한 빌드를 위해 npm ci 사용 (lockfile 기반)
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# 운영 stage 도 동일 npm 버전 사용
RUN npm install -g npm@11
# 운영용 의존성만 설치
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
# 비-root 실행 (보안 규칙)
USER node
CMD ["node", "dist/main"]
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+67
View File
@@ -0,0 +1,67 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
// 타입 규칙 — 명시적 타입 선언 강제 ([[coding-common]])
'@typescript-eslint/no-explicit-any': 'error',
// 비동기 누락은 조용한 버그가 되므로 error 로 승격
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
// 로깅 규칙 — console 대신 winston/Nest Logger 사용 ([[logging]])
'no-console': 'error',
// 에러 규칙 — 코드 없는 실패 금지. HttpException 직접 상속/생성 대신
// BusinessException 을 사용한다 ([[error-codes]])
'no-restricted-syntax': [
'error',
{
selector:
"NewExpression[callee.name='HttpException']:not([callee.name='BusinessException'])",
message:
'HttpException 을 직접 생성하지 말고 BusinessException(code) 을 사용하세요. (.claude/rules/error-codes.md)',
},
],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
{
// 에러 코드 레지스트리·예외 필터는 프레임워크 예외를 직접 다루는 계층이므로 예외 허용
files: ['src/common/errors/**/*.ts', 'src/common/filters/**/*.ts'],
rules: {
'no-restricted-syntax': 'off',
},
},
{
// CLI 스크립트는 콘솔 출력이 정상 동작이다
files: ['scripts/**/*.ts'],
rules: {
'no-console': 'off',
},
},
);
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
}
}
+13609
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"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",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"sync:error-codes": "ts-node -P tsconfig.json scripts/sync-error-codes.ts",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"migration:show": "npm run typeorm -- migration:show"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"winston": "^3.17.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.24",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"overrides": {
"multer": "^2.4.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
},
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* 에러 코드 SSOT 동기화 스크립트.
*
* backend 의 error-codes.ts 레지스트리를 읽어 프론트엔드/플러터의 에러 사전을 생성한다.
* 실행: npm run sync:error-codes (backend 디렉터리에서)
*
* 생성 파일은 직접 수정하지 말 것 — 레지스트리를 고치고 다시 실행한다.
* CI 는 이 스크립트를 실행한 뒤 git diff 로 드리프트를 검사한다.
*/
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { ERROR_CODES } from '../src/common/errors/error-codes';
const REPO_ROOT = resolve(__dirname, '..', '..');
// 이 스크립트는 저장소 루트 기준으로 frontend/·flutter/ 에 파일을 쓴다.
// 컨테이너 안에서는 backend/ 만 마운트되어 형제 디렉터리가 없으므로 실행할 수 없다.
if (!existsSync(resolve(REPO_ROOT, 'frontend'))) {
console.error(
[
'저장소 루트를 찾을 수 없습니다: ' + REPO_ROOT,
'',
'이 스크립트는 frontend/·flutter/ 에 사전 파일을 생성하므로',
'저장소 전체가 보이는 곳에서 실행해야 합니다.',
'컨테이너 안(backend/ 만 마운트됨)이 아니라 **호스트에서** 실행하세요:',
' cd backend && npm run sync:error-codes',
].join('\n'),
);
process.exit(1);
}
const GENERATED_NOTICE =
'backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.';
const entries = Object.entries(ERROR_CODES);
/** TypeScript 작은따옴표 문자열 리터럴로 이스케이프한다. */
function toTsLiteral(value: string): string {
const escaped = value
.split('\\')
.join('\\\\')
.split("'")
.join('\\' + "'");
return `'${escaped}'`;
}
/**
* Dart 작은따옴표 문자열 리터럴로 이스케이프한다.
* Dart 는 문자열 안의 `$` 를 보간으로 해석하므로 함께 이스케이프해야 한다.
*/
function toDartLiteral(value: string): string {
const escaped = value
.split('\\')
.join('\\\\')
.split("'")
.join('\\' + "'")
.split('$')
.join('\\' + '$');
return `'${escaped}'`;
}
function writeGenerated(relativePath: string, content: string): void {
const target = resolve(REPO_ROOT, relativePath);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content, { encoding: 'utf8' });
console.log(`생성 완료: ${relativePath}`);
}
// ---------- frontend (TypeScript) ----------
const tsBody = entries
.map(([code, def]) => ` ${code}: ${toTsLiteral(def.message)},`)
.join('\n');
writeGenerated(
'frontend/src/shared/constants/error-codes.generated.ts',
`// ${GENERATED_NOTICE}\n\n` +
`export const ERROR_CODE_MESSAGES = {\n${tsBody}\n} as const\n\n` +
`export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES\n\n` +
`/** 매핑 실패 시 사용하는 폴백 코드 */\n` +
`export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'\n`,
);
// ---------- flutter (Dart) ----------
const dartBody = entries
.map(([code, def]) => ` '${code}': ${toDartLiteral(def.message)},`)
.join('\n');
writeGenerated(
'flutter/lib/core/api/error_lexicon.g.dart',
`// ${GENERATED_NOTICE}\n\n` +
`const Map<String, String> kErrorCodeMessages = {\n${dartBody}\n};\n\n` +
`/// 매핑 실패 시 사용하는 폴백 코드\n` +
`const String kFallbackErrorCode = 'SYS_001';\n`,
);
console.log(`\n총 ${entries.length}개 에러 코드를 동기화했습니다.`);
+77
View File
@@ -0,0 +1,77 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { HealthModule } from '@/modules/health/health.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
import { buildDatabaseOptions } from '@/shared/config/database.config';
import { validateEnv } from '@/shared/config/env.validation';
import { ShutdownLogger } from '@/shared/logger/shutdown.logger';
// Rate Limiting 기본 정책 — 루트 .env 의 THROTTLE_* 로 덮어쓸 수 있다 (.claude/rules/env-structure.md)
const THROTTLE_DEFAULT_TTL_MS = 60_000; // 집계 구간: 60초
const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
@Module({
imports: [
ScheduleModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
// 설정이 잘못되면 조용히 기본값으로 굴러가지 않고 기동 시점에 실패시킨다
validate: validateEnv,
}),
// 전역 캐시 매니저 — Redis 도입 시 store 옵션을 redisStore 로 변경
CacheModule.register({
isGlobal: true,
ttl: 60_000, // 60초 기본 TTL
}),
// 전역 Rate Limiter — API 남용/브루트포스 방지 (프로젝트의 유일한 rate limiting 수단)
// 한도 초과 시 429 → 전역 필터가 REQ_001 로 매핑한다.
// 라우트 단위 조정은 @Throttle({ default: { ttl, limit } }), 제외는 @SkipThrottle().
ThrottlerModule.forRoot([
{
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다
ttl: parsePositiveInt(
process.env.THROTTLE_TTL_MS,
THROTTLE_DEFAULT_TTL_MS,
),
limit: parsePositiveInt(
process.env.THROTTLE_LIMIT,
THROTTLE_DEFAULT_LIMIT,
),
},
]),
// DB 접속 설정은 database.config.ts 하나만 수정한다 (CLI 와 공유)
TypeOrmModule.forRootAsync({
useFactory: () => ({
...buildDatabaseOptions(),
// Nest 모듈에 등록된 엔티티를 자동으로 수집한다 (CLI 는 glob 경로를 사용)
autoLoadEntities: true,
}),
}),
HealthModule,
UploadModule,
UserModule,
],
providers: [
ShutdownLogger,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
// 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값)
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
@@ -0,0 +1,71 @@
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { ErrorCode, getErrorDefinition } from '@/common/errors/error-codes';
/**
* Swagger 문서를 실제 응답(표준 래퍼)과 일치시킨다.
* 컨트롤러는 raw 데이터를 반환하지만 클라이언트가 받는 본문은 { success, data } 이므로,
* `@ApiResponse({ type: Dto })` 를 그대로 쓰면 문서와 실응답이 어긋난다.
*
* 사용 예:
* @ApiStandardResponse(UserResponseDto, { status: 200, description: '사용자 조회 성공' })
*/
export function ApiStandardResponse<TModel extends Type<unknown>>(
model?: TModel,
options: { status?: number; description?: string; isArray?: boolean } = {},
) {
const { status = 200, description = '요청 성공', isArray = false } = options;
const dataSchema = model
? isArray
? { type: 'array', items: { $ref: getSchemaPath(model) } }
: { $ref: getSchemaPath(model) }
: { type: 'object', nullable: true };
return applyDecorators(
...(model ? [ApiExtraModels(model)] : []),
ApiResponse({
status,
description,
schema: {
properties: {
success: { type: 'boolean', example: true },
data: dataSchema,
},
},
}),
);
}
/**
* 표준 실패 응답을 에러 코드와 함께 문서화한다.
* 상태 코드·메시지는 레지스트리에서 가져오므로 문서와 구현이 자동으로 일치한다.
*
* 사용 예:
* @ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
*/
export function ApiErrorResponse(code: ErrorCode, description?: string) {
const definition = getErrorDefinition(code);
return ApiResponse({
status: definition.status,
description: description ?? definition.message,
schema: {
properties: {
success: { type: 'boolean', example: false },
error: {
type: 'object',
properties: {
code: { type: 'string', example: code },
message: { type: 'string', example: definition.message },
traceId: {
type: 'string',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
},
},
},
},
},
});
}
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_TRANSFORM_KEY = 'skipTransform';
/**
* 표준 응답 래핑({ success, data })을 건너뛴다.
* 파일 다운로드·스트림·리다이렉트·204 No Content 처럼
* 본문 구조를 바꾸면 안 되는 핸들러에만 사용한다.
*/
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);
+99
View File
@@ -0,0 +1,99 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/** 페이지네이션 기본값 — 매직 넘버 방지용 상수 */
export const PAGINATION_DEFAULT_LIMIT = 20;
export const PAGINATION_MAX_LIMIT = 100;
/** 커서 기반 페이지네이션 쿼리 (목록 API 기본 권장) */
export class CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '이전 응답의 nextCursor 값. 첫 페이지는 생략한다.',
example: 'eyJpZCI6MTIzfQ==',
})
@IsOptional()
@IsString({ message: '커서 값의 형식이 올바르지 않습니다.' })
cursor?: string;
@ApiPropertyOptional({
description: `한 번에 가져올 개수 (1~${PAGINATION_MAX_LIMIT})`,
default: PAGINATION_DEFAULT_LIMIT,
example: PAGINATION_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(PAGINATION_MAX_LIMIT, {
message: `조회 개수는 ${PAGINATION_MAX_LIMIT} 이하여야 합니다.`,
})
limit: number = PAGINATION_DEFAULT_LIMIT;
}
/** 오프셋 기반 페이지네이션 쿼리 (전체 페이지 수가 필요한 화면에서만 사용) */
export class OffsetPaginationQueryDto {
@ApiPropertyOptional({
description: '1부터 시작하는 페이지 번호',
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '페이지 번호는 정수로 입력해 주세요.' })
@Min(1, { message: '페이지 번호는 1 이상이어야 합니다.' })
page: number = 1;
@ApiPropertyOptional({
description: `한 페이지 개수 (1~${PAGINATION_MAX_LIMIT})`,
default: PAGINATION_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(PAGINATION_MAX_LIMIT, {
message: `조회 개수는 ${PAGINATION_MAX_LIMIT} 이하여야 합니다.`,
})
limit: number = PAGINATION_DEFAULT_LIMIT;
}
/** 목록 응답의 페이지 정보 — 세 플랫폼 공유 계약 */
export class PageInfoDto {
@ApiProperty({ description: '다음 페이지 존재 여부', example: true })
hasNext!: boolean;
@ApiProperty({
description: '다음 요청에 넘길 커서. 마지막 페이지면 null',
nullable: true,
example: 'eyJpZCI6MTQzfQ==',
})
nextCursor!: string | null;
@ApiProperty({
description: '전체 개수. 계산 비용이 큰 경우 null 을 반환할 수 있다.',
nullable: true,
example: 143,
})
total!: number | null;
}
/** 목록 응답 본문 — 인터셉터가 { success, data } 로 한 번 더 감싼다. */
export interface PaginatedResult<T> {
items: T[];
pageInfo: PageInfoDto;
}
/** 조회 결과를 표준 목록 응답으로 변환한다. */
export function toPaginatedResult<T>(
items: T[],
pageInfo: Partial<PageInfoDto> = {},
): PaginatedResult<T> {
return {
items,
pageInfo: {
hasNext: pageInfo.hasNext ?? false,
nextCursor: pageInfo.nextCursor ?? null,
total: pageInfo.total ?? null,
},
};
}
@@ -0,0 +1,33 @@
import { HttpException } from '@nestjs/common';
import { ErrorCode, getErrorDefinition } from './error-codes';
export interface BusinessExceptionOptions {
/** 레지스트리 기본 메시지 대신 사용자에게 보여줄 문구 (동적 값이 필요한 경우만) */
readonly message?: string;
/** 로그에만 남기는 개발자용 컨텍스트 — 응답 본문에는 포함되지 않는다 */
readonly context?: Record<string, unknown>;
}
/**
* 사용자에게 노출되는 모든 실패는 이 예외로 던진다.
* 에러 코드가 생성자 필수 인자이므로, 코드 없는 에러가 만들어질 수 없다.
*
* 사용 예:
* throw new BusinessException('RES_001');
* throw new BusinessException('USER_001', { context: { email } });
*/
export class BusinessException extends HttpException {
readonly code: ErrorCode;
readonly context?: Record<string, unknown>;
constructor(code: ErrorCode, options: BusinessExceptionOptions = {}) {
const definition = getErrorDefinition(code);
const message = options.message ?? definition.message;
super({ code, message }, definition.status);
this.code = code;
this.context = options.context;
}
}
@@ -0,0 +1,87 @@
import { HttpStatus } from '@nestjs/common';
import {
ERROR_CODES,
FALLBACK_ERROR_CODE,
getErrorDefinition,
isErrorCode,
resolveCodeByStatus,
} from './error-codes';
import { BusinessException } from './business.exception';
describe('에러 코드 레지스트리', () => {
it('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () => {
for (const code of Object.keys(ERROR_CODES)) {
expect(code).toMatch(/^[A-Z]+_\d{3}$/);
}
});
it('모든 코드는 메시지와 HTTP 상태를 갖는다', () => {
for (const [code, definition] of Object.entries(ERROR_CODES)) {
expect(definition.message.length).toBeGreaterThan(0);
expect(definition.status).toBeGreaterThanOrEqual(400);
expect(code).not.toBe('');
}
});
it('미등록 코드는 폴백(SYS_001) 정의를 반환한다', () => {
expect(getErrorDefinition('UNKNOWN_999')).toBe(
ERROR_CODES[FALLBACK_ERROR_CODE],
);
expect(isErrorCode('UNKNOWN_999')).toBe(false);
});
it('HTTP 상태 코드를 대응하는 에러 코드로 역매핑한다', () => {
expect(resolveCodeByStatus(HttpStatus.UNAUTHORIZED)).toBe('AUTH_001');
expect(resolveCodeByStatus(HttpStatus.FORBIDDEN)).toBe('AUTH_003');
expect(resolveCodeByStatus(HttpStatus.NOT_FOUND)).toBe('RES_001');
expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001');
expect(resolveCodeByStatus(HttpStatus.TOO_MANY_REQUESTS)).toBe('REQ_001');
});
it('매핑되지 않은 4xx 는 BIZ_001, 5xx 는 SYS_001 로 수렴한다', () => {
expect(resolveCodeByStatus(HttpStatus.CONFLICT)).toBe('BIZ_001');
expect(resolveCodeByStatus(HttpStatus.BAD_GATEWAY)).toBe('SYS_001');
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => definition.clientOnly)
.map(([code]) => code);
expect(clientOnlyCodes).toContain('AUTH_002');
// 모든 4xx/5xx 상태에 대해 clientOnly 코드가 선택되지 않아야 한다.
for (let status = 400; status < 600; status += 1) {
expect(clientOnlyCodes).not.toContain(resolveCodeByStatus(status));
}
});
});
describe('BusinessException', () => {
it('레지스트리의 상태 코드와 메시지를 그대로 사용한다', () => {
const exception = new BusinessException('RES_001');
expect(exception.getStatus()).toBe(HttpStatus.NOT_FOUND);
expect(exception.getResponse()).toEqual({
code: 'RES_001',
message: ERROR_CODES.RES_001.message,
});
});
it('메시지를 재정의해도 코드와 상태는 유지된다', () => {
const exception = new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다.',
context: { email: 'user@example.com' },
});
expect(exception.code).toBe('BIZ_001');
expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST);
expect(exception.getResponse()).toEqual({
code: 'BIZ_001',
message: '이미 가입된 이메일입니다.',
});
// context 는 로그 전용이며 응답 본문에 포함되지 않는다.
expect(exception.context).toEqual({ email: 'user@example.com' });
});
});
+121
View File
@@ -0,0 +1,121 @@
import { HttpStatus } from '@nestjs/common';
/**
* 에러 코드 레지스트리 — 세 플랫폼(backend / frontend / flutter)이 공유하는 단일 진실 공급원(SSOT).
*
* 이 파일만 수정한 뒤 `npm run sync:error-codes` 를 실행하면
* 프론트엔드/플러터의 에러 사전이 자동 생성된다. (수동 복사 금지)
*/
export interface ErrorCodeDefinition {
/** 이 코드로 응답할 때 사용할 HTTP 상태 코드 */
readonly status: HttpStatus;
/** 사용자에게 노출되는 한국어 메시지 */
readonly message: string;
/** true 면 클라이언트에서만 생성하는 코드 — 서버 응답에는 절대 나타나지 않는다 */
readonly clientOnly?: true;
}
// 공통 코드 — 모든 도메인이 공유한다.
const COMMON_ERROR_CODES = {
SYS_001: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
},
REQ_001: {
status: HttpStatus.TOO_MANY_REQUESTS,
message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
},
AUTH_001: {
status: HttpStatus.UNAUTHORIZED,
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
},
AUTH_002: {
status: HttpStatus.UNAUTHORIZED,
message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
// 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다.
clientOnly: true,
},
AUTH_003: {
status: HttpStatus.FORBIDDEN,
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
},
VAL_001: {
status: HttpStatus.BAD_REQUEST,
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
},
RES_001: {
status: HttpStatus.NOT_FOUND,
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
},
BIZ_001: {
status: HttpStatus.BAD_REQUEST,
message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
/**
* 도메인 코드 — 기능 추가 시 여기에 `DOMAIN_NNN` 형식으로 등록한다.
* 번호는 001 부터 순차 부여하고, 한번 배포된 코드의 의미는 바꾸지 않는다(폐기 시 주석으로 표시).
*
* 예시:
* USER_001: {
* status: HttpStatus.CONFLICT,
* message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
* },
*/
const DOMAIN_ERROR_CODES = {
FILE_001: {
status: HttpStatus.BAD_REQUEST,
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
},
FILE_002: {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
...COMMON_ERROR_CODES,
...DOMAIN_ERROR_CODES,
} as const;
export type ErrorCode = keyof typeof ERROR_CODES;
/** 매핑에 실패했을 때 사용하는 최종 폴백 코드 */
export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001';
/** HTTP 상태 코드 → 에러 코드 폴백 매핑 (프레임워크가 던진 HttpException 용) */
const STATUS_TO_ERROR_CODE: ReadonlyMap<number, ErrorCode> = new Map<
number,
ErrorCode
>([
[HttpStatus.UNAUTHORIZED, 'AUTH_001'],
[HttpStatus.FORBIDDEN, 'AUTH_003'],
[HttpStatus.NOT_FOUND, 'RES_001'],
[HttpStatus.BAD_REQUEST, 'VAL_001'],
[HttpStatus.UNPROCESSABLE_ENTITY, 'VAL_001'],
[HttpStatus.TOO_MANY_REQUESTS, 'REQ_001'],
// multer 가 파일 크기 한도 초과 시 던지는 상태 코드
[HttpStatus.PAYLOAD_TOO_LARGE, 'FILE_002'],
]);
/** 등록된 에러 코드인지 확인한다. */
export function isErrorCode(code: string): code is ErrorCode {
return Object.prototype.hasOwnProperty.call(ERROR_CODES, code);
}
/** 에러 코드 정의를 조회한다. 미등록 코드는 폴백(SYS_001) 정의를 반환한다. */
export function getErrorDefinition(code: string): ErrorCodeDefinition {
return isErrorCode(code)
? ERROR_CODES[code]
: ERROR_CODES[FALLBACK_ERROR_CODE];
}
/** HTTP 상태 코드에 대응하는 에러 코드를 찾는다. 4xx 는 BIZ_001, 그 외는 SYS_001 로 수렴한다. */
export function resolveCodeByStatus(status: number): ErrorCode {
const mapped = STATUS_TO_ERROR_CODE.get(status);
if (mapped) return mapped;
if (status >= 400 && status < 500) return 'BIZ_001';
return FALLBACK_ERROR_CODE;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './error-codes';
export * from './business.exception';
@@ -0,0 +1,130 @@
import {
ArgumentsHost,
BadRequestException,
ForbiddenException,
HttpStatus,
Logger,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
import { BusinessException } from '@/common/errors/business.exception';
import { ERROR_CODES } from '@/common/errors/error-codes';
interface CapturedResponse {
status: number;
body: {
success: boolean;
error: { code: string; message: string; traceId?: string };
};
}
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
const captured = {} as CapturedResponse;
const response = {
status(code: number) {
captured.status = code;
return this;
},
json(body: CapturedResponse['body']) {
captured.body = body;
return this;
},
};
const request = {
method: 'GET',
url: '/api/users/1',
header: () => requestId,
};
const host = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as unknown as ArgumentsHost;
new HttpExceptionFilter().catch(exception, host);
return captured;
}
describe('HttpExceptionFilter', () => {
beforeAll(() => {
// 의도된 예외 로그가 테스트 출력을 오염시키지 않도록 억제
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterAll(() => {
jest.restoreAllMocks();
});
it('BusinessException 은 지정한 코드와 상태를 그대로 내보낸다', () => {
const result = runFilter(new BusinessException('RES_001'));
expect(result.status).toBe(HttpStatus.NOT_FOUND);
expect(result.body).toEqual({
success: false,
error: { code: 'RES_001', message: ERROR_CODES.RES_001.message },
});
});
it('Nest 내장 예외는 상태 코드로 역매핑된다', () => {
expect(runFilter(new NotFoundException()).body.error.code).toBe('RES_001');
expect(runFilter(new ForbiddenException()).body.error.code).toBe(
'AUTH_003',
);
expect(runFilter(new BadRequestException()).body.error.code).toBe(
'VAL_001',
);
});
it('ValidationPipe 의 상세 사유(배열 message)는 노출하지 않는다', () => {
const exception = new BadRequestException({
message: ['email must be an email'],
error: 'Bad Request',
statusCode: 400,
});
const result = runFilter(exception);
expect(result.body.error.code).toBe('VAL_001');
expect(result.body.error.message).toBe(ERROR_CODES.VAL_001.message);
});
it('라이브러리가 만든 영문 메시지를 사용자에게 노출하지 않는다', () => {
// multer 는 크기 초과 시 "File too large" 라는 영문 메시지로 예외를 만든다.
// 사용자 노출 문구는 에러 코드 사전에만 존재해야 한다.
const result = runFilter(new PayloadTooLargeException('File too large'));
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
expect(result.body.error.code).toBe('FILE_002');
expect(result.body.error.message).toBe(ERROR_CODES.FILE_002.message);
expect(JSON.stringify(result.body)).not.toContain('File too large');
});
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
expect(result.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect(result.body.error.code).toBe('SYS_001');
expect(result.body.error.message).toBe(ERROR_CODES.SYS_001.message);
expect(JSON.stringify(result.body)).not.toContain('10.0.0.5');
});
it('요청 추적 ID 가 있으면 응답에 traceId 로 포함한다', () => {
const result = runFilter(new BusinessException('SYS_001'), 'trace-abc');
expect(result.body.error.traceId).toBe('trace-abc');
});
it('요청 추적 ID 가 없으면 traceId 키를 넣지 않는다', () => {
const result = runFilter(new BusinessException('SYS_001'));
expect(result.body.error).not.toHaveProperty('traceId');
});
});
@@ -0,0 +1,126 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
import {
ErrorCode,
FALLBACK_ERROR_CODE,
getErrorDefinition,
isErrorCode,
resolveCodeByStatus,
} from '@/common/errors/error-codes';
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
const SERVER_ERROR_STATUS_THRESHOLD = 500;
/**
* 전역 Exception Filter — 모든 실패 응답을 표준 포맷으로 변환한다.
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
* 이 필터는 조회만 한다(여기에 문구를 하드코딩하지 말 것).
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const { status, code, message } = this.resolve(exception);
const traceId = request.header(REQUEST_ID_HEADER) ?? undefined;
this.log(exception, status, code, request, traceId);
response.status(status).json({
success: false,
error: { code, message, ...(traceId ? { traceId } : {}) },
});
}
/** 예외를 { status, code, message } 로 정규화한다. */
private resolve(exception: unknown): {
status: HttpStatus;
code: ErrorCode;
message: string;
} {
// 1. BusinessException — 코드가 이미 확정되어 있다.
if (exception instanceof BusinessException) {
const body = exception.getResponse() as {
code: ErrorCode;
message: string;
};
return {
status: exception.getStatus(),
code: body.code,
message: body.message,
};
}
// 2. 그 외 HttpException(프레임워크/Nest 내장 예외) — 상태 코드로 역매핑한다.
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
const explicitCode = this.extractCode(body);
const code = explicitCode ?? resolveCodeByStatus(status);
return {
status,
code,
// 프레임워크/라이브러리가 만든 영문 메시지("File too large" 등)가 그대로 새어나가지 않도록
// **항상 레지스트리 문구**를 쓴다. 사용자 노출 문구를 재정의하려면 BusinessException 을 사용한다.
message: getErrorDefinition(code).message,
};
}
// 3. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
code: FALLBACK_ERROR_CODE,
message: getErrorDefinition(FALLBACK_ERROR_CODE).message,
};
}
/** 예외 본문에 error code 가 실려 있으면 꺼낸다. */
private extractCode(body: unknown): ErrorCode | null {
if (typeof body === 'object' && body !== null && 'code' in body) {
const code: unknown = body.code;
if (typeof code === 'string' && isErrorCode(code)) return code;
}
return null;
}
/** 5xx 는 error, 4xx 는 warn 으로 남긴다. 추적을 위해 traceId 를 함께 기록한다. */
private log(
exception: unknown,
status: number,
code: ErrorCode,
request: Request,
traceId?: string,
): void {
const where = `${request.method} ${request.url}`;
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
if (status >= SERVER_ERROR_STATUS_THRESHOLD) {
this.logger.error(
prefix,
exception instanceof Error ? exception.stack : String(exception),
);
return;
}
const context =
exception instanceof BusinessException && exception.context
? ` ${JSON.stringify(exception.context)}`
: '';
this.logger.warn(`${prefix}${context}`);
}
}
@@ -0,0 +1,49 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SKIP_TRANSFORM_KEY } from '@/common/decorators/skip-transform.decorator';
export interface StandardResponse<T> {
success: true;
// 핸들러가 undefined/null 을 반환하면 null 로 정규화되므로 nullable
data: T | null;
}
/**
* 성공 응답을 { success: true, data } 로 래핑한다.
* `@SkipTransform()` 이 붙은 핸들러는 원본 응답을 그대로 통과시킨다.
*/
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T> | T
> {
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<StandardResponse<T> | T> {
const skip = this.reflector.getAllAndOverride<boolean>(SKIP_TRANSFORM_KEY, [
context.getHandler(),
context.getClass(),
]);
const source = next.handle() as Observable<T>;
if (skip) return source;
return source.pipe(
map((data) => ({
success: true as const,
data: data ?? null,
})),
);
}
}
@@ -0,0 +1,25 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
/** 요청 추적 헤더명 — 로그·에러 응답의 traceId 와 동일한 값을 사용한다. */
export const REQUEST_ID_HEADER = 'x-request-id';
/**
* 요청 추적 ID 미들웨어.
* 프록시/클라이언트가 보낸 값이 있으면 이어받고, 없으면 새로 발급한다.
* 발급된 ID 는 응답 헤더로도 돌려주어 클라이언트가 문의 시 제시할 수 있게 한다.
*/
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const incoming = req.header(REQUEST_ID_HEADER);
const requestId =
incoming && incoming.trim() !== '' ? incoming : randomUUID();
req.headers[REQUEST_ID_HEADER] = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
next();
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { buildDatabaseOptions } from '@/shared/config/database.config';
/**
* TypeORM CLI 전용 DataSource.
*
* 앱 런타임은 app.module.ts 의 TypeOrmModule 을 사용하고,
* 이 파일은 `migration:generate` / `migration:run` 같은 CLI 명령에만 쓰인다.
* 접속 설정은 database.config.ts 를 공유하므로 두 경로가 어긋나지 않는다.
*
* ⚠️ DB 접속 정보(DB_*)는 루트 .env 에서 docker-compose 가 주입하므로,
* 마이그레이션 명령은 **컨테이너 안에서** 실행해야 한다.
* 자세한 절차는 .claude/skills/nestjs/SKILL.md 의 "DB 마이그레이션" 참조.
*/
export default new DataSource(buildDatabaseOptions());
+109
View File
@@ -0,0 +1,109 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
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 { winstonConfig } from '@/shared/logger/winston.config';
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);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
app.setGlobalPrefix('api', {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
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 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
app.enableShutdownHooks();
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
const port = Number(process.env.PORT ?? 3000);
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`,
'Bootstrap',
);
}
void bootstrap();
View File
@@ -0,0 +1,84 @@
import { HttpStatus, ServiceUnavailableException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import {
HealthCheckResult,
HealthCheckService,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import { Response } from 'express';
import { HealthController } from './health.controller';
import { SKIP_TRANSFORM_KEY } from '@/common/decorators/skip-transform.decorator';
const okResult = {
status: 'ok',
info: { database: { status: 'up' } },
error: {},
details: { database: { status: 'up' } },
} as unknown as HealthCheckResult;
const downResult = {
status: 'error',
info: {},
error: { database: { status: 'down', message: 'timeout' } },
details: { database: { status: 'down', message: 'timeout' } },
};
describe('HealthController', () => {
let controller: HealthController;
let check: jest.Mock;
let res: { status: jest.Mock };
beforeEach(async () => {
check = jest.fn();
res = { status: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [
{ provide: HealthCheckService, useValue: { check } },
{ provide: TypeOrmHealthIndicator, useValue: { pingCheck: jest.fn() } },
],
}).compile();
controller = module.get(HealthController);
});
it('헬스체크는 표준 응답 래핑에서 제외된다 (모니터링 도구가 원본 형식을 읽는다)', () => {
// SkipTransform 메타데이터가 컨트롤러에 붙어 있어야 인터셉터가 래핑을 건너뛴다
const skip: unknown = Reflect.getMetadata(
SKIP_TRANSFORM_KEY,
HealthController,
);
expect(skip).toBe(true);
});
it('정상이면 terminus 결과를 그대로 반환한다', async () => {
check.mockResolvedValue(okResult);
const result = await controller.check(res as unknown as Response);
expect(result).toEqual(okResult);
// 성공 시에는 상태 코드를 건드리지 않는다
expect(res.status).not.toHaveBeenCalled();
});
it('실패하면 503 과 함께 어느 구성요소가 죽었는지 그대로 노출한다', async () => {
check.mockRejectedValue(new ServiceUnavailableException(downResult));
const result = await controller.check(res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
// SYS_001 로 덮어쓰지 않고 원인을 유지해야 한다
expect(result).toEqual(downResult);
expect(JSON.stringify(result)).toContain('database');
});
it('terminus 예외가 아닌 오류는 전역 필터로 넘긴다', async () => {
check.mockRejectedValue(new Error('예상치 못한 오류'));
await expect(controller.check(res as unknown as Response)).rejects.toThrow(
'예상치 못한 오류',
);
});
});
@@ -0,0 +1,73 @@
import {
Controller,
Get,
HttpStatus,
Res,
ServiceUnavailableException,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import {
HealthCheck,
HealthCheckResult,
HealthCheckService,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import type { Response } from 'express';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
// 커넥션 풀의 유휴 커넥션이 정리된 뒤(idleTimeoutMillis) 재연결하는 경우 1초를 넘겨
// 정상인 DB 를 down 으로 오판하고 헬스체크가 간헐적으로 실패한다.
const DB_PING_TIMEOUT_MS = 5_000;
/**
* 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템이 읽는다.
*
* 이 엔드포인트는 **사람이 아니라 기계가 소비**하므로 표준 응답 래퍼와 에러 코드 규칙에서 제외한다.
* (`{success,data}` 로 감싸거나 실패를 SYS_001 로 덮어쓰면 "어느 컴포넌트가 죽었는지"가 사라진다)
*
* - 성공 200: `{ status: 'ok', info: { database: { status: 'up' } }, ... }`
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
*/
@SkipThrottle()
@SkipTransform()
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
) {}
@Get()
@HealthCheck()
@ApiOperation({
summary: '애플리케이션 상태 확인 (표준 응답 래퍼 미적용)',
})
@ApiResponse({ status: 200, description: '모든 구성요소 정상' })
@ApiResponse({
status: 503,
description: '하나 이상의 구성요소 비정상 — 본문의 error 에 원인이 담긴다',
})
async check(
@Res({ passthrough: true }) res: Response,
): Promise<HealthCheckResult> {
try {
return await this.health.check([
() => this.db.pingCheck('database', { timeout: DB_PING_TIMEOUT_MS }),
]);
} catch (error) {
// terminus 는 실패 시 ServiceUnavailableException 을 던지며 본문에 상세 결과를 담는다.
// 전역 필터가 SYS_001 로 덮어쓰지 않도록 여기서 직접 503 과 원본 본문을 반환한다.
if (error instanceof ServiceUnavailableException) {
res.status(HttpStatus.SERVICE_UNAVAILABLE);
return error.getResponse() as HealthCheckResult;
}
throw error;
}
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}
@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
export class UploadResponseDto {
@ApiProperty({
description: '서버가 생성한 파일 식별자 (저장 파일명)',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
})
fileName!: string;
@ApiProperty({
description: '업로드 당시 원본 파일명',
example: '프로필.png',
})
originalName!: string;
@ApiProperty({ description: 'MIME 타입', example: 'image/png' })
mimeType!: string;
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
size!: number;
}
@@ -0,0 +1,30 @@
import { allowedExtensionList, isAllowedFile } from './upload.config';
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
expect(isAllowedFile('photo.png', 'image/png')).toBe(true);
expect(isAllowedFile('scan.PDF', 'application/pdf')).toBe(true);
});
it('허용되지 않은 MIME 은 거부한다', () => {
expect(isAllowedFile('script.js', 'application/javascript')).toBe(false);
expect(isAllowedFile('page.html', 'text/html')).toBe(false);
});
it('MIME 은 허용이지만 확장자가 불일치하면 거부한다 (확장자 위장 방지)', () => {
expect(isAllowedFile('payload.html', 'image/png')).toBe(false);
expect(isAllowedFile('payload.exe', 'application/pdf')).toBe(false);
});
it('확장자가 없으면 거부한다', () => {
expect(isAllowedFile('nofile', 'image/png')).toBe(false);
});
it('실행 가능한 형식은 허용 목록에 없다', () => {
const allowed = allowedExtensionList();
for (const dangerous of ['.html', '.svg', '.js', '.exe', '.sh']) {
expect(allowed).not.toContain(dangerous);
}
});
});
@@ -0,0 +1,48 @@
import { extname } from 'path';
import { parsePositiveInt } from '@/shared/utils/env';
/**
* 파일 업로드 정책 (로컬 디스크 기준).
*
* 오브젝트 스토리지(S3 등)로 전환할 때는 저장 어댑터만 교체하고
* 검증 규칙(허용 형식·크기)은 그대로 재사용한다.
*/
/** 업로드 파일 저장 디렉터리 — 컨테이너에서는 compose 볼륨이 연결된다 */
export const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
const MEGABYTE = 1024 * 1024;
/** 파일 1개 최대 크기 (기본 5MB) */
export const MAX_FILE_SIZE_BYTES =
parsePositiveInt(process.env.UPLOAD_MAX_SIZE_MB, 5) * MEGABYTE;
/**
* 허용 형식 — 확장자와 MIME 타입을 **둘 다** 검사한다.
* 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
*
* ⚠️ 실행 가능한 형식(.html, .svg, .js 등)은 넣지 말 것 —
* 저장된 파일을 그대로 서빙할 경우 XSS 로 이어진다.
*/
export const ALLOWED_FILE_TYPES: ReadonlyMap<string, readonly string[]> =
new Map([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/webp', ['.webp']],
['image/gif', ['.gif']],
['application/pdf', ['.pdf']],
]);
/** 허용 형식인지 검사한다 (확장자 ↔ MIME 조합이 일치해야 통과) */
export function isAllowedFile(originalName: string, mimeType: string): boolean {
const allowedExtensions = ALLOWED_FILE_TYPES.get(mimeType);
if (!allowedExtensions) return false;
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
/** 사용자에게 안내할 허용 확장자 목록 */
export function allowedExtensionList(): string {
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
}
@@ -0,0 +1,54 @@
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
@ApiTags('Upload')
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '파일 업로드 (단일)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiStandardResponse(UploadResponseDto, {
status: 201,
description: '업로드 성공',
})
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
if (!file) {
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
message: '업로드할 파일을 선택해 주세요.',
});
}
return this.uploadService.toResponse(file);
}
}
@@ -0,0 +1,53 @@
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,
isAllowedFile,
} from './upload.config';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
mkdirSync(UPLOAD_DIR, { recursive: true });
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, callback) => {
// ⚠️ 클라이언트가 보낸 파일명을 경로에 쓰지 않는다.
// '../../etc/passwd' 같은 값이 그대로 들어오면 경로 탈출이 된다.
// 서버가 생성한 UUID + 검증된 확장자만 사용한다.
const safeExtension = extname(file.originalname).toLowerCase();
callback(null, `${randomUUID()}${safeExtension}`);
},
}),
limits: {
fileSize: MAX_FILE_SIZE_BYTES,
// 파일 외 필드 수를 제한해 과도한 파싱을 막는다
files: 1,
fields: 10,
},
fileFilter: (_req, file, callback) => {
if (!isAllowedFile(file.originalname, file.mimetype)) {
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
callback(new BusinessException('FILE_001'), false);
return;
}
callback(null, true);
},
}),
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { UPLOAD_DIR } from './upload.config';
import { UploadResponseDto } from './dto/upload-response.dto';
// 업로드된 파일의 메타데이터를 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
toResponse(file: Express.Multer.File): UploadResponseDto {
this.logger.log(
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
);
return {
fileName: file.filename,
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
};
}
/**
* 저장된 파일을 삭제한다.
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
*/
async remove(fileName: string): Promise<void> {
await unlink(join(UPLOAD_DIR, fileName));
}
}
@@ -0,0 +1,20 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 사용자 생성 DTO 샘플 — 모든 검증 메시지는 한국어, Swagger 메타 필수
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
@IsString()
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
name!: string;
}
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
/** 사용자 응답 DTO — Swagger 스키마와 클라이언트 타입의 기준이 된다. */
export class UserResponseDto {
@ApiProperty({ description: '사용자 ID', example: 1 })
id!: number;
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
email!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
name!: string;
}
@@ -0,0 +1,59 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('User')
@ApiBearerAuth()
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
@ApiStandardResponse(UserResponseDto, {
status: 200,
description: '사용자 목록 조회 성공',
isArray: true,
})
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
findMany(@Query() query: CursorPaginationQueryDto) {
return this.userService.findMany(query.limit, query.cursor);
}
@Get(':id')
@ApiOperation({ summary: '사용자 단건 조회' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
@Injectable()
export class UserService {
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
}
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
const afterId = cursor ? Number(cursor) : 0;
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
const last = page.at(-1);
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext && last ? String(last.id) : null,
total: sorted.length,
});
}
create(dto: CreateUserDto): UserResponseDto {
const duplicated = [...this.users.values()].some(
(user) => user.email === dto.email,
);
if (duplicated) {
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
throw new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
context: { email: dto.email },
});
}
const id = this.users.size + 1;
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
}
}
@@ -0,0 +1,79 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { parsePositiveInt } from '@/shared/utils/env';
/**
* DB 접속 설정 — 앱(TypeOrmModule)과 CLI(data-source.ts)가 **같은 설정을 공유**한다.
* 두 곳에 따로 적으면 반드시 어긋나므로 여기서만 정의한다.
*/
// 개발 환경 기본값 — **호스트에서 직접 실행할 때** 기준이다.
// docker 환경에서는 compose 가 DB_HOST=db 등을 항상 주입하므로 이 값들이 쓰이지 않고,
// 운영에서는 validateEnv 가 DB_* 를 필수로 요구한다.
const DEV_DB_DEFAULTS = {
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'project_db',
} as const;
/** 커넥션 풀 설정 */
const POOL_OPTIONS = {
max: 100,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
} as const;
export function isProductionEnv(): boolean {
return process.env.NODE_ENV === 'production';
}
/**
* 스키마 자동 동기화 사용 여부.
*
* **기본값은 false** 다. synchronize 를 켜두면 개발 DB 가 항상 최신 스키마와 일치해
* `migration:generate` 가 "변경 없음"만 내놓는다. 그 상태로 운영에 배포하면
* (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) 테이블이 만들어지지 않는다.
*
* 스키마 변경은 마이그레이션으로 관리한다 — 개발/운영이 같은 경로를 쓰게 된다.
* 엔티티를 빠르게 실험할 때만 `DB_SYNCHRONIZE=true` 로 임시 활성화한다(개발 환경 한정).
*/
function shouldSynchronize(): boolean {
// 운영에서는 어떤 값을 주더라도 절대 허용하지 않는다 (스키마 자동 변경 → 데이터 손실 위험)
if (isProductionEnv()) return false;
return process.env.DB_SYNCHRONIZE === 'true';
}
/**
* 엔티티·마이그레이션 파일 경로.
* `__dirname` 기준이라 ts-node(src)와 빌드 산출물(dist) 양쪽에서 동작한다.
*/
function resolvePaths(baseDir: string) {
return {
entities: [join(baseDir, '..', '..', '**', '*.entity{.ts,.js}')],
migrations: [join(baseDir, '..', '..', 'migrations', '*{.ts,.js}')],
};
}
export function buildDatabaseOptions(): DataSourceOptions {
const isProduction = isProductionEnv();
const paths = resolvePaths(__dirname);
return {
type: 'postgres',
host: process.env.DB_HOST ?? DEV_DB_DEFAULTS.host,
port: parsePositiveInt(process.env.DB_PORT, DEV_DB_DEFAULTS.port),
username: process.env.DB_USER ?? DEV_DB_DEFAULTS.username,
password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password,
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
logging: !isProduction,
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
};
}
@@ -0,0 +1,127 @@
import { validateEnv } from './env.validation';
/** 지정한 키를 제외한 복사본 (구조분해로 버리면 lint 의 no-unused-vars 에 걸린다) */
const omit = (source: Record<string, string>, ...keys: string[]) =>
Object.fromEntries(
Object.entries(source).filter(([key]) => !keys.includes(key)),
);
const prod = (extra: Record<string, string> = {}) => ({
NODE_ENV: 'production',
DB_HOST: 'db',
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
...extra,
});
describe('validateEnv', () => {
describe('개발 환경', () => {
it('DB 설정이 없어도 통과한다 (코드 기본값 사용)', () => {
expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow();
});
it('숫자 항목이 잘못되면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', PORT: 'abc' }),
).toThrow(/PORT 는 양의 정수/);
});
it('빈 문자열은 통과한다 (compose 가 미정의 변수를 빈 값으로 주입한다)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
});
describe('운영 환경', () => {
it('정상 설정이면 통과한다', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('필수 DB 설정이 없으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
/DB_PASSWORD 가 설정되지 않았습니다/,
);
});
it('예시 placeholder 가 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ DB_PASSWORD: 'CHANGE-ME-BEFORE-DEPLOY' })),
).toThrow(/DB_PASSWORD 가 예시 값 그대로입니다/);
expect(() =>
validateEnv(prod({ JWT_SECRET: 'change-me-please' })),
).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/);
});
it('약한 기본값(password)도 placeholder 로 간주한다', () => {
expect(() => validateEnv(prod({ DB_PASSWORD: 'password' }))).toThrow(
/예시 값 그대로/,
);
});
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');
try {
validateEnv({ ...partial, PORT: '0' });
fail('예외가 발생해야 한다');
} catch (error) {
const message = (error as Error).message;
expect(message).toContain('DB_HOST');
expect(message).toContain('DB_NAME');
expect(message).toContain('PORT');
}
});
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* 환경변수 검증 (부팅 시 fail-fast).
*
* 설정이 잘못되면 **서버가 뜨지 않게** 한다.
* 조용히 기본값으로 굴러가다 운영에서 터지는 것보다, 기동 시점에 실패하는 편이 안전하다.
*
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
/** 운영 환경에서 반드시 채워져야 하는 키 */
const REQUIRED_IN_PRODUCTION = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
const POSITIVE_INT_KEYS = [
'PORT',
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
] as const;
/**
* 운영에서 **공개 주소**여야 하는 키.
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
* 브라우저가 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,
): string | undefined {
const value = config[key];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}
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));
}
/**
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
*/
export function validateEnv(
config: Record<string, unknown>,
): Record<string, unknown> {
const isProduction = readString(config, 'NODE_ENV') === 'production';
const errors: string[] = [];
// 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다
for (const key of POSITIVE_INT_KEYS) {
const value = readString(config, key);
if (value === undefined) continue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
errors.push(`${key} 는 양의 정수여야 합니다. (현재 값: "${value}")`);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
if (readString(config, key) === undefined) {
errors.push(`${key} 가 설정되지 않았습니다. (운영 환경 필수)`);
}
}
// 3. 예시 파일의 placeholder 가 그대로 배포되는 것을 막는다
for (const key of NO_PLACEHOLDER_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksLikePlaceholder(value)) {
errors.push(
`${key} 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.`,
);
}
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
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) {
throw new Error(
[
'환경변수 설정이 올바르지 않아 서버를 시작할 수 없습니다.',
...errors.map((message) => ` - ${message}`),
'',
'루트 .env.{APP_ENV} 를 확인해 주세요. (.claude/rules/env-structure.md)',
].join('\n'),
);
}
return config;
}
@@ -0,0 +1,22 @@
import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common';
/**
* 종료 로깅 — 컨테이너가 언제·왜 내려갔는지 남긴다.
*
* `process.on(signal)` 로 직접 잡지 않고 Nest 라이프사이클 훅을 쓰는 이유:
* Nest 는 shutdown hook 처리 후 같은 시그널을 재발생시켜 프로세스를 종료하는데,
* 직접 등록한 리스너는 이 흐름에 간섭해 종료 코드가 달라진다.
*
* 이 훅은 `app.close()` 가 끝난 뒤(= 처리 중인 요청과 커넥션 정리 후) 호출되므로,
* 로그가 남았다는 것은 정상 종료가 완료되었다는 뜻이다.
*/
@Injectable()
export class ShutdownLogger implements OnApplicationShutdown {
private readonly logger = new Logger(ShutdownLogger.name);
onApplicationShutdown(signal?: string): void {
this.logger.log(
`애플리케이션을 정상 종료했습니다. (사유: ${signal ?? '애플리케이션 종료 요청'})`,
);
}
}
@@ -0,0 +1,20 @@
import { utilities as nestWinstonUtilities } from 'nest-winston';
import * as winston from 'winston';
// 표준 로거 설정 — console.log 대체용
// 운영 환경에서는 파일 로테이션 또는 외부 로그 수집기(예: Loki, ELK) 추가 권장
export const winstonConfig: winston.LoggerOptions = {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
nestWinstonUtilities.format.nestLike('App', {
colors: process.env.NODE_ENV !== 'production',
prettyPrint: true,
}),
),
}),
],
};
+27
View File
@@ -0,0 +1,27 @@
import { parsePositiveInt } from './env';
describe('parsePositiveInt', () => {
it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
expect(parsePositiveInt('60000', 100)).toBe(60000);
});
it('값이 없으면 기본값을 반환한다', () => {
expect(parsePositiveInt(undefined, 100)).toBe(100);
});
it('빈 문자열이면 기본값을 반환한다 (docker-compose 미정의 변수 대응)', () => {
// compose 는 정의되지 않은 변수를 빈 문자열로 주입한다 — Number('') === 0 이므로 방어가 필요하다
expect(parsePositiveInt('', 100)).toBe(100);
expect(parsePositiveInt(' ', 100)).toBe(100);
});
it('숫자가 아니면 기본값을 반환한다', () => {
expect(parsePositiveInt('abc', 100)).toBe(100);
});
it('0 이하이거나 소수면 기본값을 반환한다', () => {
expect(parsePositiveInt('0', 100)).toBe(100);
expect(parsePositiveInt('-5', 100)).toBe(100);
expect(parsePositiveInt('1.5', 100)).toBe(100);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* 환경변수 파싱 유틸 (순수 함수).
*
* docker-compose 는 정의되지 않은 변수를 **빈 문자열**로 주입한다.
* `Number('')` 는 0 이므로 `??` 만으로는 기본값이 적용되지 않는다 — 이 함수로 방어한다.
*/
/**
* 환경변수를 양의 정수로 파싱한다.
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
*/
export 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;
}
+13
View File
@@ -0,0 +1,13 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
/**
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
*/
export function formatDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input;
if (Number.isNaN(date.getTime())) return '';
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
+136
View File
@@ -0,0 +1,136 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
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';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
*
* ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다).
* 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용.
*/
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
interface ContractBody {
success?: boolean;
data?: {
items?: unknown[];
pageInfo?: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
};
error?: { code?: string; message?: string; traceId?: string };
status?: string;
details?: unknown;
}
const body = (res: Response): ContractBody => res.body as ContractBody;
describe('응답 계약 (e2e)', () => {
let app: INestApplication<App>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
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)));
await app.init();
});
afterAll(async () => {
await app.close();
});
it('성공 응답은 { success, data } 로 감싸진다', async () => {
const res = await request(app.getHttpServer())
.get('/api/users')
.expect(200);
expect(body(res).success).toBe(true);
expect(body(res).data).toHaveProperty('items');
expect(body(res).data).toHaveProperty('pageInfo');
});
it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => {
const res = await request(app.getHttpServer())
.get('/api/users')
.expect(200);
const pageInfo = body(res).data?.pageInfo;
expect(Object.keys(pageInfo ?? {}).sort()).toEqual([
'hasNext',
'nextCursor',
'total',
]);
expect(typeof pageInfo?.hasNext).toBe('boolean');
expect(pageInfo?.nextCursor).toBeNull();
expect(typeof pageInfo?.total).toBe('number');
});
it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => {
const res = await request(app.getHttpServer())
.get('/api/users/999999')
.expect(404);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('RES_001');
expect(typeof body(res).error?.traceId).toBe('string');
});
it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => {
const res = await request(app.getHttpServer())
.post('/api/users')
.send({ email: '잘못된형식', password: '1' })
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
// class-validator 의 개별 메시지가 그대로 새어나가면 안 된다
expect(JSON.stringify(body(res))).not.toContain('must be an email');
});
it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => {
const res = await request(app.getHttpServer())
.get('/api/users?정의되지않은파라미터=1')
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
it('헬스체크는 표준 래퍼 없이 terminus 원본 형식을 반환한다', async () => {
const res = await request(app.getHttpServer()).get('/api/health');
expect(body(res)).not.toHaveProperty('success');
expect(body(res)).toHaveProperty('status');
expect(body(res)).toHaveProperty('details');
});
it('모든 응답에 x-request-id 헤더가 붙는다', async () => {
const res = await request(app.getHttpServer()).get('/api/users');
expect(typeof res.headers['x-request-id']).toBe('string');
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "..",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"testRegex": "test/.*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"watchOptions": {
"watchFile": "priorityPollingInterval",
"watchDirectory": "dynamicPriorityPolling",
"fallbackPolling": "dynamicPriority"
}
}
+20
View File
@@ -0,0 +1,20 @@
# ==========================================
# Gitea Actions 러너 설정
#
# 사용법: cp .env.example .env 후 토큰을 채운다.
# ⚠️ .env 는 커밋되지 않는다 (루트 .gitignore). 토큰을 이 예시 파일에 적지 말 것.
# ==========================================
# Gitea 인스턴스 주소 (끝에 / 없이)
GITEA_INSTANCE_URL=https://git.wandergo.net
# 등록 토큰 — 저장소 Settings → Actions → Runners → Create new Runner 에서 발급
# (여러 저장소에서 공용으로 쓰려면 관리자 화면의 인스턴스 레벨 토큰을 사용)
GITEA_RUNNER_REGISTRATION_TOKEN=
# 러너 식별 이름 — 여러 대를 띄운다면 구분되게 짓는다
RUNNER_NAME=gitea-runner
# 라벨: <워크플로의 runs-on>:docker://<잡을 실행할 이미지>
# ci.yml 이 ubuntu-latest 를 쓰므로 그대로 두면 된다.
RUNNER_LABELS=ubuntu-latest:docker://gitea/runner-images:ubuntu-latest
+103
View File
@@ -0,0 +1,103 @@
# CI 러너 설정
`.github/workflows/ci.yml` 을 실제로 실행하는 Gitea Actions 러너 설정입니다.
**러너가 없으면 워크플로는 "Waiting for a runner" 상태로 대기만 합니다.**
## 어디에 띄울 것인가
| 위치 | 장점 | 단점 |
| --- | --- | --- |
| **Gitea 서버** (권장) | 항상 켜져 있어 CI 가 늘 동작 | 서버에 Docker 필요 |
| 개발 PC | 바로 시작 가능 | PC 가 꺼지면 CI 도 멈춤 |
CI 의 목적이 "내가 없어도 검사되는 것"이므로 서버 쪽을 권장합니다.
## 설치
```bash
# 1) 저장소 Settings → Actions → Runners → Create new Runner 에서 토큰 발급
# 2) 설정 파일 준비
cd ci
cp .env.example .env # 편집기로 열어 GITEA_RUNNER_REGISTRATION_TOKEN 채우기
# 3) 기동
docker compose -f docker-compose.runner.yml --env-file .env up -d
# 4) 등록 확인
docker compose -f docker-compose.runner.yml --env-file .env logs -f runner
# "Runner registered successfully" 가 보이면 성공
```
저장소 **Settings → Actions → Runners** 에서 러너가 **Idle** 로 표시되면 준비 완료입니다.
이후 푸시하면 Actions 탭에서 실행이 시작됩니다.
## 잘 안 될 때
| 증상 | 원인 | 해결 |
| --- | --- | --- |
| 잡이 계속 대기 중 | 라벨 불일치 | `RUNNER_LABELS` 의 `ubuntu-latest` 가 워크플로 `runs-on` 과 같은지 확인 |
| `uses:` 액션 다운로드 실패 | 서버에서 github.com 접근 불가 | 방화벽 확인. 폐쇄망이면 액션 미러 필요 |
| 등록 실패 (401/404) | 토큰 만료·오타, URL 끝 `/` | 토큰 재발급, `GITEA_INSTANCE_URL` 확인 |
| 잡 시작 직후 실패 | 도커 소켓 마운트 누락 | `/var/run/docker.sock` 마운트 확인 (Windows 는 `//var/run/docker.sock`) |
| **모든 스텝이 한꺼번에 실패**(checkout 포함) | 서비스 컨테이너가 뜨지 못함 | 아래 "서비스 컨테이너" 참고 |
## 서비스 컨테이너 주의 (GitHub Actions 와 다른 점)
Gitea 러너는 잡을 **컨테이너로** 실행하고 서비스와 같은 네트워크에 붙인다.
GitHub Actions 는 잡이 VM 위에서 직접 돌기 때문에 관례가 다르다.
| | GitHub Actions | Gitea act_runner |
| --- | --- | --- |
| 서비스 접근 주소 | `localhost` + `ports` 매핑 | **서비스 이름** (`postgres:5432`) |
| `ports:` 필요 여부 | 필요 | **불필요 — 오히려 해롭다** |
`ports: - 5432:5432` 를 두면 호스트 포트를 점유하려 하는데,
개발 DB 가 이미 그 포트를 쓰고 있으면 **잡이 시작조차 못 하고 모든 스텝이 failure 로 표시된다.**
(스텝이 실패한 게 아니라 잡 준비 단계에서 죽은 것이다)
`ci.yml` 의 backend 잡은 이 규칙에 맞춰 `ports` 없이 `DB_HOST: postgres` 를 쓴다.
## 리소스 참고
워크플로는 두 개로 나뉘어 있습니다.
**`ci.yml`** — 모든 푸시·PR 에서 실행
- `backend` — postgres 서비스 컨테이너를 띄워 단위 테스트와 e2e 를 실행
- `frontend` — lint·type-check·테스트
- `error-codes-sync` — 에러 코드 생성 파일 드리프트 검사
**`ci-flutter.yml`** — **기본적으로 돌지 않는다**
Flutter 는 SDK 다운로드로 매 실행 수 분이 걸리는데, backend/frontend 만 고친 커밋에서는
검사할 것이 없다. 그래서 다음 경우에만 실행된다.
- `flutter/` 아래 파일이 바뀐 푸시·PR — **실제로 Flutter 를 쓰기 시작하면 자동으로 동작한다**
- Actions 탭의 **Run workflow** 버튼으로 수동 실행
모바일 앱을 쓰지 않는 프로젝트라면 `ci-flutter.yml` 을 지우면 된다.
> 러너는 기본적으로 잡을 **하나씩** 처리한다. 전체 시간을 줄이려면 러너를 여러 개 띄우거나
> `act_runner` 설정에서 동시 실행 수를 늘린다.
## 잡이 멈출 때
컨테이너 환경에서 잡이 간헐적으로 멈추는 경우가 있다 (CPU 0%, 네트워크 정지 상태로 진행 없음).
러너가 하나뿐이면 **멈춘 잡 하나가 이후 모든 잡을 굶긴다.**
그래서 모든 잡에 `timeout-minutes` 를 둔다. 상한에 걸리면 강제 종료되고 다음 잡으로 넘어간다.
새 잡을 추가할 때도 반드시 상한을 지정할 것.
멈춘 잡을 즉시 해제하려면 해당 컨테이너를 중지하면 된다 (러너가 실패로 처리하고 다음 잡을 집는다).
```bash
docker ps --format "{{.Names}}" | grep GITEA-ACTIONS # 실행 중인 잡 컨테이너
docker stop <컨테이너명>
```
## 주의
- **토큰은 `ci/.env` 에만** 둡니다. 이 파일은 루트 `.gitignore` 로 커밋되지 않습니다.
- `runner-data/` 에 등록 정보가 저장됩니다. 지우면 토큰으로 재등록해야 합니다.
- 러너는 도커 소켓에 접근하므로 **호스트에 대한 사실상의 관리자 권한**을 갖습니다.
신뢰할 수 있는 저장소의 워크플로만 실행되도록 하세요 (공개 저장소의 PR 실행 정책 확인).
+26
View File
@@ -0,0 +1,26 @@
# Gitea Actions 러너 — CI 를 실제로 실행하는 주체
#
# 이 파일은 애플리케이션 스택(루트 docker-compose.yml)과 **생명주기가 다르다.**
# 앱은 개발할 때만 띄우지만 러너는 항상 켜져 있어야 CI 가 동작한다.
#
# 사용법은 ci/README.md 참조.
services:
runner:
image: gitea/act_runner:latest
container_name: ${RUNNER_NAME:-gitea-runner}
restart: always
environment:
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL}
# ⚠️ 등록 토큰 — ci/.env 에만 두고 절대 커밋하지 않는다
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${RUNNER_NAME:-gitea-runner}
# ⚠️ 워크플로의 runs-on 과 일치해야 잡을 집는다.
# .github/workflows/ci.yml 은 모든 잡이 ubuntu-latest 다.
GITEA_RUNNER_LABELS: ${RUNNER_LABELS:-ubuntu-latest:docker://gitea/runner-images:ubuntu-latest}
volumes:
# 잡을 컨테이너로 띄우므로 도커 소켓이 필요하다.
# (Windows Docker Desktop 에서 Git Bash 를 쓰면 경로 변환 때문에 //var/run/... 로 적어야 할 수 있다)
- /var/run/docker.sock:/var/run/docker.sock
# 등록 정보를 보존한다 — 지우면 토큰으로 재등록해야 한다
- ./runner-data:/data
+80
View File
@@ -0,0 +1,80 @@
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:
target: build-stage
# 비-root 구동이라 1024 미만 포트를 쓸 수 없다 → vite 기본 포트 사용
command: npm run dev -- --host 0.0.0.0 --port 5173
ports:
- "${FRONTEND_PORT}:5173"
# dev 는 nginx(8080)가 아니라 vite(5173)를 띄우므로 base 의 헬스체크를 덮어쓴다
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)
- VITE_DEV_API_PROXY=http://backend:${BACKEND_PORT}
volumes:
- ./${FRONTEND_DIR}:/app
- /app/node_modules
# dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀
networks: !override
- app-network
backend:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
restart: "no"
build:
target: builder
command: npm run start:dev
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
- "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}"
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
volumes:
- ./${BACKEND_DIR}:/app
- /app/node_modules
# base 의 업로드 볼륨을 소스 바인드 마운트 위에 다시 얹는다
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
# dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀
networks: !override
- app-network
db:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
# 로컬 DB 툴 접속용. 127.0.0.1 로 한정해 외부 네트워크에 노출되지 않게 한다 (보안 규칙)
ports:
- "127.0.0.1:${DB_PORT}:5432"
redis:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
# 로컬 Redis CLI 접속용. 127.0.0.1 로 한정 (보안 규칙)
ports:
- "127.0.0.1:${REDIS_PORT}:6379"
# base의 proxy 외부 네트워크 의존을 dev에서 제거
networks:
proxy: !reset null
+171
View File
@@ -0,0 +1,171 @@
services:
# ----------------------------------------
# 1. Frontend (Nginx 정적 호스팅)
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
build:
context: ./${FRONTEND_DIR}
dockerfile: Dockerfile
args:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
expose:
- "8080"
environment:
- NODE_ENV=${APP_ENV}
restart: unless-stopped
networks:
- app-network
- proxy
depends_on:
- backend
healthcheck:
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 nginx 는 IPv4 만 바인딩하므로 127.0.0.1 을 명시한다
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512m
# ----------------------------------------
# 2. Backend (HTTP API & WebSocket 통합)
# ----------------------------------------
backend:
container_name: ${PROJECT_NAME}-backend
build:
context: ./${BACKEND_DIR}
dockerfile: Dockerfile
expose:
- "${BACKEND_PORT}"
- "${WEBSOCKET_PORT}"
# PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown)
init: true
# 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초)
stop_grace_period: 30s
env_file:
- ./${BACKEND_DIR}/.env.${APP_ENV}
environment:
- NODE_ENV=${APP_ENV}
- PORT=${BACKEND_PORT}
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
- CORS_ORIGIN=${CORS_ORIGIN}
# 앱이 직접 쓰지는 않는다. 로컬 주소가 남은 채 운영에 배포되는 것을
# 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts)
- BACKEND_API_URL=${BACKEND_API_URL}
# 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true
- ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-}
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000}
- THROTTLE_LIMIT=${THROTTLE_LIMIT:-100}
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false}
- UPLOAD_DIR=/app/uploads
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다
volumes:
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
restart: unless-stopped
networks:
- app-network
- proxy
depends_on:
db:
condition: service_healthy
# redis 는 profile 로 비활성화될 수 있으므로 선택적 의존으로 둔다
redis:
condition: service_healthy
required: false
healthcheck:
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1g
# ----------------------------------------
# 3. Database (PostgreSQL) — 내부 네트워크 전용
# ----------------------------------------
db:
container_name: ${PROJECT_NAME}-db
image: ${DB_IMAGE}
expose:
- "5432"
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- ${DB_DATA_DIR}:/var/lib/postgresql/data
restart: always
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512m
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ----------------------------------------
# 4. Redis — 내부 네트워크 전용 · 기본 비활성
#
# 기본적으로 기동하지 않는다. 캐시/세션 공유가 실제로 필요해질 때 켠다.
# 루트 .env.{APP_ENV} 에서 COMPOSE_PROFILES=redis
# 또는 일회성으로 docker compose --profile redis up -d
# ----------------------------------------
redis:
profiles: ["redis"]
container_name: ${PROJECT_NAME}-redis
image: ${REDIS_IMAGE}
command: redis-server --requirepass ${REDIS_PASSWORD}
expose:
- "6379"
volumes:
- ${REDIS_DATA_DIR}:/data
restart: always
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
deploy:
resources:
limits:
cpus: '0.25'
memory: 256m
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
app-network:
driver: bridge
proxy:
external: true
+12
View File
@@ -0,0 +1,12 @@
# ==========================================
# Flutter 환경변수 (flutter_dotenv) — pubspec assets 에 .env 등록됨
# 사용법: cp .env.example .env
#
# ⚠️ 모바일 앱은 docker-compose 대상이 아니므로 루트 .env 의 위임을 받지 않는다.
# 아래 값은 항상 이 파일에서 직접 관리한다.
# 빌드 타임 분리가 필요하면 --dart-define 또는 .env.{flavor} 도입 고려.
# ==========================================
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함)
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api
API_URL=http://localhost:3000/api
+54
View File
@@ -0,0 +1,54 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
+45
View File
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: web
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# tma_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+40
View File
@@ -0,0 +1,40 @@
# Dart 정적 분석 설정.
# .claude/skills/flutter/SKILL.md 의 규칙 중 기계적으로 강제 가능한 것을 여기에 담는다.
# 실행: flutter analyze (Warning 0 이어야 커밋 가능 — .claude/rules/checklist.md)
include: package:flutter_lints/flutter.yaml
analyzer:
errors:
# 규칙 위반을 경고가 아닌 에러로 승격해 CI 에서 확실히 실패시킨다
avoid_dynamic_calls: error
prefer_const_constructors: error
unawaited_futures: error
exclude:
# 자동 생성 파일은 생성 스크립트가 형식을 보장한다
- "**/*.g.dart"
- "**/*.freezed.dart"
linter:
rules:
# --- 타입 안전성 (dynamic 지양) ---
avoid_dynamic_calls: true
# --- 위젯 성능 (const 적극 활용) ---
prefer_const_constructors: true
prefer_const_constructors_in_immutables: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
# --- 비동기 안전성 ---
unawaited_futures: true
use_build_context_synchronously: true
# --- 코드 스타일 일관성 ---
prefer_single_quotes: true
prefer_final_locals: true
directives_ordering: true
require_trailing_commas: true
# --- 로깅 규칙 (print 금지 — .claude/rules/logging.md) ---
avoid_print: true
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.tma_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.tma_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="tma_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

Some files were not shown because too many files have changed in this diff Show More