Study/기타

Flutter의 Clean Architecture 클린아키텍처, 각 Layer에 대하여

코딩 잘 할거얌:) 2024. 6. 20. 21:17
반응형

앞서 배웠던 내용으로 Flutter에 Clean architecture를 적용해 보도록 하자.


 

드디어 Layer별 어떤 폴더들을 가지게되는지 설명하게 되었다.

앞서 설명한 OOP(객체 지향 프로그래밍)과 SOLID원칙을 기초로 하여 구현을 하는 게 목표이다. 하나하나씩 살펴보자.

Domain Layer

Domain Layer는 애플리케이션의 비즈니스 로직을 담고 있는 계층이다. 이 계층은 프레임워크에 독립적이며, 순수한 Dart 코드로 작성되며, 테스트가 용이하고, 다른 기술 스택으로 교체할 때도 영향을 최소화하도록 한다.

  • Entities
  • Use Cases
  • Repositories(Interfaces)

Domain Layer에서는 위 3가지 요소들이 포함이 되어있다.

하나하나씩 살펴보도록 하자.

Entities

Entities는 애플리케이션의 주요 비즈니스 객체를 표현한다.

class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});
}

 

Use Cases

유즈 케이스는 특정 비즈니스 요구사항을 수행하는 단위 작업을 정의하며, 각 유즈 케이스는 하나의 메서드만을 가져야 하며, 비즈니스 로직을 수행한다.

class GetUserDetails {
  final UserRepository repository;

  GetUserDetails(this.repository);

  Future<User> excute(int userId) {
    return repository.getUserById(userId);
  }
}

 

 

Repositories(Interfaces)

Repositories는 데이터 소스와 상호작용하는 인터페이스를 정의한다. 리포지토리 구현체가 Domain Layer에 종속되지 않도록 해야 한다.

abstract class UserRepository {
  Future<User> getUserById(int id);
  Future<List<User>> getAllUsers();
}

 

Data Layer

데이터 소스와 레포지토리 구현체를 포함한다. 예를 들어, API 호출이나 로컬 데이터베이스와의 상호작용을 담당하게 된다.

  • Models
  • Repositories
  • Data Sources

Data Layer에서는 위 3가지로 이루어져 있다. 여기서 Domain Layer에서 Repositories가 중복된다고 생각할 수 있지만, Data Layer에서 Repositories는 Domain Layer의 Repositories에서 정의된 것을 구현하고 실제 데이터 소스와 상호작용하는 곳이다.

Models

엔터티를 표현하는 데이터 구조체로, 일반적으로 JSON 등의 데이터를 파싱하고 변환하는 역할을 한다.

class UserModel {
  final int id;
  final String name;
  final String email;

  UserModel({required this.id, required this.name, required this.email});

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'],
      name: json['name'],
      email: json['email'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
    };
  }
}

 

 

 

Repositories

Domain Layer에서 정의된 Repositories interface를 구현하여 실제 데이터소스와 상호작용한다. 

mport 'package:dartz/dartz.dart';
import 'package:flutter_clean_architecture/core/error/failures.dart';
import 'package:flutter_clean_architecture/features/user/domain/entities/user.dart';
import 'package:flutter_clean_architecture/features/user/domain/repositories/user_repository.dart';
import 'package:flutter_clean_architecture/features/user/data/datasources/user_remote_datasource.dart';
import 'package:flutter_clean_architecture/features/user/data/datasources/user_local_datasource.dart';
import 'package:flutter_clean_architecture/features/user/data/models/user_model.dart';

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;
  final UserLocalDataSource localDataSource;

  UserRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
  });

  @override
  Future<Either<Failure, User>> getUserById(int id) async {
    try {
      final remoteUser = await remoteDataSource.getUserById(id);
      localDataSource.cacheUser(remoteUser);
      return Right(remoteUser);
    } on Exception {
      try {
        final localUser = await localDataSource.getUserById(id);
        return Right(localUser);
      } on Exception {
        return Left(ServerFailure());
      }
    }
  }

  @override
  Future<Either<Failure, List<User>>> getAllUsers() async {
    try {
      final remoteUsers = await remoteDataSource.getAllUsers();
      localDataSource.cacheUsers(remoteUsers);
      return Right(remoteUsers);
    } on Exception {
      try {
        final localUsers = await localDataSource.getAllUsers();
        return Right(localUsers);
      } on Exception {
        return Left(ServerFailure());
      }
    }
  }
}

