@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"이다.
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
}
}
}
}ReducerOf<Self>는Reducer<Self.State, Self.Action>의 줄임이다. Getting started처럼some Reducer<State, Action>로 써도 같다.Reduce의 클로저는(inout State, Action) -> Effect<Action>다. 모든 case가Effect를 반환해야 하므로 할 일이 없으면.none이다.
Action 이름: 한 일 vs 할 일
"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 += 1 | Reduce의 case 본문 + return .none | 변경 로직이 한 switch에 모이고 Effect를 반환 |
2. Store — 기능을 돌리는 런타임 🔥 핵심
Reducer는 규칙일 뿐 스스로 돌지 않는다. Action을 받아 Reducer를 돌리고 Effect를 실행하는 런타임이 Store다. 뷰는 이 Store 하나만 받는다.
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)
}
}
}let으로 충분하다. 튜토리얼: "The store can be held onto as a let." 관찰은@ObservableState가 알아서 한다. 넘겨받은@Observable모델을 래퍼 없이 받던 것과 같다.store.count는@dynamicMemberLookup덕분이다. 이 subscript는State: ObservableState일 때만 열리고get만 있다(소스 기준). 그래서@ObservableState를 빠뜨리면 컴파일 에러, 뷰에서 대입해도 컴파일 에러다.store.send(_:)는StoreTask를 돌려준다(버려도 된다). Effect 수명과 연결할 때 쓴다(Day 3).Store는@MainActor다(@preconcurrency @MainActor public final class Store). 뷰와 같은 메인 액터에서 읽고 보낸다.
Store는 어디서 만드나
Store(initialState:reducer:)는 시작 State와 Reducer(뒤따르는 클로저)를 받는다. 튜토리얼은 앱의 Store를 한 번만 만들라고 강조한다.
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)
}
}
}"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은 처음 접근할 때 한 번만 만들어진다.
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()
}
)
}
}
}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가 유지된다
}
}
}Migrating to 1.19: @State의 초기화 식은 즉시 평가돼 부모 body가 계산될 때마다 Store가 만들어졌다 버려진다. @StateObject는 처음 한 번만 만든다. Store의 ObservableObject 채택은 이 용도뿐이고, 관찰은 여전히 @ObservableState가 한다.
3. Preview — 리듀서를 바꿔 끼우기와 _printChanges
Preview도 Store를 만들어 넘기면 끝이다. 로직이 전부 Reducer에 있으니 Store에 다른 Reducer를 주면 같은 뷰가 다르게 동작한다.
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 — 세 가지를 붙인다
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 {} // ③ 프로토콜 채택- ①
@CasePathable:\.incrementButtonTapped같은 케이스 키패스.Scope·store.receive(\.numberFactResponse)가 이걸로 동작한다. - ②
@ReducerBuilder:body안에 Reducer를 여러 개 나열하는 문법. SwiftUI의@ViewBuilder와 같은 역할이다. - ③
Reducer채택: 그래서struct CounterFeature: Reducer를 쓰지 않아도 된다.
@ObservableState — 값 타입에 관찰을 심는다
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)@Observable과 무엇이 다른가
Apple의 @Observable은 클래스에만 붙는다. @ObservableState는 struct·enum에 붙는다.
"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가 읽은 필드만 의존성이 된다.
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. 같은 카운터, 두 방식 — 테스트까지 나란히
기능이 뷰와 떨어져 있다는 의미는 테스트에서 가장 잘 보인다.
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)
}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 += 1 | Action을 만들고 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으로만 받아도 화면이 갱신되는 이유는?
@ObservableState가 한다. 뷰가 store.count를 읽으면 생성된 getter가 _$observationRegistrar.access(self, keyPath: \.count)로 "읽었다"를 기록하고, Reducer가 count를 바꾸면 그 필드를 읽은 뷰의 body가 다시 실행된다. 넘겨받은 @Observable 모델을 래퍼 없이 받아도 되던 것과 같은 원리다.Action 이름 incrementCount를 incrementButtonTapped로 바꾸라는 이유를 한 문장으로.
@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
}
}🔮 코드 예측: 위 테스트는 통과하나, 실패하나?
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에서 그 자리를 무엇이 채웠나?
return .none, 즉 "실행할 바깥 일 없음"을 반환했다. 네트워크·타이머처럼 결과가 나중에 Action으로 돌아오는 .run은 Day 3에서 다룬다. State·Action·Reducer·Store는 모두 등장했다.🛠 미니 과제 (선택 · 10분)
1절의 CounterFeature를 확장한다. TCA 패키지는 추가돼 있다고 가정한다.
- 요구 1: State에
var step = 1을 추가하고,+/-가step만큼 움직이게 한다. - 요구 2:
stepIncrementButtonTapped(step 1 증가, 최대 10)와resetButtonTapped(count·step 초기화) Action을 추가한다. 이름은 "한 일"로. - 요구 3: 뷰는
let store로 받고, 대입 없이send만 쓴다.
확인 방법: 아래 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
}
}정답 코드 펼치기
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()
}
)
}