← TCA 14일 마스터 1 / 14

Day 1 · 왜 TCA인가

상태 변경 입구를 하나로 좁히고 일어난 일을 데이터로 남기는 TCA가 무엇을 대신 해 주고 무엇을 요구하는지 지도를 그린다.
진행률
0 / 0 완료
SwiftUI 코스에서는 @Observable 모델 하나에 상태를 모으고, 뷰는 그 모델을 읽었다. 화면이 하나일 때는 이 방식이 가장 짧다. 그런데 기능이 늘면 "이 값을 누가 바꿨지?"를 찾으려고 버튼 클로저, .task, 모델 메서드 안의 Task를 전부 뒤지게 된다. TCA는 상태를 바꾸는 입구를 하나로 좁히고, 일어난 일을 전부 데이터(Action)로 남기는 라이브러리다. 오늘은 코드를 많이 쓰기보다 TCA가 무엇을 대신 해 주고 그 대가가 무엇인지 지도를 그린다.

오늘의 목표 · 29분

  • @Observable 모델 코드를 보고 상태 변경 경로·부수효과 위치·테스트·조합의 네 가지 문제를 짚는다.
  • TCA의 5요소(State·Action·Reducer·Store·Effect)를 @Observable 모델의 어느 부분과 대응되는지 표로 옮긴다.
  • 버튼 한 번이 Action → Reducer → State → View → (Effect → Action)으로 도는 단방향 흐름을 단계별로 따라간다.
  • 보일러플레이트·학습 곡선·액션 전송 비용을 알고, "이 프로젝트에 TCA를 쓸까?"를 결정 기준으로 판단한다.

1. @Observable 모델이 커질 때 생기는 문제 🔥 핵심

먼저 SwiftUI 코스에서 배운 방식으로 검색 화면을 만든다. 문제는 코드가 틀려서가 아니라 규칙이 없어서 생긴다. 아래 코드는 전부 컴파일되고 잘 동작한다.

SwiftUI(@Observable) — 흔한 검색 모델
import SwiftUI

@Observable
final class SearchModel {
    var query = ""
    var results: [String] = []
    var isLoading = false

    func search() async {
        isLoading = true
        // 네트워크를 모델 안에서 직접 부른다
        let url = URL(string: "https://example.com/search?q=\(query)")!
        let data = (try? await URLSession.shared.data(from: url).0) ?? Data()
        results = String(decoding: data, as: UTF8.self)
            .split(separator: "\n").map(String.init)
        isLoading = false
    }
}
SwiftUI(@Observable) — 뷰 여기저기서 상태를 바꾼다
import SwiftUI

struct SearchScreen: View {
    @State private var model = SearchModel()

    var body: some View {
        List(model.results, id: \.self) { Text($0) }
            .searchable(text: $model.query)             // ① 바인딩이 바꾼다
            .onChange(of: model.query) {
                if model.query.isEmpty { model.results = [] }   // ② onChange가 바꾼다
            }
            .task { await model.search() }              // ③ 메서드가 바꾼다
            .toolbar {
                Button("지우기") {
                    model.query = ""                    // ④ 버튼 클로저가 바꾼다
                    model.results = []
                }
            }
    }
}

results를 바꾸는 곳이 세 군데(②·③·④), query를 바꾸는 곳이 두 군데(①·④)다. 화면이 10개로 늘면 이런 경로가 수십 개가 된다. 이 방식에서 반복해서 부딪히는 문제는 네 가지다.

문제@Observable에서 흔한 모습TCA의 답
상태 변경 경로 분산뷰 클로저·onChange·모델 메서드·Task가 각자 프로퍼티를 바꾼다상태는 Reducer 안에서만 바뀐다. 뷰는 Action을 보내기만 한다
부수효과 위치모델 메서드 안에 URLSession이 섞인다. 규칙은 팀 컨벤션에 맡긴다Reducer는 부수효과를 Effect 값으로 반환하고, 실행은 Store가 한다
테스트메서드가 실제 네트워크를 부르면 결과를 예측할 수 없다. 주입 방식은 직접 설계TestStore가 Action마다 상태 변화와 이펙트가 돌려준 Action을 전부 단언하게 한다
조합부모 모델이 자식 모델을 들고, 자식 이벤트를 받는 방법(클로저·델리게이트)을 매번 정한다자식의 State·Action을 부모에 넣고 Scope로 붙인다(Day 5)
@Observable — 입구가 여러 개 버튼 클로저 onChange .task 메서드 안 Task SearchModel 프로퍼티 직접 수정 "누가 바꿨나?" → 4곳을 모두 읽어야 한다 TCA — 입구가 하나 버튼 onChange .task 이펙트 결과 Action send Reducer 여기서만 수정 "누가 바꿨나?" → Reducer의 case만 읽는다
TCA도 이벤트 출처가 줄어드는 것은 아니다. 출처가 몇 개든 전부 Action이라는 한 문을 지나야 상태에 닿는다.

