TCA Deep-Dive [1]

백상휘·2023년 12월 16일
1

iOS_Programming

목록 보기
10/10

이번 프로젝트에서 SwiftUI + TCA 를 도입하여 개발 시작부터 앱 배포까지 진행을 해 보았다. 이를 통해 얻은 노하우와 팁 등을 정리하고자 TCA 시리즈를 준비해 보았다.

이번 게시글에는 TCA 의 구성요소, 의미, 그리고 간단한 구현 예제를 작성하고자 한다.

TCA 의 CA(Composable Architecture) 란

TCA, The Swift Composable Architecture 의 The 는 관사이니 제외하면 CA 만 남는다. 즉, Swift 를 이용한 Composable Architecture 라는 뜻이 된다.

Composable Architecture 에 대한 아이디어는 많은 정보를 찾을 수는 없었다. 아마도 아래의 내용에 대한 것을 포함한다면 Composable Architecture 라고 부르는 것으로 판단된다.

  1. 객체를 작게 분류한다.
  2. 분류한 객체를 각각의 목적에 따라 재사용 할 수 있다.
  3. 재사용한 객체를 하나의 시스템으로 구성 가능하다.

이를 통해 저는 TCA 의 가장 큰 장점으로 다음의 요소를 꼽고 싶다.

TCA 는 비즈니스 로직을 작게 분류하여 여러 개의 비즈니스 로직으로 만드는 데 특화된 아키텍처이다

TCA 의 기본 흐름

TCA 의 기본 흐름도이다. 우선 구성요소들에 대한 설명은 아래와 같다.

  1. View : 말 그대로 View 이다. 뷰는 구체 Store 타입을 갖는다.
  2. ViewStore : State 의 값 변화를 관찰하며 바뀔 경우 이벤트를 전달한다. Action 을 Reducer 에 전송하는 역할도 한다. (아마 가장 많이 사용되는 객체 중 하나일 것이다)
  3. Store : State, Action, Reducer 를 Thread Safe 하게 관리하는 컨테이너이다. 일종의 ViewModel 이다.
    • State : 뷰의 상태를 정의한다. State 의 각 프로퍼티는 SwiftUI 의 @Binding 처럼 작동한다.
    • Action : Store 에 미리 정의된 User Action 에 대한 추상화 타입이다.
    • Reducer : 아래 3 개의 역할을 수행한다.
      • Mutable 한 State 를 바꿀 수 있는 객체이다.
      • Store 에 주입된 Dependency (immutable) 를 사용할 수 있다.
      • Effect 를 이용해 Store 에 새로운 Action 을 보내거나, 비동기 작업을 수행하는 스레드를 생성할 수 있다.
      • 속해있는 Store 의 State 가 다른 Store 의 State 를 포함한 경우, 포함하고 있는 Store 의 Action 을 관찰하여 자신을 변경하고 재사용할 수 있도록 한다. (나중에 후술)
  4. Dependency : Store 에 주입되는 객체로, 외부 Entity 나 Repository 등을 뜻한다.

Clean Architecture 와의 비교

