← TCA 14일 마스터 2 / 14

Day 2 · 첫 기능 — Reducer와 Store

State·Action·body 세 조각으로 기능을 만들고 Store로 돌리며, 매크로가 실제로 생성하는 코드까지 펼쳐 본다.
진행률
0 / 0 완료
Day 1에서 TCA의 5요소 지도를 그렸다. 오늘은 그중 Reducer와 Store 두 개만으로 동작하는 카운터를 처음부터 만든다. 매 단계에서 SwiftUI 코스의 @Observable 카운터와 나란히 놓고, 어느 코드가 Reducer로 가고 어느 코드가 Store가 되는지를 확인한다. 마지막에는 @Reducer·@ObservableState 매크로를 펼쳐서 실제로 무엇이 생성되는지 본다.

오늘의 목표 · 30분

  • @Reducer 타입 안에 @ObservableState State, enum Action, body/Reduce를 써서 기능 하나를 완성한다.
  • Action 이름을 "사용자가 한 일"(incrementButtonTapped)로 짓고, "할 일"(incrementCount)로 지은 이름을 찾아 고친다.
  • Store/StoreOf를 만들어 뷰에서 store.count로 읽고 store.send로 보내며, Store를 만드는 위치(App·Preview)를 올바르게 고른다.
  • @Reducer·@ObservableState가 생성하는 코드를 읽고, 같은 카운터를 @Observable로 쓴 것과 무엇이 다른지 설명한다.

1. @Reducer — State·Action·body 세 조각 🔥 핵심

TCA 기능의 기본 단위는 @Reducer를 붙인 타입이다. 튜토리얼이 꼽는 가장 중요한 특징은 이 타입이 SwiftUI 뷰를 전혀 언급하지 않는다는 점이다. 그래서 따로 개발·재사용·테스트하기 쉽다.

@Reducer는 빠진 요구사항도 채운다. @Reducer struct DraftFeature {}만 써도 빈 State·Action·body가 들어가 컴파일된다(TCA 문서 · Reducer). 부모에 먼저 붙여 두고 나중에 채울 때 쓴다.

State와 Action — 도메인 모델링

State는 기능에 필요한 값, Action은 사용자가 할 수 있는 모든 일이다. SwiftUI가 관찰할 State에는 @ObservableState를 붙인다. 튜토리얼 표현으로 "값 타입에 맞춘 TCA판 @Observable"이다.

TCA — 카운터의 State·Action·body (튜토리얼 01 · Counter 케이스 스터디와 같은 구조)
import ComposableArchitecture

@Reducer
struct CounterFeature {
  @ObservableState
  struct State: Equatable {       // Equatable: 나중에 TestStore가 비교할 때 필요
    var count = 0
  }

  enum Action {
    case decrementButtonTapped    // 사용자가 한 일로 이름 짓기
    case incrementButtonTapped
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in     // state는 inout — 그 자리에서 바꾼다
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        return .none              // 실행할 Effect 없음
      case .incrementButtonTapped:
        state.count += 1
        return .none
      }
    }
  }
}

Action 이름: 한 일 vs 할 일

📝 TCA 문서 원문

"It is best to name the Action cases after literally what the user does in the UI, such as incrementButtonTapped, rather than what logic you want to perform, such as incrementCount." — Meet TCA 튜토리얼 · Your first feature

풀이: Action 이름에는 "UI에서 사용자가 실제로 한 일"을 쓴다. "그래서 무슨 로직을 돌릴지"는 Reducer가 정한다. 그래야 같은 버튼의 로직이 바뀌어도 Action 이름은 그대로다.

❌ 할 일로 지은 이름 — 뷰가 로직을 결정하고 있다
enum Action {
  case incrementCount          // "무엇을 하라"
  case setNumberFact(String)   // setter처럼 쓰는 Action
  case saveItem
}

@Observable 카운터와 조각을 맞대 보면 이렇다.

SwiftUI(@Observable)TCA차이
@Observable final class Counter@Reducer struct CounterFeature로직 담는 타입이 클래스 → struct
저장 프로퍼티 var count = 0@ObservableState struct State { var count = 0 }상태가 따로 떨어진 값 타입
메서드 이름 increment()case incrementButtonTapped호출 → 데이터. 이름도 "한 일"로
메서드 본문 count += 1Reduce의 case 본문 + return .none변경 로직이 한 switch에 모이고 Effect를 반환
@Observable class Counter var count = 0 func increment() { count += 1 } @Reducer struct CounterFeature State var count = 0 Action case incrementButtonTapped body · Reduce count += 1; return .none 값 타입으로 분리 호출 → 데이터 본문 → switch case
한 클래스 안에 섞여 있던 "데이터 · 이벤트 이름 · 변경 로직"이 세 조각으로 나뉜다. 조각마다 따로 테스트하고 조합할 수 있게 된다.

