Compare commits

...

7 Commits

Author SHA1 Message Date
ttipo 700a100a38 docs: 규칙 문서를 실제 구현에 맞춰 갱신
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
규칙과 구현이 어긋나 있던 부분을 바로잡고, 작업하며 발견한 함정을 기록한다.

- 스킬 문서의 "기본 제공 골격" 표를 실제 파일 목록과 일치시킴
- 커밋 전 체크리스트에 테스트 실행·에러 코드 동기화 절차 추가
- 커밋 규칙에 scope·브랜치 전략·PR 규칙 보강
- 새 규칙 파일(api-design, logging)을 CLAUDE.md import 목록에 등록하고,
  규칙 추가 시 등록이 필수라는 메타 규칙 명시
- README 를 현재 구조에 맞게 갱신 (traceId·페이지네이션·에러 코드 SSOT·로컬 개발 명령)

작업 중 실제로 겪은 함정들을 규칙으로 남긴다:
- Windows 에서 npm install 하면 Linux 전용 optional 의존성이 lock 에서 잘려
  Docker 의 npm ci 가 실패한다 → 의존성 변경은 컨테이너 안에서 수행
- --build 만으로는 node_modules 익명 볼륨이 갱신되지 않아
  새 패키지가 컨테이너에 반영되지 않는다 → --renew-anon-volumes 필요
- compose 는 미정의 변수를 빈 문자열로 주입해 Number('')===0 함정이 생긴다
- dev 는 PID 1 이 npm 이라 graceful shutdown 확인은 운영 이미지로 해야 한다
- Redis 는 컨테이너만 떠 있고 사용처가 없다 — 확장 지점임을 명시

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:32:23 +09:00
ttipo 258e0e37cc fix(flutter): 디렉터리 구조를 규칙에 맞추고 깨진 테스트 교체
- 공통 위젯 위치를 lib/widgets/ 에서 규칙상 목표인 lib/shared/widgets/ 로 이동
- 기본 생성된 카운터 위젯 테스트가 실제 앱과 맞지 않아 실패 상태였다 —
  앱 렌더링을 검증하는 테스트로 교체

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:31:46 +09:00
ttipo dac1603b03 ci: 규칙을 자동으로 강제하는 검사 추가
지금까지 "커밋 전 lint/test 통과"는 문서상의 약속일 뿐 강제되지 않았다.

- CI(GitHub Actions) 추가 — 세 스택의 lint·test·build 와 backend e2e 를 PR 마다 실행.
  error-codes-sync 잡은 sync 를 재실행한 뒤 git diff 로 생성 파일 드리프트를 검사한다.
  레지스트리만 고치고 sync 를 빠뜨리면 클라이언트 사전이 조용히 어긋나기 때문.
- lint 스크립트에 --max-warnings 0 적용. 기존에는 경고가 있어도 통과해
  "Warning 0" 규칙이 사실상 무의미했다. 자동 수정은 lint:fix 로 분리.
- 문서로만 있던 규칙을 린트로 이관:
  any/dynamic 금지, console/print 금지, Vue 인라인 스타일·Options API 금지,
  Dart const 누락·await 누락, HttpException 직접 생성 차단(BusinessException 유도).
- backend 에 @/ 경로 alias 설정 (tsconfig paths + jest moduleNameMapper).
  공통 규칙이 요구하는 절대경로 alias 가 backend 에만 빠져 있었다.
- npm audit 취약점 10건 중 9건 해결.
  남은 multer(high)는 platform-express v12 가 필요해 Nest v11 생태계와 충돌하고,
  파일 업로드 엔드포인트가 없어 도달 경로가 없다 — security.md 에 재검토 조건 기록.
- PR 템플릿 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:31:34 +09:00
ttipo e7c00e6f65 fix: 보안 규칙을 어기던 컨테이너·네트워크 설정 수정
security.md 가 금지한다고 적어둔 것을 실제 설정이 어기고 있었다.

- 프론트엔드 운영 컨테이너가 root 로 nginx 를 실행하고 있었다.
  nginx-unprivileged 로 전환하고 포트를 8080 으로 맞춤(비-root 는 1024 미만 바인드 불가).
- dev 오버라이드가 사용하는 빌드 스테이지에도 USER node 적용.
  익명 볼륨이 node 소유로 생성되도록 chown 을 USER 전환 앞에 둔다
  (그렇지 않으면 vite 가 node_modules/.vite 캐시를 쓰지 못한다).
- dev 의 DB/Redis 포트가 0.0.0.0 에 바인딩되어 같은 LAN 에 노출돼 있었다 → 127.0.0.1 로 한정.
- CSP 헤더 추가. 대신 현대 브라우저에서 권장되지 않는 X-XSS-Protection 은 제거.
- CORS 차단 시 Error 를 던져 500(SYS_001)이 되던 것을 헤더 미부여 방식으로 변경.
- 헬스체크가 localhost 를 ::1 로 해석해 항상 실패하던 문제 수정(127.0.0.1 명시).
- graceful shutdown 을 위해 init(tini)과 stop_grace_period 적용.
- *.example 의 실제같은 비밀번호를 placeholder 로 교체하고,
  비어 있던 서비스 env 예시에 안내를 채움.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:31:04 +09:00
ttipo bf102d758f feat: 기동·종료·헬스체크 견고성 보강
운영에서 조용히 문제를 일으킬 수 있는 지점들을 정리한다.

- env 검증(fail-fast): 설정이 잘못되면 서버가 뜨지 않는다.
  운영에서 DB_* 누락과 'change-me' 같은 예시 값 배포를 차단하고,
  문제를 하나씩이 아니라 한 번에 모아 보여준다.
  DB_PASSWORD 가 없을 때 'password' 로 조용히 동작하던 폴백도 제거.
- graceful shutdown: SIGTERM 시 요청·커넥션을 정리한 뒤 종료한다.
  종료 로그는 OnApplicationShutdown 훅으로 남긴다 —
  process.on(signal) 로 직접 잡으면 Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다.
- 헬스체크 두 가지 결함 수정:
  pingCheck 기본 타임아웃 1초가 짧아 유휴 커넥션 재연결 시 정상 DB 를 down 으로 오판했고,
  실패 응답이 SYS_001 로 덮여 어느 구성요소가 죽었는지 알 수 없었다.
  모니터링이 읽는 엔드포인트이므로 표준 래퍼·에러 코드에서 제외한다.
- Rate limiting 을 @nestjs/throttler 로 일원화 (express-rate-limit 제거).
  두 리미터가 겹쳐 헤더의 한도와 실제 동작이 어긋나 있었다.
- e2e 를 응답 계약 검증으로 교체. 기존 테스트는 없는 경로를 검사해 이미 실패 상태였다.
- 로깅 규칙(logging.md) 신설 — 레벨 기준·민감정보 마스킹·traceId

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:30:29 +09:00
ttipo c5d165275c feat: 응답 계약 정의와 페이지네이션·추적 ID 추가
세 클라이언트가 공유하는 계약 중 정의가 빠져 있던 부분을 채운다.

- 목록 응답 형태를 {items, pageInfo} 로 고정하고 커서/오프셋 쿼리 DTO 제공
- @SkipTransform() 으로 스트림·204 등 래핑하면 안 되는 응답을 제외
- ApiStandardResponse/ApiErrorResponse 로 Swagger 문서를 실응답과 일치시킴
  (컨트롤러는 raw 를 반환하지만 실제 응답은 래핑되어 문서가 어긋나 있었다)
- RequestIdMiddleware 로 모든 요청에 x-request-id 부여
- API 설계 규약(api-design.md) 신설 — REST 경로·쿼리·날짜/ID 표현·버전 관리

