반응형
요약:
클린 아키텍처에서는 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)
- GetX Controller의 비즈니스 로직을 UseCase로 추출
- 데이터 접근을 Repository 인터페이스 뒤로 이동
- Presentation에서는 GetX → Riverpod/Bloc 중 하나로 교체
- Widget → State → UseCase 단방향 데이터 흐름 유지
✅ 최종 추천 요약
기준권장
| 새 프로젝트 | Riverpod 기반 클린 아키텍처 |
| 복잡한 승인/로그/워크플로우 도메인 | BLoC 기반 아키텍처 |
| 개인/소규모 실험용 | GetX + Repository 분리 |
| 테스트·유지보수·확장성 | Riverpod > BLoC > Provider > GetX |
📘 핵심 메시지:
“클린 아키텍처에서 상태관리의 목표는 단순히 화면 갱신이 아니라
의존 방향을 명확히 하여, 테스트 가능한 코드를 만드는 것이다.”
반응형
'Dev Study > Flutter & Dart' 카테고리의 다른 글
| Flutter 게임 개발 라이브러리 용도별 장단점 (2025 기준) (0) | 2025.11.06 |
|---|---|
| Flutter 상태관리 완전 가이드 (Part 3/3) (0) | 2025.11.05 |
| Flutter 상태관리 완전 가이드 (Part 2/3) (0) | 2025.11.05 |
| Flutter 상태관리 완전 가이드 (Part 1/3) (0) | 2025.11.05 |
| Flutter 빌드는 되는데 실행할때 오류나는 경우 (0) | 2025.11.04 |
| Dart 상위 위젯과 하위 위젯의 상태값 가져오기 (0) | 2023.11.20 |
| Dart 상속 (0) | 2023.11.17 |
| Dart Class 생성자 (0) | 2023.11.17 |