Clean Architecture 의 상세한 설명은 (잘 모르기도 하고...) 아래의 그림으로 대체한다. (https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

가장 겉 껍질을 통해 계속 의존성이 전달되어야 함을 강조하는 그림이다.

  • UI 등 인터페이스는 자신을 컨트롤할 객체에 의존, 즉 자신의 책임 일부를 전달해주어야 한다.
  • Controller, Presenter 등은 최근엔 ViewModel 로 많이 해석되는 것 같다.
  • ViewModel 은 특정 도메인의 작업을 처리하는 UseCase, 그리고 UseCase 가 이용하는 Entity 를 의존성 주입받아 마찬가지로 자신의 책임을 전달한다.
  • GateWay 역할을 하는 객체는 ViewStore 이다. View 는 ViewStore 를 통해 원하는 작업을 전달함으로써 Store 에 의존한다.

클린 아키텍처 관점에서 TCA 는 어떻게 해석할 수 있을까?

TCA 의 View 와 Store 는 UI, Presenter, Controller 를 뜻한다. Dependency 는 UseCase 를 뜻한다.

다른 의미로는 Use Case, Entity 에 의한 여러 도메인들은 직접 정의해야 한다는 뜻이다. TCA 는 거기까진 책임지지 않는다.

View 와 Store

View 는 명실상부 View 이다. 한 가지 책임이라 하면 (사실 iOS 의 핵심이지 않나 싶다) 유저 이벤트를 받는다는 것이다.

그럼 Store 는 어째서 ViewModel 일까? Store 가 State, Action 을 저장하고 있다는 것을 생각하면 ViewModel 의 다음 책임을 똑같이 수행하고 있다고 할 수 있다.

  • 뷰의 상태를 정의하고 상태가 바뀌면 뷰 또한 바뀌게(다시 그리게) 된다.
  • 뷰가 전달받은 액션을 통해 자신의 값을 바꾼다.
  • 위의 바꿈은 자신의 책임 일부를 전달받은 UseCase, Entity 의 도움을 받을 수 있다.
  • UseCase, Entity 는 Dependency 를 이용한다.

이렇게 본다면 View 로 사용자에게 보여줄 뷰를 그리고, Store 로 내부 로직을 정의하면 된다는 사실을 쉽게 알 수 있을 것이다.

Dependency

Dependency 는 미리 정의된 DependencyKey, DependencyValue 를 이용해 어느 Store 에든 주입할 수 있는 객체이다. (https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/dependencymanagement/)

UseCase, Entity 는 외부 환경과 소통하기 위해 정의하는 객체이다. 이를 통해 뷰에서 사용할 모델의 Raw Data 를 받아올 수도 있으며, ViewModel 에서 만든 데이터를 외부에 전달해야 할 수도 있다.

즉, mutable 할 필요가 없다. 실제 Dependency 도 Read-Only 이다. 아래는 DependencyKey 소스코드에 포함된 예제 코드이다. 유저를 불러오고 저장하는 클라이언트 객체이며, 수정될 이유가 없는 코드이다.

struct UserClient {
  var fetchUser: (User.ID) async throws -> User
  var saveUser: (User) async throws -> Void
}

extension UserClient: DependencyKey {
  static let liveValue = Self(
    fetchUser: { /* Make request to fetch user */ },
    saveUser: { /* Make request to save user */ }
  )
}

extension DependencyValues {
  var userClient: UserClient {
    get { self[UserClient.self] }
    set { self[UserClient.self] = newValue }
  }
}

자세한 사용법은 나중에 후술하도록 하겠다.

TCA 와 Test

사실 이 부분은 개인적인 의견이 너무 많이 (사실 앞에도 그랬다) 포함되어 있다. TCA 는 자체적인 테스트 케이스 클래스를 포함하고 있는데 Unit/Integration-Test 이 모두 가능하다. 그렇게 때문에 TCA 에서 사용하는 테스트 클래스만으로 테스트를 수행해도 괜찮다.

그럼 Unit/Integration-Test 를 얼마나, 어디까지 수행해야 할 것인가? SUT(System under test) 를 어디까지 정의할 것인지가 중요한 것 같다. 그래서 필자는 SUT 를 Store 로 정의하고 테스트할 것을 추천한다. 그리고 Integration-Test 는 지양할 것을 추천한다.

Store 에는 뷰의 State 가 저장되어 있으며, 뷰가 수행해야 할 Action 도 정의되어 있다. 사실 애플리케이션에서 버그가 발생한다면 Store 부터 보게 될 것이다. 그렇기 때문에 Store 가 유일한 SUT 이다.

그리고 Integration-Test 를 추천하지 않는 이유는 Integration-Test 가 타겟팅하는 테스트 타겟은 Store 가 아니라고 생각하기 때문이다. Integration-Test 는 Dependency 에서 테스트해야 한다.

아마 TCA 개발팀도 같은 생각이었는지 TestStore 객체를 통해 테스트를 수행하는 것을 권장하고 있다. (https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file#testing)

@MainActor
func testFeature() async {
  // 기존 Store 초기화 방법
  // Store(initialState: Feature.State(), reducer: { Feature() })

  let store = TestStore(initialState: Feature.State()) {
    Feature()
  }
  
  // 동기화 된 객체 변경 - Unit-Test 가능.
  // send 를 통해 Action 전달. trailing closure 에는 Action 수행 후 변경될 State 와 같도록 mutable 한 State 를 조작.
  await store.send(.incrementButtonTapped) { $0.count = 1 }
  await store.send(.decrementButtonTapped) { $0.count = 0 }
  
  // 비동기 Action 수행 - Integration-Test 가능
  // send 를 통해 Action 전달. receive 에서 전달되는 KeyPath 는 State 의 KeyPath 이다.
  // 위와 같이 예상되는 State 의 상태로 직접 변경해주는 것으로 테스트를 수행한다.
  await store.send(.numberFactButtonTapped)
  await store.receive(\.numberFactResponse) { $0.numberFactAlert = false }
}

참고로 여기서 Unit-Test, Integration-Test 는 각각 다음의 차이점을 갖고 정의했다.

  • Unit-Test : 빠르게 수행되는 테스트. 주로 동기화 된 작업을 테스트한다. 단일 Mock 혹은 SUT 객체를 타겟으로 한다.
  • Integration-Test : 느리게 수행되는 테스트. 주로 비동기화 된 작업을 테스트한다. 여러 Mock 혹은 SUT 객체를 타겟으로 한다.

Example

아래는 간단한 계산기 앱을 구축해 볼 것이다. SwiftUI + TCA 를 사용할 것이며, 소스코드는 Repository_URL 에서 확인 가능하다.

계산기의 기능

  • 사칙연산만 수행한다.
  • Input Field 2 개에 연산해야 할 수를 입력한다.
  • 수행할 연산자는 Picker 를 이용해서 정의한다.
  • 결과 값을 Fetch 한 뒤 결과를 받아온다
    • 서버가 구축되어 있지는 않으므로 간단한 Timer 를 이용해서 비동기 작업을 수행한다.

앱의 구조

  • CalculatorView = View. 사용자와 상호작용한다.
    • CommonTextField = 서브 뷰로 재사용될 TextField.
  • ViewStore = WithViewStore 객체에 의해 참조할 수 있는 ViewStore 객체. Store 와 View 간 상호작용을 담당.
  • CalculatorFeature = Store. 뷰의 상태 및 역할을 모두 정의한다. State, Action, Reducer 소유. Point-Free 예제 코드에 따라 Feature 로 네이밍 한다.
    • CommonTextFieldFeature = 서브 뷰 TextField 의 Store
  • ResultUseCase = Dependency. Store 에 주입되는 외부 환경과 상호작용 하는 객체.

개인적으로 아키텍처를 통해 정확히 책임을 분리하는 앱을 구현하고 싶었다.

UI, View 계층

struct CalculatorView: View {
    typealias Feat = CalculatorFeature
    let store: StoreOf<Feat>
    
    var body: some View {
    	// 1
        WithViewStore(store, observe: {$0}) { vs in
            VStack {
                HStack {
                	// 2
                    ForEachStore(
                        store.scope(state: \.textFields, action: Feat.Action.fromTextField)
                    ) { textFieldStore in
                        CommonTextField(store: textFieldStore)
                    }
                    // 3
                    Picker(
                        Feat.Operator.addition.rawValue,
                        selection: vs.binding(get: \.operator, send: { .setOperator($0) })
                    ) {
                        ForEach(Feat.Operator.allCases, id: \.self) { op in
                            Text(op.rawValue)
                                .lineLimit(1)
                        }
                    }
                    .foregroundStyle(.secondary)
                    .pickerStyle(.wheel)
                    .buttonStyle(BorderedButtonStyle())
                    .minimumScaleFactor(0.2)
                }
                .padding(.horizontal, 30)
                .padding(.bottom, 50)
                // 4
                Button("Calculate!", systemImage: "rectangle.portrait.and.arrow.forward.fill") {
                    vs.send(.calculateButtonClicked)
                }
                .foregroundStyle(.primary)
                .buttonStyle(BorderedButtonStyle())
            }
            // 5
            .onAppear(perform: { vs.send(.refresh) })
            .navigationDestination(isPresented: .constant(vs.result != nil)) {
                ResultMessageView(result: vs.result ?? 0)
            }
        }
        .navigationTitle("Calculator")
    }
}

struct CommonTextField: View {
    typealias Feat = CommonTextFieldFeature
    let store: StoreOf<Feat>
    var body: some View {
        WithViewStore(store, observe: {$0}) { vs in
            TextField(
                text: vs.binding(get: \.text, send: { .setString($0) }),
                prompt: Text(vs.prompt ?? "")
            ) {
                EmptyView()
            }
            .keyboardType(.numberPad)
            .multilineTextAlignment(.center)
            .border(.secondary)
        }
    }
}
  1. Store 는 위에 선언되어 있다. WithViewStore 를 이용해 store 를 관찰하고 Action 을 보낼 수 있는 ViewStore 객체를 참조할 수 있다.
  2. 관찰 가능한 Store 를 가져오는 ForEachStore 를 사용하여 2 개의 TextField 를 생성하였다.
  3. Picker 에 넣을 raw data 를 ViewStore 에서 가져와 바인딩하였다.
  4. Button 의 action 클로저로 ViewStore 에 Action 을 보낸다.
  5. View LifeCycle 에서 처음에 refresh 를 시키기 위한 함수를 호출하고 있다.

Controller, ViewModel, Store 계층

struct CalculatorFeature: Reducer {
    typealias UseCase = ResultUseCase
    typealias Operator = ResultUseCase.Operator
    @Dependency(\.resultUseCase) var useCase: UseCase
    
    struct State: Equatable {
        var textFields = IdentifiedArrayOf<CommonTextFieldFeature.State>(uniqueElements: [.init(), .init()])
        var `operator`: Operator = .addition
        var result: Int?
        
        var localError: UseCase.UseCaseError?
    }
    
    enum Action {
        case refresh
        case setOperator(Operator)
        case setLocalError(UseCase.UseCaseError)
        case setResult(Int)
        case calculateButtonClicked
        
        case fromTextField(CommonTextFieldFeature.State.ID, CommonTextFieldFeature.Action)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .refresh:
                state.result = nil
                state.localError = nil
                return .none
            case .setOperator(let `operator`):
                state.operator = `operator`
                return .none
            case .setLocalError(let error):
                state.localError = error
                return .none
            case .setResult(let result):
                state.result = result
                return .none
            case .calculateButtonClicked:
                return .run { [lh = state.textFields[0].text, rh = state.textFields[1].text, op = state.operator] send in
                    let fetch = await useCase.getResult(lh, rh, op: op)
                    
                    switch fetch {
                    case .success(let result):
                        await send(.setResult(result))
                    case .failure(let error):
                        await send(.setLocalError(error))
                    }
                } catch: { error, send in
                    if let error = error as? UseCase.UseCaseError {
                        await send(.setLocalError(error))
                    }
                }
            default:
                return .none
            }
        }
        .forEach(\.textFields, action: /Action.fromTextField) {
            CommonTextFieldFeature()
        }
    }
}
  1. Store 에 의존성을 주입한 모습이다. @Dependency 를 이용해 DependencyValues 에 정의된 객체를 쉽게 가져올 수 있다.
  2. View 의 상태를 정의하는 State 이고 Equatable 을 구현하였다. Equatable 을 직접 구현할 경우 원하는 값이 바뀌었을 때만 뷰가 그려지도록 할 수도 있다. Equatable 을 구현하지 않으면 뷰가 다시 그려지지 않으니 주의해야 한다.
  3. View 의 상태를 변경하거나 특정 로직을 처리하는 Action 을 미리 정의하는 모습이다. fromTextField 는 후술하도록 하겠다.
  4. Reduce 에서는 refresh 로 뷰가 처음 그려질 경우 처리해야 할 작업을 만들어 놓는 것을 추천한다. 아래는 사용되는 State 중 local scope 에 있는 것들을 변경하는 모습이다. Effect 를 이용할 필요가 없으므로 Effect.none 을 반환한다.
  5. 비동기 작업을 위해 Effect.run 을 사용하고 있다.
  6. 하위 Store 를 관찰하기 위해 forEach 를 사용하고 있다. IdentifiedArray 를 사용할 경우 Reduce 자체에 forEach 를 사용하고 그렇지 않다면 Store 객체를 body 안에 선언해야 한다.
  7. TextField 에 맨 앞/뒤 0이 존재할 경우 빼주는 역할을 하고 있다.

