요리과정으로 이해하는 TCA 개념 (1)

bono·2023년 8월 30일
7
post-thumbnail

안녕하세요 보노입니다!

저는 새로운 기술을 학습 시 아래와 같은 방법을 사용합니다.

찍먹 -> 냅다 도입 -> 적응 -> 혼란 -> 개념

TCA를 학습하며 개념 단계 도달하여 본 포스팅을 작성합니다.

단방향 아키텍쳐 설계는 늘 모듈화를 언급하는데요,

단방향 아키텍쳐의 어떤 점이 기능과 기능 간의 조립을 가능하게 하는 지 이 시리즈 끝에 이해할 수 있기를 바랍니다.

제가 이해한 바를 토대로 재구성한 그림입니다.

TCA를 구성하는 대표적인 요소 간 관계가 어떻게 구성되어 있는 지 파악하고, 그 내부 구현을 이해하는 것이 포스팅의 핵심입니다.

본 포스팅은 TCA 1.0 버전을 반영한 설명입니다.

TCA란?

Brandon WilliamsStephen Celis이 함수형 프로그래밍과 Swift 언어를 탐구하며 설계한 구조이다.

TCA가 제시하는 도메인을 모델링하는 요소는 아래 네 가지이다.

State, Action, Environment, Reducer, Store

본래 위에 Environment까지 합쳐 총 다섯가지였는데, 1.0 버전에는 없다. (정확히는 그 전에 빠졌다)

아마, Reducer를 구성함에 Environment를 포함하는 게 필수적이지 않다는 판단을 내린 게 아닐까 싶다.

아래는 공식이 설명하는 네 요소의 정의이다.


State : 기능이 논리를 수행하고 UI를 렌더링하는 데 필요한 데이터를 설명하는 유형입니다.

Action : 사용자 작업, 알림, 이벤트 소스 등 기능에서 발생할 수 있는 모든 작업을 나타내는 유형입니다.

Reducer : 앱의 현재 상태를 특정 작업에 따라 다음 상태로 발전시키는 방법을 설명하는 함수입니다. 또한 리듀서는 값을 반환하여 수행할 수 있는 API 요청과 같이 실행해야 하는 모든 효과를 반환하는 역할도 담당합니다.

Store : 실제로 기능을 구동하는 런타임(아마 특유의 표현인듯)입니다. 스토어가 리듀서와 효과(Effect)를 실행할 수 있도록 모든 사용자 작업(Action)을 스토어로 보내고, UI를 업데이트할 수 있도록 스토어에서 상태 변경을 관찰할 수 있습니다.

출처 : TCA GitHub


Action, Reducer, State

묘사를 시작하기 전, 공식 예제 코드를 확인해 보았다.

import ComposableArchitecture
import Foundation

struct Feature: Reducer {
  struct State: Equatable { // State
    var count = 0
    var numberFactAlert: String?
  }
  
  enum Action: Equatable { // Action
    case factAlertDismissed
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)
  }
  
  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
    }
  }
}

위 코드를 통해 ActionStateclassprotocol을 상속, 채택해야 하여 부과되는 자격이 아님을 알 수 있다.

하지만 Feature라는 Reducer는 TCA 라이브러리 내 Reducer 프로토콜을 채택해야 한다.

struct Feature: Reducer { // Reducer
	struct State: Equatable { // State
    enum Action: Equatable { // Action

그렇다면 ActionState라는 명칭은 어디서 왔는가.

ActionStateReducer이 가진 제네릭이다.

주석을 번역하면 아래와 같다.

  • State : 해당 타입은 리듀서의 현재 상태를 담고 있습니다.
  • Action : 해당 타입은 리듀서의 State가 변경되거나 외부 세계와 소통할 수 있는 사이드 Effect를 발생시키는 가능한 모든 액션을 담고 있습니다.

Action1) State를 변경시키거나, 2) Side Effect를 발생시키는 가능한 모든 액션을 담고 있다.

일단 중요하다고 여겨지는 지점은 Action과 State 생성에 조건이 없다는 것이다.

위의 코드 상으로는 StateAction은 그저 구조체와 열거형에 불과하다.

그렇다면 이 둘의 관계는 어떻게 형성 되는 걸까?

Reducer가 둘을 엮어준다.

State와 Action은 Reducer가 역할을 부여해 줄 때
진정 TCA의 State와 Action으로서의 모습을 갖춘다. (꽃이 된다)

Reducer의 역할: 레시피

위에서 봤듯이 Reducer 프로토콜을 채택하면 필수적으로 reduce 함수를 생성해야 한다.

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: // State에 접근하지 않는 Action
      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))
        )
      } // 본래 Environmet 객체가 가지고 있던 내용 
      

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

