feat: API 통합을 위한 기초 인프라 구축

- 네트워크 레이어 구현 (Dio 기반 ApiClient)
- 환경별 설정 관리 시스템 구축
- 의존성 주입 설정 (GetIt)
- API 엔드포인트 상수 정의
- 인터셉터 구현 (Auth, Error, Logging)
- 프로젝트 아키텍처 개선 (core, data, di 디렉토리 구조)
- API 통합 계획서 및 요구사항 문서 작성
- 필요 패키지 추가 (dio, flutter_secure_storage, get_it 등)
This commit is contained in:
JiWoong Sul
2025-07-24 14:54:28 +09:00
parent e0bc5894b2
commit 2b31d3af5f
29 changed files with 3542 additions and 344 deletions

View File

@@ -0,0 +1,212 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../../core/config/environment.dart';
import 'interceptors/auth_interceptor.dart';
import 'interceptors/error_interceptor.dart';
import 'interceptors/logging_interceptor.dart';
/// API 클라이언트 클래스
class ApiClient {
late final Dio _dio;
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() => _instance;
ApiClient._internal() {
_dio = Dio(_baseOptions);
_setupInterceptors();
}
/// Dio 인스턴스 getter
Dio get dio => _dio;
/// 기본 옵션 설정
BaseOptions get _baseOptions => BaseOptions(
baseUrl: Environment.apiBaseUrl,
connectTimeout: Duration(milliseconds: Environment.apiTimeout),
receiveTimeout: Duration(milliseconds: Environment.apiTimeout),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
validateStatus: (status) {
return status != null && status < 500;
},
);
/// 인터셉터 설정
void _setupInterceptors() {
_dio.interceptors.clear();
// 인증 인터셉터
_dio.interceptors.add(AuthInterceptor());
// 에러 처리 인터셉터
_dio.interceptors.add(ErrorInterceptor());
// 로깅 인터셉터 (개발 환경에서만)
if (Environment.enableLogging && kDebugMode) {
_dio.interceptors.add(LoggingInterceptor());
}
}
/// 토큰 업데이트
void updateAuthToken(String token) {
_dio.options.headers['Authorization'] = 'Bearer $token';
}
/// 토큰 제거
void removeAuthToken() {
_dio.options.headers.remove('Authorization');
}
/// GET 요청
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) {
return _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
}
/// POST 요청
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// PUT 요청
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// PATCH 요청
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) {
return _dio.patch<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
}
/// DELETE 요청
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// 파일 업로드
Future<Response<T>> uploadFile<T>(
String path, {
required String filePath,
required String fileFieldName,
Map<String, dynamic>? additionalData,
ProgressCallback? onSendProgress,
CancelToken? cancelToken,
}) async {
final fileName = filePath.split('/').last;
final formData = FormData.fromMap({
fileFieldName: await MultipartFile.fromFile(
filePath,
filename: fileName,
),
...?additionalData,
});
return _dio.post<T>(
path,
data: formData,
options: Options(
headers: {
'Content-Type': 'multipart/form-data',
},
),
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
}
/// 파일 다운로드
Future<Response> downloadFile(
String path, {
required String savePath,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
Map<String, dynamic>? queryParameters,
}) {
return _dio.download(
path,
savePath,
queryParameters: queryParameters,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
options: Options(
responseType: ResponseType.bytes,
followRedirects: false,
),
);
}
}

View File

