클린 아키텍처 쉽게 이해하기 with SwiftUI 🔍
클린 아키텍처가 뭔가요?
앱을 만들 때 코드를 역할별로 깔끔하게 나누는 방법이에요. 마치 서랍장에 물건을 종류별로 정리하는 것처럼, 코드도 하는 일에 따라 구분해서 관리하는 거죠.
클린 아키텍처의 계층 구조 이해하기 📚
계층이란? 🤔
앱의 코드를 역할별로 나눈 각각의 층을 말해요. 각 계층은 자기만의 역할이 있고, 다른 계층과 약속된 방식으로만 소통해요.
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 개발 속도가 훨씬 빨라지고, 품질도 높아져요! 😊