SwiftUI - 마음으로 이해하는 TCA

mgdgc·2023년 10월 9일
1
post-thumbnail

TCA란 무엇인가?

TCA는 일관되고 이해하기 쉬운 방법으로 애플리케이션을 작성하기 위한 라이브러리이다.
TCA는 UIKitSwiftUI에서 모두 사용할 수 있고, iOS, watchOS, macOS, 그리고 tvOS에서 사용할 수 있다.
(아마 visionOS도 되겠지...?)

TCA는 비교적 최근에 1.0.0 버전이 나왔고, 현재 최신 버전은 1.2.0인데,
이번 사이드 프로젝트에 TCA를 사용해보면 재미있는 경험이 되지 않을까 싶어 '머리가 아닌 마음으로 이해하기' 위한 여정을 정리해보려고 한다.

TCA를 품을 준비

TCA는 라이브러리이므로 우선 프로젝트에 추가하여야 한다.
Add Package Dependencies... 메뉴 선택
Swift Package Manager
Swift Package Manager에 다음의 URL를 넣고 설치한다.

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

예제 앱 만들기

TCA를 마음으로 이해하기 위해서는 이를 활용한 간단한 앱을 만들어보는 편이 쉽고 빠를 것 같았다.
+, - 버튼을 누를 때마다 화면의 숫자가 바뀌는 간단한 앱
+, - 버튼을 누를 때마다 화면의 숫자가 바뀌는 간단한 앱이다.

Feature 작성

Feature는 Reducer 프로토콜을 구현하는 구조체로, 기능에서 사용할 값, 상태, 액션, 그리고 reduce 함수로 구성되어 있다.

  • State: 로직을 수행하고 UI를 렌더링하는 데 필요한 데이터를 포함
  • Action: 작업, 알림, 이벤트 등 기능에서 발생할 수 있는 모든 작업을 나열
  • Reducer: 작업, 알림, 이벤트 등 기능을 수행하고, State를 변경하거나 API를 호출하는 작업을 수행
  • Store: 실제로 Feature를 구동하는 런타임

우선 빈 Feature를 작성해본다.

import ComposableArchitecture

struct Feature: Reducer {
    struct State: Equatable {
    }
    
    enum Action: Equatable {
    }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
    }

	// Parent로서 동작할 경우
    /*
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
                
            }
        }
    }
    */
}

Feature는 Parent로서 Child를 가질 수 있는데, 이 경우엔 reduce 함수가 아닌 body를 구현해주어야 한다.

ContentView에서 Feature 사용

작성한 Feature를 실제로 동작시키기 위해, Store로 사용해야 한다.

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    let store: StoreOf<Feature>
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            // ... UI
        }
    }
}

ContentView에 let store: StoreOf<Feature>를 선언해주고, View에서 WithViewStore(_ store:observe:content:)를 작성해 ViewStore를 사용할 수 있도록 한다.

이제 ContentView가 store 프로퍼티를 갖게 되었으므로, ContentView를 초기화할 때에는 다음과 같이 작성한다.

ContentView(store: Store(initialState: Feature.State(), reducer: {
    Feature()
}))

Store를 초기화할 때 initialStateFeature.State()를 넣어주고, reducer에 reduce가 구현된 Feature()를 넣어주면 된다.

만약 Feature의 State가 초기에 가져야 하는 값이 있다면 이 곳에서 하면 된다.

따라서 WindowGroup은 이렇게 된다.

import SwiftUI
import ComposableArchitecture

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(store: Store(initialState: Feature.State(), reducer: {
                Feature()
            }))
        }
    }
}

View 그리기

이제 버튼을 클릭하면 화면의 숫자가 상승하는 앱을 예제로 만들어본다.

우선 화면을 그린다.

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    let store: StoreOf<Feature>
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text("0")
                    .font(.title)
                
                HStack {
                    Button("-") {
                        // - Action
                    }
                    Button("+") {
                        // + Action
                    }
                }
                .buttonStyle(.borderedProminent)
            }
        }
    }
}

숫자를 표시할 Text, 그리고 각각 더하기와 빼기 작업을 할 Button 두 개를 구현한다.

TCA에서 View는 오직 UI를 그리는 역할만을 하기 때문에 그 어떠한 상태 변수, Action 등을 선언하지 않는다.

이는 모두 Feature에서 구현된다.