UseCase, Dependency 계층

class ResultUseCase {
    private let isTestable: Bool
    private var cache: (lh: Int?, rh: Int?)
    
    init(isTestable: Bool = false) {
        self.isTestable = isTestable
    }
    
    enum Operator: String, CaseIterable {
        case addition // +
        case subtraction // -
        case multiplication // *
        case division // ÷
    }
    
    enum UseCaseError: Error {
        case divideWithZero
        case twoNumberZero
        case sameInput
        case undefinedNumbers
    }
    
    func getResult(_ lh: Int, _ rh: Int, op: Operator) async -> Result<Int, UseCaseError> {
        await withCheckedContinuation { continuation in
            self.calculate(lh: lh, rh: rh, op: op) {
                continuation.resume(returning: $0)
            }
        }
    }
    
    func getResult(_ lh: String, _ rh: String, op: Operator) async -> Result<Int, UseCaseError> {
        await withCheckedContinuation { continuation in
            guard let lh = Int(lh), let rh = Int(rh) else {
                continuation.resume(returning: .failure(UseCaseError.undefinedNumbers))
                return
            }
            
            self.calculate(lh: lh, rh: rh, op: op) {
                continuation.resume(returning: $0)
            }
        }
    }
    
    private func calculate(lh: Int, rh: Int, op: Operator, completionHandler: @escaping (Result<Int, UseCaseError>) -> Void) {
        if cache.lh == lh && cache.rh == rh {
            completionHandler(Result.failure(UseCaseError.sameInput))
            return
        }
        if lh == 0, rh == 0 {
            completionHandler(Result.failure(UseCaseError.twoNumberZero))
            return
        }
        
        let interval = DispatchTimeInterval.seconds(isTestable ? 0 : Int.random(in: 0...3))
        
        DispatchQueue.global().asyncAfter(deadline: .now() + interval) {
            guard (op == .division && rh == 0) == false else {
                completionHandler(Result.failure(UseCaseError.divideWithZero))
                return
            }
            
            self.cache = (lh, rh)
            completionHandler(Result.success(lh.calculate(op, operand: rh)))
        }
    }
}


