feat: 응답 계약 정의와 페이지네이션·추적 ID 추가
세 클라이언트가 공유하는 계약 중 정의가 빠져 있던 부분을 채운다.
- 목록 응답 형태를 {items, pageInfo} 로 고정하고 커서/오프셋 쿼리 DTO 제공
- @SkipTransform() 으로 스트림·204 등 래핑하면 안 되는 응답을 제외
- ApiStandardResponse/ApiErrorResponse 로 Swagger 문서를 실응답과 일치시킴
(컨트롤러는 raw 를 반환하지만 실제 응답은 래핑되어 문서가 어긋나 있었다)
- RequestIdMiddleware 로 모든 요청에 x-request-id 부여
- API 설계 규약(api-design.md) 신설 — REST 경로·쿼리·날짜/ID 표현·버전 관리
또한 .gitignore 의 data/ 패턴이 모든 depth 에 매칭되어
flutter/lib/features/user/data/ 의 소스가 커밋되지 않던 문제를 함께 고친다.
(user_provider.dart 가 import 하는 파일이 저장소에 없어 flutter analyze 가 깨져 있었다)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user