[Flutter] Riverpod - DI

2025. 5. 10. 01:42·Flutter/Basic Knowledge

DI 란?

의존성 주입(Dependency Injection, DI)은 한 객체가 다른 객체의 의존성을 제공하는 디자인 패턴입니다.

의존성이 있다는 건 무슨 의미 일까요?

: A를 구현함에 있어 B를 사용해서 B가 정상 동작 하지 않으면 A도 결함이 발생할 때, A가 B에 의존성이 있다고 표현을 합니다.

당연히 의존성은 최소화하는게 좋습니다.

 

 예를 들어, A가 B에 의존하고 B가 C에 의존하고 C가 D에 의존하는 구조라면, D가 변경되는 경우 A까지 실행이 안될 수 있거든요.

 

 이렇게 복잡하게 얽혀있는 경우에 문제가 발생하면, 문제의 시작점이 어디인지 찾기가 매우 어려울 수 있습니다.

 -> 이런 경우 코드의 결합도가 높다고 표현합니다.

 그렇기 때문에 DI라는 개념이 생긴 것입니다.

 의존성을 외부에서 주입받도록 설계함으로써 의존성을 관리할 수 있도록 하는 것이기 때문입니다.

 

 프로그램은 여러 Layer의 많은 Class들의 객체가 유기적으로 연결(결합)되어 동작합니다.

 그렇기 때문에 프로그램을 만들 때, 의존성이 발생하는 것은 매우 자연스러운 것 입니다.

 

 의존성이 있는 부분을 분리하지 않고 작성을 하는 경우(높은 결합), 유지보수 및 테스팅 시에 어디부터 건들여야 할 지 난감한 상황이 발생합니다.

 DI를 통해 객체들 간의 결합도를 낮추고, 코드의 재사용성과 테스트 용이성을 높일 수 있습니다.

 

 

예시 프로젝트 (Todo App)

그렇다면 DI를 통해 의존성이 있는 객체간의 결합도를 낮추고, 유지보수 시에 어떤 편의성이 있는지 예시를 통해 정리해보겠습니다.

 

[프로젝트 구조]

 

아키텍쳐

: Clean Architecture (Domain, Data, Presentation, Core)

디자인 패턴

: MVVM

사용 패키지
: Riverpod(상태관리, DI), Hive(내장 DB), build_runner(보일러플레이트 코드 자동 생성)

폴더링

CA(Clean Architecture) + MVVM 구조

페이지

 

 

구현 순서

1 단계

Domain 계층 - 비즈니스 로직의 핵심이므로 먼저 구현

  • Entity 모델 정의
  • Repository 인터페이스 정의
  • UseCase 작성

2 단계

Data 계층 - Domain에 정의된 인터페이스 구현

  • Data 모델(DTO) 작성
  • DataSource 인터페이스 정의 (Remote/Local)
  • DataSource 구현체 작성
  • Repository 구현체 작성 (DataSource 활용)

3 단계

Presentation 계층 - UI 구현

  • ViewModel 작성
  • Provider 구성 (ChangeNotifierProvider 등)
  • View 구현

4 단계

DI(Dependency Injection) - 모든 계층을 연결

  • 일반적으로 각 계층을 구현한 후 마지막에 설정하지만,
  • 처음부터 DI 프레임워크 설정을 해두고 개발하는 방식도 가능

 

프로젝트 구현

Domain

1. domain/entities/todo.dart

- todo entity 를 작성 (식별할 때 필요한 id, 제목, 완료됬는지 여부, 생성일자)

class Todo {
  final String id;
  final String title;
  final bool isCompleted;
  final DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    required this.isCompleted,
    required this.createdAt,
  });
}

 

 

2. domain/repositories/todo_repository.dart

- 필요한 Data Layer의 Repository 구현체의 인터페이스를 정의

- 이렇게 하면 Data Layer에서 인터페이스에 맞춰서 구현체를 정의 (의존성의 방향이 역전됨 : Data -> Domain)

import '../entities/todo.dart';