테스트 쪽 문제는 코드로 보면 분명하다. 아래 테스트는 컴파일되지만 결과가 네트워크 상태에 따라 달라진다.

SwiftUI(@Observable) 모델 테스트 — 컴파일은 되지만 실제 서버에 의존한다
import Testing

@MainActor
@Test func searchLoadsResults() async {
    let model = SearchModel()
    model.query = "swift"
    await model.search()              // 진짜 URLSession 호출
    #expect(model.results.isEmpty == false)   // 서버·네트워크에 따라 성공/실패
    #expect(model.isLoading == false)  // 중간에 true였는지는 확인할 방법이 없다
}
⚠️ 공정하게 말하면

@Observable로도 네트워크를 프로토콜로 주입하고, 상태 변경을 메서드로만 하도록 팀 규칙을 세우면 이 문제를 풀 수 있다. TCA가 하는 일은 그 규칙을 라이브러리 구조로 강제하고, 테스트·디버깅 도구를 함께 주는 것이다. 규칙을 스스로 설계·유지할지, 라이브러리 규칙을 따를지의 선택이다.

2. TCA의 5요소 지도 🔥 핵심

TCA 문서 Getting started는 기능 하나를 State·Action·Reducer·Store 네 타입으로 모델링하라고 한다. 여기에 Reducer가 돌려주는 Effect를 더하면 5요소가 된다.

📝 TCA 문서 원문

"Reducer: A function that describes how to evolve the current state of the app to the next state given an action. The reducer is also responsible for returning any effects that should be run, such as API requests, which can be done by returning an Effect value." — TCA 문서 · Getting started

풀이: Reducer는 "지금 상태 + 들어온 Action → 다음 상태"를 계산하는 함수다. API 요청 같은 바깥 일은 직접 실행하지 않고 Effect 값으로 돌려준다.

State — 데이터
화면과 로직에 필요한 값. 보통 struct. @ObservableState를 붙인다.
Action — 사건
일어날 수 있는 모든 일. 버튼 탭뿐 아니라 API 응답 같은 "보이지 않는 사건"도 포함. 보통 enum.
Reducer — 규칙
Action이 오면 State를 바꾸고, 실행할 Effect를 돌려준다. @Reducer 타입의 body.
Effect — 바깥 일
네트워크·타이머 같은 비동기 작업. 결과는 다시 Action으로 돌아온다(Day 3).
Store — 런타임
State를 들고, Action을 받아 Reducer를 돌리고, Effect를 실행한다. 뷰가 쥐는 유일한 객체.
View — 그냥 SwiftUI
store.count로 읽고 store.send(...)로 알린다. 뷰 자체는 SwiftUI 그대로다.

문서의 예제를 그대로 옮긴다. 숫자를 올리고 내리고, 버튼을 누르면 그 숫자에 대한 재미있는 사실을 서버에서 받아 온다.

TCA — Getting started의 Feature (State·Action·Reducer·Effect가 한 타입에)
import ComposableArchitecture

@Reducer
struct Feature {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var numberFact: String?
  }

  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)     // 이펙트가 돌려주는 "보이지 않는 사건"
  }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        return .none                    // 할 바깥 일 없음

      case .incrementButtonTapped:
        state.count += 1
        return .none

      case .numberFactButtonTapped:
        return .run { [count = state.count] send in    // 바깥 일을 "값"으로 반환
          let (data, _) = try await URLSession.shared.data(
            from: URL(string: "http://number-trivia.com/\(count)/trivia")!
          )
          await send(.numberFactResponse(String(decoding: data, as: UTF8.self)))
        }

      case let .numberFactResponse(fact):
        state.numberFact = fact
        return .none
      }
    }
  }
}
TCA — 뷰는 Store를 읽고, Action을 보내기만 한다
import ComposableArchitecture
import SwiftUI

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    Form {
      Section {
        Text("\(store.count)")
        Button("Decrement") { store.send(.decrementButtonTapped) }
        Button("Increment") { store.send(.incrementButtonTapped) }
      }
      Section {
        Button("Number fact") { store.send(.numberFactButtonTapped) }
      }
      if let fact = store.numberFact {
        Text(fact)
      }
    }
  }
}
View SwiftUI 그대로 Store (런타임) State 데이터 · struct Reducer 규칙 · 함수 Action 사건 · enum Effect 바깥 일 · 비동기 send 읽기 반환 결과 → 새 Action으로 돌아온다
뷰가 만지는 것은 Store 하나다. Store 안에서 Reducer가 State를 바꾸고, 바깥 일은 Effect로 밖에 나갔다가 Action이 되어 돌아온다.