extension ResultUseCase: DependencyKey {
    static var liveValue: ResultUseCase = .init()
    static var testValue: ResultUseCase = .init(isTestable: true)
}

extension DependencyValues {
    var resultUseCase: ResultUseCase {
        get { self[ResultUseCase.self] }
        set { self[ResultUseCase.self] = newValue }
    }
}

private extension Int {
    func calculate(
        _ `operator`: ResultUseCase.Operator,
        operand: Int
    ) -> Int {
        switch `operator` {
        case .addition:
            return self + operand
        case .subtraction:
            return self - operand
        case .multiplication:
            return self * operand
        case .division:
            return self / operand
        }
    }
}

위의 로직은 ViewModel 역할을 하는 Store 에서 계산과 관련된 역할을 모두 가져왔다고 보면 된다. TCA 와 관련하여 주목할 사항은 위의 1,2 라고 생각한다.

  1. Dependency 를 주입할 때 Key 로 어떤 값을 가져올지 정한다.
  2. Dependency 로 주입할 값을 정의한다. 일종의 Dependency Container 로 보면 된다.

Test

final class TCACalculatorTests: XCTestCase {
    @MainActor
    func testExample() async throws {
        let store = TestStore(initialState: CalculatorFeature.State()) {
            CalculatorFeature()
        }
        
        await store.send(.refresh)
        
        await store.send(.setOperator(.multiplication)) { state in
            state.operator = .multiplication
        }
        
        await store.send(.setResult(5)) { state in
            state.result = 5
        }
    }
    
