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:
2026-09-17 14:29:15 +09:00
parent 3b397b7788
commit f7ea60b8e7
16 changed files with 1022 additions and 165 deletions
+80
View File
@@ -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' });
});
});
+113
View File
@@ -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;
}
+2
View File
@@ -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}`);
}
}