TCA - ReducerProtocol
안녕하세요. 그린입니다🍏
이번 포스팅에서는 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