@@ -0,0 +1,122 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/constants/api_endpoints.dart';
/// 인증 인터셉터
class AuthInterceptor extends Interceptor {
final _storage = const FlutterSecureStorage();
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// 로그인, 토큰 갱신 요청은 토큰 없이 진행
if (_isAuthEndpoint(options.path)) {
handler.next(options);
return;
}
// 저장된 액세스 토큰 가져오기
final accessToken = await _storage.read(key: AppConstants.accessTokenKey);
if (accessToken != null) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// 401 Unauthorized 에러 처리
if (err.response?.statusCode == 401) {
// 토큰 갱신 시도
final refreshSuccess = await _refreshToken();
if (refreshSuccess) {
// 새로운 토큰으로 원래 요청 재시도
try {
final newAccessToken = await _storage.read(key: AppConstants.accessTokenKey);
if (newAccessToken != null) {
err.requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
final response = await Dio().fetch(err.requestOptions);
handler.resolve(response);
return;
}
} catch (e) {
// 재시도 실패
handler.next(err);
return;
}
}
// 토큰 갱신 실패 시 로그인 화면으로 이동
await _clearTokens();
// TODO: Navigate to login screen
}
handler.next(err);
}
/// 토큰 갱신
Future<bool> _refreshToken() async {
try {
final refreshToken = await _storage.read(key: AppConstants.refreshTokenKey);
if (refreshToken == null) {
return false;
}
final dio = Dio();
final response = await dio.post(
'${dio.options.baseUrl}${ApiEndpoints.refresh}',
data: {
'refresh_token': refreshToken,
},
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data;
// 새로운 토큰 저장
await _storage.write(
key: AppConstants.accessTokenKey,
value: data['access_token'],
);
if (data['refresh_token'] != null) {
await _storage.write(
key: AppConstants.refreshTokenKey,
value: data['refresh_token'],
);
}
return true;
}
return false;
} catch (e) {
return false;
}
}
/// 토큰 삭제
Future<void> _clearTokens() async {
await _storage.delete(key: AppConstants.accessTokenKey);
await _storage.delete(key: AppConstants.refreshTokenKey);
}
/// 인증 관련 엔드포인트 확인
bool _isAuthEndpoint(String path) {
return path.contains(ApiEndpoints.login) ||
path.contains(ApiEndpoints.refresh) ||
path.contains(ApiEndpoints.logout);
}
}

View File

@@ -0,0 +1,253 @@
import 'dart:io';
import 'package:dio/dio.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/constants/app_constants.dart';
/// 에러 처리 인터셉터
class ErrorInterceptor extends Interceptor {
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
// 에러 타입에 따른 처리
switch (err.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: NetworkException(
message: AppConstants.timeoutError,
),
type: err.type,
),
);
break;
case DioExceptionType.connectionError:
if (err.error is SocketException) {
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: NetworkException(
message: AppConstants.networkError,
),
type: err.type,
),
);
} else {
handler.reject(err);
}
break;
case DioExceptionType.badResponse:
_handleBadResponse(err, handler);
break;
case DioExceptionType.cancel:
handler.reject(err);
break;
case DioExceptionType.badCertificate:
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: NetworkException(
message: '보안 인증서 오류가 발생했습니다.',
),
type: err.type,
),
);
break;
case DioExceptionType.unknown:
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: ServerException(
message: AppConstants.unknownError,
),
type: err.type,
),
);
break;
}
}
/// 잘못된 응답 처리
void _handleBadResponse(
DioException err,
ErrorInterceptorHandler handler,
) {
final statusCode = err.response?.statusCode;
final data = err.response?.data;
String message = AppConstants.serverError;
Map<String, dynamic>? errors;
// API 응답에서 에러 메시지 추출
if (data != null) {
if (data is Map) {
message = data['message'] ?? data['error'] ?? message;
errors = data['errors'] as Map<String, dynamic>?;
} else if (data is String) {
message = data;
}
}
// 상태 코드별 예외 처리
switch (statusCode) {
case 400:
// Bad Request - 유효성 검사 실패
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: ValidationException(
message: message,
fieldErrors: _parseFieldErrors(errors),
),
type: err.type,
response: err.response,
),
);
break;
case 401:
// Unauthorized - 인증 실패
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: UnauthorizedException(
message: AppConstants.unauthorizedError,
),
type: err.type,
response: err.response,
),
);
break;
case 403:
// Forbidden - 권한 부족
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: ForbiddenException(
message: '해당 작업을 수행할 권한이 없습니다.',
),
type: err.type,
response: err.response,
),
);
break;
case 404:
// Not Found - 리소스 없음
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: NotFoundException(
message: message,
),
type: err.type,
response: err.response,
),
);
break;
case 409:
// Conflict - 중복 리소스
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: DuplicateException(
message: message,
),
type: err.type,
response: err.response,
),
);
break;
case 422:
// Unprocessable Entity - 비즈니스 로직 오류
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: BusinessException(
message: message,
code: data?['code'],
),
type: err.type,
response: err.response,
),
);
break;
case 429:
// Too Many Requests
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: NetworkException(
message: '너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요.',
),
type: err.type,
response: err.response,
),
);
break;
case 500:
case 502:
case 503:
case 504:
// Server Error
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: ServerException(
message: AppConstants.serverError,
statusCode: statusCode,
),
type: err.type,
response: err.response,
),
);
break;
default:
handler.reject(
DioException(
requestOptions: err.requestOptions,
error: ServerException(
message: message,
statusCode: statusCode,
errors: errors,
),
type: err.type,
response: err.response,
),
);
break;
}
}
/// 필드 에러 파싱
Map<String, List<String>>? _parseFieldErrors(Map<String, dynamic>? errors) {
if (errors == null) return null;
final fieldErrors = <String, List<String>>{};
errors.forEach((key, value) {
if (value is List) {
fieldErrors[key] = value.map((e) => e.toString()).toList();
} else if (value is String) {
fieldErrors[key] = [value];
}
});
return fieldErrors.isNotEmpty ? fieldErrors : null;
}
}

