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:
2026-09-17 14:30:07 +09:00
parent f7ea60b8e7
commit c5d165275c
14 changed files with 557 additions and 43 deletions
@@ -0,0 +1,71 @@
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { ErrorCode, getErrorDefinition } from '@/common/errors/error-codes';
/**
* Swagger 문서를 실제 응답(표준 래퍼)과 일치시킨다.
* 컨트롤러는 raw 데이터를 반환하지만 클라이언트가 받는 본문은 { success, data } 이므로,
* `@ApiResponse({ type: Dto })` 를 그대로 쓰면 문서와 실응답이 어긋난다.
*
* 사용 예:
* @ApiStandardResponse(UserResponseDto, { status: 200, description: '사용자 조회 성공' })
*/
export function ApiStandardResponse<TModel extends Type<unknown>>(
model?: TModel,
options: { status?: number; description?: string; isArray?: boolean } = {},
) {
const { status = 200, description = '요청 성공', isArray = false } = options;
const dataSchema = model
? isArray
? { type: 'array', items: { $ref: getSchemaPath(model) } }
: { $ref: getSchemaPath(model) }
: { type: 'object', nullable: true };
return applyDecorators(
...(model ? [ApiExtraModels(model)] : []),
ApiResponse({
status,
description,
schema: {
properties: {
success: { type: 'boolean', example: true },
data: dataSchema,
},
},
}),
);
}
/**
* 표준 실패 응답을 에러 코드와 함께 문서화한다.
* 상태 코드·메시지는 레지스트리에서 가져오므로 문서와 구현이 자동으로 일치한다.
*
* 사용 예:
* @ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
*/
export function ApiErrorResponse(code: ErrorCode, description?: string) {
const definition = getErrorDefinition(code);
return ApiResponse({
status: definition.status,
description: description ?? definition.message,
schema: {
properties: {
success: { type: 'boolean', example: false },
error: {
type: 'object',
properties: {
code: { type: 'string', example: code },
message: { type: 'string', example: definition.message },
traceId: {
type: 'string',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
},
},
},
},
},
});
}
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_TRANSFORM_KEY = 'skipTransform';
/**
* 표준 응답 래핑({ success, data })을 건너뛴다.
* 파일 다운로드·스트림·리다이렉트·204 No Content 처럼
* 본문 구조를 바꾸면 안 되는 핸들러에만 사용한다.
*/
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);
+99
View File
@@ -0,0 +1,99 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/** 페이지네이션 기본값 — 매직 넘버 방지용 상수 */
export const PAGINATION_DEFAULT_LIMIT = 20;
export const PAGINATION_MAX_LIMIT = 100;
/** 커서 기반 페이지네이션 쿼리 (목록 API 기본 권장) */
export class CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '이전 응답의 nextCursor 값. 첫 페이지는 생략한다.',
example: 'eyJpZCI6MTIzfQ==',
})
@IsOptional()
@IsString({ message: '커서 값의 형식이 올바르지 않습니다.' })
cursor?: string;
@ApiPropertyOptional({
description: `한 번에 가져올 개수 (1~${PAGINATION_MAX_LIMIT})`,
default: PAGINATION_DEFAULT_LIMIT,
example: PAGINATION_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(PAGINATION_MAX_LIMIT, {
message: `조회 개수는 ${PAGINATION_MAX_LIMIT} 이하여야 합니다.`,
})
limit: number = PAGINATION_DEFAULT_LIMIT;
}
/** 오프셋 기반 페이지네이션 쿼리 (전체 페이지 수가 필요한 화면에서만 사용) */
export class OffsetPaginationQueryDto {
@ApiPropertyOptional({
description: '1부터 시작하는 페이지 번호',
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '페이지 번호는 정수로 입력해 주세요.' })
@Min(1, { message: '페이지 번호는 1 이상이어야 합니다.' })
page: number = 1;
@ApiPropertyOptional({
description: `한 페이지 개수 (1~${PAGINATION_MAX_LIMIT})`,
default: PAGINATION_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(PAGINATION_MAX_LIMIT, {
message: `조회 개수는 ${PAGINATION_MAX_LIMIT} 이하여야 합니다.`,
})
limit: number = PAGINATION_DEFAULT_LIMIT;
}
/** 목록 응답의 페이지 정보 — 세 플랫폼 공유 계약 */
export class PageInfoDto {
@ApiProperty({ description: '다음 페이지 존재 여부', example: true })
hasNext!: boolean;
@ApiProperty({
description: '다음 요청에 넘길 커서. 마지막 페이지면 null',
nullable: true,
example: 'eyJpZCI6MTQzfQ==',
})
nextCursor!: string | null;
@ApiProperty({
description: '전체 개수. 계산 비용이 큰 경우 null 을 반환할 수 있다.',
nullable: true,
example: 143,
})
total!: number | null;
}
/** 목록 응답 본문 — 인터셉터가 { success, data } 로 한 번 더 감싼다. */
export interface PaginatedResult<T> {
items: T[];
pageInfo: PageInfoDto;
}
/** 조회 결과를 표준 목록 응답으로 변환한다. */
export function toPaginatedResult<T>(
items: T[],
pageInfo: Partial<PageInfoDto> = {},
): PaginatedResult<T> {
return {
items,
pageInfo: {
hasNext: pageInfo.hasNext ?? false,
nextCursor: pageInfo.nextCursor ?? null,
total: pageInfo.total ?? null,
},
};
}
@@ -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;
}
+28 -9
View File
@@ -5,15 +5,18 @@ import {
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('User')
@@ -22,18 +25,34 @@ import { CreateUserDto } from './dto/create-user.dto';
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
@ApiStandardResponse(UserResponseDto, {
status: 200,
description: '사용자 목록 조회 성공',
isArray: true,
})
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
findMany(@Query() query: CursorPaginationQueryDto) {
return this.userService.findMany(query.limit, query.cursor);
}
@Get(':id')
@ApiOperation({ summary: '사용자 단건 조회' })
@ApiResponse({ status: 200, description: '사용자 조회 성공' })
@ApiResponse({ status: 404, description: '사용자를 찾을 수 없음 (RES_001)' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiResponse({ status: 201, description: '사용자 생성 성공' })
@ApiResponse({ status: 400, description: '입력값 검증 실패 (VAL_001)' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
+41 -11
View File
@@ -1,27 +1,57 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
@Injectable()
export class UserService {
// 임시 인메모리 저장소 — Repository 도입 시 교체
private readonly users = new Map<
number,
{ id: number; email: string; name: string }
>();
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
findOne(id: number) {
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 404 → 전역 필터에서 RES_001 로 변환됨
throw new NotFoundException();
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
}
create(dto: CreateUserDto) {
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
const afterId = cursor ? Number(cursor) : 0;
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
const last = page.at(-1);
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext && last ? String(last.id) : null,
total: sorted.length,
});
}
create(dto: CreateUserDto): UserResponseDto {
const duplicated = [...this.users.values()].some(
(user) => user.email === dto.email,
);
if (duplicated) {
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
throw new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
context: { email: dto.email },
});
}
const id = this.users.size + 1;
const user = { id, email: dto.email, name: dto.name };
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
}