컴바인을 사용하며 진행하던 프로젝트를 SwiftUI로 바꿔보면서 다른 아키텍처를 사용해보려고 했고, 선택한 아키텍처는 TCA입니다. 어떤 형태인지 살펴보고자 합니다.
https://github.com/pointfreeco/swift-composable-architecture
위 링크에서 기본적인 사용에 있어 도메인을 모델링하기 위해 정의해야 하는 몇 가지 타입과 값을 설명합니다. 아래처럼 번역해봤습니다.
MusicVideo 모델이 존재할 때 네트워크 요청을 통해 데이터를 가져오면 그 데이터 자체가 되는 속성이자 상태입니다.Button의 액션을 떠올릴 수 있습니다.Effect 값을 반환하면서 완료되는 API 요청이 대표적입니다. 어떠한 액션이 있을 때 이에 반응해서 다음 상태로 넘어가는 과정을 나타냅니다.reducer와 effect를 실행할 수 있도록 합니다. 또한, 여기에서 상태 변화를 감지하고 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
    }
}
State와 Action의 정의가 필요한 것을 추측해볼 수 있습니다. 실제로 아래처럼 작성해보면 MusiocVideos가 ReducerProtocol을 따르지 않는다고 합니다.
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에 두고, 구현한 Action을 Reduce 부분에서 각 동작에 따라 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가 없다는 점입니다. ReducerProtocol은 ReducerProtocol을 따르는 객체에서 body가 없는 경우 func reduce(into state: inout State, action: Action) -> EffectTask<Action> 메소드를 정의하면 ReducerProtocol을 따르도록 구현되어 있습니다.