api-first-design
**API FIRST DESIGN**: 'API 만들어', 'API 설계', '엔드포인트', 'REST', 'Swagger', 'OpenAPI', 'DTO', 'CRUD' 요청 시 자동 발동. *.controller.ts/*.dto.ts/routes/** 파일 작업 시 자동 적용. Contract-First, 표준 응답 포맷, 타입 자동 생성.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install monicajeon28-gmcruise-api-first-design
Repository
Skill path: .claude/skills/api-first-design
**API FIRST DESIGN**: 'API 만들어', 'API 설계', '엔드포인트', 'REST', 'Swagger', 'OpenAPI', 'DTO', 'CRUD' 요청 시 자동 발동. *.controller.ts/*.dto.ts/routes/** 파일 작업 시 자동 적용. Contract-First, 표준 응답 포맷, 타입 자동 생성.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack, Backend, Designer.
Target audience: everyone.
License: name: MIT.
Original source
Catalog source: SkillHub Club.
Repository owner: monicajeon28.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install api-first-design into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/monicajeon28/GMcruise before adding api-first-design to shared team environments
- Use api-first-design for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: api-first-design
description: "**API FIRST DESIGN**: 'API 만들어', 'API 설계', '엔드포인트', 'REST', 'Swagger', 'OpenAPI', 'DTO', 'CRUD' 요청 시 자동 발동. *.controller.ts/*.dto.ts/routes/** 파일 작업 시 자동 적용. Contract-First, 표준 응답 포맷, 타입 자동 생성."
allowed-tools:
- Read
- Glob
- Grep
---
# API First Design Skill
> **Version**: 1.0.0
> **Purpose**: Swagger/OpenAPI 기반 Contract-First API 설계 및 일관된 응답 포맷 보장
> **Target**: NestJS + Next.js + Expo Monorepo
---
## Document Loading Strategy
**전체 문서를 로드하지 않습니다!** 상황에 따라 필요한 문서만 로드:
```yaml
Document_Loading_Strategy:
Step_1_Detect_Framework:
- 파일 확장자, import 문, 데코레이터로 프레임워크 감지
- 불명확하면 package.json/requirements.txt 확인
Step_2_Load_Required_Docs:
Universal_Always_Load:
- "core/contract-first.md" # Contract-First 원칙 (항상)
- "core/response-format.md" # 표준 응답 포맷 (항상)
- "core/error-codes.md" # 에러 코드 체계 (항상)
Framework_Specific_Load:
NestJS: "frameworks/nestjs-swagger.md" # NestJS Swagger 데코레이터
Express: "frameworks/express-openapi.md" # Express OpenAPI
FastAPI: "frameworks/fastapi-openapi.md" # Python FastAPI
Django: "frameworks/django-rest.md" # Django REST Framework
SpringBoot: "frameworks/spring-openapi.md" # Spring OpenAPI
Go: "frameworks/go-swagger.md" # Go Swagger
Rails: "frameworks/rails-openapi.md" # Rails OpenAPI
Type_Generation_Load:
TypeScript: "patterns/type-generation.md" # swagger-typescript-api
Python: "patterns/python-types.md" # pydantic
Go: "patterns/go-types.md" # oapi-codegen
Context_Specific_Load:
Controller_Template: "templates/controller-template.md"
OpenAPI_YAML: "templates/openapi-yaml.md"
Checklist: "quick-reference/checklist.md"
Anti_Patterns: "quick-reference/anti-patterns.md"
```
---
## Auto Trigger Conditions
```yaml
Auto_Trigger_Conditions:
File_Patterns:
- "*.controller.ts, *.controller.js"
- "*.dto.ts, *.dto.js"
- "**/routes/**, **/api/**"
- "*.router.ts, *.router.js"
- "openapi.yaml, swagger.yaml"
Keywords_KO:
- "API 만들어, API 작성, API 설계"
- "엔드포인트, 라우트, 라우터"
- "요청, 응답, 리퀘스트, 리스폰스"
- "REST, RESTful, GraphQL"
- "Swagger, OpenAPI, API 문서"
- "DTO, 데이터 전송, 스키마"
- "CRUD, 생성, 조회, 수정, 삭제"
Keywords_EN:
- "API, endpoint, route, router"
- "request, response, REST, RESTful"
- "Swagger, OpenAPI, API docs"
- "DTO, schema, contract"
- "CRUD, create, read, update, delete"
Code_Patterns:
- "@Controller, @Get, @Post, @Put, @Delete"
- "@ApiOperation, @ApiResponse, @ApiTags"
- "router.get, router.post, app.get"
- "@app.get, @app.post" # FastAPI
```
---
## Base Knowledge (다른 스킬 참조)
> **에러 처리 기본**: `clean-code-mastery/core/principles.md` 참조
> **인증/보안 패턴**: `security-shield` 참조 (JWT, 세션, CSRF)
> **입력 검증 기본**: `clean-code-mastery/core/security.md` 참조
---
## Core Concepts
### 1. Contract-First Workflow
```
OpenAPI Spec 정의 → 스펙 리뷰 → Type 자동 생성 → Backend/Frontend 구현
```
### 2. Standard Response Format
```typescript
// 성공
{ success: true, data: T, meta?: ApiMeta, timestamp: string }
// 에러
{ success: false, error: ApiError, timestamp: string }
```
### 3. Error Code System
```
패턴: DOMAIN_ACTION_REASON
예시: AUTH_TOKEN_EXPIRED, USER_NOT_FOUND, VALIDATION_FAILED
```
---
## Quick Reference
```yaml
필수_데코레이터:
Controller:
- "@ApiTags('Resources')" # 복수형
- "@ApiBearerAuth('access-token')"
Method:
- "@ApiOperation({ summary, description })"
- "@ApiResponse({ status, description, type })"
- "@ApiParam / @ApiQuery / @ApiBody"
DTO:
- "@ApiProperty({ description, example, format })"
- "@ApiPropertyOptional()"
버전_전략:
형식: "/api/v1/resources"
지원: "현재 버전 + 이전 버전(12개월)"
```
---
## Module Files
| File | Description |
|------|-------------|
| `core/contract-first.md` | Contract-First 워크플로우, 디렉토리 구조 |
| `core/response-format.md` | 표준 응답 포맷, ApiResponse/ApiError |
| `core/error-codes.md` | 에러 코드 체계, HTTP 상태 매핑 |
| `patterns/swagger-decorators.md` | NestJS Swagger 데코레이터 가이드 |
| `patterns/type-generation.md` | 타입 자동 생성, React Query 통합 |
| `templates/controller-template.md` | 완전한 Controller 템플릿 |
| `templates/openapi-yaml.md` | OpenAPI YAML 템플릿 |
| `quick-reference/checklist.md` | API 개발/배포 체크리스트 |
| `quick-reference/anti-patterns.md` | API 안티패턴 목록 |
---
**Document Version**: 1.0.0
**Last Updated**: 2025-12-09
---
## Code Generation Tools (2025 권장)
### 🏆 1위: Orval (강력 추천)
```yaml
Orval:
설명: "OpenAPI → TypeScript 클라이언트 + React Query 훅 자동 생성"
주간_다운로드: "513K+"
GitHub_Stars: "4,700+"
핵심_장점:
- React Query/TanStack Query 훅 자동 생성
- MSW(Mock Service Worker) 핸들러 자동 생성
- Zod 스키마 검증 통합 가능
- TypeScript 특화, 타입 안전
- 설정 간단 (orval.config.ts)
지원_클라이언트:
- Axios (기본)
- Fetch API
- React Query (TanStack Query)
- SWR
- Vue Query
- Svelte Query
- Angular HttpClient
설치_및_설정:
install: |
npm install orval -D
config_file: |
// orval.config.ts
import { defineConfig } from 'orval';
export default defineConfig({
api: {
input: './openapi.yaml', // OpenAPI 스펙 경로
output: {
target: './src/api/generated.ts',
client: 'react-query', // React Query 훅 생성
mode: 'tags-split', // 태그별 파일 분리
mock: true, // MSW mock 자동 생성
},
},
});
실행: |
npx orval
# 또는 package.json scripts에 추가
"scripts": {
"api:generate": "orval"
}
생성_결과_예시:
input_openapi: |
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
output_react_query: |
// 자동 생성된 코드
export const useGetUser = (id: string) => {
return useQuery({
queryKey: ['getUser', id],
queryFn: () => axios.get<User>(`/users/${id}`),
});
};
output_msw_mock: |
// 자동 생성된 MSW 핸들러
export const getGetUserMock = () =>
http.get('/users/:id', () => {
return HttpResponse.json(getUserResponseMock());
});
```
### 2위: openapi-typescript
```yaml
openapi-typescript:
설명: "OpenAPI → TypeScript 타입만 생성 (가벼움)"
주간_다운로드: "1.7M+"
적합한_경우:
- 타입만 필요하고 클라이언트는 직접 작성할 때
- 번들 크기 최소화가 중요할 때
사용: |
npx openapi-typescript ./openapi.yaml -o ./src/api/types.ts
```
### ❌ 피해야 할 도구
```yaml
Legacy_Tools:
swagger-codegen:
문제: "레거시, 업데이트 느림, 현대 프레임워크 미지원"
대안: "Orval 사용"
OpenAPI_Generator_CLI:
문제: "설정 복잡, Java 의존성, 느림"
대안: "Orval 또는 openapi-typescript"
swagger-typescript-api:
문제: "React Query 지원 약함, mock 생성 없음"
대안: "Orval"
```
### Contract-First + Orval 워크플로우
```yaml
Recommended_Workflow:
1_API_설계:
- OpenAPI YAML/JSON 작성
- API 스펙 리뷰 (팀)
2_코드_생성:
- "npx orval" 실행
- TypeScript 타입 생성
- React Query 훅 생성
- MSW mock 생성
3_개발:
- 프론트엔드: 생성된 훅 사용
- 백엔드: 스펙에 맞춰 구현
- 테스트: MSW mock으로 독립 테스트
4_API_변경_시:
- OpenAPI 스펙 수정
- "npx orval" 재실행
- TypeScript 컴파일 에러로 변경점 파악
```
### package.json 권장 스크립트
```json
{
"scripts": {
"api:generate": "orval",
"api:watch": "orval --watch",
"api:validate": "swagger-cli validate ./openapi.yaml"
}
}
```
---
**Updated**: 2025-12-10
**Added**: Orval 코드 생성 도구 권장 (우수사례 피드백)
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### core/contract-first.md
```markdown
# Contract-First Workflow
## 개념
**Contract-First**: API 구현 전에 OpenAPI 스펙을 먼저 정의하고, 이를 기반으로 코드 생성
```
┌─────────────────────────────────────────────────────────────────┐
│ Contract-First Workflow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. OpenAPI Spec 정의 (YAML/JSON) │
│ ↓ │
│ 2. 스펙 리뷰 (Frontend + Backend 합의) │
│ ↓ │
│ 3. Type 자동 생성 (swagger-typescript-api) │
│ ↓ │
│ 4. Backend: NestJS Controller/DTO 구현 │
│ ↓ │
│ 5. Frontend: 생성된 타입으로 API Client 구현 │
│ ↓ │
│ 6. Swagger UI로 API 문서화 자동 완성 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 장점
| 장점 | 설명 |
|------|------|
| **동기화** | Frontend/Backend 간 타입 불일치 제거 |
| **병렬 개발** | 스펙 합의 후 FE/BE 동시 개발 가능 |
| **문서화** | 코드와 문서가 항상 일치 |
| **테스트** | 스펙 기반 자동 테스트 생성 가능 |
| **타입 안전성** | End-to-End 타입 보장 |
## 디렉토리 구조
```
packages/
└── shared/
└── api/
├── openapi/
│ ├── openapi.yaml # 메인 OpenAPI 스펙
│ ├── paths/ # 경로별 스펙 분할
│ │ ├── auth.yaml
│ │ ├── users.yaml
│ │ └── products.yaml
│ ├── components/ # 재사용 컴포넌트
│ │ ├── schemas.yaml
│ │ ├── responses.yaml
│ │ ├── parameters.yaml
│ │ └── security.yaml
│ └── examples/ # 요청/응답 예시
│ └── user-examples.yaml
├── generated/ # 자동 생성된 타입
│ ├── api.ts # API Client
│ ├── types.ts # TypeScript 타입
│ └── index.ts
└── scripts/
└── generate-types.ts # 타입 생성 스크립트
```
## API Versioning
### URL Path Versioning (권장)
```typescript
// /api/v1/users
// /api/v2/users
// main.ts
app.setGlobalPrefix('api/v1');
// 또는 Controller 레벨에서
@Controller({ path: 'users', version: '1' })
export class UsersControllerV1 {}
@Controller({ path: 'users', version: '2' })
export class UsersControllerV2 {}
```
### Versioning 설정
```typescript
// main.ts
import { VersioningType } from '@nestjs/common';
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
prefix: 'api/v',
});
```
### 버전 전환 가이드라인
```yaml
Breaking_Changes:
- 필드 삭제 또는 이름 변경
- 필수 필드 추가
- 응답 구조 변경
- 인증 방식 변경
- 에러 코드 체계 변경
Non_Breaking_Changes:
- 선택적 필드 추가
- 새 엔드포인트 추가
- 에러 메시지 개선
- 성능 개선
Version_Support:
- 현재 버전 (v2): Full support
- 이전 버전 (v1): Maintenance (12개월)
- 그 이전: Deprecated (6개월 후 제거)
Deprecation_Headers:
Deprecation: "true"
Sunset: "2025-06-01T00:00:00Z"
Link: "<https://api.example.com/v2/users>; rel=\"successor-version\""
```
```
### core/response-format.md
```markdown
# Standard Response Format
## 성공 응답 (Success Response)
```typescript
// packages/shared/api/types/response.ts
/**
* 표준 성공 응답 포맷
* 모든 API는 이 포맷을 따라야 함
*/
export interface ApiResponse<T> {
/** 성공 여부 */
success: true;
/** 응답 데이터 */
data: T;
/** 메타 정보 (페이지네이션 등) */
meta?: ApiMeta;
/** 응답 시간 (ISO 8601) */
timestamp: string;
}
export interface ApiMeta {
/** 현재 페이지 (1부터 시작) */
page?: number;
/** 페이지당 항목 수 */
limit?: number;
/** 전체 항목 수 */
total?: number;
/** 전체 페이지 수 */
totalPages?: number;
/** 다음 페이지 존재 여부 */
hasNext?: boolean;
/** 이전 페이지 존재 여부 */
hasPrev?: boolean;
}
// 사용 예시
type UserResponse = ApiResponse<User>;
type UsersListResponse = ApiResponse<User[]>;
```
## 에러 응답 (Error Response)
```typescript
/**
* 표준 에러 응답 포맷
* 모든 에러는 이 포맷을 따라야 함
*/
export interface ApiErrorResponse {
/** 성공 여부 */
success: false;
/** 에러 정보 */
error: ApiError;
/** 응답 시간 (ISO 8601) */
timestamp: string;
}
export interface ApiError {
/** HTTP 상태 코드 */
statusCode: number;
/** 에러 코드 (앱 내부 코드) */
code: string;
/** 사용자에게 표시할 메시지 */
message: string;
/** 개발자용 상세 메시지 (production에서 숨김) */
details?: string;
/** 필드별 검증 에러 */
errors?: FieldError[];
/** 에러 추적 ID (로그 조회용) */
traceId?: string;
}
export interface FieldError {
/** 필드명 (e.g., "email", "user.address.zipCode") */
field: string;
/** 에러 메시지 */
message: string;
/** 검증 실패한 값 (민감 정보 제외) */
value?: unknown;
/** 검증 규칙 (e.g., "isEmail", "minLength") */
rule?: string;
}
```
## Response DTO for Swagger
```typescript
// Wrapped Response DTO (Standard Format)
@ApiExtraModels(UserDto)
export class UserResponseDto {
@ApiProperty({ example: true })
success!: true;
@ApiProperty({ type: UserDto })
data!: UserDto;
@ApiProperty({ example: '2024-01-15T09:30:00.000Z' })
timestamp!: string;
}
@ApiExtraModels(UserDto)
export class UsersListResponseDto {
@ApiProperty({ example: true })
success!: true;
@ApiProperty({ type: [UserDto] })
data!: UserDto[];
@ApiProperty({ type: ApiMetaDto })
meta!: ApiMetaDto;
@ApiProperty({ example: '2024-01-15T09:30:00.000Z' })
timestamp!: string;
}
export class ApiMetaDto {
@ApiProperty({ example: 1, description: '현재 페이지' })
page!: number;
@ApiProperty({ example: 20, description: '페이지당 항목 수' })
limit!: number;
@ApiProperty({ example: 100, description: '전체 항목 수' })
total!: number;
@ApiProperty({ example: 5, description: '전체 페이지 수' })
totalPages!: number;
@ApiProperty({ example: true, description: '다음 페이지 존재 여부' })
hasNext!: boolean;
@ApiProperty({ example: false, description: '이전 페이지 존재 여부' })
hasPrev!: boolean;
}
```
## Generic Response Helper
```typescript
// Generic 타입을 Swagger에서 처리하기 위한 헬퍼
import { applyDecorators, Type } from '@nestjs/common';
import { ApiOkResponse, getSchemaPath, ApiExtraModels } from '@nestjs/swagger';
/**
* 표준 성공 응답 데코레이터
*/
export const ApiStandardResponse = <TModel extends Type<any>>(
model: TModel,
isArray = false,
) => {
return applyDecorators(
ApiExtraModels(model),
ApiOkResponse({
schema: {
allOf: [
{
properties: {
success: { type: 'boolean', example: true },
timestamp: { type: 'string', example: '2024-01-15T09:30:00.000Z' },
},
},
{
properties: {
data: isArray
? { type: 'array', items: { $ref: getSchemaPath(model) } }
: { $ref: getSchemaPath(model) },
},
},
],
},
}),
);
};
/**
* 페이지네이션 응답 데코레이터
*/
export const ApiPaginatedResponse = <TModel extends Type<any>>(model: TModel) => {
return applyDecorators(
ApiExtraModels(model),
ApiOkResponse({
schema: {
allOf: [
{
properties: {
success: { type: 'boolean', example: true },
timestamp: { type: 'string', example: '2024-01-15T09:30:00.000Z' },
},
},
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
meta: {
type: 'object',
properties: {
page: { type: 'number', example: 1 },
limit: { type: 'number', example: 20 },
total: { type: 'number', example: 100 },
totalPages: { type: 'number', example: 5 },
hasNext: { type: 'boolean', example: true },
hasPrev: { type: 'boolean', example: false },
},
},
},
},
],
},
}),
);
};
// 사용 예시
@Controller('users')
export class UsersController {
@Get(':id')
@ApiStandardResponse(UserDto) // 단일 객체
getUser(@Param('id') id: string) {}
@Get()
@ApiPaginatedResponse(UserDto) // 배열 + 페이지네이션
getUsers(@Query() query: GetUsersQueryDto) {}
}
```
```
### core/error-codes.md
```markdown
# Error Codes System
## 에러 코드 체계
```typescript
/**
* 애플리케이션 에러 코드
* 형식: DOMAIN_ACTION_REASON
*/
export const ErrorCodes = {
// Authentication (AUTH_*)
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
AUTH_TOKEN_INVALID: 'AUTH_TOKEN_INVALID',
AUTH_REFRESH_FAILED: 'AUTH_REFRESH_FAILED',
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
// User (USER_*)
USER_NOT_FOUND: 'USER_NOT_FOUND',
USER_ALREADY_EXISTS: 'USER_ALREADY_EXISTS',
USER_EMAIL_TAKEN: 'USER_EMAIL_TAKEN',
USER_INVALID_PASSWORD: 'USER_INVALID_PASSWORD',
USER_DEACTIVATED: 'USER_DEACTIVATED',
// Validation (VALIDATION_*)
VALIDATION_FAILED: 'VALIDATION_FAILED',
VALIDATION_MISSING_FIELD: 'VALIDATION_MISSING_FIELD',
VALIDATION_INVALID_FORMAT: 'VALIDATION_INVALID_FORMAT',
// Resource (RESOURCE_*)
RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
RESOURCE_ALREADY_EXISTS: 'RESOURCE_ALREADY_EXISTS',
RESOURCE_CONFLICT: 'RESOURCE_CONFLICT',
// Server (SERVER_*)
SERVER_INTERNAL_ERROR: 'SERVER_INTERNAL_ERROR',
SERVER_SERVICE_UNAVAILABLE: 'SERVER_SERVICE_UNAVAILABLE',
SERVER_TIMEOUT: 'SERVER_TIMEOUT',
// Rate Limit (RATE_*)
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
} as const;
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
```
## HTTP 상태 코드 매핑
```typescript
/**
* 에러 코드 → HTTP 상태 코드 매핑
*/
export const ErrorCodeToStatus: Record<ErrorCode, number> = {
// 400 Bad Request
[ErrorCodes.VALIDATION_FAILED]: 400,
[ErrorCodes.VALIDATION_MISSING_FIELD]: 400,
[ErrorCodes.VALIDATION_INVALID_FORMAT]: 400,
// 401 Unauthorized
[ErrorCodes.AUTH_INVALID_CREDENTIALS]: 401,
[ErrorCodes.AUTH_TOKEN_EXPIRED]: 401,
[ErrorCodes.AUTH_TOKEN_INVALID]: 401,
[ErrorCodes.AUTH_UNAUTHORIZED]: 401,
// 403 Forbidden
[ErrorCodes.AUTH_FORBIDDEN]: 403,
[ErrorCodes.USER_DEACTIVATED]: 403,
// 404 Not Found
[ErrorCodes.USER_NOT_FOUND]: 404,
[ErrorCodes.RESOURCE_NOT_FOUND]: 404,
// 409 Conflict
[ErrorCodes.USER_ALREADY_EXISTS]: 409,
[ErrorCodes.USER_EMAIL_TAKEN]: 409,
[ErrorCodes.RESOURCE_ALREADY_EXISTS]: 409,
[ErrorCodes.RESOURCE_CONFLICT]: 409,
// 429 Too Many Requests
[ErrorCodes.RATE_LIMIT_EXCEEDED]: 429,
// 500 Internal Server Error
[ErrorCodes.SERVER_INTERNAL_ERROR]: 500,
// 503 Service Unavailable
[ErrorCodes.SERVER_SERVICE_UNAVAILABLE]: 503,
[ErrorCodes.SERVER_TIMEOUT]: 503,
};
```
## Custom Exception Classes
```typescript
// apps/backend/src/exceptions/api.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode, ErrorCodeToStatus, FieldError } from '@monorepo/shared-api';
export class ApiException extends HttpException {
constructor(
code: ErrorCode,
message: string,
errors?: FieldError[],
) {
const status = ErrorCodeToStatus[code] || HttpStatus.INTERNAL_SERVER_ERROR;
super(
{
code,
message,
errors,
},
status,
);
}
}
// 편의 클래스들
export class ValidationException extends ApiException {
constructor(errors: FieldError[]) {
super(
ErrorCodes.VALIDATION_FAILED,
'Validation failed',
errors,
);
}
}
export class NotFoundException extends ApiException {
constructor(resource: string, id?: string) {
const message = id
? `${resource} with id '${id}' not found`
: `${resource} not found`;
super(ErrorCodes.RESOURCE_NOT_FOUND, message);
}
}
export class UnauthorizedException extends ApiException {
constructor(message = 'Unauthorized') {
super(ErrorCodes.AUTH_UNAUTHORIZED, message);
}
}
export class ForbiddenException extends ApiException {
constructor(message = 'Access denied') {
super(ErrorCodes.AUTH_FORBIDDEN, message);
}
}
export class ConflictException extends ApiException {
constructor(resource: string, field: string) {
super(
ErrorCodes.RESOURCE_CONFLICT,
`${resource} with this ${field} already exists`,
);
}
}
// 사용 예시
throw new NotFoundException('User', userId);
throw new ConflictException('User', 'email');
throw new ValidationException([
{ field: 'email', message: 'Invalid email format', rule: 'isEmail' },
]);
```
## Global Exception Filter
```typescript
// apps/backend/src/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceId = uuidv4();
const timestamp = new Date().toISOString();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode = ErrorCodes.SERVER_INTERNAL_ERROR;
let message = 'Internal server error';
let details: string | undefined;
let errors: FieldError[] | undefined;
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const res = exceptionResponse as Record<string, any>;
message = res.message || message;
errorCode = res.code || this.statusToErrorCode(statusCode);
errors = res.errors;
}
} else if (exception instanceof Error) {
message = exception.message;
details = process.env.NODE_ENV === 'development'
? exception.stack
: undefined;
}
// 로깅
this.logger.error(
`[${traceId}] ${request.method} ${request.url} - ${statusCode}`,
{
traceId,
statusCode,
errorCode,
message,
path: request.url,
method: request.method,
ip: request.ip,
userAgent: request.get('user-agent'),
userId: (request as any).user?.id,
stack: exception instanceof Error ? exception.stack : undefined,
},
);
const errorResponse: ApiErrorResponse = {
success: false,
error: {
statusCode,
code: errorCode,
message,
details,
errors,
traceId,
},
timestamp,
};
response.status(statusCode).json(errorResponse);
}
private statusToErrorCode(status: number): string {
const mapping: Record<number, string> = {
400: ErrorCodes.VALIDATION_FAILED,
401: ErrorCodes.AUTH_UNAUTHORIZED,
403: ErrorCodes.AUTH_FORBIDDEN,
404: ErrorCodes.RESOURCE_NOT_FOUND,
409: ErrorCodes.RESOURCE_CONFLICT,
429: ErrorCodes.RATE_LIMIT_EXCEEDED,
500: ErrorCodes.SERVER_INTERNAL_ERROR,
503: ErrorCodes.SERVER_SERVICE_UNAVAILABLE,
};
return mapping[status] || ErrorCodes.SERVER_INTERNAL_ERROR;
}
}
```
```
### patterns/type-generation.md
```markdown
# Type Generation
## swagger-typescript-api 설정
```typescript
// packages/shared/api/scripts/generate-types.ts
import { generateApi } from 'swagger-typescript-api';
import * as path from 'path';
import * as fs from 'fs';
const OUTPUT_PATH = path.resolve(__dirname, '../generated');
const OPENAPI_PATH = path.resolve(__dirname, '../openapi/openapi.yaml');
async function generateTypes() {
// 출력 디렉토리 정리
if (fs.existsSync(OUTPUT_PATH)) {
fs.rmSync(OUTPUT_PATH, { recursive: true });
}
fs.mkdirSync(OUTPUT_PATH, { recursive: true });
await generateApi({
name: 'api.ts',
output: OUTPUT_PATH,
input: OPENAPI_PATH,
httpClientType: 'axios',
generateClient: true,
generateRouteTypes: true,
generateResponses: true,
extractRequestParams: true,
extractRequestBody: true,
extractEnums: true,
unwrapResponseData: true,
prettier: {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
trailingComma: 'all',
},
hooks: {
onFormatTypeName: (typeName: string) => typeName,
onFormatRouteName: (routeInfo: any) => {
return routeInfo.operationId || routeInfo.route;
},
},
});
console.log('Types generated at:', OUTPUT_PATH);
}
generateTypes().catch(console.error);
```
## package.json 스크립트
```json
{
"name": "@monorepo/shared-api",
"version": "1.0.0",
"scripts": {
"generate": "ts-node scripts/generate-types.ts",
"generate:watch": "nodemon --watch openapi -e yaml,json --exec 'pnpm generate'",
"validate": "openapi-generator-cli validate -i openapi/openapi.yaml",
"lint": "spectral lint openapi/openapi.yaml"
},
"dependencies": {
"axios": "^1.6.0",
"zod": "^3.22.0"
},
"devDependencies": {
"swagger-typescript-api": "^13.0.0",
"@openapitools/openapi-generator-cli": "^2.7.0",
"@stoplight/spectral-cli": "^6.11.0",
"ts-node": "^10.9.0",
"typescript": "^5.3.0",
"nodemon": "^3.0.0"
}
}
```
## 생성된 타입 활용
```typescript
// apps/web/src/lib/api-client.ts
import { Api, HttpClient } from '@monorepo/shared-api/generated';
// 기본 Axios 인스턴스 설정
const httpClient = new HttpClient({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// 인증 토큰 인터셉터
httpClient.instance.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 에러 인터셉터
httpClient.instance.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
return httpClient.instance(error.config);
}
logout();
}
return Promise.reject(error);
},
);
// API 클라이언트 인스턴스
export const api = new Api(httpClient);
// 타입 안전한 사용 예시
async function fetchUser(userId: string) {
const response = await api.users.getUserById(userId);
return response.data; // UserDto 타입
}
```
## React Query 통합
```typescript
// apps/web/src/hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CreateUserDto, UpdateUserDto, GetUsersQueryDto } from '@monorepo/shared-api';
// Query Keys 중앙 관리
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: GetUsersQueryDto) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
/**
* 사용자 목록 조회 훅
*/
export function useUsers(query: GetUsersQueryDto = {}) {
return useQuery({
queryKey: userKeys.list(query),
queryFn: async () => {
const response = await api.users.getUsers(query);
return response.data;
},
staleTime: 5 * 60 * 1000, // 5분
});
}
/**
* 단일 사용자 조회 훅
*/
export function useUser(userId: string) {
return useQuery({
queryKey: userKeys.detail(userId),
queryFn: async () => {
const response = await api.users.getUserById(userId);
return response.data;
},
enabled: !!userId,
});
}
/**
* 사용자 생성 뮤테이션
*/
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateUserDto) => {
const response = await api.users.createUser(data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
/**
* 사용자 수정 뮤테이션
*/
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateUserDto }) => {
const response = await api.users.updateUser(id, data);
return response.data;
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
/**
* 사용자 삭제 뮤테이션
*/
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
await api.users.deleteUser(userId);
},
onSuccess: (_, userId) => {
queryClient.removeQueries({ queryKey: userKeys.detail(userId) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
```
```
### templates/controller-template.md
```markdown
# Complete Controller Template
```typescript
// apps/backend/src/modules/users/users.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import {
CreateUserDto,
UpdateUserDto,
UserDto,
UserResponseDto,
UsersListResponseDto,
GetUsersQueryDto,
ApiErrorResponseDto,
} from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, UserRole } from './entities/user.entity';
import { ApiStandardResponse, ApiPaginatedResponse } from '@/common/decorators';
@ApiTags('Users')
@ApiBearerAuth('access-token')
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// ============================================================
// CREATE
// ============================================================
@Post()
@Roles(UserRole.ADMIN)
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '사용자 생성',
description: `
## 설명
새로운 사용자 계정을 생성합니다.
## 권한
- \`ADMIN\` 역할만 사용 가능
## 제약 조건
- 이메일은 시스템 내 유일해야 함
- 비밀번호: 8자 이상, 대소문자/숫자/특수문자 포함
`,
})
@ApiBody({
type: CreateUserDto,
examples: {
basic: {
summary: '기본 사용자',
value: {
email: '[email protected]',
password: 'SecurePass123!',
name: '홍길동',
},
},
admin: {
summary: '관리자',
value: {
email: '[email protected]',
password: 'AdminPass123!',
name: '관리자',
role: 'ADMIN',
},
},
},
})
@ApiResponse({
status: HttpStatus.CREATED,
description: '사용자 생성 성공',
type: UserResponseDto,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '잘못된 요청 (검증 오류)',
type: ApiErrorResponseDto,
})
@ApiResponse({
status: HttpStatus.CONFLICT,
description: '이메일 중복',
type: ApiErrorResponseDto,
})
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
const user = await this.usersService.create(createUserDto);
return this.wrapResponse(user);
}
// ============================================================
// READ (List)
// ============================================================
@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({
summary: '사용자 목록 조회',
description: '페이지네이션이 적용된 사용자 목록을 조회합니다.',
})
@ApiQuery({ name: 'page', type: Number, required: false, example: 1 })
@ApiQuery({ name: 'limit', type: Number, required: false, example: 20 })
@ApiQuery({ name: 'role', enum: UserRole, required: false })
@ApiQuery({ name: 'search', type: String, required: false })
@ApiPaginatedResponse(UserDto)
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '인증 실패',
type: ApiErrorResponseDto,
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: '권한 없음',
type: ApiErrorResponseDto,
})
async findAll(@Query() query: GetUsersQueryDto): Promise<UsersListResponseDto> {
const { data, total } = await this.usersService.findAll(query);
return this.wrapPaginatedResponse(data, query, total);
}
// ============================================================
// READ (Single)
// ============================================================
@Get(':id')
@ApiOperation({
summary: '사용자 상세 조회',
description: '특정 사용자의 상세 정보를 조회합니다.',
})
@ApiParam({
name: 'id',
type: String,
format: 'uuid',
description: '사용자 ID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@ApiStandardResponse(UserDto)
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: '사용자를 찾을 수 없음',
type: ApiErrorResponseDto,
})
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() currentUser: User,
): Promise<UserResponseDto> {
// 본인 또는 관리자만 조회 가능
if (currentUser.role !== UserRole.ADMIN && currentUser.id !== id) {
throw new ForbiddenException('자신의 정보만 조회할 수 있습니다');
}
const user = await this.usersService.findOne(id);
return this.wrapResponse(user);
}
// ============================================================
// UPDATE
// ============================================================
@Put(':id')
@ApiOperation({
summary: '사용자 정보 수정',
description: '사용자 정보를 수정합니다.',
})
@ApiParam({
name: 'id',
type: String,
format: 'uuid',
description: '사용자 ID',
})
@ApiBody({ type: UpdateUserDto })
@ApiStandardResponse(UserDto)
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: '사용자를 찾을 수 없음',
type: ApiErrorResponseDto,
})
@ApiResponse({
status: HttpStatus.CONFLICT,
description: '이메일 중복',
type: ApiErrorResponseDto,
})
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateUserDto: UpdateUserDto,
@CurrentUser() currentUser: User,
): Promise<UserResponseDto> {
// 본인 또는 관리자만 수정 가능
if (currentUser.role !== UserRole.ADMIN && currentUser.id !== id) {
throw new ForbiddenException('자신의 정보만 수정할 수 있습니다');
}
// 일반 사용자는 역할 변경 불가
if (currentUser.role !== UserRole.ADMIN && updateUserDto.role) {
throw new ForbiddenException('역할을 변경할 권한이 없습니다');
}
const user = await this.usersService.update(id, updateUserDto);
return this.wrapResponse(user);
}
// ============================================================
// DELETE
// ============================================================
@Delete(':id')
@Roles(UserRole.ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: '사용자 삭제',
description: '사용자 계정을 삭제합니다 (Soft Delete).',
})
@ApiParam({
name: 'id',
type: String,
format: 'uuid',
description: '사용자 ID',
})
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: '삭제 성공',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: '사용자를 찾을 수 없음',
type: ApiErrorResponseDto,
})
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.usersService.remove(id);
}
// ============================================================
// Helper Methods
// ============================================================
private wrapResponse(user: User): UserResponseDto {
return {
success: true,
data: this.toDto(user),
timestamp: new Date().toISOString(),
};
}
private wrapPaginatedResponse(
users: User[],
query: GetUsersQueryDto,
total: number,
): UsersListResponseDto {
const page = query.page || 1;
const limit = query.limit || 20;
const totalPages = Math.ceil(total / limit);
return {
success: true,
data: users.map((u) => this.toDto(u)),
meta: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
timestamp: new Date().toISOString(),
};
}
private toDto(user: User): UserDto {
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone,
role: user.role,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
}
```
```
### templates/openapi-yaml.md
```markdown
# OpenAPI YAML Templates
## Main OpenAPI Spec
```yaml
# packages/shared/api/openapi/openapi.yaml
openapi: 3.0.3
info:
title: MonoRepo API
description: |
# MonoRepo API Documentation
## Authentication
모든 API (인증 관련 제외)는 Bearer Token이 필요합니다.
## Response Format
모든 응답은 표준 포맷을 따릅니다.
version: 1.0.0
contact:
name: Backend Team
email: [email protected]
license:
name: MIT
servers:
- url: http://localhost:3000/api/v1
description: Local Development
- url: https://api.staging.example.com/v1
description: Staging
- url: https://api.example.com/v1
description: Production
tags:
- name: Auth
description: 인증 관련 API
- name: Users
description: 사용자 관련 API
paths:
/auth/login:
$ref: './paths/auth.yaml#/login'
/auth/refresh:
$ref: './paths/auth.yaml#/refresh'
/users:
$ref: './paths/users.yaml#/users'
/users/{id}:
$ref: './paths/users.yaml#/users-id'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT 토큰을 입력하세요
schemas:
$ref: './components/schemas.yaml'
responses:
$ref: './components/responses.yaml'
parameters:
$ref: './components/parameters.yaml'
security:
- bearerAuth: []
```
## Users Path Definition
```yaml
# packages/shared/api/openapi/paths/users.yaml
users:
get:
tags:
- Users
operationId: getUsers
summary: 사용자 목록 조회
description: 페이지네이션이 적용된 사용자 목록을 조회합니다.
parameters:
- $ref: '../components/parameters.yaml#/PageParam'
- $ref: '../components/parameters.yaml#/LimitParam'
- name: role
in: query
schema:
$ref: '../components/schemas.yaml#/UserRole'
- name: search
in: query
schema:
type: string
responses:
'200':
description: 성공
content:
application/json:
schema:
$ref: '../components/responses.yaml#/UsersListResponse'
'401':
$ref: '../components/responses.yaml#/UnauthorizedError'
'403':
$ref: '../components/responses.yaml#/ForbiddenError'
post:
tags:
- Users
operationId: createUser
summary: 사용자 생성
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas.yaml#/CreateUserDto'
examples:
basic:
summary: 기본 사용자
value:
email: [email protected]
password: SecurePass123!
name: 홍길동
responses:
'201':
description: 생성 성공
content:
application/json:
schema:
$ref: '../components/responses.yaml#/UserResponse'
'400':
$ref: '../components/responses.yaml#/ValidationError'
'409':
$ref: '../components/responses.yaml#/ConflictError'
users-id:
get:
tags:
- Users
operationId: getUserById
summary: 사용자 상세 조회
parameters:
- $ref: '../components/parameters.yaml#/UserIdParam'
responses:
'200':
description: 성공
content:
application/json:
schema:
$ref: '../components/responses.yaml#/UserResponse'
'404':
$ref: '../components/responses.yaml#/NotFoundError'
put:
tags:
- Users
operationId: updateUser
summary: 사용자 정보 수정
parameters:
- $ref: '../components/parameters.yaml#/UserIdParam'
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas.yaml#/UpdateUserDto'
responses:
'200':
description: 수정 성공
content:
application/json:
schema:
$ref: '../components/responses.yaml#/UserResponse'
'404':
$ref: '../components/responses.yaml#/NotFoundError'
'409':
$ref: '../components/responses.yaml#/ConflictError'
delete:
tags:
- Users
operationId: deleteUser
summary: 사용자 삭제
parameters:
- $ref: '../components/parameters.yaml#/UserIdParam'
responses:
'204':
description: 삭제 성공
'404':
$ref: '../components/responses.yaml#/NotFoundError'
```
## Schemas Component
```yaml
# packages/shared/api/openapi/components/schemas.yaml
UserRole:
type: string
enum:
- USER
- ADMIN
description: 사용자 역할
UserDto:
type: object
required:
- id
- email
- name
- role
- createdAt
- updatedAt
properties:
id:
type: string
format: uuid
example: '550e8400-e29b-41d4-a716-446655440000'
email:
type: string
format: email
example: '[email protected]'
name:
type: string
example: '홍길동'
phone:
type: string
example: '010-1234-5678'
role:
$ref: '#/UserRole'
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
CreateUserDto:
type: object
required:
- email
- password
- name
properties:
email:
type: string
format: email
maxLength: 255
password:
type: string
minLength: 8
maxLength: 100
name:
type: string
minLength: 2
maxLength: 50
phone:
type: string
pattern: '^01[0-9]-\d{3,4}-\d{4}$'
role:
$ref: '#/UserRole'
UpdateUserDto:
type: object
properties:
name:
type: string
minLength: 2
maxLength: 50
phone:
type: string
pattern: '^01[0-9]-\d{3,4}-\d{4}$'
role:
$ref: '#/UserRole'
```
## Parameters Component
```yaml
# packages/shared/api/openapi/components/parameters.yaml
PageParam:
name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
description: 페이지 번호 (1부터 시작)
LimitParam:
name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: 페이지당 항목 수
UserIdParam:
name: id
in: path
required: true
schema:
type: string
format: uuid
description: 사용자 ID
example: '550e8400-e29b-41d4-a716-446655440000'
```
## Responses Component
```yaml
# packages/shared/api/openapi/components/responses.yaml
UserResponse:
type: object
properties:
success:
type: boolean
example: true
data:
$ref: './schemas.yaml#/UserDto'
timestamp:
type: string
format: date-time
UsersListResponse:
type: object
properties:
success:
type: boolean
example: true
data:
type: array
items:
$ref: './schemas.yaml#/UserDto'
meta:
type: object
properties:
page:
type: integer
example: 1
limit:
type: integer
example: 20
total:
type: integer
example: 100
totalPages:
type: integer
example: 5
hasNext:
type: boolean
example: true
hasPrev:
type: boolean
example: false
timestamp:
type: string
format: date-time
UnauthorizedError:
description: 인증 실패
content:
application/json:
schema:
$ref: '#/ApiErrorResponse'
ForbiddenError:
description: 권한 없음
content:
application/json:
schema:
$ref: '#/ApiErrorResponse'
NotFoundError:
description: 리소스를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/ApiErrorResponse'
ValidationError:
description: 검증 오류
content:
application/json:
schema:
$ref: '#/ApiErrorResponse'
ConflictError:
description: 리소스 충돌
content:
application/json:
schema:
$ref: '#/ApiErrorResponse'
ApiErrorResponse:
type: object
properties:
success:
type: boolean
example: false
error:
type: object
properties:
statusCode:
type: integer
code:
type: string
message:
type: string
traceId:
type: string
errors:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
timestamp:
type: string
format: date-time
```
```
### quick-reference/checklist.md
```markdown
# API Development Checklist
## API 개발 전 체크리스트
```markdown
## 개발 전 (Contract First)
- [ ] OpenAPI 스펙 정의 완료
- [ ] Frontend/Backend 스펙 합의 완료
- [ ] 에러 케이스 정의 완료
- [ ] 인증/권한 요구사항 정의
- [ ] 페이지네이션 전략 결정
- [ ] 타입 생성 (swagger-typescript-api)
```
## Controller 구현 체크리스트
```markdown
## Controller 구현
- [ ] @ApiTags 적용 (복수형)
- [ ] @ApiBearerAuth 적용 (인증 필요 시)
- [ ] 모든 메서드에 @ApiOperation
- [ ] summary (50자 이내)
- [ ] description (마크다운, 상세)
- [ ] 모든 메서드에 @ApiResponse
- [ ] 200/201: 성공
- [ ] 400: 검증 오류
- [ ] 401: 인증 오류
- [ ] 403: 권한 오류
- [ ] 404: 리소스 없음
- [ ] 409: 충돌
- [ ] @ApiParam: 모든 Path Parameter
- [ ] @ApiQuery: 모든 Query Parameter
- [ ] @ApiBody: Request Body (예시 포함)
```
## DTO 체크리스트
```markdown
## DTO 정의
- [ ] @ApiProperty 모든 필수 필드
- [ ] @ApiPropertyOptional 모든 선택 필드
- [ ] description 작성
- [ ] example 제공
- [ ] format 지정 (email, uuid, date-time 등)
- [ ] 검증 데코레이터와 일치 (minLength, pattern 등)
- [ ] class-validator 데코레이터 적용
```
## 응답 포맷 체크리스트
```markdown
## 응답 포맷
- [ ] 성공: { success: true, data, meta?, timestamp }
- [ ] 에러: { success: false, error, timestamp }
- [ ] error.code는 ErrorCodes enum 사용
- [ ] error.traceId 포함 (로그 추적용)
- [ ] 검증 에러: error.errors 배열 포함
- [ ] 페이지네이션: meta 객체 포함
```
## 배포 전 체크리스트
```markdown
## 배포 전
- [ ] OpenAPI 스펙 유효성 검사 (openapi-generator validate)
- [ ] Swagger UI 확인
- [ ] 타입 재생성 및 프론트엔드 배포
- [ ] API 버전 확인
- [ ] 이전 버전 호환성 확인 (Breaking Change 시)
- [ ] Deprecation 헤더 설정 (구버전)
```
## Quick Reference
```yaml
표준_응답:
성공: "{ success: true, data: T, meta?: Meta, timestamp: string }"
에러: "{ success: false, error: ApiError, timestamp: string }"
필수_데코레이터:
Controller:
- "@ApiTags('Resources')"
- "@ApiBearerAuth('access-token')"
Method:
- "@ApiOperation({ summary, description })"
- "@ApiResponse({ status, description, type })"
- "@ApiParam / @ApiQuery / @ApiBody"
DTO:
- "@ApiProperty({ description, example, format })"
- "@ApiPropertyOptional()"
에러_코드_체계:
패턴: "DOMAIN_ACTION_REASON"
예시:
- "AUTH_TOKEN_EXPIRED"
- "USER_NOT_FOUND"
- "VALIDATION_FAILED"
버전_전략:
형식: "/api/v1/resources"
지원: "현재 버전 + 이전 버전(12개월)"
Breaking: "새 버전으로 분리"
```
## Documentation Rules
```yaml
모든 API는 다음 항목을 반드시 문서화:
Controller_Level:
- "@ApiTags": 그룹명 (복수형)
- "@ApiBearerAuth": 인증 필요 시
Method_Level:
- "@ApiOperation":
- summary: 한 줄 설명 (50자 이내)
- description: 상세 설명 (마크다운)
- "@ApiResponse": 모든 가능한 상태 코드
- "@ApiParam": Path 파라미터
- "@ApiQuery": Query 파라미터
- "@ApiBody": Request Body (예시 포함)
DTO_Level:
- "@ApiProperty": 모든 필수 속성
- "@ApiPropertyOptional": 모든 선택 속성
- description: 속성 설명
- example: 예시 값
- format: 형식 (email, uuid, date-time 등)
```
```
### quick-reference/anti-patterns.md
```markdown
# API Design Anti-Patterns
## 피해야 할 패턴들
### 1. 불완전한 Swagger 문서화
```typescript
// ❌ Bad
@Get()
getUsers() {} // 데코레이터 없음!
// ✅ Good
@Get()
@ApiOperation({ summary: '사용자 목록 조회' })
@ApiResponse({ status: 200, type: UsersListResponseDto })
@ApiResponse({ status: 401, type: ApiErrorResponseDto })
getUsers() {}
```
### 2. 일관성 없는 응답 포맷
```typescript
// ❌ Bad
return { user, success: true }; // 때로는 user
return { data: user }; // 때로는 data
return user; // 때로는 직접
// ✅ Good: 항상 표준 포맷 사용
return {
success: true,
data: user,
timestamp: new Date().toISOString(),
};
```
### 3. any 타입 사용
```typescript
// ❌ Bad
@ApiProperty()
data: any;
// ✅ Good
@ApiProperty({ type: UserDto })
data: UserDto;
```
### 4. 예시 없는 DTO
```typescript
// ❌ Bad
@ApiProperty()
email: string;
// ✅ Good
@ApiProperty({
description: '사용자 이메일',
example: '[email protected]',
format: 'email',
})
email: string;
```
### 5. 에러 응답 미정의
```typescript
// ❌ Bad
@ApiResponse({ status: 200, type: UserDto })
getUser() {}
// ✅ Good
@ApiResponse({ status: 200, type: UserDto })
@ApiResponse({ status: 400, type: ApiErrorResponseDto })
@ApiResponse({ status: 401, type: ApiErrorResponseDto })
@ApiResponse({ status: 404, type: ApiErrorResponseDto })
getUser() {}
```
### 6. 직접 에러 throw
```typescript
// ❌ Bad
throw new Error('User not found');
// ✅ Good
throw new NotFoundException('User', userId);
```
### 7. 하드코딩된 에러 메시지/코드
```typescript
// ❌ Bad
return { error: { code: 'NOT_FOUND', message: '...' } };
// ✅ Good
throw new ApiException(ErrorCodes.USER_NOT_FOUND, message);
```
### 8. 버전 없는 API
```typescript
// ❌ Bad
@Controller('users')
// ✅ Good
@Controller({ path: 'users', version: '1' })
```
### 9. 문서와 코드 불일치
```typescript
// ❌ Bad
@ApiProperty({ required: false }) // 선택적
email: string; // 코드에서는 필수
// ✅ Good
@ApiPropertyOptional()
email?: string;
```
### 10. 검증과 문서 불일치
```typescript
// ❌ Bad
@ApiProperty({ minLength: 8 })
@MinLength(6) // 서로 다른 값!
password: string;
// ✅ Good
@ApiProperty({ minLength: 8 })
@MinLength(8)
password: string;
```
## Anti-Pattern 탐지 정규식
```typescript
const API_DESIGN_ANTIPATTERNS = [
// Swagger 문서화 누락
{
pattern: /@(Get|Post|Put|Patch|Delete)\([^)]*\)\s*\n\s*(?!@Api)/,
message: 'HTTP 메서드에 Swagger 데코레이터 누락',
severity: 'error',
},
// any 타입 사용
{
pattern: /@ApiProperty\([^)]*\)\s*\n\s*\w+:\s*any/,
message: 'ApiProperty에 any 타입 사용 금지',
severity: 'error',
},
// 예시 없는 ApiProperty
{
pattern: /@ApiProperty\(\s*\)\s*\n/,
message: 'ApiProperty에 description과 example 필수',
severity: 'warning',
},
// 직접 Error throw
{
pattern: /throw new Error\(/,
message: 'Error 대신 커스텀 예외 클래스 사용',
severity: 'error',
},
// 하드코딩된 에러 코드
{
pattern: /code:\s*['"`][A-Z_]+['"`]/,
message: 'ErrorCodes enum 사용 필요',
severity: 'warning',
},
// 일관성 없는 응답
{
pattern: /return\s*\{\s*(?!success|data)/,
message: '표준 응답 포맷 사용 필요',
severity: 'warning',
},
];
```
## Quick Detection Commands
```bash
# Swagger 데코레이터 없는 메서드 찾기
grep -rn "@(Get|Post|Put|Delete)" --include="*.controller.ts" | \
grep -v "@Api"
# any 타입 사용 찾기
grep -rn ": any" --include="*.dto.ts"
# throw new Error 찾기
grep -rn "throw new Error" --include="*.ts"
# 하드코딩된 에러 코드 찾기
grep -rn "code: ['\"]" --include="*.ts"
```
```