2. Store — 기능을 돌리는 런타임 🔥 핵심

Reducer는 규칙일 뿐 스스로 돌지 않는다. Action을 받아 Reducer를 돌리고 Effect를 실행하는 런타임이 Store다. 뷰는 이 Store 하나만 받는다.

TCA — 뷰는 let store 하나. 읽기는 store.count, 쓰기는 store.send
import ComposableArchitecture
import SwiftUI

struct CounterView: View {
  let store: StoreOf<CounterFeature>   // = Store<CounterFeature.State, CounterFeature.Action>

  var body: some View {
    VStack(spacing: 16) {
      Text("\(store.count)")             // State 프로퍼티를 바로 읽는다
        .font(.largeTitle)
        .monospacedDigit()
      HStack {
        Button("-") { store.send(.decrementButtonTapped) }
        Button("+") { store.send(.incrementButtonTapped) }
      }
      .font(.largeTitle)
      .buttonStyle(.bordered)
    }
  }
}
CounterView let store body Store<State, Action> (@MainActor) State.count getter registrar.access 기록 Reducer 실행 inout State, 동기 새 State 저장 값이 바뀐 필드만 알림 store.count send(.x) body 무효화
읽기와 쓰기가 다른 길로 간다. 읽기는 관찰 등록만 하고, 쓰기는 반드시 send → Reducer를 거친다. 다시 그리는 대상은 바뀐 필드를 읽은 뷰뿐이다.

Store는 어디서 만드나

Store(initialState:reducer:)는 시작 State와 Reducer(뒤따르는 클로저)를 받는다. 튜토리얼은 앱의 Store를 한 번만 만들라고 강조한다.

TCA — 앱 진입점: static let으로 한 번만 만든다
import ComposableArchitecture
import SwiftUI

@main
struct CounterApp: App {
  static let store = Store(initialState: CounterFeature.State()) {
    CounterFeature()
  }

  var body: some Scene {
    WindowGroup {
      CounterView(store: CounterApp.store)
    }
  }
}
📝 TCA 문서 원문

"Xcode previews create the app entry point when run, which can cause the Store to be created and dependencies executed when you don't expect them to. For this reason we recommend holding onto the root store in static let." — 소스 · Store.swift 문서 주석

풀이: Preview 실행 때도 Xcode가 앱 진입점을 만들어 루트 Store와 의존성이 뜻밖에 실행될 수 있다. static let은 처음 접근할 때 한 번만 만들어진다.

❌ body 안에서 Store를 만든다 — 부모가 다시 그려질 때마다 count가 0으로 돌아간다
struct ParentView: View {
  @State private var isOn = false

  var body: some View {
    VStack {
      Toggle("아무 설정", isOn: $isOn)        // 토글하면 ParentView.body 재실행
      CounterView(
        store: Store(initialState: CounterFeature.State()) {   // 매번 새 Store
          CounterFeature()
        }
      )
    }
  }
}
✅ 뷰가 소유해야 한다면 @StateObject — 초기화 식이 처음 한 번만 평가된다 (1.19+)
import ComposableArchitecture
import SwiftUI

struct OwningParentView: View {
  @State private var isOn = false
  @StateObject private var store: StoreOf<CounterFeature>

  init() {
    _store = StateObject(
      wrappedValue: Store(initialState: CounterFeature.State()) {
        CounterFeature()
      }
    )
  }

  var body: some View {
    VStack {
      Toggle("아무 설정", isOn: $isOn)
      CounterView(store: store)          // 같은 Store가 유지된다
    }
  }
}
⚠️ @StateObject는 "관찰"이 아니라 "지연 초기화" 때문

Migrating to 1.19: @State의 초기화 식은 즉시 평가돼 부모 body가 계산될 때마다 Store가 만들어졌다 버려진다. @StateObject는 처음 한 번만 만든다. Store의 ObservableObject 채택은 이 용도뿐이고, 관찰은 여전히 @ObservableState가 한다.

3. Preview — 리듀서를 바꿔 끼우기와 _printChanges

Preview도 Store를 만들어 넘기면 끝이다. 로직이 전부 Reducer에 있으니 Store에 다른 Reducer를 주면 같은 뷰가 다르게 동작한다.

