Apple🍎/SwiftUI

클린 아키텍처 쉽게 이해하기 with 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 개발 속도가 훨씬 빨라지고, 품질도 높아져요! 😊