이미지 로딩 중...

UseCase 패턴으로 비즈니스 로직 캡슐화 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 4 Views

UseCase 패턴으로 비즈니스 로직 캡슐화 완벽 가이드

복잡한 비즈니스 로직을 깔끔하게 관리하는 UseCase 패턴을 마스터해보세요. Clean Architecture의 핵심 개념부터 Flutter 실무 적용까지, 유지보수가 쉬운 코드를 작성하는 방법을 배웁니다.


목차

  1. UseCase 패턴 기초
  2. 단일 책임 원칙 적용
  3. Repository와의 협업
  4. Either 타입으로 에러 핸들링
  5. 의존성 주입 패턴
  6. UseCase 파라미터 객체
  7. Stream UseCase 구현
  8. UseCase 체이닝

1. UseCase 패턴 기초

시작하며

여러분이 Flutter 앱을 개발하면서 "사용자 로그인" 기능을 구현한다고 생각해보세요. 이메일 검증, 비밀번호 암호화, API 호출, 토큰 저장, 사용자 정보 캐싱...

이 모든 로직을 어디에 작성하시나요? UI 컴포넌트에?

아니면 Repository에? 많은 개발자들이 비즈니스 로직을 위젯이나 컨트롤러에 직접 작성하거나, Repository에 너무 많은 책임을 부여합니다.

이렇게 되면 코드는 금방 스파게티가 되고, 테스트는 어려워지며, 같은 로직을 여러 곳에서 중복해서 작성하게 됩니다. 바로 이럴 때 필요한 것이 UseCase 패턴입니다.

UseCase는 "하나의 비즈니스 작업"을 캡슐화하여 재사용 가능하고, 테스트 가능하며, 이해하기 쉬운 코드를 만들어줍니다.

개요

간단히 말해서, UseCase는 애플리케이션의 "하나의 사용 사례"를 표현하는 클래스입니다. 사용자가 앱에서 수행할 수 있는 특정 작업(로그인, 게시물 작성, 프로필 수정 등)을 하나의 독립된 단위로 캡슐화합니다.

Clean Architecture에서 UseCase는 도메인 레이어에 위치하며, UI와 데이터 소스 사이의 중간 계층 역할을 합니다. 이를 통해 비즈니스 규칙이 UI 프레임워크나 데이터베이스 구현에 의존하지 않게 됩니다.

예를 들어, "사용자 프로필 업데이트" UseCase는 데이터를 Firebase에서 가져오든 REST API에서 가져오든 상관없이 동일한 비즈니스 규칙을 적용할 수 있습니다. 기존에는 비즈니스 로직을 Repository나 ViewModel에 섞어서 작성했다면, 이제는 각 작업을 독립된 UseCase 클래스로 분리할 수 있습니다.

이렇게 하면 각 UseCase를 독립적으로 테스트하고, 여러 화면에서 재사용하며, 비즈니스 규칙 변경 시 한 곳만 수정하면 됩니다. UseCase의 핵심 특징은 세 가지입니다: 첫째, 단일 책임 원칙을 따라 하나의 작업만 수행합니다.

둘째, 외부 의존성을 주입받아 테스트가 쉽습니다. 셋째, 명확한 입력과 출력을 정의하여 예측 가능한 동작을 보장합니다.

이러한 특징들이 대규모 앱에서 코드 품질을 유지하는 핵심입니다.

코드 예제

// 기본 UseCase 추상 클래스
abstract class UseCase<Type, Params> {
  // call 메서드로 UseCase를 함수처럼 호출 가능
  Future<Type> call(Params params);
}

// 로그인 UseCase 구현
class LoginUseCase implements UseCase<User, LoginParams> {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  @override
  Future<User> call(LoginParams params) async {
    // 비즈니스 로직: 이메일 검증
    if (!_isValidEmail(params.email)) {
      throw InvalidEmailException();
    }

    // Repository를 통해 데이터 레이어 호출
    return await repository.login(params.email, params.password);
  }

  bool _isValidEmail(String email) => email.contains('@');
}

// 파라미터 클래스
class LoginParams {
  final String email;
  final String password;

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

설명

이것이 하는 일: UseCase 패턴은 애플리케이션의 비즈니스 로직을 독립된 클래스로 분리하여, 각 작업을 명확하게 정의하고 관리합니다. 첫 번째로, 추상 클래스 UseCase<Type, Params>를 정의합니다.

제네릭 타입 Type은 반환값의 타입이고, Params는 입력 파라미터의 타입입니다. call 메서드를 정의함으로써 UseCase 인스턴스를 함수처럼 호출할 수 있게 됩니다(예: loginUseCase(params) 형태로 사용).

이렇게 하는 이유는 UseCase를 일급 함수처럼 취급하여 코드를 더 직관적으로 만들기 위함입니다. 그 다음으로, LoginUseCase 구현체를 만듭니다.

생성자에서 AuthRepository를 주입받는데, 이는 의존성 역전 원칙(Dependency Inversion Principle)을 따르는 것입니다. Repository는 인터페이스로 정의되어 있어, 실제 구현체가 무엇이든 상관없이 UseCase는 동일하게 작동합니다.

call 메서드 내부에서는 먼저 이메일 유효성 검증이라는 비즈니스 규칙을 적용한 후, repository를 통해 실제 로그인 작업을 수행합니다. 마지막으로, LoginParams 클래스로 입력값을 캡슐화합니다.

여러 개의 파라미터를 하나의 객체로 묶으면 파라미터 순서를 신경 쓸 필요가 없고, 나중에 파라미터를 추가하거나 수정할 때 메서드 시그니처를 변경하지 않아도 됩니다. 또한 named parameter를 사용하여 코드 가독성이 높아집니다.

여러분이 이 코드를 사용하면 비즈니스 로직이 UI 코드와 완전히 분리되어 있어서, 같은 로그인 로직을 여러 화면에서 재사용할 수 있습니다. 테스트할 때는 mock Repository를 주입하여 실제 네트워크 호출 없이 비즈니스 로직만 검증할 수 있습니다.

또한 로그인 규칙이 변경되어도 이 UseCase 파일만 수정하면 되므로 유지보수가 매우 쉬워집니다.

실전 팁

💡 UseCase 이름은 동사로 시작하세요 (예: GetUser, UpdateProfile, DeletePost). 이렇게 하면 코드를 읽을 때 "무엇을 하는지"가 즉시 명확해집니다.

💡 하나의 UseCase는 하나의 작업만 수행해야 합니다. 만약 UseCase가 너무 많은 일을 한다면, 여러 개의 작은 UseCase로 분리하세요.

💡 UseCase 내부에서는 절대 UI 관련 코드(BuildContext, Widget 등)를 사용하지 마세요. UseCase는 순수한 비즈니스 로직만 담당해야 합니다.

💡 복잡한 비즈니스 로직은 private 메서드로 분리하여 call 메서드를 깔끔하게 유지하세요. 이렇게 하면 각 로직을 개별적으로 테스트할 수도 있습니다.

💡 파라미터가 없는 UseCase의 경우 NoParams 클래스를 정의해서 일관성을 유지하세요. 예: class NoParams {}를 만들고 UseCase<User, NoParams>처럼 사용합니다.


2. 단일 책임 원칙 적용

시작하며

여러분의 프로젝트에서 "사용자 프로필 관리" UseCase를 만든다고 상상해보세요. 처음에는 간단하게 시작합니다.

프로필 조회 기능만 추가하죠. 그런데 시간이 지나면서 "프로필 업데이트도 여기서 하면 편하지 않을까?", "프로필 이미지 업로드도 같이 처리하자", "아, 프로필 삭제 기능도 필요한데..." 이렇게 하나의 UseCase가 점점 비대해집니다.

이런 식으로 개발하면 ProfileUseCase는 수백 줄의 코드를 가진 거대한 클래스가 되고, 한 부분을 수정했을 때 다른 부분에 어떤 영향을 미칠지 예측하기 어려워집니다. 테스트 코드도 복잡해지고, 다른 개발자가 코드를 이해하는 데 시간이 오래 걸립니다.

바로 이럴 때 필요한 것이 단일 책임 원칙(Single Responsibility Principle)입니다. 각 UseCase는 딱 하나의 작업만 책임지도록 설계하면, 코드는 간결해지고 변경에 유연하게 대응할 수 있습니다.

개요

간단히 말해서, 단일 책임 원칙은 "하나의 클래스는 하나의 변경 이유만 가져야 한다"는 원칙입니다. UseCase에 적용하면 각 UseCase는 정확히 하나의 비즈니스 작업만 수행해야 합니다.

SOLID 원칙의 첫 번째인 단일 책임 원칙을 UseCase 패턴에 적용하면 놀라운 효과를 얻을 수 있습니다. 예를 들어, 사용자 관리 기능이 필요하다면 GetUserUseCase, UpdateUserUseCase, DeleteUserUseCase처럼 각 작업을 별도의 UseCase로 분리합니다.

이렇게 하면 프로필 업데이트 로직이 변경되어도 조회나 삭제 로직에는 전혀 영향을 주지 않습니다. 기존에는 하나의 큰 Service 클래스에 모든 메서드를 몰아넣었다면, 이제는 각 메서드를 독립된 UseCase로 추출할 수 있습니다.

이것이 Clean Architecture의 핵심이며, 코드베이스가 커질수록 그 가치가 더욱 빛을 발합니다. 이 원칙의 핵심 이점은 세 가지입니다: 첫째, 각 UseCase가 작고 이해하기 쉽습니다.

둘째, 변경의 영향 범위가 명확합니다. 셋째, 단위 테스트를 작성하기가 매우 쉬워집니다.

이러한 이점들이 장기적으로 개발 속도를 크게 향상시킵니다.

코드 예제

// ❌ 나쁜 예: 여러 책임을 가진 UseCase
class UserManagementUseCase {
  final UserRepository repository;

