개발

UseCase 패턴: 언제 써야 하고 언제 쓰지 말아야 하는가

복잡도를 높이지 않고 현실적으로 활용하는 방법

2025년 9월 19일
15분 읽기
UseCase 패턴: 언제 써야 하고 언제 쓰지 말아야 하는가

UseCase 남용의 현실

Clean Architecture를 배우고 나면 "모든 걸 UseCase로 만들어야 한다"고 생각하게 됩니다. 하지만 이는 큰 오해입니다.

Before: 단순했던 시절

Dart
class LoginScreen extends StatelessWidget {
  void login() async {
    final user = await userRepository.login(email, password);
    Navigator.push(context, HomeScreen());
  }
}
• 파일 1개
• 로직이 한눈에 보임
• 이해하기 쉬움

After: UseCase 도입 후

Dart
// login_use_case.dart
class LoginUseCase {
  Future<User> execute(String email, String password) async {
    return await userRepository.login(email, password);
  }
}

// providers.dart  
final loginUseCaseProvider = Provider((ref) => LoginUseCase());

// login_screen.dart
class LoginScreen extends StatelessWidget {
  void login() async {
    final user = await ref.read(loginUseCaseProvider).execute(email, password);
    Navigator.push(context, HomeScreen());
  }
}
결과:
• 파일 3개로 증가
• 복잡도만 올라감
• 얻은 이익: 없음

이런 UseCase는 만들지 마세요

1. 단순히 Repository 호출만 하는 경우

Dart
// ❌ 의미 없는 UseCase
class GetUserUseCase {
  Future<User> execute(String id) {
    return userRepository.getUser(id);
  }
}

// ✅ 그냥 이렇게 하세요
final user = await userRepository.getUser(id);

2. 간단한 데이터 저장/삭제

Dart
// ❌ 불필요한 UseCase
class SaveUserUseCase {
  Future<void> execute(User user) {
    return userRepository.save(user);
  }
}

// ✅ Repository 직접 호출
await userRepository.save(user);

3. 한 곳에서만 쓰는 로직

Dart
// ❌ 재사용도 안 되는 UseCase
class LoginScreenDataUseCase {
  Future<LoginData> execute() async {
    // 로그인 화면에서만 쓰는 특수한 로직
  }
}

// ✅ 화면에서 직접 처리
class LoginScreen extends StatelessWidget {
  Future<LoginData> loadData() async {
    // 여기서 직접 처리
  }
}

이런 경우에만 UseCase를 만드세요

1. 여러 단계가 필요한 복잡한 로직

Dart
// ✅ 주문 처리 - 복잡한 단계들
class PlaceOrderUseCase {
  Future<bool> execute(List<Product> items) async {
    // 1. 재고 확인
    if (!await checkStock(items)) return false;
    
    // 2. 결제 처리
    if (!await processPayment(items)) return false;
    
    // 3. 주문 생성
    final order = await createOrder(items);
    
    // 4. 재고 차감
    await reduceStock(items);
    
    // 5. 이메일 발송
    await sendEmail(order);
    
    return true;
  }
}
왜 UseCase가 필요한가?
• 5단계의 복잡한 과정
• 실패시 롤백 필요
• 다른 곳에서도 주문 기능 재사용 가능

2. 여러 데이터 소스를 조합하는 경우

Dart
// ✅ 대시보드 데이터 - 여러 소스 조합
class GetDashboardUseCase {
  Future<Dashboard> execute() async {
    final user = await userRepository.getUser();
    final orders = await orderRepository.getRecent();
    final notifications = await notificationRepository.getUnread();
    
    return Dashboard(
      user: user,
      orderCount: orders.length,
      hasUnreadNotifications: notifications.isNotEmpty,
    );
  }
}

3. 재사용성이 높은 검색 로직

Dart
// ✅ 검색 기능 - 여러 곳에서 재사용
class SearchUseCase {
  Future<List<Product>> execute(String query) async {
    // 1. 검색어 전처리
    final cleanQuery = query.trim().toLowerCase();
    
    // 2. 검색 실행
    final products = await productRepository.search(cleanQuery);
    
    // 3. 검색 기록 저장
    await searchRepository.saveHistory(cleanQuery);
    
    return products;
  }
}
재사용 예:
• 메인 검색 화면
• 카테고리 검색
• 자동완성 위젯

판단 기준

UseCase 만들지 마세요 ❌

• Repository 메소드 1개만 호출
• 로직이 3줄 이하
• 한 곳에서만 사용
• 단순한 조회/저장

UseCase 만드세요 ✅

• 여러 Repository 사용
• 복잡한 단계별 처리
• 2곳 이상에서 재사용
• 비즈니스 규칙 포함

현실적인 가이드

소규모 프로젝트

Dart
// 대부분 Repository 직접 사용
final user = await userRepository.getUser(id);
await userRepository.save(user);

// 정말 복잡한 것만 UseCase
final success = await placeOrderUseCase.execute(items);

대규모 프로젝트

Dart
// 재사용성 높은 로직은 UseCase로
final searchResults = await searchUseCase.execute(query);
final dashboard = await dashboardUseCase.execute();

// 단순한 건 여전히 Repository 직접 사용
final user = await userRepository.getUser(id);

팀 규모별 접근법

소규모 팀 (1-3명):
• 최소한의 UseCase만 사용
• 대부분 Repository 직접 호출
• 정말 복잡한 로직만 분리

중규모 팀 (4-8명):
• 재사용성 높은 로직만 UseCase로
• 명확한 기준 수립
• 복잡한 비즈니스 로직 위주