View File

@@ -0,0 +1,177 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 로깅 인터셉터
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
debugPrint('╔════════════════════════════════════════════════════════════');
debugPrint('║ REQUEST');
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('${options.method} ${options.uri}');
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Headers:');
options.headers.forEach((key, value) {
// 민감한 정보 마스킹
if (key.toLowerCase() == 'authorization') {
debugPrint('$key: ${_maskToken(value.toString())}');
} else {
debugPrint('$key: $value');
}
});
if (options.queryParameters.isNotEmpty) {
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Query Parameters:');
options.queryParameters.forEach((key, value) {
debugPrint('$key: $value');
});
}
if (options.data != null) {
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Request Body:');
try {
final formattedData = _formatJson(options.data);
formattedData.split('\n').forEach((line) {
debugPrint('$line');
});
} catch (e) {
debugPrint('${options.data}');
}
}
debugPrint('╚════════════════════════════════════════════════════════════');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final requestTime = response.requestOptions.extra['requestTime'] as DateTime?;
final responseTime = DateTime.now();
final duration = requestTime != null
? responseTime.difference(requestTime).inMilliseconds
: null;
debugPrint('╔════════════════════════════════════════════════════════════');
debugPrint('║ RESPONSE');
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('${response.requestOptions.method} ${response.requestOptions.uri}');
debugPrint('║ Status: ${response.statusCode} ${response.statusMessage}');
if (duration != null) {
debugPrint('║ Duration: ${duration}ms');
}
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Headers:');
response.headers.forEach((key, values) {
debugPrint('$key: ${values.join(', ')}');
});
if (response.data != null) {
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Response Body:');
try {
final formattedData = _formatJson(response.data);
final lines = formattedData.split('\n');
// 너무 긴 응답은 일부만 출력
if (lines.length > 50) {
lines.take(25).forEach((line) {
debugPrint('$line');
});
debugPrint('║ ... (${lines.length - 50} lines omitted) ...');
lines.skip(lines.length - 25).forEach((line) {
debugPrint('$line');
});
} else {
lines.forEach((line) {
debugPrint('$line');
});
}
} catch (e) {
debugPrint('${response.data}');
}
}
debugPrint('╚════════════════════════════════════════════════════════════');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
debugPrint('╔════════════════════════════════════════════════════════════');
debugPrint('║ ERROR');
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('${err.requestOptions.method} ${err.requestOptions.uri}');
debugPrint('║ Error Type: ${err.type}');
debugPrint('║ Error Message: ${err.message}');
if (err.response != null) {
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Status: ${err.response!.statusCode} ${err.response!.statusMessage}');
if (err.response!.data != null) {
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Error Response:');
try {
final formattedData = _formatJson(err.response!.data);
formattedData.split('\n').forEach((line) {
debugPrint('$line');
});
} catch (e) {
debugPrint('${err.response!.data}');
}
}
}
if (err.error != null) {
debugPrint('╟────────────────────────────────────────────────────────────');
debugPrint('║ Original Error: ${err.error}');
}
debugPrint('╚════════════════════════════════════════════════════════════');
handler.next(err);
}
/// JSON 포맷팅
String _formatJson(dynamic data) {
try {
if (data is String) {
final parsed = json.decode(data);
return const JsonEncoder.withIndent(' ').convert(parsed);
} else {
return const JsonEncoder.withIndent(' ').convert(data);
}
} catch (e) {
return data.toString();
}
}
/// 토큰 마스킹
String _maskToken(String token) {
if (token.length <= 20) {
return '***MASKED***';
}
// Bearer 프리픽스 처리
if (token.startsWith('Bearer ')) {
final actualToken = token.substring(7);
if (actualToken.length > 20) {
return 'Bearer ${actualToken.substring(0, 10)}...${actualToken.substring(actualToken.length - 10)}';
}
return 'Bearer ***MASKED***';
}
return '${token.substring(0, 10)}...${token.substring(token.length - 10)}';
}
}
/// 요청 시간 측정을 위한 확장
extension RequestOptionsExtension on RequestOptions {
void setRequestTime() {
extra['requestTime'] = DateTime.now();
}
}