본문 바로가기

iOS

TCA - Reducer -> ReducerProtocol

오늘은 작년 말 쯤에 커밋되었던 ReducerProtocol을 적용시켜, SeoulSalam 앱의 deprecated된 코드들을 리팩토링하였다.

 

이전에 TCA에 대한 글을 읽어보셨다면 아시겠지만, 다시 한 번 설명하자면, TCA에는 Reducer 라는 상태(State)를 업데이트 해주고 이펙트(효과)를 내보내는 핵심적인 컴포넌트가 있다. 이는 ReducerProtocol이 Point-Free에서 2022년 8월쯤에 발표되며, deprecated되었다. 물론 Reducer라는 중심적인 개념은 남아있으니, 이것의 형태가 어떻게 변화되었는지에 집중하면 좋을 것 같다.

 

필자는 처음에 Protocol이라고 하기에 그저 하나의 blueprint라고 생각하였으나, 그 이상의 효과를 기대하고 도입한 큰 변화였다.

그 효과는 다음과 같다.

 

  • 기존에 Reducer의 인스턴스를 직접 구성하는 것과 달리 프로토콜에 따르도록 하여, 코드 작성 시에 Reducer의 형태에 대해서 재고하게 한다.

  • 기존의 클로저 형태의 리듀서를 메서드 형태로 하여 컴파일러 성능 향상과 자동완성 제안에 도움이 된다.

  • 전용 Environment 타입을 필요로하지 않고 적합한 타입의 Dependency와 직접 붙여놓기 때문에, 후에 모듈화하게 되더라도 진부하게 반복하여 환경을 초기화하지 않아도 된다.

Dependency에 대한 이야기는 또 그것만으로도 하나의 주제가 되기 때문에 다음에 다루도록하고, 오늘은 SeoulSalam의 일부 코드를 공개하고 리팩토링 과정을 따라가며 ReducerProtocol의 역할에 대해서 알아보자.


우선 기존의 코드이다 :

 

//MARK: - State
struct RestaurantState: Equatable {
    var restaurants: [Restaurant]
    var filteredRestaurants: [Restaurant] {
        if searchText.isEmpty {
            return restaurants
        }
        return restaurants.filter {
            $0.name.contains(searchText) || $0.category.contains(searchText) || $0.address.contains(searchText) || $0.certifiedState.contains(searchText)
        }
    }

    var searchText: String
    var isSearching: Bool

    init() {
        restaurants = []
        searchText = ""
        isSearching = false
    }
}

//MARK: - Action
enum RestaurantAction: Equatable {
    case startObserve
    case changeSearchText(String)
    case resetSearchText
    case updateRestaurants(Result<[Restaurant], FirebaseApiClient.ApiFailure>)
}

//MARK: - Environment
struct RestaurantEnvironment {
    var client: FirebaseApiClient
    var mainQueue: AnySchedulerOf<DispatchQueue>

    init(client: FirebaseApiClient, mainQueue: AnySchedulerOf<DispatchQueue>) {
        self.client = client
        self.mainQueue = mainQueue
    }
}

//MARK: - Reducer
let restaurantReducer = Reducer<RestaurantState, RestaurantAction, RestaurantEnvironment> { state, action, environment in
    switch action {
    case let .changeSearchText(text):
        state.searchText = text
        state.isSearching = !state.searchText.isEmpty
        return .none
    case .resetSearchText:
        state.searchText = ""
        state.isSearching = false
        return .none
    case .startObserve:
        return environment.client
            .updateSnapshot()
            .receive(on: environment.mainQueue)
            .catchToEffect()
            .map(RestaurantAction.updateRestaurants)
    case let .updateRestaurants(.success(restaurants)):
        state.restaurants = restaurants
        return .none
    case let .updateRestaurants(.failure(failure)):
        print("error: \(failure)")
        return .none

    }
}

 

필자가 Point-Free에서 제공하는 공식문서와 예시코드를 001 부터 훑으면서 공부했기에 ReducerProtocol이 나왔는데도 불구하고 deprecated된 코드로 구성하여 작성했던 코드이다.

위의 코드를 살펴보면, State, Action, Environment의 구조체와 열거형이 각각 따로 선언되어있고, reducer에서 클로저의 형태로 각각을 받아서 상태를 업데이트하고 Effect를 방출하고 있는 것을 볼 수 있다. 각각의 역할에 대해서는 TCA란?에서 자세히 다루고 있으니 먼저 이 글을 읽기를 바란다.

 

다음으로 ReducerProtocol을 따른 코드이다 :

 

import ComposableArchitecture
import SwiftUI

struct RestaurantFeature: ReducerProtocol {
    struct State: Equatable {
        var restaurants: [Restaurant]
        var filteredRestaurants: [Restaurant] {
            if searchText.isEmpty {
                return restaurants
            }
            return restaurants.filter {
                $0.name.contains(searchText) || $0.category.contains(searchText) || $0.address.contains(searchText) || $0.certifiedState.contains(searchText)
            }
        }

        var searchText: String
        var isSearching: Bool

        init() {
            restaurants = []
            searchText = ""
            isSearching = false
        }
    }
    
    enum Action: Equatable {
        case startObserve
        case changeSearchText(String)
        case resetSearchText
        case updateRestaurants(Result<[Restaurant], FirebaseApiClient.ApiFailure>)
    }
    
    @Dependency(\.firebaseApi) var firebaseApi
    @Dependency(\.mainQueue) var mainQueue
    
    func reduce(into state: inout State, action: Action) -> EffectPublisher<Action, Never> {
        switch action {
        case let .changeSearchText(text):
            state.searchText = text
            state.isSearching = !state.searchText.isEmpty
            return .none
        case .resetSearchText:
            state.searchText = ""
            state.isSearching = false
            return .none
        case .startObserve:
            return self.firebaseApi
                .updateSnapshot()
                .receive(on: self.mainQueue)
                .catchToEffect()
                .map(Action.updateRestaurants)
        case let .updateRestaurants(.success(restaurants)):
            state.restaurants = restaurants
            return .none
        case let .updateRestaurants(.failure(failure)):
            print("error: \(failure)")
            return .none

        }
    }
}

 

RestaurantFeature이라는 하나의 구조체가 State, Action, Reducer(func reduce)로 구성되어 있으며, Environment가 사라진 것을 볼 수 있고, reduce가 훨씬 더 명시적으로 변한 것을 확인할 수 있다.
우선, State를 받고, 변화시켜 방출 하는 것을 명시적으로 확인 할 수 있었다. 또한 이 코드만으로는 확인할 수 없지만, 의존성(Dependency) 또한 Client에서 직접 주입되어 reduce에서는 바로 받아서 사용하는 것을 볼 수 있다.

 

이외에도, Xcode 상에서 직접 코드를 작성할 때에 ReducerProtocol을 따르도록 제안하기 때문에 Feature의 형태를 의식하면서 아주 간편하게 컴포넌트들을 구성시킬 수 있었다는 장점도 필자에겐 정말 크게 느껴졌다.

 

실제로 이러한 부분들이 컴파일러의 안정성을 향상시킨다고 공식문서에서는 말하고 있었다. 다음 글에서는 Dependency와 Test에 대해서 다루면서 ReducerProtocol에 대해서 더 알아보도록 하겠다.