diff --git a/.claude/rules/error-codes.md b/.claude/rules/error-codes.md index d6ef5d0..a8a0e99 100644 --- a/.claude/rules/error-codes.md +++ b/.claude/rules/error-codes.md @@ -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]] 참조. diff --git a/backend/scripts/sync-error-codes.ts b/backend/scripts/sync-error-codes.ts new file mode 100644 index 0000000..f4da218 --- /dev/null +++ b/backend/scripts/sync-error-codes.ts @@ -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 kErrorCodeMessages = {\n${dartBody}\n};\n\n` + + `/// 매핑 실패 시 사용하는 폴백 코드\n` + + `const String kFallbackErrorCode = 'SYS_001';\n`, +); + +console.log(`\n총 ${entries.length}개 에러 코드를 동기화했습니다.`); diff --git a/backend/src/common/errors/business.exception.ts b/backend/src/common/errors/business.exception.ts new file mode 100644 index 0000000..b34653f --- /dev/null +++ b/backend/src/common/errors/business.exception.ts @@ -0,0 +1,33 @@ +import { HttpException } from '@nestjs/common'; + +import { ErrorCode, getErrorDefinition } from './error-codes'; + +export interface BusinessExceptionOptions { + /** 레지스트리 기본 메시지 대신 사용자에게 보여줄 문구 (동적 값이 필요한 경우만) */ + readonly message?: string; + /** 로그에만 남기는 개발자용 컨텍스트 — 응답 본문에는 포함되지 않는다 */ + readonly context?: Record; +} + +/** + * 사용자에게 노출되는 모든 실패는 이 예외로 던진다. + * 에러 코드가 생성자 필수 인자이므로, 코드 없는 에러가 만들어질 수 없다. + * + * 사용 예: + * throw new BusinessException('RES_001'); + * throw new BusinessException('USER_001', { context: { email } }); + */ +export class BusinessException extends HttpException { + readonly code: ErrorCode; + readonly context?: Record; + + 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; + } +} diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts new file mode 100644 index 0000000..b92f411 --- /dev/null +++ b/backend/src/common/errors/error-codes.spec.ts @@ -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' }); + }); +}); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts new file mode 100644 index 0000000..31fe48e --- /dev/null +++ b/backend/src/common/errors/error-codes.ts @@ -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; + +/** + * 도메인 코드 — 기능 추가 시 여기에 `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 = 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; +} diff --git a/backend/src/common/errors/index.ts b/backend/src/common/errors/index.ts new file mode 100644 index 0000000..da55e19 --- /dev/null +++ b/backend/src/common/errors/index.ts @@ -0,0 +1,2 @@ +export * from './error-codes'; +export * from './business.exception'; diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts new file mode 100644 index 0000000..e525218 --- /dev/null +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 224d5a5..f2a5d3d 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -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(); const response = ctx.getResponse(); - // 비교 대상인 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}`); + } } diff --git a/flutter/lib/core/api/api_exception.dart b/flutter/lib/core/api/api_exception.dart new file mode 100644 index 0000000..de03d23 --- /dev/null +++ b/flutter/lib/core/api/api_exception.dart @@ -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'; +} diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 0f899a1..13141bb 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -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 && payload.containsKey('success')) { + if (payload is Map && + 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) { - final payload = e.response!.data as Map; - - // 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 _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) { + final error = _readErrorObject(response.data as Map); + 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 payload, + ) { + final error = payload['error']; + if (error is! Map) 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; } diff --git a/flutter/lib/core/api/error_lexicon.dart b/flutter/lib/core/api/error_lexicon.dart index 423e18f..ce7195d 100644 --- a/flutter/lib/core/api/error_lexicon.dart +++ b/flutter/lib/core/api/error_lexicon.dart @@ -1,15 +1,19 @@ -class ErrorLexicon { - static const Map 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 codes = kErrorCodeMessages; + + /// 등록된 코드인지 확인한다. + static bool contains(String code) => codes.containsKey(code); + + /// 코드에 해당하는 메시지를 반환한다. 미등록 코드는 폴백 메시지를 반환한다. + static String getMessage(String code) => + codes[code] ?? codes[kFallbackErrorCode]!; } diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart new file mode 100644 index 0000000..a68a2d0 --- /dev/null +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -0,0 +1,15 @@ +// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요. + +const Map kErrorCodeMessages = { + 'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + 'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', + 'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + 'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', + 'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', + 'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', + 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', + 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', +}; + +/// 매핑 실패 시 사용하는 폴백 코드 +const String kFallbackErrorCode = 'SYS_001'; diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart new file mode 100644 index 0000000..e43fc31 --- /dev/null +++ b/flutter/test/error_lexicon_test.dart @@ -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); + }); + }); +} diff --git a/frontend/src/composables/useApi.spec.ts b/frontend/src/composables/useApi.spec.ts new file mode 100644 index 0000000..b00f5d8 --- /dev/null +++ b/frontend/src/composables/useApi.spec.ts @@ -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}$/) + } + }) +}) diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts index 8a7e69d..21d74f0 100644 --- a/frontend/src/composables/useApi.ts +++ b/frontend/src/composables/useApi.ts @@ -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 { success: boolean data?: T error?: { code: string message: string + /** 서버 요청 추적 ID — 문의 대응 시 로그 검색 키 */ + traceId?: string } } -// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑 -// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응) -const ErrorCodeLexicon: Record & { 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) => { @@ -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)) }, ) diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts new file mode 100644 index 0000000..8c0e472 --- /dev/null +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -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'