refactor: Clean Architecture 적용 및 코드베이스 전면 리팩토링
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

## 주요 변경사항

### 아키텍처 개선
- Clean Architecture 패턴 적용 (Domain, Data, Presentation 레이어 분리)
- Use Case 패턴 도입으로 비즈니스 로직 캡슐화
- Repository 패턴으로 데이터 접근 추상화
- 의존성 주입 구조 개선

### 상태 관리 최적화
- 모든 Controller에서 불필요한 상태 관리 로직 제거
- 페이지네이션 로직 통일 및 간소화
- 에러 처리 로직 개선 (에러 메시지 한글화)
- 로딩 상태 관리 최적화

### Mock 서비스 제거
- MockDataService 완전 제거
- 모든 화면을 실제 API 전용으로 전환
- 불필요한 Mock 관련 코드 정리

### UI/UX 개선
- Overview 화면 대시보드 기능 강화
- 라이선스 만료 알림 위젯 추가
- 사이드바 네비게이션 개선
- 일관된 UI 컴포넌트 사용

### 코드 품질
- 중복 코드 제거 및 함수 추출
- 파일별 책임 분리 명확화
- 테스트 코드 업데이트

## 영향 범위
- 모든 화면의 Controller 리팩토링
- API 통신 레이어 구조 개선
- 에러 처리 및 로깅 시스템 개선

## 향후 계획
- 단위 테스트 커버리지 확대
- 통합 테스트 시나리오 추가
- 성능 모니터링 도구 통합
This commit is contained in:
JiWoong Sul
2025-08-11 00:04:28 +09:00
parent 6b5d126990
commit 162fe08618
113 changed files with 11072 additions and 3319 deletions

View File

