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]] 참조.
|
||||
|
||||
Reference in New Issue
Block a user