클린 아키텍처(Clean Architecture)는 소프트웨어 개발에서 유지 보수성과 확장성, 테스트 용이성을 높이기 위해 제안된 아키텍처 패턴입니다. 이 아키텍처의 핵심은 코드의 구조를 명확하게 분리하고, 변경에 유연하게 대응할 수 있도록 설계하는 것입니다. 이번 포스팅에서는 클린 아키텍처의 주요 개념과 이를 적용하는 방법에 대해 살펴보겠습니다.
언어: dart
IDE: Android Studio
Framework: Flutter
Test device: Android
1. 클린 아키텍처의 개요
클린 아키텍처는 로버트 C. 마틴(Robert C. Martin, 'Uncle Bob')이 제시한 아키텍처 패턴입니다. 주요 목표는 소프트웨어 시스템을 구성하는 각 계층을 독립적으로 설계하여, 의존성을 최소화하고, 시스템의 변화에 강하게 만드는 것입니다. 이를 위해 시스템의 각 컴포넌트가 어떻게 의존 관계를 맺을지 명확히 정의하고, 의존성 역전 원칙(Dependency Inversion Principle)을 따른다고 볼 수 있습니다.
2. 클린 아키텍처의 주요 원칙
클린 아키텍처의 핵심 원칙은 다음과 같습니다:
2.1 의존성 규칙
- 의존성은 항상 바깥에서 안으로 향해야 하며, 이는 각 계층이 내부 계층에 의존할 수는 있지만, 그 반대는 허용되지 않음을 의미합니다. 즉, 바깥쪽 계층은 내부 계층을 호출할 수 있지만, 내부 계층은 바깥쪽 계층에 의존해서는 안 됩니다.
- 이러한 규칙은 **의존성 역전 원칙(Dependency Inversion Principle)**을 따릅니다. 이를 통해 각 계층은 독립적으로 변경될 수 있습니다.
2.2 계층화된 구조
클린 아키텍처는 시스템을 여러 개의 계층으로 나누어 설계합니다. 각 계층은 서로 다른 책임을 가지고 있으며, 주요 계층은 다음과 같습니다:
- 엔터프라이즈 비즈니스 규칙(Entities): 시스템에서 핵심적인 도메인 모델을 포함합니다. 데이터나 비즈니스 로직을 정의하고, 다른 계층에 의존하지 않습니다.
- 응용 비즈니스 규칙(Use Cases): 엔터프라이즈 비즈니스 규칙을 이용하여 애플리케이션의 요구 사항을 구현합니다. 주로 서비스나 인터페이스에 해당합니다.
- 인터페이스 어댑터(Interface Adapters): 외부 시스템과 상호작용하는 부분을 담당합니다. 예를 들어, 데이터베이스, UI, API 등과의 연결을 처리합니다.
- 프레임워크와 드라이버(Frameworks and Drivers): 외부 라이브러리나 프레임워크, 데이터베이스 등과 같은 기술적인 구현을 처리합니다.
2.3 경계와 데이터 흐름
클린 아키텍처에서는 각 계층 간의 경계를 명확히 정의하고, 데이터를 전달할 때 계층 간 데이터 흐름을 제어합니다. 데이터는 외부 계층에서 내부 계층으로 흐를 수는 있지만, 그 반대는 허용되지 않습니다.
3. 클린 아키텍처의 이점
3.1 유지보수성 증가
클린 아키텍처는 각 계층이 독립적으로 설계되어, 하나의 계층을 변경해도 다른 계층에 미치는 영향을 최소화합니다. 이로 인해 시스템 유지 보수와 수정이 용이합니다.
3.2 테스트 용이성
각 계층은 독립적으로 테스트할 수 있습니다. 예를 들어, 비즈니스 로직이나 데이터 변환 로직을 별도로 테스트할 수 있어 테스트가 용이하고, 품질을 보장할 수 있습니다.
3.3 확장성
새로운 기능이나 모듈을 추가할 때, 기존 시스템에 미치는 영향을 최소화하면서 확장이 가능합니다. 새로운 인터페이스나 기능을 추가하려면 기존 계층과의 의존성만 고려하면 되기 때문에, 시스템이 커져도 유연하게 대응할 수 있습니다.
3.4 기술 독립성
클린 아키텍처는 기술 스택에 의존하지 않도록 설계되어, 새로운 기술이나 프레임워크를 도입하는 데 유리합니다. 예를 들어, 데이터베이스나 웹 프레임워크를 변경하더라도 핵심 비즈니스 로직에 영향이 적습니다.
4. 클린 아키텍처 적용 방법
클린 아키텍처를 실제 프로젝트에 적용하기 위한 주요 단계는 다음과 같습니다:
- 도메인 모델 정의: 시스템의 핵심 비즈니스 로직과 규칙을 도메인 모델로 정의합니다.
- 계층화 설계: 비즈니스 로직, 응용 프로그램 로직, 데이터베이스, 사용자 인터페이스 등을 명확히 구분하여 계층화합니다.
- 의존성 관리: 각 계층 간의 의존성을 명확히 하여, 바깥쪽 계층에서 안쪽 계층으로만 의존하도록 합니다.
- 테스트 주도 개발(TDD): 각 계층의 테스트를 독립적으로 작성하여, 시스템 전반의 안정성을 높입니다.
이를 적용한 간단한 todo 앱을 만들어 보겠습니다.
우선 이번 예제에 사용할 dependecies를 추가합니다.
- flutter_bloc(v9.0.0 현재 최신)
flutter pub add flutter_bloc
1. main.dart
Flutter 앱의 진입점으로, MaterialApp을 설정하고 Bloc을 제공하는 역할을 합니다.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_practice/src/presentation/bloc/blocs/todo_bloc.dart';
import 'package:flutter_practice/src/presentation/pages/todo_page.dart';
import 'data/datasources/todo_local_data_source.dart';
import 'data/repositories/todo_repository_impl.dart';
import 'domain/repositories/todo_repository.dart';
import 'domain/usecases/add_todo.dart';
import 'domain/usecases/get_todos.dart';
import 'domain/usecases/remove_todo.dart';
import 'domain/usecases/toggle_todo.dart';
void main() {
final todoRepository = TodoRepositoryImpl(TodoLocalDataSourceImpl());
runApp(MyApp(todoRepository: todoRepository));
}
class MyApp extends StatelessWidget {
final TodoRepository todoRepository;
const MyApp({Key? key, required this.todoRepository}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => TodoBloc(
getTodos: GetTodos(todoRepository),
addTodo: AddTodo(todoRepository),
toggleTodo: ToggleTodo(todoRepository),
removeTodo: RemoveTodo(todoRepository),
),
child: MaterialApp(
title: 'Flutter Clean Architecture TODO',
theme: ThemeData(primarySwatch: Colors.blue),
home: TodoPage(),
),
);
}
}
2. todo.dart
비즈니스 로직의 기본 데이터(Entity)를 정의합니다.
class Todo {
final int id;
final String title;
final bool isCompleted;
Todo({
required this.id,
required this.title,
this.isCompleted = false,
});
Todo copyWith({int? id, String? title, bool? isCompleted}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
3. todo_repository.dart
Repository 인터페이스를 정의하여, data 계층과 domain 계층을 분리합니다.
import '../entities/todo.dart';
abstract class TodoRepository {
Future<List<Todo>> getTodos();
Future<void> addTodo(Todo todo);
Future<void> toggleTodo(int id);
Future<void> removeTodo(int id);
}
4. todo_repository_impl.dart
데이터 저장소를 구현합니다. 여기서는 List<Todo> 를 메모리에 저장합니다.
import '../../domain/entities/todo.dart';
import '../../domain/repositories/todo_repository.dart';
import '../datasources/todo_local_data_source.dart';
class TodoRepositoryImpl implements TodoRepository {
final TodoLocalDataSource localDataSource;
TodoRepositoryImpl(this.localDataSource);
@override
Future<List<Todo>> getTodos() async {
return await localDataSource.getTodos();
}
@override
Future<void> addTodo(Todo todo) async {
final todos = await localDataSource.getTodos();
todos.add(todo);
await localDataSource.saveTodos(todos);
}
@override
Future<void> toggleTodo(int id) async {
final todos = await localDataSource.getTodos();
final updatedTodos = todos.map((todo) {
if (todo.id == id) {
return todo.copyWith(isCompleted: !todo.isCompleted);
}
return todo;
}).toList();
await localDataSource.saveTodos(updatedTodos);
}
@override
Future<void> removeTodo(int id) async {
final todos = await localDataSource.getTodos();
final updatedTodos = todos.where((todo) => todo.id != id).toList();
await localDataSource.saveTodos(updatedTodos);
}
}
5. usecase 생성
- 각 기능별 Use Case 클래스를 생성해야 합니다.
- BLoC은 유즈케이스를 호출하고, 유즈케이스는 Repository를 호출하도록 해야 합니다.
5-1. get_todos.dart
import '../entities/todo.dart';
import '../repositories/todo_repository.dart';
class GetTodos {
final TodoRepository repository;
GetTodos(this.repository);
Future<List<Todo>> call() async {
return await repository.getTodos();
}
}
5-2. add_todo.dart
import '../entities/todo.dart';
import '../repositories/todo_repository.dart';
class AddTodo {
final TodoRepository repository;
AddTodo(this.repository);
Future<void> call(String title) async {
final newTodo = Todo(id: DateTime.now().millisecondsSinceEpoch, title: title);
await repository.addTodo(newTodo);
}
}
5-3. toggle_todo.dart
import '../repositories/todo_repository.dart';
class ToggleTodo {
final TodoRepository repository;
ToggleTodo(this.repository);
Future<void> call(int id) async {
await repository.toggleTodo(id);
}
}
5-4. remove_todo.dart
import '../repositories/todo_repository.dart';
class RemoveTodo {
final TodoRepository repository;
RemoveTodo(this.repository);
Future<void> call(int id) async {
await repository.removeTodo(id);
}
}
6. todo_local_data_source.dart
import '../../domain/entities/todo.dart';
abstract class TodoLocalDataSource {
Future<List<Todo>> getTodos();
Future<void> saveTodos(List<Todo> todos);
}
class TodoLocalDataSourceImpl implements TodoLocalDataSource {
List<Todo> _todos = [];
@override
Future<List<Todo>> getTodos() async {
return _todos;
}
@override
Future<void> saveTodos(List<Todo> todos) async {
_todos = todos;
}
}
7. todo_bloc.dart
BLoC 패턴을 사용하여 상태를 관리합니다.
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/usecases/add_todo.dart';
import '../../../domain/usecases/get_todos.dart';
import '../../../domain/usecases/remove_todo.dart';
import '../../../domain/usecases/toggle_todo.dart';
import '../events/todo_event.dart';
import '../states/todo_state.dart';
class TodoBloc extends Bloc<TodoEvent, TodoState> {
final GetTodos getTodos;
final AddTodo addTodo;
final ToggleTodo toggleTodo;
final RemoveTodo removeTodo;
TodoBloc({
required this.getTodos,
required this.addTodo,
required this.toggleTodo,
required this.removeTodo,
}) : super(TodoInitial()) {
on<LoadTodosEvent>((event, emit) async {
final todos = await getTodos();
emit(TodoLoaded(todos));
});
on<AddTodoEvent>((event, emit) async {
await addTodo(event.title);
final todos = await getTodos();
emit(TodoLoaded(todos));
});
on<ToggleTodoEvent>((event, emit) async {
await toggleTodo(event.id);
final todos = await getTodos();
emit(TodoLoaded(todos));
});
on<RemoveTodoEvent>((event, emit) async {
await removeTodo(event.id);
final todos = await getTodos();
emit(TodoLoaded(todos));
});
}
}
8. todo_event.dart
abstract class TodoEvent {}
class LoadTodosEvent extends TodoEvent {}
class AddTodoEvent extends TodoEvent {
final String title;
AddTodoEvent(this.title);
}
class ToggleTodoEvent extends TodoEvent {
final int id;
ToggleTodoEvent(this.id);
}
class RemoveTodoEvent extends TodoEvent {
final int id;
RemoveTodoEvent(this.id);
}
9. todo_state.dart
import '../../../domain/entities/todo.dart';
abstract class TodoState {}
class TodoInitial extends TodoState {}
class TodoLoaded extends TodoState {
final List<Todo> todos;
TodoLoaded(this.todos);
}
10. todo_page.dart
UI를 구성하는 페이지입니다.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/blocs/todo_bloc.dart';
import '../bloc/events/todo_event.dart';
import '../bloc/states/todo_state.dart';
class TodoPage extends StatelessWidget {
final TextEditingController _controller = TextEditingController();
TodoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Clean Architecture TODO')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Enter new todo',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 10),
IconButton(
icon: const Icon(Icons.add, color: Colors.blue),
onPressed: () {
if (_controller.text.isNotEmpty) {
context.read<TodoBloc>().add(AddTodoEvent(_controller.text));
_controller.clear();
}
},
)
],
),
),
Expanded(
child: BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
if (state is TodoLoaded) {
return ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: ListTile(
title: Text(
todo.title,
style: TextStyle(
fontSize: 18,
decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
),
),
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) {
context.read<TodoBloc>().add(ToggleTodoEvent(todo.id));
},
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
context.read<TodoBloc>().add(RemoveTodoEvent(todo.id));
},
),
),
);
},
);
}
return const Center(child: CircularProgressIndicator());
},
),
),
],
),
);
}
}
파일의 구조는 다음과 같습니다.
lib/
├── src/
│ ├── data/ # ✅ 데이터 계층 (Infrastructure Layer)
│ │ ├── datasources/ # 데이터 원본 (ex: API, Local DB)
│ │ ├── repositories/ # 데이터 저장소 구현체
│ ├── domain/ # ✅ 도메인 계층 (Business Rules)
│ │ ├── entities/ # 엔터티 (핵심 데이터 모델)
│ │ ├── repositories/ # Repository 인터페이스 (추상화)
│ │ ├── usecases/ # Use Case (비즈니스 로직)
│ ├── presentation/ # ✅ 프레젠테이션 계층 (UI & 상태관리)
│ │ ├── bloc/ # BLoC (State Management)
│ │ │ ├── blocs/ # BLoC 클래스
│ │ │ ├── events/ # BLoC 이벤트
│ │ │ ├── states/ # BLoC 상태
│ │ ├── pages/ # UI 페이지
├── main.dart # ✅ 앱의 진입점
1. Domain 계층 (핵심 비즈니스 로직)
"애플리케이션의 핵심 비즈니스 규칙이 포함된 계층"
이 계층은 외부 프레임워크나 데이터 소스와 독립적이어야 하며, 애플리케이션이 수행하는 비즈니스 로직을 정의합니다.
domain/
- entities/ (todo.dart)
- 핵심 데이터 모델 (ex: Todo)
- ex: id, title, isCompleted 등의 필드를 가지는 순수 클래스.
- UI나 DB에 의존하지 않는 순수 Dart 객체 (POJO)
- repositories/ (todo_repository.dart)
- Repository 인터페이스 (추상 클래스)
- data 계층에서 구현됨 (ex: TodoRepositoryImpl)
- 비즈니스 로직이 data 계층의 구현체에 직접 의존하지 않도록 하기 위함.
- usecases/ (add_todo.dart, get_todos.dart 등)
- 비즈니스 로직을 실행하는 서비스 계층
- TodoRepository 인터페이스를 활용하여 기능을 수행
- 각 유즈케이스는 단일 책임 원칙(SRP)을 따름
- 예를 들면, AddTodo 유즈케이스는 새로운 할 일을 추가하는 작업만 담당.
이 계층의 핵심:
- 외부 의존성 없음 (DB, UI, 네트워크 등과 독립적)
- 모든 비즈니스 로직이 여기에 존재해야 함
- Use Case는 Repository 인터페이스를 통해 데이터 계층과 통신
2. Data 계층 (데이터 관리)
"데이터를 관리하는 계층 (DB, API, Local Storage 등)"
이 계층은 Repository 인터페이스의 실제 구현을 담당하며,
데이터를 외부 데이터 소스(Local DB, API 등)와 연결하는 역할을 수행합니다.
data/
- datasources/ (todo_local_data_source.dart)
- 데이터 저장 및 읽기 담당
- ex: SQLite, Firebase, API 호출 등의 데이터 접근 코드 포함
- RepositoryImpl에서 호출됨.
- repositories/ (todo_repository_impl.dart)
- domain/repositories/todo_repository.dart 인터페이스를 구현
- datasources를 이용하여 데이터를 조작
- 유즈케이스가 직접 데이터 소스와 통신하지 않도록 중간 역할 수행.
이 계층의 핵심:
- 실제 데이터를 저장, 조회하는 로직을 포함
- Repository 인터페이스를 구현하여 domain 계층과 연결
- Local, Remote DataSource를 활용하여 데이터 관리
3. Presentation 계층 (UI & 상태 관리)
"사용자에게 보여지는 UI와 상태 관리 담당"
이 계층에서는 사용자와의 상호작용을 담당하며,
BLoC 패턴을 활용하여 상태를 관리합니다.
presentation/
- bloc/
- blocs/ (todo_bloc.dart)
- TodoEvent를 받아 TodoState를 변경하는 역할
- Use Case를 호출하여 상태 업데이트
- events/ (todo_event.dart)
- LoadTodos, AddTodo, ToggleTodo, RemoveTodo 같은 이벤트 정의
- states/ (todo_state.dart)
- TodoInitial, TodoLoaded 등 화면 상태를 정의.
- blocs/ (todo_bloc.dart)
- pages/ (todo_page.dart)
- UI 화면 (Flutter Scaffold, ListView, TextField 등 포함)
- BlocBuilder 또는 BlocListener를 사용하여 상태 변화 감지
이 계층의 핵심:
- UI와 상태 관리를 담당 (BLoC 패턴 적용)
- Use Case를 호출하여 데이터를 가져오고 UI를 업데이트
- 의존성 방향이 항상 "Use Case → Repository"로 향해야 함
4. 앱의 진입점 (main.dart)
"앱을 실행하는 엔트리 포인트"
여기에서 BlocProvider를 사용하여 TodoBloc을 생성하고,
Repository와 UseCase를 주입합니다.
핵심 기능:
- Repository와 Use Case를 BlocProvider를 통해 Bloc에 주입
- MaterialApp을 실행하여 UI를 띄움
클린 아키텍처 의존성 방향
Presentation (UI, BLoC)
↓ (Use Case 호출)
Domain (Entities, Repositories, Use Cases)
↓ (Repository 인터페이스 사용)
Data (Repository 구현체, Data Sources)
↓ (DB, API 접근)
External Services (Firebase, SQLite 등)
이 구조를 따르면 장점
- Domain 계층이 독립적이므로 UI, 데이터베이스, API 변경이 자유로움.
- Use Case를 추가하면 비즈니스 로직 확장이 쉬움.
- Bloc을 활용하여 UI와 비즈니스 로직을 완전히 분리할 수 있음.
이제 테스트를 해보겠습니다.
잘 동작하고 있습니다.
클린 아키텍처는 소프트웨어 시스템을 유연하고, 유지보수가 쉬우며, 확장 가능한 구조로 만들기 위한 강력한 아키텍처 패턴입니다. 이를 적용하면 시스템의 각 계층이 명확히 분리되고, 비즈니스 로직과 기술적인 세부 사항을 독립적으로 관리할 수 있게 됩니다. 특히, 변화가 잦은 프로젝트나 대규모 시스템에서 클린 아키텍처는 유지보수 비용을 절감하고, 품질을 향상시키는 데 큰 도움이 됩니다.
클린 아키텍처를 적용하는 데 있어 처음에는 어려울 수 있지만, 그 이점은 시간이 지남에 따라 분명히 나타나게 됩니다.
'Flutter Application > 앱 설계' 카테고리의 다른 글
Flutter 상태 관리 - Bloc pattern (1) | 2024.11.29 |
---|---|
Flutter app 상태 관리 - state hoisting (0) | 2024.11.28 |