Flutter Application/앱 설계

클린 아키텍처(Clean Architecture With Flutter)

sdchjjj 2025. 2. 9. 20:36
반응형

클린 아키텍처(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. 클린 아키텍처 적용 방법

클린 아키텍처를 실제 프로젝트에 적용하기 위한 주요 단계는 다음과 같습니다:

  1. 도메인 모델 정의: 시스템의 핵심 비즈니스 로직과 규칙을 도메인 모델로 정의합니다.
  2. 계층화 설계: 비즈니스 로직, 응용 프로그램 로직, 데이터베이스, 사용자 인터페이스 등을 명확히 구분하여 계층화합니다.
  3. 의존성 관리: 각 계층 간의 의존성을 명확히 하여, 바깥쪽 계층에서 안쪽 계층으로만 의존하도록 합니다.
  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());
              },
            ),
          ),
        ],
      ),
    );
  }
}

 

 

파일의 구조는 다음과 같습니다.

figure1

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 등 화면 상태를 정의.
  • 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와 비즈니스 로직을 완전히 분리할 수 있음.

이제 테스트를 해보겠습니다.

result1

 

result2

잘 동작하고 있습니다.

 

클린 아키텍처는 소프트웨어 시스템을 유연하고, 유지보수가 쉬우며, 확장 가능한 구조로 만들기 위한 강력한 아키텍처 패턴입니다. 이를 적용하면 시스템의 각 계층이 명확히 분리되고, 비즈니스 로직과 기술적인 세부 사항을 독립적으로 관리할 수 있게 됩니다. 특히, 변화가 잦은 프로젝트나 대규모 시스템에서 클린 아키텍처는 유지보수 비용을 절감하고, 품질을 향상시키는 데 큰 도움이 됩니다.

클린 아키텍처를 적용하는 데 있어 처음에는 어려울 수 있지만, 그 이점은 시간이 지남에 따라 분명히 나타나게 됩니다.

반응형

'Flutter Application > 앱 설계' 카테고리의 다른 글

Flutter 상태 관리 - Bloc pattern  (1) 2024.11.29
Flutter app 상태 관리 - state hoisting  (0) 2024.11.28