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
+42
View File
@@ -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';
}
+99 -46
View File
@@ -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;
}
+17 -13
View File
@@ -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]!;
}
+15
View File
@@ -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';