abstract class TodoRepository {
  Future<List<Todo>> getTodos();
  Future<void> addTodo(Todo todo);
  Future<void> updateTodo(Todo todo);
  Future<void> deleteTodo(String id);
}

 

 

3. domain/usecases/add_todos, delete_todos, get_todos, update_todos

- add_todos, get_todos, update_todos, delete_todos case 작성

- usecase들은 repository에 의존성이 존재 (DI 필요 -> 마지막에 Riverpod의 Provider로 주입, 의존성 역전을 위한 주입 X)

class AddTodo {
  final TodoRepository repository;

  AddTodo(this.repository);

  Future<void> call(Todo todo) async {
    await repository.addTodo(todo);
  }
}
class GetTodos {
  final TodoRepository repository;

  GetTodos(this.repository);

  Future<List<Todo>> call() async {
    return await repository.getTodos();
  }
}
class UpdateTodo {
  final TodoRepository repository;

  UpdateTodo(this.repository);

  Future<void> call(Todo todo) async {
    await repository.updateTodo(todo);
  }
}
class DeleteTodo {
  final TodoRepository repository;

  DeleteTodo(this.repository);

  Future<void> call(String id) async {
    await repository.deleteTodo(id);
  }
}

 

Data

1. data/models(DTO)/todo_model.dart

- Hive 내장 DB에 저장할 것이기 때문에 hive_generator와 build_runner를 활용해 Hive 객체 형식의 보일러플레이트 코드를 작성 (todo_model.g.dart)

   -> Hive 내장 DB는 객체 방식으로 정보를 저장할 수 있는데, flutter에서 class를 만들고 @HiveType(id:~) 키워드를 클래스 위에 작성하고 build_runner를 실행시키면 hive_generator가 객체 형식으로 저장 가능한 형태의 보일러 코드를 만들어줌.

- domain의 Todo Class를 상속받아서 구현하였고, fromEntity를 Class 내부에 구현함으로써 Mapper 클래스를 작성하지 않음

   -> 모델간의 의존성이 복잡한 경우, Mapper 클래스를 사용하는게 더 효과적일 수 있음

part 'todo_model.g.dart';

@HiveType(typeId: 0)
class TodoModel extends Todo {
  @override
  @HiveField(0)
  final String id;

  @override
  @HiveField(1)
  final String title;

  @override
  @HiveField(2)
  final bool isCompleted;

  @override
  @HiveField(3)
  final DateTime createdAt;

  TodoModel({
    required this.id,
    required this.title,
    required this.isCompleted,
    required this.createdAt,
  }) : super(
         id: id,
         title: title,
         isCompleted: isCompleted,
         createdAt: createdAt,
       );

  factory TodoModel.fromEntity(Todo todo) {
    return TodoModel(
      id: todo.id,
      title: todo.title,
      isCompleted: todo.isCompleted,
      createdAt: todo.createdAt,
    );
  }
}

 

 

2. data/datasources/todo_local_datasource.dart

- data source 인터페이스 정의

- 기본적인 crud 기능 구현 (add, get, update, delete)

abstract class TodoLocalDataSource {
  Future<List<TodoModel>> getTodos();
  Future<void> addTodo(TodoModel todo);
  Future<void> updateTodo(TodoModel todo);
  Future<void> deleteTodo(String id);
}

 

 

3. data/datasources/todo_local_datasource_impl.dart

- data source 인터페이스 구현체

- hive DB 로 crud 기능 구현

- 해당 클래스는 Hive의 Box<TodoModel>에 의존하기 때문에 DI 과정에서 주입

class TodoLocalDataSourceImpl implements TodoLocalDataSource {
  final Box<TodoModel> _todoBox;

  TodoLocalDataSourceImpl(this._todoBox);

  @override
  Future<List<TodoModel>> getTodos() async {
    return _todoBox.values.toList();
  }

  @override
  Future<void> addTodo(TodoModel todo) async {
    await _todoBox.add(todo);
  }

