generated from ttipo/project-base
Initial commit
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,121 @@
|
||||
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 = {
|
||||
FILE_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
},
|
||||
FILE_002: {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
message: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
},
|
||||
} 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'],
|
||||
// multer 가 파일 크기 한도 초과 시 던지는 상태 코드
|
||||
[HttpStatus.PAYLOAD_TOO_LARGE, 'FILE_002'],
|
||||
]);
|
||||
|
||||
/** 등록된 에러 코드인지 확인한다. */
|
||||
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,130 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
} 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('라이브러리가 만든 영문 메시지를 사용자에게 노출하지 않는다', () => {
|
||||
// multer 는 크기 초과 시 "File too large" 라는 영문 메시지로 예외를 만든다.
|
||||
// 사용자 노출 문구는 에러 코드 사전에만 존재해야 한다.
|
||||
const result = runFilter(new PayloadTooLargeException('File too large'));
|
||||
|
||||
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
|
||||
expect(result.body.error.code).toBe('FILE_002');
|
||||
expect(result.body.error.message).toBe(ERROR_CODES.FILE_002.message);
|
||||
expect(JSON.stringify(result.body)).not.toContain('File too large');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
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>();
|
||||
|
||||
const { status, code, message } = this.resolve(exception);
|
||||
const traceId = request.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
|
||||
this.log(exception, status, code, request, traceId);
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
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,
|
||||
// 프레임워크/라이브러리가 만든 영문 메시지("File too large" 등)가 그대로 새어나가지 않도록
|
||||
// **항상 레지스트리 문구**를 쓴다. 사용자 노출 문구를 재정의하려면 BusinessException 을 사용한다.
|
||||
message: 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;
|
||||
}
|
||||
|
||||
/** 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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} 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: true;
|
||||
// 핸들러가 undefined/null 을 반환하면 null 로 정규화되므로 nullable
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답을 { success: true, data } 로 래핑한다.
|
||||
* `@SkipTransform()` 이 붙은 핸들러는 원본 응답을 그대로 통과시킨다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
StandardResponse<T> | T
|
||||
> {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): 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 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user