새벽의 기록

[iOS] TCA Zero-to-Hero #1 - State, Action, Reducer의 단방향 데이터 흐름 이해 본문

[iOS]

[iOS] TCA Zero-to-Hero #1 - State, Action, Reducer의 단방향 데이터 흐름 이해

OneTen 2025. 12. 20. 23:36

UIKit만을 사용하던 과거와는 달리, 최근 흐름에 속하는 SwiftUI에서의 데이터 바인딩은 굳이 ViewModel을 두지 않더라도 View 자체에서 프로퍼티 래퍼를 사용하는 방식으로 구현할 수 있다.

 

다만 "비즈니스 로직까지 View에서 처리해야 하는가?"라는 질문에 대한 답이 "No"라면, 이에 대한 해결책으로 등장하는 흐름이 "단방향 데이터 흐름 (Unidirectional Data Flow)"이다.

 

여기서 데이터 바인딩이 아니라 비즈니스 로직을 View로부터 보다 효율적으로 분리하는 과정에서 떠올리게 된 단방향 데이터 흐름 구조가 Flux였고, TCA는 이 단방향 데이터 흐름이라는 Flux 컨셉을 받아 발전시킨 아키텍처 라이브러리다.

 

✔️선언형 프로그래밍 방식 (SwiftUI)에서 적합한 데이터 흐름 구조를 떠올리다가 등장한 것이 바로 단방향 데이터 흐름 (Unidirectional Data Flow)이다.

✔️ 단뱡향 데이터 흐름을 제안한 대표 아키텍처가 Flux이고, 이 컨셉을 발전시킨 것이 TCA (The Composable Architecure)이다.


State, Action, Reducer의 단방향 데이터 흐름 이해

  • 상태(State): 말 그대로 애플리케이션의 상태 (State)를 나타내는 타입. 비즈니스 로직을 수행하거나 UI를 그릴 때 필요한 데이터에 대한 설명을 나타내는 타입이다.
  • 액션(Action): 애플리케이션에서 발생하는 모든 이벤트를 정의한 열거형 (enum). 사용자가 하는 행동이나 노티피케이션 등 어플리케이션에서 생길 수 있는 모든 행동을 나타내는 타입이다.
  • 리듀서(Reducer): 어떤 행동(Action)이 주어졌을 때 지금 상태(State)를 다음 상태로 변화시키는 방법을 가지고 있는 함수이다. 또한 Reducer는 실행할 수 있는 Effect(예시: API 리퀘스트)를 반환해야 하며, 보통은 Effect 값을 반환한다.
  • 스토어(Store): 실제로 기능이 작동하는 공간. State, Action, Reducer를 실제 관리하고, 개발자가 정의한 애플리케이션의 모든 기능이 동작하는 핵심 공간.
  • Environment: 애플리케이션 외부로부터 데이터를 받아올 때, 애플리케이션이 갖게 되는 의존성을 갖고 있는 타입.

 

 

시작은 가장 왼쪽에 있는 View에서부터 출발한다.

View(= UI)에서는 사용자가 버튼을 클릭하거나, 텍스트를 입력하는 등의 이벤트, 즉 Action을 발생시킬 수 있다.

 

View는 Store를 구독하고 있기에, View에서 발생한 Action은 Store로 즉시 전달된다.

 

Store는 전달받은 Action을 처리하기 위해 Reducer (함수)를 호출한다.

(Action이 Store가 아니라 Reducer로 바로 이동하는 흐름으로 봐도 무방하지만, 정확하게는 Store 내부에서 Reducer 함수 부분을 호출하는 쪽으로 보는게 더 정확할 것 같아 구분)

 

Reducer는 전달받은 Action의 종류에 따라 State 값을 업데이트하거나 / Effect를 반환하도록 결과를 반환한다. 단순 View의 상태를 변화시키는 작업은 바로 State로, 앱 외부와 관련된 복잡한 비동기 처리 작업은 Effect로 Reducer에서 결과가 나뉘어진다고 생각하면 된다.

 

[State 값을 업데이트 하는 경우]

단순히 View로 보여지는 카운트 값을 증가시키는 경우에는 Reducer에서 State 값만 단순 업데이트하면 된다. (state.count += 1)

이렇게 업데이트된 State는 SwiftUI의 View와 바인딩되어 새롭게 UI를 그리는 데 반영된다.

 

[Effect로 넘어가는 경우]

대표적으로 비동기 네트워크 작업을 한다고 했을 때, State가 아닌 Effect 타입으로 결과를 반환하게 될 것이다.

 

비동기 네트워크 작업의 결과가 원하는 효과 (Good Effect)를 가져올 수도 있지만, 그렇지 않을 수도 있다.

이때 네트워크 작업 (= 비동기 작업)의 결과가 원하지 않은 효과를 가져왔을 때, 우리는 이것을 사이드 이펙트 (Side Effect)라고 부른다.

 

아무튼 원하는 효과가 나타났든, 원하지 않은 사이드 이펙트가 발생을 했든 간에 이 모든 Effect는 다시 Action으로 취급되어 Reducer 함수에 전달된다.

 

TCA가 사이드 이펙트 (Side Effect)를 테스트하고, 처리하기 용이하다고 하는 이유가 여기에 있다.

원하는 효과가 나타났으면, 그에 따라 적절한 State 값을 업데이트해 View에 다시 전달해주면 되고,

만약 원하지 않은 사이드 이펙트가 발생했다면, <다시 네트워크 요청을 하던지 / 알럿을 띄워 사용자에게 안내를 해주던지>와 같은 예외 처리를 단방향 흐름으로 해줄 수 있다.

 

import SwiftUI
import ComposableArchitecture

@Reducer
struct CounterFeature {
    @ObservableState
    struct State: Equatable {
        var count = 0
    }
    
    enum Action {
        case incrementButtonTapped
        case decrementButtonTapped
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .incrementButtonTapped:
                state.count += 1
                return .none
                
            case .decrementButtonTapped:
                state.count -= 1
                return .none
            }
        }
    }
}

struct CounterView: View {
    let store: StoreOf<CounterFeature>
    
    var body: some View {
        VStack {
            Text("\(store.count)")
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            
            HStack {
                Button("-") {
                    store.send(.decrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
                
                Button("+") {
                    store.send(.incrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            }
        }
    }
}

#Preview {
    CounterView(
        store: Store(initialState: CounterFeature.State()) {
            CounterFeature()
                ._printChanges()
        }
    )
}

Comments