말랑한 하루
[Flutter] Clean Architecture 본문
🐇 Clean Architecture?
Robert C. Martin이 제시한 아키텍처 패턴입니다.
소프트웨어 시스템을 독립적인 계층으로 분리하고, 각 계층 간의 의존성을 최소화하여 유지보수성, 테스트 용이성, 확장성을 향상시키기 위해 만들어 졌습니다.
🐇 계층
소프트웨어 시스템을 안정적이고 유연하며 변화에 적응하기 쉽게 만드는 것을 목표로 Entity, Use Case, Interface Adapter, Framework and Drivers 계층으로 구성되어 있습니다.
🍒 Entity
핵심 비즈니스 규칙과 데이터를 포함하는 가장 안쪽 계층입니다.
애플리케이션의 핵심 데이터 구조를 포함하고 있습니다.
🍒 Use Case
비즈니스 규칙과 로직을 구현하는 계층입니다.
Entity에 접근하여 특정 비즈니스 기능을 수행합니다.
🍒 Interface Adapter
외부와 통신을 담당하는 계층입니다.
데이터의 변환 및 형식을 조정하여, Use Case와 외부의 결합을 진행합니다.
UI, Database 및 외부 서비스 등과 상호작용 할 수 있습니다.
🍒 Framework and Driver
가장 바깥쪽 계층입니다.
Framework 또는 Library, Databse 등과 연결을 관리하며, 구체적인 기술 구현이 이루어집니다.
위 계층 구조는, 의존성 규칙에 따라 하위 레이어에 대한 의존성만 허용하도록 설계되어 있습니다. 그로 인해 코드의 유지보수와 테스트를 쉽게 진행할 수 있습니다.
🥕 의존성 규칙
의존성 규칙의 가장 기본은 안쪽 계층이 바깥쪽 계층에 종속되면 안된다는 것입니다.
즉, 안쪽 계층이 변경되어도 바깥쪽 계층에는 아무런 영향을 미치지 않도록 설계하여 시스템의 안정성을 높입니다.
🍒 의존성 역전 원칙
고수준 모듈은 저수준 모듈에 의존해서는 안되며, 양쪽 모두 추상화에 의존해야 합니다.
추상화를 통해 의존성을 역전 시키면, 시스템을 더 유연하고 확장 가능하게 만들 수 있습니다.
🍇 의존성 주입 (Dependency Injection)
고수준 모듈이 필요로 하는 저수준 모듈을 외부에서 주입받는 방식입니다.
의존성이 외부에서 주입되므로 고수준 모듈은 구체적인 구현이 아닌 추상화에 의존할 수 있습니다.
🍇 서비스 로케이터 (Service Locator)
애플리케이션 전반에서 서비스를 제공하는 서비스 로케이터를 활용하여, 고수준 모듈이 필요로 하는 서비스를 얻는 방식입니다.
서비스 로케이터를 활용해 고수준 모듈이 필요로 하는 정보를 제공 받으므로, 고수준 모듈이 추상화에 의존할 수 있습니다.
🍒 의존성 교환 원칙
고수준 정책이 저수준 세부사항에 의존하지 않도록 인터페이스를 활용하여 의존성을 교환 가능하게 만들어야 합니다.
의존성 교환을 통해 안쪽 계층의 구현을 분리하여 테스트 용이성을 향상시키고, 변경을 쉽게 진행할 수 있습니다.
🍒 안정된 추상화 원칙
안정된 인터페이스와 추상화를 설계해야 합니다. 시스템이 변화에 대응하기 쉬워지도록 하고, 안정성을 유지하는 데 도움이됩니다.
🍒 경계를 향한 지향성 원칙
시스템의 구성 요소는 일반적인 방향으로 진행되어야 합니다.
안쪽 계층에서 바깥쪽 계층으로 의존성이 향하도록 만드는 것이 중요합니다.
이런 규칙과 원칙에 따라 Clean Architecture는 견고하고, 유지보수 가능한 구조를 갖출 수 있습니다. 또한, 계층을 구분하고 계층 간의 의존성을 관리하여 유연성과 확장성을 확보합니다.
🐇 파일 구조
Entity, Use Case, Interface Adpater, Framework and Driver 계층과 관련한 파일들을 어떤 구조로 쌓아올려야 하는지 처음부터 감을 잡긴 어렵습니다. 여기서 제시하는 가이드는 가장 기초적이고 대중적으로 사용되는 구조로서, 개발 팀 마다 다른 모습의 구조를 가지고 있을 수 있으니 유연하게 학습하길 바랍니다.
관련 파일 구조는 TodoList를 구현하는 것에 초점을 맞추어 아래 구현 항목과 일치하도록 생성하겠습니다.
lib
├── core
│ └── error
│ └── failure.dart
│
├── data
│ ├── datasources
│ │ └── todo_remote_data_source.dart
│ └── repositories
│ └── todo_repository_impl.dart
│
├── domain
│ ├── entities
│ │ └── todo.dart
│ ├── repositories
│ │ └── todo_repository.dart
│ └── usecases
│ └── todo_use_case.dart
│
├── presentation
│ ├── blocs
│ │ └── todo_bloc.dart
│ ├── pages
│ │ └── todo_list_page.dart
│ └── widgets
│ └── todo_list_widget.dart
│
└── main.dart
🐇 구현
Entity, Use Case, Interface Adapter, Framework and Driver 계층을 활용하여 Flutter에서 각 단계별 구현 방법은 다음과 같습니다. 구현은 TodoList를 활용한 기본적인 예시를 들어드리겠습니다.
🥕 Entity
class Todo {
final String id;
final String title;
final bool isCompleted;
Todo({required this.id,
required this.title,
this.isCompleted = false,
)};
Todo copyWith({ ... });
factory Todo.formJson() {};
}
🥕 Use Case
외부 API로부터 데이터를 받아온다면, Use Case가 어떤 방식으로 데이터를 가져오는지 추상화를 진행하는 것이 좋습니다. 추상화를 통해 유지보수성과 확장성을 향상시키며, 의존성을 더욱 역전시킬 수 있습니다.
🍒 Repository
Framework and Driver 계층의 역할을 하는 Repository를 추가한다면, Use Case가 Repository 인터페이스에만 의존하게 만들어 외부 API로부터 데이터를 가져오는 방식을 더 유연하게 변경할 수 있습니다.
아래 방식과 같이 외부 API로부터 데이터를 가져오는 Repository를 구현한다면, 이 Repository는 Framework and Driver 계층에 속합니다.
abstract class TodoRepository {
Future<List<Todo>> getTodos();
Future<Todo> addTodo(Todo newTodo);
}
class TodoRepositoryImpl implements TodoRepository {
@override
Future<List<Todo>> getTodos() async {
final response = await http.get(Uri.parse('url'));
if (response.statusCode == 200) {
final List<dynamic> todoData = json.decode(response.body);
return todoData.map((json) => Todo.fromJson(json)).toList();
} else {
throw Exception('Failed to load todos');
}
}
@override
Future<Todo> addTodo(Todo newTodo) async {
final response = await http.post(
Uri.parse('url'),
headers: {'Content-Type': 'application/json'},
body: json.encode({'title': newTodo.title, 'completed': newTodo.isCompleted}),
);
if (response.statusCode == 201) {
final Map<String, dynamic> todoData = json.decode(response.body);
return Todo.fromJson(todoData);
} else {
throw Exception('Failed to add todo');
}
}
}
위 방법과 같이 Repository에 외부 API와 연결되는 코드를 직접 작성할 수 있지만, DataSource를 활용하여 외부 API에 대한 구현을 분리하고 DataSource로부터 의존성을 주입받아 활용할 수 있는 방법도 존재합니다. 단, 이 경우 간단한 UseCase와 Repository를 구현한다면 역할이 중첩되기 때문에 UseCase에서 정확한 비즈니스 로직을 처리하고, Repository에서는 데이터 엑세스에 대한 책임만을 구현해야 하는 것이 중요합니다.
이후 구현한 Repository를 활용해 UseCase를 구현한 모습은 다음과 같습니다.
Use Case Class를 구현할 땐, 외부 API와의 연결을 구현한 Repository를 사용한다는 목적에 집중하면 됩니다.
abstract class TodoUseCase {
Future<List<Todo>> fetchTodos();
Future<void> addTodo(Todo newTodo);
}
class TodoUseCase {
final TodoRepository _todoRepository;
TodoUseCase(this._todoRepository);
Future<List<Todo>> fetchTodos() async {
try {
return await _todoRepository.getTodos();
} catch (e) {
print('Error fetching todos: $e');
rethrow;
}
}
Future<void> addTodo(Todo newTodo) async {
try {
await _todoRepository.addTodo(newTodo);
} catch (e) {
print('Error adding todo: $e');
rethrow;
}
}
}
🥕 State Management
프레임워크에서 사용하는 여러 상태의 경우 전역으로 관리합니다. 여기서는 Riverpod의 Notifier와 Provider를 사용하여 상태를 관리하고, Use Case를 직접적으로 사용한다고 생각하면 좋습니다.
final asyncTodosProvider = AsyncNotifierProvider<AsyncTodosNotifier, List<Todo>>(() {
return AsyncTodosNotifier();
});
class AsyncTodosNotifier extends AsyncNotifier<List<Todo>> {
final TodoUseCase _todoUseCase;
@override
Future<List<Todo>> build() async {
return _todoUseCase.fetchTodos();
}
Future<void> getTodos() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await _todoUseCase.fetchTodos();
});
}
** Future<void> addTodo(Todo todo) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await _todoUseCase.addTodo(state, todo);
return _todoUseCase._fetchTodos();
});
}
}
🥕 Interface Adatper
Flutter를 활용하여 Todo의 List를 보여주는 UI를 제작합니다.
class TodoListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<Todo>> todos = ref.watch(asyncTodosProvider);
return todos.when(
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
data: (todoList) {
return ListView.builder(
itemCount: todoList.length,
itemBuilder: (context, index) {
final todo = todoList[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.isCompleted,
onChanged: (_) {
ref.read(asyncTodosProvider.notifier).toggleTodoCompletion(todo.id);
},
),
);
},
);
},
);
}
}
void main() {
runApp(ProviderScope(
child: MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Todo List')),
body: TodoListScreen(),
),
),
));
}
이런 과정을 기반으로 Entity, Use Case(+State Management), Interface Adapter(UI), Framework & Driver(+Repository) 계층에 대한 직접적인 구현을 진행해 보았습니다.
'개발 > Flutter' 카테고리의 다른 글
[Flutter] TDD (Test Driven Development) (0) | 2024.02.02 |
---|---|
[Flutter] MVVM (0) | 2024.01.31 |
[Flutter] (Project) MapleApp: 30. 리젝 (1) | 2024.01.25 |
[Flutter] (Project) MapleApp: 29. 배포 전 준비 (0) | 2024.01.24 |
[Flutter] (Project) MapleApp: 28. 예외 처리 작업-마무리 (0) | 2024.01.23 |