TCA의 State, Action, Reducer

Horus-iOS·2023년 1월 9일
1

컴바인을 사용하며 진행하던 프로젝트를 SwiftUI로 바꿔보면서 다른 아키텍처를 사용해보려고 했고, 선택한 아키텍처는 TCA입니다. 어떤 형태인지 살펴보고자 합니다.

https://github.com/pointfreeco/swift-composable-architecture

위 링크에서 기본적인 사용에 있어 도메인을 모델링하기 위해 정의해야 하는 몇 가지 타입과 값을 설명합니다. 아래처럼 번역해봤습니다.

  • State: 기능이 로직을 수행하고 UI를 렌더링하기 위해 필요한 데이터를 설명하는 타입입니다. 데이터가 나타날 속성을 떠올려볼 수 있습니다. 예를 들어 MusicVideo 모델이 존재할 때 네트워크 요청을 통해 데이터를 가져오면 그 데이터 자체가 되는 속성이자 상태입니다.
  • Action: 사용자의 동작, 노티피케이션, 이벤트 소스 등 기능에서 발생할 수 있는 동작의 모든 것을 나타내는 타입입니다. 사용자 이벤트로 생각해볼 수 있습니다. Button의 액션을 떠올릴 수 있습니다.
  • Reducer: 주어진 액션에서 앱의 현재 상태가 다음 상태로 어떻게 진행되어야 하는지 설명하는 함수이며, 실행되어야 하는 모든 효과를 반환할 책임도 갖고 있습니다. 예를 들어 Effect 값을 반환하면서 완료되는 API 요청이 대표적입니다. 어떠한 액션이 있을 때 이에 반응해서 다음 상태로 넘어가는 과정을 나타냅니다.
  • Store: 기능을 실제로 동작시키는 런타임입니다. 여기에 사용자의 모든 액션을 보내서 reducereffect를 실행할 수 있도록 합니다. 또한, 여기에서 상태 변화를 감지하고 UI를 업데이트합니다.

한국어 번역 문서가 이미 존재하는데, Environment 한 가지가 추가적으로 설명되어 있습니다. 링크는 인용 아래에 있습니다.

환경(Environment): API 클라이언트나 애널리틱스 클라이언트와 같이 어플리케이션이 필요로 하는 의존성(Dependency)을 가지고 있는 타입입니다.

Reference
https://gist.github.com/pilgwon/ea05e2207ab68bdd1f49dff97b293b17

샘플 앱도 있으며 ToDos 앱을 참고하면서 글을 작성하기로 했습니다. 아래처럼 ReducerProtocol을 따르는 구조체가 보입니다.

struct Todos: ReducerProtocol {
    struct State: Equatable {
    }
    
    enum Action: Equatable {
    }
}

ReducerProtocol가 정의된 파일을 살펴보면 주어진 '주어진 액션으로 앱의 현재 상태가 다음 상태로 어떻게 진행되어야 하는지 설명하는 프로토콜'이라고 합니다. 동시에 스토어에 의해 EffectTask 가 이후 어떻게 실행되어야 하는지 설명하는 프로토콜이라고 하기도 합니다. 간단한 구현도 보여주고 있습니다.

struct Feature: ReducerProtocol {
    struct State {
        var count = 0
    }
    
    enum Action {
        case decrementButtonTapped
        case incrementButtonTapped
    }
}

StateAction의 정의가 필요한 것을 추측해볼 수 있습니다. 실제로 아래처럼 작성해보면 MusiocVideosReducerProtocol을 따르지 않는다고 합니다.

struct MusicVideos: ReducerProtocol {
    
}

ReducerProtocol을 살펴보면 아래처럼 State, Action가 있고, Body라는 것도 있습니다.

public protocol ReducerProtocol<State,Action> {
    /// A type that holds the current state of the reducer.
    associatedtype State