  @override
  Future<void> updateTodo(TodoModel todo) async {
    final index = _todoBox.values.toList().indexWhere((item) => item.id == todo.id);
    if (index != -1) {
      await _todoBox.putAt(index, todo);
    }
  }

  @override
  Future<void> deleteTodo(String id) async {
    final index = _todoBox.values.toList().indexWhere((item) => item.id == id);
    if (index != -1) {
      await _todoBox.deleteAt(index);
    }
  }
}

 

 

4. data/repositories/todo_repository_impl.dart

- domain에서 작성했던 repositories의 구현체를 작성

- datasource 구현체를 주입받아 사용하기 때문에 의존성이 발생 -> DI 과정에서 추가

- domain layer에서 data layer의 코드를 사용하는 것은 맞지만 인터페이스를 domain 쪽에서 정의했기 때문에 data layer에서 의존하는 꼴이 됨 (의존성 역전)

class TodoRepositoryImpl implements TodoRepository {
  final TodoLocalDataSource localDataSource;

  TodoRepositoryImpl(this.localDataSource);

  @override
  Future<List<Todo>> getTodos() async {
    final todoModels = await localDataSource.getTodos();
    return todoModels;
  }

  @override
  Future<void> addTodo(Todo todo) async {
    await localDataSource.addTodo(TodoModel.fromEntity(todo));
  }

  @override
  Future<void> updateTodo(Todo todo) async {
    await localDataSource.updateTodo(TodoModel.fromEntity(todo));
  }

  @override
  Future<void> deleteTodo(String id) async {
    await localDataSource.deleteTodo(id);
  }
}

 

 

Presentation

1. Presentation/viewmodels/todo_viewmodel.dart

- 해당 view 혹은 feature에서 사용되는 state를 정의

- ref를 todoViewmodelProvider에서 주입받아서 각 usecase들의 provider들을 사용(의존)

-> core/di/injection.dart에 마지막에 추가

// 상태 클래스
class TodoState {
  final List<Todo> todos;
  final bool isLoading;

  TodoState({required this.todos, this.isLoading = false});

