앱 아키텍처 — Clean Architecture를 Flutter에 적용하기
앱 아키텍처 — Clean Architecture를 Flutter에 적용하기
프로젝트가 커지면 코드를 어디에 놓을지, 레이어를 어떻게 나눌지가 중요해집니다. Clean Architecture는 관심사를 분리하여 테스트와 유지보수를 쉽게 만드는 아키텍처 패턴입니다.
Clean Architecture의 3개 레이어
┌──────────────────────────────────────┐
│ Presentation Layer │
│ (UI, State Management) │
├──────────────────────────────────────┤
│ Domain Layer │
│ (Entities, Use Cases, Repositories) │
├──────────────────────────────────────┤
│ Data Layer │
│ (API, DB, Repository Impl) │
└──────────────────────────────────────┘
| 레이어 | 역할 | 포함 요소 |
|---|---|---|
| Presentation | UI, 사용자 인터랙션 | Widget, Bloc/Provider, State |
| Domain | 비즈니스 로직 (핵심) | Entity, UseCase, Repository(인터페이스) |
| Data | 외부 데이터 소스 | API Client, DB, Repository(구현체) |
면접 포인트: "의존성 방향은 항상 안쪽(Domain)을 향해야 합니다." Domain 레이어는 다른 레이어에 의존하지 않습니다.
디렉토리 구조
lib/
├── core/
│ ├── error/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── network/
│ │ └── network_info.dart
│ └── usecases/
│ └── usecase.dart
├── features/
│ └── auth/
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── auth_remote_datasource.dart
│ │ │ └── auth_local_datasource.dart
│ │ ├── models/
│ │ │ └── user_model.dart
│ │ └── repositories/
│ │ └── auth_repository_impl.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── user.dart
│ │ ├── repositories/
│ │ │ └── auth_repository.dart
│ │ └── usecases/
│ │ ├── login.dart
│ │ └── signup.dart
│ └── presentation/
│ ├── bloc/
│ │ ├── auth_bloc.dart
│ │ ├── auth_event.dart
│ │ └── auth_state.dart
│ ├── pages/
│ │ └── login_page.dart
│ └── widgets/
│ └── login_form.dart
Domain Layer
Entity (핵심 비즈니스 객체)
class User {
final String id;
final String name;
final String email;
const User({
required this.id,
required this.name,
required this.email,
});
}
Repository (인터페이스)
import 'package:dartz/dartz.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> signup(String name, String email, String password);
Future<Either<Failure, void>> logout();
}
UseCase
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class LoginUseCase implements UseCase<User, LoginParams> {
final AuthRepository repository;
const LoginUseCase(this.repository);
@override
Future<Either<Failure, User>> call(LoginParams params) {
return repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
const LoginParams({required this.email, required this.password});
}
Data Layer
Model (Entity + JSON 변환)
class UserModel extends User {
const UserModel({
required super.id,
required super.name,
required super.email,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
}
DataSource
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final Dio dio;
AuthRemoteDataSourceImpl(this.dio);
@override
Future<UserModel> login(String email, String password) async {
final response = await dio.post('/auth/login', data: {
'email': email,
'password': password,
});
return UserModel.fromJson(response.data);
}
}
Repository 구현체
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
final NetworkInfo networkInfo;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> login(
String email,
String password,
) async {
if (await networkInfo.isConnected) {
try {
final user = await remoteDataSource.login(email, password);
await localDataSource.cacheUser(user);
return Right(user);
} on ServerException {
return const Left(ServerFailure('서버 에러'));
}
} else {
return const Left(NetworkFailure('네트워크 연결 없음'));
}
}
}
Presentation Layer
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase loginUseCase;
AuthBloc({required this.loginUseCase}) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await loginUseCase(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
}
}
Either 타입 (에러 처리)
dependencies:
dartz: ^0.10.0 # 또는 fpdart
// Either<Left, Right> — Left는 실패, Right는 성공
Future<Either<Failure, User>> login(...) async {
try {
final user = await dataSource.login(...);
return Right(user); // 성공
} catch (e) {
return Left(ServerFailure('에러')); // 실패
}
}
// 사용
result.fold(
(failure) => showError(failure.message),
(user) => navigateToHome(user),
);
정리
- Clean Architecture는 Domain(핵심) → Data(외부) → Presentation(UI) 순으로 의존합니다
- Domain 레이어는 프레임워크에 의존하지 않아 순수 Dart로 작성합니다
- UseCase로 비즈니스 로직을 캡슐화하면 테스트와 재사용이 쉽습니다
- Repository 인터페이스(Domain)와 구현체(Data)를 분리하면 데이터 소스 교체가 용이합니다
- 소규모 프로젝트에서는 오버엔지니어링이 될 수 있으니 팀 규모와 복잡도에 맞게 적용하세요
댓글 로딩 중...