  UserManagementUseCase(this.repository);

  Future<User> getUser(String id) => repository.getUser(id);
  Future<void> updateUser(User user) => repository.updateUser(user);
  Future<void> deleteUser(String id) => repository.deleteUser(id);
  // 변경 이유가 3개 이상!
}

// ✅ 좋은 예: 단일 책임을 가진 UseCase들
class GetUserUseCase implements UseCase<User, String> {
  final UserRepository repository;

  GetUserUseCase(this.repository);

  @override
  Future<User> call(String userId) async {
    // 조회 관련 비즈니스 로직만 담당
    if (userId.isEmpty) throw InvalidUserIdException();
    return await repository.getUser(userId);
  }
}

class UpdateUserUseCase implements UseCase<void, User> {
  final UserRepository repository;

  UpdateUserUseCase(this.repository);

  @override
  Future<void> call(User user) async {
    // 업데이트 관련 비즈니스 로직만 담당
    if (!_isValidUser(user)) throw InvalidUserDataException();
    await repository.updateUser(user);
  }

  bool _isValidUser(User user) => user.name.isNotEmpty;
}

설명

이것이 하는 일: 단일 책임 원칙을 적용하여 각 UseCase가 명확한 하나의 목적만 가지도록 설계합니다. 첫 번째로, 나쁜 예시인 UserManagementUseCase를 살펴보면, 이 클래스는 사용자 조회, 업데이트, 삭제라는 세 가지 다른 작업을 모두 담당합니다.

이것은 세 가지 서로 다른 "변경 이유"를 가진다는 의미입니다. 예를 들어, 사용자 업데이트 로직에 복잡한 검증 규칙이 추가되면 이 거대한 클래스를 열어서 수정해야 하고, 실수로 다른 메서드에 영향을 줄 수 있습니다.

또한 이 클래스를 테스트하려면 모든 기능을 한꺼번에 테스트해야 하므로 테스트 코드가 복잡해집니다. 그 다음으로, 좋은 예시들을 보면 각 작업이 독립된 UseCase로 분리되어 있습니다.

GetUserUseCase는 오직 사용자 조회만 담당하고, 사용자 ID 검증이라는 조회 관련 비즈니스 로직만 포함합니다. UpdateUserUseCase는 업데이트만 담당하고, 사용자 데이터 유효성 검증이라는 업데이트 관련 로직만 가지고 있습니다.

이렇게 분리하면 각 UseCase는 50줄 미만의 간결한 코드로 유지되며, 한눈에 무엇을 하는지 이해할 수 있습니다. 마지막으로, 이런 구조의 실전 이점을 생각해보세요.

프로필 업데이트 정책이 변경되어 추가 검증이 필요하다면, UpdateUserUseCase만 열어서 수정하면 됩니다. 다른 UseCase들은 전혀 건드릴 필요가 없습니다.

또한 새로운 기능(예: 사용자 일괄 삭제)이 필요하면 새로운 UseCase를 추가하기만 하면 되고, 기존 코드를 수정할 필요가 없습니다. 이것이 개방-폐쇄 원칙(Open-Closed Principle)과도 연결됩니다.

여러분이 이 패턴을 사용하면 코드 리뷰가 훨씬 쉬워집니다. 각 UseCase가 작고 명확하므로 리뷰어는 빠르게 로직을 이해하고 피드백을 줄 수 있습니다.

또한 신규 팀원이 합류했을 때 전체 시스템을 이해하지 않아도, 하나의 UseCase만 보고도 해당 기능을 파악할 수 있습니다. 장기적으로 코드베이스의 유지보수성이 극적으로 향상됩니다.

실전 팁

💡 UseCase 이름만 보고도 정확히 무엇을 하는지 알 수 있어야 합니다. UserUseCase보다는 GetUserProfileUseCase처럼 구체적으로 네이밍하세요.

💡 하나의 UseCase가 100줄을 넘어간다면 책임이 너무 많다는 신호입니다. 로직을 분석해서 별도의 UseCase로 분리할 수 있는지 검토하세요.

💡 "그리고(and)"가 들어가는 UseCase는 의심하세요. 예를 들어 "사용자를 가져오고 로그를 기록한다"는 두 개의 UseCase로 분리해야 합니다.

💡 여러 UseCase가 같은 로직을 공유한다면, 그 로직을 별도의 도메인 서비스나 헬퍼 클래스로 추출하세요. UseCase 간에 상속을 사용하지 마세요.

💡 폴더 구조도 중요합니다. usecases/user/get_user_usecase.dart, usecases/user/update_user_usecase.dart처럼 도메인별로 폴더를 나누면 관련 UseCase들을 쉽게 찾을 수 있습니다.


3. Repository와의 협업

시작하며

여러분이 UseCase를 작성하다 보면 궁금증이 생깁니다. "데이터는 어디서 가져오지?

UseCase에서 직접 API를 호출해야 하나? 아니면 데이터베이스에 직접 접근해야 하나?" 만약 UseCase에서 직접 HTTP 클라이언트를 사용하거나 데이터베이스 쿼리를 작성한다면, UseCase가 데이터 소스의 구현 세부사항에 강하게 결합됩니다.

이런 구조는 심각한 문제를 야기합니다. API 엔드포인트가 변경되면 모든 UseCase를 수정해야 하고, Firebase에서 Supabase로 전환하려면 수십 개의 UseCase를 다시 작성해야 합니다.

또한 네트워크 없이 UseCase를 테스트하는 것도 거의 불가능해집니다. 바로 이럴 때 필요한 것이 Repository 패턴입니다.

Repository는 데이터 접근을 추상화하여 UseCase가 "데이터가 어디서 오는지" 신경 쓰지 않고 "무슨 데이터가 필요한지"만 표현할 수 있게 해줍니다.

개요

간단히 말해서, Repository는 데이터 소스(API, 데이터베이스, 로컬 캐시 등)를 추상화한 인터페이스입니다. UseCase는 이 인터페이스에만 의존하므로, 실제 데이터가 어디서 오는지 알 필요가 없습니다.

Clean Architecture에서 Repository는 데이터 레이어의 진입점 역할을 합니다. UseCase는 도메인 레이어에 위치하고, Repository 인터페이스도 도메인 레이어에 정의됩니다.

하지만 실제 Repository 구현체는 데이터 레이어에 위치합니다. 예를 들어, UserRepository 인터페이스는 도메인 레이어에서 정의하고, UserRepositoryImpl은 데이터 레이어에서 API 호출이나 데이터베이스 접근을 구현합니다.

이것이 의존성 역전 원칙입니다. 기존에는 UseCase에서 직접 데이터 소스를 다루었다면, 이제는 Repository 인터페이스를 통해 간접적으로 접근합니다.

이렇게 하면 데이터 소스가 변경되어도 UseCase는 전혀 수정할 필요가 없습니다. Repository 구현체만 교체하면 됩니다.

Repository 패턴의 핵심 이점은 세 가지입니다: 첫째, UseCase와 데이터 소스의 결합도가 낮아집니다. 둘째, 테스트 시 mock Repository를 쉽게 주입할 수 있습니다.

셋째, 데이터 소스를 변경하거나 추가할 때 UseCase를 수정하지 않아도 됩니다. 이러한 이점들이 장기적으로 앱의 유연성을 크게 향상시킵니다.

코드 예제

// 도메인 레이어: Repository 인터페이스 정의
abstract class UserRepository {
  Future<User> getUser(String id);
  Future<void> updateUser(User user);
  Future<List<User>> searchUsers(String query);
}

// 도메인 레이어: UseCase는 인터페이스에만 의존
class GetUserUseCase implements UseCase<User, String> {
  final UserRepository repository; // 추상화에 의존

