TCA

TCA 1.0 - TCA의 기본 개념 (2) (ch.03)

GREEN.1229 2024. 2. 8. 19:00

안녕하세요. 그린입니다 🍏

이번 포스팅에선 저번 TCA의 기본 개념 Part1에 이어 Part2로 기본 개념을 마무리 지어보겠습니다.

 

항상 포스팅에서도 소개했지만, TCA 1.0 시리즈 학습은 아래 학습자료를 기반으로 하고 있습니다.

해당 레퍼를 기반으로 학습하면서 제 나름대로 정리해보는 포스팅이기에, 주관적인 사견이 추가됩니다 🙋🏻

 

 

 

Chapter 3. TCA의 기본개념(2) | Notion

앞선 장에서 우리는 앱의 상태를 나타내는 State와 이를 변경할 수단인 Action, 그 Action의 기능을 구현하고 상태의 변경을 처리하는 Reducer을 알아보며, TCA에서의 데이터흐름에 대해서 살펴보았습니

axiomatic-fuschia-666.notion.site

 

그럼 시작해보시죠 😃


TCA의 기본 개념 - Part 2


Action에 따른 결과 Effect

  • Effect는 리듀서의 액션이 반환되는 타입을 의미함으로 즉, 액션을 거친 모든 결과를 Effect로 칭하고 있음
  • 여기서 네트워크 통신이나 OS를 건드는 등의 작업은 사이드 이펙트로 결국 Effect는 외부 시스템과도 상호작용하는 작업도 의미함
  • Effect를 통해 State가 변경되고 앱의 상태가 업데이트되는 흐름
  • 비동기 작업을 수행하고 해당 결과를 다시 Action으로 반환하여 State에 반영하는것으로도 사용
  • Action은 결과에 따라 새로운 Action을 생성하기도 하고 이를 통해 결국 State를 업데이트 하는 역할을 가짐
  • Effect는 그래서 정리해보면 아래와 같은 기능들을 수행함
    1. 비동기 작업을 관리
    2. 사이드 이펙트 분리하여 다룸 (Effect는 순수해야하기에)
    3. 취소 및 에러 핸들링
    4. 순서 보장
      • 이펙트 자체는 순차적으로 실행되기에 순서를 보장해줌
      • 결국 순서를 보장함으로 인해 State 변화를 주는 사이드 이펙트를 처리하고 순수하게 가져감으로 보다 더 예측 가능한 결과를 제공해줌

순수함수의 의미로써 Effect

  • 순수함수란 항상 주어진 입력에 대해 동일한 출력을 반환해주고 외부 상태의 변경이 없는 즉, Side Effect가 없는 함수를 의미함
func getNumber() -> Int {
  return 1
}
  • 위 코드를 볼때 해당 함수의 시그니쳐는 Void를 전달받아 Int 타입을 반환해주기에 내부 구현을 보더라도 1이라는 Int 값을 리턴해주는것을 볼 수 있음
  • 그렇기에 순수한 함수!
func getNumber() -> Int {
  print("1")
  return 1
}
  • 위 함수는 시그니쳐를 볼때 Void를 전달받아 Int 타입을 반환해주는것만을 우리는 의도하는데 내부에선 print가 되고 있음
  • 그렇기에 즉, 예상과는 다른 작업을 하기에 순수하지 않음
  • 결국 TCA에서도 네트워크 통신같은 Side Effect의 처리는 별도로 수행하고 그 결과를 다시 Action으로 반환하여 생성된 또 다른 Action이 리듀서에서 처리되어 State를 업데이트함으로 순수함을 가짐
  • 중요한건 Effect 자체가 순수한것이 아닌 순수하도록 Side Effect를 관리하는것!
    • 비동기 작업을 예를들면, Effect가 네트워크 요청을 수행하고 결과 혹은 오류에 대해 새로운 Action을 발행하여 반환하고 이 Action이 다시 리듀서에서 Action으로 받아 State를 업데이트하는 로직을 수행하는 흐름이기에 순수하게 관리됨
  • Combine에서 Publisher가 Effect로 볼 수 있고, Subscriber가 Effect의 .run으로 볼 수 있음
  • Effect의 .run은 또 뒤에서 나오겠지만 비동기 작업을 처리하고 해당 결과를 Action으로 반환하기 위해 주로 사용됨
  • TCA의 Effect도 Swift의 Combine 프레임워크를 기반으로 작성되었기에 Combine의 동작과 거의 유사

Effect의 주요 메서드들

.none

  • Action에서 Effect는 필수로 반환해야하기에 만약 상태 변경 및 로직 처리 후 다른 동작을 하지 않는다면 .none을 사용
case .incrementButtonTapped:
  state.count += 1
  return .none

 

