Initial commit

This commit is contained in:
2026-10-27 15:22:41 +09:00
commit 98e530c186
269 changed files with 33654 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
---
name: nestjs
description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스/DTO 관심사 분리, 표준 응답 인터셉터, 전역 예외 필터, Swagger, JWT 인증, 캐싱/Rate Limiting/페이지네이션. backend/ 에서 작업하거나 REST API·모듈·DTO·인터셉터·필터를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/nestjs/SKILL.md`에 위치한다.
> 사용법: `/nestjs [요청 내용]` 또는 backend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[logging]] · [[security]] · [[coding-common]]
---
## 템플릿 기본 제공 골격
> 이 템플릿은 아래 인프라를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
## 아키텍처 원칙
### 관심사 분리 (Separation of Concerns)
```
Controller → HTTP 라우팅, 요청/응답 파싱만 담당
Service → 모든 비즈니스 로직 처리
Repository → 데이터 접근 계층 (TypeORM)
DTO → 요청/응답 데이터 형식 정의 및 검증
```
- `Controller`에 비즈니스 로직 작성 **금지**
- `new` 키워드로 수동 객체 생성 **지양** → 의존성 주입(DI) 활용
### 모듈 구조
```
src/
├── modules/
│ └── user/
│ ├── user.module.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ ├── user.repository.ts ← 커스텀 Repository (선택)
│ └── dto/
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── common/
│ ├── interceptors/ ← 응답 래핑, 에러 처리
│ ├── filters/ ← 전역 Exception Filter
│ ├── guards/ ← 인증/권한 Guard
│ ├── decorators/ ← 커스텀 데코레이터
│ └── pipes/ ← 전역 Validation Pipe
└── shared/
└── utils/ ← 순수 유틸 함수
```
---
## DTO 작성 규칙
```typescript
import { IsString, IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password: string;
}
```
- 모든 DTO는 `class-validator` + `class-transformer` 사용
- 에러 메시지는 **한국어**로 작성
- `@ApiProperty()` 반드시 포함 (Swagger 명세용)
---
## 표준 응답 인터셉터
```typescript
// common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data: data ?? null,
})),
);
}
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
- 응답 포맷 SSOT는 [[response-format]] 참조.
---
## 에러 처리 — 코드 없는 실패는 만들지 않는다
사용자에게 노출되는 모든 실패는 `BusinessException` 으로 던진다. 에러 코드가 **생성자 필수 인자**이므로 코드 누락이 구조적으로 불가능하다.
```typescript
import { BusinessException } from '@/common/errors/business.exception';
// 레지스트리의 상태 코드·메시지를 그대로 사용
throw new BusinessException('RES_001', { context: { userId: id } });
// 동적 값이 필요할 때만 메시지 재정의 (코드·HTTP 상태는 레지스트리 값 유지)
throw new BusinessException('USER_001', {
message: `이미 가입된 이메일입니다. (${email})`,
context: { email },
});
```
- `context` 는 **로그 전용** — 응답 본문에 포함되지 않으므로 식별자를 자유롭게 넣어도 된다 ([[logging]]).
- 새 코드는 `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록하고 `npm run sync:error-codes` 를 실행한다.
- `HttpExceptionFilter` 는 레지스트리를 **조회만** 한다. 필터에 문구를 하드코딩하지 말 것.
- Nest 내장 예외(`NotFoundException` 등)도 필터가 상태 코드로 역매핑해 주지만, 새 코드에서는 `BusinessException` 을 쓴다.
- 미정의 예외는 항상 `SYS_001` 로 수렴하며 내부 정보(스택·호스트명)는 응답에 노출되지 않는다.
전체 규칙과 도메인 코드 추가 절차는 [[error-codes]] 참조.
---
## Swagger 필수 적용
```typescript
// main.ts
const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
```
- 모든 Controller에 `@ApiTags()` 적용
- 모든 엔드포인트에 `@ApiOperation()` 작성
- **응답은 `@ApiResponse` 대신 래퍼 데코레이터를 쓴다** — 컨트롤러는 raw 를 반환하지만 실응답은 `{success,data}` 라 문서가 어긋난다.
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
- 모든 DTO에 `@ApiProperty()` + 한국어 `description` 작성
---
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
- 토큰 저장·전송 보안 규칙은 [[security]] 참조.
---
## 성능 & 최신 트렌드 추가 규칙
- **캐싱**: `@nestjs/cache-manager` 가 전역 등록되어 있으나 **현재 저장소는 in-memory** 다.
> 📐 **Redis 는 확장 지점이다.** compose 에서 **기본 비활성**(profile)이며 애플리케이션도 사용하지 않는다.
> 단일 인스턴스에서는 in-memory 로 충분하다.
>
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
> 3. `modules/health` 에 Redis 인디케이터 추가 (지금은 DB 만 검사한다)
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
>
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
(`shutdown.logger.ts` 가 참고 패턴).
- **Health Check**: `@nestjs/terminus` 로 구성. 주의점 두 가지 —
`pingCheck` 기본 타임아웃 1초는 짧아 유휴 커넥션 재연결 시 오판하므로 늘려서 쓰고,
표준 응답 래퍼/에러 코드에서 제외해 원인이 보이게 한다 ([[response-format]])
- **Versioning**: API 버전 관리 (`/v1/`, `/v2/`) 적용 고려
---
## DB 마이그레이션
스키마 변경은 **마이그레이션으로 관리**한다. `synchronize` 는 기본적으로 꺼져 있다.
> **왜 끄는가**: `synchronize: true` 는 엔티티를 DB 에 자동 반영하므로 개발 DB 가 항상 최신 상태가 된다.
> 그러면 `migration:generate` 가 "변경 없음"만 내놓고, 그 코드를 운영에 배포하면
> (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) **테이블이 만들어지지 않는다.**
> 개발과 운영이 같은 경로를 쓰도록 마이그레이션으로 통일한다.
### 엔티티 추가/변경 절차
```bash
# 0) 개발 스택이 떠 있어야 한다 (DB 접속 정보는 루트 .env → compose 주입)
# dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development
# 1) 엔티티 작성 (src/**/*.entity.ts)
# 2) 마이그레이션 생성 — 엔티티와 실제 DB 스키마의 차이를 자동으로 계산한다
dc exec backend npm run migration:generate -- src/migrations/AddUser
# 3) 생성된 SQL 을 반드시 눈으로 확인한다 (의도치 않은 DROP 이 없는지)
# 4) 적용
dc exec backend npm run migration:run
# 5) 엔티티와 마이그레이션 파일을 함께 커밋
```
| 명령 | 용도 |
| --- | --- |
| `npm run migration:generate -- src/migrations/<이름>` | 엔티티 ↔ DB 차이로 자동 생성 |
| `npm run migration:create -- src/migrations/<이름>` | 빈 마이그레이션 (데이터 이관 등 수동 SQL) |
| `npm run migration:run` | 미적용 마이그레이션 실행 |
| `npm run migration:revert` | 마지막 마이그레이션 롤백 |
| `npm run migration:show` | 적용 상태 확인 |
규칙:
- **명령은 컨테이너 안에서 실행**한다. DB 접속 정보가 compose 로 주입되기 때문이다.
- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다.
- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다.
- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다.
- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되,
**커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다.
- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유).
---
## ⚠️ 의존성 추가/제거 (Windows 개발 환경 주의)
**Windows 에서 `npm install` / `npm uninstall` 을 실행하면 lock 파일에서 Linux 전용 optional 의존성이 잘려나간다.**
로컬에서는 멀쩡하지만 Docker(Linux) 빌드의 `npm ci` 가 다음처럼 실패한다.
```
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: @emnapi/core@1.11.3 from lock file
```
그래서 의존성 변경은 **컨테이너 안에서** 수행한다 (lock 이 Linux 기준으로 생성된다).
```bash
# 개발 스택이 떠 있는 상태에서
dc exec backend npm install <패키지>
dc exec backend npm uninstall <패키지>
```
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
---
## 작업 점검 체크리스트
코드 작성/수정 후 아래를 점검한다. (전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **응답 포맷**: 컨트롤러는 raw 데이터만 반환 — `{success,data}` 래핑은 인터셉터가 담당. 직접 래핑 금지. 스트림/204 는 `@SkipTransform()`.
- [ ] **에러**: 사용자 노출 실패는 `BusinessException('CODE')` 로 던진다. 코드 없는 실패 금지.
- [ ] **에러 코드 추가 시**: `error-codes.ts` 만 수정 → `npm run sync:error-codes` 실행 → 세 스택 목록 테스트 갱신 ([[error-codes]]).
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).