State

Feature.State 에는 숫자를 저장할 변수를 선언한다.

import Foundation
import ComposableArchitecture

struct Feature: Reducer {
    struct State: Equatable {
        var count: Int = 0
    }
    //...
}

이 앱은 화면에 숫자를 표시할 count: Int 변수를 하나 선언해주었다.

Action

Feature.Action 에는 버튼이 수행할 Action을 정의해놓는다.

import Foundation
import ComposableArchitecture

struct Feature: Reducer {
    struct State: Equatable { ... }
    
    enum Action: Equatable {
        case plusButtonTap
        case minusButtonTap
    }
    
    //...
}

Action은 열거형으로, 보낼 수 있는 Action을 나열한 것이다.
실제 이 Action과 State가 연결되는 것은 reduce 함수이다.

reduce 함수

reduce 함수를 잠깐 보면

func reduce(into state: inout State, action: Action) -> Effect<Action> { ... }

State의 변수를 변경할 수 있도록 inout State 와 어떤 액션이 호출되었는지 알 수 있도록 Action 이 전달된다.

여기서 Actionswitch 의 판별 변수로 사용하면 각 Action의 case에 따라 작업을 수행할 수 있다.

func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .plusButtonTap:
		// 더하기 작업 구현
        return .none
        
    case .minusButtonTap:
		// 빼기 작업 구현
        return .none
    }
}

State의 값을 변경하기 위해서는 인자로 전달된 state: inout State 를 사용하여 조작한다.

func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .plusButtonTap:
		// 더하기 작업 구현
        state.count += 1
        return .none
        
    case .minusButtonTap:
		// 빼기 작업 구현
        state.count -= 1
        return .none
    }
}

Feature의 전체 코드

import Foundation
import ComposableArchitecture

struct Feature: Reducer {
    
    struct State: Equatable {
        var count: Int = 0
    }
    
    enum Action: Equatable {
        case plusButtonTap
        case minusButtonTap
    }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .plusButtonTap:
            state.count += 1
            return .none
            
        case .minusButtonTap:
            state.count -= 1
            return .none
        }
    }
}

View에 적용

Feature의 구현이 완료되었으면, 이제 View에서 값을 참조하거나 변경하는 작업을 어떻게 할 수 있는지 알아보자

값을 State에서 참조하기

아까 변경한 ContentView를 다시 보자

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    let store: StoreOf<Feature>
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            // ... UI
        }
    }
}

이 코드에서 WithViewStore의 content 클로저를 보면 viewStore 라는 argument 하나가 반환되는 것을 볼 수 있는데,
viewStore를 가지고 State의 값을 참조할 수 있다.

Text("\(viewStore.count)")
	.font(.title)

Text가 viewStore의 count를 보여주도록 했다.
그럼 State의 count 값이 변경되면 상태 변화를 감지하여 다시 랜더링 될 것이다.

다만 이 때 viewStore의 프로퍼티들은 모두 let 으로, 값의 직접적인 변경은 불가능하다.

값을 변경하기 위해서는 꼭 Action을 통해야 한다.

TCA에서의 흐름은 무조건 단방향이라는 것을 기억하자.

Action 보내기

Action을 보내는 것도 State의 값을 참조하는 것 만큼 쉽다.

viewStore의 send(_ action: Feature.Action) 함수를 사용하면 Action을 보낼 수 있다.

Button("-") {
		// - Action
		viewStore.send(.minusButtonTap)
}
Button("+") {
		// + Action
		viewStore.send(.plusButtonTap)
}

이렇게 버튼이 눌렸을 때 viewStore에 아까 선언했던 .plusButtonTap Action을 send() 함수를 통해 보내면
reduce 함수에서 구현했던 .plusButtonTap 로직을 수행할 수 있는 것이다.

ContentView 전체 코드

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    let store: StoreOf<Feature>
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text("\(viewStore.count)")
                    .font(.title)
                
                HStack {
                    Button("-") {
                        // - Action
                        viewStore.send(.minusButtonTap)
                    }
                    Button("+") {
                        // + Action
                        viewStore.send(.plusButtonTap)
                    }
                }
                .buttonStyle(.borderedProminent)
            }
        }
    }
}

#Preview {
    ContentView(store: Store(initialState: Feature.State(), reducer: {
        Feature()
    }))
}

0개의 댓글