  GetUserUseCase(this.repository);

  @override
  Future<User> call(String userId) async {
    // 비즈니스 로직: 캐시된 데이터가 유효한지 확인
    final cachedUser = await repository.getCachedUser(userId);
    if (cachedUser != null && !_isCacheExpired(cachedUser)) {
      return cachedUser;
    }

    // 캐시가 없으면 새로 가져오기
    return await repository.getUser(userId);
  }

  bool _isCacheExpired(User user) {
    return DateTime.now().difference(user.lastFetched) > Duration(hours: 1);
  }
}

// 데이터 레이어: 실제 구현 (UseCase는 이것을 몰라도 됨)
class UserRepositoryImpl implements UserRepository {
  final ApiClient apiClient;
  final LocalDatabase database;

  UserRepositoryImpl(this.apiClient, this.database);

  @override
  Future<User> getUser(String id) async {
    // API 호출, 에러 처리, 캐싱 등의 구현
    final response = await apiClient.get('/users/$id');
    final user = User.fromJson(response.data);
    await database.saveUser(user); // 로컬에 캐싱
    return user;
  }

  @override
  Future<User?> getCachedUser(String id) => database.getUser(id);
}

설명

이것이 하는 일: Repository 패턴을 통해 UseCase와 데이터 소스를 분리하여, 각 레이어가 독립적으로 변경될 수 있는 구조를 만듭니다. 첫 번째로, UserRepository 추상 클래스를 정의합니다.

이것은 도메인 레이어에 위치하며, "사용자 데이터에 접근하는 방법"을 정의하지만 "어떻게 구현되는지"는 명시하지 않습니다. getUser, updateUser 같은 메서드 시그니처만 정의되어 있습니다.

이것이 핵심입니다 - UseCase는 이 인터페이스만 알면 되고, 실제 구현이 REST API를 사용하는지, GraphQL을 사용하는지, 로컬 데이터베이스를 사용하는지 전혀 몰라도 됩니다. 그 다음으로, GetUserUseCase를 보면 생성자에서 UserRepository 타입의 의존성을 주입받습니다.

구체적인 구현체가 아닌 추상화에 의존하는 것이 핵심입니다. 이 UseCase는 비즈니스 로직(캐시 유효성 검증)을 처리하고, 실제 데이터 가져오기는 repository에게 위임합니다.

이렇게 하면 UseCase의 책임은 "비즈니스 규칙 적용"에만 집중되고, 데이터 가져오기의 복잡한 세부사항은 Repository가 담당합니다. 마지막으로, UserRepositoryImpl 구현체를 보면 실제 API 호출, 에러 처리, 로컬 캐싱 등 데이터 접근의 모든 세부사항이 여기에 구현되어 있습니다.

중요한 점은 이 구현체가 데이터 레이어에 위치하므로, UseCase는 이것의 존재조차 알 필요가 없다는 것입니다. 나중에 API를 Firebase로 교체하고 싶다면 UserRepositoryFirebaseImpl을 새로 만들고 의존성 주입 시점에만 변경하면 됩니다.

UseCase 코드는 단 한 줄도 수정하지 않아도 됩니다. 여러분이 이 패턴을 사용하면 테스트가 극적으로 쉬워집니다.

UseCase를 테스트할 때 mock Repository를 주입하여 네트워크 호출 없이 순수한 비즈니스 로직만 검증할 수 있습니다. 또한 여러 데이터 소스를 조합할 수도 있습니다 - Repository 구현체가 "먼저 로컬 DB를 확인하고, 없으면 API를 호출하고, 결과를 다시 DB에 저장하는" 복잡한 로직을 처리하더라도, UseCase는 단순히 repository.getUser()만 호출하면 됩니다.

실전 팁

💡 Repository 인터페이스는 도메인 레이어에, 구현체는 데이터 레이어에 위치시키세요. 이것이 의존성 역전 원칙의 핵심입니다.

💡 Repository 메서드는 도메인 모델(Entity)을 반환해야 하고, DTO나 JSON을 직접 반환하면 안 됩니다. 데이터 변환은 Repository 내부에서 처리하세요.

💡 여러 데이터 소스를 다룬다면 각각을 DataSource로 분리하고, Repository에서 이들을 조합하세요. 예: RemoteDataSource, LocalDataSource를 만들고 Repository가 둘을 사용합니다.

💡 Repository는 데이터 접근만 담당해야 합니다. 비즈니스 로직(검증, 계산 등)은 절대 Repository에 넣지 말고 UseCase에 두세요.

💡 캐싱 전략(메모리 캐시, 디스크 캐시, TTL 등)은 Repository에서 처리하세요. UseCase는 캐싱을 몰라도 되고, Repository가 알아서 최적화된 데이터를 제공합니다.


4. Either 타입으로 에러 핸들링

시작하며

여러분이 UseCase를 작성하다 보면 항상 마주치는 문제가 있습니다. 에러 처리입니다.

네트워크 오류, 잘못된 입력값, 권한 부족, 서버 에러... 수많은 실패 케이스가 있고, 이들을 어떻게 처리해야 할까요?

전통적으로는 try-catch로 예외를 던지고 상위에서 잡는 방식을 사용합니다. 하지만 예외를 던지는 방식은 몇 가지 문제가 있습니다.

어떤 예외가 던져질지 타입 시스템이 강제하지 않아서 놓치기 쉽고, 예외 처리 코드가 흩어져서 에러 흐름을 추적하기 어렵습니다. 또한 예외는 "예외적인 상황"을 위한 것인데, 네트워크 실패는 앱에서 흔히 발생하는 정상적인 흐름입니다.

바로 이럴 때 필요한 것이 Either 타입입니다. Either는 함수형 프로그래밍에서 온 개념으로, 성공과 실패를 명시적인 타입으로 표현하여 안전하고 예측 가능한 에러 처리를 가능하게 합니다.

개요

간단히 말해서, Either 타입은 "왼쪽(Left) 또는 오른쪽(Right) 중 하나"를 담을 수 있는 컨테이너입니다. 관례적으로 Left는 실패(Failure), Right는 성공(Success) 값을 담습니다.

Either를 사용하면 함수의 반환 타입만 보고도 "이 함수는 실패할 수 있구나"를 즉시 알 수 있습니다. 예를 들어, Future<Either<Failure, User>>는 "이 함수는 비동기로 실행되며, Failure 또는 User 중 하나를 반환한다"는 의미입니다.

호출하는 쪽에서는 반드시 두 경우를 모두 처리해야 하므로, 에러 처리를 잊어버릴 수 없습니다. 기존에는 try-catch로 예외를 잡고, 어떤 예외가 발생할지 문서나 코드를 읽어야 알 수 있었다면, 이제는 타입 시스템이 모든 것을 명확하게 알려줍니다.

컴파일러가 에러 처리를 강제하므로, 런타임 크래시가 극적으로 줄어듭니다. Either 타입의 핵심 이점은 세 가지입니다: 첫째, 에러가 타입 시그니처에 명시되어 있어 예측 가능합니다.

둘째, 패턴 매칭이나 fold로 성공/실패 케이스를 우아하게 처리할 수 있습니다. 셋째, 함수형 연산자(map, flatMap 등)로 에러를 전파하거나 변환하기 쉽습니다.

이러한 특징들이 안전한 코드를 작성하는 데 결정적인 역할을 합니다.

코드 예제

// Failure 클래스 정의 (모든 에러의 베이스)
abstract class Failure {
  final String message;
  Failure(this.message);
}

class NetworkFailure extends Failure {
  NetworkFailure() : super('네트워크 연결에 실패했습니다');
}

class ServerFailure extends Failure {
  ServerFailure(String msg) : super(msg);
}

// Either를 사용한 UseCase
class GetUserUseCase implements UseCase<Either<Failure, User>, String> {
  final UserRepository repository;

