guricode

[클린아키텍쳐]Clean Architecture -4 본문

앱/Flutter&Dart

[클린아키텍쳐]Clean Architecture -4

agentrakugaki 2025. 8. 31. 00:26

usecase를 작성한다

 

usecase는 레파지토리 인터페이스를 참조하여 메서드를 실행한다.

뷰모델에서 usecase를 사용하여 screen에 공급해준다

 

//fetch_movies_usecase.dart

import 'package:flutter_clean_arch/domain/entity/movie.dart';
import 'package:flutter_clean_arch/domain/repository/movie_repository.dart';

//usecase는 repository interface를 참조한다
class FetchMoviesUsecase {
  final MovieRepository _movieRepository;
  FetchMoviesUsecase(this._movieRepository);

  Future<List<Movie>> execute() async {
    return await _movieRepository.fetchMovies();
  }
}

 

테스트

//fetch_movies_usecase_test.dart

import 'package:flutter_clean_arch/domain/entity/movie.dart';
import 'package:flutter_clean_arch/domain/repository/movie_repository.dart';
import 'package:flutter_clean_arch/domain/useacase/fetch_movies_usecase.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockMovieRepository extends Mock implements MovieRepository {}

void main() {
  MockMovieRepository? mockMovieRepository;
  FetchMoviesUsecase? fetchMoviesusercase;

  setUp(() {
    mockMovieRepository = MockMovieRepository();
    fetchMoviesusercase = FetchMoviesUsecase(mockMovieRepository!);
  });
  //

  test('FetchMoviesUsecase test : fetchMovies', () async {
    when(() => mockMovieRepository!.fetchMovies()).thenAnswer(
      (_) async => [
        Movie(
          title: 'title',
          released: 'released',
          runtime: 'runtime',
          director: 'director',
          actors: 'actors',
          poster: 'poster',
        ),
      ],
    );
    final result = await fetchMoviesusercase!.execute();
    expect(result.length, 1);
    expect(result.first.title, 'title');
  });
}

 

실제사용할땐 어떤 Repository Impl이 들어왔는지에 따라 실행 로직이 바뀐다.

 

  • 인터페이스 1개 ↔ 구현체 여러 개 가능
  • 상황에 따라 Impl을 바꿔 주입하면 됨 (실제 서버용, 캐싱용, 테스트용 등)
  • 이게 Clean Architecture나 DI(의존성 주입)에서 인터페이스를 쓰는 핵심 이유

이제 뷰 모델에 usecase를 붙여아한다.

그러려면 뷰 모델이 repository impl을 만들어야하고 data source impl객체도 만들어야한다.

이 작업을 따로 관리하기위해 provider에서 작업할 것이다.

 

Viewmodel에서 직접 객체 생성하지 않을수 있게 UseCase 공급해주는 Provider생성한다

 

//providers.dart

//ViewModel 내에서는 Provider에 의해서 useCase 공급받을것

import 'package:flutter/services.dart';
import 'package:flutter_clean_arch/data/data_source/movie_asset_data_source_impl.dart';
import 'package:flutter_clean_arch/data/data_source/movie_data_source.dart';
import 'package:flutter_clean_arch/data/repository/movie_repository_impl.dart';
import 'package:flutter_clean_arch/domain/repository/movie_repository.dart';
import 'package:flutter_clean_arch/domain/useacase/fetch_movies_usecase.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

//데이터소스
final movieDataSourceProvider = Provider<MovieDataSource>((ref) {
  //AssetBundle 타입. MovieAssetDataSourceImpl 앱 실행 환경에서 assets 접근을 보장하기 위해 rootBundle 을 넣는 것
  return MovieAssetDataSourceImpl(rootBundle);
});

//레파지토리
//객체를 공급해주는 공급자,관리자
final _movieRepositoryProvider = Provider<MovieRepository>((ref) {
  final dataSource = ref.read(movieDataSourceProvider);
  return MovieRepositoryImpl(dataSource);
});

