ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 클린 아키텍처 쉽게 이해하기 with SwiftUI 🔍
    Apple🍎/SwiftUI 2024. 10. 29. 23:06

    클린 아키텍처가 뭔가요?

    앱을 만들 때 코드를 역할별로 깔끔하게 나누는 방법이에요. 마치 서랍장에 물건을 종류별로 정리하는 것처럼, 코드도 하는 일에 따라 구분해서 관리하는 거죠.

    클린 아키텍처의 계층 구조 이해하기 📚

    계층이란? 🤔

    앱의 코드를 역할별로 나눈 각각의 층을 말해요. 각 계층은 자기만의 역할이 있고, 다른 계층과 약속된 방식으로만 소통해요.

     

    Domain Layer (핵심 계층) 💡

    앱의 가장 기본이 되는 부분이에요. 마치 집을 지을 때 기초 공사가 중요한 것처럼, Domain Layer는 앱의 핵심적인 데이터 모델과 이를 다루는 규칙들을 담고 있답니다.

    Entity: 실제 세상의 물건이나 개념을 앱 속에서 어떻게 표현할지 정하는 설계도예요.

    • 예를 들어 쇼핑앱을 만든다고 생각해볼까요?
    • 실제 상품을 앱에서 다루기 위해 Product라는 설계도를 그리는 거예요. 마치 다양한 물건들(칫솔, 노트북 등)이 가진 공통된 특징들을 쏙쏙 뽑아서 앱에서 쓸 수 있게 정리하는 거죠!
    • 이렇게 만든 설계도를 가지고 실제로 상품을 어떻게 주문하고 관리할지 규칙을 만들어요.

    비즈니스 로직은 쉽게 말해서 "앱에서 할 수 있는 일들"을 의미해요. 예를 들어 쇼핑앱에서는 상품 목록을 구경하고, 상세 정보를 보고, 장바구니에 담고, 결제하고, 후기를 남기는 등 사용자가 할 수 있는 모든 행동들이 있잖아요? 개발자는 이런 각각의 행동들이 일어날 때 앱이 어떻게 반응하고 데이터를 처리할지 비즈니스 로직을 통해 결정하는 거예요. 마치 가게 주인이 손님의 요청에 어떻게 응대할지 규칙을 정하는 것처럼요!

    Use Case: 사용자가 앱에서 실제로 하고 싶은 일들을 구현하는 방법이에요!

    • 앞서 만든 Product 설계도를 가지고 실제로 어떤 일을 할 수 있을까요?
    • 쇼핑앱에서 자주 하는 일들을 생각해봐요:
    protocol ProductUseCase { 
        // 상품 목록 가져오기 
        func fetchProducts() async throws -> [Product] 
        // 상품 상세 정보 보기 
        func getProductDetail(id: String) async throws -> Product 
        // 장바구니에 상품 담기 
        func addToCart(product: Product, quantity: Int) async throws 
        // 상품 주문하기 
        func orderProduct(product: Product, quantity: Int) async throws -> Order 
        }

    Use Case는 마치 식당의 요리사와 같아요!

    • 손님(사용자)이 메뉴(기능)을 고르면, 요리사는 재료(데이터)를 가져와서 레시피(비즈니스 로직)대로 요리(처리)를 해서 완성된 음식(결과)을 내놓죠.
    • Use Case도 똑같아요. 사용자가 어떤 동작을 하면(예: 주문하기 버튼 클릭), Use Case는 필요한 데이터를 가져와서 정해진 규칙대로 처리한 후 결과를 돌려줘요.
    • 특히 SwiftUI에서는 이런 Use Case들을 ViewModel에서 호출해서 사용하게 되죠!

    이렇게 Use Case를 만들면 좋은 점이 있어요

    1. 코드가 깔끔해져요: 각 기능이 독립적이라 수정하기 쉽고 테스트하기도 좋아요
    2. 재사용성이 높아져요: 다른 화면에서도 같은 기능이 필요하면 쉽게 가져다 쓸 수 있죠
    3. 비즈니스 규칙을 한 곳에서 관리할 수 있어요: 주문 시 재고 확인 같은 중요한 규칙들을 놓치지 않게 됩니다

     

    Data Layer (데이터 계층) 💾

    앱에서 필요한 모든 데이터를 관리하는 층이에요! 마치 도서관처럼 필요한 정보를 가져오고, 보관하고, 정리하는 역할을 담당하죠.

    Repository: 데이터를 가져오고 저장하는 창고 관리자예요! 🏪

    • Repository는 앱에서 필요한 데이터를 어디서 가져올지, 어떻게 저장할지를 결정하는 역할을 해요.
    • 데이터는 여러 곳에서 올 수 있어요:
      • 서버 (인터넷을 통해)
      • 앱 내부 저장소 (UserDefaults, CoreData)
      • 캐시 (임시 저장소)

    먼저 Repository 설계도를 만들어볼까요?

    protocol ProductRepository {
        // 서버에서 상품 목록 가져오기
        func fetchProducts() async throws -> [Product]
    
        // 특정 상품 정보 가져오기
        func getProduct(id: String) async throws -> Product
    
        // 주문 처리하기
        func processOrder(_ order: Order) async throws
    
        // 상품 정보 캐시에 저장하기
        func cacheProducts(_ products: [Product])
    }

    이렇게 Repository를 두면 좋은 점이 있어요

    1. 데이터 접근 방식을 한 곳에서 관리할 수 있어요
    2. Use Case는 데이터가 어디서 오는지 몰라도 돼요 (마치 마트 손님이 물건이 어디서 왔는지 신경 쓰지 않는 것처럼!)
    3. 캐시를 활용해서 앱 성능을 개선할 수 있어요
    4. 나중에 데이터 저장 방식을 바꾸더라도 다른 코드는 수정할 필요가 없어요

    Data Source: 실제로 데이터를 주고받는 친구예요! 🔄

    • Data Source는 Repository의 일꾼이라고 생각하면 돼요
    • Remote Data Source(원격)와 Local Data Source(로컬)로 나눌 수도 있어요
    // 원격 데이터 소스 (서버와 통신)
    protocol RemoteProductDataSource {
        func fetchProducts() async throws -> [ProductDTO]
        func getProduct(id: String) async throws -> ProductDTO
        func createOrder(_ order: OrderDTO) async throws
    }
    
    
    // 로컬 데이터 소스 (앱 내부 저장소)
    protocol LocalProductDataSource {
        func getProducts() -> [ProductDTO]?
        func saveProducts(_ products: [ProductDTO])
        func clearCache()
    }

    이렇게 Data Source를 두면 좋은 점이 있어요

    1. Repository는 데이터를 어디서 가져올지만 결정하고, 실제 데이터 통신은 Data Source가 담당해요
    2. Remote와 Local을 분리해서 각각 독립적으로 관리할 수 있어요
    3. 테스트하기 쉬워져요 (Mock Data Source를 만들어서 테스트할 수 있죠!)

    DTO (Data Transfer Object): 데이터 배달통이에요! 📦

    • DTO는 데이터를 주고받을 때 사용하는 특별한 모델이에요
    • API에서 오는 데이터 형식과 앱에서 사용하는 데이터 형식이 다를 수 있기 때문에 필요해요
    // 서버에서 받는 데이터 형식 
    struct ProductDTO: Codable {
        let productId: String
        let productName: String
        let productPrice: Int
        let stockQuantity: Int
        let productDescription: String
        let createdAt: String
        let updatedAt: String
    
        // Domain Layer의 Product로 변환하는 메서드
        func toDomain() -> Product {
            return Product(
                id: productId,
                name: productName,
                price: productPrice,
                stock: stockQuantity,
                description: productDescription
            )
        }
    }

    이렇게 DTO를 두면 좋은 점이 있어요

    1. 서버 데이터 형식이 바뀌어도 앱 내부 모델(Entity)은 안전해요
    2. 필요한 데이터만 골라서 사용할 수 있어요
    3. 데이터 변환 과정을 한 곳에서 깔끔하게 관리할 수 있어요

     

    Presentation Layer (화면 계층) 📱

    사용자가 직접 보고 상호작용하는 화면을 만드는 층이에요! SwiftUI에서는 View와 ViewModel이 이 역할을 담당하죠.

    View: 사용자에게 보여지는 화면을 구성해요 📺

    • 오직 UI 표시와 사용자 입력 처리에만 집중해요
    • ViewModel을 통해 데이터를 받아와서 표시하기만 해요
    • SwiftUI의 선언적 UI를 활용해 깔끔하게 코드를 작성할 수 있어요
    struct ProductListView: View {
        @StateObject private var viewModel: ProductViewModel
    
        // 의존성 주입
        init(viewModel: ProductViewModel = .init(
            useCase: DefaultProductUseCase(
                repository: DefaultProductRepository()
            ))
        ) {
            _viewModel = StateObject(wrappedValue: viewModel)
        }
    
        var body: some View {
            NavigationView {
                Group {
                    switch viewModel.state {
                    case .loading:
                        ProgressView()
                            .progressViewStyle(.circular)
                    case .loaded:
                        productList
                    case .error(let message):
                        ErrorView(message: message) {
                            viewModel.fetchProducts()
                        }
                    }
                }
                .navigationTitle("상품 목록")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button(action: { viewModel.fetchProducts() }) {
                            Image(systemName: "arrow.clockwise")
                        }
                    }
                }
            }
            .onAppear {
                viewModel.fetchProducts()
            }
        }
    
        private var productList: some View {
            ScrollView {
                LazyVStack(spacing: 16) {
                    ForEach(viewModel.products) { product in
                        NavigationLink {
                            ProductDetailView(productId: product.id)
                        } label: {
                            ProductRowView(product: product)
                        }
                    }
                }
                .padding()
            }
            .refreshable {
                await viewModel.fetchProducts()
            }
        }
    }

    ViewModel: 화면에 필요한 데이터를 관리하고 비즈니스 로직을 처리해요 🧑‍🔧

    • 화면에 필요한 데이터를 가공하고 상태를 관리해요
    • UseCase를 통해 비즈니스 로직을 실행해요
    • @Published 속성을 통해 데이터 변경을 View에 자동으로 알려줘요
    @MainActor
    class ProductViewModel: ObservableObject {
        // 상태 관리
        enum ViewState {
            case loading
            case loaded
            case error(String)
        }
    
        private let useCase: ProductUseCase
    
        @Published private(set) var state: ViewState = .loading
        @Published private(set) var products: [ProductViewData] = []
    
        init(useCase: ProductUseCase) {
            self.useCase = useCase
        }
    
        func fetchProducts() {
            Task {
                do {
                    state = .loading
    
                    // UseCase를 통해 데이터 가져오기
                    let domainProducts = try await useCase.fetchProducts()
    
                    // Domain 모델을 View 모델로 변환
                    self.products = domainProducts.map { product in
                        ProductViewData(
                            id: product.id,
                            name: product.name,
                            price: product.price,
                            stock: product.stock
                        )
                    }
    
                    state = .loaded
    
                } catch {
                    state = .error(error.localizedDescription)
                }
            }
        }
    
        // 상품 주문하기
        func orderProduct(_ product: ProductViewData, quantity: Int) {
            Task {
                do {
                    try await useCase.orderProduct(
                        productId: product.id,
                        quantity: quantity
                    )
                    // 주문 성공 후 목록 새로고침
                    await fetchProducts()
    
                } catch {
                    state = .error("주문 실패: \(error.localizedDescription)")
                }
            }
        }
    }

     

    Clean Architecture의 데이터 흐름 🔄

    1. 사용자 입력 -> 데이터 가져오기 흐름

    사용자 입력 → View → ViewModel → UseCase → Repository → DataSource → API/DB

    예시로 "상품 목록 보기" 흐름을 따라가볼까요?

    // 1. View: 사용자가 화면을 열거나 새로고침
    ProductListView().onAppear {
        viewModel.fetchProducts()  // ViewModel 호출
    }
    
    // 2. ViewModel: UseCase 호출
    class ProductViewModel {
        func fetchProducts() {
            Task {
                let products = try await useCase.fetchProducts()
                self.products = products.map { $0.toViewData() }
            }
        }
    }
    
    // 3. UseCase: 비즈니스 로직 처리
    class ProductUseCase {
        func fetchProducts() async throws -> [Product] {
            let products = try await repository.fetchProducts()
            return products.filter { $0.isAvailable }  // 비즈니스 규칙 적용
        }
    }
    
    // 4. Repository: 데이터 소스 선택 및 조정
    class ProductRepository {
        func fetchProducts() async throws -> [Product] {
            if let cached = try? localDataSource.getProducts() {
                return cached.map { $0.toDomain() }
            }
    
            let dtos = try await remoteDataSource.fetchProducts()
            return dtos.map { $0.toDomain() }
        }
    }
    
    // 5. DataSource: 실제 데이터 통신
    class RemoteDataSource {
        func fetchProducts() async throws -> [ProductDTO] {
            return try await networkService.request(endpoint: .products)
        }
    }

    2. 사용자 입력 -> 데이터 저장 흐름

    사용자 입력 → View → ViewModel → UseCase → Repository → DataSource → API/DB
                                                        ↓
                                                로컬 캐시 저장

    "상품 주문하기" 예시로 살펴볼까요?

    // 1. View: 주문 버튼 탭
    Button("주문하기") {
        viewModel.orderProduct(product, quantity: 1)
    }
    
    // 2. ViewModel: UseCase에 주문 요청
    func orderProduct(_ product: ProductViewData, quantity: Int) {
        Task {
            try await useCase.orderProduct(productId: product.id, quantity: quantity)
        }
    }
    
    // 3. UseCase: 주문 규칙 검증
    func orderProduct(productId: String, quantity: Int) async throws {
        guard quantity > 0 else { throw OrderError.invalidQuantity }
        try await repository.createOrder(productId: productId, quantity: quantity)
    }
    
    // 4. Repository: 주문 처리 및 캐시 업데이트
    func createOrder(productId: String, quantity: Int) async throws {
        // 원격 주문 처리
        let orderDTO = try await remoteDataSource.createOrder(
            productId: productId, 
            quantity: quantity
        )
    
        // 로컬 캐시 업데이트
        try await localDataSource.updateProductStock(
            productId: productId, 
            quantity: quantity
        )
    }

    주요 포인트 정리 🎯

    1. 단방향 데이터 흐름 : 바깥쪽(UI) → 안쪽(Data) 방향으로만 의존성이 있어요.
    2. 데이터 변환 : 각 계층마다 전용 데이터 구조를 두어 각 계층에는 해당 계층에 필요한 데이터들만 관리되요.
    3. DTO (서버 데이터) ↓ toDomain() Domain Model (비즈니스 로직용) ↓ toViewData() ViewData (화면 표시용)
    4. 에러 처리 흐름 : 각 계층별로 발생하는 에러 또한 계층별로 격리시켜서 관리할 수 있어요.
    5. DataSource → Repository → UseCase → ViewModel → View (각 계층에서 적절한 에러 변환 및 처리)

     

    Clean Architecture, 프로토콜 이용해 설계하자 🎯

    프로토콜은 간단히 말해서 "이런 기능들은 꼭 구현해야 해!" 하고 약속하는 설계도예요.

    예시로 살펴보는 프로토콜 활용

    // 1. UseCase 프로토콜
    protocol ProductUseCase {
        func fetchProducts() async throws -> [Product]
        func orderProduct(productId: String, quantity: Int) async throws
    }
    
    // UseCase 구현체
    class DefaultProductUseCase: ProductUseCase {
        private let repository: ProductRepository  // 프로토콜 타입으로 선언!
    
        init(repository: ProductRepository) {
            self.repository = repository
        }
    
        func fetchProducts() async throws -> [Product] {
            return try await repository.fetchProducts()
        }
    }
    
    // 2. Repository 프로토콜
    protocol ProductRepository {
        func fetchProducts() async throws -> [Product]
        func createOrder(productId: String, quantity: Int) async throws
    }
    
    // Repository 구현체
    class DefaultProductRepository: ProductRepository {
        private let remoteDataSource: ProductRemoteDataSource  // 프로토콜 타입!
        private let localDataSource: ProductLocalDataSource    // 프로토콜 타입!
    
        init(remote: ProductRemoteDataSource, local: ProductLocalDataSource) {
            self.remoteDataSource = remote
            self.localDataSource = local
        }
    }
    
    // 3. DataSource 프로토콜
    protocol ProductRemoteDataSource {
        func fetchProducts() async throws -> [ProductDTO]
    }
    
    // DataSource 구현체
    class DefaultProductRemoteDataSource: ProductRemoteDataSource {
        private let network: NetworkService
    
        func fetchProducts() async throws -> [ProductDTO] {
            return try await network.request(endpoint: .products)
        }
    }

    프로토콜을 사용하는 주요 이유 💡

    의존성 역전 원칙 (Dependency Inversion)

       // 나쁜 예: 구체 타입에 직접 의존
       class ProductViewModel {
           let useCase = DefaultProductUseCase()  // 😢
       }
    
       // 좋은 예: 프로토콜에 의존
       class ProductViewModel {
           let useCase: ProductUseCase  // 😊
    
           init(useCase: ProductUseCase) {
               self.useCase = useCase
           }
       }

    테스트 용이성

    // Mock 구현체를 쉽게 만들 수 있어요
       class MockProductUseCase: ProductUseCase {
           var products: [Product] = []
           var isOrderSuccessful = true
    
           func fetchProducts() async throws -> [Product] {
               return products  // 테스트용 데이터 반환
           }
    
           func orderProduct(productId: String, quantity: Int) async throws {
               if !isOrderSuccessful {
                   throw OrderError.failed
               }
           }
       }
    
       // 테스트 코드
       func testProductViewModel() async {
           // Given
           let mockUseCase = MockProductUseCase()
           mockUseCase.products = [testProduct1, testProduct2]
           let viewModel = ProductViewModel(useCase: mockUseCase)
    
           // When
           await viewModel.fetchProducts()
    
           // Then
           XCTAssertEqual(viewModel.products.count, 2)
       }

    유연한 구현체 교체

    // 개발할 때는 테스트용 DataSource
       let devDataSource: ProductRemoteDataSource = MockProductRemoteDataSource()
    
       // 실제 배포할 때는 실제 DataSource
       let prodDataSource: ProductRemoteDataSource = DefaultProductRemoteDataSource()

    기능 명세의 명확성

    protocol ProductUseCase {
           // 프로토콜만 봐도 어떤 기능들이 있는지 한눈에 파악할 수 있어요
           func fetchProducts() async throws -> [Product]
           func getProduct(id: String) async throws -> Product
           func orderProduct(productId: String, quantity: Int) async throws
           func cancelOrder(orderId: String) async throws
       }

    프로토콜 사용의 장점 요약 🌟

    1. 느슨한 결합
      • 각 계층이 구체적인 구현체가 아닌 프로토콜에 의존
      • 구현체를 쉽게 교체할 수 있음
    2. 테스트 용이성
      • Mock 객체를 쉽게 만들 수 있음
      • 각 계층을 독립적으로 테스트 가능
    3. 명확한 책임과 역할
      • 프로토콜이 각 계층의 책임을 명확하게 정의
      • 코드의 가독성과 유지보수성 향상
    4. 확장성
      • 새로운 구현체를 추가하기 쉬움
      • 기존 코드 수정 없이 새로운 기능 추가 가능

    이렇게 프로토콜 기반으로 설계하면 앱이 더 견고해지고 유지보수하기 쉬워져요! 😊

     

    Clean Architecture와 의존성 이해하기 🎯

    의존성이란?

    의존성은 쉽게 말해서 "A가 B를 필요로 한다"는 관계를 말해요.

    // 1. 강한 의존성의 예 (😢 피해야 할 패턴)
    class ProductViewModel {
        // ViewModel이 직접 구체적인 UseCase를 생성해요
        private let useCase = DefaultProductUseCase(
            repository: DefaultProductRepository()
        )
    }
    // 문제점:
    // - UseCase 구현체를 바꾸기 어려워요
    // - 테스트하기 어려워요
    // - 재사용이 어려워요
    
    // 2. 느슨한 의존성의 예 (😊 지향해야 할 패턴)
    class ProductViewModel {
        private let useCase: ProductUseCase  // 프로토콜에 의존
    
        init(useCase: ProductUseCase) {
            self.useCase = useCase
        }
    }
    // 장점:
    // - UseCase 구현체를 쉽게 교체할 수 있어요
    // - 테스트가 쉬워져요
    // - 재사용이 쉬워져요

    Clean Architecture에서의 의존성 규칙

    UI Layer (View, ViewModel) → Domain Layer (UseCase) → Data Layer (Repository, DataSource)

    각 계층은 안쪽 계층의 "추상화(프로토콜)"에만 의존해야 해요:

    // 1. UI Layer (Presentation)
    class ProductViewModel {
        private let useCase: ProductUseCase  // Domain Layer의 프로토콜에 의존
    
        init(useCase: ProductUseCase) {
            self.useCase = useCase
        }
    }
    
    // 2. Domain Layer
    protocol ProductUseCase {
        func fetchProducts() async throws -> [Product]
    }
    
    class DefaultProductUseCase: ProductUseCase {
        private let repository: ProductRepository  // Data Layer의 프로토콜에 의존
    
        init(repository: ProductRepository) {
            self.repository = repository
        }
    }
    
    // 3. Data Layer
    protocol ProductRepository {
        func fetchProducts() async throws -> [Product]
    }
    
    class DefaultProductRepository: ProductRepository {
        private let remote: ProductRemoteDataSource
        private let local: ProductLocalDataSource
    
        init(remote: ProductRemoteDataSource, local: ProductLocalDataSource) {
            self.remote = remote
            self.local = local
        }
    }

    Clean Architecture에서의 의존성 주입

    계층별 의존성 주입

    Feature별 모듈화

    // Feature 모듈화
    struct ProductFeature {
        private let container: DIContainer
    
        init(container: DIContainer) {
            self.container = container
        }
    
        // Feature의 진입점
        func makeProductListView() -> some View {
            let viewModel = makeViewModel()
            return ProductListView(viewModel: viewModel)
        }
    
        // Feature 내부 의존성 구성
        private func makeViewModel() -> ProductViewModel {
            return container.makeProductViewModel()
        }
    }
    
    // 사용 예시
    struct MainView: View {
        let container: DIContainer
    
        var body: some View {
            TabView {
                ProductFeature(container: container)
                    .makeProductListView()
                    .tabItem { Text("Products") }
                // ... 다른 탭들
            }
        }
    }

    테스트를 위한 Mock 의존성

    // Test Container
    class TestDIContainer: DIContainer {
        // Mock Data Layer
        override private func makeRepository() -> ProductRepository {
            return MockProductRepository()
        }
    
        // Mock Domain Layer
        override private func makeUseCase() -> ProductUseCase {
            return MockProductUseCase(
                repository: makeRepository()
            )
        }
    }
    
    // 테스트 코드
    class ProductViewModelTests: XCTestCase {
        var container: TestDIContainer!
        var viewModel: ProductViewModel!
    
        override func setUp() {
            container = TestDIContainer()
            viewModel = container.makeProductViewModel()
        }
    
        func testFetchProducts() async {
            // Given
            let mockProducts = [Product.mock(), Product.mock()]
    
            // When
            await viewModel.fetchProducts()
    
            // Then
            XCTAssertEqual(viewModel.products, mockProducts)
        }
    }

    Clean Architecture에서 의존성 주입이 중요한 이유 🌟

    계층 분리와 단방향 의존성
    - 각 계층이 독립적으로 존재할 수 있어요
    - 안쪽 계층은 바깥쪽 계층을 모르게 돼요
    테스트 용이성
    - 각 계층을 독립적으로 테스트할 수 있어요
    - Mock 객체를 쉽게 주입할 수 있어요
    유지보수성

       // 구현체 변경이 쉬워요
       let repository: ProductRepository = isTestMode 
           ? MockProductRepository() 
           : DefaultProductRepository()

    확장성
    - 새로운 기능 추가가 쉬워요
    - 기존 코드 수정 없이 새로운 구현체를 추가할 수 있어요

    의존성 주입을 통해 Clean Architecture의 핵심 원칙들을 지킬 수 있어요:

    • 관심사의 분리
    • 계층 간 독립성
    • 테스트 용이성
    • 유연한 확장성

    이렇게 의존성을 잘 관리하면 앱이 더 견고해지고 유지보수하기 좋아져요! 😊

     

    Clean Architecture 구조도 한눈에 보기 👀

    각 레이어의 경계와 각 레이어를 구성하는 컴포넌트들 사이에 의존관계 그리고 실제 구현과 전체 데이터의 흐름을 아래와 같이 그림으로 나타내볼 수 있어요.

     

    SwiftUI Preview와 의존성 주입 활용하기 🎨

    1. Preview 전용 Container 만들기

    // Preview용 DIContainer
    class PreviewDIContainer: DIContainer {
        // Mock 데이터를 주입할 수 있는 속성들
        var mockProducts: [Product] = []
        var mockError: Error?
        var isLoading: Bool = false
    
        override func makeProductUseCase() -> ProductUseCase {
            return MockProductUseCase(
                products: mockProducts,
                error: mockError,
                isLoading: isLoading
            )
        }
    }
    
    // Mock UseCase
    class MockProductUseCase: ProductUseCase {
        private let products: [Product]
        private let error: Error?
        private let isLoading: Bool
    
        init(products: [Product] = [], error: Error? = nil, isLoading: Bool = false) {
            self.products = products
            self.error = error
            self.isLoading = isLoading
        }
    
        func fetchProducts() async throws -> [Product] {
            if isLoading {
                try await Task.sleep(nanoseconds: 2_000_000_000) // 2초 지연
            }
            if let error = error {
                throw error
            }
            return products
        }
    }

    2. Preview Provider 구성하기

    struct ProductListView_Previews: PreviewProvider {
        static var previews: some View {
            // 다양한 상태의 Preview 제공
            Group {
                // 1. 기본 상태
                makePreview(name: "기본 상태") {
                    container.mockProducts = Product.sampleData
                }
    
                // 2. 로딩 상태
                makePreview(name: "로딩 중") {
                    container.isLoading = true
                }
    
                // 3. 빈 상태
                makePreview(name: "데이터 없음") {
                    container.mockProducts = []
                }
    
                // 4. 에러 상태
                makePreview(name: "에러 상태") {
                    container.mockError = NSError(domain: "", code: -1, userInfo: nil)
                }
            }
        }
    
        // Preview 헬퍼 메서드
        static func makePreview(
            name: String,
            configure: (PreviewDIContainer) -> Void
        ) -> some View {
            let container = PreviewDIContainer()
            configure(container)
    
            return ProductListView()
                .environment(\.container, container)
                .previewDisplayName(name)
        }
    }

    3. 재사용 가능한 Preview 컴포넌트

    // Preview 에셋
    struct PreviewAsset {
        // 샘플 데이터
        static let sampleProducts: [Product] = [
            .init(id: "1", name: "맛있는 사과", price: 1000, stock: 10),
            .init(id: "2", name: "달콤한 배", price: 2000, stock: 5),
            .init(id: "3", name: "신선한 포도", price: 3000, stock: 0)
        ]
    
        // 샘플 에러
        static let sampleError = NSError(
            domain: "PreviewError",
            code: -1,
            userInfo: [NSLocalizedDescriptionKey: "미리보기 에러 발생"]
        )
    }
    
    // Preview 모디파이어
    struct PreviewModifier: ViewModifier {
        let container: PreviewDIContainer
    
        func body(content: Content) -> some View {
            content
                .environment(\.container, container)
        }
    }
    
    extension View {
        func withPreviewContainer(_ container: PreviewDIContainer) -> some View {
            modifier(PreviewModifier(container: container))
        }
    }

    4. 실제 View에서 활용

    struct ProductListView: View {
        @Environment(\.container) private var container
        @StateObject private var viewModel: ProductViewModel
    
        init() {
            _viewModel = StateObject(
                wrappedValue: container.makeProductViewModel()
            )
        }
    
        var body: some View {
            Group {
                switch viewModel.state {
                case .loading:
                    ProgressView()
                case .loaded(let products):
                    productList(products)
                case .error(let message):
                    ErrorView(message: message)
                }
            }
            .navigationTitle("상품 목록")
        }
    }
    
    // 각 컴포넌트별 Preview
    struct ProductRow_Previews: PreviewProvider {
        static var previews: some View {
            let container = PreviewDIContainer()
            let product = PreviewAsset.sampleProducts[0]
    
            ProductRow(product: product)
                .withPreviewContainer(container)
                .previewLayout(.sizeThatFits)
        }
    }

    5. 더 나은 Preview를 위한 팁들 💡

    상태별 Preview 모음 만들기

    struct ProductPreviewGallery: PreviewProvider {
        static var previews: some View {
            NavigationView {
                List {
                    Section("로딩 상태") {
                        makeLoadingPreview()
                    }
    
                    Section("다양한 상품 상태") {
                        makeProductPreview(stock: 100, name: "충분한 재고")
                        makeProductPreview(stock: 5, name: "부족한 재고")
                        makeProductPreview(stock: 0, name: "품절")
                    }
    
                    Section("에러 상태") {
                        makeErrorPreview(error: PreviewAsset.sampleError)
                    }
                }
            }
        }
    }

    Preview 전용 Helper 확장

    extension PreviewDIContainer {
        static func withMockProducts(_ products: [Product]) -> PreviewDIContainer {
            let container = PreviewDIContainer()
            container.mockProducts = products
            return container
        }
    
        static func withError(_ error: Error) -> PreviewDIContainer {
            let container = PreviewDIContainer()
            container.mockError = error
            return container
        }
    
        static func loading() -> PreviewDIContainer {
            let container = PreviewDIContainer()
            container.isLoading = true
            return container
        }
    }
    
    // 사용 예시
    struct SomeView_Previews: PreviewProvider {
        static var previews: some View {
            SomeView()
                .withPreviewContainer(.withMockProducts(PreviewAsset.sampleProducts))
        }
    }

    디바이스/환경별 Preview

    struct ProductList_DeviceGallery: PreviewProvider {
        static var previews: some View {
            Group {
                // iPhone
                ForEach(DeviceType.allCases) { device in
                    makePreview()
                        .previewDevice(PreviewDevice(rawValue: device.rawValue))
                        .previewDisplayName(device.rawValue)
                }
    
                // Dark Mode
                makePreview()
                    .preferredColorScheme(.dark)
                    .previewDisplayName("Dark Mode")
    
                // 다국어
                makePreview()
                    .environment(\.locale, .init(identifier: "ko"))
                    .previewDisplayName("한국어")
            }
        }
    }
    

    이렇게 의존성 주입을 활용한 Preview를 구성하면:

    1. 다양한 상태를 쉽게 테스트할 수 있어요
    2. 컴포넌트를 독립적으로 미리보기할 수 있어요
    3. 실제 데이터 없이도 UI 개발이 가능해요
    4. 여러 상황에 대한 테스트가 용이해져요

    Preview를 잘 활용하면 UI 개발 속도가 훨씬 빨라지고, 품질도 높아져요! 😊

    'Apple🍎 > SwiftUI' 카테고리의 다른 글

    SwiftUI가 선언형이라는게 무슨말일까?  (0) 2024.06.11

    댓글

Designed by Tistory.