iOS의 Clean Architecture

Layers

ios-clean-architecture
  • Presentation Layer는 Domain Layer에 의존
  • Data Layer는 Domain Layer에 의존
  • Domain Layer는 의존성이 없기 때문에 분리해서 다른 프로젝트에서도 사용 가능
ios-clean-architecture
  • Presentation: View(SwiftUI/UIKit), ViewModel(Presenter)
  • Domain: Entity, Use Case(Interactor), Repository 인터페이스
  • Data: Repository 구현, Data Source(remote/local), JSON Data 맵핑

Data 흐름

  • View가 ViewModel의 method 호출
  • ViewModel이 UseCase 실행
  • UseCase가 Repository의 data를 조합
  • Repository는 Remote/Local에서 data 받아와 리턴
  • View에 Information 표시

Presentation

SwiftUI는 MVVM 패턴의 클린 아키텍처와 어울리지 않는다는 의견도 많이 있습니다. 단방향 흐름을 갖고 있는 TCA나 MV 패턴이 대세가 되어가고 있지만, 그래도 SwiftUI에도 적용하는 방법을 알아보겠습니다.

View

SwiftUI View

struct DefaultView: View {
    @StateObject private var viewModel = ViewModel()
        
    var body: some View {
        List(viewModel.items) { item in
            Title(item.title)
        }
        .task {
            await viewModel.didSearch("")
        }
    }
}

UIKit ViewController

class DefaultViewController: UIViewController, StoryboardInstantiable {
    private var viewModel: ViewModel!
    
    static func create() -> DefaultViewController {
        let viewController = DefaultViewController.instantiateViewController()
        viewController.viewModel = ViewModel()
        return viewController
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel)
    }
    
    private func bind(to viewModel: ViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            // items 사용
        }
        viewModel.error.observe(on: self) { [weak self] error in
            // error 사용
        }
    }
}

ViewModel

SwiftUI ViewModel

class ViewModel: ObservableObject {
    private let useCase = DefaultUseCase()
    
    @Published var items = [Item]()
    @Published var error = ""
}

extension ViewModel {
    func didSearch(query: String) async {
        let page = 0
        let query = Query(query: query)
        
        await load(query: query, page: page)
    }
    
    private func load(query: Query, page: Int) async {
        do {
            let request = Request(query: query.query, page: page)
            self.items = try await useCase.execute(request: request)
        } catch {
            self.error = error
        }
    }
}

UIKit ViewModel

class ViewModel {
    private let useCase = DefaultUseCase()
    
    let items: Observable<[Item]> = Observable([])
    let error: Observable<String> = Observable("")
    
    private func load(query: Query, page: Int) async {
        do {
            let request = Request(query: query.query, page: page)
            self.items = try await useCase.execute(request: request)
        } catch {
            self.error = error.localizedDescription
        }
    }
}
final class Observable<Value> {
    struct Observer<Value> {
        weak var observer: AnyObject?
        let block: (Value) -> Void
    }
    
    private var observers = [Observer<Value>]()
    
    var value: Value {
        didSet { notifyObservers() }
    }
    
    init(_ value: Value) {
        self.value = value
    }
    
    func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) {
        observers.append(Observer(observer: observer, block: observerBlock))
        observerBlock(self.value)
    }
    
    func remove(observer: AnyObject) {
        observers = observers.filter { $0.observer !== observer }
    }
    
    private func notifyObservers() {
        for observer in observers {
            observer.block(self.value)
        }
    }
}

Data 바인딩

  • SwiftUI는 SwiftUI Property Wrappers, Observable Macro, Combine ObservableObject 등 사용
  • UIKit은 Observable(커스텀), RxSwift, Closure, Delegate 등 사용

Domain

Entity

  • Business Logic에 사용될 Data 형태
  • Codable Data를 Entity 형태로 맵핑해서 사용
struct Item: Equatable, Identifiable {
    let id = UUID()
    let title: String
}

UseCase

  • Business Logic을 구현하는 곳
  • Interactor로 불리기도 함
protocol UseCase {
    func execute(request: Request) async throws -> [Item]
}

class DefaultUseCase: UseCase {
    private let repository = DefaultRepository()
    
    func execute(request: Request) async throws -> [Item] {
        let result = try await repository.fetchList(query: request.query, page: request.page)
        
        // result 활용 비즈니스 로직
        
        return result
    }
}

Repository 인터페이스

  • Dependency Inversion을 위해 필요
  • 간단히 말해, Data Layer가 Domain Layer에 의존하기 위해 필요
protocol Repository {
    func fetchList(query: Query, page: Int) async throws -> [Item]
}

Data

Repository 구현부

class DefaultRepository: Repository {    
    private let networkService = NetworkService()
    
    func fetchList(query: Query, page: Int) async throws -> [Item] {
        let request = Request(query: query.query, page: page)        
        let endpoint = APIEndpoints.get(with: request)
        
        let response: Response = try await networkService.request(with: endpoint)
        let result = response.toDomain()
        
        return result
    }
}

Network Service

  • Remote data source로써 API와 통신하는 역할
  • Alamofire 같은 써드파티 라이브러리를 사용해도 됨
  • Network Layer 글 참고해서 자체적인 구현도 가능

Local Data

  • Local data source로써 CoreData, Realm, Cache 등에 해당

JSON Codable

  • JSON을 Codable로 파싱하는 Data 구조체
  • Domain에서 사용하기 위해 Entity로 맵핑하는 부분도 구현
struct Request: Encodable {
    let query: String
    let page: Int
}

struct Response: Decodable {
    let page: Int
    let titles: [String]
}

extension Response {
    func toDomain() -> [Item] {
        titles.map { Item(title: $0) }        
    }
}

Dependency Injection

SwiftUI

  • @Environment, @EnvironmentObject, @ObservedObject 사용
  • SwiftUI에서는 DI 라이브러리가 굳이 필요할까? 테스트 용이성이나 추가적인 확장을 위해 Point-Free의 swift-dependencies와 같은 라이브러리를 사용하기도 함

UIKit

Dependency와 Coordinator

struct Actions {
    let details: (Item) -> Void
}

protocol Dependency  {
    func makeViewController(actions: Actions) -> UIViewController
    func makeDetailsViewController(item: Item) -> UIViewController
}

class DefaultDependency: Dependency {
    ...
    
    func makeCoordinator(navigationController: UINavigationController) -> Coordinator {
        return Coordinator(navigationController: navigationController, dependency: self)
    }
}

class Coordinator {    
    private weak var navigationController: UINavigationController?
    private let dependency: Dependency

    init(navigationController: UINavigationController, dependency: Dependency) {
        self.navigationController = navigationController
        self.dependency = dependency
    }
    
    func start() {
        let actions = Actions(details: details)
        let viewController = dependency.makeViewController(actions: actions)        
        navigationController?.pushViewController(viewController, animated: false)
    }
    
    private func details(item: Item) {
        let viewController = dependency.makeDetailsViewController(item: item)
        navigationController?.pushViewController(viewController, animated: true)
    }
}

Closure

class DefaultDependency {
    ...
    func makeCoordinator(navigationController: UINavigationController) -> Coordinator {
        return Coordinator(navigationController: navigationController, makeViewController: self.makeViewController)
    }
    
    func makeViewController() -> DefaultViewController { ... }
}

class Coordinator {   
    private var makeViewController: () -> DefaultViewController

    init(navigationController: UINavigationController, makeViewController: @escaping () -> DefaultViewController) {
        ...
        self.makeViewController = makeViewController
    }
    ...
}

참고