.send

  • 파라미터로 Action을 넣어줌으로 현재 Action에서 로직 처리 후 추가로 다른 Action의 처리가 동기적으로 필요할 때 사용함
  • 액션을 전달하면서 애니메이션을 지정할 수 있다고는 하는데 아직 잘 모르겠음 🥲
  • 특이한것은 로직을 공유하기 위한 목적으로 .send를 사용하지 말라고 권장한다는데 (코드 중복이 발생할 수 있기에) 이는 설계하기 나름이 아닐까 싶긴합니다..
    • 여기서 말하는건 그런 중복된건 메서드로 분리하거나 하라는데, 사실 스타일의 차이이지 다른 차이가 있을까 하는 의문이 생김
    • 왜냐하면, 내부 상태를 변경하는 공통된 로직을 하나의 내부 액션으로 구현하고 .send를 통해 사용한다면 코드 중복을 더 줄이는 방법이 안리까 생각이듬 (메서드보다)

.run

  • 앞서 말했듯, 비동기 작업을 래핑하는 메서드로 인자로 비동기 클로저를 받아 실행하고 클로저 내부에서는 send를 통해 액션을 시스템에 전달하여 상태 처리 등 그 다음 작업을 해줌
case .aButtonTapped:
  return .run { send in 
    for await event in self.events() {
      send(.event(event))
    }
  }
  • 해당 코드를 살펴보면 .run으로 비동기 작업의 Effect를 수행한다.
  • 여기서 self.events()가 어떤한 비동기 작업일 것으로 추측됨
  • for await을 통해 비동기 스트림을 처리한 후에 send를 호출해 그에 맞는 다음 해당 액션을 처리하게 되는 형식
case .aButtonTapped:
  return .run { [count = state.count] send in 
  	let (data, _) = try await URLSession.shared.data(from: URL(string: "\(count)")!)
  	send(.nextAction)
  }
  • 비동기 작업을 처리하는것이기에 클로저에 캡쳐리스트처럼 현재 State값을 넣어줄 수 있음
  • 기존 리듀서 프로토콜 이전 방식에서의 처리는 inout이나 별도 내부 메서드를 거쳐 빼주는 등 사용이 번거로웠는데 좀 괜찮아진듯..!?

.cancellable(id:) & cancel(id:)

private enum CancelID { case timer }
case .buttonTapped:
if state.isOn {
  return .run { send in 
    while true {
      try await Task.sleep(for: .seconds(1))
      await send(.nextAction)
    }
  } else {
    .return .cancel(id: CancelID.timer)
  }
}
  • 코드를 보면 .cancellable은 id에 Effect를 식별하는 값을 담아 Effect를 취소할 수 있게 하는 메서드
  • cancelInFlight 옵션을 줄 수 있으며, 기본값으로 false이고 true로 설정한다면 같은 id로 진행중인 Effect를 모두 취소하는 효과를 가짐
  • cancel은 실제로 Effect를 취소하는것!
  • 즉 정리하자면, cancellable은 스트림에 넣어두는것처럼 해당 비동기 작업을 옵저빙하며 언제든 스트림을 끊어낼 수 있다는 영역표시 같은것이고, cancel은 실제 Effect를 취소하면 해당 cancellable로 옵저빙하고 있던 녀석들을 취소해버리게 해주는것!
  • 한줄로 요약해보면 🥲 cancellable은 취소 가능하게 하는것이고 cancel은 그렇게 생성된 작업들을 실제로 취소하는 역할로 결국 두 메서드를 모두 함께 사용하면서 비동기 작업의 수명 주기를 관리할 수 있음!

.merge & .concatenate

  • 둘다 여러 메서드를 return하여 실행해주는것은 같지만 순서와 동시라는 개념에서 차이가 있음
  • merge는 동시에 Effect를 실행하기에 순서를 보장해주지 않음
  • concatenate는 선언한 순서대로 Effect를 실행하기에 순서를 보장해줌

Effect의 활용 (Side Effect)

  • 사이드이펙트는 결국 앱의 주요 로직과 별개로 순수하지 않게 발생하는 작업이기에 예상치 못한 결과를 가져올 수 있음 (외부 서비스와의 상호 작용이나 비동기 작업 같은)
  • 에러 처리 또한 이런 사이드 이펙트 중 하나
  • 그렇기에 TCA에서 Effect의 주요 메서드를 활용해 Side Effect를 다뤄줌으로 코드를 순수하게 가져가며 테스트 용이성을 향상 시킬 수 있음
  • 먼저 소개된 예시 코드를 보시죠 🙋🏻
import ComposableArchitecture
import Speech
import SwiftUI

