-
클린 아키텍처 쉽게 이해하기 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를 만들면 좋은 점이 있어요
- 코드가 깔끔해져요: 각 기능이 독립적이라 수정하기 쉽고 테스트하기도 좋아요
- 재사용성이 높아져요: 다른 화면에서도 같은 기능이 필요하면 쉽게 가져다 쓸 수 있죠
- 비즈니스 규칙을 한 곳에서 관리할 수 있어요: 주문 시 재고 확인 같은 중요한 규칙들을 놓치지 않게 됩니다
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를 두면 좋은 점이 있어요
- 데이터 접근 방식을 한 곳에서 관리할 수 있어요
- Use Case는 데이터가 어디서 오는지 몰라도 돼요 (마치 마트 손님이 물건이 어디서 왔는지 신경 쓰지 않는 것처럼!)
- 캐시를 활용해서 앱 성능을 개선할 수 있어요
- 나중에 데이터 저장 방식을 바꾸더라도 다른 코드는 수정할 필요가 없어요
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를 두면 좋은 점이 있어요
- Repository는 데이터를 어디서 가져올지만 결정하고, 실제 데이터 통신은 Data Source가 담당해요
- Remote와 Local을 분리해서 각각 독립적으로 관리할 수 있어요
- 테스트하기 쉬워져요 (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를 두면 좋은 점이 있어요
- 서버 데이터 형식이 바뀌어도 앱 내부 모델(Entity)은 안전해요
- 필요한 데이터만 골라서 사용할 수 있어요
- 데이터 변환 과정을 한 곳에서 깔끔하게 관리할 수 있어요
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 ) }
주요 포인트 정리 🎯
- 단방향 데이터 흐름 : 바깥쪽(UI) → 안쪽(Data) 방향으로만 의존성이 있어요.
- 데이터 변환 : 각 계층마다 전용 데이터 구조를 두어 각 계층에는 해당 계층에 필요한 데이터들만 관리되요.
DTO (서버 데이터) ↓ toDomain() Domain Model (비즈니스 로직용) ↓ toViewData() ViewData (화면 표시용)
- 에러 처리 흐름 : 각 계층별로 발생하는 에러 또한 계층별로 격리시켜서 관리할 수 있어요.
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 }
프로토콜 사용의 장점 요약 🌟
- 느슨한 결합
- 각 계층이 구체적인 구현체가 아닌 프로토콜에 의존
- 구현체를 쉽게 교체할 수 있음
- 테스트 용이성
- Mock 객체를 쉽게 만들 수 있음
- 각 계층을 독립적으로 테스트 가능
- 명확한 책임과 역할
- 프로토콜이 각 계층의 책임을 명확하게 정의
- 코드의 가독성과 유지보수성 향상
- 확장성
- 새로운 구현체를 추가하기 쉬움
- 기존 코드 수정 없이 새로운 기능 추가 가능
이렇게 프로토콜 기반으로 설계하면 앱이 더 견고해지고 유지보수하기 쉬워져요! 😊
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를 구성하면:
- 다양한 상태를 쉽게 테스트할 수 있어요
- 컴포넌트를 독립적으로 미리보기할 수 있어요
- 실제 데이터 없이도 UI 개발이 가능해요
- 여러 상황에 대한 테스트가 용이해져요
Preview를 잘 활용하면 UI 개발 속도가 훨씬 빨라지고, 품질도 높아져요! 😊
'Apple🍎 > SwiftUI' 카테고리의 다른 글
SwiftUI가 선언형이라는게 무슨말일까? (0) 2024.06.11