TCA (The Composable Architecture)

Tabber·2023년 8월 22일
0

디자인패턴

목록 보기
2/2
post-thumbnail

TCA란 무엇일까?

TCA는 SwiftUI, Combine 와 함께 사용할 수 있는 아키텍쳐 개념 중 하나입니다. SwiftUI는 기본적으로 View와 Model 간의 상태 Publishing이 가능한 형태이기에 기존에 주를 이루는 MVVM과는 조금 맞지 않습니다.

기본적으로 TCA는 Store의 State 변화에 따라 View를 업데이트 해주는 상태 기반의 단방향 아키텍쳐입니다.

따라서 오늘은 위 개념에 대해 알아보겠습니다.

https://github.com/pointfreeco/swift-composable-architecture

💡 이 글은 TCA github Repo를 보고 번역하여 적는 글입니다.

The Composable Architecture (복합적인 아키텍쳐)

The Composable Architecture (TCA) 는 구성과 테스팅, 그리고 인체공학(인간이 이해하기 쉽게)을 염두에 두고 일관되고 이해하기 쉬운 방식으로 애플리케이션을 구축하기 위한 라이브러리입니다.

위 라이브러리는 SwiftUI, UIKit 그리고 어떤 애플 플랫폼에서도 사용이 가능합니다.

복합적인(구성이 가능한) 아키텍쳐가 뭔가요?

TCA는 다양한 목적과 복잡성을 가진 애플리케이션을 만드는데 필요한 몇 가지의 핵심적인 툴들을 제공한다.

애플리케이션을 만드는데 매일매일 마주치는 많은 문제들을 푸는 다양한 흥미로운 사례들을 제공합니다.

TCA의 특징은 아래와 같습니다.

  • 상태 관리(State management) 단순한 값 타입을 사용하여 개발하는 애플리케이션의 상태를 관리하거나 많은 화면 간의 상태를 공유하는 방법을 제공한다. 이는 특정 화면의 변화를 다른 화면에서 즉시 관찰할 수 있도록 도와준다.
  • 구성 (Composition) 큰 기능들을 작은 컴포넌트로 분해할 수 있도록 해준다.
    분해된 작은 컴포넌트들은 격리된 모듈로 추출될 수 있으며, 기능의 형태를 구성하기 위해 쉽게 붙을 수 있다. 모듈화라는 말 인듯하다.
  • 사이드 이펙트 (Side Effects) 애플리케이션의 특정 부분이 최대한 테스트 가능하고, 이해가능한 방식으로 외부 세계와 소통할 수 있도록 도와준다.
  • 테스팅(Testing) 아키텍쳐 상에 구축 된 기능들을 테스트할 뿐만 아니라, 많은 파트로 구성되어진 기능들의 통합 테스트를 작성할 수 있다.
    또한 종단테스트 (end-to-end tests)를 통해, 사이드 이펙트가 애플리케이션에 어떤 영향을 주는지 이해할 수 있다. 이를 통해서 개발자가 예상한 방식대로 비즈니스 로직이 돌아가고 있는지를 강력하게 보장할 수 있도록 해준다.
  • 인체공학(Ergonomics) 가능한 적은 수의 개념과 동작 파트만을 가진 단순한 API만으로 모든것을 수행할 수 있다.

동작 방식

TCA에서 각각 View는 Store를 갖는다.

TCA에서는 MVVM 디자인패턴을 적용할 때 처럼, ObservableObject 프로토콜을 채용한 ViewModel을 구현하고, @ObservedObject , @StateObject 로 가져와서 사용하는 방식을 취하지 않는다.

각각의 View는 저마다의 Store를 가지고 있다. Store가 갖는 State는 각각의 View에 대한 비즈니스 로직 및 설명에 필요한 데이터가 정의되고, Action에는 이벤트가 enum 타입으로 정의된다. State, Action, Reducer (+ Environment) 들은 각각의 View에 대응되는 Core 파일에 정의가 된다.

WithViewStore를 통해 Store를 ObservableObject 프로토콜을 채택한 ViewStore로 변환

각각의 View Body 블록 내부에는 WhitViewStore로 감싸여있습니다. WithViewStore는 Store를 ObservableObject 프로토콜을 준수하는 ViewStore로 변환하여 View를 계산하는데 사용하게된다.