    @MainActor
    func testDivisionError() async throws {
        let store = TestStore(initialState: CalculatorFeature.State(textFields: .init(uniqueElements: [
            .init(),
            .init(text: "0")
        ]))) {
            CalculatorFeature()
        }
        
        await store.send(.calculateButtonClicked)
        await store.receive(/CalculatorFeature.Action.setLocalError) { state in
            state.localError = .undefinedNumbers
        }
    }
    
    @MainActor
    func testFailed() async throws {
        let store = TestStore(initialState: CalculatorFeature.State(textFields: .init(uniqueElements: [
            .init(text: "2"),
            .init(text: "0")
        ]))) {
            CalculatorFeature()
        }
        
        await store.send(.setOperator(.division)) { state in
            state.operator = .division
        }
        
        await store.send(.calculateButtonClicked).finish()
        await store.receive(/CalculatorFeature.Action.setLocalError) { state in
            state.localError = .divideWithZero
        }
    }
}

Unit Test 를 진행중이다. 원래는 비동기 테스트를 진행하지 않도록 하는 것이 원칙이나, 할 수만 있다면 하는 방법을 고민해봐야 할 것이다. 하나의 테스트로 여러 개 테스트할 수 있다면 좋은 거 아닐까?

하지만 UseCase 테스트를 빼먹으면 안된다고 생각한다. 내부에 정확한 테스트 자체는 Store 만 진행한다면 테스트 코드에 드는 노력도 너무 낭비될 것이다.

