4 분 소요

The Composable Architecture란?

The Composable Architecture(이하 TCA)는 Point-Free에서 개발하고 있는 오픈소스 라이브러리로 일관되고 이해하기 쉬운 방식으로 어플리케이션을 만들기 위해 제작된 라이브러리 입니다.

SwiftUI, UIKit을 지원하며 모든 애플 플랫폼(iOS, macOS, tvOS, watchOS)에서 사용 가능합니다. 하지만 SwiftUI를 염두에 두고 디자인된 아키텍처이고 Combine 프레임워크에 의존성을 가지고 있기 때문에 iOS 13 이상에서 사용 가능하며 SwiftUI를 이용햇을때 효과적입니다.

Composition(합성), Ergonomics(인체 공학), 그리고 Testing(테스팅) 세 가지 요소를 염두에 두고 제작 되었는데 여기서 말하는 Composition은 앱을 구성하는 기능들을 작은 단위로 나누고 합쳐서 하나의 앱으로 만드는 과정을 이야기 하는것이고 Ergonomics는 신체적인 인체공학을 말하는 것이 아니라 코드를 작성하고 만들어나가는 개발자의 사고의 흐름상 자연스럽게 구조 되었다는 말이다. 그리고, Testing의 경우는 말그대로 테스팅인데 effects(제작자가 의도한 효과)와 side effects(제작자가 의도하지 않은 상황)에대한 처리와 전체 혹은 부분적인 기능에 대한 테스트를 고려해서 만들어졌다는 뜻입니다.


TCA 기본 사용법

TCA를 통해 기능을 만들기 위해선 다음과 같은 도메인을 구성하는 몇 가지 타입을 정의해야 합니다.

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

위와 같이 정의 함으로써 기능들을 테스트 할 수 있게 되고, 크고 복잡한 기능들을 작은 단위의 도메인으로 나누거나 그것을 합칠 수 있게 되는 이점을 얻을 수 있습니다.

처음 보면 이게 다 무엇인가 싶겠지만 천천히 살펴보면 사실 익숙한 흐름입니다. 간략하게 설명해보도록 하겠습니다. View에서 동작하는 모든 행동, 즉, 사용자의 입력, 피드백 등과 같은 모든 행동을 action이라고 합니다. 앱에 숫자, 닉네임, 사진, 등등 앱에서 사용되는 모든 데이터를 가지고 있는 일종의 그릇을 state라고 생각하시면 됩니다. 이러한 state를 변화시키는 함수를 reducer라고 부르는 것입니다. 하지만, 앱 외부의 데이터베이스나 api와의 통신을 통해 데이터를 불러와 state의 값을 바꿔야 하는 경우 environment 도메인에서 이것을 처리 합니다. 마지막으로 Store는 state, reducer, environment 모두를 포함하는 부분을 일컫는 말로 TCA 개발자는 The runtime이라고 표현하는데 앱의 실질적인 동작을 담당하는 뇌와같은 부분이라고 생각하면 됩니다.

간단한 예시와 함께 살펴보면 화면에 숫자가 있고 +버튼을 통해 숫자를 늘리고 -버튼을 통해 숫자를 감소시키는 기능을 구현한다고 가정해보면 우선 다음과 같이 기능을 만들기 위해 Reducer를 생성합니다.

import ComposableArchitecture

struct Feature: Reducer {
}

이 안에 State를 정의해주는데 세고 있는 숫자를 표시하는 count 변수와 알림을 위한 numberFactAlert 변수를 생성해주는데 여기서 알람을 보내지 않는 경우(nil)를 고려하여 numberFactAlert 변수는 옵셔널로 생성해주었습니다.

struct Feature: Reducer {
  struct State: Equatable {
    var count = 0
    var numberFactAlert: String?
  }
}

그 다음 이 앱에서 실행될 동작, 즉, Action을 열거형의 형태로 정의해주었습니다.

struct Feature: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action: Equatable {
    case factAlertDismissed
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)
  }
}

그 다음 실질적인 기능을 위한 함수를 넣어줍니다. 여기서 effect를 반환하지 않아도 되는 case의 경우 .none을 반환하도록 입력해 줍니다.

struct Feature: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action: Equatable { /* ... */ }
  
  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    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 .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared.data(
          from: URL(string: "http://numbersapi.com/\(count)/trivia")!
        )
        await send(
          .numberFactResponse(String(decoding: data, as: UTF8.self))
        )
      }

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

마지막으로 뷰를 그리는 과정인데 간단하게 표현하면 view 구조체 안에 store(위에서 만든 reducer 프로토콜을 따르는 feature구조체를 포함하는 일종의 그릇)안에 UI를 그림으로써 state의 변화를 계속 관찰하고 전달 할 수 있는 구조로 UI를 그려냅니다.

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { 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 }
}

위와 같은 과정을 마쳤다면 마지막으로 앱의 시작점에서 다음과같이 마무리 합니다.

import ComposableArchitecture

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(
        store: Store(initialState: Feature.State()) {
          Feature()
        }
      )
    }
  }
}

정석적인 SwiftUI의 방식보다 조금은 복잡하지만 상태 변화를 위해 여기저기 참조되어 있는 기능과 action 들을 통합해서 관리함으로써 일관성과 side effects(부작용)관리, 그리고 즉각적인 테스트의 용이성 등의 이점을 가져갈 수 있습니다.

이후 testing에 관한 코드는 생략 하지만 공식 깃허브를 통해 확인 할 수 있습니다.

TCA에 대해 간단하게 살펴보았는데 아직 프로젝트에 적용해보지 못했고 이제 알아보는 단계이기 때문에 부정확한 이해나 설명이 있을 수 있습니다. 참고자료로 활용하시고 자세한 내용은 공식 사이트나 깃허브를 통해 확인하시면 될 것 같습니다. 앞으로 알게되는 추가적인 내용과 자세한 내용은 차차 업로드 하겠습니다.


Reference

댓글남기기