[Flutter] Riverpod Clean Architecture

Parrottkim·2023년 11월 6일
10

시작하기에 앞서

프로젝트의 규모가 복잡해져 갈수록 아키텍처 패턴은 중요해집니다. 저 역시도 여러 프로젝트를 진행하면서 이런 고민 없이 앱을 설계했다가 점점 스파게티 코드를 생성하고 있는 모습을 종종 발견했는데요. 문제는 이를 알고나서 해결하기에는 이미 늦은 경우가 많다는 것이었습니다.
그래서 앱을 구현하기보다 먼저 아키텍처 패턴을 통해 앱을 설계함으로서 앱 개발을 하면서 발생하는 구조적인 문제에 대해 대비하고자 합니다.
이 글에서는 샘플 앱을 통해 Flutter와 Riverpod으로 Clean Architecture 패턴을 따라 설계하는 방법을 알아보도록 하겠습니다.

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 업데이트 등의 역할을 합니다.

  1. State
    애플리케이션의 상태를 나타냅니다.
    UI를 표시하는데 필요한 데이터와 정보를 담고 있습니다.

sealed class PostState with _$PostState {
  factory PostState({
    (false) bool isLoading, // 비동기 로딩 여부
    (0) int currentIndex, // 현재 페이지 인덱스
    ([]) List<PostEntity> posts, // 게시물 목록
    (false) bool hasReachEnd, // 모든 목록을 불러왔는지 여부
  }) = _PostState;
}
  1. Controller
    애플리케이션의 상태를 관리하고 도메인 계층에서 가져온 데이터를 Presentation 계층에 표시하기 위해 데이터를 처리합니다.

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]);
        });
      }
    }
  }
}
  1. UI
    실질적으로 사용자의 화면에 보여지는 부분입니다.
    애플리케이션 상태나 사용자 상호작용을 처리합니다.
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 레이어
애플리케이션의 핵심 부분입니다.
비즈니스 로직을 포함하고 있으며, 외부 레이어와 독립적으로 기능합니다.

  1. Entity
    비즈니스 로직에서 사용하는 정제된 데이터 구조를 나타냅니다.

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);
}
  1. Repository Abstraction
    데이터 레이어와 도메인 레이어를 분리하기 위한 리포지토리 추상화 부분입니다.
    데이터 레이어의 리포지토리 구현 부분이 수정되어도 도메인 레이어는 이를 알지 못하며, 단방향 의존성을 유지하게 해줍니다.
abstract class PostRepository {
  Future<List<PostEntity>> getPostList({required int start, int limit = 20});

  Future<PostEntity> getPostDetail({required int id});
}
  1. Usecase
    핵심 비즈니스 로직을 포함하고 데이터 흐름을 조정합니다.
    데이터 레이어에서 필요한 데이터를 수신하고 처리합니다.

Future<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);
}

3. Data 레이어
애플리케이션과 외부 소스, 예를 들어 인터넷과 데이터베이스 사이의 경계 역할을 합니다. 외부 소스에서 데이터를 가져오고 도메인 레이어에서 사용하는 형식으로 변환합니다.

  1. Model
    외부 소스에서 가져온 데이터를 구조화하기 위한 용도로 사용됩니다.
    데이터를 엔티티로 변환하기 위한 확장 메서드를 포함합니다.

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,
      );
}

  1. Data Source
    외부 소스, 이 예시에서는 REST API에서 데이터를 가져오는 역할을 합니다.
()
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));
}
  1. Repository Implementation
    도메인 레이어의 리포지토리 구현부입니다.
    데이터를 실제로 데이터 소스로부터 가져온 뒤, 도메인 레이어에 전달하는 역할을 합니다.
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

Github Repository

profile
Flutter developer

10개의 댓글

comment-user-thumbnail
2023년 12월 28일

좋은 글 감사드립니다!
riverpod 공식문서에서 changenotifier 을 사용하지않는 방향으로 쓰는것이 좋다고해서 데이터를 어떻게 관리하는게 좋을지 계속 고민중이었는데 bloc 처럼 state 패턴으로 사용하는것도 괜찮아보이네요
또 굳이 di 라이브러리를 사용하지않고도 riverpod 자체가 전역적으로 동작하니 의존성 관리하기도 편한것 같구요!

1개의 답글
comment-user-thumbnail
2024년 3월 13일

안녕하세요
좋은 글 잘 봤습니다. !

한가지 궁금한 점은
presentation 의 controller 단에 ui의 동작 로직 (버튼 클릭, TextFieldController ) 등을 선언하여
ref.read(provider.notifier).btnClick(); 등으로 사용해도 될지 궁금합니다 .

아니라면 혹시 어떤방식으로 처리하는게 좋을지도 알려주시면 감사하겠습니다.

1개의 답글
comment-user-thumbnail
2024년 4월 13일

감사합니다! 홀로 공부중인 사람으로서 설명부터 예시까지 거를타선이 없는 포스트입니다!

1개의 답글
comment-user-thumbnail
2024년 7월 15일

안녕하세요.
깔끔하게 정리되어 있는 글을 보니 이해가 잘 되네요.

한 가지 궁금점이 있습니다.
PostService를 retroft 패캐지를 활용하였는데 Code Gererator를 활용해 PostService의 구현체를 생성된 코드가 대신한다고 생각합니다. 그러므로 PostDataSource의 추상 클래스를 활용하지 않고 PostService의 추상 클래스에 의존하면 의존성 역전의 원칙에 위배되지 않는다 생각합니다.

현재 PostDataSource 를 사이에 두어 의미 없는 의존성을 부여하는 것이라 보여지는데 혹시 이부분에 대해서 어떻게 생각하시는 지 궁금합니다.

1개의 답글