  GetUserUseCase(this.repository);

  @override
  Future<Either<Failure, User>> call(String userId) async {
    try {
      // 입력값 검증
      if (userId.isEmpty) {
        return Left(ValidationFailure('사용자 ID가 비어있습니다'));
      }

      // Repository 호출 (Repository도 Either 반환)
      final result = await repository.getUser(userId);

      // 성공 시 Right로 감싸서 반환
      return result; // Either<Failure, User>
    } catch (e) {
      // 예상치 못한 에러도 Either로 변환
      return Left(UnknownFailure(e.toString()));
    }
  }
}

// UI에서 사용
final result = await getUserUseCase(userId);

result.fold(
  (failure) {
    // 실패 처리: Failure 타입에 따라 다른 메시지 표시
    if (failure is NetworkFailure) {
      showSnackBar('인터넷 연결을 확인해주세요');
    } else if (failure is ServerFailure) {
      showSnackBar('서버 오류: ${failure.message}');
    }
  },
  (user) {
    // 성공 처리: User 데이터 사용
    setState(() => this.user = user);
  },
);

설명

이것이 하는 일: Either 타입을 활용하여 예외 대신 명시적인 반환값으로 에러를 처리하며, 타입 안전성을 보장합니다. 첫 번째로, Failure 계층을 정의합니다.

추상 클래스 Failure를 베이스로 하고, 각 에러 종류별로 구체적인 클래스(NetworkFailure, ServerFailure 등)를 만듭니다. 이렇게 하면 UI 레이어에서 실패 타입에 따라 다른 처리를 할 수 있습니다.

예를 들어, 네트워크 에러면 재시도 버튼을 보여주고, 권한 에러면 로그인 화면으로 이동하는 식으로 말이죠. 모든 에러를 하나의 Exception으로 던지는 것보다 훨씬 구체적이고 처리하기 쉽습니다.

그 다음으로, GetUserUseCase의 반환 타입을 보세요: Future<Either<Failure, User>>. 이것은 "이 UseCase는 비동기로 실행되며, Failure 또는 User를 반환한다"는 의미입니다.

호출하는 쪽에서는 이 타입 시그니처를 보고 즉시 "아, 이 함수는 실패할 수 있으니 에러 처리를 해야겠구나"라고 알 수 있습니다. 내부 구현을 보면, 입력값 검증 실패 시 Left(ValidationFailure(...))를 반환하고, 성공 시 Right(user)를 반환합니다.

try-catch로 감싼 것은 예상치 못한 예외를 Either로 변환하기 위함입니다. 마지막으로, UI에서 사용하는 방법을 보면 fold 메서드가 등장합니다.

fold는 Either의 두 가지 케이스를 모두 처리하는 패턴 매칭입니다. 첫 번째 콜백은 Left(실패) 케이스, 두 번째 콜백은 Right(성공) 케이스를 처리합니다.

이것이 아름다운 이유는 컴파일러가 두 케이스를 모두 처리하도록 강제하기 때문입니다. if 문으로 null 체크만 하고 에러 케이스를 깜빡하는 실수가 불가능합니다.

여러분이 이 패턴을 사용하면 코드 리뷰 시 "여기서 에러가 발생하면 어떻게 되나요?"라는 질문을 받지 않습니다. 타입 시그니처가 모든 것을 말해주기 때문이죠.

또한 여러 UseCase를 체이닝할 때 에러가 자동으로 전파됩니다. 하나의 UseCase가 Left를 반환하면 이후 체인은 자동으로 스킵되고 에러가 최종 호출자에게 전달됩니다.

이것이 함수형 프로그래밍의 강력함입니다.

실전 팁

💡 dartz 패키지를 사용하면 Either 타입과 함께 유용한 함수형 유틸리티(Option, Task 등)를 제공받을 수 있습니다.

💡 Failure 클래스는 Equatable을 상속하여 테스트에서 에러를 비교하기 쉽게 만드세요. 같은 타입의 Failure를 ==로 비교할 수 있습니다.

💡 Repository 레이어에서도 Either를 반환하면 일관성이 있습니다. 하지만 DataSource 레이어는 예외를 던지고 Repository에서 Either로 변환하는 방식도 좋습니다.

💡 result.fold() 대신 result.isRight(), result.getOrElse() 같은 헬퍼 메서드도 활용하세요. 간단한 경우에는 더 읽기 쉽습니다.

💡 여러 UseCase를 순차적으로 실행할 때는 flatMap(또는 bind)을 사용하여 에러를 자동으로 전파하세요. 첫 번째 UseCase가 실패하면 나머지는 실행되지 않습니다.


5. 의존성 주입 패턴

시작하며

여러분이 UseCase를 작성했습니다. Repository를 주입받고, 비즈니스 로직을 구현하고, Either로 에러를 처리했습니다.

완벽해 보입니다. 그런데 실제로 이 UseCase를 사용하려고 하니 문제가 생깁니다.

"UseCase 인스턴스를 어디서 만들지? 매번 수동으로 생성해야 하나?

Repository는 또 어떻게 주입하지?" 만약 화면마다 final useCase = GetUserUseCase(UserRepositoryImpl(ApiClient(), Database()))처럼 생성한다면, 코드 중복이 심하고, 의존성 그래프가 복잡해지며, 테스트 시 mock 객체를 주입하기 어려워집니다. 또한 싱글톤이 필요한 객체를 매번 새로 생성하게 되어 메모리도 낭비됩니다.

바로 이럴 때 필요한 것이 의존성 주입(Dependency Injection) 패턴입니다. DI 컨테이너를 사용하면 객체 생성과 의존성 연결을 자동화하고, 테스트 가능한 코드를 만들며, 앱 전체의 의존성을 중앙에서 관리할 수 있습니다.

개요

간단히 말해서, 의존성 주입은 객체가 자신이 필요로 하는 의존성을 직접 생성하지 않고, 외부에서 주입받는 디자인 패턴입니다. Flutter에서는 주로 get_it, provider, riverpod 같은 패키지를 사용합니다.

의존성 주입을 적용하면 UseCase는 자신이 필요로 하는 Repository를 어떻게 생성하는지 몰라도 됩니다. 누군가(DI 컨테이너)가 알아서 필요한 의존성을 제공해줍니다.

예를 들어, GetUserUseCase는 생성자에서 UserRepository를 받기만 하면 되고, 실제로 어떤 구현체가 주입되는지, 그 구현체가 어떤 의존성을 가지는지 전혀 신경 쓰지 않습니다. 기존에는 객체를 사용하는 곳에서 직접 생성했다면(new나 팩토리 메서드), 이제는 DI 컨테이너에 "이 타입이 필요할 때 이렇게 생성해줘"라고 등록해두고, 필요한 곳에서는 "이 타입의 인스턴스를 주세요"라고 요청하기만 하면 됩니다.

이것이 제어의 역전(Inversion of Control)입니다. 의존성 주입의 핵심 이점은 세 가지입니다: 첫째, 결합도가 낮아져 코드 변경이 쉬워집니다.

둘째, 테스트 시 mock 객체를 쉽게 주입할 수 있습니다. 셋째, 싱글톤, 팩토리 같은 생명주기 관리를 중앙에서 할 수 있습니다.

이러한 이점들이 대규모 앱 개발에서 필수적입니다.

코드 예제

// get_it 사용 예시
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

// 의존성 등록 (앱 시작 시 한 번만 실행)
void setupDependencies() {
  // 1. 최하위 의존성부터 등록 (싱글톤)
  getIt.registerLazySingleton<ApiClient>(() => ApiClient());
  getIt.registerLazySingleton<LocalDatabase>(() => LocalDatabase());

  // 2. Repository 등록 (싱글톤)
  getIt.registerLazySingleton<UserRepository>(
    () => UserRepositoryImpl(
      getIt<ApiClient>(),      // 자동으로 의존성 해결
      getIt<LocalDatabase>(),
    ),
  );

  // 3. UseCase 등록 (팩토리 - 호출할 때마다 새 인스턴스)
  getIt.registerFactory<GetUserUseCase>(
    () => GetUserUseCase(getIt<UserRepository>()),
  );

  getIt.registerFactory<UpdateUserUseCase>(
    () => UpdateUserUseCase(getIt<UserRepository>()),
  );
}

// ViewModel/Controller에서 사용
class UserProfileViewModel extends ChangeNotifier {
  // DI 컨테이너에서 가져오기
  final GetUserUseCase _getUserUseCase = getIt<GetUserUseCase>();
  final UpdateUserUseCase _updateUserUseCase = getIt<UpdateUserUseCase>();

