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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user