Flutter Clean Architecture

Clean Architecture, MVVM, Repository 패턴

Flutter고급
8시간
2개 항목
학습 진행률0 / 2 (0%)

학습 항목

1. Flutter
Flutter|Clean|Architecture|완벽|가이드
퀴즈튜토리얼
2. Flutter
Flutter|MVVM|패턴|완벽|가이드
퀴즈튜토리얼
1 / 2

이미지 로딩 중...

Flutter Clean Architecture 완벽 가이드 - 슬라이드 1/13

Flutter Clean Architecture 완벽 가이드

Flutter 앱 개발에서 Clean Architecture를 적용하는 방법을 처음부터 끝까지 배워봅니다. 실무에서 바로 사용할 수 있는 레이어 분리, 의존성 관리, 테스트 가능한 코드 작성법까지 모든 것을 다룹니다.


목차

  1. Clean Architecture 개요 - 왜 필요하고 어떤 구조인가
  2. Entity 레이어 - 순수한 비즈니스 객체
  3. Repository Interface - 데이터 계약 정의
  4. UseCase - 비즈니스 로직 실행
  5. Data Model - Entity와 JSON 변환
  6. Repository 구현체 - 실제 데이터 처리
  7. DataSource - API 호출 처리
  8. Dependency Injection - 의존성 주입 설정
  9. Bloc/State Management - UI 상태 관리
  10. Presentation Layer - UI 구성

1. Clean Architecture 개요 - 왜 필요하고 어떤 구조인가

시작하며

여러분이 Flutter 앱을 개발하다가 비즈니스 로직과 UI 코드가 뒤섞여서 버그를 찾기 힘들었던 적 있나요? 또는 API를 변경하려고 했는데 앱 전체를 수정해야 했던 경험이 있나요?

이런 문제는 코드의 역할이 명확하게 분리되지 않았을 때 발생합니다. Widget에서 직접 HTTP 요청을 보내고, 비즈니스 로직을 UI 레이어에 작성하면 코드가 점점 복잡해지고 유지보수가 어려워집니다.

바로 이럴 때 필요한 것이 Clean Architecture입니다. 코드를 명확한 레이어로 나누어 각 레이어가 독립적으로 동작하게 만들어줍니다.

개요

간단히 말해서, Clean Architecture는 코드를 3개의 주요 레이어(Presentation, Domain, Data)로 분리하는 아키텍처 패턴입니다. 실무에서는 여러 개발자가 협업하고, API가 자주 변경되며, 새로운 기능이 계속 추가됩니다.

Clean Architecture를 사용하면 이런 변화에 유연하게 대응할 수 있습니다. 예를 들어, REST API를 GraphQL로 바꾸더라도 Data 레이어만 수정하면 되고, UI를 Material에서 Cupertino로 바꾸더라도 비즈니스 로직은 그대로 유지됩니다.

기존에는 모든 코드를 한 파일에 작성했다면, 이제는 각 레이어가 명확한 책임을 가지고 독립적으로 동작합니다. Presentation은 UI만, Domain은 비즈니스 로직만, Data는 데이터 소스 처리만 담당합니다.

핵심 특징은 첫째, 의존성 규칙입니다. 안쪽 레이어(Domain)는 바깥쪽 레이어를 알지 못합니다.

둘째, 테스트 가능성입니다. 각 레이어를 독립적으로 테스트할 수 있습니다.

셋째, 유연성입니다. 한 레이어를 수정해도 다른 레이어에 영향을 주지 않습니다.

코드 예제

// Clean Architecture 폴더 구조
lib/
  features/
    user/
      domain/              // 비즈니스 로직 (가장 안쪽)
        entities/          // User 엔티티
        repositories/      // Repository 인터페이스
        usecases/          // GetUser UseCase
      data/                // 데이터 처리
        models/            // UserModel (JSON 변환)
        repositories/      // Repository 구현체
        datasources/       // API 호출
      presentation/        // UI
        pages/             // UserPage
        widgets/           // UserWidget
        bloc/              // UserBloc

설명

이것이 하는 일: Clean Architecture는 앱을 3개의 독립적인 레이어로 나누어 각 레이어가 명확한 책임을 가지도록 합니다. 첫 번째로, Domain 레이어(가장 안쪽)는 비즈니스 로직의 핵심입니다.

여기에는 Entity(순수한 데이터 객체), Repository Interface(데이터를 어떻게 가져올지 정의), UseCase(실제 비즈니스 로직)가 있습니다. 이 레이어는 Flutter나 외부 라이브러리를 전혀 몰라야 합니다.

왜냐하면 비즈니스 로직은 프레임워크와 무관하게 동작해야 하기 때문입니다. 그 다음으로, Data 레이어는 실제 데이터를 가져오는 방법을 구현합니다.

Repository의 구현체가 여기 있고, API 호출, 로컬 DB 접근, 캐싱 등을 처리합니다. Model 클래스는 JSON을 Entity로 변환하는 역할을 합니다.

이 레이어는 Domain의 인터페이스를 구현하므로, Domain은 Data의 구체적인 구현을 알 필요가 없습니다. 마지막으로, Presentation 레이어는 UI를 담당합니다.

Widget, Page, 상태 관리(Bloc, Provider 등)가 여기 포함됩니다. 이 레이어는 Domain의 UseCase를 호출하여 데이터를 가져오고, 그 결과를 화면에 표시합니다.

여러분이 이 구조를 사용하면 코드의 역할이 명확해져서 버그를 찾기 쉽고, 새로운 기능을 추가할 때도 어디에 코드를 작성해야 할지 바로 알 수 있습니다. 또한 각 레이어를 독립적으로 테스트할 수 있어 테스트 커버리지를 높일 수 있습니다.

실전 팁

💡 폴더 구조를 feature 단위로 나누세요. user, product, order 등 feature별로 domain/data/presentation을 만들면 코드를 찾기 쉽고 팀원 간 충돌이 줄어듭니다.

💡 Domain 레이어에는 절대 Flutter를 import하지 마세요. dart:core만 사용해야 합니다. 이렇게 해야 비즈니스 로직이 프레임워크에 독립적으로 유지됩니다.

💡 처음부터 완벽한 구조를 만들려고 하지 마세요. 작은 feature 하나부터 Clean Architecture를 적용해보고, 익숙해지면 점차 확장하세요.

💡 각 레이어별로 테스트를 작성하세요. Domain은 단위 테스트, Data는 Mock을 사용한 테스트, Presentation은 Widget 테스트로 각각 검증할 수 있습니다.

💡 의존성 방향을 항상 확인하세요. Presentation → Domain ← Data 방향으로만 의존성이 흘러야 합니다. Domain이 다른 레이어를 import하면 잘못된 것입니다.


2. Entity 레이어 - 순수한 비즈니스 객체

시작하며

여러분이 사용자 정보를 다루는 앱을 만들 때, User 클래스를 어디에 정의해야 할지 고민해본 적 있나요? API 응답 형태에 맞춰 만들다 보면 JSON 파싱 코드와 비즈니스 로직이 섞이게 됩니다.