State가 변경되면 그에 맞게 View가 업데이트 되는데, State가 변경되기 위해서는 특정 이벤트를 감지해야한다.

어떻게 이벤트를 받는지 확인해보자.

ViewStore를 통해 필요할 때 특정 Action을 전송

위 코드는 특정 버튼을 눌렀을때 reconnectWebSocket 이벤트를 전송하는 모습이다.
viewStore.send 를 통해 특정 이벤트를 전송하며, reducer는 해당 이벤트에 맞는 동작을 실행한다.

이러한 View에서 발생될 수 있는 Action들은 enum 타입으로 정의가 된다.
Reducer는 이러한 Action들을 발생하는 case에 맞게 처리한다.

Reducer에서 Action에 맞게 State를 변경하고, 다음 이벤트를 실행할 Effect를 반환

ViewStore에서 send를 통해 특정 Action을 전송하면 Reducer는 Action에 맞는 동작을 실행한다.

Reducer는 발생한 Action 이벤트에 맞게 State를 바꾸거나 추가적인 이벤트를 Effect 타입으로 변환할 수 있습니다. 이때 반환하는 Effect는 Combine 프레임워크의 Publisher타입이며, Effect는 다양한 Combine Operator기능을 래핑한 연산자 메서드를 지원하고 있습니다.

위 코드는 .onAppear 이벤트가 발생했을 때 Effect의 concatenate 래핑 연산자를 통해 다양한 Effect들을 순차적으로 실행하는 것을 볼 수 있다.

위 코드는 Effect생성자 중 하나입니다. 하나의 이벤트를 Just 로 즉시 방출하고 있습니다.

만약 특정 이벤트가 실행되고 추가적인 이벤트 발생을 원치 않는다면 어떻게 해야할까요?

이어서 실행할 이벤트가 없을 때는?

위 코드는 Effect에 구현되어 있는 타입 프로퍼티 입니다. 더이상 수행할 Action이 없다면, .none 을 반환하면 됩니다.

위와 같이 특정 이벤트에 맞게 state 멤버 변수를 수정할 수 있습니다.

근데 그 다음 추가적인 이벤트를 실행할 생각이 없다면 .none을 반환하면 됩니다.

이렇게 Action이 전송되면, Reducer는 Action case에 맞는 작업을 수행하며, 이때 state 멤버변수를 변경하거나 또 다른 Effect들을 반환할 수 있었습니다.

+ 분해, 결합이 가능한 Reducer

Local하게, 때로는 Global 하게 사용할 수 있다.

지금까지 봤듯, Reducer는 이벤트에 맞게 State를 변경하고 그 다음의 Effect를 반환하는데요. 이런 Reducer는 여러개가 있을 수 있습니다. 최상단의 AppReducer부터 시작해, MainReducer, AReducer, BReducer 등등 말이죠.

이러한 Reducer들은 서로 분해하고 결합할 수 있습니다. pullback을 통해 다양한 기능 화면의 Reducer 들이 local → global 하게 사용될 수 있도록 변경하고, Combine으로 하나의 Reducer로 결합할 수 있습니다. 위에서 사용된 Combine 메서드를 보겠습니다.

Reducer는 Combine 타입 메서드를 통해 다수의 Reducer들을 가변인자로 받아서 하나의 Reducer로 변환할 수 있는 것을 볼 수 있습니다.

깃허브 리드미에서 가져오는 정보들

기본적인 사용법

TCA를 통해 기능을 만들기 위해서는 여러분의 도메인을 구성하는 몇 가지 타입을 정의해야 합니다.

  • 상태(State) : 비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터에 대한 설명을 나타내는 타입
  • 행동(Action) : 사용자가 하는 행동이나 노티피케이션 등 애플리케이션에서 생길 수 있는 모든 행동을 나타내는 타입
  • 환경(Environment) : API 클라이언트나 애널리틱스 클라이언트와 같이 애플리케이션이 필요로 하는 의존성(Dependency)을 가지고 있는 타입
  • 리듀서(Reducer) : 어떤 Action이 주어졌을 때 지금 State를 다음 State로 변화시키는 방법을 가지고 있는 함수입니다.
    또한 리듀서는 실행할 수 있는 Effect 를 반환해야 하며, 보통은 Effect값을 반환합니다.
  • 스토어 (Store) : 실제로 기능이 작동하는 공간
    우리는 사용자 Action을 보내서 Store는 Reducer와 Effect를 실행할 수 있고, Store에서 일어나는 State 변화를 Observe 해서 UI를 업데이트할 수도 있습니다.