  User? user;

  Future<void> loadUser(String userId) async {
    final result = await _getUserUseCase(userId);
    result.fold(
      (failure) => /* 에러 처리 */,
      (userData) => user = userData,
    );
    notifyListeners();
  }
}

// 테스트 코드
void main() {
  late GetUserUseCase useCase;
  late MockUserRepository mockRepository;

  setUp(() {
    mockRepository = MockUserRepository();
    useCase = GetUserUseCase(mockRepository); // mock 주입
  });

  test('사용자 조회 성공 시 Right 반환', () async {
    // given
    when(mockRepository.getUser('123'))
        .thenAnswer((_) async => Right(testUser));

    // when
    final result = await useCase('123');

    // then
    expect(result.isRight(), true);
  });
}

설명

이것이 하는 일: 의존성 주입 패턴을 통해 객체 생성과 의존성 관리를 중앙화하고, 코드의 결합도를 낮추며 테스트 용이성을 높입니다. 첫 번째로, setupDependencies 함수에서 앱의 모든 의존성을 등록합니다.

중요한 것은 등록 순서입니다. 의존성이 없는 최하위 객체(ApiClient, Database)부터 등록하고, 그 다음 이들을 사용하는 Repository, 마지막으로 Repository를 사용하는 UseCase를 등록합니다.

registerLazySingleton은 처음 요청될 때 한 번만 생성되고 이후 재사용됩니다(싱글톤 패턴). registerFactory는 요청할 때마다 새 인스턴스를 생성합니다.

UseCase는 보통 상태를 가지지 않으므로 싱글톤이어도 되지만, 팩토리로 등록하면 더 안전합니다. 그 다음으로, ViewModel에서 UseCase를 사용하는 방법을 보세요.

getIt<GetUserUseCase>()로 인스턴스를 가져오기만 하면 됩니다. UseCase가 내부적으로 어떤 Repository를 사용하는지, Repository가 어떤 데이터 소스를 사용하는지 전혀 몰라도 됩니다.

DI 컨테이너가 의존성 그래프를 자동으로 해결해서 완전히 구성된 객체를 제공합니다. 이것이 핵심입니다 - ViewModel은 UseCase를 "사용"하는 데만 집중하고, "생성"에 대해서는 신경 쓰지 않습니다.

마지막으로, 테스트 코드를 보면 의존성 주입의 진정한 가치가 드러납니다. 테스트에서는 DI 컨테이너를 사용하지 않고, 직접 mock Repository를 생성해서 UseCase 생성자에 주입합니다.

이렇게 하면 실제 네트워크나 데이터베이스 없이 UseCase의 비즈니스 로직만 격리해서 테스트할 수 있습니다. MockUserRepository는 원하는 대로 동작을 정의할 수 있으므로, 성공 케이스, 실패 케이스, 엣지 케이스를 모두 쉽게 테스트할 수 있습니다.

여러분이 이 패턴을 사용하면 새로운 기능을 추가할 때 기존 코드를 수정할 필요가 없습니다. 새로운 UseCase를 만들고 DI 컨테이너에 등록하기만 하면 됩니다.

또한 개발 환경과 프로덕션 환경에서 다른 구현체를 사용할 수도 있습니다. 예를 들어, 개발 시에는 MockUserRepository를, 프로덕션에서는 UserRepositoryImpl을 등록하면 됩니다.

설정 파일만 바꾸면 되고, 앱 코드는 전혀 수정하지 않아도 됩니다.

실전 팁

💡 get_itregisterLazySingleton을 적극 활용하세요. 앱 시작 시점에 모든 객체를 생성하지 않고, 필요할 때 생성되어 메모리를 절약합니다.

💡 의존성 등록 코드는 별도 파일로 분리하세요. 예: di/injection.dart. 도메인별로 파일을 나눌 수도 있습니다(user_di.dart, post_di.dart).

💡 테스트에서는 getIt.reset()으로 컨테이너를 초기화한 후 test용 mock 객체를 등록하세요. 이렇게 하면 테스트 간 격리가 보장됩니다.

💡 환경별 설정(dev, staging, prod)은 getIt.registerSingletonWithDependencies를 활용하여 조건부로 등록할 수 있습니다.

💡 Provider나 Riverpod를 선호한다면, 각각의 생태계에 맞는 의존성 주입 방법을 사용하세요. 핵심 원칙(추상화에 의존, 외부 주입)은 동일합니다.


6. UseCase 파라미터 객체

시작하며

여러분이 "사용자 검색" UseCase를 만든다고 생각해보세요. 처음에는 간단합니다.

검색어 하나만 받으면 되니까 call(String query) 이렇게 정의합니다. 그런데 요구사항이 추가됩니다.

"나이 필터를 추가해주세요", "정렬 옵션도 필요해요", "페이지네이션도 지원해야 합니다". 이제 메서드 시그니처는 `call(String query, int?

minAge, int? maxAge, String?

sortBy, int page, int pageSize)`가 됩니다. 이런 식으로 개발하면 여러 문제가 생깁니다.

파라미터 순서를 기억해야 하고, 파라미터를 추가할 때마다 모든 호출 코드를 수정해야 하며, null 처리가 복잡해집니다. 또한 call 메서드의 시그니처가 계속 변경되어 기존 코드가 깨집니다.

바로 이럴 때 필요한 것이 파라미터 객체(Parameter Object) 패턴입니다. 여러 파라미터를 하나의 클래스로 묶으면 파라미터 관리가 쉬워지고, 기본값을 설정할 수 있으며, 코드 가독성이 크게 향상됩니다.

개요

간단히 말해서, 파라미터 객체는 UseCase의 입력값을 하나의 클래스로 캡슐화하는 패턴입니다. 각 파라미터는 클래스의 필드가 되고, named parameter와 기본값을 활용하여 유연성을 높입니다.

이 패턴은 특히 파라미터가 3개 이상이거나, 선택적 파라미터가 많거나, 파라미터가 자주 변경될 때 빛을 발합니다. 예를 들어, 복잡한 검색 기능이나 필터링 기능처럼 다양한 옵션을 받아야 하는 경우에 매우 유용합니다.

파라미터 객체를 사용하면 새로운 옵션을 추가해도 기존 코드를 전혀 수정하지 않아도 됩니다. 기존에는 함수 시그니처에 파라미터를 나열했다면, 이제는 하나의 객체로 모든 입력값을 전달합니다.

이렇게 하면 파라미터 순서를 신경 쓸 필요가 없고, IDE의 자동완성이 모든 옵션을 보여주며, 같은 타입의 파라미터를 헷갈릴 일도 없습니다(예: userIdpostId가 둘 다 String일 때). 파라미터 객체의 핵심 이점은 세 가지입니다: 첫째, 파라미터 변경 시 호출 코드가 깨지지 않습니다.

둘째, 기본값과 옵셔널 파라미터를 명확하게 표현할 수 있습니다. 셋째, 파라미터 검증 로직을 파라미터 객체 내부에 캡슐화할 수 있습니다.

이러한 이점들이 API의 안정성과 사용성을 크게 높여줍니다.

코드 예제

// ❌ 나쁜 예: 파라미터가 많은 UseCase
class SearchUsersUseCase {
  Future<List<User>> call(
    String query,
    int? minAge,
    int? maxAge,
    String? sortBy,
    bool? isActive,
    int page,
    int pageSize,
  ) async {
    // 파라미터 순서를 기억하기 어렵고, 호출 시 헷갈림
  }
}

// ✅ 좋은 예: 파라미터 객체 사용
class SearchUsersParams {
  final String query;
  final int? minAge;
  final int? maxAge;
  final SortOption sortBy;
  final bool isActiveOnly;
  final int page;
  final int pageSize;