이런 문제는 데이터의 본질(Entity)과 데이터의 전송 형태(Model)를 구분하지 않아서 발생합니다. API가 변경되면 비즈니스 로직까지 수정해야 하는 상황이 생깁니다.

바로 이럴 때 필요한 것이 Entity입니다. 순수한 비즈니스 객체로서 앱의 핵심 데이터를 표현합니다.

개요

간단히 말해서, Entity는 앱의 비즈니스 규칙과 데이터를 담는 순수한 Dart 클래스입니다. 실무에서는 같은 데이터를 여러 곳에서 다루게 됩니다.

API 응답, 로컬 DB, 화면 표시 등에서 사용하는데, 각각의 형태가 다를 수 있습니다. Entity를 사용하면 앱 내부에서는 항상 동일한 형태의 데이터를 사용하고, 외부와 통신할 때만 변환합니다.

예를 들어, API가 snake_case를 사용하더라도 앱 내부에서는 camelCase로 통일된 Entity를 사용할 수 있습니다. 기존에는 API 응답 객체를 그대로 사용했다면, 이제는 API와 독립적인 Entity를 정의하고 Model이 Entity로 변환하는 구조를 만듭니다.

핵심 특징은 첫째, 외부 의존성이 없습니다. dart:core만 사용하고 Flutter나 외부 패키지를 import하지 않습니다.

둘째, 불변성(Immutability)을 유지합니다. final 필드와 const 생성자를 사용합니다.

셋째, 비즈니스 규칙을 포함합니다. 단순 데이터뿐 아니라 validation이나 계산 로직도 포함할 수 있습니다.

코드 예제

// lib/features/user/domain/entities/user.dart
class User {
  final String id;
  final String name;
  final String email;
  final DateTime createdAt;

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

  // 비즈니스 로직: 사용자가 최근 가입했는지 확인
  bool get isNewUser {
    final daysSinceCreated = DateTime.now().difference(createdAt).inDays;
    return daysSinceCreated <= 7;
  }

  // 비즈니스 로직: 이메일 유효성 검증
  bool get hasValidEmail {
    return email.contains('@') && email.contains('.');
  }
}

설명

이것이 하는 일: Entity는 앱에서 사용하는 핵심 데이터의 구조와 비즈니스 규칙을 정의합니다. 첫 번째로, User Entity는 id, name, email, createdAt 필드를 가집니다.

모든 필드가 final이므로 한 번 생성되면 변경할 수 없습니다. 이렇게 불변 객체로 만들면 예상치 못한 데이터 변경을 방지할 수 있고, 상태 관리가 훨씬 간단해집니다.

그 다음으로, isNewUser getter는 비즈니스 로직을 포함합니다. 사용자가 가입한 지 7일 이내면 신규 사용자로 판단합니다.

이런 로직을 Entity에 포함하면 앱 전체에서 일관된 기준을 적용할 수 있습니다. UI 레이어에서 이 로직을 중복 작성할 필요가 없어집니다.

마찬가지로, hasValidEmail getter도 이메일 검증 로직을 담고 있습니다. 간단한 검증이지만 Entity에 정의함으로써 검증 로직이 한 곳에 모여 있고, 테스트하기도 쉬워집니다.

여러분이 이 Entity를 사용하면 앱의 모든 곳에서 동일한 User 구조를 사용할 수 있습니다. API가 변경되거나 로컬 DB 스키마가 바뀌더라도 Entity는 그대로 유지되고, Model만 수정하면 됩니다.

또한 비즈니스 로직이 Entity에 있으므로 UI 코드가 간결해지고, 로직을 재사용할 수 있습니다.

실전 팁

💡 Entity는 항상 불변 객체로 만드세요. final 필드와 const 생성자를 사용하면 예상치 못한 버그를 방지할 수 있습니다.

💡 Equatable 패키지를 사용하여 동등성 비교를 쉽게 만드세요. props를 오버라이드하면 두 Entity를 간단히 비교할 수 있습니다.

💡 복잡한 비즈니스 로직은 UseCase로 분리하세요. Entity에는 간단한 getter나 validation만 두고, 복잡한 로직은 UseCase에서 처리하는 게 좋습니다.

💡 copyWith 메서드를 추가하여 일부 필드만 변경한 새 객체를 만들 수 있게 하세요. 불변 객체를 다룰 때 매우 유용합니다.

💡 Entity에는 JSON 파싱 코드를 넣지 마세요. fromJson, toJson은 Model의 역할입니다. Entity는 순수하게 비즈니스 데이터만 담아야 합니다.


3. Repository Interface - 데이터 계약 정의

시작하며

여러분이 사용자 데이터를 가져오는 기능을 만들 때, API 호출 코드를 어디에 작성해야 할지 고민해본 적 있나요? UseCase에서 직접 http 패키지를 사용하면 테스트하기 어렵고, API 변경 시 수정 범위가 넓어집니다.

이런 문제는 데이터를 가져오는 방법(구현)과 무엇을 가져올지(인터페이스)를 분리하지 않아서 발생합니다. UseCase가 구체적인 구현에 의존하면 유연성이 떨어지고 테스트가 어려워집니다.

바로 이럴 때 필요한 것이 Repository Interface입니다. 데이터를 어떻게 가져올지는 정의하지 않고, 무엇을 가져올지만 선언합니다.

개요

간단히 말해서, Repository Interface는 Domain 레이어에서 필요한 데이터 작업을 추상화한 계약서입니다. 실무에서는 데이터 소스가 자주 변경됩니다.

개발 초기에는 Mock 데이터를 사용하다가, API가 준비되면 REST API로 전환하고, 나중에는 GraphQL로 바꿀 수도 있습니다. Repository Interface를 사용하면 이런 변경이 생겨도 UseCase 코드는 전혀 수정할 필요가 없습니다.

예를 들어, getUserById 메서드는 그대로 두고 구현체만 바꾸면 됩니다. 기존에는 UseCase에서 직접 API를 호출했다면, 이제는 Repository Interface를 통해 간접적으로 호출합니다.

UseCase는 "어떻게"가 아닌 "무엇을"에만 집중합니다. 핵심 특징은 첫째, 의존성 역전(Dependency Inversion)입니다.

Domain이 Data에 의존하지 않고, Data가 Domain의 인터페이스를 구현합니다. 둘째, 테스트 용이성입니다.

Mock Repository를 만들어 UseCase를 쉽게 테스트할 수 있습니다. 셋째, 유연성입니다.

구현체를 자유롭게 교체할 수 있습니다.

코드 예제

// lib/features/user/domain/repositories/user_repository.dart
import 'package:dartz/dartz.dart';
import '../entities/user.dart';

// Repository Interface - 추상 클래스로 정의
abstract class UserRepository {
  // 사용자 조회 - 성공 시 User, 실패 시 Failure 반환
  Future<Either<Failure, User>> getUserById(String id);

  // 사용자 목록 조회
  Future<Either<Failure, List<User>>> getAllUsers();

