안녕하세요, 우기입니다! 오늘부터 Flutter 개발 가이드라인 시리즈의 첫 번째 파트를 시작합니다. 이번 글에서는 모든 Flutter 프로젝트의 근간이 되는 핵심 원칙과 효과적인 프로젝트 구조에 대해 알아보겠습니다.
복잡한 모바일 앱을 개발하다 보면 코드베이스가 빠르게 증가하고, 이를 관리하는 것이 점점 어려워집니다. 특히 팀 단위로 개발할 때는 일관된 코드 스타일과 구조가 없다면 프로젝트는 금세 유지보수하기 어려운 상태가 됩니다.
제가 다양한 규모의 Flutter 프로젝트를 진행하며 깨달은 것은, 명확한 원칙과 잘 설계된 구조가 장기적으로 프로젝트의 성공을 좌우한다는 점입니다. 이 글에서는 제가 실제 프로젝트에서 적용해 효과를 본 원칙들을 공유하려고 합니다.
Flutter 개발에 있어 가장 중요한 네 가지 원칙은 다음과 같습니다:
가독성이란 코드가 얼마나 쉽게 이해될 수 있는지를 의미합니다. 코드는 컴퓨터가 실행하는 것이지만, 결국 사람이 작성하고 유지보수하는 것입니다.
가독성이 좋은 코드의 특징:
예측 가능성은 코드의 동작이 얼마나 예상 가능한지를 의미합니다. 개발자가 코드의 한 부분을 보고 그 동작을 정확히 예측할 수 있어야 합니다.
예측 가능한 코드의 특징:
응집성은 연관된 코드가 함께 유지되는 정도를 의미합니다. 높은 응집성을 가진 모듈은 단일 책임을 가지며, 관련된 기능들이 적절히 그룹화되어 있습니다.
높은 응집성의 특징:
결합도는 코드의 다른 부분 간의 의존성 정도를 의미합니다. 낮은 결합도는 각 부분이 독립적으로 작동할 수 있고, 한 부분의 변경이 다른 부분에 미치는 영향이 최소화됨을 의미합니다.
낮은 결합도의 특징:
Flutter 프로젝트에서 효과적인 코드 구조를 설계하기 위해 기능 중심(feature-first) 접근 방식을, 클린 아키텍처 원칙과 결합하는 것을 추천합니다.
다음은 중규모 이상의 Flutter 프로젝트에 적합한 기본 폴더 구조입니다:
lib/
├── core/ # 핵심 유틸리티 및 공통 기능
│ ├── config/ # 앱 구성, 상수, 환경 설정
│ ├── di/ # 의존성 주입 설정
│ ├── network/ # 네트워크 관련 유틸리티
│ ├── storage/ # 로컬 스토리지 관련 기능
│ ├── theme/ # 앱 테마 정의
│ └── utils/ # 범용 유틸리티 및 확장 함수
│
├── features/ # 앱의 주요 기능 모듈
│ ├── auth/ # 인증 관련 기능
│ │ ├── data/ # 데이터 레이어
│ │ │ ├── datasources/ # 데이터 소스 구현
│ │ │ ├── models/ # DTO/모델 클래스
│ │ │ └── repositories/ # 저장소 구현
│ │ ├── domain/ # 도메인 레이어
│ │ │ ├── entities/ # 비즈니스 엔티티
│ │ │ ├── repositories/ # 저장소 인터페이스
│ │ │ └── usecases/ # 유스케이스 정의
│ │ └── presentation/ # UI 레이어
│ │ ├── pages/ # 화면 위젯
│ │ ├── providers/# 상태 관리 (Riverpod, BLoC 등)
│ │ └── widgets/ # 화면별 커스텀 위젯
│ │
│ └── feature_name/ # 다른 기능도 동일한 구조 적용
│
├── shared/ # 여러 feature 간 공유 컴포넌트
│ ├── widgets/ # 공통 위젯
│ └── models/ # 공통 모델
│
└── main.dart # 앱 진입점
이 구조의 핵심 원칙은 다음과 같습니다:
코드를 타입별이 아닌 기능별로 그룹화: 모든 위젯을 'widgets/' 폴더에 넣는 대신, 각 기능별로 관련 위젯, 모델, 상태 관리 코드를 함께 배치합니다.
레이어 분리: 각 기능 내에서 data, domain, presentation 레이어를 분리하여 관심사를 명확하게 구분합니다.
공통 코드 중앙화: 여러 기능에서 공유되는 코드는 'core/' 또는 'shared/' 폴더에 배치합니다.
위 구조는 클린 아키텍처의 원칙을 Flutter에 맞게 조정한 것입니다. 각 레이어의 역할은 다음과 같습니다:
// domain/entities/user.dart
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<User?> getCurrentUser();
Future<User> signIn(String email, String password);
Future<void> signOut();
}
// domain/usecases/signin_usecase.dart
class SignInUseCase {
final AuthRepository repository;
SignInUseCase(this.repository);
Future<User> execute(String email, String password) {
return repository.signIn(email, password);
}
}
// data/models/user_model.dart
class UserModel {
final String id;
final String name;
final String email;
UserModel({required this.id, required this.name, required this.email});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
User toEntity() => User(id: id, name: name, email: email);
}
// data/datasources/auth_remote_datasource.dart
class AuthRemoteDataSource {
final Dio dio;
AuthRemoteDataSource(this.dio);
Future<UserModel> signIn(String email, String password) async {
final response = await dio.post('/auth/signin', data: {
'email': email,
'password': password
});
return UserModel.fromJson(response.data);
}
}
// data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
Future<User> signIn(String email, String password) async {
final userModel = await remoteDataSource.signIn(email, password);
await localDataSource.cacheUser(userModel);
return userModel.toEntity();
}
// Other implementations...
}
// presentation/providers/auth_state.dart (예시: Riverpod 사용)
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// presentation/providers/auth_notifier.dart
class AuthNotifier extends StateNotifier<AuthState> {
final SignInUseCase _signInUseCase;
final SignOutUseCase _signOutUseCase;
final GetCurrentUserUseCase _getCurrentUserUseCase;
AuthNotifier({
required SignInUseCase signInUseCase,
required SignOutUseCase signOutUseCase,
required GetCurrentUserUseCase getCurrentUserUseCase,
}) : _signInUseCase = signInUseCase,
_signOutUseCase = signOutUseCase,
_getCurrentUserUseCase = getCurrentUserUseCase,
super(const AuthState.initial());
Future<void> checkAuthStatus() async {
state = const AuthState.loading();
final user = await _getCurrentUserUseCase.execute();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
try {
final user = await _signInUseCase.execute(email, password);
state = AuthState.authenticated(user);
} catch (e) {
state = AuthState.error(e.toString());
}
}
Future<void> signOut() async {
state = const AuthState.loading();
await _signOutUseCase.execute();
state = const AuthState.unauthenticated();
}
}
// presentation/pages/login_page.dart
class LoginPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
return Scaffold(
body: authState.maybeWhen(
loading: () => const CircularProgressIndicator(),
error: (error) => Text('Error: $error'),
orElse: () => LoginForm(),
),
);
}
}
클린 아키텍처를 효과적으로 구현하기 위해서는 의존성 주입이 필수적입니다. Flutter에서는 다음과 같은 방법으로 구현할 수 있습니다:
예시 (GetIt 사용):
// lib/core/di/service_locator.dart
final GetIt getIt = GetIt.instance;
Future<void> setupDependencies() async {
// 외부 의존성
final sharedPreferences = await SharedPreferences.getInstance();
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
getIt.registerSingleton<Dio>(() {
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
// 인터셉터 등 설정...
return dio;
}());
// 데이터 소스
getIt.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSource(getIt<Dio>())
);
getIt.registerLazySingleton<AuthLocalDataSource>(
() => AuthLocalDataSource(getIt<SharedPreferences>())
);
// 저장소
getIt.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: getIt<AuthRemoteDataSource>(),
localDataSource: getIt<AuthLocalDataSource>(),
)
);
// 유스케이스
getIt.registerLazySingleton<SignInUseCase>(
() => SignInUseCase(getIt<AuthRepository>())
);
getIt.registerLazySingleton<SignOutUseCase>(
() => SignOutUseCase(getIt<AuthRepository>())
);
getIt.registerLazySingleton<GetCurrentUserUseCase>(
() => GetCurrentUserUseCase(getIt<AuthRepository>())
);
// 상태 관리
getIt.registerFactory<AuthBloc>(
() => AuthBloc(
signInUseCase: getIt<SignInUseCase>(),
signOutUseCase: getIt<SignOutUseCase>(),
getCurrentUserUseCase: getIt<GetCurrentUserUseCase>(),
)
);
}
위의 코드에서:
registerSingleton
: 앱 전체에서 단일 인스턴스를 공유 (즉시 생성)registerLazySingleton
: 첫 호출 시에만 인스턴스 생성 (지연 로딩)registerFactory
: 매번 새로운 인스턴스 생성앱의 시작 부분(main.dart)에서 의존성 설정을 초기화합니다:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await setupDependencies();
runApp(MyApp());
}
그리고 필요한 곳에서 의존성을 가져와 사용합니다:
class LoginPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<AuthBloc>(),
child: LoginView(),
);
}
}
// 위젯 파일
auth_page.dart // 페이지 위젯
login_form.dart // 컴포넌트 위젯
// 기능 파일
auth_repository.dart // 인터페이스
auth_repository_impl.dart // 구현체
// 상태 관리 파일
auth_state.dart // 상태 정의
auth_notifier.dart // 상태 컨트롤러
Flutter 프로젝트에서 import 경로 관리는 코드 가독성과 유지보수성에 영향을 미치는 중요한 요소입니다. 절대 경로와 상대 경로는 각각 장단점이 있어 팀 내에서 합의하여 일관되게 사용하는 것이 중요합니다.
절대 경로 사용 (추천):
1. Dart/Flutter SDK imports
import 'dart:async';
import 'package:flutter/material.dart';
// 2. 외부 패키지 imports
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
// 3. 내부 imports (절대 경로 사용)
import 'package:my_app/features/auth/domain/entities/user.dart';
import 'package:my_app/features/auth/domain/repositories/auth_repository.dart';
import 'package:my_app/core/utils/date_formatter.dart';
장점:
물론 상대 경로를 선호하는 팀도 있습니다:
상대 경로 사용
import '../domain/entities/user.dart';
import '../domain/repositories/auth_repository.dart';
import '../../../core/utils/date_formatter.dart';
장점:
어떤 방식을 선택하든, 팀 내에서 일관된 접근 방식을 유지하는 것이 가장 중요합니다.
프로젝트가 성장함에 따라 구조도 확장될 수 있어야 합니다. 예를 들어:
좋은 프로젝트 구조는 테스트하기 쉬워야 합니다:
test/
├── features/
│ └── auth/
│ ├── data/
│ │ ├── datasources/
│ │ └── repositories/
│ ├── domain/
│ │ └── usecases/
│ └── presentation/
│ ├── pages/
│ └── providers/
└── shared/
└── widgets/
각 레이어와 컴포넌트에 대한 테스트를 쉽게 작성할 수 있도록 프로젝트 구조를 설계하세요.
Flutter 프로젝트에서 핵심 원칙을 따르고 잘 구조화된 프로젝트 설계를 채택하면 다음과 같은 장점이 있습니다:
좋은 아키텍처는 단순히 폴더 구조를 나누는 것이 아니라, 코드의 흐름과 책임을 명확히 하는 것입니다. 이 가이드라인이 여러분의 Flutter 프로젝트를 더 관리하기 쉽고 확장 가능하게 만드는 데 도움이 되길 바랍니다.
다음 파트에서는 가독성, 예측 가능성, 응집성을 코드 레벨에서 어떻게 구현할 수 있는지 더 자세히 살펴보겠습니다.