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:
+109
-23
@@ -1,29 +1,115 @@
|
||||
---
|
||||
name: error-codes
|
||||
description: 세 플랫폼이 공유하는 에러 코드 ↔ 한국어 메시지 매핑(SSOT). 메시지 수정 시 backend/frontend/flutter 3개 파일을 함께 갱신한다.
|
||||
---
|
||||
# 에러 코드 규칙
|
||||
|
||||
# 에러 코드 맵핑
|
||||
## 핵심 원칙
|
||||
|
||||
세 플랫폼이 공유하는 **단일 진실 공급원(SSOT)**. 메시지를 수정할 때는 아래 세 파일을 함께 갱신한다.
|
||||
> **사용자에게 노출되는 모든 실패는 반드시 에러 코드를 가진다.**
|
||||
> 코드 없이 문자열 메시지만 던지는 실패는 허용하지 않는다.
|
||||
|
||||
- backend: `src/common/filters/http-exception.filter.ts`
|
||||
- frontend: `src/composables/useApi.ts` (`ErrorCodeLexicon`)
|
||||
- flutter: `lib/core/api/error_lexicon.dart` (`ErrorLexicon`)
|
||||
- 사용자 노출 문구는 **에러 코드 사전에만 존재**한다. 컨트롤러·서비스·컴포넌트에 문구를 하드코딩하지 않는다.
|
||||
- 클라이언트는 `message` 가 아니라 **`code` 로 분기**한다. (문구는 언제든 바뀔 수 있다)
|
||||
- 서버 내부 정보(스택, 쿼리, 호스트명)는 절대 응답에 싣지 않는다 — 로그로만 남긴다 ([[logging]]).
|
||||
|
||||
| 코드 | HTTP / 상황 | 메시지 |
|
||||
| -------- | -------------------- | ------------------------------------------------------- |
|
||||
| SYS_001 | 500 / 미정의 예외 | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| AUTH_001 | 401 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
|
||||
| AUTH_002 | 세션 만료 (클라이언트) | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| VAL_001 | 400 / 422 (Validation) | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 200(비즈니스 실패) / 기타 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
## SSOT — 레지스트리 한 곳
|
||||
|
||||
> 비즈니스 예외는 `HttpException` 응답의 `message` 로 사용자 노출 문구를 재정의할 수 있다.
|
||||
> 응답 래퍼 구조는 [[response-format]] 참조.
|
||||
에러 코드 ↔ 메시지 ↔ HTTP 상태는 **백엔드 레지스트리 한 파일**이 단일 진실 공급원이다.
|
||||
|
||||
> **구현 주의:** 백엔드 필터(`http-exception.filter.ts`)는 **6개 코드**(SYS_001 / AUTH_001 / AUTH_003 / VAL_001 / RES_001 / BIZ_001)만 방출한다.
|
||||
> `AUTH_002`(세션 만료)는 **클라이언트(frontend/flutter)에서만** 생성하는 코드로, 서버 응답에는 나타나지 않는다.
|
||||
> 코드/메시지 변경 시 위 3개 파일을 함께 갱신해야 하며, 점검 항목은 [[checklist]] 참조.
|
||||
```
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
│ npm run sync:error-codes
|
||||
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
|
||||
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
|
||||
```
|
||||
|
||||
- **생성 파일은 직접 수정 금지.** 레지스트리를 고치고 `cd backend && npm run sync:error-codes` 를 실행한다.
|
||||
- 코드 추가/변경 후에는 세 스택의 코드 목록 일치 테스트가 통과해야 한다
|
||||
(backend `error-codes.spec.ts` · frontend `useApi.spec.ts` · flutter `error_lexicon_test.dart`).
|
||||
목록을 바꿨다면 이 테스트의 기대 배열도 함께 갱신한다.
|
||||
|
||||
## 공통 코드
|
||||
|
||||
| 코드 | HTTP | 상황 | 메시지 |
|
||||
| --- | --- | --- | --- |
|
||||
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
|
||||
### AUTH_002 (클라이언트 전용)
|
||||
|
||||
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
|
||||
|
||||
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
|
||||
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
|
||||
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
|
||||
|
||||
## 도메인 코드 추가
|
||||
|
||||
1. `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 `DOMAIN_NNN` 형식으로 등록한다.
|
||||
```typescript
|
||||
USER_001: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
|
||||
},
|
||||
```
|
||||
2. `npm run sync:error-codes` 실행 → 클라이언트 사전 자동 갱신.
|
||||
3. 세 스택의 코드 목록 테스트에 새 코드를 추가한다.
|
||||
|
||||
규칙:
|
||||
|
||||
- 번호는 **001 부터 순차 부여**하고, 한번 배포된 코드의 **의미는 바꾸지 않는다** (폐기 시 주석으로 `deprecated` 표시, 번호 재사용 금지).
|
||||
- 도메인 접두어는 대문자 스네이크 (`USER`, `ORDER`, `PAYMENT`).
|
||||
- 같은 의미의 실패에 새 코드를 만들지 말고 기존 코드를 재사용한다.
|
||||
|
||||
## 던지는 방법 (backend)
|
||||
|
||||
```typescript
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
// 레지스트리 메시지 그대로 사용
|
||||
throw new BusinessException('RES_001', { context: { userId: id } });
|
||||
|
||||
// 동적 값이 필요할 때만 메시지 재정의 (코드·상태는 레지스트리 값 유지)
|
||||
throw new BusinessException('USER_001', {
|
||||
message: `이미 가입된 이메일입니다. (${email})`,
|
||||
context: { email },
|
||||
});
|
||||
```
|
||||
|
||||
- `context` 는 **로그 전용**이다. 응답 본문에는 포함되지 않으므로 식별자를 자유롭게 넣어도 된다.
|
||||
- Nest 내장 예외(`NotFoundException` 등)를 던져도 필터가 상태 코드로 역매핑하지만, **새 코드에서는 `BusinessException` 을 쓴다** (코드가 명시되어 추적이 쉽다).
|
||||
- `HttpExceptionFilter` 에 문구를 하드코딩하지 않는다 — 레지스트리 조회만 한다.
|
||||
|
||||
## 받는 방법 (client)
|
||||
|
||||
```typescript
|
||||
// frontend — 인터셉터가 ApiError { code, message, traceId } 로 정규화해 reject
|
||||
try {
|
||||
await api.post('/users', payload)
|
||||
} catch (e) {
|
||||
const error = e as ApiError
|
||||
if (error.code === 'USER_001') focusEmailField() // 코드로 분기
|
||||
// 알림은 인터셉터가 이미 처리했으므로 여기서 다시 띄우지 않는다
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// flutter — 인터셉터가 DioException.error 에 ApiException 을 실어준다
|
||||
on DioException catch (e) {
|
||||
final error = e.error as ApiException;
|
||||
if (error.code == 'AUTH_002') redirectToLogin();
|
||||
}
|
||||
```
|
||||
|
||||
- 개별 `try/catch` 에서 **메시지를 새로 만들지 않는다.** 문구는 사전에서만 온다.
|
||||
- 같은 코드의 알림이 연속으로 뜨지 않도록 인터셉터가 중복을 억제한다 — 호출부에서 다시 알림을 띄우지 말 것.
|
||||
|
||||
## 추적 (traceId)
|
||||
|
||||
- 모든 요청에 `x-request-id` 가 부여되고(`RequestIdMiddleware`), 실패 응답의 `error.traceId` 와 서버 로그에 같은 값이 남는다.
|
||||
- 사용자 문의 대응 시 이 값으로 로그를 검색한다. 상세 화면·에러 페이지에 작게 노출하는 것을 권장한다.
|
||||
|
||||
> 응답 래퍼 구조는 [[response-format]], 로그 규칙은 [[logging]] 참조.
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'error_lexicon.dart';
|
||||
|
||||
/// 정규화된 API 예외 — 화면에서는 [code] 로 분기하고 [message] 를 그대로 노출한다.
|
||||
///
|
||||
/// 모든 실패는 반드시 에러 코드를 가진다(코드 없는 에러를 만들 수 없도록 필수 인자).
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.traceId,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
/// 에러 코드 (예: AUTH_001, RES_001)
|
||||
final String code;
|
||||
|
||||
/// 사용자에게 노출할 한국어 메시지
|
||||
final String message;
|
||||
|
||||
/// 서버 요청 추적 ID — 문의 대응 시 로그 검색 키
|
||||
final String? traceId;
|
||||
|
||||
/// 원본 HTTP 상태 코드 (네트워크 오류 등은 null)
|
||||
final int? statusCode;
|
||||
|
||||
/// 코드만으로 예외를 만든다(메시지는 사전에서 조회).
|
||||
factory ApiException.fromCode(
|
||||
String code, {
|
||||
String? traceId,
|
||||
int? statusCode,
|
||||
}) {
|
||||
return ApiException(
|
||||
code: code,
|
||||
message: ErrorLexicon.getMessage(code),
|
||||
traceId: traceId,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code): $message';
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'error_lexicon.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
|
||||
const String kJwtTokenKey = 'jwt_token';
|
||||
|
||||
const Duration _kConnectTimeout = Duration(seconds: 15);
|
||||
const Duration _kReceiveTimeout = Duration(seconds: 15);
|
||||
|
||||
class DioClient {
|
||||
static final DioClient _instance = DioClient._internal();
|
||||
@@ -18,15 +25,15 @@ class DioClient {
|
||||
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
connectTimeout: _kConnectTimeout,
|
||||
receiveTimeout: _kReceiveTimeout,
|
||||
));
|
||||
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
|
||||
final token = await _storage.read(key: 'jwt_token');
|
||||
final token = await _storage.read(key: kJwtTokenKey);
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -35,61 +42,107 @@ class DioClient {
|
||||
onResponse: (response, handler) {
|
||||
final payload = response.data;
|
||||
|
||||
if (payload is Map<String, dynamic> && payload.containsKey('success')) {
|
||||
if (payload is Map<String, dynamic> &&
|
||||
payload.containsKey('success')) {
|
||||
if (payload['success'] == true) {
|
||||
// Global Response Wrapper: 데이터 Unwrap
|
||||
response.data = payload['data'];
|
||||
return handler.next(response);
|
||||
} else {
|
||||
// HTTP 200 OK 하에서의 비즈니스 실패 로직
|
||||
final errorCode = payload['error']?['code'] ?? 'BIZ_001';
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
error: ErrorLexicon.getMessage(errorCode),
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
|
||||
final error = _readErrorObject(payload);
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
error: ApiException.fromCode(
|
||||
error.code ?? 'BIZ_001',
|
||||
traceId: error.traceId,
|
||||
statusCode: response.statusCode,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
}
|
||||
return handler.next(response);
|
||||
},
|
||||
onError: (DioException e, handler) {
|
||||
String errorCode = 'SYS_001';
|
||||
onError: (DioException e, handler) async {
|
||||
final apiException = await _toApiException(e);
|
||||
|
||||
if (e.response != null && e.response?.data is Map<String, dynamic>) {
|
||||
final payload = e.response!.data as Map<String, dynamic>;
|
||||
|
||||
// 1. 서버에서 표준 에러 객체를 보낸 경우
|
||||
if (payload['error'] != null && payload['error']['code'] != null) {
|
||||
errorCode = payload['error']['code'];
|
||||
} else {
|
||||
// 2. 서버 에러 객체가 없는 경우 HTTP 상태 코드 기반 매핑
|
||||
final statusCode = e.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
errorCode = 'AUTH_001';
|
||||
} else if (statusCode == 403) {
|
||||
errorCode = 'AUTH_003';
|
||||
} else if (statusCode == 400 || statusCode == 422) {
|
||||
errorCode = 'VAL_001';
|
||||
} else if (statusCode == 404) {
|
||||
errorCode = 'RES_001';
|
||||
}
|
||||
}
|
||||
} else if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
errorCode = 'SYS_001';
|
||||
}
|
||||
|
||||
final errorMessage = ErrorLexicon.getMessage(errorCode);
|
||||
|
||||
// User-Friendly Error UI를 위해 에러 메시지 덮어쓰기
|
||||
final customError = e.copyWith(error: errorMessage);
|
||||
return handler.next(customError);
|
||||
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
|
||||
return handler.next(e.copyWith(error: apiException));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
|
||||
Future<ApiException> _toApiException(DioException e) async {
|
||||
final response = e.response;
|
||||
|
||||
if (response == null) {
|
||||
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
|
||||
return ApiException.fromCode('SYS_001');
|
||||
}
|
||||
|
||||
String? traceId;
|
||||
String? code;
|
||||
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final error = _readErrorObject(response.data as Map<String, dynamic>);
|
||||
code = error.code;
|
||||
traceId = error.traceId;
|
||||
}
|
||||
|
||||
code ??= _resolveCodeByStatus(response.statusCode);
|
||||
|
||||
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
|
||||
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
|
||||
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
|
||||
await _storage.delete(key: kJwtTokenKey);
|
||||
code = 'AUTH_002';
|
||||
}
|
||||
|
||||
return ApiException.fromCode(
|
||||
code,
|
||||
traceId: traceId,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
|
||||
String _resolveCodeByStatus(int? statusCode) {
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
return 'AUTH_001';
|
||||
case 403:
|
||||
return 'AUTH_003';
|
||||
case 400:
|
||||
case 422:
|
||||
return 'VAL_001';
|
||||
case 404:
|
||||
return 'RES_001';
|
||||
case 429:
|
||||
return 'REQ_001';
|
||||
default:
|
||||
return 'SYS_001';
|
||||
}
|
||||
}
|
||||
|
||||
/// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다.
|
||||
({String? code, String? traceId}) _readErrorObject(
|
||||
Map<String, dynamic> payload,
|
||||
) {
|
||||
final error = payload['error'];
|
||||
if (error is! Map<String, dynamic>) return (code: null, traceId: null);
|
||||
|
||||
final code = error['code'];
|
||||
final traceId = error['traceId'];
|
||||
return (
|
||||
code: code is String ? code : null,
|
||||
traceId: traceId is String ? traceId : null,
|
||||
);
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
class ErrorLexicon {
|
||||
static const Map<String, String> codes = {
|
||||
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
};
|
||||
import 'error_lexicon.g.dart';
|
||||
|
||||
static String getMessage(String code) {
|
||||
return codes[code] ?? codes['SYS_001']!;
|
||||
}
|
||||
/// 에러 코드 → 사용자 노출 메시지 사전.
|
||||
///
|
||||
/// 실제 문구는 backend 레지스트리에서 생성된 [kErrorCodeMessages] 가 SSOT 이며,
|
||||
/// 이 파일에 문구를 직접 추가하지 않는다.
|
||||
/// (문구 변경: backend/src/common/errors/error-codes.ts 수정 → npm run sync:error-codes)
|
||||
class ErrorLexicon {
|
||||
const ErrorLexicon._();
|
||||
|
||||
static const Map<String, String> codes = kErrorCodeMessages;
|
||||
|
||||
/// 등록된 코드인지 확인한다.
|
||||
static bool contains(String code) => codes.containsKey(code);
|
||||
|
||||
/// 코드에 해당하는 메시지를 반환한다. 미등록 코드는 폴백 메시지를 반환한다.
|
||||
static String getMessage(String code) =>
|
||||
codes[code] ?? codes[kFallbackErrorCode]!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
|
||||
|
||||
const Map<String, String> kErrorCodeMessages = {
|
||||
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
const String kFallbackErrorCode = 'SYS_001';
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/api_exception.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
|
||||
void main() {
|
||||
group('ErrorLexicon', () {
|
||||
test('등록된 코드는 해당 한국어 메시지를 반환한다', () {
|
||||
expect(
|
||||
ErrorLexicon.getMessage('AUTH_001'),
|
||||
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
test('미등록 코드는 SYS_001 폴백 메시지를 반환한다', () {
|
||||
expect(
|
||||
ErrorLexicon.getMessage('UNKNOWN_999'),
|
||||
ErrorLexicon.getMessage('SYS_001'),
|
||||
);
|
||||
});
|
||||
|
||||
test('세 플랫폼 공유 에러 코드가 모두 등록되어 있다', () {
|
||||
// backend error-codes.ts 레지스트리와 동일한 목록이어야 한다.
|
||||
const expected = [
|
||||
'SYS_001',
|
||||
'REQ_001',
|
||||
'AUTH_001',
|
||||
'AUTH_002',
|
||||
'AUTH_003',
|
||||
'VAL_001',
|
||||
'RES_001',
|
||||
'BIZ_001',
|
||||
];
|
||||
|
||||
expect(ErrorLexicon.codes.keys.toSet(), expected.toSet());
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiException', () {
|
||||
test('코드만으로 생성하면 사전의 메시지가 채워진다', () {
|
||||
final exception = ApiException.fromCode('RES_001', statusCode: 404);
|
||||
|
||||
expect(exception.code, 'RES_001');
|
||||
expect(exception.message, ErrorLexicon.getMessage('RES_001'));
|
||||
expect(exception.statusCode, 404);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ERROR_CODE_MESSAGES,
|
||||
FALLBACK_ERROR_CODE,
|
||||
} from '@/shared/constants/error-codes.generated'
|
||||
import { getErrorMessage } from './useApi'
|
||||
|
||||
describe('에러 코드 사전', () => {
|
||||
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
|
||||
expect(getErrorMessage('AUTH_001')).toBe(
|
||||
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
it('미등록 코드는 SYS_001 폴백 메시지를 반환한다', () => {
|
||||
expect(getErrorMessage('UNKNOWN_999')).toBe(
|
||||
ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE],
|
||||
)
|
||||
})
|
||||
|
||||
it('세 플랫폼 공유 에러 코드가 모두 등록되어 있다', () => {
|
||||
// backend error-codes.ts 레지스트리와 동일한 목록이어야 한다.
|
||||
const expected = [
|
||||
'SYS_001',
|
||||
'REQ_001',
|
||||
'AUTH_001',
|
||||
'AUTH_002',
|
||||
'AUTH_003',
|
||||
'VAL_001',
|
||||
'RES_001',
|
||||
'BIZ_001',
|
||||
]
|
||||
|
||||
expect(Object.keys(ERROR_CODE_MESSAGES).sort()).toEqual(expected.sort())
|
||||
})
|
||||
|
||||
it('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () => {
|
||||
for (const code of Object.keys(ERROR_CODE_MESSAGES)) {
|
||||
expect(code).toMatch(/^[A-Z]+_\d{3}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,37 +4,63 @@ import axios, {
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
|
||||
import {
|
||||
ERROR_CODE_MESSAGES,
|
||||
FALLBACK_ERROR_CODE,
|
||||
type ErrorCode,
|
||||
} from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor 와 동일 구조
|
||||
export interface StandardResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
/** 서버 요청 추적 ID — 문의 대응 시 로그 검색 키 */
|
||||
traceId?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응)
|
||||
const ErrorCodeLexicon: Record<string, string> & { SYS_001: string } = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
/** 인터셉터가 reject 할 때 넘기는 정규화된 에러 — 호출부는 code 로 분기한다. */
|
||||
export interface ApiError {
|
||||
code: ErrorCode
|
||||
message: string
|
||||
traceId?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
const showErrorUI = (message: string) => {
|
||||
const API_TIMEOUT_MS = 10000
|
||||
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
|
||||
const DUPLICATE_ALERT_WINDOW_MS = 3000
|
||||
|
||||
function isErrorCode(code: string): code is ErrorCode {
|
||||
return code in ERROR_CODE_MESSAGES
|
||||
}
|
||||
|
||||
/** 에러 코드 → 사용자 노출 문구. 사용자 문구는 이 사전에만 존재한다. */
|
||||
export function getErrorMessage(code: string): string {
|
||||
return isErrorCode(code)
|
||||
? ERROR_CODE_MESSAGES[code]
|
||||
: ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE]
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수 하나만 교체한다.
|
||||
let lastAlert: { code: string; at: number } | null = null
|
||||
const showErrorUI = (code: ErrorCode, message: string) => {
|
||||
const now = Date.now()
|
||||
if (lastAlert && lastAlert.code === code && now - lastAlert.at < DUPLICATE_ALERT_WINDOW_MS) {
|
||||
return
|
||||
}
|
||||
lastAlert = { code, at: now }
|
||||
window.alert(message)
|
||||
}
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
timeout: API_TIMEOUT_MS,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
@@ -45,6 +71,41 @@ api.interceptors.request.use(
|
||||
(error: unknown) => Promise.reject(error),
|
||||
)
|
||||
|
||||
/** HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우) */
|
||||
function resolveCodeByStatus(status: number): ErrorCode {
|
||||
if (status === 401) return 'AUTH_001'
|
||||
if (status === 403) return 'AUTH_003'
|
||||
if (status === 400 || status === 422) return 'VAL_001'
|
||||
if (status === 404) return 'RES_001'
|
||||
if (status === 429) return 'REQ_001'
|
||||
return FALLBACK_ERROR_CODE
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 세션이 있는 상태에서 401 을 받으면 "세션 만료"로 간주한다.
|
||||
* AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
|
||||
*/
|
||||
function toAuthCode(code: ErrorCode): ErrorCode {
|
||||
if (code !== 'AUTH_001') return code
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isLoggedIn) {
|
||||
authStore.clearAuth()
|
||||
return 'AUTH_002'
|
||||
}
|
||||
return 'AUTH_001'
|
||||
}
|
||||
|
||||
/** 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다. */
|
||||
function handle(rawCode: string, traceId?: string, status?: number): ApiError {
|
||||
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE)
|
||||
const message = getErrorMessage(code)
|
||||
|
||||
showErrorUI(code, message)
|
||||
|
||||
return { code, message, traceId, status }
|
||||
}
|
||||
|
||||
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<StandardResponse>) => {
|
||||
@@ -55,30 +116,24 @@ api.interceptors.response.use(
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(payload.error)
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 (백엔드는 4xx 로 내리지만 방어적으로 처리)
|
||||
return Promise.reject(
|
||||
handle(payload.error?.code ?? 'BIZ_001', payload.error?.traceId, response.status),
|
||||
)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
if (!error?.response) {
|
||||
// 네트워크 단절·타임아웃 — 서버 응답 자체가 없는 경우
|
||||
return Promise.reject(handle(FALLBACK_ERROR_CODE))
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(error)
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
const code = payload?.error?.code ?? resolveCodeByStatus(status)
|
||||
|
||||
return Promise.reject(handle(code, payload?.error?.traceId, status))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
|
||||
|
||||
export const ERROR_CODE_MESSAGES = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
REQ_001: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
/** 매핑 실패 시 사용하는 폴백 코드 */
|
||||
export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'
|
||||
Reference in New Issue
Block a user