  // 사용자 생성
  Future<Either<Failure, User>> createUser(String name, String email);

  // 사용자 삭제
  Future<Either<Failure, void>> deleteUser(String id);
}

// Failure 클래스 - 에러 정보를 담음
abstract class Failure {
  final String message;
  const Failure(this.message);
}

설명

이것이 하는 일: Repository Interface는 Domain 레이어가 필요로 하는 데이터 작업을 메서드 시그니처로만 선언합니다. 첫 번째로, UserRepository는 abstract class로 정의됩니다.

추상 클래스이므로 구현 코드는 없고, 메서드 선언만 있습니다. getUserById, getAllUsers 등의 메서드는 어떤 작업을 할지만 정의하고, 실제로 어떻게 수행할지는 Data 레이어의 구현체가 결정합니다.

이렇게 하면 Domain 레이어는 데이터가 API에서 오는지, 로컬 DB에서 오는지 전혀 알 필요가 없습니다. 그 다음으로, Either<Failure, User> 타입을 반환합니다.

Either는 dartz 패키지의 타입으로, 성공(Right)과 실패(Left)를 명확하게 구분합니다. 기존의 try-catch보다 함수형 프로그래밍 방식으로 에러를 다룰 수 있습니다.

예를 들어, API 호출이 실패하면 Left(ServerFailure("서버 오류"))를 반환하고, 성공하면 Right(user)를 반환합니다. Failure는 추상 클래스로 정의하여 다양한 에러 타입을 만들 수 있습니다.

ServerFailure, NetworkFailure, CacheFailure 등으로 세분화하면 에러 처리를 더 정교하게 할 수 있습니다. 여러분이 이 인터페이스를 사용하면 UseCase에서는 Repository 인터페이스만 의존하므로 테스트할 때 Mock Repository를 쉽게 주입할 수 있습니다.

또한 API를 변경하거나 새로운 데이터 소스를 추가할 때도 인터페이스는 그대로 두고 구현체만 수정하면 됩니다.

실전 팁

💡 Either 대신 Result 타입을 직접 만들어도 됩니다. dartz 패키지가 부담스럽다면 sealed class로 Success와 Error를 정의하세요.

💡 Failure를 세분화하세요. ServerFailure, NetworkFailure, ValidationFailure 등으로 나누면 UI에서 적절한 에러 메시지를 표시할 수 있습니다.

💡 Repository 메서드 이름은 동사로 시작하세요. getUser, createUser, updateUser처럼 명확한 동작을 나타내는 이름을 사용하세요.

💡 Repository는 Entity만 다루고 Model은 다루지 마세요. Model은 Data 레이어에서만 사용되고, Domain 레이어는 Entity만 알아야 합니다.

💡 Repository 메서드는 가능한 한 작게 만드세요. 하나의 메서드가 하나의 작업만 수행하도록 설계하면 재사용성이 높아집니다.


4. UseCase - 비즈니스 로직 실행

시작하며

여러분이 사용자 정보를 가져오고 추가 처리를 해야 할 때, 이 로직을 어디에 작성해야 할지 고민해본 적 있나요? Widget에 작성하면 UI 코드가 복잡해지고, Repository에 작성하면 데이터 처리와 비즈니스 로직이 섞입니다.

이런 문제는 비즈니스 로직을 담을 명확한 장소가 없어서 발생합니다. 로직이 여기저기 흩어지면 중복 코드가 생기고, 수정할 때 어디를 고쳐야 할지 찾기 어려워집니다.

바로 이럴 때 필요한 것이 UseCase입니다. 하나의 비즈니스 작업을 캡슐화하여 재사용 가능하고 테스트 가능한 단위로 만듭니다.

개요

간단히 말해서, UseCase는 앱에서 수행할 수 있는 하나의 비즈니스 작업을 나타내는 클래스입니다. 실무에서는 단순히 데이터를 가져오는 것을 넘어 여러 단계의 처리가 필요합니다.

사용자를 조회하고, 권한을 확인하고, 로그를 남기고, 결과를 가공하는 등의 작업이 복합적으로 일어납니다. UseCase를 사용하면 이런 복잡한 로직을 하나의 단위로 묶을 수 있습니다.

예를 들어, GetUserProfile UseCase는 사용자 정보 조회, 프로필 이미지 URL 생성, 최근 활동 정보 추가 등을 모두 처리할 수 있습니다. 기존에는 이런 로직이 Widget이나 State 클래스에 흩어져 있었다면, 이제는 UseCase로 모아서 관리합니다.

Widget은 UseCase만 호출하면 됩니다. 핵심 특징은 첫째, 단일 책임(Single Responsibility)입니다.

하나의 UseCase는 하나의 작업만 수행합니다. 둘째, 재사용성입니다.

여러 화면에서 동일한 UseCase를 사용할 수 있습니다. 셋째, 테스트 가능성입니다.

UseCase만 독립적으로 테스트할 수 있어 비즈니스 로직 검증이 쉽습니다.

코드 예제

// lib/features/user/domain/usecases/get_user.dart
import 'package:dartz/dartz.dart';
import '../entities/user.dart';
import '../repositories/user_repository.dart';

class GetUser {
  final UserRepository repository;

  GetUser(this.repository);

  // call 메서드로 UseCase 실행
  Future<Either<Failure, User>> call(String userId) async {
    // 입력 검증
    if (userId.isEmpty) {
      return Left(ValidationFailure('사용자 ID가 비어있습니다'));
    }

    // Repository를 통해 데이터 조회
    final result = await repository.getUserById(userId);

    // 추가 비즈니스 로직 (예: 로깅, 캐싱 등)
    return result.fold(
      (failure) => Left(failure),
      (user) {
        // 성공 시 추가 처리
        print('사용자 조회 성공: ${user.name}');
        return Right(user);
      },
    );
  }
}

설명

이것이 하는 일: UseCase는 특정 비즈니스 작업을 수행하기 위한 모든 로직을 담습니다. 첫 번째로, GetUser 클래스는 생성자에서 UserRepository를 주입받습니다.

이것이 의존성 주입(Dependency Injection)입니다. UseCase는 Repository의 구체적인 구현을 모르고, 인터페이스만 알고 있습니다.

이렇게 하면 테스트할 때 Mock Repository를 주입하여 실제 API 호출 없이 테스트할 수 있습니다. 그 다음으로, call 메서드가 실제 작업을 수행합니다.

call 메서드를 정의하면 클래스 인스턴스를 함수처럼 호출할 수 있습니다. getUserUseCase(userId)처럼 간결하게 사용할 수 있어 코드 가독성이 좋아집니다.

메서드 내부에서는 먼저 입력값을 검증합니다. userId가 비어있으면 ValidationFailure를 반환하여 잘못된 입력을 미리 차단합니다.

그 다음, repository.getUserById를 호출하여 실제 데이터를 가져옵니다. 결과는 Either 타입이므로 fold 메서드로 성공과 실패를 각각 처리합니다.

