Clean Architecture란 무엇이고 왜 필요한가?
소프트웨어를 개발하다 보면 이런 경험이 있을 것입니다. 처음엔 간단했던 앱이 기능을 추가할수록 복잡해지고, 한 부분을 고치면 전혀 관계없어 보이는 다른 부분이 깨집니다. 특히 "Firebase에서 Supabase로 바꿔야 해요"라는 요구사항이 들어오면 앱 전체를 뜯어고쳐야 하는 상황에 직면합니다.
Clean Architecture는 이런 문제를 해결하기 위해 Robert C. Martin이 제안한 소프트웨어 설계 원칙입니다. 핵심 아이디어는 "관심사의 분리"입니다. 즉, 비즈니스 로직, 데이터 처리, UI를 각각 독립적인 레이어로 분리하는 것입니다.
Clean Architecture는 이런 문제를 해결하기 위해 Robert C. Martin이 제안한 소프트웨어 설계 원칙입니다. 핵심 아이디어는 "관심사의 분리"입니다. 즉, 비즈니스 로직, 데이터 처리, UI를 각각 독립적인 레이어로 분리하는 것입니다.
양파 구조로 이해하기
Clean Architecture를 양파에 비유하면 이해가 쉽습니다. 양파의 중심부터 바깥쪽으로 레이어가 있듯이, Clean Architecture도 안쪽부터 바깥쪽으로 레이어가 구성됩니다.
text
[UI/Widgets] <- 가장 바깥층 (Presentation)
/ \
[State] [Navigation] <- 상태 관리 (Presentation)
\ /
[Use Cases] <- 비즈니스 규칙 (Domain)
|
[Entities] <- 핵심 모델 (Domain)
|
[Repository Interface] <- 데이터 추상화 (Domain)
|
[Repository Impl] <- 데이터 구현 (Data)
/ \
[API Client] [Local DB] <- 실제 데이터 소스 (Data)
중요한 규칙이 하나 있습니다: 의존성은 항상 안쪽을 향해야 합니다. 바깥쪽 레이어는 안쪽을 알 수 있지만, 안쪽 레이어는 바깥쪽을 전혀 모릅니다. 마치 양파의 중심은 겉껍질이 있는지도 모르는 것처럼요.
Domain Layer - 비즈니스의 핵심
Domain Layer는 앱의 "두뇌"입니다. 여기에는 비즈니스 규칙, 정책, 그리고 핵심 로직이 들어갑니다.
Domain Layer가 특별한 이유
Domain Layer의 가장 큰 특징은 완전히 독립적이라는 것입니다. Flutter, Firebase, HTTP, JSON 같은 기술적인 세부사항을 전혀 모릅니다. 순수한 Dart 코드로만 이루어져 있죠.
이게 왜 중요할까요? 예를 들어보겠습니다. 여러분이 배달 앱을 만든다고 가정해봅시다. "주문 금액이 15,000원 이상이면 배달비 무료"라는 규칙이 있다면, 이 규칙은 Flutter를 쓰든, React Native를 쓰든, 심지어 웹앱이든 변하지 않습니다. 이런 불변의 비즈니스 규칙이 Domain Layer에 들어갑니다.
이게 왜 중요할까요? 예를 들어보겠습니다. 여러분이 배달 앱을 만든다고 가정해봅시다. "주문 금액이 15,000원 이상이면 배달비 무료"라는 규칙이 있다면, 이 규칙은 Flutter를 쓰든, React Native를 쓰든, 심지어 웹앱이든 변하지 않습니다. 이런 불변의 비즈니스 규칙이 Domain Layer에 들어갑니다.
1. Entities - 비즈니스 객체의 본질
Entity는 단순한 데이터 모델이 아닙니다. 비즈니스의 핵심 개념을 코드로 표현한 것입니다.
예를 들어, 쇼핑몰의 "장바구니"를 생각해봅시다. 장바구니는 단순히 상품 목록이 아닙니다. 여러 비즈니스 규칙을 가지고 있죠:
• 최대 수량 제한이 있을 수 있습니다
• 특정 상품은 함께 담을 수 없을 수 있습니다 (예: 신선식품과 일반상품 분리)
• 쿠폰 적용 규칙이 있습니다
• 최소 주문 금액이 있을 수 있습니다
예를 들어, 쇼핑몰의 "장바구니"를 생각해봅시다. 장바구니는 단순히 상품 목록이 아닙니다. 여러 비즈니스 규칙을 가지고 있죠:
• 최대 수량 제한이 있을 수 있습니다
• 특정 상품은 함께 담을 수 없을 수 있습니다 (예: 신선식품과 일반상품 분리)
• 쿠폰 적용 규칙이 있습니다
• 최소 주문 금액이 있을 수 있습니다
Dart
// domain/entities/shopping_cart.dart
@freezed
class ShoppingCart with _$ShoppingCart {
const ShoppingCart._(); // private constructor를 추가해야 custom method 사용 가능
const factory ShoppingCart({
required String id,
required String userId,
required List<CartItem> items,
AppliedCoupon? appliedCoupon,
required DateTime createdAt,
required DateTime updatedAt,
}) = _ShoppingCart;
// ========== 비즈니스 규칙 시작 ==========
// 규칙 1: 장바구니 총액 계산
// 단순히 가격을 더하는 것이 아니라, 각 상품의 할인, 수량 등을 고려
double get subtotal {
return items.fold(0.0, (sum, item) {
// 각 상품의 실제 가격 계산 (할인가가 있으면 할인가, 없으면 정가)
final price = item.discountPrice ?? item.originalPrice;
return sum + (price * item.quantity);
});
}
// 규칙 2: 쿠폰 적용 후 최종 금액
double get totalAmount {
if (appliedCoupon == null) return subtotal;
// 쿠폰 종류에 따른 할인 계산
switch (appliedCoupon!.type) {
case CouponType.percentage:
// 퍼센트 할인 (최대 할인 금액 제한 체크)
final discount = subtotal * (appliedCoupon!.value / 100);
final maxDiscount = appliedCoupon!.maxDiscountAmount ?? double.infinity;
return subtotal - min(discount, maxDiscount);
case CouponType.fixed:
// 정액 할인
return max(0, subtotal - appliedCoupon!.value);
case CouponType.freeShipping:
// 무료 배송 쿠폰은 여기서 계산하지 않음
return subtotal;
}
}
Dart
// 규칙 3: 무료 배송 여부
// 회사 정책: 3만원 이상 구매 시 무료 배송
bool get isFreeShipping {
const freeShippingThreshold = 30000.0;
return totalAmount >= freeShippingThreshold ||
(appliedCoupon?.type == CouponType.freeShipping);
}
// 규칙 4: 장바구니에 상품 추가 가능 여부
// 비즈니스 규칙: 장바구니에는 최대 50개 품목만 담을 수 있음
bool canAddItem(CartItem newItem) {
const maxItemCount = 50;
// 이미 50개가 담겨 있으면 추가 불가
if (items.length >= maxItemCount) {
return false;
}
// 이미 담긴 상품이면 수량만 증가 (별도 체크 필요)
final existingItem = items.firstWhereOrNull(
(item) => item.productId == newItem.productId
);
if (existingItem != null) {
// 같은 상품의 수량 합이 99를 넘으면 안 됨
return (existingItem.quantity + newItem.quantity) <= 99;
}
return true;
}
Dart
// 규칙 5: 체크아웃 가능 여부
// 여러 조건을 확인해야 함
CheckoutEligibility get checkoutEligibility {
// 장바구니가 비어있으면 체크아웃 불가
if (items.isEmpty) {
return CheckoutEligibility.failed(reason: '장바구니가 비어있습니다');
}
// 최소 주문 금액 확인 (1만원)
const minimumOrderAmount = 10000.0;
if (totalAmount < minimumOrderAmount) {
return CheckoutEligibility.failed(
reason: '최소 주문 금액은 ${minimumOrderAmount.toStringAsFixed(0)}원입니다'
);
}
// 품절 상품 확인
final outOfStockItems = items.where((item) => !item.isAvailable).toList();
if (outOfStockItems.isNotEmpty) {
return CheckoutEligibility.failed(
reason: '품절된 상품이 있습니다',
problematicItems: outOfStockItems,
);
}
// 모든 조건 통과
return CheckoutEligibility.eligible();
}
// 규칙 6: 장바구니 유효 기간
// 30일이 지난 장바구니는 자동 삭제 대상
bool get isExpired {
const cartLifetime = Duration(days: 30);
return DateTime.now().difference(updatedAt) > cartLifetime;
}
}
Entity가 단순한 데이터 홀더가 아니라 비즈니스 규칙의 수호자임을 알 수 있습니다. 이 규칙들은 UI가 Flutter든 React든, 데이터베이스가 Firebase든 PostgreSQL이든 관계없이 항상 동일하게 적용되어야 하는 핵심 비즈니스 로직입니다.
2. Repository Interfaces - 데이터 접근의 계약
Repository Interface는 "데이터를 가져오는 방법"을 추상화합니다. 여기서 중요한 것은 "어떻게"가 아니라 "무엇을"에 집중한다는 점입니다.
비유하자면, Repository Interface는 "음식 주문 메뉴판" 같은 것입니다. 메뉴판은 "짜장면", "짬뽕"이 있다고 알려주지만, 그것을 어떻게 만드는지는 알려주지 않죠. 주방(Data Layer)에서 직접 만들든, 다른 곳에서 배달시키든, 메뉴판(Interface)은 신경쓰지 않습니다.
비유하자면, Repository Interface는 "음식 주문 메뉴판" 같은 것입니다. 메뉴판은 "짜장면", "짬뽕"이 있다고 알려주지만, 그것을 어떻게 만드는지는 알려주지 않죠. 주방(Data Layer)에서 직접 만들든, 다른 곳에서 배달시키든, 메뉴판(Interface)은 신경쓰지 않습니다.
Dart
// domain/repositories/cart_repository.dart
/// 장바구니 데이터 접근을 추상화한 인터페이스
///
/// 이 인터페이스는 장바구니 데이터를 "어디서" 가져오는지,
/// "어떻게" 저장하는지는 전혀 모릅니다.
/// 오직 "무엇"을 할 수 있는지만 정의합니다.
abstract class CartRepository {
/// 현재 사용자의 장바구니를 가져옵니다
///
/// 이 메서드가 로컬 캐시를 확인하는지, 서버 API를 호출하는지,
/// 또는 둘 다 하는지는 구현체가 결정합니다.
Future<ShoppingCart?> getCurrentCart();
/// 장바구니를 저장합니다
///
/// 로컬에만 저장할지, 서버에 동기화할지는 구현체의 몫입니다.
/// Domain Layer는 그저 "저장해줘"라고만 요청합니다.
Future<void> saveCart(ShoppingCart cart);
/// 장바구니에 상품을 추가합니다
///
/// 내부적으로 재고 확인, 가격 업데이트 등을 할 수 있지만
/// 이 인터페이스는 그런 세부사항을 노출하지 않습니다.
Future<ShoppingCart> addItemToCart(String productId, int quantity);
/// 장바구니를 비웁니다
Future<void> clearCart();
/// 실시간으로 장바구니 변경사항을 구독합니다
///
/// WebSocket을 쓸지, Firebase Realtime을 쓸지,
/// 폴링을 할지는 구현체가 결정합니다.
Stream<ShoppingCart> watchCart();
/// 장바구니 동기화 상태를 확인합니다
///
/// 오프라인 상태에서 로컬 변경사항이 있는지 확인
Future<bool> hasPendingChanges();
/// 서버와 장바구니를 동기화합니다
Future<void> syncCart();
}
Repository Interface의 핵심 원칙:
• 기술 중립적: HTTP, GraphQL, gRPC 같은 기술 용어가 없음
• 비즈니스 언어 사용: 개발자가 아닌 사람도 이해할 수 있는 메서드명
• 구현 세부사항 숨김: 캐싱, 에러 재시도, 로깅 등은 언급하지 않음
• 기술 중립적: HTTP, GraphQL, gRPC 같은 기술 용어가 없음
• 비즈니스 언어 사용: 개발자가 아닌 사람도 이해할 수 있는 메서드명
• 구현 세부사항 숨김: 캐싱, 에러 재시도, 로깅 등은 언급하지 않음
3. Use Cases - 비즈니스 시나리오의 지휘자
Use Case는 하나의 비즈니스 시나리오를 완성하는 지휘자입니다. 여러 Repository와 Entity를 조합해서 의미 있는 비즈니스 액션을 수행합니다.
Use Case를 이해하는 가장 좋은 방법은 "사용자 스토리"로 생각하는 것입니다. "사용자로서 나는 상품을 장바구니에 담고 싶다"가 하나의 Use Case가 됩니다.
Use Case를 이해하는 가장 좋은 방법은 "사용자 스토리"로 생각하는 것입니다. "사용자로서 나는 상품을 장바구니에 담고 싶다"가 하나의 Use Case가 됩니다.
Dart
// domain/usecases/add_to_cart_usecase.dart
/// 장바구니에 상품을 추가하는 유스케이스
///
/// 이것은 단순히 CartRepository.addItem()을 호출하는 것이 아닙니다.
/// 여러 비즈니스 규칙을 확인하고, 여러 시스템과 협업하여
/// "장바구니에 상품 추가"라는 비즈니스 시나리오를 완성합니다.
class AddToCartUseCase {
final CartRepository _cartRepository;
final ProductRepository _productRepository;
final InventoryRepository _inventoryRepository;
final UserRepository _userRepository;
final AnalyticsRepository _analyticsRepository;
AddToCartUseCase({
required CartRepository cartRepository,
required ProductRepository productRepository,
required InventoryRepository inventoryRepository,
required UserRepository userRepository,
required AnalyticsRepository analyticsRepository,
}) : _cartRepository = cartRepository,
_productRepository = productRepository,
_inventoryRepository = inventoryRepository,
_userRepository = userRepository,
_analyticsRepository = analyticsRepository;
Dart
/// 장바구니에 상품을 추가합니다
///
/// 이 메서드는 다음과 같은 복잡한 비즈니스 프로세스를 수행합니다:
/// 1. 사용자 검증
/// 2. 상품 정보 확인
/// 3. 재고 확인
/// 4. 구매 제한 확인
/// 5. 장바구니 추가
/// 6. 분석 데이터 전송
Future<Either<AddToCartFailure, ShoppingCart>> execute({
required String productId,
required int quantity,
}) async {
try {
// ===== Step 1: 사용자 검증 =====
final user = await _userRepository.getCurrentUser();
// ===== Step 2: 상품 정보 조회 =====
final product = await _productRepository.getProduct(productId);
if (product == null) {
return Left(AddToCartFailure.productNotFound(productId));
}
if (!product.isActive) {
return Left(AddToCartFailure.productNotAvailable(product.name));
}
// 성인 상품인데 로그인하지 않았거나 미성년자인 경우
if (product.isAdultOnly) {
if (user == null) {
return Left(AddToCartFailure.loginRequired());
}
if (!user.isAdult) {
return Left(AddToCartFailure.ageRestriction());
}
}
// ===== Step 3: 재고 확인 =====
final availableStock = await _inventoryRepository.getAvailableStock(productId);
if (availableStock < quantity) {
return Left(AddToCartFailure.insufficientStock(
requested: quantity,
available: availableStock,
));
}
// ===== Step 4: 구매 제한 확인 =====
// 일부 상품은 1인당 구매 수량 제한이 있을 수 있습니다
if (product.maxQuantityPerUser != null) {
final currentCart = await _cartRepository.getCurrentCart();
final existingQuantity = currentCart?.items
.firstWhereOrNull((item) => item.productId == productId)
?.quantity ?? 0;
final totalQuantity = existingQuantity + quantity;
if (totalQuantity > product.maxQuantityPerUser!) {
return Left(AddToCartFailure.exceedsMaxQuantity(
max: product.maxQuantityPerUser!,
));
}
}
// 성공적으로 완료
return Right(updatedCart);
} catch (e) {
return Left(AddToCartFailure.unknown(e.toString()));
}
}
}
Use Case가 하는 일:
• 조율(Orchestration): 여러 Repository를 호출하여 작업 조율
• 검증(Validation): 비즈니스 규칙 검증
• 트랜잭션 관리: 여러 작업을 하나의 단위로 처리
• 에러 처리: 다양한 실패 케이스를 구체적으로 정의
• 조율(Orchestration): 여러 Repository를 호출하여 작업 조율
• 검증(Validation): 비즈니스 규칙 검증
• 트랜잭션 관리: 여러 작업을 하나의 단위로 처리
• 에러 처리: 다양한 실패 케이스를 구체적으로 정의
Data Layer - 현실 세계와의 연결
Data Layer는 이상적인 Domain의 세계와 복잡한 현실 세계를 연결하는 다리입니다. Domain Layer가 "무엇을 원하는지" 정의했다면, Data Layer는 "어떻게 그것을 얻을지" 구현합니다.
Data Layer의 책임
Data Layer는 다음과 같은 복잡한 현실을 처리합니다:
• 네트워크 연결이 불안정할 수 있음
• API 응답이 예상과 다를 수 있음
• 로컬 캐시와 서버 데이터가 다를 수 있음
• 인증 토큰이 만료될 수 있음
• 서버가 점검 중일 수 있음
이 모든 복잡함을 Domain Layer로부터 숨기는 것이 Data Layer의 역할입니다.
• 네트워크 연결이 불안정할 수 있음
• API 응답이 예상과 다를 수 있음
• 로컬 캐시와 서버 데이터가 다를 수 있음
• 인증 토큰이 만료될 수 있음
• 서버가 점검 중일 수 있음
이 모든 복잡함을 Domain Layer로부터 숨기는 것이 Data Layer의 역할입니다.
Repository Implementation - 실제 구현
Repository Implementation은 Domain의 Interface를 실제로 구현하는 곳입니다. 여기서 모든 "지저분한" 작업이 일어납니다.
Dart
// data/repositories/cart_repository_impl.dart
class CartRepositoryImpl implements CartRepository {
final RemoteDataSource _remoteDataSource;
final LocalDataSource _localDataSource;
final NetworkInfo _networkInfo;
final AuthTokenManager _tokenManager;
final CacheManager _cacheManager;
final ErrorReporter _errorReporter;
@override
Future<ShoppingCart?> getCurrentCart() async {
try {
// 1. 캐시 확인 (빠른 응답을 위해)
final cachedCart = await _cacheManager.get<ShoppingCartDto>('current_cart');
if (cachedCart != null && !cachedCart.isExpired) {
// 캐시가 있고 유효하면 바로 반환 (UX 개선)
// 하지만 백그라운드에서 업데이트 확인
_refreshCartInBackground();
return cachedCart.toEntity();
}
// 2. 네트워크 상태 확인
if (!await _networkInfo.isConnected) {
// 오프라인 상태
final localCart = await _localDataSource.getCart();
if (localCart != null) {
return localCart.toEntity().copyWith(
metadata: CartMetadata(isOffline: true),
);
}
return null;
}
// 3. 토큰 확인 및 갱신
var token = await _tokenManager.getAccessToken();
if (token == null || _tokenManager.isExpired(token)) {
token = await _tokenManager.refreshToken();
if (token == null) {
throw AuthenticationException('로그인이 필요합니다');
}
}
// 4. 서버에서 장바구니 조회 (재시도 로직 포함)
CartDto? cartDto = await _remoteDataSource.getCart(token);
// 5. 로컬 저장 및 캐싱
if (cartDto != null) {
await _localDataSource.saveCart(cartDto);
await _cacheManager.set('current_cart', cartDto);
return cartDto.toEntity();
}
return null;
} catch (e, stackTrace) {
// 에러 리포팅
await _errorReporter.report(
error: e,
stackTrace: stackTrace,
context: 'CartRepository.getCurrentCart',
);
// 에러 발생 시 로컬 데이터라도 반환
final localCart = await _localDataSource.getCart();
if (localCart != null) {
return localCart.toEntity().copyWith(
metadata: CartMetadata(hasError: true),
);
}
rethrow;
}
}
}
Repository Implementation이 처리하는 현실적인 문제들:
• 캐싱 전략: 성능과 최신성 사이의 균형
• 오프라인 지원: 네트워크 없이도 기본 기능 제공
• 에러 복구: 일시적 실패에 대한 재시도
• 낙관적 업데이트: 사용자 경험 개선
• 백그라운드 동기화: UI 블로킹 없이 데이터 최신화
• 캐싱 전략: 성능과 최신성 사이의 균형
• 오프라인 지원: 네트워크 없이도 기본 기능 제공
• 에러 복구: 일시적 실패에 대한 재시도
• 낙관적 업데이트: 사용자 경험 개선
• 백그라운드 동기화: UI 블로킹 없이 데이터 최신화
Presentation Layer - 사용자와의 대화
Presentation Layer는 사용자와 직접 소통하는 최전선입니다. 복잡한 비즈니스 로직과 데이터 처리를 사용자가 이해할 수 있는 형태로 표현합니다.
Presentation Layer의 역할
이 레이어는 다음을 담당합니다:
• 사용자 입력 처리
• 데이터를 시각적으로 표현
• 애니메이션과 트랜지션
• 사용자 피드백 (로딩, 에러, 성공 메시지)
• 네비게이션과 라우팅
중요한 것은 Presentation Layer는 "어떻게 보여줄지"만 신경쓰고, "무엇을 보여줄지"는 Domain Layer가 결정한다는 점입니다.
• 사용자 입력 처리
• 데이터를 시각적으로 표현
• 애니메이션과 트랜지션
• 사용자 피드백 (로딩, 에러, 성공 메시지)
• 네비게이션과 라우팅
중요한 것은 Presentation Layer는 "어떻게 보여줄지"만 신경쓰고, "무엇을 보여줄지"는 Domain Layer가 결정한다는 점입니다.
State Management - 상태의 지휘자
Dart
// presentation/providers/cart_state.dart
/// 장바구니 화면의 상태를 관리하는 StateNotifier
///
/// 이 클래스는 UI와 Domain Layer 사이의 중개자 역할을 합니다.
/// 사용자의 액션을 Use Case로 전달하고,
/// 그 결과를 UI가 이해할 수 있는 상태로 변환합니다.
@freezed
class CartState with _$CartState {
const factory CartState({
ShoppingCart? cart,
@Default(false) bool isLoading,
@Default(false) bool isSubmitting, // 특정 액션 진행 중
String? errorMessage,
String? successMessage,
@Default([]) List<String> selectedItemIds, // UI 전용 상태
@Default(false) bool isEditMode, // UI 전용 상태
PromoCode? appliedPromo,
@Default(0.0) double estimatedShipping,
}) = _CartState;
}
class CartNotifier extends StateNotifier<CartState> {
final GetCartUseCase _getCartUseCase;
final AddToCartUseCase _addToCartUseCase;
final RemoveFromCartUseCase _removeFromCartUseCase;
CartNotifier({
required GetCartUseCase getCartUseCase,
required AddToCartUseCase addToCartUseCase,
required RemoveFromCartUseCase removeFromCartUseCase,
}) : _getCartUseCase = getCartUseCase,
_addToCartUseCase = addToCartUseCase,
_removeFromCartUseCase = removeFromCartUseCase,
super(const CartState()) {
// 초기화 시 장바구니 로드
loadCart();
}
Dart
/// 장바구니를 로드합니다
Future<void> loadCart() async {
// 로딩 시작
state = state.copyWith(
isLoading: true,
errorMessage: null,
);
try {
// Use Case 실행
final result = await _getCartUseCase.execute();
result.fold(
// 실패 처리
(failure) {
state = state.copyWith(
isLoading: false,
errorMessage: _mapFailureToMessage(failure),
);
},
// 성공 처리
(cart) {
state = state.copyWith(
cart: cart,
isLoading: false,
errorMessage: null,
);
},
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: '장바구니를 불러오는 중 오류가 발생했습니다',
);
}
}
/// 상품을 장바구니에 추가합니다 (낙관적 업데이트)
Future<void> addToCart({
required String productId,
required int quantity,
}) async {
// 낙관적 업데이트를 위한 임시 아이템 생성
final optimisticItem = CartItem(
productId: productId,
productName: 'Loading...',
originalPrice: 0,
quantity: quantity,
isAvailable: true,
);
// UI에 즉시 반영
if (state.cart != null) {
state = state.copyWith(
cart: state.cart!.addItem(optimisticItem),
isSubmitting: true,
);
}
try {
final result = await _addToCartUseCase.execute(
productId: productId,
quantity: quantity,
);
result.fold(
// 실패: 낙관적 업데이트 롤백
(failure) {
loadCart(); // 서버 상태로 다시 동기화
state = state.copyWith(
isSubmitting: false,
errorMessage: _mapAddToCartFailureToMessage(failure),
);
},
// 성공
(updatedCart) {
state = state.copyWith(
cart: updatedCart,
isSubmitting: false,
successMessage: '장바구니에 추가되었습니다',
);
},
);
} catch (e) {
loadCart(); // 롤백
state = state.copyWith(
isSubmitting: false,
errorMessage: '상품 추가 중 오류가 발생했습니다',
);
}
}
}
State Management의 핵심 역할:
• Use Case와 UI 연결: 비즈니스 로직 실행과 결과 처리
• UI 상태 관리: 로딩, 에러, 선택 상태 등
• 사용자 피드백: 성공/실패 메시지 표시
• 낙관적 업데이트: 즉각적인 UI 반응
• 실시간 동기화: 다른 소스의 변경사항 반영
• Use Case와 UI 연결: 비즈니스 로직 실행과 결과 처리
• UI 상태 관리: 로딩, 에러, 선택 상태 등
• 사용자 피드백: 성공/실패 메시지 표시
• 낙관적 업데이트: 즉각적인 UI 반응
• 실시간 동기화: 다른 소스의 변경사항 반영
의존성 방향의 중요성
Clean Architecture의 핵심은 의존성 방향입니다. 이것이 왜 그렇게 중요할까요?
의존성 역전 원칙 (Dependency Inversion Principle)
전통적인 계층 구조에서는 상위 레이어가 하위 레이어에 의존합니다:
이 구조의 문제는 Database를 바꾸면 Business Logic도 바꿔야 한다는 것입니다.
Clean Architecture는 이를 역전시킵니다:
UI → Business Logic ← Database
↑
(인터페이스 정의)
Business Logic이 인터페이스를 정의하고, Database가 그것을 구현합니다. 이제 Database를 바꿔도 Business Logic은 변하지 않습니다.
UI → Business Logic → Database
이 구조의 문제는 Database를 바꾸면 Business Logic도 바꿔야 한다는 것입니다.
Clean Architecture는 이를 역전시킵니다:
`UI → Business Logic ← Database
↑
(인터페이스 정의)
`Business Logic이 인터페이스를 정의하고, Database가 그것을 구현합니다. 이제 Database를 바꿔도 Business Logic은 변하지 않습니다.
실제 예시로 이해하기
온라인 쇼핑몰에서 "30일 내 반품 가능"이라는 비즈니스 규칙이 있다고 합시다.
나쁜 예 (Domain이 Data에 의존):
Dart
// domain/usecases/return_product_usecase.dart
class ReturnProductUseCase {
Future<bool> canReturn(String orderId) async {
// Firebase에 직접 의존 😱
final doc = await FirebaseFirestore.instance
.collection('orders')
.doc(orderId)
.get();
final orderDate = doc.data()['created_at'];
final daysSinceOrder = DateTime.now().difference(orderDate).inDays;
return daysSinceOrder <= 30;
}
}
Firebase를 Supabase로 바꾸면? Use Case를 다시 작성해야 합니다.
좋은 예 (의존성 역전):
Dart
// domain/usecases/return_product_usecase.dart
class ReturnProductUseCase {
final OrderRepository repository; // 인터페이스에 의존
Future<bool> canReturn(String orderId) async {
final order = await repository.getOrder(orderId);
final daysSinceOrder = DateTime.now().difference(order.createdAt).inDays;
return daysSinceOrder <= 30; // 비즈니스 규칙은 그대로
}
}
이제 Firebase를 Supabase로 바꿔도 Use Case는 그대로입니다. Repository 구현체만 바꾸면 됩니다.
데이터 흐름의 전체 그림
실제로 사용자가 "장바구니에 담기" 버튼을 누르면 어떤 일이 일어날까요? 전체 흐름을 자세히 따라가 봅시다.
text
1. UI 이벤트 (Presentation Layer)
- 사용자가 "장바구니 담기" 버튼 탭
- onPressed 핸들러 실행
↓
2. State 업데이트 요청 (Presentation Layer)
- CartNotifier.addToCart() 호출
- 낙관적 업데이트로 UI 즉시 반영
- 로딩 인디케이터 표시
↓
3. Use Case 실행 (Domain Layer)
- AddToCartUseCase.execute() 호출
- 비즈니스 규칙 검증 (재고, 구매 제한 등)
- 여러 Repository 조율
↓
4. Repository 호출 (Domain Layer Interface)
- ProductRepository.getProduct()
- InventoryRepository.checkStock()
- CartRepository.addItem()
↓
5. Repository 구현 실행 (Data Layer)
- 캐시 확인
- 네트워크 상태 확인
- API 호출 또는 로컬 DB 조회
↓
6. 데이터 소스 접근 (Data Layer)
- HTTP 요청 전송
- 응답 대기
- JSON 파싱
↓
7. DTO 변환 (Data Layer)
- JSON → DTO → Entity
- 데이터 유효성 검증
- 타입 변환
↓
8. 결과 반환 (역방향)
- Entity를 Use Case로 반환
- Use Case 결과를 State로 반환
- State 변경을 UI에 반영
↓
9. UI 업데이트 (Presentation Layer)
- 성공: 장바구니 아이콘에 뱃지 추가
- 실패: 에러 메시지 표시
- 로딩 인디케이터 제거
이 과정에서 각 레이어는 자신의 책임만 수행하고, 다른 레이어의 세부사항은 모릅니다.
마무리
Clean Architecture의 3개 레이어는 각각 명확한 책임과 경계를 가집니다:
Domain Layer는 비즈니스의 본질을 담습니다. 기술이 바뀌어도 변하지 않는 핵심 규칙과 프로세스를 정의합니다.
Data Layer는 이상과 현실을 연결합니다. 복잡한 기술적 세부사항을 처리하면서 Domain이 원하는 것을 제공합니다.
Presentation Layer는 사용자와 소통합니다. 복잡한 비즈니스 로직을 사용자가 이해하고 조작할 수 있는 형태로 표현합니다.
이 구조를 지키면:
• 비즈니스 로직이 UI나 DB 변경에 영향받지 않습니다
• 각 부분을 독립적으로 테스트할 수 있습니다
• 새로운 기능 추가가 쉬워집니다
• 팀원들이 코드를 이해하기 쉬워집니다
처음에는 복잡해 보이지만, 이 구조는 프로젝트가 성장할수록 빛을 발합니다. 작은 프로젝트에서는 과할 수 있지만, 장기적으로 유지보수해야 하는 프로젝트라면 Clean Architecture는 훌륭한 투자입니다.
Domain Layer는 비즈니스의 본질을 담습니다. 기술이 바뀌어도 변하지 않는 핵심 규칙과 프로세스를 정의합니다.
Data Layer는 이상과 현실을 연결합니다. 복잡한 기술적 세부사항을 처리하면서 Domain이 원하는 것을 제공합니다.
Presentation Layer는 사용자와 소통합니다. 복잡한 비즈니스 로직을 사용자가 이해하고 조작할 수 있는 형태로 표현합니다.
이 구조를 지키면:
• 비즈니스 로직이 UI나 DB 변경에 영향받지 않습니다
• 각 부분을 독립적으로 테스트할 수 있습니다
• 새로운 기능 추가가 쉬워집니다
• 팀원들이 코드를 이해하기 쉬워집니다
처음에는 복잡해 보이지만, 이 구조는 프로젝트가 성장할수록 빛을 발합니다. 작은 프로젝트에서는 과할 수 있지만, 장기적으로 유지보수해야 하는 프로젝트라면 Clean Architecture는 훌륭한 투자입니다.