전역 구조 리팩터링 및 테스트 확장

This commit is contained in:
JiWoong Sul
2025-09-29 01:51:47 +09:00
parent c00c0c9ab2
commit fef7108479
70 changed files with 7709 additions and 3185 deletions

View File

@@ -2,18 +2,23 @@
import 'package:dio/dio.dart';
import 'api_error.dart';
/// 공통 API 클라이언트 (Dio 래퍼)
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
class ApiClient {
ApiClient({required Dio dio, ApiErrorMapper? errorMapper})
: _dio = dio,
_errorMapper = errorMapper ?? const ApiErrorMapper();
final Dio _dio;
final ApiErrorMapper _errorMapper;
/// 내부에서 사용하는 Dio 인스턴스
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
Dio get dio => _dio;
ApiClient({required Dio dio}) : _dio = dio;
/// GET 요청 헬퍼
Future<Response<T>> get<T>(
String path, {
@@ -21,7 +26,14 @@ class ApiClient {
Options? options,
CancelToken? cancelToken,
}) {
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken);
return _wrap(
() => _dio.get<T>(
path,
queryParameters: query,
options: options,
cancelToken: cancelToken,
),
);
}
/// POST 요청 헬퍼
@@ -32,7 +44,15 @@ class ApiClient {
Options? options,
CancelToken? cancelToken,
}) {
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
return _wrap(
() => _dio.post<T>(
path,
data: data,
queryParameters: query,
options: options,
cancelToken: cancelToken,
),
);
}
/// PATCH 요청 헬퍼
@@ -43,7 +63,15 @@ class ApiClient {
Options? options,
CancelToken? cancelToken,
}) {
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
return _wrap(
() => _dio.patch<T>(
path,
data: data,
queryParameters: query,
options: options,
cancelToken: cancelToken,
),
);
}
/// DELETE 요청 헬퍼
@@ -54,7 +82,22 @@ class ApiClient {
Options? options,
CancelToken? cancelToken,
}) {
return _dio.delete<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
return _wrap(
() => _dio.delete<T>(
path,
data: data,
queryParameters: query,
options: options,
cancelToken: cancelToken,
),
);
}
Future<Response<T>> _wrap<T>(Future<Response<T>> Function() request) async {
try {
return await request();
} on DioException catch (error) {
throw _errorMapper.map(error);
}
}
}

View File

@@ -0,0 +1,148 @@
import 'package:dio/dio.dart';
enum ApiErrorCode {
badRequest,
unauthorized,
notFound,
conflict,
unprocessableEntity,
network,
timeout,
cancel,
unknown,
}
class ApiException implements Exception {
const ApiException({
required this.code,
required this.message,
this.statusCode,
this.details,
this.cause,
});
final ApiErrorCode code;
final String message;
final int? statusCode;
final Map<String, dynamic>? details;
final DioException? cause;
@override
String toString() =>
'ApiException(code: $code, statusCode: $statusCode, message: $message)';
}
class ApiErrorMapper {
const ApiErrorMapper();
ApiException map(DioException error) {
final status = error.response?.statusCode;
final data = error.response?.data;
final message = _resolveMessage(error, data);
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout) {
return ApiException(
code: ApiErrorCode.timeout,
message: '서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하세요.',
statusCode: status,
cause: error,
);
}
if (error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.badCertificate) {
return ApiException(
code: ApiErrorCode.network,
message: '네트워크 연결에 실패했습니다. 잠시 후 다시 시도하세요.',
statusCode: status,
cause: error,
);
}
if (error.type == DioExceptionType.cancel) {
return ApiException(
code: ApiErrorCode.cancel,
message: '요청이 취소되었습니다.',
statusCode: status,
cause: error,
);
}
if (status != null) {
final details = _extractDetails(data);
switch (status) {
case 400:
return ApiException(
code: ApiErrorCode.badRequest,
message: message,
statusCode: status,
details: details,
cause: error,
);
case 401:
return ApiException(
code: ApiErrorCode.unauthorized,
message: '세션이 만료되었습니다. 다시 로그인해 주세요.',
statusCode: status,
cause: error,
);
case 404:
return ApiException(
code: ApiErrorCode.notFound,
message: '요청한 리소스를 찾을 수 없습니다.',
statusCode: status,
cause: error,
);
case 409:
return ApiException(
code: ApiErrorCode.conflict,
message: message,
statusCode: status,
details: details,
cause: error,
);
case 422:
return ApiException(
code: ApiErrorCode.unprocessableEntity,
message: message,
statusCode: status,
details: details,
cause: error,
);
default:
break;
}
}
return ApiException(
code: ApiErrorCode.unknown,
message: message,
statusCode: status,
cause: error,
);
}
String _resolveMessage(DioException error, dynamic data) {
if (data is Map<String, dynamic>) {
final message = data['message'] ?? data['error'];
if (message is String && message.isNotEmpty) {
return message;
}
} else if (data is String && data.isNotEmpty) {
return data;
}
return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
}
Map<String, dynamic>? _extractDetails(dynamic data) {
if (data is Map<String, dynamic>) {
final errors = data['errors'];
if (errors is Map<String, dynamic>) {
return errors;
}
}
return null;
}
}

