CleanArchitecture for SwiftUI with Combine, Concurrency

HelloCleanArchitectureWithSwiftUI

CleanArchitecture for SwiftUI with Combine, Concurrency

개요

Clean Architecture를 SwiftUI와 Combine을 사용한 iOS 프로젝트에 적용한 예제

Layer와 Data Flow

먼저 역할별 레이어들부터 알아보자면 다음과 같다.

  • Presentation Layer: UI 관련 레이어
  • Domain Layer: 비즈니스 룰과 로직 담당 레이어
  • Data Layer: 원격/로컬등 외부에서 데이터를 가져오는 레이어

  • 각 레이어들의 Dependency 방향은 모두 원밖에서 원안쪽으로 향하고 있음
  • UI를 담당하는 Presentation Layer는 MVVM 패턴으로 구현됨

각 레이어의 데이터 흐름은 다음과 같다.

  • Domain Layer에서 Data Layer를 실행 시킬 수 있는 이유는 Dependency Inversion 으로 구현되었기 때문

Dependency Inversion 이란?

각 모듈간의 의존성을 분리시키기 위해 추상화된 인터페이스만 제공하고 의존성은 외부에서 주입(Dependency Injection)시킴

프로젝트 구성 (Swift Package Manager)

Clean Architecture의 각 Layer 별 의존성을 구현하기 위해 SPM을 사용하여 프로젝트를 구성한다.

  • 로컬 패키지를 하나 추가하고 폴더 구조를 다음과 같이 구성한다.

  • LayerPackage/Package.swift 에서 다음과 같이 Dependency를 줄 수 있음

import PackageDescription

let package = Package(
    name: "LayerPackage",
    platforms: [.iOS(.v15), .macOS("12")],
    products: [
        .library(
            name: "LayerPackage",
            targets: ["DataLayer", "DomainLayer", "PresentationLayer"]),
        
    ],
    dependencies: [
    ],
    targets: [
        
        //MARK: - Data Layer
        // Dependency Inversion : UseCase(DomainLayer) <- Repository <-> DataSource
        .target(
            name: "DataLayer",
            dependencies: ["DomainLayer"]),
        
        //MARK: - Domain Layer
        .target(
            name: "DomainLayer",
            dependencies: []),
        
        //MARK: - Presentation Layer (MVVM)
        // Dependency : View -> ViewModel -> Model(DomainLayer)
        .target(
            name: "PresentationLayer",
            dependencies: ["DomainLayer"]),
                        
        //MARK: - Tests
        .testTarget(
            name: "DataLayerTests",
            dependencies: ["DataLayer"]),
        
        .testTarget(
            name: "DomainLayerTests",
            dependencies: ["DomainLayer"]),
    
        .testTarget(
            name: "PresentationLayerTests",
            dependencies: ["PresentationLayer"]),
    ]
)

Domain Layer 구현

  • 원의 가장 내부 계층이며 핵심 기능을 담당하는 데이터 구조
  • 상위 계층에 의존성을 갖고 있지 않음으로 독립적으로 수행 가능해야 함

public struct ServiceModel: Identifiable {
    public var id: Int64 = 0
    public var otpCode: String?
    public var serviceName: String?
    public var additinalInfo: String?
    public var period: Int
    
    public init(id: Int64 = 0,
                otpCode: String? = nil,
                serviceName: String? = nil,
                additinalInfo: String? = nil,
                period: Int = 30) {
        self.id = id
        self.otpCode = otpCode
        self.serviceName = serviceName
        self.additinalInfo = additinalInfo
        self.period = period
    }
}
  • Data Layer에서 구현될 Repository에 대한 인터페이스를 추상화 함으로써 Dependency Inversion 구현을 가능하도록 함

import Combine

public protocol ServiceRepositoryInterface {
    func insertService(value: InsertServiceRequestValue) -> AnyPublisher<ServiceModel, Error>
    func fetchServiceList() -> AnyPublisher<[ServiceModel], Never>
}
  • 비즈니스 로직에 대한 각 UseCase를 구현
  • ServiceUseCase는 associatedtype을 활용한 UseCase 프로토콜

protocol ServiceUseCase {
    associatedtype RequestValue
    associatedtype ResponseValue
    var repository: ServiceRepositoryInterface { get }
    
    init(repository: ServiceRepositoryInterface)
    func execute(value: RequestValue) -> ResponseValue
}

public struct FetchServiceListUseCase: ServiceUseCase {
    typealias RequestValue = Void
    typealias ResponseValue = AnyPublisher<[ServiceModel], Never>
    let repository: ServiceRepositoryInterface
    
