앱 아키텍처 — Clean Architecture를 Flutter에 적용하기

프로젝트가 커지면 코드를 어디에 놓을지, 레이어를 어떻게 나눌지가 중요해집니다. Clean Architecture는 관심사를 분리하여 테스트와 유지보수를 쉽게 만드는 아키텍처 패턴입니다.


Clean Architecture의 3개 레이어

PLAINTEXT
┌──────────────────────────────────────┐
│        Presentation Layer            │
│  (UI, State Management)             │
├──────────────────────────────────────┤
│        Domain Layer                  │
│  (Entities, Use Cases, Repositories) │
├──────────────────────────────────────┤
│        Data Layer                    │
│  (API, DB, Repository Impl)         │
└──────────────────────────────────────┘
레이어역할포함 요소
PresentationUI, 사용자 인터랙션Widget, Bloc/Provider, State
Domain비즈니스 로직 (핵심)Entity, UseCase, Repository(인터페이스)
Data외부 데이터 소스API Client, DB, Repository(구현체)

면접 포인트: "의존성 방향은 항상 안쪽(Domain)을 향해야 합니다." Domain 레이어는 다른 레이어에 의존하지 않습니다.


디렉토리 구조

PLAINTEXT
lib/
├── core/
│   ├── error/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   └── network_info.dart
│   └── usecases/
│       └── usecase.dart
├── features/
│   └── auth/
│       ├── data/
│       │   ├── datasources/
│       │   │   ├── auth_remote_datasource.dart
│       │   │   └── auth_local_datasource.dart
│       │   ├── models/
│       │   │   └── user_model.dart
│       │   └── repositories/
│       │       └── auth_repository_impl.dart
│       ├── domain/
│       │   ├── entities/
│       │   │   └── user.dart
│       │   ├── repositories/
│       │   │   └── auth_repository.dart
│       │   └── usecases/
│       │       ├── login.dart
│       │       └── signup.dart
│       └── presentation/
│           ├── bloc/
│           │   ├── auth_bloc.dart
│           │   ├── auth_event.dart
│           │   └── auth_state.dart
│           ├── pages/
│           │   └── login_page.dart
│           └── widgets/
│               └── login_form.dart

Domain Layer

Entity (핵심 비즈니스 객체)

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

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

Repository (인터페이스)

DART
import 'package:dartz/dartz.dart';

abstract class AuthRepository {
  Future<Either<Failure, User>> login(String email, String password);
  Future<Either<Failure, User>> signup(String name, String email, String password);
  Future<Either<Failure, void>> logout();
}

UseCase

DART
abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

class LoginUseCase implements UseCase<User, LoginParams> {
  final AuthRepository repository;

  const LoginUseCase(this.repository);

  @override
  Future<Either<Failure, User>> call(LoginParams params) {
    return repository.login(params.email, params.password);
  }
}

class LoginParams {
  final String email;
  final String password;

  const LoginParams({required this.email, required this.password});
}

Data Layer

Model (Entity + JSON 변환)

DART
class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

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

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

DataSource

DART
abstract class AuthRemoteDataSource {
  Future<UserModel> login(String email, String password);
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final Dio dio;

  AuthRemoteDataSourceImpl(this.dio);

  @override
  Future<UserModel> login(String email, String password) async {
    final response = await dio.post('/auth/login', data: {
      'email': email,
      'password': password,
    });
    return UserModel.fromJson(response.data);
  }
}

Repository 구현체

DART
class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final AuthLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, User>> login(
    String email,
    String password,
  ) async {
    if (await networkInfo.isConnected) {
      try {
        final user = await remoteDataSource.login(email, password);
        await localDataSource.cacheUser(user);
        return Right(user);
      } on ServerException {
        return const Left(ServerFailure('서버 에러'));
      }
    } else {
      return const Left(NetworkFailure('네트워크 연결 없음'));
    }
  }
}

Presentation Layer

DART
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;

  AuthBloc({required this.loginUseCase}) : super(AuthInitial()) {
    on<LoginRequested>(_onLoginRequested);
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());

    final result = await loginUseCase(
      LoginParams(email: event.email, password: event.password),
    );

    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (user) => emit(AuthAuthenticated(user)),
    );
  }
}

Either 타입 (에러 처리)

YAML
dependencies:
  dartz: ^0.10.0  # 또는 fpdart
DART
// Either<Left, Right> — Left는 실패, Right는 성공
Future<Either<Failure, User>> login(...) async {
  try {
    final user = await dataSource.login(...);
    return Right(user);  // 성공
  } catch (e) {
    return Left(ServerFailure('에러'));  // 실패
  }
}

// 사용
result.fold(
  (failure) => showError(failure.message),
  (user) => navigateToHome(user),
);

정리

  • Clean Architecture는 Domain(핵심) → Data(외부) → Presentation(UI) 순으로 의존합니다
  • Domain 레이어는 프레임워크에 의존하지 않아 순수 Dart로 작성합니다
  • UseCase로 비즈니스 로직을 캡슐화하면 테스트와 재사용이 쉽습니다
  • Repository 인터페이스(Domain)와 구현체(Data)를 분리하면 데이터 소스 교체가 용이합니다
  • 소규모 프로젝트에서는 오버엔지니어링이 될 수 있으니 팀 규모와 복잡도에 맞게 적용하세요
댓글 로딩 중...