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개로 증가
• 복잡도만 올라감
• 얻은 이익: 없음
• 파일 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단계의 복잡한 과정
• 실패시 롤백 필요
• 다른 곳에서도 주문 기능 재사용 가능
• 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줄 이하
• 한 곳에서만 사용
• 단순한 조회/저장
• 로직이 3줄 이하
• 한 곳에서만 사용
• 단순한 조회/저장
UseCase 만드세요 ✅
• 여러 Repository 사용
• 복잡한 단계별 처리
• 2곳 이상에서 재사용
• 비즈니스 규칙 포함
• 복잡한 단계별 처리
• 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 역할
• 최소한의 UseCase만 사용
• 대부분 Repository 직접 호출
• 정말 복잡한 로직만 분리
중규모 팀 (4-8명):
• 재사용성 높은 로직만 UseCase로
• 명확한 기준 수립
• 복잡한 비즈니스 로직 위주
대규모 팀 (9명 이상):
• 체계적인 UseCase 적용
• 도메인별 UseCase 체계 구축
• 팀 간 API 역할
프로젝트 단계별 접근법
MVP/프로토타입 단계:
• UseCase 없이 빠른 개발
• Repository 직접 사용
• 검증 후 리팩토링 시점에 도입
성장 단계:
• 복잡해지는 로직부터 UseCase 적용
• 점진적 도입
• 성능과 유지보수성 균형
성숙 단계:
• 체계적인 UseCase 아키텍처
• 도메인 주도 설계 적용
• 팀 표준으로 패턴 정착
• 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. 비즈니스 규칙이 포함될 때
• 할인 적용 규칙
• 권한 확인 로직
• 복잡한 계산 과정
• 검색 기능이 여러 화면에서 사용
• 사용자 프로필 업데이트 로직 중복
• 주문 처리 과정이 여러 곳에서 필요
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는 도구일 뿐입니다. 필요할 때만 선택적으로 사용하는 것이 현명한 접근법입니다.
기억하세요: 좋은 아키텍처는 복잡함을 숨기는 것이 아니라, 복잡함을 적절히 관리하는 것입니다.
핵심 원칙:
• 단순한 것은 단순하게 유지
• 복잡한 것만 분리
• 재사용성을 고려
• 팀 상황에 맞게 적용
실무 조언:
• 처음에는 UseCase 없이 시작
• 복잡해지면 그때 분리
• "정말 필요한가?" 항상 자문
• 과도한 추상화 금지
UseCase는 도구일 뿐입니다. 필요할 때만 선택적으로 사용하는 것이 현명한 접근법입니다.
기억하세요: 좋은 아키텍처는 복잡함을 숨기는 것이 아니라, 복잡함을 적절히 관리하는 것입니다.