실패하면 그대로 Left를 반환하고, 성공하면 추가 처리(로깅, 분석 등)를 한 후 Right를 반환합니다. 여러분이 이 UseCase를 사용하면 비즈니스 로직이 한 곳에 모여 있어 수정이 쉽고, 여러 화면에서 재사용할 수 있습니다.

또한 UseCase만 독립적으로 유닛 테스트하여 로직의 정확성을 검증할 수 있습니다.

실전 팁

💡 하나의 UseCase는 하나의 작업만 수행하세요. GetUser, CreateUser, UpdateUser처럼 명확하게 분리하면 코드를 이해하고 수정하기 쉽습니다.

💡 UseCase에 Parameters 클래스를 만들어 복잡한 입력을 정리하세요. 파라미터가 3개 이상이면 Params 클래스로 묶는 것이 좋습니다.

💡 call 메서드 대신 execute 메서드를 사용해도 됩니다. 팀 내 코딩 스타일에 맞춰 일관되게 사용하세요.

💡 UseCase에서는 UI 관련 코드를 절대 사용하지 마세요. BuildContext, Widget 등을 import하면 테스트가 어려워집니다.

💡 복잡한 UseCase는 여러 작은 UseCase로 나누세요. 하나의 UseCase가 100줄을 넘어가면 분리를 고려해야 합니다.


5. Data Model - Entity와 JSON 변환

시작하며

여러분이 API 응답을 받아서 앱에서 사용할 때, JSON 파싱 코드를 어디에 작성해야 할지 고민해본 적 있나요? Entity에 fromJson을 넣으면 Entity가 JSON 형식에 의존하게 되고, 순수한 비즈니스 객체가 아니게 됩니다.

이런 문제는 데이터의 본질(Entity)과 데이터의 전송 형태(JSON)를 같은 클래스에서 다루려고 해서 발생합니다. Entity는 비즈니스 로직에 집중해야 하는데 JSON 파싱 때문에 복잡해집니다.

바로 이럴 때 필요한 것이 Model입니다. JSON과 Entity 사이의 변환을 전담하여 각 레이어를 깔끔하게 분리합니다.

개요

간단히 말해서, Model은 Data 레이어에서 외부 데이터(JSON, DB 등)를 Entity로 변환하는 DTO(Data Transfer Object)입니다. 실무에서는 API 응답 형식이 자주 변경됩니다.

필드 이름이 바뀌거나, 중첩 구조가 추가되거나, 타입이 변경될 수 있습니다. Model을 사용하면 이런 변경이 생겨도 Entity는 그대로 유지되고, Model의 fromJson/toJson만 수정하면 됩니다.

예를 들어, API가 "user_name"을 "userName"으로 바꾸더라도 Entity의 name 필드는 변경할 필요가 없습니다. 기존에는 Entity가 직접 JSON 파싱을 담당했다면, 이제는 Model이 그 역할을 맡고 Entity는 순수하게 비즈니스 데이터만 표현합니다.

핵심 특징은 첫째, Entity를 상속합니다. Model은 Entity의 모든 필드를 가지면서 JSON 변환 기능을 추가합니다.

둘째, fromJson/toJson을 구현합니다. JSON과 객체 간 양방향 변환이 가능합니다.

셋째, 검증 로직을 포함할 수 있습니다. 잘못된 데이터를 Entity로 변환하기 전에 미리 걸러낼 수 있습니다.

코드 예제

// lib/features/user/data/models/user_model.dart
import '../../domain/entities/user.dart';

class UserModel extends User {
  const UserModel({
    required String id,
    required String name,
    required String email,
    required DateTime createdAt,
  }) : super(
          id: id,
          name: name,
          email: email,
          createdAt: createdAt,
        );

  // JSON에서 UserModel 생성
  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  // UserModel을 JSON으로 변환
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'created_at': createdAt.toIso8601String(),
    };
  }

  // Entity를 Model로 변환
  factory UserModel.fromEntity(User user) {
    return UserModel(
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt,
    );
  }
}

설명

이것이 하는 일: Model은 외부 데이터 형식과 내부 Entity 사이의 다리 역할을 합니다. 첫 번째로, UserModel은 User Entity를 상속합니다.

따라서 UserModel은 User의 모든 속성을 가지고 있으면서, 추가로 JSON 변환 기능을 제공합니다. extends User를 사용하면 UserModel을 User 타입으로 사용할 수 있어, Repository 구현체에서 UserModel을 반환하지만 UseCase는 User로 받을 수 있습니다.

그 다음으로, fromJson factory 생성자가 JSON Map을 받아 UserModel을 생성합니다. json['created_at']처럼 snake_case인 API 응답을 createdAt처럼 camelCase로 변환합니다.

DateTime.parse로 문자열을 DateTime 객체로 변환하는 등, 타입 변환도 담당합니다. as String으로 타입을 명시하여 타입 안정성을 보장합니다.

toJson 메서드는 그 반대로 UserModel을 JSON Map으로 변환합니다. API에 데이터를 보낼 때 사용합니다.

created_at처럼 API가 요구하는 형식으로 변환하고, DateTime을 ISO 8601 문자열로 직렬화합니다. 마지막으로 fromEntity factory는 순수 Entity를 Model로 변환합니다.

이미 메모리에 있는 Entity를 API로 보내야 할 때 유용합니다. 예를 들어, 사용자가 프로필을 수정하면 Entity를 Model로 변환한 후 toJson을 호출하여 API에 전송합니다.

여러분이 이 Model을 사용하면 API 형식이 바뀌어도 fromJson/toJson만 수정하면 되고, 앱의 나머지 부분은 전혀 영향을 받지 않습니다. 또한 JSON 파싱 에러가 발생하면 Data 레이어에서 처리할 수 있어 디버깅이 쉽습니다.

실전 팁

💡 json_serializable 패키지를 사용하면 fromJson/toJson을 자동 생성할 수 있습니다. 수동으로 작성하는 실수를 줄일 수 있습니다.

💡 JSON 파싱 시 null 체크를 철저히 하세요. json['name'] ?? ''처럼 기본값을 제공하거나, null이면 예외를 던져 데이터 무결성을 보장하세요.

💡 복잡한 중첩 JSON은 여러 Model로 나누세요. UserModel 안에 AddressModel, ProfileModel 등을 포함하면 구조가 명확해집니다.

💡 API 응답이 snake_case면 Model에서 camelCase로 변환하여 Dart 컨벤션을 따르세요. 코드 일관성이 중요합니다.

💡 Model 테스트를 작성하세요. 실제 API 응답 샘플을 fixtures에 저장하고, fromJson이 올바르게 파싱하는지 검증하세요.


6. Repository 구현체 - 실제 데이터 처리

시작하며

여러분이 Repository Interface를 정의했다면, 이제 실제로 API를 호출하고 데이터를 가져오는 코드를 작성해야 합니다. 하지만 에러 처리, 네트워크 예외, 타임아웃 등을 고려하면 생각보다 복잡합니다.

이런 문제는 데이터 소스와의 통신에서 발생하는 다양한 예외 상황을 체계적으로 처리하지 않으면 발생합니다. 단순히 http.get을 호출하는 것만으로는 부족합니다.