또한 .gitignore 의 data/ 패턴이 모든 depth 에 매칭되어
flutter/lib/features/user/data/ 의 소스가 커밋되지 않던 문제를 함께 고친다.
(user_provider.dart 가 import 하는 파일이 저장소에 없어 flutter analyze 가 깨져 있었다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:30:07 +09:00
ttipo f7ea60b8e7 feat: 에러 코드 SSOT 체계 도입
사용자에게 노출되는 실패가 에러 코드 없이 만들어질 수 없도록 구조를 바꾼다.

기존에는 예외 필터가 HTTP 상태로 코드를 추론해 도메인별 에러를 표현할 수 없었고,
세 플랫폼의 에러 사전을 손으로 맞춰야 해서 어긋나기 쉬웠다.

- 코드↔메시지↔HTTP 상태를 error-codes.ts 레지스트리 한 곳으로 통합
- BusinessException 은 에러 코드를 생성자 필수 인자로 받아 코드 누락을 타입으로 차단
- sync:error-codes 스크립트가 프론트·플러터 사전을 자동 생성 (수동 복사 제거)
- 429 전용 코드 REQ_001 추가 (기존에는 "시스템 오류"로 잘못 안내)
- AUTH_002(세션 만료)를 클라이언트에서 실제로 발행하도록 구현
- 응답에 traceId 를 실어 로그와 대조 가능하게 함

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:29:15 +09:00
81 changed files with 3239 additions and 777 deletions
+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]] 참조.
+40 -20
View File
@@ -1,8 +1,3 @@
---
name: checklist
description: 신규 프로젝트 셋업 절차 + 매 작업 공통 점검(에러코드 SSOT 동기화 등). 특정 시점의 구현 상태가 아니라 언제나 유효한 공용 규칙만 담는다.
---
# 작업 점검 & 신규 프로젝트 셋업
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
@@ -12,41 +7,66 @@ description: 신규 프로젝트 셋업 절차 + 매 작업 공통 점검(에러
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 (루트가 설정 SSOT — 대표 키가 채워져 있음, [[env-structure]])
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
- [ ] `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 키)
- [ ] 백엔드 고유 시크릿(`JWT_SECRET` 등)을 `backend/.env.{env}` 에 작성 (compose `env_file` 로 로드)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
- [ ] Docker 개발 환경 기동으로 동작 확인 ([[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` (Warning 0)
- [ ] 린트/분석 통과 — 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` 만 추적 ([[security]])
- [ ] 커밋 메시지 `<type>: <한국어 제목>` 형식, 한 커밋 한 변경 ([[git-convention]])
- [ ] 민감정보(`.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 동기화 (메시지/코드 변경 시 반드시 3곳 동시 수정)
## 3. 에러 코드 변경 시 (SSOT 절차)
- [ ] backend `src/common/filters/http-exception.filter.ts`
- [ ] frontend `src/composables/useApi.ts` (`ErrorCodeLexicon`)
- [ ] flutter `lib/core/api/error_lexicon.dart` (`ErrorLexicon`)
에러 코드/메시지는 **백엔드 레지스트리 한 파일**만 고친다. 세 파일을 손으로 맞추지 않는다.
- [ ] `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. 공유 계약을 바꿀 때 (응답 포맷 · 페이지네이션 · API 규약)
- [ ] 백엔드 구현 수정 ([[response-format]] · [[api-design]])
- [ ] 세 클라이언트의 인터셉터/타입 동기화 (`useApi.ts` · `dio_client.dart` · 공유 타입 파일)
- [ ] Swagger 데코레이터(`ApiStandardResponse` / `ApiErrorResponse`) 갱신 — 문서와 실응답 불일치 방지
- [ ] PR 본문에 영향 범위 명시 ([[git-convention]])
---
## 4. 기능 확장 시 따르는 순서
## 5. 기능 확장 시 따르는 순서
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
2. 기본 제공 골격(인터셉터·필터·dio client·error lexicon·예제 모듈)에서 패턴 재사용
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]])
4. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
5. 커밋 전 위 2·3절 점검 통과
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 엔티티/리포지토리)
> - 데이터 영속화 → [[nestjs]](TypeORM 엔티티/리포지토리 · 마이그레이션)
> - 상태·화면 계층 → [[flutter]](Riverpod 프로바이더 · features 구조)
+9 -1
View File
@@ -8,9 +8,16 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
- 모든 코드 주석은 **한국어**로 작성
- 웹(`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]])
## 네이밍 규약
@@ -23,5 +30,6 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
| 변수/함수 | 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]]
> 도메인별 세부 규칙: [[nestjs]] · [[vue3]] · [[flutter]] · 테스트: [[testing]] · 로깅: [[logging]] · API 설계: [[api-design]]
+11 -1
View File
@@ -22,9 +22,19 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- **새 설정값은 루트 `.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}` 파일은 비어 있어도 **존재해야 한다.**
- **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` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`. 실제 목록은 `.env.development.example` 참조.
> 루트 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]] 참조.
+109 -23
View File
@@ -1,29 +1,115 @@
---
name: error-codes
description: 세 플랫폼이 공유하는 에러 코드 ↔ 한국어 메시지 매핑(SSOT). 메시지 수정 시 backend/frontend/flutter 3개 파일을 함께 갱신한다.
---
# 에러 코드 규칙
# 에러 코드 맵핑
## 핵심 원칙
세 플랫폼이 공유하는 **단일 진실 공급원(SSOT)**. 메시지를 수정할 때는 아래 세 파일을 함께 갱신한다.
> **사용자에게 노출되는 모든 실패는 반드시 에러 코드를 가진다.**
> 코드 없이 문자열 메시지만 던지는 실패는 허용하지 않는다.
- backend: `src/common/filters/http-exception.filter.ts`
- frontend: `src/composables/useApi.ts` (`ErrorCodeLexicon`)
- flutter: `lib/core/api/error_lexicon.dart` (`ErrorLexicon`)
- 사용자 노출 문구는 **에러 코드 사전에만 존재**한다. 컨트롤러·서비스·컴포넌트에 문구를 하드코딩하지 않는다.
- 클라이언트는 `message` 가 아니라 **`code` 로 분기**한다. (문구는 언제든 바뀔 수 있다)
- 서버 내부 정보(스택, 쿼리, 호스트명)는 절대 응답에 싣지 않는다 — 로그로만 남긴다 ([[logging]]).
| 코드 | HTTP / 상황 | 메시지 |
| -------- | -------------------- | ------------------------------------------------------- |
| SYS_001 | 500 / 미정의 예외 | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
| AUTH_001 | 401 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | 세션 만료 (클라이언트) | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| VAL_001 | 400 / 422 (Validation) | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 200(비즈니스 실패) / 기타 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
## SSOT — 레지스트리 한 곳
> 비즈니스 예외는 `HttpException` 응답의 `message` 로 사용자 노출 문구를 재정의할 수 있다.
> 응답 래퍼 구조는 [[response-format]] 참조.
에러 코드 ↔ 메시지 ↔ HTTP 상태는 **백엔드 레지스트리 한 파일**이 단일 진실 공급원이다.
> **구현 주의:** 백엔드 필터(`http-exception.filter.ts`)는 **6개 코드**(SYS_001 / AUTH_001 / AUTH_003 / VAL_001 / RES_001 / BIZ_001)만 방출한다.
> `AUTH_002`(세션 만료)는 **클라이언트(frontend/flutter)에서만** 생성하는 코드로, 서버 응답에는 나타나지 않는다.
> 코드/메시지 변경 시 위 3개 파일을 함께 갱신해야 하며, 점검 항목은 [[checklist]] 참조.
```
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]] 참조.
+34 -8
View File
@@ -1,12 +1,38 @@
---
name: git-convention
description: Git 커밋 메시지 컨벤션 — <type>: <제목> 형식, 한국어 명령형, 한 커밋 한 논리 변경.
---
# Git 컨벤션
# Git 커밋 컨벤션
## 커밋 메시지
`<type>: <제목>` 형식 — type: **feat / fix / refactor / docs / chore / perf / test / style**
`<type>(<scope>): <제목>` 형식 — scope 는 선택.
- 예: `feat: 사용자 목록 페이지네이션 추가`
- 제목은 한국어, 명령형 현재 시제로 작성
- 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]] 참조.
+79 -10
View File
@@ -1,21 +1,90 @@
---
name: response-format
description: 모든 API 응답이 따르는 표준 응답 래퍼(success/data/error) 규칙. SSOT는 backend TransformInterceptor + HttpExceptionFilter.
---
# 표준 응답 포맷 (Global Response Wrapper)
모든 API 응답은 아래 구조로 통일한다.
(SSOT: backend `TransformInterceptor` + `HttpExceptionFilter` — [[nestjs]] 참조)
SSOT: backend `TransformInterceptor` + `HttpExceptionFilter` ([[nestjs]] 참조)
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
```
- 프론트/플러터 인터셉터는 `success: true` 면 `data` 만 언래핑하여 반환한다.
- `success: false` 이거나 HTTP 4xx/5xx 면 `error.code` 로 메시지를 매핑한다.
- 에러 코드 ↔ 메시지 매핑은 [[error-codes]] 참조.
- 컨트롤러는 **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]] 참조.
+71 -9
View File
@@ -1,15 +1,77 @@
---
name: security
description: 프로젝트 공통 보안 규칙 — 민감정보 커밋 금지, JWT 쿠키/secure storage, SQL Injection 차단, DTO 검증, 보안 헤더/CORS/Rate Limiting, 컨테이너 비-root 구동.
---
# 보안
## 비밀 정보
- `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용
- **`*.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 쿼리 시 파라미터 바인딩 필수)
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달
- 보안 헤더(`helmet`)·CORS·Rate Limiting(`@nestjs/throttler`) 적용
- Docker 컨테이너 내부 root 실행 금지 (비-root 사용자로 구동)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
- 파일 업로드는 확장자·MIME·용량을 서버에서 검증하고, 업로드 경로를 실행 가능한 위치에 두지 않는다
## 네트워크 · 인프라
- 보안 헤더(`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. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**
> - `multer` (high, DoS via deeply nested field names) — `@nestjs/platform-express` 의 전이 의존성.
> 수정하려면 `@nestjs/platform-express@12` 가 필요한데 나머지 Nest 생태계가 v11 이라 호환이 깨진다.
> **이 템플릿에는 파일 업로드 엔드포인트가 없어 도달 경로가 없다**(`FileInterceptor` 미사용).
> → **파일 업로드를 구현하는 시점에 반드시 재검토**한다. Nest v12 로 일괄 업그레이드하는 것이 정석.
> 환경변수 관리 규칙은 [[env-structure]], 도메인별 적용은 [[nestjs]] · [[flutter]] 참조.
+34 -2
View File
@@ -11,8 +11,10 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
## 공통 원칙
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
## 스택별 위치·실행
@@ -20,6 +22,7 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
| 스택 | 러너 | 위치·네이밍 | 실행 |
| --- | --- | --- | --- |
| backend | Jest | 단위 `*.spec.ts` (소스 옆), e2e `test/*.e2e-spec.ts` | `npm run test`, `npm run test:e2e` |
| frontend | Vitest | `*.spec.ts` / `*.test.ts` (대상 옆 또는 `__tests__/`) | `npm run test:unit` |
| flutter | flutter_test | `test/` 하위, `*_test.dart` | `flutter test` |
@@ -36,5 +39,34 @@ describe('formatSomething', () => {
});
```
> API 응답 래퍼/에러 코드는 세 플랫폼 공유 계약이므로([[response-format]] · [[error-codes]]),
> 매핑 로직을 변경하면 해당 분기 테스트를 추가/갱신한다.
---
## 계약 테스트 (삭제·비활성화 금지)
세 플랫폼이 공유하는 계약은 어긋나도 컴파일 에러가 나지 않는다. 아래 테스트가 그 안전망이다.
| 대상 | 위치 | 검증 내용 |
| --- | --- | --- |
| 에러 코드 레지스트리 | `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]]).
+2
View File
@@ -6,6 +6,8 @@
"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)",
+21 -2
View File
@@ -79,6 +79,25 @@ dc exec redis redis-cli -a <REDIS_PASSWORD>
## 규칙
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
- **env 파일**: 실행 전 `.env.{APP_ENV}` 존재를 확인한다. 없으면 `.env.{APP_ENV}.example` 을 복사해 생성.
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 사용자로 실행한다 ([[security]] 참조).
- **의존성을 바꿨다면 `--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`)로 해야 한다.
- **컨테이너 비-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`.
+26 -17
View File
@@ -5,7 +5,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다.
> 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[security]] · [[coding-common]]
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
> Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조).
---
@@ -18,11 +18,13 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| --- | --- | --- |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 에러 코드 → 한국어 메시지, fallback `SYS_001` ([[error-codes]]) |
| 토큰 저장 | `flutter_secure_storage`, key `jwt_token` | `dio_client` 에서 사용 |
| **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`·`data/repositories`·`presentation/providers` 수직 슬라이스 패턴 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
@@ -64,7 +66,7 @@ lib/
│ ├── widgets/ ← 재사용 위젯
│ └── providers/ ← 상태 관리
└── shared/
└── widgets/ ← 앱 공통 위젯
└── widgets/ ← 앱 공통 위젯 (생성 완료)
```
---
@@ -176,17 +178,18 @@ class DioClient {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// jwt_token 읽어 Authorization 헤더 첨부
final token = await _storage.read(key: 'jwt_token');
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 → 에러 코드로 reject
// success==true → data 언래핑, false → ApiException 으로 reject
handler.next(response);
},
onError: (err, handler) {
// 상태코드/서버 error.code → ErrorLexicon 한국어 메시지 ([[error-codes]] 참조)
handler.next(err);
onError: (err, handler) async {
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
handler.next(err.copyWith(error: await _toApiException(err)));
},
));
}
@@ -203,17 +206,18 @@ class DioClient {
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 인터셉터와 통일: jwt_token)
await storage.write(key: 'jwt_token', value: token);
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
await storage.write(key: kJwtTokenKey, value: token);
// 읽기
final token = await storage.read(key: 'jwt_token');
final token = await storage.read(key: kJwtTokenKey);
// 삭제 (로그아웃)
await storage.deleteAll();
```
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]])
- `SharedPreferences`에 토큰 저장 **금지**
- 추가 보안 규칙은 [[security]] 참조.
@@ -237,10 +241,15 @@ await storage.deleteAll();
- [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용.
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 에러 메시지는 `error_lexicon.dart` 경유.
- [ ] **토큰**: `flutter_secure_storage` + key `jwt_token` 통일. `SharedPreferences` 저장 금지.
- [ ] **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/widgets/` → `shared/widgets/` 이동 고려.
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`.
- [ ] **analyze**: 커밋 전 `flutter analyze` 통과.
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과.
- [ ] **린트로 강제되는 항목**: `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` 가 경고**한다.
+103 -32
View File
@@ -5,7 +5,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
> 이 스킬은 `.claude/skills/nestjs/SKILL.md`에 위치한다.
> 사용법: `/nestjs [요청 내용]` 또는 backend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[security]] · [[coding-common]]
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[logging]] · [[security]] · [[coding-common]]
---
@@ -15,20 +15,30 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환 |
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | `HttpException` → `{success:false,error:{code,message}}` ([[error-codes]]) |
| **에러 코드 레지스트리** | `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(`express-rate-limit`) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger), `modules/health`(`/health`) | 새 모듈 패턴 참고. user 예제는 in-memory 데모 저장소 |
| 보안 미들웨어 | `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]]) |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체)
> - **페이지네이션**: 목록 API 에 커서/오프셋 적용
> - `common/{guards,decorators,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있으며 **운영에서는 `synchronize:false` + 마이그레이션**이 강제된다 — 이 설정을 바꾸지 말 것
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
@@ -119,23 +129,30 @@ export class TransformInterceptor<T> implements NestInterceptor {
---
## 전역 Exception Filter
## 에러 처리 — 코드 없는 실패는 만들지 않는다
사용자에게 노출되는 모든 실패는 `BusinessException` 으로 던진다. 에러 코드가 **생성자 필수 인자**이므로 코드 누락이 구조적으로 불가능하다.
```typescript
// common/filters/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
import { BusinessException } from '@/common/errors/business.exception';
// HTTP 상태 → 에러 코드 매핑 후 { success: false, error: { code, message } } 반환
// SYS_001 / AUTH_001 / VAL_001 등 에러 맵은 [[error-codes]] 참조
}
}
// 레지스트리의 상태 코드·메시지를 그대로 사용
throw new BusinessException('RES_001', { context: { userId: id } });
// 동적 값이 필요할 때만 메시지 재정의 (코드·HTTP 상태는 레지스트리 값 유지)
throw new BusinessException('USER_001', {
message: `이미 가입된 이메일입니다. (${email})`,
context: { email },
});
```
이 필터는 세 플랫폼 공유 에러 코드 맵의 **단일 진실 공급원(SSOT)** 이다. 메시지 수정 시 [[error-codes]] 의 3개 파일을 함께 갱신한다.
- `context` 는 **로그 전용** — 응답 본문에 포함되지 않으므로 식별자를 자유롭게 넣어도 된다 ([[logging]]).
- 새 코드는 `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록하고 `npm run sync:error-codes` 를 실행한다.
- `HttpExceptionFilter` 는 레지스트리를 **조회만** 한다. 필터에 문구를 하드코딩하지 말 것.
- Nest 내장 예외(`NotFoundException` 등)도 필터가 상태 코드로 역매핑해 주지만, 새 코드에서는 `BusinessException` 을 쓴다.
- 미정의 예외는 항상 `SYS_001` 로 수렴하며 내부 정보(스택·호스트명)는 응답에 노출되지 않는다.
전체 규칙과 도메인 코드 추가 절차는 [[error-codes]] 참조.
---
@@ -155,8 +172,15 @@ SwaggerModule.setup('api-docs', app, document);
```
- 모든 Controller에 `@ApiTags()` 적용
- 모든 엔드포인트에 `@ApiOperation()`, `@ApiResponse()` 작성
- 모든 DTO에 `@ApiProperty()` 작성
- 모든 엔드포인트에 `@ApiOperation()` 작성
- **응답은 `@ApiResponse` 대신 래퍼 데코레이터를 쓴다** — 컨트롤러는 raw 를 반환하지만 실응답은 `{success,data}` 라 문서가 어긋난다.
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
- 모든 DTO에 `@ApiProperty()` + 한국어 `description` 작성
---
@@ -174,24 +198,71 @@ SwaggerModule.setup('api-docs', app, document);
## 성능 & 최신 트렌드 추가 규칙
- **캐싱**: 자주 조회되는 데이터는 `@nestjs/cache-manager` (Redis) 활용 고려
- **Rate Limiting**: 템플릿은 `main.ts` 에서 `express-rate-limit`(초과 시 `SYS_001`)로 API 남용을 방지한다. 라우트 단위 데코레이터 제어가 필요하면 `@nestjs/throttler` 로 전환 고려
- **Pagination**: 목록 API는 반드시 커서 기반 또는 오프셋 페이지네이션 적용
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 지양
- **Health Check**: `@nestjs/terminus`로 `/health` 엔드포인트 구성 권장
- **캐싱**: `@nestjs/cache-manager` 가 전역 등록되어 있으나 **현재 저장소는 in-memory** 다.
> 📐 **Redis 는 확장 지점이다.** compose 에 redis 컨테이너가 뜨고 `REDIS_HOST`/`REDIS_PORT`/`REDIS_PASSWORD` 가
> 백엔드로 주입되지만, **아직 이를 사용하는 코드는 없다.** 단일 인스턴스에서는 in-memory 로 충분하다.
>
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
> 3. `modules/health` 에 Redis 인디케이터 추가 (지금은 DB 만 검사한다)
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
>
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스를 빼는 편이 낫다 — 쓰지 않는 컨테이너는 혼란만 준다.
- **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/`) 적용 고려
---
## ⚠️ 의존성 추가/제거 (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 도 같은 검사를 한다.
---
## 작업 점검 체크리스트
코드 작성/수정 후 아래를 점검한다. (전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **응답 포맷**: 컨트롤러는 raw 데이터만 반환 — `{success,data}` 래핑은 인터셉터가 담당. 직접 래핑 금지.
- [ ] **에러**: 사용자 노출 실패는 `HttpException` 으로 던져 필터가 코드 매핑하게 한다. 새 코드 추가 시 [[error-codes]] 의 3개 파일 동시 갱신.
- [ ] **응답 포맷**: 컨트롤러는 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`/`@ApiResponse`.
- [ ] **lint**: 커밋 전 `npm run lint` 통과 (Warning 0).
- [ ] **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` 로 로드).
+30 -15
View File
@@ -5,7 +5,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
> 이 스킬은 `.claude/skills/vue3/SKILL.md`에 위치한다.
> 사용법: `/vue3 [요청 내용]` 또는 frontend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[security]] · [[coding-common]]
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
---
@@ -16,8 +16,10 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 처리 |
| ErrorCodeLexicon | `useApi.ts` 내부 | 에러 코드 → 한국어 메시지 ([[error-codes]]) |
| 응답 언래핑 인터셉터 | `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` | 새 스토어 패턴 참고 |
@@ -112,23 +114,33 @@ export const useUserStore = defineStore('user', () => {
## API 호출 (Composables + Axios)
```typescript
// composables/useApi.ts — 중앙 Axios 인스턴스
// composables/useApi.ts — 중앙 Axios 인스턴스 (구조 요약)
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // env.d.ts 에 타입 선언
withCredentials: true, // HttpOnly 쿠키 전송
})
// 응답 인터셉터 — 표준 응답 언래핑 + 에러 코드 전역 처리
api.interceptors.response.use(
(response) => response.data.data, // { success, data } → data 언래핑
(error) => {
const code = error.response?.data?.error?.code
// 에러 코드별 Toast 알림 처리 ([[error-codes]] 참조)
return Promise.reject(error)
},
)
// 실패 시 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() {
@@ -194,10 +206,13 @@ router.beforeEach((to) => {
(전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **API 호출**: 컴포넌트에서 `axios` 직접 호출 금지 → `composables/` 경유.
- [ ] **에러 처리**: 개별 try/catch 로 메시지를 만들지 말고 `useApi` 인터셉터 + [[error-codes]] 에 위임.
- [ ] **에러 처리**: 개별 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` 의 `TODO` — `auth.store.ts` 작성 후 `isLoggedIn` 검사 연결.
- [ ] **lint**: 커밋 전 `npm run lint` + `npm run type-check` 통과.
- [ ] **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` 에 별도 작성 불필요.
+11 -4
View File
@@ -20,8 +20,8 @@ REDIS_PORT=6379
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=dev_ttipo
DB_PASSWORD=dev_960426
DB_USER=app_user
DB_PASSWORD=change-me-in-local-env
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
@@ -29,11 +29,18 @@ DB_DATA_DIR=./data/postgres
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=dev_960426
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
CORS_ORIGIN=http://localhost:5173
# ==========================================
# Rate Limiting (API 남용 방지)
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
# ==========================================
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
+11 -4
View File
@@ -20,8 +20,8 @@ REDIS_PORT=6379
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=prd_ttipo
DB_PASSWORD=prd_960426
DB_USER=app_user
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
@@ -29,11 +29,18 @@ DB_DATA_DIR=./data/postgres
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=prd_960426
REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
CORS_ORIGIN=http://localhost:5173
# ==========================================
# Rate Limiting (API 남용 방지)
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
# ==========================================
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
+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)
- [ ] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음
+150
View File
@@ -0,0 +1,150 @@
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
defaults:
run:
working-directory: backend
# e2e 는 응답 계약을 실제 HTTP 로 검증하므로 DB 가 필요하다
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: ci_user
POSTGRES_PASSWORD: ci_password
POSTGRES_DB: ci_db
ports:
- 5432:5432
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
DB_HOST: localhost
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
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
flutter:
name: Flutter (analyze · test)
runs-on: ubuntu-latest
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
error-codes-sync:
name: 에러 코드 SSOT 동기화 검사
runs-on: ubuntu-latest
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
+6 -4
View File
@@ -9,10 +9,12 @@
# ==========================================
# 도커 영속 데이터
# ==========================================
data/
!data/.gitkeep
db-data/
redis-data/
# ⚠️ 루트 기준으로 고정한다(앞의 /). 'data/' 로 두면 모든 depth 의 data 디렉터리가 무시되어
# flutter/lib/features/<도메인>/data/ 같은 소스 코드까지 커밋되지 않는다.
/data/
!/data/.gitkeep
/db-data/
/redis-data/
# ==========================================
# 의존성/빌드 산출물
+4 -1
View File
@@ -12,7 +12,8 @@
세 클라이언트는 동일한 **표준 응답 포맷**과 **에러 코드 맵**을 공유한다 (아래 공통 규칙 참조).
> 규칙은 `.claude/` 아래로 모듈화되어 있다.
> - `.claude/rules/` — 세 플랫폼 공통 규칙 (코딩·응답 포맷·에러 코드·커밋·보안·환경변수)
> - `.claude/rules/` — 세 플랫폼 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·커밋·보안·환경변수·테스트)
> 새 규칙 파일을 추가하면 **반드시 아래 `@` import 목록에도 등록**한다(등록하지 않으면 로드되지 않는다).
> - `.claude/skills/` — 도메인별 개발 규칙 (스킬). 슬래시 명령(`/nestjs` 등) 또는 해당 디렉터리 작업 시 자동 적용
> - `.claude/settings.json` — Claude Code 권한·환경 설정
@@ -23,6 +24,8 @@
@.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
+61 -5
View File
@@ -74,17 +74,70 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
// 목록 — data 안의 형태가 고정되어 있다
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
```
에러 코드 맵과 코딩 규칙 전문은 [`CLAUDE.md`](./CLAUDE.md) 및 `.claude/rules/`·`.claude/skills/` 의 규칙 문서를 참고하세요.
`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 없이)
```bash
# backend
cd backend && npm ci && npm run start:dev
# frontend
cd frontend && npm ci && npm run dev
# flutter
cd flutter && flutter pub get && flutter run
```
### 커밋 전 검사
```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) |
@@ -96,12 +149,15 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
.
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
├── flutter/ # Flutter (core / features / widgets)
├── 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
└── .claude/
├── settings.json # Claude Code 권한·환경 설정
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·커밋·보안·환경변수)
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
└── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
```
+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
+8 -2
View File
@@ -2,13 +2,19 @@ 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 package*.json ./
COPY --chown=node:node package*.json ./
RUN npm ci
COPY . .
COPY --chown=node:node . .
RUN npm run build
FROM node:20-alpine AS production
+37 -5
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
@@ -26,10 +26,42 @@ export default tseslint.config(
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
// 타입 규칙 — 명시적 타입 선언 강제 ([[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',
},
},
);
+126 -272
View File
@@ -22,7 +22,6 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
@@ -177,24 +176,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -202,21 +183,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -282,24 +248,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -307,21 +255,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -373,7 +306,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -922,7 +854,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -935,7 +866,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1672,9 +1602,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1968,9 +1898,9 @@
}
},
"node_modules/@jest/reporters/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2676,7 +2606,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -2707,7 +2636,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -2874,7 +2802,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz",
"integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.3.4",
"iterare": "1.2.1",
@@ -2921,7 +2848,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz",
"integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-safe-stringify": "2.1.1",
"iterare": "1.2.1",
@@ -2977,15 +2903,14 @@
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.27",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz",
"integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==",
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.5.tgz",
"integrity": "sha512-R3LSHqgPQo7ZTdYqA39lvdHS2RBnVJUePKIpL+cr1oB3HtYj9qPdhdmS307eLDc1VeTPFXAnSuVfcG9lMDEFSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1",
"multer": "2.1.1",
"multer": "2.2.0",
"path-to-regexp": "8.4.2",
"tslib": "2.8.1"
},
@@ -3098,24 +3023,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@nestjs/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -3123,21 +3030,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@nestjs/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -3159,20 +3051,20 @@
}
},
"node_modules/@nestjs/swagger": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.4.tgz",
"integrity": "sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==",
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.1.1",
"js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.32.6"
"swagger-ui-dist": "5.32.13"
},
"peerDependencies": {
"@fastify/static": "^8.0.0 || ^9.0.0",
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"class-transformer": "*",
@@ -3192,15 +3084,25 @@
}
},
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/@nestjs/terminus": {
@@ -3317,7 +3219,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz",
"integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
@@ -3461,7 +3362,6 @@
"integrity": "sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@xhmikosr/bin-wrapper": "^14.0.0",
@@ -3492,9 +3392,9 @@
}
},
"node_modules/@swc/cli/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3531,10 +3431,9 @@
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@@ -3576,7 +3475,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3593,7 +3491,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3610,7 +3507,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3627,7 +3523,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3644,7 +3539,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3661,7 +3555,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3678,7 +3571,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3695,7 +3587,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3712,7 +3603,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3729,7 +3619,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3746,7 +3635,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3763,7 +3651,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -3777,14 +3664,14 @@
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
@@ -4057,7 +3944,6 @@
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4202,7 +4088,6 @@
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
@@ -4357,16 +4242,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -5109,7 +4994,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5159,7 +5043,6 @@
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5299,6 +5182,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
"integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -5557,9 +5441,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"version": "2.11.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
"integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5824,9 +5708,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5848,9 +5732,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
"integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
"dev": true,
"funding": [
{
@@ -5867,13 +5751,12 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.427",
"node-releases": "^2.0.55",
"update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
@@ -5974,7 +5857,6 @@
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-6.4.3.tgz",
"integrity": "sha512-VV5eq/QQ5rIVix7/aICO4JyvSeEv9eIQuKL5iFwgM2BrcYoE0A/D1mNsAHJAsB0WEbNdBlKkn6Tjz6fKzh/cKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"keyv": "^5.3.3"
}
@@ -6078,9 +5960,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -6146,7 +6028,6 @@
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
@@ -6194,15 +6075,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz",
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -6840,9 +6719,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.373",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz",
"integrity": "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==",
"version": "1.5.430",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
"integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
"dev": true,
"license": "ISC"
},
@@ -6991,7 +6870,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7052,7 +6930,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7291,7 +7168,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7330,24 +7206,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -7430,9 +7288,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
@@ -8034,16 +7892,16 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
@@ -8417,15 +8275,6 @@
"kind-of": "^6.0.2"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -8693,7 +8542,6 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -8937,9 +8785,9 @@
}
},
"node_modules/jest-config/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9317,9 +9165,9 @@
}
},
"node_modules/jest-runtime/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9518,9 +9366,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -9619,7 +9467,6 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -10095,9 +9942,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -10243,9 +10090,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
"version": "2.0.55",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10656,7 +10503,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
@@ -10924,7 +10770,6 @@
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11034,12 +10879,13 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -11133,8 +10979,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
@@ -11253,7 +11098,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -11958,9 +11802,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.6",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz",
"integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==",
"version": "5.32.13",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -12117,7 +11961,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -12500,7 +12343,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -12673,17 +12515,16 @@
"license": "MIT"
},
"node_modules/typeorm": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz",
"integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz",
"integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0",
"ansis": "^4.3.1",
"app-root-path": "^3.1.0",
"buffer": "^6.0.3",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"debug": "^4.4.3",
"dedent": "^1.7.2",
"dotenv": "^16.6.1",
@@ -12693,7 +12534,7 @@
"sql-highlight": "^6.1.0",
"tslib": "^2.8.1",
"uuid": "^11.1.1",
"yargs": "^17.7.2"
"yargs": "^17.7.3"
},
"bin": {
"typeorm": "cli.js",
@@ -12714,13 +12555,13 @@
"mongodb": "^5.8.0 || ^6.0.0",
"mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"mysql2": "^2.2.5 || ^3.0.1",
"oracledb": "^6.3.0",
"oracledb": "^6.3.0 || ^7.0.0",
"pg": "^8.5.1",
"pg-native": "^3.0.0",
"pg-query-stream": "^4.0.0",
"redis": "^3.1.1 || ^4.0.0 || ^5.0.14",
"sql.js": "^1.4.0",
"sqlite3": "^5.0.3",
"sqlite3": "^5.0.3 || ^6.0.0",
"ts-node": "^10.7.0",
"typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0"
},
@@ -12775,10 +12616,19 @@
}
}
},
"node_modules/typeorm/node_modules/ansis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz",
"integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==",
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/typeorm/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12884,7 +12734,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13044,9 +12893,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
"integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
"dev": true,
"funding": [
{
@@ -13275,6 +13124,7 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@@ -13293,6 +13143,7 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@@ -13306,6 +13157,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -13320,6 +13172,7 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@@ -13329,7 +13182,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/webpack/node_modules/schema-utils": {
"version": "4.3.3",
@@ -13337,6 +13191,7 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
@@ -13410,7 +13265,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -13559,9 +13413,9 @@
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
+8 -4
View File
@@ -12,12 +12,14 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"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"
"test:e2e": "jest --config ./test/jest-e2e.json",
"sync:error-codes": "ts-node -P tsconfig.json scripts/sync-error-codes.ts"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
@@ -33,7 +35,6 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
@@ -50,11 +51,11 @@
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.24",
"chokidar": "^5.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.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",
@@ -77,6 +78,9 @@
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
},
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
+80
View File
@@ -0,0 +1,80 @@
/**
* 에러 코드 SSOT 동기화 스크립트.
*
* backend 의 error-codes.ts 레지스트리를 읽어 프론트엔드/플러터의 에러 사전을 생성한다.
* 실행: npm run sync:error-codes (backend 디렉터리에서)
*
* 생성 파일은 직접 수정하지 말 것 — 레지스트리를 고치고 다시 실행한다.
* CI 는 이 스크립트를 실행한 뒤 git diff 로 드리프트를 검사한다.
*/
import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { ERROR_CODES } from '../src/common/errors/error-codes';
const REPO_ROOT = resolve(__dirname, '..', '..');
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}개 에러 코드를 동기화했습니다.`);
-22
View File
@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
-16
View File
@@ -1,16 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppService } from './app.service';
// 루트 컨트롤러 — 헬스체크/디버그 용 단순 엔드포인트
@ApiTags('App')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('hello')
@ApiOperation({ summary: '동작 확인용 Hello World' })
getHello(): string {
return this.appService.getHello();
}
}
+49 -18
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@@ -6,10 +6,26 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { HealthModule } from '@/modules/health/health.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
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별 최대 요청 수
// 개발 환경 DB 기본값 — compose 가 주입하는 값과 동일해야 한다.
// 운영에서는 validateEnv 가 DB_* 를 필수로 요구하므로 이 값들이 쓰이지 않는다.
const DEV_DB_DEFAULTS = {
host: 'db',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'project_db',
} as const;
@Module({
imports: [
@@ -17,17 +33,28 @@ import { UserModule } from './modules/user/user.module';
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 — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능
// 전역 Rate Limiter — API 남용/브루트포스 방지 (프로젝트의 유일한 rate limiting 수단)
// 한도 초과 시 429 → 전역 필터가 REQ_001 로 매핑한다.
// 라우트 단위 조정은 @Throttle({ default: { ttl, limit } }), 제외는 @SkipThrottle().
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다
ttl: parsePositiveInt(
process.env.THROTTLE_TTL_MS,
THROTTLE_DEFAULT_TTL_MS,
),
limit: parsePositiveInt(
process.env.THROTTLE_LIMIT,
THROTTLE_DEFAULT_LIMIT,
),
},
]),
TypeOrmModule.forRootAsync({
@@ -35,13 +62,13 @@ import { UserModule } from './modules/user/user.module';
const isProduction = process.env.NODE_ENV === 'production';
return {
type: 'postgres',
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432', 10),
username:
process.env.DB_USER || process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database:
process.env.DB_NAME || process.env.DB_DATABASE || 'project_db',
// 개발 편의를 위한 기본값 — 운영 환경 필수값은 validateEnv 가 기동 전에 보장한다.
// (약한 기본값 'password' 로 조용히 동작하던 문제를 제거)
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,
autoLoadEntities: true,
// 운영 환경에서는 절대 synchronize 사용 금지 (스키마 자동 변경 → 데이터 손실 위험)
synchronize: !isProduction,
@@ -60,13 +87,17 @@ import { UserModule } from './modules/user/user.module';
HealthModule,
UserModule,
],
controllers: [AppController],
providers: [
AppService,
ShutdownLogger,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
export class AppModule implements NestModule {
// 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값)
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
-8
View File
@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
@@ -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' });
});
});
+113
View File
@@ -0,0 +1,113 @@
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 = {} 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'],
]);
/** 등록된 에러 코드인지 확인한다. */
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,118 @@
import {
ArgumentsHost,
BadRequestException,
ForbiddenException,
HttpStatus,
Logger,
NotFoundException,
} 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('미정의 예외는 내부 정보 없이 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');
});
});
@@ -6,70 +6,132 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
// 전역 Exception Filter — CLAUDE.md 에러 코드 맵에 따라 표준 응답으로 변환
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>();
// 비교 대상인 HttpStatus enum 과 동일 타입으로 선언 (no-unsafe-enum-comparison 대응)
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'SYS_001';
let message =
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
const { status, code, message } = this.resolve(exception);
const traceId = request.header(REQUEST_ID_HEADER) ?? undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (status === HttpStatus.UNAUTHORIZED) {
code = 'AUTH_001';
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
} else if (status === HttpStatus.FORBIDDEN) {
code = 'AUTH_003';
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
} else if (status === HttpStatus.NOT_FOUND) {
code = 'RES_001';
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
} else if (
status === HttpStatus.BAD_REQUEST ||
status === HttpStatus.UNPROCESSABLE_ENTITY
) {
code = 'VAL_001';
message =
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
} else {
code = 'BIZ_001';
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
// 비즈니스 예외에서는 사용자에게 보일 메시지를 재정의 가능
if (
typeof exceptionResponse === 'object' &&
exceptionResponse !== null &&
'message' in exceptionResponse
) {
const responseMessage = exceptionResponse.message;
if (typeof responseMessage === 'string') {
message = responseMessage;
}
}
}
} else {
// 정의되지 않은 예외 → 운영팀이 추적 가능하도록 로그 + SYS_001 응답 유지
this.logger.error('Unhandled Exception', exception as Error);
}
this.log(exception, status, code, request, traceId);
response.status(status).json({
success: false,
error: {
code,
message,
},
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,
// 내장 예외의 영문 기본 메시지는 노출하지 않고 레지스트리 문구를 사용한다.
message: this.extractMessage(body) ?? 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;
}
/**
* 직접 문자열 메시지를 지정해 던진 경우에만 사용자 문구로 채택한다.
* (ValidationPipe 처럼 message 가 배열이면 상세 사유가 노출되므로 무시한다.)
*/
private extractMessage(body: unknown): string | null {
if (typeof body === 'object' && body !== null && 'message' in body) {
const message: unknown = body.message;
if (typeof message === 'string') return message;
}
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}`);
}
}
@@ -1,31 +1,47 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} 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: boolean;
success: true;
// 핸들러가 undefined/null 을 반환하면 null 로 정규화되므로 nullable
data: T | null;
}
/**
* 성공 응답을 { success: true, data } 로 래핑한다.
* `@SkipTransform()` 이 붙은 핸들러는 원본 응답을 그대로 통과시킨다.
*/
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T>
StandardResponse<T> | T
> {
constructor(private readonly reflector: Reflector) {}
intercept(
_context: ExecutionContext,
context: ExecutionContext,
next: CallHandler,
): Observable<StandardResponse<T>> {
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
return (next.handle() as Observable<T>).pipe(
): 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,
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();
}
}
+20 -22
View File
@@ -4,16 +4,17 @@ import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { WinstonModule } from 'nest-winston';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import type {
Express,
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { winstonConfig } from './shared/logger/winston.config';
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, {
@@ -54,28 +55,17 @@ async function bootstrap() {
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
// 클라이언트에는 403 금지 에러로 리턴됨
callback(new Error('CORS 정책에 의해 차단된 도메인입니다.'));
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
// CORS 헤더를 부여하지 않아 브라우저가 차단하도록 한다.
callback(null, false);
}
},
credentials: true, // 쿠키 교환 허용
});
// 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방)
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 150, // 15분 IP당 최대 150개 요청
message: {
success: false,
error: {
code: 'SYS_001',
message:
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
},
},
}),
);
// 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다.
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
@@ -88,7 +78,7 @@ async function bootstrap() {
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// 6. Swagger API 문서 설정
const config = new DocumentBuilder()
@@ -100,6 +90,13 @@ async function bootstrap() {
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');
@@ -108,4 +105,5 @@ async function bootstrap() {
'Bootstrap',
);
}
void bootstrap();
@@ -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(
'예상치 못한 오류',
);
});
});
@@ -1,12 +1,40 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
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';
// 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템에서 사용
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 {
@@ -17,8 +45,29 @@ export class HealthController {
@Get()
@HealthCheck()
@ApiOperation({ summary: '애플리케이션 상태 확인' })
check() {
return this.health.check([() => this.db.pingCheck('database')]);
@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,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;
}
+28 -9
View File
@@ -5,15 +5,18 @@ import {
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
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')
@@ -22,18 +25,34 @@ import { CreateUserDto } from './dto/create-user.dto';
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: '사용자 단건 조회' })
@ApiResponse({ status: 200, description: '사용자 조회 성공' })
@ApiResponse({ status: 404, description: '사용자를 찾을 수 없음 (RES_001)' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiResponse({ status: 201, description: '사용자 생성 성공' })
@ApiResponse({ status: 400, description: '입력값 검증 실패 (VAL_001)' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
+41 -11
View File
@@ -1,27 +1,57 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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 {
// 임시 인메모리 저장소 — Repository 도입 시 교체
private readonly users = new Map<
number,
{ id: number; email: string; name: string }
>();
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
findOne(id: number) {
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 404 → 전역 필터에서 RES_001 로 변환됨
throw new NotFoundException();
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
}
create(dto: CreateUserDto) {
/** 커서 기반 목록 조회 샘플 — 목록 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 = { id, email: dto.email, name: dto.name };
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
}
@@ -0,0 +1,78 @@
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('문제가 여러 개면 한 번에 모두 보여준다', () => {
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');
}
});
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* 환경변수 검증 (부팅 시 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;
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));
}
/**
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
*/
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} 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.`,
);
}
}
}
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 ?? '애플리케이션 종료 요청'})`,
);
}
}
+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;
}
+120 -9
View File
@@ -1,25 +1,136 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
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>;
beforeEach(async () => {
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();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
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');
});
});
+10 -3
View File
@@ -1,8 +1,15 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "..",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"testRegex": "test/.*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
+3
View File
@@ -14,6 +14,9 @@
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"incremental": true,
"skipLibCheck": true,
"strict": true,
+10 -4
View File
@@ -4,9 +4,13 @@ services:
restart: "no"
build:
target: build-stage
command: npm run dev -- --host 0.0.0.0 --port 80
# 비-root 구동이라 1024 미만 포트를 쓸 수 없다 → vite 기본 포트 사용
command: npm run dev -- --host 0.0.0.0 --port 5173
ports:
- "${FRONTEND_PORT}:80"
- "${FRONTEND_PORT}:5173"
# dev 는 nginx(8080)가 아니라 vite(5173)를 띄우므로 base 의 헬스체크를 덮어쓴다
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ || exit 1"]
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
@@ -39,14 +43,16 @@ services:
db:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
# 로컬 DB 툴 접속용. 127.0.0.1 로 한정해 외부 네트워크에 노출되지 않게 한다 (보안 규칙)
ports:
- "${DB_PORT}:5432"
- "127.0.0.1:${DB_PORT}:5432"
redis:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
# 로컬 Redis CLI 접속용. 127.0.0.1 로 한정 (보안 규칙)
ports:
- "${REDIS_PORT}:6379"
- "127.0.0.1:${REDIS_PORT}:6379"
# base의 proxy 외부 네트워크 의존을 dev에서 제거
networks:
+11 -3
View File
@@ -11,7 +11,7 @@ services:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
expose:
- "80"
- "8080"
environment:
- NODE_ENV=${APP_ENV}
restart: unless-stopped
@@ -21,7 +21,8 @@ services:
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
# 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
@@ -43,6 +44,10 @@ services:
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:
@@ -50,6 +55,8 @@ services:
- PORT=${BACKEND_PORT}
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
- CORS_ORIGIN=${CORS_ORIGIN}
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS}
- THROTTLE_LIMIT=${THROTTLE_LIMIT}
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER}
@@ -68,7 +75,8 @@ services:
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:${BACKEND_PORT}/ || exit 1"]
# 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
+36 -24
View File
@@ -1,28 +1,40 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# Dart 정적 분석 설정.
# .claude/skills/flutter/SKILL.md 의 규칙 중 기계적으로 강제 가능한 것을 여기에 담는다.
# 실행: flutter analyze (Warning 0 이어야 커밋 가능 — .claude/rules/checklist.md)
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
analyzer:
errors:
# 규칙 위반을 경고가 아닌 에러로 승격해 CI 에서 확실히 실패시킨다
avoid_dynamic_calls: error
prefer_const_constructors: error
unawaited_futures: error
exclude:
# 자동 생성 파일은 생성 스크립트가 형식을 보장한다
- "**/*.g.dart"
- "**/*.freezed.dart"
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
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
+42
View File
@@ -0,0 +1,42 @@
import 'error_lexicon.dart';
/// 정규화된 API 예외 — 화면에서는 [code] 로 분기하고 [message] 를 그대로 노출한다.
///
/// 모든 실패는 반드시 에러 코드를 가진다(코드 없는 에러를 만들 수 없도록 필수 인자).
class ApiException implements Exception {
const ApiException({
required this.code,
required this.message,
this.traceId,
this.statusCode,
});
/// 에러 코드 (예: AUTH_001, RES_001)
final String code;
/// 사용자에게 노출할 한국어 메시지
final String message;
/// 서버 요청 추적 ID — 문의 대응 시 로그 검색 키
final String? traceId;
/// 원본 HTTP 상태 코드 (네트워크 오류 등은 null)
final int? statusCode;
/// 코드만으로 예외를 만든다(메시지는 사전에서 조회).
factory ApiException.fromCode(
String code, {
String? traceId,
int? statusCode,
}) {
return ApiException(
code: code,
message: ErrorLexicon.getMessage(code),
traceId: traceId,
statusCode: statusCode,
);
}
@override
String toString() => 'ApiException($code): $message';
}
+99 -46
View File
@@ -1,7 +1,14 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'error_lexicon.dart';
import 'api_exception.dart';
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
const String kJwtTokenKey = 'jwt_token';
const Duration _kConnectTimeout = Duration(seconds: 15);
const Duration _kReceiveTimeout = Duration(seconds: 15);
class DioClient {
static final DioClient _instance = DioClient._internal();
@@ -18,15 +25,15 @@ class DioClient {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
));
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: 'jwt_token');
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
@@ -35,61 +42,107 @@ class DioClient {
onResponse: (response, handler) {
final payload = response.data;
if (payload is Map<String, dynamic> && payload.containsKey('success')) {
if (payload is Map<String, dynamic> &&
payload.containsKey('success')) {
if (payload['success'] == true) {
// Global Response Wrapper: 데이터 Unwrap
response.data = payload['data'];
return handler.next(response);
} else {
// HTTP 200 OK 하에서의 비즈니스 실패 로직
final errorCode = payload['error']?['code'] ?? 'BIZ_001';
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ErrorLexicon.getMessage(errorCode),
type: DioExceptionType.badResponse,
),
);
}
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
final error = _readErrorObject(payload);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ApiException.fromCode(
error.code ?? 'BIZ_001',
traceId: error.traceId,
statusCode: response.statusCode,
),
type: DioExceptionType.badResponse,
),
);
}
return handler.next(response);
},
onError: (DioException e, handler) {
String errorCode = 'SYS_001';
onError: (DioException e, handler) async {
final apiException = await _toApiException(e);
if (e.response != null && e.response?.data is Map<String, dynamic>) {
final payload = e.response!.data as Map<String, dynamic>;
// 1. 서버에서 표준 에러 객체를 보낸 경우
if (payload['error'] != null && payload['error']['code'] != null) {
errorCode = payload['error']['code'];
} else {
// 2. 서버 에러 객체가 없는 경우 HTTP 상태 코드 기반 매핑
final statusCode = e.response?.statusCode;
if (statusCode == 401) {
errorCode = 'AUTH_001';
} else if (statusCode == 403) {
errorCode = 'AUTH_003';
} else if (statusCode == 400 || statusCode == 422) {
errorCode = 'VAL_001';
} else if (statusCode == 404) {
errorCode = 'RES_001';
}
}
} else if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
errorCode = 'SYS_001';
}
final errorMessage = ErrorLexicon.getMessage(errorCode);
// User-Friendly Error UI를 위해 에러 메시지 덮어쓰기
final customError = e.copyWith(error: errorMessage);
return handler.next(customError);
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
return handler.next(e.copyWith(error: apiException));
},
),
);
}
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
Future<ApiException> _toApiException(DioException e) async {
final response = e.response;
if (response == null) {
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
return ApiException.fromCode('SYS_001');
}
String? traceId;
String? code;
if (response.data is Map<String, dynamic>) {
final error = _readErrorObject(response.data as Map<String, dynamic>);
code = error.code;
traceId = error.traceId;
}
code ??= _resolveCodeByStatus(response.statusCode);
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
await _storage.delete(key: kJwtTokenKey);
code = 'AUTH_002';
}
return ApiException.fromCode(
code,
traceId: traceId,
statusCode: response.statusCode,
);
}
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
String _resolveCodeByStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 429:
return 'REQ_001';
default:
return 'SYS_001';
}
}
/// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다.
({String? code, String? traceId}) _readErrorObject(
Map<String, dynamic> payload,
) {
final error = payload['error'];
if (error is! Map<String, dynamic>) return (code: null, traceId: null);
final code = error['code'];
final traceId = error['traceId'];
return (
code: code is String ? code : null,
traceId: traceId is String ? traceId : null,
);
}
Dio get dio => _dio;
}
+17 -13
View File
@@ -1,15 +1,19 @@
class ErrorLexicon {
static const Map<String, String> codes = {
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
};
import 'error_lexicon.g.dart';
static String getMessage(String code) {
return codes[code] ?? codes['SYS_001']!;
}
/// 에러 코드 → 사용자 노출 메시지 사전.
///
/// 실제 문구는 backend 레지스트리에서 생성된 [kErrorCodeMessages] 가 SSOT 이며,
/// 이 파일에 문구를 직접 추가하지 않는다.
/// (문구 변경: backend/src/common/errors/error-codes.ts 수정 → npm run sync:error-codes)
class ErrorLexicon {
const ErrorLexicon._();
static const Map<String, String> codes = kErrorCodeMessages;
/// 등록된 코드인지 확인한다.
static bool contains(String code) => codes.containsKey(code);
/// 코드에 해당하는 메시지를 반환한다. 미등록 코드는 폴백 메시지를 반환한다.
static String getMessage(String code) =>
codes[code] ?? codes[kFallbackErrorCode]!;
}
+15
View File
@@ -0,0 +1,15 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
const Map<String, String> kErrorCodeMessages = {
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
const String kFallbackErrorCode = 'SYS_001';
@@ -0,0 +1,27 @@
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
class UserModel {
const UserModel({
required this.id,
required this.email,
required this.name,
});
final int id;
final String email;
final String name;
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as int,
email: json['email'] as String,
name: json['name'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
};
}
@@ -0,0 +1,44 @@
import 'package:dio/dio.dart';
import '../models/user_model.dart';
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
///
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
class UserRepository {
const UserRepository(this._dio);
final Dio _dio;
Future<UserModel> getUser(int id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return UserModel.fromJson(response.data!);
}
/// 커서 기반 목록 조회 — 목록 API 는 항상 페이지네이션을 적용한다.
Future<({List<UserModel> items, String? nextCursor, bool hasNext})> getUsers({
int limit = 20,
String? cursor,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/users',
queryParameters: {
'limit': limit,
'cursor': ?cursor,
},
);
final data = response.data!;
final items = (data['items'] as List<dynamic>)
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
.toList();
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
return (
items: items,
nextCursor: pageInfo['nextCursor'] as String?,
hasNext: pageInfo['hasNext'] as bool? ?? false,
);
}
}
+8
View File
@@ -158,6 +158,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_lints:
dependency: "direct dev"
description:
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/api_exception.dart';
import 'package:tma_app/core/api/error_lexicon.dart';
void main() {
group('ErrorLexicon', () {
test('등록된 코드는 해당 한국어 메시지를 반환한다', () {
expect(
ErrorLexicon.getMessage('AUTH_001'),
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
);
});
test('미등록 코드는 SYS_001 폴백 메시지를 반환한다', () {
expect(
ErrorLexicon.getMessage('UNKNOWN_999'),
ErrorLexicon.getMessage('SYS_001'),
);
});
test('세 플랫폼 공유 에러 코드가 모두 등록되어 있다', () {
// backend error-codes.ts 레지스트리와 동일한 목록이어야 한다.
const expected = [
'SYS_001',
'REQ_001',
'AUTH_001',
'AUTH_002',
'AUTH_003',
'VAL_001',
'RES_001',
'BIZ_001',
];
expect(ErrorLexicon.codes.keys.toSet(), expected.toSet());
});
});
group('ApiException', () {
test('코드만으로 생성하면 사전의 메시지가 채워진다', () {
final exception = ApiException.fromCode('RES_001', statusCode: 404);
expect(exception.code, 'RES_001');
expect(exception.message, ErrorLexicon.getMessage('RES_001'));
expect(exception.statusCode, 404);
});
});
}
+7 -22
View File
@@ -1,30 +1,15 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('앱이 정상적으로 렌더링된다', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(child: MyApp()),
);
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.byType(MaterialApp), findsOneWidget);
expect(find.byType(Scaffold), findsOneWidget);
});
}
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 프론트엔드 환경변수 (development)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.development 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# cp .env.development.example .env.development
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 프론트엔드 환경변수 (production)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.production 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# cp .env.production.example .env.production
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
+15 -5
View File
@@ -2,12 +2,18 @@ FROM node:20-alpine AS build-stage
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
COPY package*.json ./
# 개발 환경(docker-compose.dev.yml)은 이 스테이지에서 vite dev 서버를 구동하므로 비-root 로 전환한다.
# /app 소유권을 넘겨야 익명 볼륨(node_modules)이 node 소유로 생성되어 vite 캐시(.vite) 쓰기가 가능하다.
RUN chown node:node /app
USER node
COPY --chown=node:node package*.json ./
RUN npm ci
COPY . .
COPY --chown=node:node . .
ARG BUILD_MODE=production
ARG VITE_API_BASE_URL
@@ -15,12 +21,16 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
FROM nginx:stable-alpine AS production-stage
# 비-root 전용 nginx 이미지 (보안 규칙: 컨테이너 root 실행 금지)
# 1024 미만 포트는 바인드할 수 없으므로 8080 을 사용한다 — nginx.conf 의 listen 과 일치해야 한다.
FROM nginxinc/nginx-unprivileged:stable-alpine AS production-stage
# 보안 헤더 포함된 nginx 설정 적용
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 비-root 실행은 nginx-unprivileged 이미지 도입 시 활성화 (현재는 80 포트 바인드 필요)
EXPOSE 80
# 이미지 기본 사용자(nginx, uid 101)로 구동 — root 로 되돌리지 말 것
USER nginx
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -2,6 +2,7 @@ import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
// Flat config — Vue 3 + TypeScript 표준 권장 룰
// .claude/skills/vue3/SKILL.md 의 규칙 중 기계적으로 강제 가능한 것을 여기에 담는다.
export default [
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
@@ -9,6 +10,25 @@ export default [
rules: {
// 컴포넌트 단어 수 강제 해제 (HomePage 등 단일 컴포넌트도 허용)
'vue/multi-word-component-names': 'off',
// Composition API + <script setup> 강제 — Options API 금지
'vue/component-api-style': ['error', ['script-setup']],
// 인라인 스타일 금지 → <style scoped> 사용
'vue/no-static-inline-styles': ['error', { allowBinding: false }],
// 콘솔 출력 금지 (운영 빌드에 디버그 로그가 남지 않도록)
'no-console': 'error',
// 명시적 타입 선언 — any 금지
'@typescript-eslint/no-explicit-any': 'error',
},
},
{
// 생성 파일은 스크립트가 만든 산출물이므로 포맷 규칙 대상에서 제외
files: ['src/**/*.generated.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
+16 -2
View File
@@ -1,5 +1,6 @@
server {
listen 80;
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
listen 8080;
server_name localhost;
# ----------------------------------------
@@ -7,10 +8,23 @@ server {
# ----------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content-Security-Policy — XSS 방어의 핵심 헤더
#
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : Vue 의 동적 스타일 바인딩(:style)이 인라인 스타일을 생성하므로 필요
# - connect-src 'self' : API 가 다른 도메인이면 그 origin 을 반드시 추가할 것
# (예: connect-src 'self' https://api.example.com)
# - img-src data: blob: : 인라인 아이콘·캔버스/파일 미리보기 대응
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
#
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# ----------------------------------------
# 압축 (Gzip)
# ----------------------------------------
+2 -1
View File
@@ -10,7 +10,8 @@
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"format": "oxfmt src/"
},
"dependencies": {
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
ERROR_CODE_MESSAGES,
FALLBACK_ERROR_CODE,
} from '@/shared/constants/error-codes.generated'
import { getErrorMessage } from './useApi'
describe('에러 코드 사전', () => {
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
expect(getErrorMessage('AUTH_001')).toBe(
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
)
})
it('미등록 코드는 SYS_001 폴백 메시지를 반환한다', () => {
expect(getErrorMessage('UNKNOWN_999')).toBe(
ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE],
)
})
it('세 플랫폼 공유 에러 코드가 모두 등록되어 있다', () => {
// backend error-codes.ts 레지스트리와 동일한 목록이어야 한다.
const expected = [
'SYS_001',
'REQ_001',
'AUTH_001',
'AUTH_002',
'AUTH_003',
'VAL_001',
'RES_001',
'BIZ_001',
]
expect(Object.keys(ERROR_CODE_MESSAGES).sort()).toEqual(expected.sort())
})
it('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () => {
for (const code of Object.keys(ERROR_CODE_MESSAGES)) {
expect(code).toMatch(/^[A-Z]+_\d{3}$/)
}
})
})
+87 -32
View File
@@ -4,37 +4,63 @@ import axios, {
type InternalAxiosRequestConfig,
} from 'axios'
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
import {
ERROR_CODE_MESSAGES,
FALLBACK_ERROR_CODE,
type ErrorCode,
} from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.store'
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor 와 동일 구조
export interface StandardResponse<T = unknown> {
success: boolean
data?: T
error?: {
code: string
message: string
/** 서버 요청 추적 ID — 문의 대응 시 로그 검색 키 */
traceId?: string
}
}
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응)
const ErrorCodeLexicon: Record<string, string> & { SYS_001: string } = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
/** 인터셉터가 reject 할 때 넘기는 정규화된 에러 — 호출부는 code 로 분기한다. */
export interface ApiError {
code: ErrorCode
message: string
traceId?: string
status?: number
}
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
const showErrorUI = (message: string) => {
const API_TIMEOUT_MS = 10000
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
const DUPLICATE_ALERT_WINDOW_MS = 3000
function isErrorCode(code: string): code is ErrorCode {
return code in ERROR_CODE_MESSAGES
}
/** 에러 코드 → 사용자 노출 문구. 사용자 문구는 이 사전에만 존재한다. */
export function getErrorMessage(code: string): string {
return isErrorCode(code)
? ERROR_CODE_MESSAGES[code]
: ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE]
}
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수 하나만 교체한다.
let lastAlert: { code: string; at: number } | null = null
const showErrorUI = (code: ErrorCode, message: string) => {
const now = Date.now()
if (lastAlert && lastAlert.code === code && now - lastAlert.at < DUPLICATE_ALERT_WINDOW_MS) {
return
}
lastAlert = { code, at: now }
window.alert(message)
}
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
timeout: API_TIMEOUT_MS,
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
withCredentials: true,
})
@@ -45,6 +71,41 @@ api.interceptors.request.use(
(error: unknown) => Promise.reject(error),
)
/** HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우) */
function resolveCodeByStatus(status: number): ErrorCode {
if (status === 401) return 'AUTH_001'
if (status === 403) return 'AUTH_003'
if (status === 400 || status === 422) return 'VAL_001'
if (status === 404) return 'RES_001'
if (status === 429) return 'REQ_001'
return FALLBACK_ERROR_CODE
}
/**
* 인증 세션이 있는 상태에서 401 을 받으면 "세션 만료"로 간주한다.
* AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
*/
function toAuthCode(code: ErrorCode): ErrorCode {
if (code !== 'AUTH_001') return code
const authStore = useAuthStore()
if (authStore.isLoggedIn) {
authStore.clearAuth()
return 'AUTH_002'
}
return 'AUTH_001'
}
/** 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다. */
function handle(rawCode: string, traceId?: string, status?: number): ApiError {
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE)
const message = getErrorMessage(code)
showErrorUI(code, message)
return { code, message, traceId, status }
}
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
api.interceptors.response.use(
(response: AxiosResponse<StandardResponse>) => {
@@ -55,30 +116,24 @@ api.interceptors.response.use(
// 성공 시 data 만 언래핑하여 반환
return payload.data as never
}
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
const errCode = payload.error?.code || 'BIZ_001'
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
return Promise.reject(payload.error)
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 (백엔드는 4xx 로 내리지만 방어적으로 처리)
return Promise.reject(
handle(payload.error?.code ?? 'BIZ_001', payload.error?.traceId, response.status),
)
}
return payload as never
},
(error) => {
let errCode = 'SYS_001'
if (error?.response) {
const status: number = error.response.status
const payload = error.response.data as StandardResponse | undefined
if (payload?.error?.code) {
errCode = payload.error.code
} else if (status === 401) errCode = 'AUTH_001'
else if (status === 403) errCode = 'AUTH_003'
else if (status === 400 || status === 422) errCode = 'VAL_001'
else if (status === 404) errCode = 'RES_001'
if (!error?.response) {
// 네트워크 단절·타임아웃 — 서버 응답 자체가 없는 경우
return Promise.reject(handle(FALLBACK_ERROR_CODE))
}
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
return Promise.reject(error)
const status: number = error.response.status
const payload = error.response.data as StandardResponse | undefined
const code = payload?.error?.code ?? resolveCodeByStatus(status)
return Promise.reject(handle(code, payload?.error?.traceId, status))
},
)
@@ -0,0 +1,17 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
export const ERROR_CODE_MESSAGES = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
REQ_001: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
/** 매핑 실패 시 사용하는 폴백 코드 */
export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'
+25
View File
@@ -0,0 +1,25 @@
// 목록 응답 공유 계약 — 백엔드 PaginatedResult 와 동일 구조
// (백엔드: backend/src/common/dto/pagination.dto.ts)
export interface PageInfo {
/** 다음 페이지 존재 여부 */
hasNext: boolean
/** 다음 요청에 넘길 커서. 마지막 페이지면 null */
nextCursor: string | null
/** 전체 개수. 계산 비용이 큰 경우 null 일 수 있다. */
total: number | null
}
export interface PaginatedResult<T> {
items: T[]
pageInfo: PageInfo
}
/** 커서 기반 목록 조회 쿼리 파라미터 */
export interface CursorPaginationQuery {
cursor?: string
limit?: number
}
/** 목록 API 기본 조회 개수 — 백엔드 PAGINATION_DEFAULT_LIMIT 와 동일 */
export const DEFAULT_PAGE_SIZE = 20