ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TCA Tutorial 1, 2 - 상태관리와 비동기 처리 TCA
    swift 2025. 5. 14. 15:33

    앱 개발의 사이즈가 커질때마다 상태 관리의 중요성을 실감하고 있습니다. 간단한 UI 상태만 다룰 때는 문제가 없지만, 입력 -> API 호출 -> 화면 전환 등 다양한 이벤트가 얽히면서 복잡도가 높아지기 시작하면 이게 어떤 코드였지 ? 하는 생각이 많아지는 것 같습니다. 이런 상황이 쌓이면 유지보수도 힘들어지고 앱의 데이터 흐름을 파악하기 어려워지죠.

    이런 문제를 해결하기 위해 UIKit 에서는 RxSwift를 SwiftUI 에서는 TCA가 자주 활용됩니다. 물론 채용 공고에서도 많은 비중을 차지하고 있죠. 이번 TCA 글에서는 TCA의 기본 튜토리얼을 따르며 왜 이렇게 최근 앱 개발환경에서 많이 사용하는지 개인적인 시선으로 풀어보려 합니다.

     

    단방향 데이터 흐름

     

    TCA는 단방향 데이터 흐름을 철저하게 지킵니다. 사용자 입력 → 액션 전송 → 상태 변경 → 뷰 업데이트로 이어지는 이 흐름은 Redux, Elm 등에서도 강조되는 아키텍처 철학이죠. 그리고 SwiftUI 환경 에서는 이 구조를 가장 잘 실현한 것이 바로 TCA입니다.

     

    단방향 데이터 흐름의 핵심은 예측 가능성입니다.

     

     

    예를 들어, 어떤 액션이 발생했을 때 상태가 왜 그렇게 바뀌었는지를 항상 추적 가능하다는 것이죠. 앱이 복잡해질수록 단방향 데이터 흐름은 진가를 발휘합니다!

    특징 RxSwift TCA
    View -> Action 사용자 이벤트 감지 Action 으로 추상화
    Action -> State Observable로 반응 Reducer를 통한 변경
    State -> View Bind로 반영 SwiftUI 에서 자동 반영
    비동기 처리 Observable chaining Effect 처리

     


    SwiftUI의 MVVM과 TCA의 차이

     

    MVVM (class 중심)

    장점

    항목 설명
    관찰 공유가 쉬움 ObservableObject는 여러 뷰에서 공유될 수 있어 전역 상태처럼 쓰기 편함
    수정이 자유로움 상태를 참조로 넘기면 어디서든 값을 수정할 수 있음
    SwiftUI와 연동이 간단 @ObservedObject, @StateObject 등으로 쉽게 바인딩 가능
    초기 진입 장벽이 낮음 익숙하게 빠른 뷰모델 구성 가능

     

     

    ⚠️ 단점

     

    상태 추적 어려움 상태 변경이 어디서 일어났는지 추적이 어려움
    테스트 불리 Side effect와 state mutation이 섞이기 쉬워 테스트가 까다로움
    멀티 쓰레딩에 취약 공유 상태가 충돌이나 race condition 을 만들 가능성 있음
    예측 불가한 상태 흐름 로직이 분산되기 쉽고, 직접 print 찍어봐야 알 수 있음

    TCA(struct 중심)

     

     장점

    항목 설명
    상태 불변성 보장 inout 기반의 reducer 구조로 외부에서 직접 변경 불가 → 안정성↑
    상태 변경 추적이 명확 모든 상태 변경은 Action → Reducer를 통해만 발생
    테스트 용이성 순수 함수 기반이라 단위 테스트 및 시나리오 테스트 매우 쉬움
    사이드이펙트 분리 Effect로 비동기 처리를 분리 → 예측 가능하고 안정적
    디버깅 도구 내장 .printChanges()로 상태 변화 실시간 추적 가능

     

     

    ⚠️ 단점

     

    항목 설명
    간단한 앱엔 과함 작은 기능에도 Reducer로 감싸야 하므로 코드가 늘어남
    SwiftUI와의 바인딩에 익숙해져야 함 ViewStore, WithViewStore 등의 개념 필요

    Tutorial 1 Code

    //  CounterFeature.swift
    
    import ComposableArchitecture
    
    @Reducer
    struct CounterFeature {
        @ObservableState
        struct State {
            var count = 0
        }
    
        enum Action {
            case decrementButtonTapped
            case incrementButtonTapped
        }
    
        var body: some ReducerOf<Self> {
            Reduce { state, action in
                switch action {
                case .decrementButtonTapped:
                    state.count -= 1
                    return .none
                case .incrementButtonTapped:
                    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()
        }
      )
    }

    MVVM보다 과한가? → 작은 앱이라면, 그렇다

    솔직히 말해서, 단순한 카운터 앱에까지 TCA를 적용하는 건 다소 과하다는 생각도 들었습니다. MVVM이 더 빠르고 직관적으로 구현할 수 있습니다. 하지만 중장기적으로 유지보수가 필요한 앱이라면 TCA의 구조적인 접근이 훨씬 더 안정적일것이라는 생각이 들었습니다.

     

    그럼 전역 상태는 어떻게?

    MVVM에서는 @EnvironmentObject로 손쉽게 상태를 공유할 수 있었는데, TCA는 그렇게 간단하지 않습니다. 하지만 @Dependency 기반의 의존성 주입을 통해 전역 상태 또는 외부 리소스를 안전하게 주입하고 테스트할 수 있는 구조를 제공하고 있었습니다. 이 부분은 이후 튜토리얼에서 더 깊이 다루게 되니 기대되는 포인트였습니다.


    비동기 처리와 side effect 분리

    TCA 튜토리얼 2편에서는 비동기 처리와 사이드 이펙트 관리가 핵심이었습니다. 이전 튜토리얼에서는 단순한 상태 변경만 다뤘다면, 이제는 실제 앱의 복잡도를 좌우하는 요소들을 어떻게 다룰지에 대한 내용이 중심입니다.

     

     

    Reducer 안에서 비동기 작업을 직접 수행할 수 없다.

     

    리듀서 안에서 아래처럼 URLSession 을 통해 데이터를 받아오고 싶어도

    let (data, _) = try await URLSession.shared.data(from: URL(string: "...")!)

    리듀서 안에서 비동기 처리를 하려고 하면 에러가 납니다.

    결론부터 말하자면, 리듀서는 반드시 순수 함수여야 하기 때문입니다.

     

     

    순수 함수의 조건 (pure function)

    조건 설명
    외부 상태에 의존하지 않는다. Date() , UUID() 등
    외부 상태를 변경하지 않는다. UserDefaults, Disk , Network 등
    Side effect가 없어야 한다. API 요청, 타이머 등도 포함됨

     

    async/await는 네트워크, 디스크 접근 등 외부 시스템과의 상호작용이 포함되어 있고 이는 side effect로 간주되기 때문에, TCA에서는 이런 코드는 Effect로 분리해야 합니다.


    비동기 클로저 내부에서는 상태를 직접 변경할 수 없다.

    return .run { [count = state.count] send in
              let (data, _) = try await URLSession.shared
                .data(from: URL(string: "http://numbersapi.com/\(count)")!)
              let fact = String(decoding: data, as: UTF8.self)
              state.fact = fact
              // 🛑 Mutable capture of 'inout' parameter 'state' is not allowed in
              //    concurrently-executing code
            }

     

    다음과 같이 Effect 를 통해서 받아온 값을 그대로 State 에 업데이트를 하려고 할때 에러가 나오는데요. Swift의 .run 클로저는 @Sendable 해야하며, 이는 concurrency-safe 해야한다는 의미입니다. 즉 inout state 를 캡처해서 쓰는 건 금지됩니다.

    그래서 어떻게 해야 하나?

     

    1. 비동기 결과를 담을 새로운 Action을 정의 (예: .factResponse(String))
    2. Effect 안에서 send(.factResponse(value))로 리듀서에 다시 전달
    3. 그 액션을 받아서 Reducer 내부에서 안전하게 state를 업데이트
    return .run { [count = state.count] send in
                        let (data, _) = try await URLSession.shared
                            .data(from: URL(string: "http://numbersapi.com/\(count)")!)
                        let fact = String(decoding: data, as: UTF8.self)
                        await send(.factResponse(fact))
                    }
    
     case let .factResponse(fact):
                    state.fact = fact
                    state.isLoading = false
                    return .none

    -> 상태는 오직 리듀서 내부에서만 변경되고, Effect는 결과를 액션으로 되돌리는 역할만 함.

     

    ✅ 이 구조의 장점은?

    SideEffect는 액션으로 되돌아오기 전까지는 상태를 못 건드린다.

     

    처음에는 답답해보였지만, 이 덕분에 로직 흐름이 예측 가능하고, 어디서 상태가 바뀌는지 추적 가능하며, 테스트가 엄청 쉬워진다는 걸 알게 됐습니다.


    타이머

    타이머 역시 비동기 처리로 동작하는 대표적인 예시입니다. 비동기 처리는 Reducer 안에서 직접 행할 수 없기 때문에 Effect로 처리해야합니다.

    .run { send in
        while true {
            try await Task.sleep(for: .seconds(1))
            await send(.timerTick)
        }
    }

    while true 루프와 sleep을 이용해 1초마다 .timerTick을 전송합니다. 그런데, 이런 무한 루프 형태의 Effect는 중단할 방법이 있어야 합니다. 그래서 등장하는 개념이 바로 .cancellable(id:)입니다.

    .cancellable(id: CancelID.timer)

    이렇게 Effect에 ID를 부여하면, 특정 조건에서 .cancel(id: ) 로 중단할 수 있습니다. cancel 을 보면서 생각나는 것은 Combine 의 cancel인데요. 이 두가지의 차이점을 비교해봤습니다.

     

    개념 Combine TCA
    구독 종료 AnyCancellable.cancel() Effect.cancel(id:)
    메모리 해제 자동 참조 해제 ID 기반 명시적 취소
    실행 중지 퍼블리셔 체인 취소 비동기 작업 중단

     

     

    둘 다 작업을 멈추는 목적은 같지만, Combine은 구독/퍼블리셔 중심이고,TCA는 Effect 실행 흐름에 초점이 맞춰져 있습니다. 즉, Combine 은 흐름 제어중심, TCA는 상태와 액션 기반의 제어라고 표현할 수 있습니다.

     

    전체 코드

    import ComposableArchitecture
    import Foundation
    
    @Reducer
    struct CounterFeature {
        @ObservableState
        struct State {
            var count = 0
            var fact: String?
            var isLoading = false
            var isTimerRunning: Bool = false
        }
    
        enum Action {
            case decrementButtonTapped
            case factButtonTapped
            case factResponse(String)
            case incrementButtonTapped
            case timerTick
            case toggleTimerButtonTapped
        }
    
        enum CancelID { case timer }
    
        var body: some ReducerOf<Self> {
            Reduce { state, action in
                switch action {
                case .decrementButtonTapped:
                    state.count -= 1
                    state.fact = nil
                    return .none
                case .factButtonTapped:
                    state.fact = nil
                    state.isLoading = true
                    return .run { [count = state.count] send in
                        let (data, _) = try await URLSession.shared
                            .data(from: URL(string: "http://numbersapi.com/\(count)")!)
                        let fact = String(decoding: data, as: UTF8.self)
                        await send(.factResponse(fact))
                    }
                case let .factResponse(fact):
                    state.fact = fact
                    state.isLoading = false
                    return .none
                case .incrementButtonTapped:
                    state.count += 1
                    state.fact = nil
                    return .none
                case .toggleTimerButtonTapped:
                    state.isTimerRunning.toggle()
                    if state.isTimerRunning {
                        return .run { send in
                            while true {
                                try await Task.sleep(for: .seconds(1))
                                await send(.timerTick)
                            }
                        }
                        .cancellable(id: CancelID.timer)
                    } else {
                        return .cancel(id: CancelID.timer)
                    }
                case .timerTick:
                    state.count += 1
                    state.fact = nil
                    return .none
                }
            }
        }
    }
    
    @Reducer
    struct CounterFeature {
        @ObservableState
        struct State {
            var count = 0
            var fact: String?
            var isLoading = false
            var isTimerRunning: Bool = false
        }
    
        enum Action {
            case decrementButtonTapped
            case factButtonTapped
            case factResponse(String)
            case incrementButtonTapped
            case timerTick
            case toggleTimerButtonTapped
        }
    
        enum CancelID { case timer }
    
        var body: some ReducerOf<Self> {
            Reduce { state, action in
                switch action {
                case .decrementButtonTapped:
                    state.count -= 1
                    state.fact = nil
                    return .none
                case .factButtonTapped:
                    state.fact = nil
                    state.isLoading = true
                    return .run { [count = state.count] send in
                        let (data, _) = try await URLSession.shared
                            .data(from: URL(string: "http://numbersapi.com/\(count)")!)
                        let fact = String(decoding: data, as: UTF8.self)
                        await send(.factResponse(fact))
                    }
                case let .factResponse(fact):
                    state.fact = fact
                    state.isLoading = false
                    return .none
                case .incrementButtonTapped:
                    state.count += 1
                    state.fact = nil
                    return .none
                case .toggleTimerButtonTapped:
                    state.isTimerRunning.toggle()
                    if state.isTimerRunning {
                        return .run { send in
                            while true {
                                try await Task.sleep(for: .seconds(1))
                                await send(.timerTick)
                            }
                        }
                        .cancellable(id: CancelID.timer)
                    } else {
                        return .cancel(id: CancelID.timer)
                    }
                case .timerTick:
                    state.count += 1
                    state.fact = nil
                    return .none
                }
            }
        }
    }

     


    정리 

    1.  구조체 기반 State 인 TCA 

    1. 불변성과 예측 가능성 확보
      • 구조체는 값 타입이기 때문에 변경시 복사된다. -> 상태 변경은 명확하게 추적이 가능하다. 
      • Reducer 안에서만 inout 으로 상태를 변경하고, 그 외에는 변경할 수 없음 -> 이 상태가 왜 바뀌었지 ? 설명 가능 
    2. 멀티 스레딩 안정성 
      • MVVM 의 ObservableObject 는 여러 뷰에서 공유되기 때문에 데이터 충돌 가능성이 있다. 
      • 구조체 기반은 값 복사 -> 공유 상태가 없으니 충돌도 없음 
    3. 테스트 용이성 
      • 구조체는 스냅샷처럼 상태를 보존하거나 비교하는데 유리하다. 
      • TCA의 TestStore 는 매 테스트마다 상태를 지정하고, 상태 변화를 검증함 
    4. SwiftUI 와 잘 어울리는 설계 
      • SwiftUI는 상태 변화에 따른 View 갱신을 기본 철학으로 삼는다. 
      • 이 변화는 값 타입 변화에 따라 자동으로 발생하며, TCA는 그 흐름에 자연스럽게 연결된다. 

     

     

    2. 책임 분리, MVVM으로도 가능한데 왜 굳이?

     

    MVVM도 잘만 설계하면 책임 분리는 분명히 가능합니다. 하지만 여기엔 전제가 있습니다:

     

    “구현자마다 실력이 다르다.”

     

     

    • 뷰모델에 로직이 몰리고, 결국 View와 비즈니스 로직이 뒤섞이는 상황을 경험해보신 분이라면 공감하실 거예요.
    • 규모가 커질수록 “어디까지 뷰 로직이고 어디까지 도메인 로직인가?”가 흐려지기 쉽습니다.

     

    📌 반면 TCA는 이걸 구조적으로 강제합니다.

     

    • State, Action, Reducer, Effect 등 모든 개념이 명확히 나뉘며,
    • 누가 만들어도 같은 방향의 설계를 유지할 수 있어 협업과 유지보수에 강점이 있습니다. 

     

    초기 러닝 커브는 있더라도, 팀 작업에서는 제약이 오히려 자유를 만들어낸다는 걸 체감할 수 있었습니다.

     

    3. Reducer 에서 비동기 처리 X 

    TCA에서는 Reducer 안에서 async/await를 직접 사용할 수 없습니다.

    이건 단순한 제약이 아니라, 구조적 안정성을 위한 설계 원칙입니다.

     

    • 비동기 로직은 모두 Effect로 분리되어야 한다.
    • Reducer는 순수 함수로 유지되어야 한다 → 항상 같은 입력에 같은 결과를 보장
    • 모든 비동기 처리 결과는 Action으로 래핑되어 Reducer로 돌아오고,
    • 상태는 그때서야 변경됩니다.

    이 흐름이 왜 좋을까요?

    1. 상태가 언제, 어디서, 어떻게 바뀌는지 100% 추적 가능
    2. Effect를 Mocking하여 완벽한 테스트 가능
    3. 로직 흐름이 예측 가능하고 단순해서 유지보수와 협업이 수월

     

    처음에는 “너무 제한적인 것 아닌가?” 싶었지만,

    결국에는 개발자가 실수하지 않도록 도와주는 설계라는 걸 체감하게 됩니다.

     


    마무리 하며 

    채용 공고에서 자주 보던 TCA, 그만큼 러닝 커브도 높다는 이야기도 익숙했습니다. 실제로 간단한 앱에서는 “이 정도 구조까지 필요할까?” 싶은 순간도 있었지만, 이번 튜토리얼을 따라가며 느낀 건 앱이 고도화될수록 TCA의 상태 관리 철학이 빛을 발한다는 점.

     

    입력, API, 화면 전환처럼 복잡하게 얽힌 상태 흐름을 명확히 추적할 수 있다는 점은, 유지보수와 디버깅, 그리고 협업까지 고려했을 때 아주 큰 강점이었습니다. 이번 포스팅은 전체 튜토리얼의 25%, 기초에 해당하는 내용을 정리한 것이며, 다음 포스팅에서는 TCA로 구성된 구조 안에서 상태를 어떻게 더 유연하고 효과적으로 다루는지, 그리고 Navigation, Presentation 등의 기능이 어떻게 조합되는지 살펴보려 합니다.

     

     

    [참조]

    https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/meetcomposablearchitecture/

     

     

     

Designed by Tistory.