TCA — 평소 Preview와, 로직을 뺀 "디자인 전용" Preview
import ComposableArchitecture
import SwiftUI

#Preview("동작") {
  CounterView(
    store: Store(initialState: CounterFeature.State(count: 42)) {
      CounterFeature()
        ._printChanges()      // Preview 콘솔에도 Action과 State diff가 찍힌다
    }
  )
}

#Preview("디자인만") {
  CounterView(
    store: Store(initialState: CounterFeature.State(count: 42)) {
      // 비워 두면 아무 일도 하지 않는 Reducer가 된다. 버튼을 눌러도 42 그대로
    }
  )
}

튜토리얼은 두 번째 방식을 "로직이나 동작을 신경 쓰지 않고 기능의 디자인만 미리 보는" 용도로 소개한다(원문: "preview the design of the feature without worrying about any of its logic or behavior"). Reducer 빌더 클로저를 비우면 상태 변경도 Effect도 없는 Reducer가 들어간다. 시작 State는 멤버와이즈 init으로 원하는 값을 준다.

_printChanges()는 DEBUG 빌드에서만 출력한다(소스 · DebugReducer.swift "Printing is only done in DEBUG configurations"). SwiftUI의 Self._printChanges()가 "왜 body가 다시 불렸나"를 찍는다면 이쪽은 "어떤 Action이 와서 State가 어떻게 바뀌었나"를 찍는다.

Preview에서 하고 싶은 일SwiftUI(@Observable)TCA
특정 상태로 시작모델을 만들고 프로퍼티를 세팅해 넘긴다initialState:에 값을 준다
로직을 끄고 디자인만 보기메서드가 모델 안에 있어 따로 목(mock) 모델을 설계해야 한다Reducer 클로저를 비운다
무슨 일이 일어났는지 로그메서드마다 print를 넣는다._printChanges() 한 줄

4. 매크로가 만들어 주는 것

Xcode에서 @Reducer나 @ObservableState를 우클릭 → Expand Macro하면 생성 코드가 보인다. TCA 저장소의 매크로 테스트(ReducerMacroTests)에 기대 확장 결과가 그대로 적혀 있다.

@Reducer — 세 가지를 붙인다

@Reducer를 펼친 모습을 손으로 쓴 것 — 매크로 없이도 이대로 컴파일된다
import ComposableArchitecture

struct ExpandedCounter {
  struct State: Equatable { var count = 0 }

  @CasePathable                       // ① Action에 케이스 키패스
  enum Action { case incrementButtonTapped }

  @ComposableArchitecture.ReducerBuilder<Self.State, Self.Action>   // ② body에 결과 빌더
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementButtonTapped:
        state.count += 1
        return .none
      }
    }
  }
}

extension ExpandedCounter: ComposableArchitecture.Reducer {}   // ③ 프로토콜 채택

@ObservableState — 값 타입에 관찰을 심는다

@ObservableState가 펼친 코드 (ObservableStateMacroTests 발췌 · 내부 API라 그대로는 컴파일 안 됨)
struct ExpandedState {       // 원래 이름은 State
  var count {
    @storageRestrictions(initializes: _count)
    init(initialValue) { _count = initialValue }
    get {
      _$observationRegistrar.access(self, keyPath: \.count)     // "읽었다" 기록
      return _count
    }
    set {
      _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue,
                                    _$isIdentityEqual, shouldNotifyObservers)
    }
    _modify { /* willModify … didModify */ }
  }

  var _$observationRegistrar = ComposableArchitecture.ObservationStateRegistrar()
  public var _$id: ComposableArchitecture.ObservableStateID { _$observationRegistrar.id }
  public mutating func _$willModify() { _$observationRegistrar._$willModify() }

  // Equatable 값이면 lhs != rhs 일 때만 알린다
  private nonisolated func shouldNotifyObservers<T: Equatable>(_ lhs: T, _ rhs: T) -> Bool {
    lhs != rhs
  }
}
// + extension: Observable, ObservableState 채택 (매크로 선언의 conformances)
@Reducer struct CounterFeature @ObservableState struct State ① Action에 @CasePathable (케이스 키패스) ② body에 @ReducerBuilder<State, Action> ③ extension: Reducer 채택 + 빠진 요구사항 채움 ① 프로퍼티마다 get → access / set → mutate 접근자 ② _$observationRegistrar · _$id · _$willModify() ③ extension: Observable, ObservableState 채택
@Reducer는 "조합과 케이스 키패스"를 위한 코드를, @ObservableState는 "읽은 필드만 다시 그리기"를 위한 코드를 만든다.