바로 이럴 때 필요한 것이 Repository 구현체입니다. Interface를 구현하여 실제 API 통신, 에러 처리, 데이터 변환을 모두 담당합니다.

개요

간단히 말해서, Repository 구현체는 Data 레이어에서 Repository Interface를 implements하여 실제 데이터 처리 로직을 구현한 클래스입니다. 실무에서는 하나의 Repository가 여러 데이터 소스를 다룹니다.

Remote DataSource(API)에서 먼저 시도하고, 실패하면 Local DataSource(캐시)에서 가져오는 식입니다. Repository 구현체는 이런 복잡한 로직을 캡슐화하여 UseCase는 단순하게 Repository를 호출하기만 하면 됩니다.

예를 들어, 네트워크가 없을 때는 로컬 캐시를 사용하고, 네트워크가 있으면 API를 호출한 후 캐시를 업데이트하는 전략을 구현할 수 있습니다. 기존에는 UseCase에서 직접 http 패키지를 사용했다면, 이제는 Repository 구현체가 모든 통신을 담당하고 UseCase는 깔끔하게 유지됩니다.

핵심 특징은 첫째, DataSource를 주입받습니다. Remote와 Local DataSource를 조합하여 사용합니다.

둘째, 예외를 Failure로 변환합니다. try-catch로 예외를 잡아 적절한 Failure 객체로 변환합니다.

셋째, Model을 Entity로 변환합니다. DataSource가 반환한 Model을 Domain의 Entity로 바꿔서 반환합니다.

코드 예제

// lib/features/user/data/repositories/user_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
import '../datasources/user_remote_datasource.dart';

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;

  UserRepositoryImpl({required this.remoteDataSource});

  @override
  Future<Either<Failure, User>> getUserById(String id) async {
    try {
      // Remote DataSource에서 데이터 가져오기
      final userModel = await remoteDataSource.getUserById(id);
      // Model은 이미 Entity를 상속하므로 그대로 반환 가능
      return Right(userModel);
    } on ServerException catch (e) {
      // 서버 에러를 Failure로 변환
      return Left(ServerFailure(e.message));
    } on NetworkException {
      // 네트워크 에러를 Failure로 변환
      return Left(NetworkFailure('인터넷 연결을 확인해주세요'));
    } catch (e) {
      // 예상치 못한 에러
      return Left(UnexpectedFailure('알 수 없는 오류가 발생했습니다'));
    }
  }

  @override
  Future<Either<Failure, List<User>>> getAllUsers() async {
    // 구현 로직...
  }
}

설명

이것이 하는 일: Repository 구현체는 Domain과 Data를 연결하는 실제 다리입니다. 첫 번째로, UserRepositoryImpl은 UserRepository 인터페이스를 implements합니다.

따라서 인터페이스에 정의된 모든 메서드를 구현해야 합니다. 생성자에서 UserRemoteDataSource를 주입받는데, 이것이 의존성 주입입니다.

테스트할 때는 Mock DataSource를 주입하여 실제 API 호출 없이 Repository를 테스트할 수 있습니다. 그 다음으로, getUserById 메서드가 실제 로직을 구현합니다.

try-catch 블록으로 감싸서 모든 예외를 처리합니다. remoteDataSource.getUserById를 호출하여 UserModel을 받아오고, 성공하면 Right(userModel)로 감싸서 반환합니다.

UserModel이 User를 상속하므로 별도 변환 없이 바로 반환할 수 있습니다. catch 블록에서는 다양한 예외를 각각 처리합니다.

ServerException이 발생하면 ServerFailure로 변환하고, NetworkException이면 NetworkFailure로 변환합니다. 이렇게 예외를 Failure로 변환하면 UseCase에서 에러 종류에 따라 다르게 대응할 수 있습니다.

예를 들어, NetworkFailure면 "인터넷 연결을 확인하세요" 메시지를 표시하고, ServerFailure면 "잠시 후 다시 시도하세요"를 표시할 수 있습니다. 여러분이 이 구현체를 사용하면 모든 예외 처리가 Repository에서 이루어져 UseCase 코드가 간결해집니다.

또한 DataSource를 교체하거나 캐싱 로직을 추가할 때도 Repository만 수정하면 되어 유지보수가 쉽습니다.

실전 팁

💡 Remote와 Local DataSource를 모두 주입받아 캐싱 전략을 구현하세요. 네트워크 실패 시 자동으로 캐시에서 가져오도록 만들 수 있습니다.

💡 Repository에서 로깅을 추가하세요. API 호출 시간, 성공/실패 여부를 기록하면 디버깅과 모니터링에 유용합니다.

💡 Retry 로직을 추가하세요. 일시적인 네트워크 오류는 재시도하면 성공할 수 있습니다. exponential backoff를 사용하면 더 좋습니다.

💡 Repository는 얇게 유지하세요. 복잡한 비즈니스 로직은 UseCase에 두고, Repository는 데이터 가져오기와 에러 변환만 담당해야 합니다.

💡 예외 타입을 구체적으로 정의하세요. ServerException, TimeoutException, UnauthorizedException 등으로 세분화하면 정교한 에러 처리가 가능합니다.


7. DataSource - API 호출 처리

시작하며

여러분이 실제로 HTTP 요청을 보내고 응답을 받아야 할 때, 이 코드를 어디에 작성해야 할지 고민해본 적 있나요? Repository에 직접 작성하면 테스트하기 어렵고, API 엔드포인트가 바뀔 때 수정 범위가 넓어집니다.

이런 문제는 데이터를 가져오는 실제 수단(HTTP, DB, 파일 등)을 추상화하지 않아서 발생합니다. Repository가 http 패키지에 직접 의존하면 Mock을 만들기 어렵습니다.

바로 이럴 때 필요한 것이 DataSource입니다. 실제 데이터 통신을 캡슐화하여 Repository와 분리합니다.

개요

간단히 말해서, DataSource는 외부 데이터 소스(API, DB, 파일 등)와 직접 통신하는 레이어입니다. 실무에서는 Remote DataSource와 Local DataSource를 나누어 관리합니다.

Remote는 REST API, GraphQL 등을 담당하고, Local은 Shared Preferences, SQLite, Hive 등을 담당합니다. DataSource를 분리하면 Repository는 어떤 소스에서 데이터를 가져올지 결정하기만 하면 되고, 실제 통신 세부사항은 DataSource가 처리합니다.

예를 들어, 사용자 목록을 가져올 때 먼저 Local에서 확인하고 없으면 Remote에서 가져오는 로직을 Repository에 구현할 수 있습니다. 기존에는 Repository에서 직접 http.get을 호출했다면, 이제는 DataSource가 모든 HTTP 통신을 담당하고 Repository는 DataSource를 조합하여 사용합니다.

핵심 특징은 첫째, 통신 수단에 집중합니다. URL 생성, 헤더 설정, 타임아웃 처리 등 HTTP 통신의 세부사항을 담당합니다.

둘째, Model을 반환합니다. JSON을 파싱하여 Model 객체로 변환해서 반환합니다.

