반응형

요약:
클린 아키텍처에서는 Riverpod과 BLoC이 가장 잘 어울립니다.
DI(의존성 주입), 테스트 용이성, 계층 분리, 유지보수성을 기준으로 비교합니다.

 

1. 클린 아키텍처 기본 원칙

클린 아키텍처의 핵심은 의존 방향을 안쪽(Domain)으로만 흐르게 하는 것입니다.

Presentation → Domain ← Data
  • Presentation(UI): 화면, 상태관리(Bloc/Riverpod 등)
  • Domain: Entity, UseCase, Repository 인터페이스
  • Data: Repository 구현, API, DB

이 구조에서 상태관리 도구 Presentation 레이어에 위치하며

UseCase/Repository를 주입받아 동작해야 합니다.

 

2. Riverpod vs BLoC 요약 비교

항목RiverpodBLoC

설계 철학 선언적 DI + 안전한 Provider Event → State 흐름
러닝커브 중간 다소 높음
테스트 용이성 매우 높음 높음
DI(의존성 주입) 내장형 Provider 그래프 외부 주입 필요
전역 안전성 ✅ 높음 ✅ 높음
코드 복잡도 중간 다소 복잡
적합한 규모 중~대형 대형
비즈니스 로직 위치 Notifier 내부 Bloc 내부(Event→State)
장점 간결, 타입 안정성, 테스트 쉬움 이벤트 추적 명확, QA·로그 우수
단점 학습 필요, Provider 의존 보일러플레이트 많음

 

3. 클린 아키텍처 레이어 매핑

✅ Presentation (UI Layer)

  • Notifier / Bloc / Cubit
  • ConsumerWidget / BlocBuilder
  • go_router 라우팅

✅ Domain Layer

  • Entity, Value Object
  • UseCase, Repository Interface

✅ Data Layer

  • API, DB, DTO
  • RepositoryImpl, Mapper
lib/
 ├─ app/           # Router, Theme, DI
 ├─ core/          # 공용 유틸, Error, Result
 ├─ domain/        # Entity, Repository, UseCase
 ├─ data/          # API, RepositoryImpl, DTO
 └─ features/
     └─ todo/
         ├─ presentation/
         ├─ domain/
         └─ data/

 

4. Riverpod 방식 예시

// di
final todoRepoProvider = Provider<TodoRepository>((ref) {
  final api = ref.watch(todoApiProvider);
  return TodoRepositoryImpl(api);
});

// usecase
final addTodoProvider = Provider<AddTodo>(
  (ref) => AddTodo(ref.watch(todoRepoProvider)),
);

// state
final todosProvider = AsyncNotifierProvider<TodosController, List<Todo>>(
  TodosController.new,
);

class TodosController extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async {
    return ref.read(todoRepoProvider).fetch();
  }

  Future<void> add(String title) async {
    final add = ref.read(addTodoProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final added = await add(title);
      final prev = await future;
      return [added, ...prev];
    });
  }
}

장점
- Provider 기반 DI → 테스트 시 overrideWithValue 사용 용이

- AsyncValue로 로딩/에러/데이터 3상태 통합

- 파일 구조 명확, 코드 재사용성 높음

 

5. BLoC 방식 예시

sealed class TodoEvent { const TodoEvent(); }
class FetchTodos extends TodoEvent {}
class AddTodoEvt extends TodoEvent { final String title; const AddTodoEvt(this.title); }

sealed class TodoState { const TodoState(); }
class TodoLoading extends TodoState {}
class TodoLoaded extends TodoState { final List<Todo> items; const TodoLoaded(this.items); }
class TodoError extends TodoState { final String message; const TodoError(this.message); }

class TodoBloc extends Bloc<TodoEvent, TodoState> {
  final AddTodo addTodo;
  final TodoRepository repo;

