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
+109 -23
View File
@@ -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]] 참조.