struct RecordMeetingFeature: Reducer {
  struct State: Equatable {
    /* code */
    var durationRemaining: Duration {
      self.standup.duration - .seconds(self.secondsElapsed)
    }
  }
  enum Action: Equatable {
    case endMeetingButtonTapped
    case nextButtonTapped
    case timerTicked
  }
  /* code */
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .endMeetingButtonTapped:
        return .none

      case .nextButtonTapped:
        return .none

      case .onTask:
        return .run { send in
          let status = await withUnsafeContinuation { continuation in
            SFSpeechRecognizer.requestAuthorization { status in
              continuation.resume(with: .success(status))
            }
          }
      /* code */
        }

      case .timerTicked:
        state.secondsElapsed += 1
        return .none
      }
    }
  }
}

struct RecordMeetingView: View {
  let store: StoreOf<RecordMeetingFeature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
    /* code */
      .task { await viewStore.send(.onTask).finish() }
    }
  }
}
  • 뷰에서는 .task를 이용해 뷰가 onAppear 될 때 비동기 작업을 실행
  • 뷰가 사라지면 비동기 작업도 자동으로 취소되도록 finish를 이용하는건가!? 처음본 메서드라...
  • 그냥 .task를 통해 구현해두면 뷰가 사라지면 진행중인 비동기 작업도 자동으로 취소되게 하는 역할인것 같은 느낌?
  • 근데 요새 최신에서는 레거시가 됐다는걸 들은것 같은데 현 시점에서 명확히 잘 모르니 일단 넘어가봅니다 🥲
  • 리듀서를 보면 .run으로 비동기 작업을 수행하고 있음
  • 여기서 continuation을 이용한 이유는 해당 requestAuthorization이 콜백 형식으로 비동기 함수가 아니기에 continuation을 이용해 비동기 함수로 연결하는 역할을 가짐
  • withUnsafeCOntinuation은 Swift Concurrency에서 나온 개념인걸로 아는데, 비동기/동기 코드 간 상호 운용성을 보장하기 위함으로 사용!
  • 즉, 비동기 함수 내에서 호출되며 비동기 작업이 완료될때까지 실행을 잠시 중단하고 구체적인 결과를 반환할 수 있게 하는 역할로 이때 continuation을 제공해 비동기 작업 결과를 반환하게함

Store

  • store는 앱의 런타임 동안 리듀서의 인스턴스를 관리하는 참조 타입의 객체로 앱의 상태와 액션을 관리하며 상태 변화를 감지하고 액션을 처리하는 역할을 함
let store: Store<CounterFeature.State, CounterFeature.Action>
  • Store 클래스는 뷰 구조체 내에서 주어진 초기 상태와 리듀서를 사용해 초기화됨
public typealias StoreOf<R: Reducer> = Store<R.State, R.Action>
let store: StoreOf<CounterFeature>
  • 이렇게 StoreOf로 축약해서 사용할 수 있음

scope(state:action:)

  • Store에서 어떻게 보면 가장 핵심인 scope 메서드!
  • 해당 메서드로 조금 더 작은 범위로 State, Action을 다루게 Store를 축소할 수 있음
struct State {
  var activity: Activity.State
  var profile: Profile.State
}
enum Action {
case activity(Activity.Action)
case profile(Profile.Action)
}
struct AppView: View {
  let store: StoreOf<AppFeature>
  
  var body: some View {
    TabView {
      ActivityView(store: self.store.scope(state: \.activity, action: AppFeature.Action.activity))
      
      ProfileView(store: self.store.scope(state: \.profile, action: AppFeature.Action.profile))
    }
  }
}
  • 위 코드와 같이 store 상수는 전체를 다루는 리듀서인데, 이를 각 뷰에 맞게 스코프를 지정하는것!
  • scope는 state와 action 두 인자를 필수로 받음
  • state는 상태를 지정할 키패스이고 action은 액션을 지정할 키패스
  • 주로 하위 상태와 액션을 상위에서 가지고 있을 경우 스코프를 지정하여 사용하는 경우가 많음
  • 이렇게 scope를 지정하는것의 장점은 뷰에서 딱 필요한 상태와 액션에만 접근함으로 불필요하게 다 가져갈 필요 없어 모듈화와 코드 유연성 향상으로 유닛 테스트 시에도 적절할거라 생각함