  const SearchUsersParams({
    required this.query,
    this.minAge,
    this.maxAge,
    this.sortBy = SortOption.name,  // 기본값
    this.isActiveOnly = false,       // 기본값
    this.page = 1,                   // 기본값
    this.pageSize = 20,              // 기본값
  });

  // 검증 로직을 파라미터 객체에 캡슐화
  bool isValid() {
    if (query.isEmpty) return false;
    if (minAge != null && minAge! < 0) return false;
    if (maxAge != null && minAge != null && maxAge! < minAge!) return false;
    if (page < 1 || pageSize < 1) return false;
    return true;
  }

  // copyWith로 불변성 유지하면서 수정
  SearchUsersParams copyWith({
    String? query,
    int? minAge,
    int? maxAge,
    SortOption? sortBy,
    bool? isActiveOnly,
    int? page,
    int? pageSize,
  }) {
    return SearchUsersParams(
      query: query ?? this.query,
      minAge: minAge ?? this.minAge,
      maxAge: maxAge ?? this.maxAge,
      sortBy: sortBy ?? this.sortBy,
      isActiveOnly: isActiveOnly ?? this.isActiveOnly,
      page: page ?? this.page,
      pageSize: pageSize ?? this.pageSize,
    );
  }
}

enum SortOption { name, age, createdAt }

// UseCase는 깔끔해짐
class SearchUsersUseCase implements UseCase<List<User>, SearchUsersParams> {
  final UserRepository repository;

  SearchUsersUseCase(this.repository);

  @override
  Future<Either<Failure, List<User>>> call(SearchUsersParams params) async {
    // 파라미터 검증
    if (!params.isValid()) {
      return Left(ValidationFailure('잘못된 검색 조건입니다'));
    }

    return await repository.searchUsers(params);
  }
}

// 호출 시 명확하고 읽기 쉬움
final result = await searchUsersUseCase(
  SearchUsersParams(
    query: 'John',
    minAge: 20,
    sortBy: SortOption.age,
    // 나머지는 기본값 사용
  ),
);

설명

이것이 하는 일: 파라미터 객체 패턴을 통해 UseCase의 입력값을 구조화하고, 유지보수성과 확장성을 높입니다. 첫 번째로, 나쁜 예시를 보면 call 메서드가 7개의 파라미터를 받습니다.

이것은 여러 문제를 야기합니다. 호출할 때 파라미터 순서를 정확히 기억해야 하고(3번째가 maxAge였나?

minAge였나?), null을 전달할 때도 위치를 맞춰야 합니다. 가장 큰 문제는 새로운 필터 옵션이 추가될 때마다 모든 호출 코드를 수정해야 한다는 것입니다.

만약 8번째 파라미터를 추가하면 기존의 모든 call 호출에 컴파일 에러가 발생합니다. 그 다음으로, SearchUsersParams 클래스를 보면 모든 파라미터가 명명된 필드로 정의되어 있습니다.

required로 필수 파라미터를 명시하고, 선택적 파라미터는 nullable이거나 기본값을 가집니다. 이렇게 하면 호출 시 SearchUsersParams(query: 'John', minAge: 20) 처럼 필요한 것만 명시할 수 있고, 나머지는 자동으로 기본값이 적용됩니다.

또한 isValid() 메서드로 파라미터 검증 로직을 캡슐화했습니다. UseCase에서는 params.isValid()만 호출하면 되므로 검증 로직이 중복되지 않습니다.

copyWith 메서드도 중요합니다. 이것은 불변 객체를 유지하면서 일부 필드만 변경할 수 있게 해줍니다.

예를 들어, "다음 페이지 로드"를 구현할 때 params.copyWith(page: params.page + 1)처럼 페이지만 증가시킨 새 객체를 만들 수 있습니다. 이것은 Flutter의 상태 관리에서 매우 유용합니다.

마지막으로, UseCase 구현을 보면 놀라울 정도로 깔끔합니다. call 메서드는 하나의 파라미터만 받고, 그 안에 모든 정보가 구조화되어 있습니다.

새로운 필터 옵션을 추가해도 SearchUsersParams에 필드를 추가하고 기본값을 설정하기만 하면 됩니다. UseCase의 시그니처는 변하지 않고, 기존 호출 코드도 깨지지 않습니다.

이것이 개방-폐쇄 원칙(확장에는 열려있고 수정에는 닫혀있음)의 실천입니다. 여러분이 이 패턴을 사용하면 API 변경이 훨씬 안전해집니다.

6개월 후에 누군가 "지역 필터를 추가해주세요"라고 요청하면, SearchUsersParamsString? location 필드만 추가하고 기본값을 null로 설정하면 끝입니다.

기존의 수백 개의 호출 코드는 전혀 수정하지 않아도 정상 작동합니다. 또한 IDE의 자동완성이 모든 가능한 파라미터를 보여주므로, 개발자 경험도 크게 향상됩니다.

실전 팁

💡 파라미터가 3개 이상이면 파라미터 객체를 고려하세요. 2개 이하라면 직접 전달하는 것이 더 간단할 수 있습니다.

💡 파라미터 객체는 Equatable을 상속하면 테스트에서 비교가 쉬워집니다. 특히 상태 관리 라이브러리와 함께 사용할 때 유용합니다.

💡 freezed 패키지를 사용하면 copyWith, ==, hashCode를 자동 생성할 수 있어 보일러플레이트를 줄일 수 있습니다.

💡 파라미터 검증은 파라미터 객체의 생성자나 isValid() 메서드에 두세요. UseCase는 이미 검증된 파라미터를 받는다고 가정하면 코드가 깔끔해집니다.

💡 기본값은 비즈니스 로직에 맞게 신중히 선택하세요. "가장 흔한 사용 케이스"를 기본값으로 하면 대부분의 호출이 간단해집니다.


7. Stream UseCase 구현

시작하며

여러분이 채팅 앱을 만든다고 상상해보세요. 새 메시지가 도착하면 실시간으로 화면에 표시되어야 합니다.

지금까지 배운 Future를 반환하는 UseCase로는 이것을 구현할 수 없습니다. Future는 "한 번" 완료되는 비동기 작업을 표현하지만, 채팅은 "지속적으로" 데이터가 흘러오는 스트림입니다.

만약 폴링(일정 간격으로 API 호출)으로 구현한다면 네트워크 낭비가 심하고, 실시간성이 떨어지며, 배터리 소모도 큽니다. 사용자가 메시지를 보낸 후 1초나 2초 뒤에 화면에 나타나면 UX가 형편없어집니다.

바로 이럴 때 필요한 것이 Stream UseCase입니다. Dart의 Stream을 활용하면 실시간 데이터, 이벤트 기반 업데이트, 연속적인 데이터 흐름을 우아하게 처리할 수 있습니다.

개요

간단히 말해서, Stream UseCase는 Future 대신 Stream을 반환하여 지속적인 데이터 흐름을 처리하는 UseCase입니다. 한 번의 응답이 아니라 여러 번의 응답을 시간에 따라 받을 수 있습니다.

Stream은 Firebase Realtime Database, WebSocket, Server-Sent Events처럼 실시간 데이터 소스와 완벽하게 맞습니다. 또한 로컬 데이터베이스의 변경사항을 감지하거나, 사용자 입력을 debounce 처리하거나, 여러 이벤트를 조합하는 데도 사용됩니다.

예를 들어, "온라인 사용자 목록"은 사용자가 접속하거나 떠날 때마다 자동으로 업데이트되어야 하는데, Stream으로 구현하면 완벽합니다. 기존에는 Future<User>를 반환했다면, 이제는 Stream<User>를 반환합니다.

호출하는 쪽에서는 await로 기다리는 대신 listen이나 StreamBuilder로 스트림을 구독하고, 새 데이터가 올 때마다 UI를 업데이트합니다. 이것이 반응형(reactive) 프로그래밍의 핵심입니다.

Stream UseCase의 핵심 이점은 세 가지입니다: 첫째, 실시간 데이터를 자연스럽게 표현할 수 있습니다. 둘째, 폴링 없이 효율적으로 데이터를 받습니다.

셋째, Dart의 강력한 스트림 연산자(map, where, debounce 등)를 활용할 수 있습니다. 이러한 특징들이 현대적인 앱 개발에서 필수적입니다.

코드 예제

// Stream을 반환하는 UseCase 인터페이스
abstract class StreamUseCase<Type, Params> {
  Stream<Type> call(Params params);
}

// 채팅방 메시지를 실시간으로 받는 UseCase
class WatchChatMessagesUseCase
    implements StreamUseCase<Either<Failure, List<Message>>, String> {
  final ChatRepository repository;

  WatchChatMessagesUseCase(this.repository);

  @override
  Stream<Either<Failure, List<Message>>> call(String roomId) {
    try {
      // Repository가 반환하는 Stream을 그대로 전달
      return repository.watchMessages(roomId)
          .map((messages) => Right(messages)) // 성공 케이스
          .handleError((error) {
            // 에러를 Either로 변환
            return Left(ServerFailure(error.toString()));
          });
    } catch (e) {
      // 동기 에러는 Stream.error로 변환
      return Stream.value(Left(UnknownFailure(e.toString())));
    }
  }
}

// Repository 구현 예시 (Firebase)
class ChatRepositoryImpl implements ChatRepository {
  final FirebaseFirestore firestore;

