feat: 에러 코드 SSOT 체계 도입
사용자에게 노출되는 실패가 에러 코드 없이 만들어질 수 없도록 구조를 바꾼다. 기존에는 예외 필터가 HTTP 상태로 코드를 추론해 도메인별 에러를 표현할 수 없었고, 세 플랫폼의 에러 사전을 손으로 맞춰야 해서 어긋나기 쉬웠다. - 코드↔메시지↔HTTP 상태를 error-codes.ts 레지스트리 한 곳으로 통합 - BusinessException 은 에러 코드를 생성자 필수 인자로 받아 코드 누락을 타입으로 차단 - sync:error-codes 스크립트가 프론트·플러터 사전을 자동 생성 (수동 복사 제거) - 429 전용 코드 REQ_001 추가 (기존에는 "시스템 오류"로 잘못 안내) - AUTH_002(세션 만료)를 클라이언트에서 실제로 발행하도록 구현 - 응답에 traceId 를 실어 로그와 대조 가능하게 함 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 에러 코드 SSOT 동기화 스크립트.
|
||||
*
|
||||
* backend 의 error-codes.ts 레지스트리를 읽어 프론트엔드/플러터의 에러 사전을 생성한다.
|
||||
* 실행: npm run sync:error-codes (backend 디렉터리에서)
|
||||
*
|
||||
* 생성 파일은 직접 수정하지 말 것 — 레지스트리를 고치고 다시 실행한다.
|
||||
* CI 는 이 스크립트를 실행한 뒤 git diff 로 드리프트를 검사한다.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
import { ERROR_CODES } from '../src/common/errors/error-codes';
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..');
|
||||
const GENERATED_NOTICE =
|
||||
'backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.';
|
||||
|
||||
const entries = Object.entries(ERROR_CODES);
|
||||
|
||||
/** TypeScript 작은따옴표 문자열 리터럴로 이스케이프한다. */
|
||||
function toTsLiteral(value: string): string {
|
||||
const escaped = value
|
||||
.split('\\')
|
||||
.join('\\\\')
|
||||
.split("'")
|
||||
.join('\\' + "'");
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dart 작은따옴표 문자열 리터럴로 이스케이프한다.
|
||||
* Dart 는 문자열 안의 `$` 를 보간으로 해석하므로 함께 이스케이프해야 한다.
|
||||
*/
|
||||
function toDartLiteral(value: string): string {
|
||||
const escaped = value
|
||||
.split('\\')
|
||||
.join('\\\\')
|
||||
.split("'")
|
||||
.join('\\' + "'")
|
||||
.split('$')
|
||||
.join('\\' + '$');
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
|
||||
function writeGenerated(relativePath: string, content: string): void {
|
||||
const target = resolve(REPO_ROOT, relativePath);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content, { encoding: 'utf8' });
|
||||
console.log(`생성 완료: ${relativePath}`);
|
||||
}
|
||||
|
||||
// ---------- frontend (TypeScript) ----------
|
||||
const tsBody = entries
|
||||
.map(([code, def]) => ` ${code}: ${toTsLiteral(def.message)},`)
|
||||
.join('\n');
|
||||
|
||||
writeGenerated(
|
||||
'frontend/src/shared/constants/error-codes.generated.ts',
|
||||
`// ${GENERATED_NOTICE}\n\n` +
|
||||
`export const ERROR_CODE_MESSAGES = {\n${tsBody}\n} as const\n\n` +
|
||||
`export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES\n\n` +
|
||||
`/** 매핑 실패 시 사용하는 폴백 코드 */\n` +
|
||||
`export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'\n`,
|
||||
);
|
||||
|
||||
// ---------- flutter (Dart) ----------
|
||||
const dartBody = entries
|
||||
.map(([code, def]) => ` '${code}': ${toDartLiteral(def.message)},`)
|
||||
.join('\n');
|
||||
|
||||
writeGenerated(
|
||||
'flutter/lib/core/api/error_lexicon.g.dart',
|
||||
`// ${GENERATED_NOTICE}\n\n` +
|
||||
`const Map<String, String> kErrorCodeMessages = {\n${dartBody}\n};\n\n` +
|
||||
`/// 매핑 실패 시 사용하는 폴백 코드\n` +
|
||||
`const String kFallbackErrorCode = 'SYS_001';\n`,
|
||||
);
|
||||
|
||||
console.log(`\n총 ${entries.length}개 에러 코드를 동기화했습니다.`);
|
||||
@@ -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,113 @@
|
||||
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 = {} 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'],
|
||||
]);
|
||||
|
||||
/** 등록된 에러 코드인지 확인한다. */
|
||||
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,118 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} 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('미정의 예외는 내부 정보 없이 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');
|
||||
});
|
||||
});
|
||||
@@ -6,70 +6,132 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
// 전역 Exception Filter — CLAUDE.md 에러 코드 맵에 따라 표준 응답으로 변환
|
||||
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>();
|
||||
|
||||
// 비교 대상인 HttpStatus enum 과 동일 타입으로 선언 (no-unsafe-enum-comparison 대응)
|
||||
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = 'SYS_001';
|
||||
let message =
|
||||
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
const { status, code, message } = this.resolve(exception);
|
||||
const traceId = request.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (status === HttpStatus.UNAUTHORIZED) {
|
||||
code = 'AUTH_001';
|
||||
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
|
||||
} else if (status === HttpStatus.FORBIDDEN) {
|
||||
code = 'AUTH_003';
|
||||
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
|
||||
} else if (status === HttpStatus.NOT_FOUND) {
|
||||
code = 'RES_001';
|
||||
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
|
||||
} else if (
|
||||
status === HttpStatus.BAD_REQUEST ||
|
||||
status === HttpStatus.UNPROCESSABLE_ENTITY
|
||||
) {
|
||||
code = 'VAL_001';
|
||||
message =
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
|
||||
} else {
|
||||
code = 'BIZ_001';
|
||||
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
|
||||
|
||||
// 비즈니스 예외에서는 사용자에게 보일 메시지를 재정의 가능
|
||||
if (
|
||||
typeof exceptionResponse === 'object' &&
|
||||
exceptionResponse !== null &&
|
||||
'message' in exceptionResponse
|
||||
) {
|
||||
const responseMessage = exceptionResponse.message;
|
||||
if (typeof responseMessage === 'string') {
|
||||
message = responseMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 정의되지 않은 예외 → 운영팀이 추적 가능하도록 로그 + SYS_001 응답 유지
|
||||
this.logger.error('Unhandled Exception', exception as Error);
|
||||
}
|
||||
this.log(exception, status, code, request, traceId);
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
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,
|
||||
// 내장 예외의 영문 기본 메시지는 노출하지 않고 레지스트리 문구를 사용한다.
|
||||
message: this.extractMessage(body) ?? 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직접 문자열 메시지를 지정해 던진 경우에만 사용자 문구로 채택한다.
|
||||
* (ValidationPipe 처럼 message 가 배열이면 상세 사유가 노출되므로 무시한다.)
|
||||
*/
|
||||
private extractMessage(body: unknown): string | null {
|
||||
if (typeof body === 'object' && body !== null && 'message' in body) {
|
||||
const message: unknown = body.message;
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user