셋째, 예외를 던집니다. 통신 실패 시 적절한 Exception을 throw하여 Repository가 처리하도록 합니다.

코드 예제

// lib/features/user/data/datasources/user_remote_datasource.dart
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/user_model.dart';

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

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final http.Client client;
  final String baseUrl;

  UserRemoteDataSourceImpl({
    required this.client,
    this.baseUrl = 'https://api.example.com',
  });

  @override
  Future<UserModel> getUserById(String id) async {
    // API 엔드포인트 구성
    final uri = Uri.parse('$baseUrl/users/$id');

    // HTTP 요청 전송
    final response = await client.get(
      uri,
      headers: {'Content-Type': 'application/json'},
    );

    // 상태 코드 확인
    if (response.statusCode == 200) {
      final json = jsonDecode(response.body) as Map<String, dynamic>;
      return UserModel.fromJson(json);
    } else if (response.statusCode == 404) {
      throw NotFoundException('사용자를 찾을 수 없습니다');
    } else {
      throw ServerException('서버 오류: ${response.statusCode}');
    }
  }
}

설명

이것이 하는 일: DataSource는 외부 세계와의 실제 통신을 캡슐화합니다. 첫 번째로, UserRemoteDataSource 인터페이스를 정의합니다.

추상 클래스로 만들어 테스트 시 Mock DataSource를 쉽게 만들 수 있습니다. UserRemoteDataSourceImpl이 실제 구현체이며, http.Client를 주입받습니다.

Client를 주입받으면 테스트할 때 Mock Client를 사용할 수 있어 실제 네트워크 호출 없이 테스트할 수 있습니다. 그 다음으로, getUserById 메서드가 실제 API를 호출합니다.

baseUrl과 id를 조합하여 URI를 생성하고, client.get으로 HTTP GET 요청을 보냅니다. headers에 Content-Type을 지정하여 JSON 형식임을 서버에 알립니다.

await로 비동기 요청을 기다립니다. 응답을 받으면 statusCode를 확인합니다.

200이면 성공이므로 response.body를 JSON으로 파싱하고 UserModel.fromJson으로 변환하여 반환합니다. 404면 사용자가 없다는 뜻이므로 NotFoundException을 던집니다.

그 외의 에러 코드는 ServerException을 던져 Repository가 처리하도록 합니다. 여러분이 이 DataSource를 사용하면 API 엔드포인트나 HTTP 라이브러리가 바뀌어도 DataSource만 수정하면 됩니다.

또한 테스트할 때 Mock DataSource를 만들어 다양한 응답 시나리오(성공, 404, 500 등)를 쉽게 시뮬레이션할 수 있습니다.

실전 팁

💡 http.Client를 주입받으세요. 테스트 시 MockClient를 사용하면 실제 네트워크 호출 없이 DataSource를 테스트할 수 있습니다.

💡 인증 토큰은 Interceptor로 자동 추가하세요. dio 패키지를 사용하면 모든 요청에 자동으로 헤더를 추가할 수 있습니다.

💡 타임아웃을 설정하세요. client.timeout(Duration(seconds: 10))으로 너무 오래 기다리지 않도록 제한할 수 있습니다.

💡 에러 응답도 파싱하세요. 서버가 { "error": "message" } 형태로 에러를 보내면 이를 파싱하여 구체적인 메시지를 사용자에게 보여줄 수 있습니다.

💡 환경별 baseUrl을 설정하세요. dev, staging, production 환경마다 다른 baseUrl을 사용하도록 설정 파일이나 환경 변수로 관리하세요.


8. Dependency Injection - 의존성 주입 설정

시작하며

여러분이 여러 레이어의 클래스를 연결할 때, 어디서 인스턴스를 생성하고 주입해야 할지 고민해본 적 있나요? Widget에서 직접 Repository를 생성하면 테스트하기 어렵고, 싱글톤 패턴을 남용하면 의존성 관리가 복잡해집니다.

이런 문제는 객체 생성과 주입을 체계적으로 관리하지 않아서 발생합니다. 각 클래스가 필요한 의존성을 직접 생성하면 결합도가 높아지고 유연성이 떨어집니다.

바로 이럴 때 필요한 것이 Dependency Injection입니다. 의존성을 중앙에서 관리하고 자동으로 주입하여 코드를 깔끔하게 만듭니다.

개요

간단히 말해서, Dependency Injection(DI)은 객체가 필요로 하는 의존성을 외부에서 주입받도록 만드는 디자인 패턴입니다. 실무에서는 수십 개의 클래스가 서로 의존합니다.

UseCase는 Repository를 필요로 하고, Repository는 DataSource를 필요로 하며, DataSource는 HTTP Client를 필요로 합니다. DI 컨테이너를 사용하면 이런 복잡한 의존성 그래프를 자동으로 해결해줍니다.

예를 들어, get_it이나 injectable 패키지를 사용하면 GetUser UseCase를 요청했을 때 필요한 모든 의존성(Repository, DataSource, Client)을 자동으로 생성하고 주입해줍니다. 기존에는 클래스 내부에서 직접 의존성을 생성했다면, 이제는 생성자나 팩토리로 의존성을 주입받습니다.

이렇게 하면 테스트 시 Mock 객체를 쉽게 주입할 수 있습니다. 핵심 특징은 첫째, 결합도 감소입니다.

클래스가 구체적인 구현이 아닌 인터페이스에 의존합니다. 둘째, 테스트 용이성입니다.

Mock 객체를 주입하여 독립적으로 테스트할 수 있습니다. 셋째, 싱글톤 관리입니다.

DI 컨테이너가 객체의 생명주기를 관리하여 메모리를 효율적으로 사용합니다.

코드 예제

// lib/injection_container.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;

final sl = GetIt.instance; // Service Locator

Future<void> init() async {
  // Use Cases - Factory (매번 새 인스턴스)
  sl.registerFactory(() => GetUser(sl()));

  // Repository - Lazy Singleton (처음 사용 시 생성)
  sl.registerLazySingleton<UserRepository>(
    () => UserRepositoryImpl(remoteDataSource: sl()),
  );

  // Data Sources - Lazy Singleton
  sl.registerLazySingleton<UserRemoteDataSource>(
    () => UserRemoteDataSourceImpl(client: sl()),
  );

  // External - Singleton (앱 시작 시 생성)
  sl.registerLazySingleton(() => http.Client());

  // Bloc - Factory
  sl.registerFactory(() => UserBloc(getUser: sl()));
}

// main.dart에서 초기화
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await init(); // DI 컨테이너 초기화
  runApp(MyApp());
}

설명

이것이 하는 일: DI 컨테이너는 앱의 모든 의존성을 등록하고 필요할 때 자동으로 주입합니다. 첫 번째로, GetIt 인스턴스를 생성합니다.

GetIt은 Service Locator 패턴을 구현한 라이브러리로, 의존성을 등록하고 가져올 수 있게 해줍니다. sl(Service Locator)이라는 이름으로 전역 인스턴스를 만듭니다.