    public init(repository: ServiceRepositoryInterface) {
        self.repository = repository
    }
    
    public func execute(value: Void) -> AnyPublisher<[ServiceModel], Never> {
        return repository.fetchServiceList()
    }
}

public struct InsertServiceRequestValue {
    public let serviceName: String?
    public let secretKey: String?
    public let additionalInfo: String?
    
    public init(serviceName: String? = nil,
                secretKey: String? = nil,
                additionalInfo: String? = nil) {
        self.serviceName = serviceName
        self.secretKey = secretKey
        self.additionalInfo = additionalInfo
    }
}

public struct InsertServiceUseCase: ServiceUseCase {
    typealias RequestValue = InsertServiceRequestValue
    typealias ResponseValue = AnyPublisher<ServiceModel, Error>
    let repository: ServiceRepositoryInterface
    
    public init(repository: ServiceRepositoryInterface) {
        self.repository = repository
    }
    
    public func execute(value: InsertServiceRequestValue) -> AnyPublisher<ServiceModel, Error> {
        return repository.insertService(value: value)
    }
}

Presentation Layer 구현

  • UI 를 담당하는 Layer
  • MVVM 패턴으로 구현
  • View와 ViewModel 사이는 Combine으로 Data Binding 처리

import Foundation
import Combine
import DomainLayer

public protocol TokenViewModelInput {
    func executeFetchList()
    func executeInsertService(serviceName: String?,
                              secretKey: String?,
                              additionalInfo: String?)
}

public protocol TokenViewModelOutput {
    var services: [ServiceModel] { get }
}

public final class TokenViewModel: ObservableObject, TokenViewModelInput, TokenViewModelOutput {
    @Published public var services = [ServiceModel]()
    
    private let fetchListUseCase: FetchServiceListUseCase?
    private let insertServiceUseCase: InsertServiceUseCase?
    
    private var bag = Set<AnyCancellable>()
    
    public init(fetchListUseCase: FetchServiceListUseCase? = nil,
                insertServiceUseCase: InsertServiceUseCase? = nil) {
        self.fetchListUseCase = fetchListUseCase
        self.insertServiceUseCase = insertServiceUseCase
    }
    
    public func executeFetchList() {
        self.fetchListUseCase?.execute(value: ())
            .assign(to: \.services, on: self)
            .store(in: &bag)
    }
    
    public func executeInsertService(serviceName: String?,
                                     secretKey: String?,
                                     additionalInfo: String?) {
        
        let value = InsertServiceRequestValue(serviceName: serviceName,
                                              secretKey: secretKey,
                                              additionalInfo: additionalInfo)
        self.insertServiceUseCase?.execute(value: value)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    print(error.localizedDescription)
                    break
                }
            }, receiveValue: { service in
                self.services.append(service)
            })
            .store(in: &bag)
    }
}
  • ObservableObject로 구현
  • Domain Layer에 대한 의존성

List로 service를 보여줄 TokenView 작성

import SwiftUI
import DomainLayer

public struct TokenView: View {
    //1
    @ObservedObject var viewModel: TokenViewModel
    
    public init(viewModel: TokenViewModel) {
        self.viewModel = viewModel
    }
    
    public var body: some View {
        NavigationView {
            List {
                //1
                ForEach(self.viewModel.services) { service in
                    VStack(alignment: .leading) {
                        Text(service.serviceName ?? "")
                        Text(service.otpCode ?? "")
                            .font(.title)
                            .bold()
                        Text(service.additinalInfo ?? "")
                    }
                    .padding()
                }
            }
            .navigationTitle("Tokens")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Insert") {
                        //2
                        self.viewModel.executeInsertService(serviceName: "Token",
                                                            secretKey: "123",
                                                            additionalInfo: "insert@test.com")
                    }
                }
            }
        }
        .onAppear {
            //3
            self.viewModel.executeFetchList()
        }
    }
}
  1. ObservedObject로 선언된 ViewModel 내의 데이터가 업데이트되면 화면이 갱신됨
  2. Insert 버튼 누르면 ViewModel의 insert UseCase를 실행
  3. 화면이 보일때 ViewModel의 fetch list UseCase를 실행

Data Layer 구현

  • DB, Network 등 내/외부 데이터를 사용하는 Layer
  • DataSource는 비동기로 동작하기 위해 Concurrency 로 구현
  • mocked data로 구현, data race를 방지하기 위해 actor 사용

import Foundation
import DomainLayer