대규모 팀 (9명 이상):
• 체계적인 UseCase 적용
• 도메인별 UseCase 체계 구축
• 팀 간 API 역할

프로젝트 단계별 접근법

MVP/프로토타입 단계:
• UseCase 없이 빠른 개발
• Repository 직접 사용
• 검증 후 리팩토링 시점에 도입

성장 단계:
• 복잡해지는 로직부터 UseCase 적용
• 점진적 도입
• 성능과 유지보수성 균형

성숙 단계:
• 체계적인 UseCase 아키텍처
• 도메인 주도 설계 적용
• 팀 표준으로 패턴 정착

실무에서 자주 하는 실수들

실수 1: 모든 걸 UseCase로 만들기

Dart
// ❌ 과도한 UseCase 남용
class GetUserNameUseCase {
  String execute(User user) => user.name;
}

class GetUserEmailUseCase {
  String execute(User user) => user.email;
}

class IsUserAdminUseCase {
  bool execute(User user) => user.role == 'admin';
}

// ✅ 단순한 것은 직접 접근
final name = user.name;
final email = user.email;
final isAdmin = user.role == 'admin';

실수 2: UseCase 체이닝

Dart
// ❌ UseCase가 다른 UseCase를 호출
class ComplexUseCase {
  Future<Result> execute() async {
    final user = await getUserUseCase.execute();
    final orders = await getOrdersUseCase.execute(user.id);
    return processOrdersUseCase.execute(orders);
  }
}

// ✅ 하나의 UseCase에서 모든 로직 처리
class GetUserOrderSummaryUseCase {
  Future<OrderSummary> execute(String userId) async {
    final user = await userRepository.getUser(userId);
    final orders = await orderRepository.getByUser(userId);
    
    return OrderSummary(
      user: user,
      totalOrders: orders.length,
      totalAmount: orders.fold(0, (sum, order) => sum + order.amount),
    );
  }
}

실수 3: 과도한 추상화

Dart
// ❌ 불필요한 추상화
abstract class UseCase<T, P> {
  Future<Result<T>> call(P params);
}

class NoParams {}

class GetProductsUseCase implements UseCase<List<Product>, NoParams> {
  @override
  Future<Result<List<Product>>> call(NoParams params) async {
    try {
      final products = await productRepository.getProducts();
      return Result.success(products);
    } catch (e) {
      return Result.failure(e.toString());
    }
  }
}

// ✅ 단순하게 유지
class GetProductsUseCase {
  Future<List<Product>> execute() async {
    return await productRepository.getProducts();
  }
}

언제 Repository에서 UseCase로 리팩토링할까?

처음에는 Repository를 직접 사용하다가, 다음 신호가 보이면 UseCase로 리팩토링을 고려하세요:

리팩토링 신호들

1. 같은 로직이 3곳 이상에서 반복될 때
• 검색 기능이 여러 화면에서 사용
• 사용자 프로필 업데이트 로직 중복
• 주문 처리 과정이 여러 곳에서 필요

2. 로직이 10줄 이상으로 복잡해질 때
• 여러 단계의 검증이 필요
• 다양한 예외 처리가 포함
• 여러 Repository 조합이 필요

3. 비즈니스 규칙이 포함될 때
• 할인 적용 규칙
• 권한 확인 로직
• 복잡한 계산 과정

리팩토링 예시

Dart
// Before: Repository 직접 사용
class ProductScreen extends ConsumerWidget {
  void addToCart() async {
    final product = await productRepository.getProduct(productId);
    final cart = await cartRepository.getCart();
    
    // 재고 확인
    if (product.stock < 1) {
      showError('재고가 없습니다');
      return;
    }
    
    // 중복 확인
    if (cart.items.any((item) => item.productId == productId)) {
      showError('이미 장바구니에 있습니다');
      return;
    }
    
    // 장바구니에 추가
    await cartRepository.addItem(CartItem(productId: productId, quantity: 1));
    showSuccess('장바구니에 추가되었습니다');
  }
}

// After: UseCase로 분리
class AddToCartUseCase {
  Future<bool> execute(String productId) async {
    final product = await productRepository.getProduct(productId);
    final cart = await cartRepository.getCart();
    
    // 재고 확인
    if (product.stock < 1) return false;
    
    // 중복 확인
    if (cart.items.any((item) => item.productId == productId)) return false;
    
    // 장바구니에 추가
    await cartRepository.addItem(CartItem(productId: productId, quantity: 1));
    return true;
  }
}

class ProductScreen extends ConsumerWidget {
  void addToCart() async {
    final success = await addToCartUseCase.execute(productId);
    
    if (success) {
      showSuccess('장바구니에 추가되었습니다');
    } else {
      showError('장바구니 추가에 실패했습니다');
    }
  }
}

결론

UseCase는 만능이 아닙니다. 오히려 남용하면 복잡도만 증가합니다.

핵심 원칙:

단순한 것은 단순하게 유지
복잡한 것만 분리
재사용성을 고려
팀 상황에 맞게 적용

실무 조언:

• 처음에는 UseCase 없이 시작
• 복잡해지면 그때 분리
• "정말 필요한가?" 항상 자문
• 과도한 추상화 금지

UseCase는 도구일 뿐입니다. 필요할 때만 선택적으로 사용하는 것이 현명한 접근법입니다.

기억하세요: 좋은 아키텍처는 복잡함을 숨기는 것이 아니라, 복잡함을 적절히 관리하는 것입니다.
#Flutter
#UseCase
#Clean Architecture
#Pattern
#Best Practice