그 다음으로, init 함수에서 모든 의존성을 등록합니다. registerFactory는 요청할 때마다 새 인스턴스를 생성합니다.

UseCase와 Bloc은 Factory로 등록하여 매번 깨끗한 상태로 시작합니다. registerLazySingleton은 처음 요청할 때 한 번만 생성하고 이후에는 같은 인스턴스를 재사용합니다.

Repository와 DataSource는 싱글톤으로 등록하여 메모리를 절약합니다. sl()은 의존성을 가져오는 단축 표기법입니다.

GetUser(sl())이라고 쓰면 GetIt이 자동으로 UserRepository를 찾아서 주입합니다. 타입 추론으로 필요한 의존성을 알아서 찾아줍니다.

UserRepositoryImpl(remoteDataSource: sl())도 마찬가지로 UserRemoteDataSource를 자동으로 주입받습니다. main 함수에서 init()을 호출하여 앱 시작 시 모든 의존성을 등록합니다.

이후 어디서든 sl<GetUser>()로 UseCase를 가져와 사용할 수 있습니다. 여러분이 이 DI 컨테이너를 사용하면 의존성 관리가 중앙화되어 코드를 이해하기 쉽고, 테스트할 때 특정 의존성만 Mock으로 교체할 수 있습니다.

또한 싱글톤과 팩토리를 적절히 사용하여 메모리를 효율적으로 관리할 수 있습니다.

실전 팁

💡 레이어별로 등록 순서를 정리하세요. External → DataSource → Repository → UseCase → Bloc 순서로 등록하면 의존성 그래프를 이해하기 쉽습니다.

💡 Feature별로 init 함수를 나누세요. initUserFeature, initProductFeature처럼 나누면 코드를 찾기 쉽고 feature를 독립적으로 관리할 수 있습니다.

💡 테스트 시에는 별도의 DI 설정을 만드세요. setUp에서 sl.reset()으로 초기화하고 Mock 객체를 등록하면 격리된 테스트가 가능합니다.

💡 injectable 패키지를 사용하면 어노테이션으로 자동 등록할 수 있습니다. @lazySingleton, @injectable 등으로 코드를 더 간결하게 만들 수 있습니다.

💡 의존성 순환을 조심하세요. A가 B를 의존하고 B가 A를 의존하면 에러가 발생합니다. 의존성은 항상 한 방향으로만 흘러야 합니다.


9. Bloc/State Management - UI 상태 관리

시작하며

여러분이 API에서 데이터를 가져와 화면에 표시할 때, 로딩 상태, 에러 상태, 성공 상태를 어떻게 관리해야 할지 고민해본 적 있나요? setState를 남발하면 코드가 복잡해지고, StatefulWidget이 비대해집니다.

이런 문제는 UI 상태와 비즈니스 로직이 섞여 있어서 발생합니다. Widget이 직접 API를 호출하고 상태를 관리하면 테스트하기 어렵고 재사용성이 떨어집니다.

바로 이럴 때 필요한 것이 Bloc입니다. UI와 비즈니스 로직을 분리하고, 상태를 예측 가능하게 관리합니다.

개요

간단히 말해서, Bloc(Business Logic Component)은 UI와 비즈니스 로직을 분리하여 상태를 관리하는 디자인 패턴입니다. 실무에서는 하나의 화면에서 여러 상태를 다룹니다.

로딩 중, 데이터 로드 성공, 에러 발생, 재시도 중 등 다양한 상태가 있습니다. Bloc을 사용하면 이런 상태 전환을 명확하게 정의하고, UI는 상태에 따라 자동으로 업데이트됩니다.

예를 들어, 사용자 프로필 화면에서 로딩 중이면 Spinner를 보여주고, 성공하면 프로필 정보를 표시하고, 에러가 발생하면 에러 메시지와 재시도 버튼을 보여주는 로직을 Bloc이 관리합니다. 기존에는 StatefulWidget에서 setState로 상태를 관리했다면, 이제는 Bloc이 상태를 관리하고 Widget은 상태를 구독하여 반응하기만 합니다.

핵심 특징은 첫째, Event와 State의 명확한 분리입니다. 사용자 액션은 Event로, UI 상태는 State로 표현합니다.

둘째, Stream 기반입니다. 상태 변경이 Stream으로 전달되어 reactive하게 동작합니다.

셋째, 테스트 가능성입니다. Bloc만 독립적으로 테스트하여 상태 전환 로직을 검증할 수 있습니다.

코드 예제

// lib/features/user/presentation/bloc/user_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_user.dart';

// Events
abstract class UserEvent {}
class GetUserEvent extends UserEvent {
  final String userId;
  GetUserEvent(this.userId);
}

// States
abstract class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
  final User user;
  UserLoaded(this.user);
}
class UserError extends UserState {
  final String message;
  UserError(this.message);
}

// Bloc
class UserBloc extends Bloc<UserEvent, UserState> {
  final GetUser getUser;

  UserBloc({required this.getUser}) : super(UserInitial()) {
    on<GetUserEvent>(_onGetUser);
  }

  Future<void> _onGetUser(GetUserEvent event, Emitter<UserState> emit) async {
    emit(UserLoading());

    final result = await getUser(event.userId);

    result.fold(
      (failure) => emit(UserError(failure.message)),
      (user) => emit(UserLoaded(user)),
    );
  }
}

설명

이것이 하는 일: Bloc은 Event를 받아 비즈니스 로직을 실행하고, 그 결과를 State로 변환하여 UI에 전달합니다. 첫 번째로, Event 클래스들을 정의합니다.

UserEvent는 추상 클래스이고, GetUserEvent는 구체적인 이벤트입니다. 사용자가 프로필 화면을 열면 GetUserEvent(userId)를 발행합니다.

Event는 사용자 액션이나 시스템 이벤트를 나타냅니다. 그 다음으로, State 클래스들을 정의합니다.

UserInitial은 초기 상태, UserLoading은 데이터를 가져오는 중, UserLoaded는 성공적으로 데이터를 받음, UserError는 에러 발생을 나타냅니다. 각 State는 명확한 의미를 가지며, UI는 State에 따라 다른 위젯을 표시합니다.

UserBloc 클래스는 GetUser UseCase를 주입받습니다. 생성자에서 super(UserInitial())로 초기 상태를 설정하고, on<GetUserEvent>로 이벤트 핸들러를 등록합니다.

_onGetUser 메서드가 실제 로직을 처리합니다. _onGetUser 메서드는 먼저 emit(UserLoading())으로 로딩 상태를 발행합니다.

그러면 UI가 즉시 로딩 스피너를 표시합니다. 그 다음 getUser UseCase를 호출하여 데이터를 가져옵니다.

결과는 Either 타입이므로 fold로 처리합니다. 실패하면 emit(UserError(...))로 에러 상태를, 성공하면 emit(UserLoaded(user))로 성공 상태를 발행합니다.

여러분이 Bloc을 사용하면 Widget은 매우 간단해집니다. BlocBuilder로 State를 구독하고, State에 따라 다른 위젯을 반환하기만 하면 됩니다.