이 5요소를 SwiftUI 코스에서 쓰던 @Observable 모델에 대응시키면 이렇다. TCA가 새로 만든 개념이 아니라, 모델 안에 섞여 있던 역할을 타입으로 떼어 낸 것에 가깝다.

TCASwiftUI(@Observable)에서 같은 역할달라지는 점
State (struct)모델의 저장 프로퍼티클래스가 아니라 값 타입. 복사·비교(Equatable)가 쉽다
Action (enum)모델 메서드 호출, 뷰 클로저의 직접 대입호출이 아니라 데이터. 기록·가로채기·단언이 가능
Reducer메서드 본문들모든 변경 로직이 switch action 한 곳에
Effect메서드 안의 await, Task { }Reducer가 실행하지 않고 반환. 결과는 Action으로 복귀
Store@State private var model = Model() 인스턴스뷰가 쥐는 객체. 읽기는 자유, 쓰기는 send로만

3. 단방향 흐름 — 버튼 한 번을 끝까지 따라가기

"Number fact" 버튼을 누른 순간부터 사실이 화면에 뜰 때까지를 따라간다. 화살표는 한 방향으로만 돈다. 뷰도 Effect도 State를 직접 바꾸지 않는다.

View Store · Reducer Effect ① send(.numberFactButtonTapped) ② state 그대로, .run 반환 ③ Store가 Effect 실행 ④ await 네트워크 ⑤ send(.numberFactResponse) ⑥ state.numberFact = fact ⑦ numberFact를 읽은 뷰만 다시 그림
State를 바꾸는 순간(⑥)은 Reducer 안에서만 일어난다. Effect는 결과를 Action(⑤)으로 보낼 뿐, State에 손대지 못한다.

이 규칙은 컴파일러가 지킨다. 뷰에서 store.count는 읽을 수만 있다. Store의 상태 접근 subscript에 get만 있기 때문이다(소스 · Store+Observation.swift).

❌ 컴파일 에러 — 뷰에서 Store 상태를 직접 바꿀 수 없다
struct BadFeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    Button("+") {
      store.count += 1     // error: left side of mutating operator isn't mutable
    }
  }
}
SwiftUI(@Observable)는 허용 / TCA는 Action으로만
import SwiftUI

@Observable final class CounterModel {
  var count = 0
}

struct ObservableCounterButton: View {
  let model: CounterModel
  var body: some View {
    Button("+") { model.count += 1 }      // 어디서든 대입 가능
  }
}

struct TCACounterButton: View {
  let store: StoreOf<Feature>
  var body: some View {
    Button("+") { store.send(.incrementButtonTapped) }   // "무슨 일이 있었나"만 알린다
  }
}

Action이 데이터라서 공짜로 얻는 것

FAQ는 "Action enum을 따로 유지하는 건 낭비 아니냐"는 질문에 세 가지로 답한다(TCA 문서 · FAQ).

TCA — 앱 진입점에서 _printChanges 켜기 (Store는 static let으로 한 번만)
import ComposableArchitecture
import SwiftUI

@main
struct NumberFactApp: App {
  static let store = Store(initialState: Feature.State()) {
    Feature()
      ._printChanges()        // DEBUG 빌드에서 Action과 State diff 출력
  }

  var body: some Scene {
    WindowGroup {
      FeatureView(store: Self.store)
    }
  }
}
TCA — 맛보기: TestStore로 사용자 흐름을 단언 (Day 8에서 본격적으로)
import ComposableArchitecture
import Testing

@MainActor
@Test func incrementThenDecrement() async {
  let store = TestStore(initialState: Feature.State()) {
    Feature()
  }
  await store.send(.incrementButtonTapped) {
    $0.count = 1          // "이렇게 바뀌어야 한다"를 적는다. 틀리면 테스트 실패
  }
  await store.send(.decrementButtonTapped) {
    $0.count = 0
  }
}

4. 대가 — 보일러플레이트·학습 곡선·성능 주의

같은 카운터를 두 방식으로 쓰면 줄 수 차이가 바로 보인다.

SwiftUI(@Observable) 카운터 — 모델 4줄
import SwiftUI

@Observable final class TallyModel {
  var count = 0
  func increment() { count += 1 }
}
TCA 카운터 — 같은 일에 State·Action·body가 모두 필요하다
import ComposableArchitecture

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