@Observable과 무엇이 다른가

Apple의 @Observable은 클래스에만 붙는다. @ObservableState는 struct·enum에 붙는다.

📝 TCA 문서 원문

"TCA apps are allowed to use Swift's observation tools with value types, whereas vanilla SwiftUI is limited to only reference types. … But we are able to overcome those limitations thanks to the Store type." — TCA 문서 · FAQ

풀이: 순수 SwiftUI의 관찰은 참조 타입(클래스)에서만 된다. TCA는 값 타입 State를 Store라는 참조 타입이 감싸기 때문에 값 타입에서도 관찰이 동작한다.

관찰 규칙은 SwiftUI 코스 Day 5와 같다. body가 읽은 필드만 의존성이 된다.

TCA — 읽은 필드만 추적: 이름이 바뀌어도 CountLabel은 다시 그려지지 않는다
import ComposableArchitecture
import SwiftUI

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

struct CountLabel: View {
  let store: StoreOf<ProfileCounter>
  var body: some View {
    let _ = Self._printChanges()   // 다시 그려질 때만 찍힌다
    Text("\(store.count)")          // count만 읽는다 → name 변경에는 반응 안 함
  }
}

5. 같은 카운터, 두 방식 — 테스트까지 나란히

기능이 뷰와 떨어져 있다는 의미는 테스트에서 가장 잘 보인다.

SwiftUI(@Observable) — 모델 메서드를 부르고 결과만 확인한다
import SwiftUI
import Testing

@Observable final class PlainCounter {
  var count = 0
  func increment() { count += 1 }
  func decrement() { count -= 1 }
}

@MainActor
@Test func plainCounter() {
  let model = PlainCounter()
  model.increment()
  #expect(model.count == 1)
  model.decrement()
  #expect(model.count == 0)
}
TCA — TestStore: Action마다 "이렇게 바뀐다"를 적고, 적지 않은 변화가 있으면 실패
import ComposableArchitecture
import Testing

@MainActor
@Test func counterFeature() async {
  let store = TestStore(initialState: CounterFeature.State()) {
    CounterFeature()
  }
  await store.send(.incrementButtonTapped) {
    $0.count = 1
  }
  await store.send(.decrementButtonTapped) {
    $0.count = 0
  }
}

이 규모에서는 @Observable 테스트가 더 짧다. 차이는 커질 때 난다. @Observable 테스트는 내가 확인한 프로퍼티만 본다. TestStore는 State 전체를 비교해 적지 않은 변화가 있으면 실패하고, Effect가 돌려준 Action도 단언하게 한다(Day 8).

흔한 실수 모음

증상원인고치기
store.count에서 "referencing subscript … requires that 'State' conform to 'ObservableState'"State에 @ObservableState 없음State에 매크로를 붙인다
"missing return in closure expected to return 'Effect<Action>'" (클로저 전체에 return이 하나도 없으면 "cannot convert value of type '()' to specified type 'Effect<Action>'")case 하나에서 return 누락할 일이 없으면 return .none
"left side of mutating operator isn't mutable"뷰에서 store.count += 1Action을 만들고 store.send
부모가 다시 그려지면 카운트가 0이 된다body 안에서 Store(...) 생성App의 static let 또는 @StateObject
body 안에서 자동완성이 안 된다매크로 관련 알려진 Swift 이슈(Reducer 문서 Gotchas)struct Feature: Reducer를 명시하거나 Reduce<State, Action>

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
뷰에서 let store: StoreOf<Feature>처럼 let으로만 받아도 화면이 갱신되는 이유는?
관찰은 Store 프로퍼티 래퍼가 아니라 State에 붙은 @ObservableState가 한다. 뷰가 store.count를 읽으면 생성된 getter가 _$observationRegistrar.access(self, keyPath: \.count)로 "읽었다"를 기록하고, Reducer가 count를 바꾸면 그 필드를 읽은 뷰의 body가 다시 실행된다. 넘겨받은 @Observable 모델을 래퍼 없이 받아도 되던 것과 같은 원리다.
쉽게 말하면 초인종은 문(let store)이 아니라 집 안 물건(State)마다 달려 있다. 물건이 바뀌면 그 물건을 봤던 사람에게만 벨이 울린다.
Action 이름 incrementCount를 incrementButtonTapped로 바꾸라는 이유를 한 문장으로.
Action은 "UI에서 사용자가 한 일"을 기록하고, 그 일에 대해 어떤 로직을 돌릴지는 Reducer가 정해야 하기 때문이다(튜토리얼 Tip). 나중에 버튼이 카운트 증가 외에 분석 로그도 보내게 바뀌어도 Action 이름과 뷰는 그대로이고 Reducer만 바뀐다.
쉽게 말하면 학생은 "손 들었어요"만 말하고, 발표를 시킬지 화장실을 보낼지는 선생님이 정한다. 학생이 "저 발표시켜요"라고 명령하면 선생님이 판단할 틈이 없다.
코드 예측용 — 0 아래로 내려가지 않는 카운터와 그 테스트
@Reducer
struct FloorCounter {
  @ObservableState
  struct State: Equatable { var count = 0 }
  enum Action { case decrementButtonTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        if state.count > 0 { state.count -= 1 }
        return .none
      }
    }
  }
}