💡 위의 타입을 정의하는 것의 이점은 즉시 여러분의 기능에 테스트 가능성을 부여할 수 있다는 것이고,
게다가 크고 복잡한 기능을 서로 결합 가능한 작고 독립된 모듈로 쪼갤 수도 있습니다.

간단한 예시로 설명을 드리겠습니다.

화면에 숫자와 이 숫자를 증가할 수 있는 + 버튼, 감소할 수 있는 - 버튼이 있다고 해보겠습니다.

더 다양한 행동을 위해서 탭 하면 API 호출을 해서 숫자에 관한 무작위 사실을 알림찾으로 보여주는 버튼도 추가해보겠습니다.

자, 그러면 화면의 상태(State)는 무엇이 있을까요?

먼저 화면의 숫자를 정수로 가지고 있을 것이고, 알림창을 보여줄 때 필요한 숫자에 관한 사실도 있을 것입니다.

struct AppState: Equatable {
	var count: Int = 0
	var numberFactAlert: String?
}

그러면 행동(Action)에는 무엇이 있을까요?

증가 버튼이나 감소 버튼을 누르는 행동은 누구나 생각할 수 있을 만큼 명확한 행동도 있고, 반대로 알림창을 닫거나 무작위 사실 API 리퀘스트 결과를 받았을 때 발생하는 행동같이 약간은 생각하기 어려운 행동도 있을 것입니다.

enum AppAction: Equatable {
	case factAlertDismissed
	case decrementButtonTapped
	case incrementButtonTapped
	case numberFactButtonTapped
	case numberFactResponse(Result<String, ApiError>)
}

다음으로는 화면이 제대로 동작하기 위해 필요한 의존성(Dependency)을 관리하는 환경(Environment) 차례입니다.

숫자에 관한 사실을 가져오는 경우 네트워크 리퀘스트를 요약해서 Effect 값으로 만드는 작업이 있겠네요. 이 작업의 의존성은 Int 를 받아서 Effect<String, ApiError> 를 반환하는 함수가 되겠습니다. 여기서 String은 리퀘스트의 리스폰스를 요약한 값입니다.

이펙트는 통상적으로 백그라운드 스레드에서 작업을 처리하게 될 것입니다.(URLSession 이 하는 것처럼요)

저희는 이펙트의 값을 메인 큐에서 받을 방법이 필요합니다. 메인 큐 스케줄러를 사용해야 테스트를 작성할 수 있습니다. AnyScheduler 를 사용해서 프로덕션에선 DispatchQueue 를 사용하고 테스트 시엔 테스트 스케줄러를 사용해봅시다.

struct AppEnvironment {
	var mainQueue: AnySchedulerOf<DispatchQueue>
	var numberFact: (Int) -> Effect<String, ApiError>
}

이제 리듀서를 구현해봅시다. 그러려면 현재 상태(State)를 변화시켜서 다음 상대로 만드는 방법에 대한 설명과 어떤 이펙트(Effect)가 실행되야하는지에 대한 설명이 필요합니다. 만약 어떠한 이펙트도 실행이 필요하지 않을 경우엔 .none 을 반환하면 됩니다.

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  switch action {
  case .factAlertDismissed:
    state.numberFactAlert = nil
    return .none

  case .decrementButtonTapped:
    state.count -= 1
    return .none

  case .incrementButtonTapped:
    state.count += 1
    return .none

  case .numberFactButtonTapped:
    return environment.numberFact(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect()
      .map(AppAction.numberFactResponse)

  case let .numberFactResponse(.success(fact)):
    state.numberFactAlert = fact
    return .none

  case .numberFactResponse(.failure):
    state.numberFactAlert = "Could not load a number fact :("
    return .none
  }
}