public protocol ServiceDataSourceInterface {
    func insertService(value: InsertServiceRequestValue) async throws -> ServiceModel
    func fetchServiceList() async -> [ServiceModel]
}

public final actor ServiceMockDataSource {
    // 테스트 데이터
    var mockData: [ServiceModel] = [
        ServiceModel(id: 0, otpCode: "123 123", serviceName: "Google", additinalInfo: "sample@google.com"),
        ServiceModel(id: 1, otpCode: "456 456", serviceName: "Github", additinalInfo: "sample@github.com"),
        ServiceModel(id: 2, otpCode: "789 789", serviceName: "Amazon", additinalInfo: "sample@amazon.com")
    ]
    
    public init() {}
}

extension ServiceMockDataSource: ServiceDataSourceInterface {
    
    public func insertService(value: InsertServiceRequestValue) async throws -> ServiceModel {
        guard let serviceName = value.serviceName else { throw ServiceError.unknown }
        
        let insertData = ServiceModel(id: Int64.random(in: 0..<Int64.max),
                                      otpCode: "123 456",
                                      serviceName: serviceName,
                                      additinalInfo: value.additionalInfo ?? "")
        
        self.mockData.append(insertData)
        
        return insertData
    }
    
    public func fetchServiceList() async -> [ServiceModel] {
        return mockData
    }

}
  • Combine operator에서 concurrency 호출을 위해 Future를 래핑하여 사용

import Combine

extension Publisher {
    func asyncMap<T>(
        _ transform: @escaping (Output) async -> T
    ) -> Publishers.FlatMap<Future<T, Never>, Self> {
        flatMap { value in
            Future { promise in
                Task {
                    let output = await transform(value)
                    promise(.success(output))
                }
            }
        }
    }
    
    func tryAsyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> Publishers.FlatMap<Future<T, Error>, Self> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        let output = try await transform(value)
                        promise(.success(output))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
    }
}
  • Domain Layer의 Repository Interface를 구현하여 Dependency Inversion을 완성

import Foundation
import Combine
import DomainLayer

public struct ServiceRepository: ServiceRepositoryInterface {
    
    private let dataSource: ServiceDataSourceInterface
    
    public init(dataSource: ServiceDataSourceInterface) {
        self.dataSource = dataSource
    }
    
    public func insertService(value: InsertServiceRequestValue) -> AnyPublisher<ServiceModel, Error> {
        return Just(value)
            .setFailureType(to: Error.self)
            .tryAsyncMap { try await dataSource.insertService(value: $0) }
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }
        
    public func fetchServiceList() -> AnyPublisher<[ServiceModel], Never> {
        return Just(())
            .asyncMap { await dataSource.fetchServiceList() }
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }
}

Dependency Injection 구현

  • 앱의 진입점에서 의존성 주입 및 환경 설정

  • AppDI Interface는 Presentation Layer에 구현

public protocol AppDIInterface {
    var tokenViewModel: TokenViewModel { get }
}
  • AppDI는 모든 DI를 사용하는 컨테이너 역할

import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

enum PHASE {
    case DEV, ALPHA, REAL
}

public struct AppEnvironment {
    let phase: PHASE = .DEV
}

public class AppDI: AppDIInterface {

    static let shared = AppDI(appEnvironment: AppEnvironment())

    private let appEnvironment: AppEnvironment

    private init(appEnvironment: AppEnvironment) {
        self.appEnvironment = appEnvironment
    }

    public lazy var tokenViewModel: TokenViewModel = {

        //MARK: Data Layer
        let dataSource: ServiceDataSourceInterface

        switch appEnvironment.phase {
        case .DEV:
            dataSource = ServiceMockDataSource()
        default:
            dataSource = ServiceMockDataSource()
        }

        let repository = ServiceRepository(dataSource: dataSource)

        //MARK: Domain Layer
        let fetchListUseCase = FetchServiceListUseCase(repository: repository)
        let insertServiceUseCase = InsertServiceUseCase(repository: repository)

        //MARK: Presentation
        let viewModel = TokenViewModel(fetchListUseCase: fetchListUseCase,
                                       insertServiceUseCase: insertServiceUseCase)

        return viewModel
    }()
}
  • 뷰 초기화 시 AppDI를 사용하여 의존성 주입

import SwiftUI
import PresentationLayer

@main
struct HelloCleanArchitectureWithSwiftUIApp: App {
    var body: some Scene {
        WindowGroup {
            TokenView(viewModel: AppDI.shared.tokenViewModel)
        }
    }
}

References

GitHub

View Github