Data Sources

실제 데이터소스와 상호작용을 담당하며, API호출하는 Remote부분과 Local부분이 따로 정의된다.

abstract class UserRemoteDataSource {
  Future<UserModel> getUserById(int id);
  Future<List<UserModel>> getAllUsers();
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final http.Client client;

  UserRemoteDataSourceImpl({required this.client});

  @override
  Future<UserModel> getUserById(int id) async {
    final response = await client.get(Uri.parse('https://api.example.com/users/$id'));

    if (response.statusCode == 200) {
      return UserModel.fromJson(json.decode(response.body));
    } else {
      throw Exception('Failed to load user');
    }
  }

  @override
  Future<List<UserModel>> getAllUsers() async {
    final response = await client.get(Uri.parse('https://api.example.com/users'));

    if (response.statusCode == 200) {
      Iterable jsonResponse = json.decode(response.body);
      return List<UserModel>.from(jsonResponse.map((model) => UserModel.fromJson(model)));
    } else {
      throw Exception('Failed to load users');
    }
  }
}

------------------------------

abstract class UserLocalDataSource {
  Future<UserModel> getUserById(int id);
  Future<List<UserModel>> getAllUsers();
  Future<void> cacheUser(UserModel user);
  Future<void> cacheUsers(List<UserModel> users);
}

class UserLocalDataSourceImpl implements UserLocalDataSource {
  final Database database;

  UserLocalDataSourceImpl({required this.database});

  @override
  Future<UserModel> getUserById(int id) async {
    final List<Map<String, dynamic>> maps = await database.query(
      'users',
      where: 'id = ?',
      whereArgs: [id],
    );

    if (maps.isNotEmpty) {
      return UserModel.fromJson(maps.first);
    } else {
      throw Exception('No user found');
    }
  }

  @override
  Future<List<UserModel>> getAllUsers() async {
    final List<Map<String, dynamic>> maps = await database.query('users');

    return List<UserModel>.from(maps.map((map) => UserModel.fromJson(map)));
  }

  @override
  Future<void> cacheUser(UserModel user) async {
    await database.insert(
      'users',
      user.toJson(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  @override
  Future<void> cacheUsers(List<UserModel> users) async {
    for (var user in users) {
      await cacheUser(user);
    }
  }
}

 

Presentation Layer

UI 관련 코드와 상태 관리를 포함한다. Bloc, Riverpod, Provider, GetX 등 상태 관리 라이브러리를 사용하여 UI와 비즈니스 로직을 연결한다. 여기서 MVC, MVVM, MVP 등 디자인패턴을 적용하는 곳이다.

  • Providers
  • Pages
  • Widgets

Presentation Layer에서는 위 세 가지로 나눌 수 있다. 물론 MVC, MVVM 등 다른 디자인패턴을 적용하거나 어떤 상태관리를 쓰냐에 따라 구조가 달라질 수 있다. 그래서 여기서는 riverpod을 적용했다는 가정하에 작성하도록 하겠다.

Providers

상태 관리 및 의존성 주입을 위한 프로바이더들을 정의한다. 어떤 상태관리 및 디자인패턴을 적용했냐에 따라 폴더명이 달라질 수 있고 코드도 달라질 수 있다.

// lib/features/user/presentation/providers/user_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_clean_architecture/features/user/domain/entities/user.dart';
import 'package:flutter_clean_architecture/features/user/domain/usecases/get_user_details.dart';
import 'package:flutter_clean_architecture/core/error/failures.dart';
import 'package:flutter_clean_architecture/features/user/presentation/state/user_state.dart';
import 'package:flutter_clean_architecture/features/user/data/models/user_model.dart';

part 'user_providers.g.dart';

@riverpod
class UserProvider extends _$UserProvider {
  @override
  AsyncValue<User> build() => const AsyncData(User(id: 0, name: '', email: ''));

  Future<void> loadUser(int userId) async {
    if (state.isLoading) return;

    state = const AsyncLoading();

    try {
      final Either<Failure, User> result = await GetUserDetails().execute(userId);

      result.fold(
        (failure) => state = AsyncError(failure, StackTrace.current),
        (user) => state = AsyncData(user),
      );
    } catch (error) {
      state = AsyncError(
        CustomErrorModel.errorModel(
          errorCode: '700',
          errorDescription: 'User provider error $error',
          errorMessage: '사용자 정보를 불러오는데 실패했습니다.',
        ),
        StackTrace.current,
      );
    }
  }
}

Pages

주요 화면(페이지) 컴포넌트들을 정의하며, 각 페이지는 일반적으로 앱 내의 주요 내비게이션 단위이다.

// lib/features/user/presentation/pages/user_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_clean_architecture/features/user/presentation/providers/user_providers.dart';
import 'package:flutter_clean_architecture/features/user/presentation/widgets/user_details.dart';

class UserPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final userState = watch(userStateNotifierProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('User Details'),
      ),
      body: userState.isLoading
          ? Center(child: CircularProgressIndicator())
          : userState.error != null
              ? Center(child: Text(userState.error!))
              : userState.user != null
                  ? UserDetails(user: userState.user!)
                  : Center(child: Text('No user loaded')),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          context.read(userStateNotifierProvider.notifier).loadUser(1);
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}

// lib/features/user/presentation/widgets/user_details.dart
import 'package:flutter/material.dart';
import 'package:flutter_clean_architecture/features/user/domain/entities/user.dart';

class UserDetails extends StatelessWidget {
  final User user;

  UserDetails({required this.user});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          Text('ID: ${user.id}'),
          Text('Name: ${user.name}'),
          Text('Email: ${user.email}'),
        ],
      ),
    );
  }
}