@MainActor
@Test func floor() async {
  let store = TestStore(initialState: FloorCounter.State()) { FloorCounter() }
  await store.send(.decrementButtonTapped) {
    $0.count = 0
  }
}
🔮 코드 예측: 위 테스트는 통과하나, 실패하나?
실패한다. count가 0이라 Reducer는 아무것도 바꾸지 않았다. 그런데 뒤따르는 클로저를 줬으므로 TestStore는 "State가 바뀔 것"이라고 기대한다. 소스(TestStore.swift)의 메시지 그대로: "Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is expected, omit the trailing closure." 고치려면 클로저를 지우고 await store.send(.decrementButtonTapped)만 쓴다.
쉽게 말하면 "이 버튼 누르면 뭔가 바뀔 거예요"라고 적어 놓고 실제로 아무것도 안 바뀌면 거짓말이 된다. 안 바뀌는 게 맞으면 "바뀐다" 칸을 아예 비워야 한다.
🔁 Day 1 복습: TCA의 5요소 중 오늘 코드에 아직 등장하지 않은 것은? 오늘 Reducer에서 그 자리를 무엇이 채웠나?
Effect(의 실제 작업)다. 오늘 카운터의 모든 case는 return .none, 즉 "실행할 바깥 일 없음"을 반환했다. 네트워크·타이머처럼 결과가 나중에 Action으로 돌아오는 .run은 Day 3에서 다룬다. State·Action·Reducer·Store는 모두 등장했다.
쉽게 말하면 오늘은 교실 안에서 끝나는 일만 했다. 밖에 심부름 보내는 법(Effect)은 내일 배운다.

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

1절의 CounterFeature를 확장한다. TCA 패키지는 추가돼 있다고 가정한다.

확인 방법: 아래 TestStore 테스트가 통과하면 성공이다. Preview에 ._printChanges()를 붙여 로그로도 확인한다.

확인용 테스트
import ComposableArchitecture
import Testing

@MainActor
@Test func stepCounter() async {
  let store = TestStore(initialState: StepCounter.State()) { StepCounter() }
  await store.send(.stepIncrementButtonTapped) { $0.step = 2 }
  await store.send(.incrementButtonTapped) { $0.count = 2 }
  await store.send(.decrementButtonTapped) { $0.count = 0 }
  await store.send(.incrementButtonTapped) { $0.count = 2 }
  await store.send(.resetButtonTapped) {
    $0.count = 0
    $0.step = 1
  }
}
정답 코드 펼치기
✅ StepCounter와 뷰
import ComposableArchitecture
import SwiftUI

@Reducer
struct StepCounter {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var step = 1
  }
  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
    case stepIncrementButtonTapped
    case resetButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= state.step
        return .none
      case .incrementButtonTapped:
        state.count += state.step
        return .none
      case .stepIncrementButtonTapped:
        state.step = min(state.step + 1, 10)
        return .none
      case .resetButtonTapped:
        state.count = 0
        state.step = 1
        return .none
      }
    }
  }
}

struct StepCounterView: View {
  let store: StoreOf<StepCounter>
  var body: some View {
    Form {
      Text("\(store.count)").font(.largeTitle)
      Text("간격 \(store.step)")
      Button("-") { store.send(.decrementButtonTapped) }
      Button("+") { store.send(.incrementButtonTapped) }
      Button("간격 +1") { store.send(.stepIncrementButtonTapped) }
      Button("초기화") { store.send(.resetButtonTapped) }
    }
  }
}

#Preview {
  StepCounterView(
    store: Store(initialState: StepCounter.State()) {
      StepCounter()._printChanges()
    }
  )
}

출처 · 참고자료