//유즈케이스
final fetchMoviesUsecaseProvider = Provider((ref) {
  final movieRepo = ref.read(_movieRepositoryProvider);
  return FetchMoviesUsecase(movieRepo);
});

//뷰모델에 공급해주면 된다

 

 

 

//movie_list_view_model.dart

//1.상태클래스
// 영화 리스트 List<Movie>

import 'package:flutter_clean_arch/domain/entity/movie.dart';
import 'package:flutter_clean_arch/presentation/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class MovieListViewModel extends Notifier<List<Movie>> {
  @override
  List<Movie> build() {
    fetchMovies();
    return [];
  }

  Future<void> fetchMovies() async {
    //provider에서 fetchMoviesUsecaseProvider 구독
    //fetchMoviesUsecaseProvider는 FetchMoviesUsecase제공
    final fetchMoviesUsecase = ref.read(fetchMoviesUsecaseProvider);
    final result = await fetchMoviesUsecase.execute();

    state = result;
  }
}

//뷰모델 공급자
final movieListViewMocelProvider =
    NotifierProvider<MovieListViewModel, List<Movie>>(() {
      return MovieListViewModel();
    });
//테스트진행

 

 

 

 

 

이제 뷰 모델을 테스트 한다

//movie_list_view_model_test.dart

import 'package:flutter_clean_arch/domain/entity/movie.dart';
import 'package:flutter_clean_arch/domain/useacase/fetch_movies_usecase.dart';
import 'package:flutter_clean_arch/presentation/pages/movie_list/movie_list_view_model.dart';
import 'package:flutter_clean_arch/presentation/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockFetchmoviesUsecase extends Mock implements FetchMoviesUsecase {}

void main() {
  //리버팟테스트할떄 프로바이더 컨테이너를 이용해 데이터제공
  ProviderContainer? providerContainer;

  setUp(() {
    //MovieListViewModel에서 fetchMoviesUsercase 달라고했을때 가짜객체 전달해주는세팅

    final fetchMoviesUsecaseProviderOverride = fetchMoviesUsecaseProvider
        .overrideWith((ref) {
          return MockFetchmoviesUsecase();
        });
    providerContainer = ProviderContainer(
      //프로바이더 컨테이너를 통해서 fetchMoviesUsecaseProvider에 접근해서
      //fetchMoviesUsecase달라고 요청을 하면 MockFetchmoviesUsecase가 리턴된다
      overrides: [fetchMoviesUsecaseProviderOverride],
    );
    //addTearDown으로 테스트가 끝날때 providerContainer가  dispose됨
    addTearDown(providerContainer!.dispose);
  });
  test('MovieListViewModel test : state update after fetchMovies', () async {
    when(
      () => providerContainer!.read(fetchMoviesUsecaseProvider).execute(),
    ).thenAnswer(
      (_) async => [
        Movie(
          title: 'title',
          released: 'released',
          runtime: 'runtime',
          director: 'director',
          actors: 'actors',
          poster: 'poster',
        ),
      ],
    );

    //1.최초상태 빈 배열
    final stateBefore = providerContainer!.read(movieListViewMocelProvider);
    expect(stateBefore.length, 0);

    //2. fetchMovie 호출한뒤 배열상태사이즈 1
    await providerContainer!
        .read(movieListViewMocelProvider.notifier)
        .fetchMovies();
    final stateAfter = providerContainer!.read(movieListViewMocelProvider);
    expect(stateAfter.isEmpty, false);
    expect(stateAfter.length, 1);
    expect(stateAfter.first.title, 'title');
  });
}

 

 

뷰모델까지 완성됐으니 이제 페이지에 공급해줘야한다

 