  ChatRepositoryImpl(this.firestore);

  @override
  Stream<List<Message>> watchMessages(String roomId) {
    // Firestore의 실시간 스냅샷을 Stream으로 반환
    return firestore
        .collection('rooms')
        .doc(roomId)
        .collection('messages')
        .orderBy('timestamp', descending: true)
        .snapshots() // Stream<QuerySnapshot>
        .map((snapshot) {
          return snapshot.docs
              .map((doc) => Message.fromFirestore(doc))
              .toList();
        });
  }
}

// UI에서 사용 (StreamBuilder)
class ChatScreen extends StatelessWidget {
  final WatchChatMessagesUseCase watchMessages;
  final String roomId;

  const ChatScreen({required this.watchMessages, required this.roomId});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<Either<Failure, List<Message>>>(
      stream: watchMessages(roomId), // Stream 구독
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return snapshot.data!.fold(
            (failure) => ErrorWidget(failure.message),
            (messages) => MessageList(messages),
          );
        }
        return LoadingIndicator();
      },
    );
  }
}

설명

이것이 하는 일: Stream UseCase를 통해 실시간으로 변하는 데이터를 반응형으로 처리하며, 효율적이고 사용자 친화적인 경험을 제공합니다. 첫 번째로, StreamUseCase 추상 클래스를 정의합니다.

일반 UseCase와 거의 동일하지만, 반환 타입이 Future<Type> 대신 Stream<Type>입니다. Stream은 시간에 따라 여러 값을 emit할 수 있습니다.

예를 들어, 채팅방에 새 메시지가 오면 Stream이 새 메시지 목록을 emit하고, UI는 자동으로 업데이트됩니다. 사용자는 별도의 새로고침 버튼을 누를 필요가 없습니다.

그 다음으로, WatchChatMessagesUseCase 구현을 보면, repository의 watchMessages가 반환하는 Stream을 받아서 Either로 감쌉니다. map 연산자로 성공 케이스를 Right로 변환하고, handleError로 에러를 Left로 변환합니다.

이렇게 하면 Future 기반 UseCase와 동일한 에러 핸들링 패턴을 Stream에서도 사용할 수 있습니다. Stream의 강력한 점은 에러가 발생해도 Stream이 종료되지 않고 계속 데이터를 받을 수 있다는 것입니다.

Repository 구현을 보면 Firebase의 snapshots() 메서드가 Stream<QuerySnapshot>을 반환합니다. 이것을 map으로 변환하여 Stream<List<Message>>를 만듭니다.

Firebase는 데이터베이스가 변경될 때마다 자동으로 새 스냅샷을 emit하므로, 추가 코드 없이 실시간 동기화가 구현됩니다. 이것이 Stream의 아름다움입니다 - 명령형으로 "언제 업데이트해야 하나"를 생각하지 않고, 선언적으로 "데이터가 변하면 자동으로 업데이트된다"고 표현합니다.

마지막으로, UI에서 StreamBuilder를 사용하여 Stream을 구독합니다. StreamBuilder는 Stream에서 새 데이터가 올 때마다 자동으로 builder 함수를 호출하여 UI를 다시 그립니다.

snapshot.hasData로 데이터가 있는지 확인하고, fold로 성공/실패를 처리하는 패턴은 Future 기반과 동일합니다. 차이점은 이것이 "한 번"이 아니라 "매번 데이터가 변할 때마다" 실행된다는 것입니다.

여러분이 이 패턴을 사용하면 실시간 기능을 매우 쉽게 구현할 수 있습니다. 좋아요 수 실시간 업데이트, 온라인 사용자 표시, 주식 가격 추적, IoT 센서 데이터 모니터링 등 모든 실시간 기능에 적용 가능합니다.

또한 Stream 연산자를 사용하여 복잡한 데이터 처리도 가능합니다. 예를 들어, debounce로 검색어 입력을 최적화하거나, combineLatest로 여러 Stream을 조합하거나, where로 특정 조건의 데이터만 필터링할 수 있습니다.

실전 팁

💡 Stream은 반드시 취소(cancel)해야 메모리 누수를 방지할 수 있습니다. StreamBuilder는 자동으로 처리하지만, 직접 listen을 사용한다면 StreamSubscription을 저장하고 dispose에서 cancel()을 호출하세요.

💡 에러가 발생해도 Stream을 계속 유지하고 싶다면 handleError를 사용하세요. 에러를 Either의 Left로 변환하면 Stream이 종료되지 않습니다.

💡 복잡한 Stream 조작은 rxdart 패키지를 사용하세요. debounce, throttle, combineLatest, switchMap 같은 강력한 연산자를 제공합니다.

💡 hot stream vs cold stream을 이해하세요. Firebase의 snapshots()는 hot stream(구독과 관계없이 이벤트 발생)이고, 일반 Stream.fromFuture()는 cold stream(구독 시점에 시작)입니다.

💡 테스트 시에는 Stream.value()나 Stream.fromIterable()로 mock Stream을 쉽게 만들 수 있습니다. 시간 기반 테스트는 fake_async 패키지를 활용하세요.


8. UseCase 체이닝

시작하며

여러분이 "게시물 작성" 기능을 구현한다고 생각해보세요. 단순히 게시물을 저장하는 것이 아니라, 여러 단계를 거쳐야 합니다: 먼저 이미지를 업로드하고, 그 URL을 받아서 게시물 데이터에 포함시키고, 게시물을 저장하고, 알림을 전송하고, 사용자의 포인트를 증가시킵니다.

각 단계는 이전 단계의 결과에 의존합니다. 만약 이 모든 로직을 하나의 UseCase에 넣는다면 단일 책임 원칙을 위반하고, 코드가 복잡해지며, 개별 단계를 재사용할 수 없게 됩니다.

그렇다고 UI 레이어에서 여러 UseCase를 수동으로 순차 호출한다면, 비즈니스 로직이 UI에 누수되고 에러 처리가 복잡해집니다. 바로 이럴 때 필요한 것이 UseCase 체이닝(chaining)입니다.

여러 개의 작은 UseCase를 조합하여 복잡한 비즈니스 워크플로우를 만들되, 각 UseCase는 여전히 단일 책임을 유지합니다.

개요

간단히 말해서, UseCase 체이닝은 여러 UseCase를 순차적으로 실행하면서 이전 UseCase의 결과를 다음 UseCase의 입력으로 전달하는 패턴입니다. 함수형 프로그래밍의 모나드 체이닝과 유사합니다.