마지막으로 이 기능이 작동될 뷰를 정의합니다. Store<AppState, AppAction> 가 있으면 모든 상태 변화를 관측하고 UI를 다시 그릴 수 있으며, 사용자 행동을 보내서 상태를 변화할 수도 있습니다. .alert View Modifier가 요구하는 대로 숫자에 관한 사실을 구조체로 한 번 감싸서 Identifiable 을 따르게 만들겠습니다.

struct AppView: View {
  let store: Store<AppState, AppAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        HStack {
          Button("−") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

한 가지 중요한 사실은 이 모든 기능을 실제 이펙트 없이 구현할 수 있다는 것입니다. 이는 기능 자체를 독립된 환경에서 디펜던시 없이 만들 수 있다는 것을 증명하는 것이며 컴파일 시간 단축으로 직결되기도 합니다.

이 말인즉슨, 동일한 스토어에 UIKit을 붙이는 것도 가능하다는 의미입니다. UI업데이트나 알림창을 보여주는 작업을 위해 viewDidLoad 에서 스토어로 구독 하기만 하면 됩니다. 코드 자체는 SwiftUI 버전보다 조금 더 깁니다.

 class AppViewController: UIViewController {
   let viewStore: ViewStore<AppState, AppAction>
   var cancellables: Set<AnyCancellable> = []
 
   init(store: Store<AppState, AppAction>) {
     self.viewStore = ViewStore(store)
     super.init(nibName: nil, bundle: nil)
   }
 
   required init?(coder: NSCoder) {
     fatalError("init(coder:) has not been implemented")
   }
 
   override func viewDidLoad() {
     super.viewDidLoad()
 
     let countLabel = UILabel()
     let incrementButton = UIButton()
     let decrementButton = UIButton()
     let factButton = UIButton()
 
     // addSubview나 constraint 설정하는 코드는 생략했습니다
 
     self.viewStore.publisher
       .map { "\($0.count)" }
       .assign(to: \.text, on: countLabel)
       .store(in: &self.cancellables)
 
     self.viewStore.publisher.numberFactAlert
       .sink { [weak self] numberFactAlert in
         let alertController = UIAlertController(
           title: numberFactAlert, message: nil, preferredStyle: .alert
         )
         alertController.addAction(
           UIAlertAction(
             title: "Ok",
             style: .default,
             handler: { _ in self?.viewStore.send(.factAlertDismissed) }
           )
         )
         self?.present(alertController, animated: true, completion: nil)
       }
       .store(in: &self.cancellables)
   }
 
   @objc private func incrementButtonTapped() {
     self.viewStore.send(.incrementButtonTapped)
   }
   @objc private func decrementButtonTapped() {
     self.viewStore.send(.decrementButtonTapped)
   }
   @objc private func factButtonTapped() {
     self.viewStore.send(.numberFactButtonTapped)
   }
 }

이제 뷰는 준비되었으니 작동을 위한 스토어를 만들어봅시다. 여기서 디펜던시를 제공하면 됩니다. 그리고 API 리퀘스트를 생략하기 위해 문자열을 mock 해서 바로 반환하는 이펙트를 주입합니다.

let appView = AppView(
  store: Store(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment(
      mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
      numberFact: { number in Effect(value: "\(number) is a good number Brent") }
    )
  )
)

드디어 화면을 보여주기 위한 작업이 모두 끝났습니다.

이렇게 여러 단계를 통해 기능을 만드는 것은 순수하게 SwiftUI로 만드는 것보단 확실히 몇 단계 더 있긴 합니다. 하지만 그만큼 더 이점이 있습니다. 이러한 단계는 단순히 로직을 관측 가능한 객체나 다양한 UI 컴포넌트의 클로저에 흩뿌리는 것보다, 상태 변경을 적용하는 것에 일관된 태도를 가지도록 해줍니다. 또한 사이드 이펙트를 간결하게 표현하는 방법도 제공합니다. 그리고 추가적인 작업 없이 이펙트가 포함된 로직을 바로 테스트할 수도 있습니다.

참고 & 내용 인용

https://0urtrees.tistory.com/359
https://gist.github.com/pilgwon/ea05e2207ab68bdd1f49dff97b293b17


다음 시간에는 실제로 만든 프로젝트로 글을 작성하겠습니다.

profile
iOS 정복중인 Tabber 입니다.

0개의 댓글