비즈니스 로직은 모두 Bloc에 있으므로 Widget을 테스트할 필요 없이 Bloc만 테스트하면 됩니다.

실전 팁

💡 State를 불변 객체로 만드세요. Equatable을 사용하면 State 비교가 쉬워지고 불필요한 rebuild를 방지할 수 있습니다.

💡 Event와 State는 sealed class로 만들면 더 좋습니다. Dart 3.0의 sealed class를 사용하면 모든 케이스를 처리했는지 컴파일 타임에 확인할 수 있습니다.

💡 복잡한 State는 copyWith를 구현하세요. 일부 필드만 변경한 새 State를 만들 때 유용합니다.

💡 Bloc은 화면이 dispose될 때 자동으로 close됩니다. BlocProvider로 제공하면 생명주기를 자동 관리합니다.

💡 BlocObserver를 설정하여 모든 Event와 State 변화를 로깅하세요. 디버깅할 때 상태 전환을 추적하기 쉽습니다.


10. Presentation Layer - UI 구성

시작하며

여러분이 지금까지 만든 모든 레이어를 실제 화면에서 사용할 때, Widget을 어떻게 구성해야 할지 고민해본 적 있나요? Bloc과 Widget을 어떻게 연결하고, 에러 처리는 어떻게 해야 깔끔할까요?

이런 문제는 UI 레이어를 체계적으로 구성하지 않아서 발생합니다. Widget이 Bloc을 직접 생성하거나, 상태에 따른 UI 분기가 복잡하게 얽히면 코드를 이해하기 어려워집니다.

바로 이럴 때 필요한 것이 체계적인 Presentation Layer입니다. BlocProvider로 의존성을 주입하고, BlocBuilder로 상태를 구독하여 깔끔한 UI를 만듭니다.

개요

간단히 말해서, Presentation Layer는 Widget, Page, BlocProvider를 조합하여 실제 사용자에게 보이는 화면을 구성하는 레이어입니다. 실무에서는 하나의 화면이 여러 Widget으로 구성됩니다.

Page는 전체 화면을 담당하고, Widget은 재사용 가능한 UI 컴포넌트입니다. BlocProvider는 Bloc을 Widget 트리에 제공하고, BlocBuilder는 State 변화를 감지하여 UI를 자동 업데이트합니다.

예를 들어, 사용자 프로필 화면은 UserPage가 전체 레이아웃을 담당하고, UserProfileHeader, UserInfoCard 같은 작은 Widget들로 구성됩니다. 기존에는 StatefulWidget에서 모든 것을 처리했다면, 이제는 Page는 구조만 정의하고 Bloc이 상태를 관리하며 Widget은 표시만 담당하도록 역할을 분리합니다.

핵심 특징은 첫째, 선언적 UI입니다. State에 따라 UI가 자동으로 결정됩니다.

둘째, 재사용성입니다. 작은 Widget을 조합하여 복잡한 화면을 만듭니다.

셋째, 테스트 가능성입니다. Widget 테스트로 UI가 올바르게 표시되는지 검증할 수 있습니다.

코드 예제

// lib/features/user/presentation/pages/user_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class UserPage extends StatelessWidget {
  final String userId;

  const UserPage({required this.userId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('사용자 프로필')),
      body: BlocProvider(
        create: (context) => sl<UserBloc>()..add(GetUserEvent(userId)),
        child: BlocBuilder<UserBloc, UserState>(
          builder: (context, state) {
            if (state is UserLoading) {
              return Center(child: CircularProgressIndicator());
            } else if (state is UserLoaded) {
              return _buildUserProfile(state.user);
            } else if (state is UserError) {
              return _buildError(context, state.message);
            }
            return SizedBox.shrink();
          },
        ),
      ),
    );
  }

  Widget _buildUserProfile(User user) {
    return Column(
      children: [
        Text(user.name, style: TextStyle(fontSize: 24)),
        Text(user.email),
        if (user.isNewUser) Chip(label: Text('신규 회원')),
      ],
    );
  }

  Widget _buildError(BuildContext context, String message) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(message),
          ElevatedButton(
            onPressed: () {
              context.read<UserBloc>().add(GetUserEvent(userId));
            },
            child: Text('재시도'),
          ),
        ],
      ),
    );
  }
}

설명

이것이 하는 일: Presentation Layer는 모든 레이어를 연결하여 최종 사용자 화면을 만듭니다. 첫 번째로, UserPage는 StatelessWidget입니다.

Bloc이 상태를 관리하므로 StatefulWidget이 필요 없습니다. userId를 생성자에서 받아 Bloc에 전달합니다.

그 다음으로, BlocProvider로 UserBloc을 제공합니다. create에서 sl<UserBloc>()으로 DI 컨테이너에서 Bloc을 가져오고, 즉시 GetUserEvent를 발행합니다.

..add는 cascade 연산자로, Bloc을 생성하자마자 이벤트를 보냅니다. 이렇게 하면 화면이 열리는 순간 데이터를 가져오기 시작합니다.

BlocBuilder는 UserState를 구독합니다. State가 변할 때마다 builder가 호출되어 새 UI를 반환합니다.

if-else로 State 타입을 확인하여 각 상태에 맞는 Widget을 반환합니다. UserLoading이면 CircularProgressIndicator를, UserLoaded면 _buildUserProfile을, UserError면 _buildError를 표시합니다.

_buildUserProfile은 User Entity를 받아 프로필 정보를 표시합니다. user.isNewUser처럼 Entity의 비즈니스 로직을 직접 사용할 수 있습니다.

_buildError는 에러 메시지와 재시도 버튼을 표시합니다. 버튼을 누르면 context.read<UserBloc>()로 Bloc을 가져와 다시 GetUserEvent를 발행합니다.

여러분이 이런 구조를 사용하면 UI 코드가 매우 간결해지고, 상태에 따른 UI 분기가 명확해집니다. 또한 Widget 테스트에서 Mock Bloc을 제공하여 다양한 상태를 쉽게 테스트할 수 있습니다.

실전 팁

💡 BlocConsumer를 사용하면 State를 구독하면서 side effect도 처리할 수 있습니다. 예를 들어, 에러 발생 시 SnackBar를 표시하는 로직을 listener에 작성할 수 있습니다.

💡 State별로 별도 Widget을 만드세요. UserLoadingWidget, UserLoadedWidget처럼 나누면 코드가 더 깔끔해집니다.

💡 context.watch와 context.read를 구분하세요. watch는 State 변화를 구독하고, read는 일회성 접근입니다. build 메서드에서는 watch, 이벤트 핸들러에서는 read를 사용하세요.

💡 MultiBlocProvider를 사용하여 여러 Bloc을 한 번에 제공하세요. 복잡한 화면에서 여러 Bloc이 필요할 때 유용합니다.

💡 Golden 테스트를 작성하여 UI 스냅샷을 비교하세요. 의도하지 않은 UI 변경을 자동으로 감지할 수 있습니다.


#Flutter#CleanArchitecture#DDD#Repository#UseCase