FAQ의 입장은 "기능 하나로는 줄이 더 들지만, 테스트를 쓰거나 기능을 조합하기 시작하면 오히려 덜 든다"이다. 또 하나 자주 나오는 불만이 액션 핑퐁이다. 비동기 작업 사이사이에 State를 바꿔야 하면 Action을 여러 번 왕복해야 한다. 사이에 State 변경이 없다면 Effect 하나 안에서 await를 이어 붙이면 된다.

✅ 중간 상태 변경이 없으면 Effect 하나에서 await를 잇는다 (FAQ 권장)
import ComposableArchitecture

struct User: Equatable, Sendable { var id: Int }

@Reducer
struct MoviesFeature {
  @ObservableState
  struct State: Equatable {
    var isLoading = false
    var movies: [String] = []
  }
  enum Action {
    case refreshButtonTapped
    case moviesResponse([String])
  }
  let fetchUser: @Sendable () async -> User       // 의존성은 Day 7에서 제대로
  let fetchMovies: @Sendable (Int) async -> [String]

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .refreshButtonTapped:
        state.isLoading = true
        return .run { send in
          let user = await fetchUser()            // 두 요청을 한 Effect에서 연달아
          let movies = await fetchMovies(user.id)
          await send(.moviesResponse(movies))     // Action 왕복은 한 번
        }
      case let .moviesResponse(movies):
        state.isLoading = false
        state.movies = movies
        return .none
      }
    }
  }
}
대가실제 크기완화책
보일러플레이트기능마다 State·Action·body. 매크로가 상당 부분을 대신 쓴다(Day 2)옛 ViewStore 시절보다 크게 줄었다(FAQ). 테스트·조합에서 코드가 오히려 준다
학습 곡선Reducer·Effect·Scope·의존성·TestStore 등 개념이 많다SwiftUI 기본기가 전제. FAQ: Swift/SwiftUI를 처음 배울 때는 권하지 않음
액션 전송 비용Action은 여러 계층의 Reducer를 통과한다. 메서드 호출보다 무겁다로직 공유는 Action 대신 메서드로(Performance, Day 13)
서드파티 의존앱 구조 전체가 한 라이브러리에 묶인다FAQ: 팀이 논의해 정할 일. 블로그 팁을 이어 붙이는 것보다 위험이 크지 않다는 입장
📝 TCA 문서 원문

"It's definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components." — TCA 문서 · Getting started

풀이: 순수 SwiftUI보다 단계가 많다는 것을 문서도 인정한다. 대신 상태 변경을 observable 객체와 UI 클로저에 흩어 놓지 않고 한 가지 방식으로 하게 된다. 1절에서 본 문제가 바로 그 "흩어진 로직"이다.

5. 언제 쓰나 — 결정 기준

FAQ는 모든 앱에 TCA를 쓰라고 하지 않는다. 두 가지를 명시적으로 권하지 않는다. Swift·SwiftUI를 처음 배우는 단계, 그리고 JSON을 받아 보여 주기만 하는 "리더" 앱이다.

📝 TCA 문서 원문

"In general it can be fine to start a project with vanilla SwiftUI (with a concentration on concise domain modeling), and then transition to TCA later if there is a need for any of its powers." — TCA 문서 · FAQ

풀이: 순수 SwiftUI로 시작하되 도메인 모델을 간결하게 설계해 두고, TCA의 기능이 필요해지면 그때 옮겨도 된다.

화면 로직·부수효과가 복잡한가? 순수 SwiftUI + @Observable "리더" 앱(JSON 표시)은 여기 테스트·기능 조합·디버깅 도구가 지금 필요한가? 팀이 SwiftUI 기본기를 갖췄는가? @Observable로 시작 필요해지면 TCA로 전환(FAQ) TCA 도입 아니오 예 아니오 예
TCA는 SwiftUI의 대체재가 아니라 위에 얹는 구조다. 로직이 단순하면 얹을 이유가 약하다.