    /// A type that holds all possible actions that cause the ``State`` of the reducer to change
    /// and/or kick off a side ``EffectTask`` that can communicate with the outside world.
    associatedtype Action
    
    associatedtype _Body

    /// A type representing the body of this reducer.
    ///
    /// When you create a custom reducer by implementing the ``body-swift.property-7foai``, Swift
    /// infers this type from the value returned.
    ///
    /// If you create a custom reducer by implementing the ``reduce(into:action:)-8yinq``, Swift
    /// infers this type to be `Never`.
    typealias Body = _Body
}

간단히 State는 현재 상태를 갖고 있고, Action은 가능한 경우의 수 만큼 액션을 갖는다고 합니다. Body 설명에 대한 이해는 아직 부족하지만 더 진행해보기로 했습니다.

이전에 구현하려면 MusicVideos로 돌아와서 아래처럼 작성하면 ReducerProtocol을 따르지 않는다는 메시지는 사라집니다.

struct MusicVideos: ReducerProtocol {
    struct State {
        
    }
    
    enum Action {
        
    }
    
    var body: some ReducerProtocol<State, Action> {
        
    }
}

body 속성의 반환이 없다는 메시지가 나옵니다. 샘플 앱인 ToDo 앱을 따라하면서 다시 아래처럼 작성합니다.

struct MusicVideos: ReducerProtocol {
    struct State {
        
    }
    
    enum Action {
        case showDetail
    }
    
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .showDetail:
                return .none
                
            }
        }
    }
}

Reduce라는 것이 보입니다. 다음과 같이 구조체로 정의되어 있습니다. 한 가지 특징이 더 있다면 위 코드처럼 State가 구현되어 있는 구조체에서 선언하면 Reduce 선언의 클로저 부분 state, action의 타입은 각각 MusicVideos.State, MusicVideos.Action입니다.

public struct Reduce<State, Action>: ReducerProtocol

다시 ToDos 샘플 앱을 살펴보려고 합니다. 코드의 일부를 보려고 합니다. 열거형인 Action에 구현된 동작 각각을 Reduce 블록 아래에서 state를 동작에 맞도록 변경해주고 있습니다. 즉 Reduce를 통해서 Action에 따라 State를 관리합니다.

struct Todos: ReducerProtocol {
    struct State: Equatable {
        var todos: IdentifiedArrayOf<Todo.State> = []
    }
    
    enum Action: Equatable {
        case addTodoButtonTapped
        case delete(IndexSet)
    }
    
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .addTodoButtonTapped:
                state.todos.insert(Todo.State(id: self.uuid()), at: 0)
                return .none
                
            case let .delete(indexSet):
                let filteredTodos = state.filteredTodos
                for index in indexSet {
                    state.todos.remove(id: filteredTodos[index].id)
                }
                return .none
                
            }
        }
    }
}

요약하면 도메인 모델을 State에 두고, 구현한 ActionReduce 부분에서 각 동작에 따라 State를 관리한다고 생각하면 어느 정도 틀을 이해할 수 있습니다.

마지막으로 샘플 앱에서 'Todo' 파일을 살펴보려고 합니다.

struct Todo: ReducerProtocol {
    struct State: Equatable, Identifiable {
        var description = ""
        let id: UUID
        var isComplete = false
    }
    
    enum Action: Equatable {
        case checkBoxToggled
        case textFieldChanged(String)
    }
    
    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        case .checkBoxToggled:
            state.isComplete.toggle()
            return .none
            
        case let .textFieldChanged(description):
            state.description = description
            return .none
        }
    }
}

Todos 파일과 다른 점은 변수로 정의해줬던 body가 없다는 점입니다. ReducerProtocolReducerProtocol을 따르는 객체에서 body가 없는 경우 func reduce(into state: inout State, action: Action) -> EffectTask<Action> 메소드를 정의하면 ReducerProtocol을 따르도록 구현되어 있습니다.

0개의 댓글