  TodoBloc(this.repo, this.addTodo): super(TodoLoading()) {
    on<FetchTodos>((event, emit) async {
      try {
        emit(TodoLoaded(await repo.fetch()));
      } catch (e) {
        emit(TodoError(e.toString()));
      }
    });

    on<AddTodoEvt>((event, emit) async {
      final current = state is TodoLoaded ? (state as TodoLoaded).items : <Todo>[];
      final added = await addTodo(event.title);
      emit(TodoLoaded([added, ...current]));
    });
  }
}

장점
- Event→State 전이가 명시적 (감사 로그/QA 추적 용이)
- 대형 조직형 앱(결제, 승인, 워크플로우 등)에 적합
- 비즈니스 규칙 명세를 코드로 표현하기 쉬움

 

6. Domain & Data 구조 예시

// domain/entity/todo.dart
class Todo {
  final String id;
  final String title;
  final bool done;
  const Todo(this.id, this.title, this.done);
}

// domain/repository/todo_repository.dart
abstract interface class TodoRepository {
  Future<List<Todo>> fetch();
  Future<Todo> add(String title);
  Future<Todo> toggle(String id);
}

// domain/usecase/add_todo.dart
class AddTodo {
  final TodoRepository repo;
  AddTodo(this.repo);
  Future<Todo> call(String title) => repo.add(title);
}

// data/repository/todo_repository_impl.dart
class TodoRepositoryImpl implements TodoRepository {
  final TodoApi api;
  TodoRepositoryImpl(this.api);

  @override
  Future<List<Todo>> fetch() async {
    final dto = await api.fetchTodos();
    return dto.map((e) => e.toEntity()).toList();
  }

  @override
  Future<Todo> add(String title) async {
    final dto = await api.addTodo(title);
    return dto.toEntity();
  }

  @override
  Future<Todo> toggle(String id) async {
    final dto = await api.toggle(id);
    return dto.toEntity();
  }
}

 

7. 테스트 전략

구분RiverpodBLoC

단위 테스트 ProviderContainer(overrides: [...]) blocTest() 사용
상태 테스트 ref.read().notifier 직접 호출 expectLater으로 상태 시퀀스 확인
통합 테스트 pumpWidget + ProviderScope pumpWidget + BlocProvider

예시 (Riverpod):

test('todosProvider fetches correctly', () async {
  final container = ProviderContainer(overrides: [
    todoRepoProvider.overrideWithValue(FakeTodoRepository()),
  ]);
  final result = await container.read(todosProvider.future);
  expect(result, isA<List<Todo>>());
});

예시 (BLoC):

blocTest<TodoBloc, TodoState>(
  'emits [TodoLoading, TodoLoaded] when FetchTodos is added',
  build: () => TodoBloc(FakeRepo(), AddTodo(FakeRepo())),
  act: (bloc) => bloc.add(FetchTodos()),
  expect: () => [isA<TodoLoading>(), isA<TodoLoaded>()],
);

 

8. 선택 가이드

상황추천 방식

중·대형 프로젝트 / 장기 유지보수  Riverpod
이벤트 로그·승인 등 명세 중심 도메인  BLoC
MVP·프로토타입 / 단기 프로젝트 ⚙️ GetX
Provider 기반 프로젝트 🔄 Riverpod으로 점진적 전환

9. 마이그레이션 팁 (GetX → Clean Architecture)

  1. GetX Controller의 비즈니스 로직을 UseCase로 추출
  2. 데이터 접근을 Repository 인터페이스 뒤로 이동
  3. Presentation에서는 GetX → Riverpod/Bloc 중 하나로 교체
  4. Widget → State → UseCase 단방향 데이터 흐름 유지

 

✅ 최종 추천 요약

기준권장

새 프로젝트 Riverpod 기반 클린 아키텍처
복잡한 승인/로그/워크플로우 도메인 BLoC 기반 아키텍처
개인/소규모 실험용 GetX + Repository 분리
테스트·유지보수·확장성 Riverpod > BLoC > Provider > GetX

📘 핵심 메시지:
“클린 아키텍처에서 상태관리의 목표는 단순히 화면 갱신이 아니라
의존 방향을 명확히 하여, 테스트 가능한 코드를 만드는 것이다.”

반응형
Posted by 까칠코더
,