-
TCA - ReducerProtocolTCA 2023. 1. 31. 09:09
안녕하세요. 그린입니다🍏
이번 포스팅에서는 TCA의 ReducerProtocol을 간단히 학습해보려합니다🙌
TCA가 날이 갈수록 업데이트도 빨라지고 더 발전하고 있어요!
현재 버전이 0.50.1까지 나왔으며 그전에 대격변이 0.41.0에서 일어났습니다.
바로 ReducerProtocol의 등장이죠🕺🏻
기존에 TCA에서 Core를 구성할때는 State, Action, Environment들을 별도 struct, enum으로 정의하고 이를 Reducer타입의 상수로 만들어 정의하는 형태였어요.
그런데 이와 반대로 Protocol Programming의 장점도 살리면서 더 적합하게 ReducerProtocol의 사용으로 전부 변화되었으며 장점도 많아졌어요🙏🏻
만약 이전 TCA 구성이 대략적으로 어떻게 되어있었는지 궁금하신분들은 아래 사용 예시 포스팅을 먼저 보고 비교해보시는것도 좋습니다!
https://green1229.tistory.com/156
그럼 본격적으로 ReducerProtocol에 대해 알아보겠습니다🥊
ReducerProtocol?
우선 Pointfree 공식문서에 따르면 앱에서 액션이 주어질때 현재 상태에서 다음 상태로의 전환되는 방법을 설명하고 후에 Store에서 실행해야하는 Effect를 설명하는 프로토콜입니다.
protocol ReducerProtocol<State, Action>
즉 선언은 당연히 State, Action을 갖는 프로토콜입니다.
그럼 이제는 이 프로토콜을 채택하는 구조체를 하나 만들면서 이 안에 늘 먹던것처럼 State, Action, Env 그리고 reducer 메서드까지 구현해주는것이죠.
공식문서 기반으로 예시를 한번 보겠습니다!
struct Feature: ReducerProtocol { struct State: Equatable { var count = 0 var numberFactAlert: String? } enum Action: Equatable { case factAlertDismissed case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse(TaskResult<String>) } func reduce(into state: inout State, action: Action) -> Effect<Action, Never> { 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 .task { [count = state.count] in await .numberFactResponse( TaskResult { String( decoding: try await URLSession.shared .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!).0, using: UTF8.self ) } ) } case let .numberFactResponse(.success(fact)): state.numberFactAlert = fact return .none case .numberFactResponse(.failure): state.numberFactAlert = "Could not load a number fact :(" return .none } } } }
이렇게 프로토콜을 채택하고 State, Action을 정의 해주고 있죠.
그 다음 reducer 상수를 구현하듯 reduce 메서드를 구현해줘요.
State는 변화를 입혀야되니 inout 타입이고 반환 타입은 당연히 Effect 타입입니다.
실제 이제 사용에 있어 .run, .task라던지 SwiftConcurrency에 맞춰 async await을 적용하면서 변경된 부분들은 양이 꽤 되기에 이후 포스팅에서 다뤄보겠습니다🥲
자 그럼 이제 View단 body에서는 어떻게 다뤄줘야할지 보겠습니다.
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 } }
실제 View에 올릴때는 이제 store 상수에 StoreOf의 타입으로 생성하는데 해당 Core에서 정의하고 만든 Feature를 제네릭하게 받게 해줍니다.
그런다음 WithViewStore로 body 내부에서 감싸서 사용하는것은 기존과 거의 동일합니다!
마지막으로 이 View를 올려줄 main App에서는 이렇게 사용하게 되죠.
@main struct MyApp: App { var body: some Scene { FeatureView( store: Store( initialState: Feature.State(), reducer: Feature() ) ) } }
보면 state에서는 Feature의 State 인스턴스를 사용하고 reducer는 Feature 인스턴스를 생성해준것을 확인할 수 있죠.
그리고 안짚고 넘어간것이 있는데 기존 서버 통신 및 외부 사이드 이펙트를 처리할때는 Environment 구조체를 정의하고 이를 reducer 상수에서 제네릭하게 받아 사용했는데요.
이제는 다릅니다.
struct NumberFactClient { var fetch: (Int) async throws -> String }
예를들어 이렇게 fetch 기능을 갖는 NumberFactClient 구조체가 있고 의존성을 주입하여 사용해야 된다고 가정해볼께요.
extension NumberFactClient: DependencyKey { static let liveValue = Self( fetch: { number in let (data, _) = try await URLSession.shared .data(from: .init(string: "http://numbersapi.com/\(number)")!) return String(decoding: data, as: UTF8.self) } ) } extension DependencyValues { var numberFact: NumberFactClient { get { self[NumberFactClient.self] } set { self[NumberFactClient.self] = newValue } } }
이제는 DependencyKey 프로토콜을 채택해 해당 유형을 의존성 관리 시스템에 등록하게 됩니다.
그런다음 DependencyValues를 확장해 numberFact라는 get set 프로퍼티를 구현해주죠.
앞으론 이걸 사용하게 될거에요.
struct Feature: ReducerProtocol { struct State { … } enum Action { … } @Dependency(\.numberFact) var numberFact … }
그럼 이렇게 필요한 Core Feature에서 @Dependency 프로퍼티 래퍼 타입으로 numberFact를 가져옵니다.
@main struct MyApp: App { var body: some Scene { FeatureView( store: Store( initialState: Feature.State(), reducer: Feature() ) ) } }
그럼 이렇게 실제로 App단에서부터 의존성을 주입받아 부여하지 않아도 되는것이죠.
let store = TestStore( initialState: Feature.State(), reducer: Feature() ) { $0.numberFact.fetch = { "\($0) is a good number Brent" } }
만약 테스트 환경을 구축한다면 TestStore를 만들때 live하지 않은 fetch 구현을 정의해주면 됩니다.
즉 ReducerProtocol을 사용하면서 장점으로 느껴졌던 큰 부분이 의존성이 편리해졌다는것 같아요⭐️
이전에는 App 상위부터 쭈욱 받아오며 의존성 주입을 해주니 까먹을때도 있고 추가되면 많은 뎁스의 코드 수정이 이뤄졌어야하는데 이제는 그런 작업이 불필요하죠👍
마무리
이렇게 아주아주아주 간단하게 ReducerProtocol의 리드미 정도로만 어떤게 나온건지 알아보긴만 했어요.
아직 깊이있고 실전 응용으로 하기에는 학습이 미생이라!
이제 최신버전을 기준으로 조금씩 블로깅 해나가겠습니다🙌
[참고 자료]
https://www.pointfree.co/blog/posts/81-announcing-the-reducer-protocol
https://github.com/pointfreeco/swift-composable-architecture/tree/0.50.1
'TCA' 카테고리의 다른 글
TCA 1.0 - TCA의 기본 개념 (1) (ch.02) (96) 2024.02.05 TCA 1.0 - Hello, TCA (ch.01) (114) 2024.02.01 TCA - Testing Effects (feat. unimplemented) (0) 2022.10.31 TCA - fireAndForget (0) 2022.10.27 TCA - concatenate & merge (여러 Effect를 단일 Effect로 만들기) (2) 2022.10.25