ViewStore

  • Store는 앱 상태 변화를 전체적으로 관리한다면 ViewStore는 View에 딱 필요한 상태만 구독하고 업데이트하는 역할을 가짐
  • 즉, 뷰가 필요하지 않은 상태 변경에서 불필요한 뷰 업데이트를 방지함
  • 앱 규모가 커질수록 하나의 스토어로 관리하기 힘들기에 TCA의 MultiStore 개념을 도입하여 뷰에서 하위 뷰를 생성할 때 상위 뷰의 상태 일부를 소유하는 별도의 스토어를 연결하게 되는데 여기서 문제가 발생함
    • 자식 뷰의 액션이 일어나면 부모 스토어에 전달하여 부모 스토어의 리듀서에서 상태 업데이트를 치는데, 이때 더 상위 스토어에서 뷰를 렌더링하는 요청이 들어오면 뷰를 여러번 렌더링하게 되는 상황이 발생
  • 결국 뷰 렌더링 관점에서는 변화 중복을 방지하기 위해 ViewStore를 사용함
  • 즉, Store 상위 전체 상태에서 뷰에 필요한 부분만 추출하여 ViewStore를 사용하는 느낌
  • 근데 사실... Observation이 나오고 TCA도 또 바껴서 이제 ViewStore도 크게 필요없고 아래 코드처럼 쓰이면서 Store만 있어도 됨 (WithViewStore로 감싸고 viewStore를 이용하는 그런것들을 안해도 됨 🥲)
@Reducer
struct Feature {
  @ObservableState
  struct State { }
  enum Action { }
  var body: some ReducerOf<Self> { }
}

struct FeatureView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    Text(store.count.description)
    Button("+") { store.send(.incrementButtonTapped) }
  }
}
  • 여기서 사실 최신 정리가 끝남ㅎㅎ..
  • 근데 지금 방식은 iOS 17 이상이고 TCA도 1.7 이상 최신 버전에서만 사용 가능하지만, 만약 TCA는 최신 버전인데 iOS가 17이 아니면 아래처럼 사용할 수 있다고 함!
@Reducer
struct Feature {
  @ObservableState
  struct State { }
  enum Action { }
  var body: some ReducerOf<Self> { }
}

struct FeatureView: View {
  let store: StoreOf<Feature>
  
  var body: some View {
    WithPerceptionTracking {
      Text(store.count.description)
      Button("+") { store.send(.incrementButtonTapped) }
    }
  }
}
  • 더 자세히 알고 싶으면 아래 레퍼를 참고해주세요 
 

Documentation

 

pointfreeco.github.io

 


WithViewStore

  • TCA 1.7 이상에선 사실 레거시의 느낌이 큰데 알아보쟈~
  • WithViewStore는 Store를 뷰 빌더에 사용할 수 있도록 하는 역할로 즉, 스토어와 뷰를 연결해주는것!
  • 근데 WithViewStore로 감싼 뷰가 복잡해질수록 당연하겠지만, 컴파일러 추론 성능이 저하됨
    • 이에 대해 타입을 명확히 명시하거나 이니셜라이저를 통해 Store를 주입받아 그 안에서 뷰스토어를 생성하면서 해결할 수 있음
WithViewStore(self.store, observe: { $0 }) { viewStore: ViewStoreOf<CounterFeature> in
    /* View code */
}

let store: StoreOf<CounterFeature>
@ObservedObject var viewStore: ViewStoreOf<CounterFeature>

init(store: StoreOf<CounterFeature>) {
  self.store = store
  self.viewStore = ViewStore(self.store, observe: { $0 })
}

 


Store가 Thread Safe하지 않는 이유

  • Store는 참조 타입이고 Thread Safe하지 않음
  • 여기서 Thread Safe하다는건 여러 스레드에서 동시에 접근이 이뤄져도 실행에 문제가 없어야되는것을 의미
  • 결국 Store는 참조 타입이고 모든 Store의 상호 작용 자체는 Store가 생성된 동일한 스레드에서 수행되어야 함
  • Store에 액션이 전달될 때 현재 State에서 리듀서가 실행되는데, 만약 State의 변경이 동시에 발생하게 된다면?
    • Thread Safe하지 않음!
    • 결국 이를 락을 걸거나 다양한 동시성의 처리를 통해 해결할 수도 있지만, 또 다른 사이드 이펙트를 발생시킬 수 있어 주의해야함
  • 결국 Store는 Thread Safe하지 않기에 모든 액션 자체는 동일한 스레드에서 보내야함

소감

어찌저찌 TCA의 기본 개념에 대해 알아봤는데, 최신인 TCA 1.7에서 하도 변하는게 많아서 조금 의욕이 꺾이긴했지만..! 그래도 킵고잉 합니다~


레퍼런스

 

Chapter 3. TCA의 기본개념(2) | Notion

앞선 장에서 우리는 앱의 상태를 나타내는 State와 이를 변경할 수단인 Action, 그 Action의 기능을 구현하고 상태의 변경을 처리하는 Reducer을 알아보며, TCA에서의 데이터흐름에 대해서 살펴보았습니

axiomatic-fuschia-666.notion.site