View File

@@ -1,29 +1,124 @@
// ignore_for_file: public_member_api_docs
import 'dart:async';
import 'package:dio/dio.dart';
/// 인증 인터셉터(스켈레톤)
import '../../services/token_storage.dart';
typedef RefreshTokenCallback = Future<TokenPair?> Function();
class TokenPair {
const TokenPair({required this.accessToken, required this.refreshToken});
final String accessToken;
final String refreshToken;
}
/// 인증 인터셉터
/// - 요청 전에 Authorization 헤더 주입
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도
class AuthInterceptor extends Interceptor {
/// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository)
AuthInterceptor();
AuthInterceptor({
required TokenStorage tokenStorage,
required Dio dio,
this.onRefresh,
}) : _tokenStorage = tokenStorage,
_dio = dio;
final TokenStorage _tokenStorage;
final Dio _dio;
final RefreshTokenCallback? onRefresh;
final List<Completer<void>> _refreshQueue = [];
bool _isRefreshing = false;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다.
// final token = await _authRepository.getToken();
// if (token != null && token.isNotEmpty) {
// options.headers['Authorization'] = 'Bearer $token';
// }
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _tokenStorage.readAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
// if (err.response?.statusCode == 401) { ... }
handler.next(err);
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (!_shouldAttemptRefresh(err)) {
handler.next(err);
return;
}
try {
await _refreshToken();
final response = await _retry(err.requestOptions);
handler.resolve(response);
} on _RefreshFailedException {
await _tokenStorage.clear();
handler.next(err);
} on DioException catch (e) {
handler.next(e);
} catch (_) {
handler.next(err);
}
}
bool _shouldAttemptRefresh(DioException err) {
return onRefresh != null &&
err.response?.statusCode == 401 &&
err.requestOptions.extra['__retry'] != true;
}
Future<void> _refreshToken() async {
if (_isRefreshing) {
final completer = Completer<void>();
_refreshQueue.add(completer);
return completer.future;
}
_isRefreshing = true;
try {
final callback = onRefresh;
if (callback == null) {
throw const _RefreshFailedException();
}
final pair = await callback();
if (pair == null) {
throw const _RefreshFailedException();
}
await _tokenStorage.writeAccessToken(pair.accessToken);
await _tokenStorage.writeRefreshToken(pair.refreshToken);
} finally {
_isRefreshing = false;
for (final completer in _refreshQueue) {
if (!completer.isCompleted) {
completer.complete();
}
}
_refreshQueue.clear();
}
}
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
final token = await _tokenStorage.readAccessToken();
if (token != null && token.isNotEmpty) {
requestOptions.headers['Authorization'] = 'Bearer $token';
} else {
requestOptions.headers.remove('Authorization');
}
requestOptions.extra['__retry'] = true;
return _dio.fetch(requestOptions);
}
}
class _RefreshFailedException implements Exception {
const _RefreshFailedException();
}