//movie_list_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_clean_arch/presentation/pages/movie_list/movie_list_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class MovieListPage extends StatelessWidget {
  const MovieListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('영화 목록')),

      body: Consumer(
        //builder로 감싸준후 컨슈머로 교체 후 ref , child추가
        builder: (context, ref, child) {
          //movieListViewMocelProvider 뷰모델 구독
          final movieList = ref.watch(movieListViewModelProvider);
          return ListView.builder(
            itemCount: movieList.length, //갯수
            itemBuilder: (context, index) {
              final movie = movieList[index]; //리스트 요소
              return Container(
                height: 200,
                padding: const EdgeInsets.all(10),
                child: Row(
                  children: [
                    AspectRatio(
                      aspectRatio: 2 / 3,
                      child: Image.network(movie.poster, fit: BoxFit.cover),
                    ),
                    const SizedBox(width: 10),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(movie.title),
                          Text(movie.released),
                          Text(movie.runtime),
                          Text(movie.director),
                          Text(movie.actors),
                        ],
                      ),
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
    );
  }
}

기존에 작성했던 ListView.builder를 Consumer로 감싸야하는데, Builder위젯으로 자동완성기능으로 감싸준 후 Consumer로 바꾸면 더 간단하게 바꿀수 있다.

 

전체적인 흐름을 정리해보자

 

data 레이어

  • DTO = 데이터를 변환해준다. 반환타입  DTO타입
  • data_source = 데이터 소스에 접근하기 위해 추상클래스로 interface 메서드를 정의한다. 반환타입  DTO타입
  • data_source_impl= DataSource 클래스를 상속받아  데이터소스에 접근하기 위한 메서드를 구현한다 반환타입  DTO타입

 

Domain 레이어

  • entity = 앱의 비즈니스 규칙을 표현하는 핵심 모델이다. 데이터의 “형태”뿐 아니라, 그 데이터가 가진 의미와 불변성, 규칙까지 포함한다. 여기서는 Dto와 같은 형태지만 비지니스에 따라 바뀐다. 반환타입    entity 타입
  • repository= 레포지토리 구현하기위해 인터페이스작성 반환타입    entity 타입

 

data 레이어

  • repository_impl= DataSource를 데이터소스 인터페이스로 접근하여 entity타입으로 변환. 반환타입   entity 타입

 

presintation 레이어

  • fetch_usecase=레파지토리 인터페이스를 참조하여 메서드를 실행한다.뷰모델에서 usecase를 사용하여 screen에 공급해준다.들어가는 타입 Repository, 반환타입 entity 타입

 

  • provider(아래는 Provider에 대한 설명)

final DataSourceProvider = Provider<DataSource>((ref) {
  return DataSourceImpl(파라미터);
});
  • DataSource Provider = 실제 데이터를 불러오는 “출처”를 주입

 

final _RepositoryProvider = Provider<Repository>((ref) {
  final dataSource = ref.read(DataSourceProvider);
  return RepositoryImpl(dataSource);
});
  • Repository Provider = Repository 구현체를 만들어 Domain 계층에 공급.
  • DataSourceProvider 를 읽어와서 RepositoryImpl 에 주입.
  • RepositoryImpl 은 DataSource에서 가져온 DTO를 Entity로 변환

 

final fetchUsecaseProvider = Provider((ref) {
  final Repo = ref.read(_RepositoryProvider);
  return FetchUsecase(Repo);
});
  • UseCase Provider = 도메인 로직(UseCase)을 Provider로 등록.
  • Repository를 주입받아서, 실제 비즈니스 규칙 단위 기능을 실행할 수 있는 usecase를 만든다.
  • ViewModel은 이 Provider를 구독해서 UseCase를 얻는다.

  • view_model = provider에서 fUsecaseProvider 구독 UseCase 호출, 뷰모델 공급자 생성

 

 

 

전체 의존성 흐름

UI(ViewModel)
   ↓ (ref.read)
UseCase (fetchMoviesUsecaseProvider)
   ↓
Repository (MovieRepositoryImpl, _movieRepositoryProvider)
   ↓
DataSource (MovieAssetDataSourceImpl, movieDataSourceProvider)
   ↓
외부 자원

 

 

 

조금 더 복습하고 써봐야 겠지만, 이렇게 정리하니 조금 눈에 들어온다...

프로젝트가 두개정도 남았고 한개가 진행중인데...

열심히 하자 할수있다!!