Widgets

재사용 가능한 UI 위젯들을 정의한다. 이러한 위젯들은 여러 페이지에서 재사용될 수 있고, UI 구성 요소를 모듈화 하는데 용이하다.

 

// lib/features/user/presentation/widgets/user_details.dart
import 'package:flutter/material.dart';
import 'package:flutter_clean_architecture/features/user/domain/entities/user.dart';

class UserDetails extends StatelessWidget {
  final User user;

  UserDetails({required this.user});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          Text('ID: ${user.id}'),
          Text('Name: ${user.name}'),
          Text('Email: ${user.email}'),
        ],
      ),
    );
  }
}

 

폴더구조

lib/
├── features/
│   ├── user/
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   │   └── user.dart
│   │   │   ├── usecases/
│   │   │   │   └── get_user_details.dart
│   │   │   └── repositories/
│   │   │       └── user_repository.dart
│   │   ├── data/
│   │   │   ├── models/
│   │   │   │   └── user_model.dart
│   │   │   ├── repositories/
│   │   │   │   └── user_repository_impl.dart
│   │   │   └── datasources/
│   │   │       ├── user_remote_datasource.dart
│   │   │       └── user_local_datasource.dart
│   │   └── presentation/
│   │       ├── providers/
│   │       │   └── user_providers.dart
│   │       ├── pages/
│   │       │   └── user_page.dart
│   │       ├── widgets/
│   │       │   └── user_details.dart
└── core/
    ├── error/
    ├── network/
    └── util/

 

이렇게 폴더구조까지 작성하면 Clean Architecture를 적용할 수 있다.

개인적인 견해

개인적으로 클린아키텍처는 무조건 좋다. 100% 좋다.라고 생각하지는 않는다. 우리가 클린아키텍처를 적용하는 이유는 "장기적인 유지보수 및 확장성" 때문이다.

하지만 우리는 개발을 하면 "장기적인 유지보수 및 확장성" 보다 더 우선시 되는 것들이 많다. 예를 들면 단기적인 효율성 및 생산성을 중시하여 빠른 개발을 원하는 경우에는 반복되는 코드들과 boiler plate들이 생성이 되어 오히려 걸림돌이 되는 경우가 발생할 수 있다. 그래서 클린아키텍처를 무조건 적용하는 것보다 그에 맞는 상황에 따라 적용하는 것이 가장 중요할 것이다.

728x90