ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TCA 1.0 - TCA의 기본 개념 (2) (ch.03)
    TCA 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

    'TCA' 카테고리의 다른 글

    TCA 1.0 - Dependency (ch.05)  (67) 2024.02.15
    TCA 1.0 - TCA Binding (ch.04)  (73) 2024.02.12
    TCA 1.0 - TCA의 기본 개념 (1) (ch.02)  (96) 2024.02.05
    TCA 1.0 - Hello, TCA (ch.01)  (114) 2024.02.01
    TCA - ReducerProtocol  (8) 2023.01.31
Designed by Tistory.