  TodoState copyWith({List<Todo>? todos, bool? isLoading}) {
    return TodoState(
      todos: todos ?? this.todos,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

// ViewModel
class TodoViewModel extends StateNotifier<TodoState> {
  final Ref _ref;

  TodoViewModel(this._ref) : super(TodoState(todos: [])) {
    _loadTodos();
  }

  Future<void> _loadTodos() async {
    state = state.copyWith(isLoading: true);
    final getTodos = _ref.read(getTodosProvider);
    final todos = await getTodos();
    state = state.copyWith(todos: todos, isLoading: false);
  }

  Future<void> addTodo(String title) async {
    state = state.copyWith(isLoading: true);
    final todo = Todo(
      id: IdGenerator.generateId(),
      title: title,
      isCompleted: false,
      createdAt: DateTime.now(),
    );
    final addTodo = _ref.read(addTodoProvider);
    await addTodo(todo);
    await _loadTodos();
  }

  Future<void> toggleTodo(String id) async {
    state = state.copyWith(isLoading: true);
    final todo = state.todos.firstWhere((t) => t.id == id);
    final updatedTodo = Todo(
      id: todo.id,
      title: todo.title,
      isCompleted: !todo.isCompleted,
      createdAt: todo.createdAt,
    );
    final updateTodo = _ref.read(updateTodoProvider);
    await updateTodo(updatedTodo);
    await _loadTodos();
  }

  Future<void> deleteTodo(String id) async {
    state = state.copyWith(isLoading: true);
    final deleteTodo = _ref.read(deleteTodoProvider);
    await deleteTodo(id);
    await _loadTodos();
  }
}

 

 

2. Presentation/pages/todo_screen.dart

- flutter_riverpod의 widget인 consumerWidget을 사용하여 구현

- build 시 WidgetRef를 불러와 원하는 Provider에 접근 가능하도록 설정
(main.dart 최상위 객체(보통 App 혹은 MyApp 위젯)에 ProviderScope 위젯을 추가해 자식 위젯들에 WidgetRef를 사용할 수 있도록 함)

- ref를 통해 TodoViewmodelProvider의 상태를 watch 하고 viewmodel을 read하여 내부 function 사용

class TodoScreen extends ConsumerWidget {
  final TextEditingController _textController = TextEditingController();

  TodoScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todoState = ref.watch(todoViewModelProvider);
    final viewModel = ref.read(todoViewModelProvider.notifier);

    return GestureDetector(
      onTap: () {
        FocusScope.of(context).unfocus(); // 키보드 내리기
      },
      child: Scaffold(
        appBar: AppBar(title: const Text('Todo App - Clean MVVM')),
        body: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(16.0),
              child: Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _textController,
                      decoration: const InputDecoration(hintText: '할 일을 입력하세요'),
                      onSubmitted: (value) {
                        if (value.isNotEmpty) {
                          viewModel.addTodo(value);
                          _textController.clear();
                        }
                      },
                    ),
                  ),
                  IconButton(
                    icon: const Icon(Icons.add),
                    onPressed: () {
                      if (_textController.text.isNotEmpty) {
                        viewModel.addTodo(_textController.text);
                        _textController.clear();
                      }
                    },
                  ),
                ],
              ),
            ),
            if (todoState.isLoading)
              const Center(child: CircularProgressIndicator()),
            Expanded(
              child: ListView.builder(
                itemCount: todoState.todos.length,
                itemBuilder: (context, index) {
                  final todo =
                      todoState.todos[todoState.todos.length - index - 1];
                  return TodoItem(
                    todo: todo,
                    onToggle: () => viewModel.toggleTodo(todo.id),
                    onDelete: () => viewModel.deleteTodo(todo.id),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

 

- Presentation/widgets/todo_item.dart

class TodoItem extends StatelessWidget {
  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onDelete;

  const TodoItem({
    super.key,
    required this.todo,
    required this.onToggle,
    required this.onDelete,
  });

  @override
  Widget build(BuildContext context) {
    return Dismissible(
      key: UniqueKey(),
      direction: DismissDirection.endToStart,
      onDismissed: (_) => onDelete(),
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: EdgeInsets.symmetric(horizontal: 20),
        child: Icon(Icons.delete, color: Colors.white),
      ),
      child: ListTile(
        leading: Checkbox(
          value: todo.isCompleted,
          onChanged: (_) => onToggle(),
        ),
        title: Text(
          todo.title,
          style: TextStyle(
            decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
          ),
        ),
        trailing: Text(
          "${todo.createdAt.year}-${todo.createdAt.month.toString().padLeft(2, '0')}-${todo.createdAt.day.toString().padLeft(2, '0')}",
          style: TextStyle(fontSize: 12, color: Colors.grey),
        ),
      ),
    );
  }
}

 

- main.dart

/// @title: Todo App - Clean MVVM
// 이 프로젝트는 Flutter와 Riverpod, Hive를 사용하여 간단한 Todo 애플리케이션을 구현한 것입니다.
// Clean Architecture와 MVVM 패턴을 기반으로 설계되었으며, 상태 관리는 Riverpod을 사용하고,
// 데이터 저장소로는 Hive를 활용합니다.
void main() async {
  // Flutter 초기화
  WidgetsFlutterBinding.ensureInitialized();

  // Hive 초기화
  await Hive.initFlutter();

  // Hive 어댑터 등록
  Hive.registerAdapter(TodoModelAdapter());

  // Hive 박스 열기
  await Hive.openBox<TodoModel>('todos');

  runApp(
    // Riverpod Provider 초기화
    const ProviderScope(child: MyApp()),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo App - Clean MVVM',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
      home: TodoScreen(),
    );
  }
}

 

 

Dependency Injection

1. Core/di/injection.dart

- 프로젝트의 규모가 작아서 하나의 파일에 di 코드를 전부 작성

- 최종적으로 각 파트의 의존성이 필요한 부분 전부 연결

- 다양한 di 패키지가 있는데, 그 중 riverpod을 사용

// External
final todoBoxProvider = Provider<Box<TodoModel>>((ref) {
  return Hive.box<TodoModel>('todos');
});

// Data sources
final todoLocalDataSourceProvider = Provider<TodoLocalDataSource>((ref) {
  return TodoLocalDataSourceImpl(ref.watch(todoBoxProvider));
});

// Repository
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
  return TodoRepositoryImpl(ref.watch(todoLocalDataSourceProvider));
});

// UseCases
final getTodosProvider = Provider<GetTodos>((ref) {
  return GetTodos(ref.watch(todoRepositoryProvider));
});

final addTodoProvider = Provider<AddTodo>((ref) {
  return AddTodo(ref.watch(todoRepositoryProvider));
});

final updateTodoProvider = Provider<UpdateTodo>((ref) {
  return UpdateTodo(ref.watch(todoRepositoryProvider));
});

final deleteTodoProvider = Provider<DeleteTodo>((ref) {
  return DeleteTodo(ref.watch(todoRepositoryProvider));
});

// ViewModel
final todoViewModelProvider = StateNotifierProvider<TodoViewModel, TodoState>((ref) {
  return TodoViewModel(ref);
});

 

 

번외

core/utils/id_generator.dart

- todo 의 id를 자동으로 생성하는 클래스를 작성

class IdGenerator {
  static String generateId() {
    return const Uuid().v4();
  }
}

 

 

마무리

제가 DI를 공부하게 된 이유가 클린아키텍쳐의 의존성 관리를 프로젝트에 적용해보고 싶어서였는데,
공부를 하고 프로젝트에 적용하다보니 어떤 부분들이 유용한건지 명확히 알게 된 것 같습니다.

 

중~대 단위의 프로젝트에 투입이 되었을 때 이러한 기초 지식을 가지고 프로젝트에 투입되면 후에 유지보수 및 확장, 테스트 측면에서 좋은 인사이트를 가지고 시작할 수 있을 것 같습니다.

 

아래는 위의 코드들이 저장되어있는 깃허브 레포지 링크입니다.

위의 예시 코드는 lib/practice_3 폴더에 저장되어있습니다.

https://github.com/jeonmmingu/flutter_riverpod_practice

 

GitHub - jeonmmingu/flutter_riverpod_practice

Contribute to jeonmmingu/flutter_riverpod_practice development by creating an account on GitHub.

github.com

 

이만 글을 마칩니다 ~~

'Flutter > Basic Knowledge' 카테고리의 다른 글

[Flutter] Fastlane 친해지기  (0) 2025.05.12
[Flutter] Riverpod - 상태 관리  (0) 2025.05.08
[Flutter] 앱 개발을 위한 클린 아키텍처 기본기 다지기 - 1단계  (0) 2025.05.02
[Dart] Stream 이란?  (3) 2024.01.22
[Dart] Asynchronous & Future  (3) 2024.01.12
'Flutter/Basic Knowledge' 카테고리의 다른 글
  • [Flutter] Fastlane 친해지기
  • [Flutter] Riverpod - 상태 관리
  • [Flutter] 앱 개발을 위한 클린 아키텍처 기본기 다지기 - 1단계
  • [Dart] Stream 이란?
halfcodx
halfcodx
iOS, Flutter Developer
  • halfcodx
    Mins_Programming
    halfcodx
  • 전체
    오늘
    어제
    • 분류 전체보기 (28)
      • My App (1)
        • NostelgiAlbum (0)
        • Ro_ad (1)
        • Growith (0)
      • Retrospect (2)
      • iOS (9)
        • iOS (6)
        • Swift (2)
      • Python (5)
        • Module (3)
        • Knowledge (2)
      • Flutter (10)
        • Basic Knowledge (10)
        • Implementation (0)
  • 블로그 메뉴

    • Home
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    combine 이란
    DART
    Flutter
    requests
    티스토리챌린지
    오블완
    ios combine
    파이썬
    Python
    combine
  • hELLO· Designed By정상우.v4.10.1
halfcodx
[Flutter] Riverpod - DI
상단으로

티스토리툴바