흔한 오해 세 가지

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
Reducer는 API 요청을 직접 실행하지 않는다. 대신 무엇을 하고, 그 결과는 어떻게 State에 반영되나?
Reducer는 요청을 Effect 값(.run { send in … })으로 반환만 한다. 실행은 Store가 한다. 요청이 끝나면 Effect가 send(.numberFactResponse(fact))로 새 Action을 보내고, Reducer가 그 Action을 받아 state.numberFact = fact로 바꾼다. State를 바꾸는 곳은 끝까지 Reducer 하나다.
쉽게 말하면 반장(Reducer)은 심부름을 직접 가지 않고 "이거 사 와" 쪽지(Effect)만 쓴다. 심부름꾼이 돌아와 "사 왔어요"(Action)라고 말하면, 그때 반장이 칠판(State)에 적는다.
FAQ가 TCA를 권하지 않는 두 경우는? 그런 프로젝트는 어떻게 시작하라고 하나?
① Swift·SwiftUI를 처음 배우는 단계, ② 네트워크에서 JSON을 받아 보여 주기만 하는 "리더" 앱. 이런 경우 순수 SwiftUI로 시작하되 도메인 모델을 간결하게 설계해 두고, TCA의 기능(테스트·조합·Effect 관리 등)이 필요해지면 그때 옮겨도 된다고 한다.
쉽게 말하면 자전거를 처음 타는 사람에게 기어 21단짜리를 주지 않는다. 동네 마트만 가는 사람도 굳이 필요 없다. 먼 길을 자주 가게 되면 그때 바꿔도 된다.
코드 예측용 — 버튼 클로저 안에서 send 직후 읽기
struct PeekView: View {
  let store: StoreOf<Feature>

  var body: some View {
    Button("두 번") {
      store.send(.incrementButtonTapped)
      store.send(.incrementButtonTapped)
      print(store.count)
    }
  }
}
🔮 코드 예측: 처음 count가 0일 때 위 버튼을 한 번 누르면 콘솔에 무엇이 찍히나? (Feature는 2절의 것)
2가 찍힌다. store.send는 Reducer를 그 자리에서 동기로 실행하고, 돌아오기 전에 새 State를 저장한다(소스 기준: RootCore._send가 reducer._reduce(into:action:)를 호출하고 defer에서 self.state = currentState). 그래서 다음 줄에서 읽는 store.count는 이미 2다. 비동기로 늦게 반영되는 것은 .run 같은 Effect가 나중에 보내는 Action뿐이다.
쉽게 말하면 계산대에 물건을 올리면 점원이 바로 찍어 준다. 두 개 올리고 영수증을 보면 이미 두 개가 찍혀 있다. 기다려야 하는 건 "택배로 보내 주세요" 같은 일(Effect)뿐이다.
🔁 SwiftUI 코스 복습: @Observable 모델에서 뷰가 다시 그려지는 기준은? TCA의 뷰도 같은 기준을 따르나?
body가 실행되는 동안 읽은 프로퍼티만 의존성이 된다(SwiftUI 코스 Day 5). TCA도 같다. @ObservableState를 붙인 State는 store.count를 읽을 때 관찰 등록기에 "읽었다"를 기록하므로, numberFact만 바뀌면 count만 읽은 뷰는 다시 그려지지 않는다. FAQ: "views re-compute the minimal number of times based off of what state is accessed in the view, just as it does in vanilla SwiftUI with the @Observable macro."
쉽게 말하면 자기가 구독한 잡지만 우편함에 온다. TCA로 이사 가도 구독 규칙은 똑같다.

🛠 미니 과제 (선택 · 10분)

Xcode 프로젝트에 TCA 패키지(https://github.com/pointfreeco/swift-composable-architecture)를 추가했다고 가정한다. 2절의 Feature와 FeatureView를 파일 하나에 붙여 넣고 아래를 한다.

확인 방법: Preview를 실행하고 +, +, Reset을 누른다. Xcode 콘솔의 received action: 아래에 - _count: 2 / + _count: 0 줄이 찍히면 성공이다(필드 이름 앞 _는 @ObservableState가 만든 저장 프로퍼티 이름이다).

정답 코드 펼치기

정답은 2절 Feature를 줄인 새 버전이다 — 감소·숫자 사실 액션은 빼고 resetButtonTapped를 더했다.

✅ reset 액션과 _printChanges
import ComposableArchitecture
import SwiftUI

@Reducer
struct ResettableFeature {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var numberFact: String?
  }
  enum Action {
    case incrementButtonTapped
    case resetButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementButtonTapped:
        state.count += 1
        return .none
      case .resetButtonTapped:
        state.count = 0
        state.numberFact = nil
        return .none
      }
    }
  }
}

struct ResettableView: View {
  let store: StoreOf<ResettableFeature>
  var body: some View {
    Form {
      Text("\(store.count)")
      Button("Increment") { store.send(.incrementButtonTapped) }
      Button("Reset") { store.send(.resetButtonTapped) }
    }
  }
}

#Preview {
  ResettableView(
    store: Store(initialState: ResettableFeature.State()) {
      ResettableFeature()
        ._printChanges()
    }
  )
}

출처 · 참고자료