extension ResultUseCase: DependencyKey {
    static var liveValue: ResultUseCase = .init()
    static var testValue: ResultUseCase = .init(isTestable: true)
}

Dependency 로 추가될 값 중 test 에서 쓰일 의존성은 testValue 이다. 여기서 사용된 isTestable 프로퍼티는 아래와 같이 실행 타이밍을 지워준다.

let interval = DispatchTimeInterval.seconds(isTestable ? 0 : Int.random(in: 0...3))

DispatchQueue.global().asyncAfter(deadline: .now() + interval) {
    // [execute logic]
}

Integration-Test 는 UseCase 만 따로 진행해주면 대부분의 코드를 테스트할 수 있을 것이다.

func useCaseTests() async throws {
    typealias E = ResultUseCase.UseCaseError
    let useCase = ResultUseCase()
    
    if case let .success(result) = await useCase.getResult(2, 0, op: .addition) {
        XCTAssert(result > 0)
    }
    
    if case let .failure(error) = await useCase.getResult("2", "0", op: .multiplication) {
        XCTAssertEqual(error, E.sameInput)
    }
    
    if case let .failure(error) = await useCase.getResult(0, 0, op: .addition) {
        XCTAssertEqual(error, E.twoNumberZero)
    }
    
    if case let .failure(error) = await useCase.getResult("NotNum", "0", op: .addition) {
        XCTAssertEqual(error, E.undefinedNumbers)
    }
    
    if case let .success(result) = await useCase.getResult("2", "4", op: .multiplication) {
        XCTAssert(result == 8)
    }
    
    if case let .failure(error) = await useCase.getResult("2", "0", op: .division) {
        XCTAssertEqual(error, E.divideWithZero)
    }
}

Reference

profile
plug-compatible programming unit

2개의 댓글

comment-user-thumbnail
2024년 3월 20일

전체 코드를 볼 수 있나요?

1개의 답글