이 패턴은 복잡한 비즈니스 워크플로우를 작은 단위로 분해하면서도, 전체 흐름을 하나의 UseCase로 표현할 수 있게 해줍니다. 예를 들어, "회원가입" 프로세스는 "이메일 중복 확인" → "사용자 생성" → "환영 이메일 발송" → "기본 프로필 설정"의 체인으로 표현할 수 있습니다.

각 단계는 독립된 UseCase이므로 다른 곳에서도 재사용 가능합니다. 기존에는 하나의 거대한 UseCase나 UI 레이어의 복잡한 로직으로 처리했다면, 이제는 작은 UseCase들을 조합합니다.

Either의 flatMap(또는 bind)을 사용하면 에러 처리도 자동으로 전파됩니다. 한 단계에서 실패하면 나머지 단계는 자동으로 스킵됩니다.

UseCase 체이닝의 핵심 이점은 세 가지입니다: 첫째, 각 UseCase가 작고 테스트하기 쉽습니다. 둘째, 동일한 UseCase를 다른 워크플로우에서 재사용할 수 있습니다.

셋째, 복잡한 비즈니스 로직을 선언적이고 읽기 쉽게 표현할 수 있습니다. 이러한 이점들이 대규모 앱의 복잡도를 효과적으로 관리하게 해줍니다.

코드 예제

// 개별 UseCase들 (각각 단일 책임)
class UploadImageUseCase implements UseCase<Either<Failure, String>, File> {
  final StorageRepository repository;

  UploadImageUseCase(this.repository);

  @override
  Future<Either<Failure, String>> call(File imageFile) async {
    return await repository.uploadImage(imageFile);
    // String은 업로드된 이미지 URL
  }
}

class CreatePostUseCase implements UseCase<Either<Failure, Post>, PostData> {
  final PostRepository repository;

  CreatePostUseCase(this.repository);

  @override
  Future<Either<Failure, Post>> call(PostData data) async {
    return await repository.createPost(data);
  }
}

class SendNotificationUseCase implements UseCase<Either<Failure, void>, String> {
  final NotificationRepository repository;

  SendNotificationUseCase(this.repository);

  @override
  Future<Either<Failure, void>> call(String postId) async {
    return await repository.sendNewPostNotification(postId);
  }
}

// UseCase들을 조합하는 Composite UseCase
class PublishPostUseCase implements UseCase<Either<Failure, Post>, PublishPostParams> {
  final UploadImageUseCase uploadImage;
  final CreatePostUseCase createPost;
  final SendNotificationUseCase sendNotification;

  PublishPostUseCase({
    required this.uploadImage,
    required this.createPost,
    required this.sendNotification,
  });

  @override
  Future<Either<Failure, Post>> call(PublishPostParams params) async {
    // 1단계: 이미지 업로드
    final imageResult = await uploadImage(params.image);

    // flatMap으로 체이닝 (에러 시 자동으로 Left 전파)
    return imageResult.fold(
      (failure) => Left(failure), // 실패하면 여기서 종료
      (imageUrl) async {
        // 2단계: 게시물 생성 (이미지 URL 포함)
        final postData = PostData(
          title: params.title,
          content: params.content,
          imageUrl: imageUrl, // 이전 단계의 결과 사용
        );

        final postResult = await createPost(postData);

        return postResult.fold(
          (failure) => Left(failure),
          (post) async {
            // 3단계: 알림 전송 (생성된 게시물 ID 사용)
            await sendNotification(post.id);

            // 최종 결과 반환
            return Right(post);
          },
        );
      },
    );
  }
}

// 파라미터 객체
class PublishPostParams {
  final String title;
  final String content;
  final File image;

  PublishPostParams({
    required this.title,
    required this.content,
    required this.image,
  });
}

// UI에서 사용
final result = await publishPostUseCase(
  PublishPostParams(
    title: '새 게시물',
    content: '내용',
    image: selectedImage,
  ),
);

result.fold(
  (failure) {
    // 어느 단계에서 실패했든 여기서 처리
    if (failure is NetworkFailure) {
      showError('네트워크 오류');
    } else if (failure is StorageFailure) {
      showError('이미지 업로드 실패');
    }
  },
  (post) {
    // 모든 단계가 성공
    showSuccess('게시물이 발행되었습니다');
    navigateToPost(post.id);
  },
);

설명

이것이 하는 일: UseCase 체이닝 패턴을 통해 복잡한 비즈니스 프로세스를 작고 재사용 가능한 단위로 분해하면서도, 전체 워크플로우를 명확하게 표현합니다. 첫 번째로, 개별 UseCase들을 보세요.

UploadImageUseCase는 오직 이미지 업로드만, CreatePostUseCase는 오직 게시물 생성만 담당합니다. 각각은 완벽하게 독립적이며, 다른 워크플로우에서도 재사용할 수 있습니다.

예를 들어, "프로필 사진 변경" 기능에서도 UploadImageUseCase를 사용할 수 있습니다. 이것이 단일 책임 원칙과 재사용성의 완벽한 조화입니다.

그 다음으로, PublishPostUseCase는 이들을 조합하는 Composite UseCase입니다. 생성자에서 필요한 모든 UseCase를 주입받습니다(의존성 주입).

call 메서드 내부를 보면 각 단계를 순차적으로 실행하되, fold를 사용하여 에러를 처리합니다. 핵심은 이것입니다: 한 단계가 실패(Left 반환)하면 즉시 Left를 반환하고 나머지 단계는 실행되지 않습니다.

성공(Right)했을 때만 다음 단계로 진행합니다. 이것이 에러의 자동 전파입니다.

중첩된 fold를 보면 약간 복잡해 보이지만, 실제로는 매우 명확한 흐름을 표현합니다: "이미지를 업로드하고, 성공하면 그 URL로 게시물을 만들고, 성공하면 알림을 보낸다." 만약 dartz 패키지의 flatMap이나 for-comprehension을 사용하면 더 간결하게 작성할 수도 있습니다. 마지막으로, UI에서 사용하는 코드를 보면 놀라울 정도로 간단합니다.

UI는 여러 단계의 복잡한 워크플로우를 전혀 몰라도 됩니다. 단순히 publishPostUseCase를 호출하고 결과를 처리하기만 하면 됩니다.

어느 단계에서 실패했든 상관없이 하나의 fold에서 모든 에러를 처리할 수 있습니다. 물론 필요하다면 Failure 타입을 확인하여 더 구체적인 에러 메시지를 보여줄 수도 있습니다.

여러분이 이 패턴을 사용하면 복잡한 비즈니스 로직을 작은 조각으로 나누어 관리할 수 있습니다. 각 UseCase는 독립적으로 개발하고 테스트할 수 있으며, 나중에 워크플로우가 변경되어도 개별 UseCase는 수정하지 않고 조합 방식만 바꾸면 됩니다.

예를 들어, "알림 전송"을 선택적으로 만들고 싶다면 Composite UseCase의 로직만 수정하면 되고, SendNotificationUseCase 자체는 그대로 둡니다.

실전 팁

💡 Composite UseCase의 이름은 전체 워크플로우를 표현하세요. 예: PublishPostUseCase, CompleteCheckoutUseCase, RegisterUserUseCase

💡 중첩된 fold가 깊어지면 가독성이 떨어질 수 있습니다. dartzflatMap 또는 async/await와 조합하여 더 읽기 쉽게 만들 수 있습니다.

💡 각 단계가 독립적으로 실행 가능하다면 병렬 실행을 고려하세요. Future.wait로 여러 UseCase를 동시에 실행할 수 있습니다.

💡 롤백이 필요한 트랜잭션 로직이라면 Saga 패턴을 고려하세요. 중간에 실패하면 이미 실행된 단계를 되돌리는 로직을 추가합니다.

💡 테스트 시에는 각 개별 UseCase를 먼저 테스트하고, Composite UseCase는 mock을 사용하여 조합 로직만 검증하세요. 이렇게 하면 테스트가 빠르고 명확해집니다.


#Flutter#UseCase#CleanArchitecture#BusinessLogic#SOLID

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.