@@ -47,28 +47,8 @@ class Environment {
}
}
/// API 사용 여부 (false면 Mock 데이터 사용)
static bool get useApi {
try {
final useApiStr = dotenv.env['USE_API'];
if (enableLogging && kDebugMode) {
debugPrint('[Environment] USE_API 원시값: $useApiStr');
}
if (useApiStr == null || useApiStr.isEmpty) {
if (enableLogging && kDebugMode) {
debugPrint('[Environment] USE_API가 설정되지 않음, 기본값 true 사용');
}
return true;
}
final result = useApiStr.toLowerCase() == 'true';
if (enableLogging && kDebugMode) {
debugPrint('[Environment] USE_API 최종값: $result');
}
return result;
} catch (e) {
return true; // 기본값
}
}
/// API 사용 여부 (Mock 서비스 제거로 항상 true)
static bool get useApi => true;
/// 환경 초기화
static Future<void> initialize([String? environment]) async {
@@ -97,8 +77,6 @@ class Environment {
debugPrint('[Environment] API Base URL: ${dotenv.env['API_BASE_URL'] ?? '설정되지 않음'}');
debugPrint('[Environment] API Timeout: ${dotenv.env['API_TIMEOUT'] ?? '설정되지 않음'}');
debugPrint('[Environment] 로깅 활성화: ${dotenv.env['ENABLE_LOGGING'] ?? '설정되지 않음'}');
debugPrint('[Environment] API 사용 (원시값): ${dotenv.env['USE_API'] ?? '설정되지 않음'}');
debugPrint('[Environment] API 사용 (getter): $useApi');
}
} catch (e) {
if (kDebugMode) {

View File

@@ -5,6 +5,30 @@ class AppConstants {
static const int maxPageSize = 100;
static const Duration cacheTimeout = Duration(minutes: 5);
// API 타임아웃
static const Duration apiConnectTimeout = Duration(seconds: 30);
static const Duration apiReceiveTimeout = Duration(seconds: 30);
static const Duration healthCheckTimeout = Duration(seconds: 10);
static const Duration loginTimeout = Duration(seconds: 10);
// 디바운스 시간
static const Duration searchDebounce = Duration(milliseconds: 500);
static const Duration licenseSearchDebounce = Duration(milliseconds: 300);
// 애니메이션 시간
static const Duration autocompleteAnimation = Duration(milliseconds: 200);
static const Duration formAnimation = Duration(milliseconds: 300);
static const Duration loginAnimation = Duration(milliseconds: 1000);
static const Duration loginSubAnimation = Duration(milliseconds: 800);
// 라이선스 만료 기간
static const int licenseExpiryWarningDays = 30;
static const int licenseExpiryCautionDays = 60;
static const int licenseExpiryInfoDays = 90;
// 헬스체크 주기
static const Duration healthCheckInterval = Duration(seconds: 30);
// 토큰 키
static const String accessTokenKey = 'access_token';
static const String refreshTokenKey = 'refresh_token';

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import '../utils/error_handler.dart';
import '../../data/models/common/pagination_params.dart';
/// 모든 리스트 컨트롤러의 기본 클래스
///
/// 페이지네이션, 검색, 필터, 에러 처리 등 공통 기능을 제공합니다.
/// 개별 컨트롤러는 이 클래스를 상속받아 특화된 로직만 구현하면 됩니다.
abstract class BaseListController<T> extends ChangeNotifier {
/// 전체 아이템 목록
List<T> _items = [];
/// 필터링된 아이템 목록
List<T> _filteredItems = [];
/// 로딩 상태
bool _isLoading = false;
/// 에러 메시지
String? _error;
/// 검색 쿼리
String _searchQuery = '';
/// 현재 페이지 번호
int _currentPage = 1;
/// 페이지당 아이템 수
int _pageSize = 20;
/// 더 많은 데이터가 있는지 여부
bool _hasMore = true;
/// 전체 아이템 수 (서버에서 제공하는 실제 전체 개수)
int _total = 0;
/// 총 페이지 수
int _totalPages = 0;
BaseListController();
// Getters
List<T> get items => _filteredItems;
bool get isLoading => _isLoading;
String? get error => _error;
String get searchQuery => _searchQuery;
int get currentPage => _currentPage;
int get pageSize => _pageSize;
bool get hasMore => _hasMore;
int get total => _total;
int get totalPages => _totalPages;
// Setters
set pageSize(int value) {
_pageSize = value;
notifyListeners();
}
// Protected setters for subclasses
@protected
set isLoadingState(bool value) {
_isLoading = value;
}
@protected
set errorState(String? value) {
_error = value;
}
/// 데이터 로드 (하위 클래스에서 구현)
/// PagedResult를 반환하여 페이지네이션 메타데이터를 포함
Future<PagedResult<T>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
});
/// 아이템 필터링 (하위 클래스에서 선택적으로 오버라이드)
bool filterItem(T item, String query) {
// 기본 구현: toString()을 사용한 간단한 필터링
return item.toString().toLowerCase().contains(query.toLowerCase());
}
/// 데이터 로드 및 관리
Future<void> loadData({
bool isRefresh = false,
Map<String, dynamic>? additionalFilters,
}) async {
if (_isLoading) return;
if (isRefresh) {
_currentPage = 1;
_items.clear();
_filteredItems.clear();
_hasMore = true;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
// PaginationParams 생성
final params = PaginationParams(
page: _currentPage,
perPage: _pageSize,
search: _searchQuery.isNotEmpty ? _searchQuery : null,
);
// 데이터 가져오기
final result = await fetchData(
params: params,
additionalFilters: additionalFilters,
);
if (isRefresh) {
_items = result.items;
} else {
_items.addAll(result.items);
}
// 메타데이터 업데이트
_total = result.meta.total;
_totalPages = result.meta.totalPages;
_hasMore = result.meta.hasNext;
_applyFiltering();
if (!isRefresh && result.items.isNotEmpty) {
_currentPage++;
}
} catch (e) {
if (e is AppFailure) {
_error = ErrorHandler.getUserFriendlyMessage(e);
} else {
_error = '데이터를 불러오는 중 오류가 발생했습니다.';
}
print('[BaseListController] Error loading data: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 다음 페이지 로드
Future<void> loadNextPage() async {
if (!_hasMore || _isLoading) return;
await loadData(isRefresh: false);
}
/// 검색
void search(String query) {
_searchQuery = query;
_currentPage = 1;
_applyFiltering();
notifyListeners();
}
/// 필터링 적용
void _applyFiltering() {
if (_searchQuery.isEmpty) {
_filteredItems = List.from(_items);
} else {
_filteredItems = _items.where((item) => filterItem(item, _searchQuery)).toList();
}
}
/// 새로고침
Future<void> refresh() async {
await loadData(isRefresh: true);
}
/// 에러 초기화
void clearError() {
_error = null;
notifyListeners();
}
/// 아이템 추가 (로컬)
void addItemLocally(T item) {
_items.insert(0, item);
_applyFiltering();
_total++;
notifyListeners();
}
/// 아이템 업데이트 (로컬)
void updateItemLocally(T item, bool Function(T) matcher) {
final index = _items.indexWhere(matcher);
if (index != -1) {
_items[index] = item;
_applyFiltering();
notifyListeners();
}
}
/// 아이템 삭제 (로컬)
void removeItemLocally(bool Function(T) matcher) {
_items.removeWhere(matcher);
_applyFiltering();
_total--;
notifyListeners();
}
/// 리소스 정리
@override
void dispose() {
super.dispose();
}
}

View File

@@ -26,12 +26,14 @@ abstract class Failure {
class ServerFailure extends Failure {
final int? statusCode;
final Map<String, dynamic>? errors;
final dynamic originalError;
const ServerFailure({
required super.message,
super.code,
this.statusCode,
this.errors,
this.originalError,
});
}
@@ -45,36 +47,49 @@ class CacheFailure extends Failure {
/// 네트워크 실패
class NetworkFailure extends Failure {
final dynamic originalError;
const NetworkFailure({
required super.message,
super.code,
this.originalError,
});
}
/// 인증 실패
class AuthenticationFailure extends Failure {
final dynamic originalError;
const AuthenticationFailure({
required super.message,
super.code,
this.originalError,
});
}
/// 권한 실패
class AuthorizationFailure extends Failure {
final dynamic originalError;
const AuthorizationFailure({
required super.message,
super.code,
this.originalError,
});
}
/// 유효성 검사 실패
class ValidationFailure extends Failure {
final Map<String, List<String>>? fieldErrors;
final Map<String, dynamic>? errors; // 기존 코드와 호환성을 위해 추가
final dynamic originalError; // 원본 에러 정보
const ValidationFailure({
required super.message,
super.code,
this.fieldErrors,
this.errors,
this.originalError,
});
}
@@ -112,6 +127,23 @@ class BusinessFailure extends Failure {
});
}
/// 알 수 없는 실패
class UnknownFailure extends Failure {
final dynamic originalError;
const UnknownFailure({
required super.message,
super.code,
this.originalError,
});
}
/// AuthFailure는 AuthenticationFailure의 별칭
typedef AuthFailure = AuthenticationFailure;
/// PermissionFailure는 AuthorizationFailure의 별칭
typedef PermissionFailure = AuthorizationFailure;
/// 타입 정의
typedef FutureEither<T> = Future<Either<Failure, T>>;
typedef FutureVoid = FutureEither<void>;

View File

@@ -0,0 +1,289 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 에러 처리 표준화를 위한 유틸리티 클래스
class ErrorHandler {
/// API 호출을 감싸서 일관된 에러 처리를 제공하는 wrapper 함수
///
/// 사용 예시:
/// ```dart
/// final result = await ErrorHandler.handleApiCall(
/// () => apiService.getData(),
/// onError: (failure) => print('Error: ${failure.message}'),
/// );
/// ```
static Future<T?> handleApiCall<T>(
Future<T> Function() apiCall, {
Function(AppFailure)? onError,
bool showErrorDialog = true,
}) async {
try {
return await apiCall();
} on DioException catch (e) {
final failure = _handleDioError(e);
if (onError != null) {
onError(failure);
}
if (kDebugMode) {
print('API Error: ${failure.message}');
print('Stack trace: ${e.stackTrace}');
}
return null;
} on FormatException catch (e) {
final failure = DataParseFailure(
message: '데이터 형식 오류: ${e.message}',
);
if (onError != null) {
onError(failure);
}
return null;
} catch (e, stackTrace) {
final failure = UnexpectedFailure(
message: '예상치 못한 오류가 발생했습니다: ${e.toString()}',
);
if (onError != null) {
onError(failure);
}
if (kDebugMode) {
print('Unexpected Error: $e');
print('Stack trace: $stackTrace');
}
return null;
}
}
/// DioException을 AppFailure로 변환
static AppFailure _handleDioError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return NetworkFailure(
message: '네트워크 연결 시간이 초과되었습니다.',
statusCode: null,
);
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
String message = '서버 오류가 발생했습니다.';
// 서버에서 전달한 에러 메시지가 있으면 사용
if (responseData != null) {
if (responseData is Map && responseData.containsKey('message')) {
message = responseData['message'];
} else if (responseData is String) {
message = responseData;
}
}
// 상태 코드별 메시지 처리
switch (statusCode) {
case 400:
return ValidationFailure(
message: message.isNotEmpty ? message : '잘못된 요청입니다.',
statusCode: statusCode,
);
case 401:
return AuthenticationFailure(
message: '인증이 필요합니다. 다시 로그인해주세요.',
statusCode: statusCode,
);
case 403:
return AuthorizationFailure(
message: '이 작업을 수행할 권한이 없습니다.',
statusCode: statusCode,
);
case 404:
return NotFoundFailure(
message: '요청한 리소스를 찾을 수 없습니다.',
statusCode: statusCode,
);
case 409:
return ConflictFailure(
message: message.isNotEmpty ? message : '중복된 데이터가 있습니다.',
statusCode: statusCode,
);
case 422:
return ValidationFailure(
message: message.isNotEmpty ? message : '입력 데이터가 올바르지 않습니다.',
statusCode: statusCode,
);
case 500:
case 502:
case 503:
case 504:
return ServerFailure(
message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
statusCode: statusCode,
);
default:
return ServerFailure(
message: message,
statusCode: statusCode,
);
}
case DioExceptionType.cancel:
return CancelledFailure(
message: '요청이 취소되었습니다.',
);
case DioExceptionType.connectionError:
return NetworkFailure(
message: '네트워크 연결을 확인해주세요.',
statusCode: null,
);
case DioExceptionType.badCertificate:
return SecurityFailure(
message: '보안 인증서 오류가 발생했습니다.',
);
case DioExceptionType.unknown:
return UnexpectedFailure(
message: error.message ?? '알 수 없는 오류가 발생했습니다.',
);
}
}
/// 에러 메시지를 사용자 친화적인 메시지로 변환
static String getUserFriendlyMessage(AppFailure failure) {
if (failure is NetworkFailure) {
return '네트워크 연결을 확인해주세요.';
} else if (failure is AuthenticationFailure) {
return '로그인이 필요합니다.';
} else if (failure is AuthorizationFailure) {
return '권한이 없습니다.';
} else if (failure is ValidationFailure) {
return failure.message;
} else if (failure is ServerFailure) {
return '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
} else {
return failure.message;
}
}
}
/// 기본 Failure 클래스
abstract class AppFailure {
final String message;
final int? statusCode;
AppFailure({
required this.message,
this.statusCode,
});
@override
String toString() => message;
}
/// 네트워크 관련 실패
class NetworkFailure extends AppFailure {
NetworkFailure({
required super.message,
super.statusCode,
});
}
/// 서버 관련 실패
class ServerFailure extends AppFailure {
ServerFailure({
required super.message,
super.statusCode,
});
}
/// 인증 실패 (401)
class AuthenticationFailure extends AppFailure {
AuthenticationFailure({
required super.message,
super.statusCode,
});
}
/// 권한 실패 (403)
class AuthorizationFailure extends AppFailure {
AuthorizationFailure({
required super.message,
super.statusCode,
});
}
/// 유효성 검증 실패 (400, 422)
class ValidationFailure extends AppFailure {
ValidationFailure({
required super.message,
super.statusCode,
});
}
/// 리소스를 찾을 수 없음 (404)
class NotFoundFailure extends AppFailure {
NotFoundFailure({
required super.message,
super.statusCode,
});
}
/// 충돌 발생 (409)
class ConflictFailure extends AppFailure {
ConflictFailure({
required super.message,
super.statusCode,
});
}
/// 데이터 파싱 실패
class DataParseFailure extends AppFailure {
DataParseFailure({
required super.message,
});
}
/// 요청 취소됨
class CancelledFailure extends AppFailure {
CancelledFailure({
required super.message,
});
}
/// 보안 관련 실패
class SecurityFailure extends AppFailure {
SecurityFailure({
required super.message,
});
}
/// 예상치 못한 실패
class UnexpectedFailure extends AppFailure {
UnexpectedFailure({
required super.message,
});
}
/// 에러 처리 결과를 담는 Result 타입
class Result<T> {
final T? data;
final AppFailure? failure;
Result._({this.data, this.failure});
factory Result.success(T data) => Result._(data: data);
factory Result.failure(AppFailure failure) => Result._(failure: failure);
bool get isSuccess => data != null;
bool get isFailure => failure != null;
R when<R>({
required R Function(T data) success,
required R Function(AppFailure failure) failure,
}) {
if (this.data != null) {
return success(this.data as T);
} else {
return failure(this.failure!);
}
}
}

View File

@@ -48,7 +48,6 @@ class LoginDiagnostics {
/// 환경 설정 확인
static Map<String, dynamic> _checkEnvironment() {
return {
'useApi': env.Environment.useApi,
'apiBaseUrl': env.Environment.apiBaseUrl,
'isDebugMode': kDebugMode,
'platform': defaultTargetPlatform.toString(),
@@ -70,20 +69,18 @@ class LoginDiagnostics {
}
// API 서버 연결 테스트
if (env.Environment.useApi) {
try {
final response = await dio.get(
'${env.Environment.apiBaseUrl}/health',
options: Options(
validateStatus: (status) => status != null && status < 500,
),
);
results['apiServerReachable'] = true;
results['apiServerStatus'] = response.statusCode;
} catch (e) {
results['apiServerReachable'] = false;
results['apiServerError'] = e.toString();
}
try {
final response = await dio.get(
'${env.Environment.apiBaseUrl}/health',
options: Options(
validateStatus: (status) => status != null && status < 500,
),
);
results['apiServerReachable'] = true;
results['apiServerStatus'] = response.statusCode;
} catch (e) {
results['apiServerReachable'] = false;
results['apiServerError'] = e.toString();
}
return results;
@@ -91,9 +88,6 @@ class LoginDiagnostics {
/// API 엔드포인트 확인
static Future<Map<String, dynamic>> _checkApiEndpoint() async {
if (!env.Environment.useApi) {
return {'mode': 'mock', 'skip': true};
}
final dio = Dio();
final results = <String, dynamic>{};

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
import '../../data/models/auth/auth_user.dart';
/// 역할 기반 접근 제어를 위한 AuthGuard 위젯
///
/// 사용자의 역할을 확인하여 허용된 역할만 child 위젯에 접근할 수 있도록 제어합니다.
/// 권한이 없는 경우 UnauthorizedScreen을 표시합니다.
class AuthGuard extends StatelessWidget {
/// 접근을 허용할 역할 목록
final List<String> allowedRoles;
/// 권한이 있을 때 표시할 위젯
final Widget child;
/// 권한이 없을 때 표시할 커스텀 위젯 (선택사항)
final Widget? unauthorizedWidget;
/// 권한 체크를 건너뛸지 여부 (개발 모드용)
final bool skipCheck;
const AuthGuard({
super.key,
required this.allowedRoles,
required this.child,
this.unauthorizedWidget,
this.skipCheck = false,
});
@override
Widget build(BuildContext context) {
// 개발 모드에서 권한 체크 건너뛰기
if (skipCheck) {
return child;
}
// AuthService에서 현재 사용자 정보 가져오기
final authService = context.read<AuthService>();
return FutureBuilder<AuthUser?>(
future: authService.getCurrentUser(),
builder: (context, snapshot) {
// 로딩 중
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// 현재 사용자 정보
final currentUser = snapshot.data;
// 로그인하지 않은 경우
if (currentUser == null) {
return unauthorizedWidget ?? const UnauthorizedScreen(
message: '로그인이 필요합니다.',
);
}
// 역할 확인 - 대소문자 구분 없이 비교
final userRole = currentUser.role.toLowerCase();
final hasPermission = allowedRoles.any(
(role) => role.toLowerCase() == userRole,
);
// 권한이 있는 경우
if (hasPermission) {
return child;
}
// 권한이 없는 경우
return unauthorizedWidget ?? UnauthorizedScreen(
message: '이 페이지에 접근할 권한이 없습니다.',
userRole: currentUser.role,
requiredRoles: allowedRoles,
);
},
);
}
}
/// 권한이 없을 때 표시되는 기본 화면
class UnauthorizedScreen extends StatelessWidget {
final String message;
final String? userRole;
final List<String>? requiredRoles;
const UnauthorizedScreen({
super.key,
required this.message,
this.userRole,
this.requiredRoles,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
padding: const EdgeInsets.all(32),
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.lock_outline,
size: 80,
color: Colors.grey,
),
const SizedBox(height: 24),
Text(
'접근 권한 없음',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
if (userRole != null) ...[
const SizedBox(height: 16),
Text(
'현재 권한: $userRole',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
],
if (requiredRoles != null && requiredRoles!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'필요한 권한: ${requiredRoles!.join(", ")}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
],
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushReplacementNamed('/dashboard');
},
child: const Text('대시보드로 이동'),
),
],
),
),
),
);
}
}
/// 역할 상수 정의
class UserRole {
static const String admin = 'Admin';
static const String manager = 'Manager';
static const String member = 'Member';
/// 관리자 및 매니저 권한
static const List<String> adminAndManager = [admin, manager];
/// 모든 권한
static const List<String> all = [admin, manager, member];
/// 관리자 전용
static const List<String> adminOnly = [admin];
}