그리고 Reduce 함수에 대한 설명은 아래와 같다. (TCA 설명 친절하다)

  • parameters :
    state: 리듀서의 현재 상태입니다.
    action: 리듀서의 상태를 변경할 수 있고/또는 외부 세계와 통신할 수 있는 부수효과를 일으킬 수 있는 액션입니다.
  • return: 외부 세계와 통신하고 시스템에 다시 액션을 주입할 수 있는 효과입니다.

정리하자면, ReducerStateAction을 파라미터로 받아 Effect로 바꾸어 반환하는 함수 reduce()를 가지고 있다.

근데, 이게 다다.

???

Reducer는 프로토콜이기 때문에 함수 reduce에 대한 기본 구현 값이 없다. (매번 재정의 한다)

즉, reducer는 Action과 State의 관계를 정의하긴 하지만 그 또한 문서 그 자체로, 움직이지 않는다.

마치 재료(State)를 어떻게 사용해야 할 지 행동(Action) 순서를 명시한 레시피(Reducer) 같다.

즉 원하는 요리(기능)를 만들려면, 레시피(Reducer)를 읽어내릴 누군가가 필요하다.

그렇다. 요리사(Store)이다.

Store의 역할: 요리사

이제 요리사가 레시피(Reducer)를 들고 한 줄 한 줄 읽어나갈 것이다.

여기서 한 줄 한 줄에 읽어나가는 행위에 해당하는 하는 것이 바로 StoreReducerreduce의 메서드에 현재 State와 Action을 넣어 실행하는 것이다.

그렇다면 이제는 네 키워드의 소유 관계에 대해 이해할 수 있다.

요리사는 주방(View)의 적절한 위치에서 적절한 action을 수행하기 위해 가진 재료를 레시피(Reducer)에 넣는다.

그 action이 무엇이었냐에 따라 요리사는 다음 레시피를 읽기도 하고(action/노란 화살표) 변경된 재료 (State)를 가지기도 한다.

중요한 점은 실제 재료(State)를 가지고 있는 것이 요리사라는 것이다.

이것으로 TCA 기초 첫 번째 포스팅을 마친다.

다음 포스팅으로는 View와 Store 관계에 대한 이야기를 하려 한다.


(참고) TCA 1.0 버전에는 Environment가 없다?

본래 TCA 1.0.0 버전 이전에는 Reducer의 정의를 찍고 들어가면 아래와 같은 설명을 마주할 수 있었다.

Reducers have 3 generics:

  • State: A type that holds the current state of the application.
  • Action: A type that holds all possible actions that cause the state of the application to change.
  • Environment: A type that holds all dependencies needed in order to produce Effects, such as API clients, analytics clients, random number generators, etc.

하지만 공식 릴리즈된 1.0.0 버전에는 Reducer 내부에 Environment의 흔적이 없다.

즉, 제네릭 하게 관리되던 요소 중 Environment의 삭제됨으로서 Environment가 없어도 Reducer의 생성이 가능해 졌다.

본래 TCA에서 Reducer 내 제네릭 Environment가 가지던 설명을 다시 보자

  • Environment: A type that holds all dependencies needed in order to produce Effects, such as API clients, analytics clients, random number generators, etc.
  • Environment: API 클라이언트, 분석 클라이언트, 난수 생성기 등과 같은 Effect를 생성하는 데 필요한 모든 종속성을 보유하는 유형입니다.

과거 Environment는 API 통신과 같이 해당 기능의 Reducer가 필요로 하는 외부 의존성을 정의한 객체였다.

Action과 State의 관계를 정의한 Reducer 생성에 Environment의 존재가 필수적이지 않다는 결론을 내린 게 아닐까 싶다.

현재에는 본래 Environment로 정의되던 객체에 DependencyKey를 채택하고 해당 객체를 DependencyValues라는 구조체를 확장해 등록, 사용하도록 변경되었다.

생성 시 외부에서 주입 받지 않아도 되는 게 장점이라곤 하는데, 그렇다면 모든 dependency가 런타임에 생성된다는 건가? 이건 추가 학습이 필요할 것 같다.

포스팅으로 작성한다면 링크를 달아두겠다.


마치며,

TCA의 기초 뼈대 자체는 간단하다고 생각합니다.

그러나 유명한 라이브러리인만큼 그 내부 구현(코드 작성 센스)에서 얻어갈 점이 많았습니다.

어떤 점을 고려하여 어떤 설계를 하였는 지 차분히 확인하려 합니다.

감사합니다!

profile
iOS 개발자 보노

1개의 댓글

comment-user-thumbnail
2023년 8월 30일

👍👍👍

답글 달기