프로젝트의 규모가 복잡해져 갈수록 아키텍처 패턴은 중요해집니다. 저 역시도 여러 프로젝트를 진행하면서 이런 고민 없이 앱을 설계했다가 점점 스파게티 코드를 생성하고 있는 모습을 종종 발견했는데요. 문제는 이를 알고나서 해결하기에는 이미 늦은 경우가 많다는 것이었습니다.
그래서 앱을 구현하기보다 먼저 아키텍처 패턴을 통해 앱을 설계함으로서 앱 개발을 하면서 발생하는 구조적인 문제에 대해 대비하고자 합니다.
이 글에서는 샘플 앱을 통해 Flutter와 Riverpod으로 Clean Architecture 패턴을 따라 설계하는 방법을 알아보도록 하겠습니다.
각 레이어는 독립적으로 동작함으로써 코드의 가독성, 유지보수성, 품질을 향상시킬 수 있게됩니다.
예를 들어, Presentation (UI) 레이어의 변경이 Domain 레이어에 영향을 주지 않습니다. Domain 레이어는 어느 UI가 자신을 가리키는지 알지 못하고, 알 필요도 없기 때문입니다. 반대로, Domain 레이어가 변경되면 Presentation 레이어는 Domain 레이어에 대해 단방향 의존성을 가지고 있기 때문에 UI가 변경될 수 있습니다.
/lib
├── /src
│ ├── /data
│ │ ├── /model
│ │ ├── /repository
│ │ └── /source
│ ├── /domain
│ │ ├── /entity
│ │ ├── /repository
│ │ └── /usecase
│ └── /presentation
│ ├── /controller
│ ├── /page
│ └── app.dart
└── main.dart
프로젝트 구조는 세가지 레이어를 따릅니다.
1. Presentation (UI) 레이어
UI를 담당하며 사용자와 직접 상호작용하는 레이어입니다. 애플리케이션의 상태를 관리하며 페이지 탐색, 데이터 표시, UI 업데이트 등의 역할을 합니다.
class PostState with _$PostState {
factory PostState({
(false) bool isLoading, // 비동기 로딩 여부
(0) int currentIndex, // 현재 페이지 인덱스
([]) List<PostEntity> posts, // 게시물 목록
(false) bool hasReachEnd, // 모든 목록을 불러왔는지 여부
}) = _PostState;
}
sealed
class PostController extends _$PostController {
FutureOr<PostState> build() async {
return _fetchData();
}
// 첫 데이터 로드
Future<PostState> _fetchData() async {
// UseCase에서 첫 데이터 로드
final posts = await ref.watch(getInitialPostsProvider.future);
// 애플리케이션 상태 변경
return PostState(currentIndex: posts.last.id, posts: posts);
}
// 추가 데이터 로드
Future<void> loadMore() async {
final value = state.valueOrNull;
if (value != null) {
if (value.hasReachEnd) return;
if (!value.isLoading) {
// 애플리케이션 상태 변경 (로딩중)
state = AsyncValue.data(value.copyWith(isLoading: true));
state = await AsyncValue.guard(() async {
// UseCase에서 애플리케이션 상태의 currentIndex 값으로 추가 데이터 로드
final posts =
await ref.watch(getMorePostsProvider(start: value.currentIndex).future);
// 애플리케이션 상태 변경
return value.copyWith(
isLoading: false,
hasReachEnd: posts.isEmpty,
currentIndex: posts.isEmpty ? value.posts.last.id : posts.last.id,
posts: [...value.posts, ...posts]);
});
}
}
}
}
class HomePage extends ConsumerWidget {
const HomePage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(postControllerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
),
// 애플리케이션 상태마다 UI 변경
body: switch (state) {
AsyncData(:final value) => _buildList(value),
AsyncError() => _error(),
_ => _loading(),
});
...
}
}
2. Domain 레이어
애플리케이션의 핵심 부분입니다.
비즈니스 로직을 포함하고 있으며, 외부 레이어와 독립적으로 기능합니다.
class PostEntity with _$PostEntity {
factory PostEntity({
required int id,
required int userId,
required String title,
required String body,
}) = _PostEntity;
factory PostEntity.fromJson(Map<String, dynamic> json) =>
_$PostEntityFromJson(json);
}
abstract class PostRepository {
Future<List<PostEntity>> getPostList({required int start, int limit = 20});
Future<PostEntity> getPostDetail({required int id});
}
<List<PostEntity>> getInitialPosts(GetInitialPostsRef ref) async {
final repository = ref.watch(postRepositoryProvider);
return await repository.getPostList(start: 0);
}
Future<List<PostEntity>> getMorePosts(GetMorePostsRef ref,
{required int start}) async {
final repository = ref.watch(postRepositoryProvider);
return await repository.getPostList(start: start);
}
Future<PostEntity> getPostDetail(GetPostDetailRef ref,
{required int id}) async {
final repository = ref.watch(postRepositoryProvider);
return await repository.getPostDetail(id: id);
}
Future
3. Data 레이어
애플리케이션과 외부 소스, 예를 들어 인터넷과 데이터베이스 사이의 경계 역할을 합니다. 외부 소스에서 데이터를 가져오고 도메인 레이어에서 사용하는 형식으로 변환합니다.
class PostModel with _$PostModel {
factory PostModel({
required int id,
required int userId,
required String title,
required String body,
}) = _PostModel;
factory PostModel.fromJson(Map<String, dynamic> json) =>
_$PostModelFromJson(json);
}
extension PostExtension on PostModel {
PostEntity toEntity() => PostEntity(
id: id,
userId: userId,
title: title,
body: body,
);
}
()
abstract class PostService {
factory PostService(Dio dio, {String baseUrl}) = _PostService;
('posts')
Future<List<PostModel>> getPostList({
('_start') required int start,
('_limit') required int limit,
});
('posts/{id}')
Future<PostModel> getPostDetail({
('id') required int id,
});
}
abstract class PostDataSource {
Future<List<PostModel>> getPostList({required int start, int limit = 20});
Future<PostModel> getPostDetail({required int id});
}
class PostDataSourceImpl implements PostDataSource {
PostDataSourceImpl({required PostService service}) : _service = service;
final PostService _service;
Future<List<PostModel>> getPostList(
{required int start, int limit = 20}) async =>
_service.getPostList(start: start, limit: limit);
Future<PostModel> getPostDetail({required int id}) =>
_service.getPostDetail(id: id);
}
PostDataSource postDataSource(PostDataSourceRef ref) {
final http = ref.watch(httpProvider);
return PostDataSourceImpl(service: PostService(http));
}
class PostRepositoryImpl implements PostRepository {
PostRepositoryImpl({required PostDataSource source}) : _source = source;
final PostDataSource _source;
Future<List<PostEntity>> getPostList(
{required int start, int limit = 20}) async {
final list = await _source.getPostList(start: start, limit: limit);
return list.map((element) => element.toEntity()).toList();
}
Future<PostEntity> getPostDetail({required int id}) async {
final model = await _source.getPostDetail(id: id);
return model.toEntity();
}
}
PostRepository postRepository(PostRepositoryRef ref) {
final source = ref.watch(postDataSourceProvider);
return PostRepositoryImpl(source: source);
}
도메인 계층이 데이터 계층에 직접 접근하는 경우 단방향 의존성 규칙에 위배됩니다.
이러한 상황에서 도메인 계층에 Repository Interface를 구현하고, Usecase가 Repository Interface에 의존하면서 Usecase가 데이터 계층에 대해 알지 못해도 통신이 가능한 의존성 역전 원칙이 지켜지게 됩니다.
이외에도 테스트 용이성, 확장 가능성 등의 이점도 있습니다.
https://github.com/fluttertutorialin/cubit_clean_archi
https://github.com/guilherme-v/flutter-clean-architecture-example
안녕하세요
좋은 글 잘 봤습니다. !
한가지 궁금한 점은
presentation 의 controller 단에 ui의 동작 로직 (버튼 클릭, TextFieldController ) 등을 선언하여
ref.read(provider.notifier).btnClick(); 등으로 사용해도 될지 궁금합니다 .
아니라면 혹시 어떤방식으로 처리하는게 좋을지도 알려주시면 감사하겠습니다.
안녕하세요.
깔끔하게 정리되어 있는 글을 보니 이해가 잘 되네요.
한 가지 궁금점이 있습니다.
PostService를 retroft 패캐지를 활용하였는데 Code Gererator를 활용해 PostService의 구현체를 생성된 코드가 대신한다고 생각합니다. 그러므로 PostDataSource의 추상 클래스를 활용하지 않고 PostService의 추상 클래스에 의존하면 의존성 역전의 원칙에 위배되지 않는다 생각합니다.
현재 PostDataSource 를 사이에 두어 의미 없는 의존성을 부여하는 것이라 보여지는데 혹시 이부분에 대해서 어떻게 생각하시는 지 궁금합니다.
좋은 글 감사드립니다!
riverpod 공식문서에서 changenotifier 을 사용하지않는 방향으로 쓰는것이 좋다고해서 데이터를 어떻게 관리하는게 좋을지 계속 고민중이었는데 bloc 처럼 state 패턴으로 사용하는것도 괜찮아보이네요
또 굳이 di 라이브러리를 사용하지않고도 riverpod 자체가 전역적으로 동작하니 의존성 관리하기도 편한것 같구요!