Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 700a100a38 | |||
| 258e0e37cc | |||
| dac1603b03 | |||
| e7c00e6f65 | |||
| bf102d758f | |||
| c5d165275c | |||
| f7ea60b8e7 |
@@ -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
@@ -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 구조)
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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]] 참조.
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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]] 참조.
|
||||
@@ -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]] 참조.
|
||||
|
||||
@@ -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]] 참조.
|
||||
|
||||
@@ -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]]).
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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` 로 로드).
|
||||
|
||||
@@ -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` 에 별도 작성 불필요.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
- [ ] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음
|
||||
@@ -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
@@ -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/
|
||||
|
||||
# ==========================================
|
||||
# 의존성/빌드 산출물
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+126
-272
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}개 에러 코드를 동기화했습니다.`);
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '애플리케이션 종료 요청'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
+10
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;"]
|
||||
@@ -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
@@ -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)
|
||||
# ----------------------------------------
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user