Task를 직접 취소했다. TCA에서는 이 세 가지를 리듀서 조립 도구가 맡는다. 오늘은 Scope로 자식을 끼우고, store.scope로 자식 뷰에 스토어를 넘기고, delegate 액션으로 위로 말하고, ifLet으로 있다 없다 하는 자식을 다룬다.오늘의 목표 · 30분
- 부모 State·Action에 자식 도메인을 넣고
Scope(\.child, action: \.child)로 자식 리듀서를 끼운다. store.scope(\.child, action: \.child)로 자식 뷰에 스토어를 넘기고, 액션이 루트까지 갔다 오는 순서를 그림으로 설명한다.- 부모가 자식 내부 액션을 엿보는 코드를 찾아 delegate 액션으로 고친다.
- 옵셔널 자식을
ifLet으로 붙이고, 로직을 "공유 액션" 대신 메서드로 뽑는다.
1. Scope — 자식 도메인을 부모에 끼운다 🔥 핵심
기능이 커지면 하나의 리듀서에 모든 case를 넣을 수 없다. TCA는 기능을 작은 리듀서로 나누고 부모가 그 도메인을 그대로 품게 한다. 준비물은 세 가지다.
- State: 부모 State에
var counter1: Counter.State같은 자식 상태 필드. - Action: 부모 Action에
case counter1(Counter.Action)— 자식 액션을 통째로 감싸는 케이스. - body:
Scope로 "이 조각에는 이 리듀서"라고 연결.
먼저 SwiftUI 코스에서 하던 방식이다. 부모 모델이 자식 모델 인스턴스를 들고 있으면 끝난다(SwiftUI 코스 Day 5).
@Observable
final class CounterModel {
var count = 0
func incrementButtonTapped() { count += 1 }
func decrementButtonTapped() { count -= 1 }
}
@Observable
final class TwoCountersModel {
let counter1 = CounterModel() // 자식은 참조 — 누가 바꿨는지는 추적 안 됨
let counter2 = CounterModel()
}TCA에서는 자식이 값(State)과 액션 목록으로 들어간다. 아래는 공식 케이스 스터디 TwoCounters와 같은 구조다.
@Reducer
struct Counter {
@ObservableState
struct State: Equatable {
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
}
}
}
}
@Reducer
struct TwoCounters {
@ObservableState
struct State: Equatable {
var counter1 = Counter.State() // 자식 상태 = 값
var counter2 = Counter.State()
}
enum Action {
case counter1(Counter.Action) // 자식 액션을 감싸는 케이스
case counter2(Counter.Action)
}
var body: some ReducerOf<Self> {
Scope(\.counter1, action: \.counter1) { Counter() }
Scope(\.counter2, action: \.counter2) { Counter() }
}
}"When run, it will intercept all child actions sent and feed them to the child reducer so that it can update the parent state and execute effects." — TCA 소스 · Scope
풀이: Scope는 자식 액션만 골라 자식 리듀서에 먹인다. 자식이 바꾼 상태는 부모 State 안의 그 필드에 그대로 쓰이고, 자식 이펙트가 보낸 액션은 다시 .counter1(…)로 감싸져 돌아온다.
Scope(state:action:)은 옛 표기1.26.2 소스에서 Scope(state:action:)과 store.scope(state:action:)은 init(_:action:_:) / scope(_:action:)으로 이름이 바뀐(renamed) 옛 표기다. 지금은 경고가 나지 않지만(deprecated: 9999), 2.0 대비 trait(ComposableArchitecture2Deprecations)를 켜면 경고가 뜬다(소스 기준). 공식 예제는 모두 라벨 없는 쪽을 쓴다. 회사 코드에서 state:를 보면 같은 것으로 읽으면 된다.
| 할 일 | SwiftUI(@Observable) | TCA |
|---|---|---|
| 자식을 품기 | let counter1 = CounterModel() | var counter1 = Counter.State() + case counter1(Counter.Action) |
| 자식 로직 연결 | 자동(메서드 직접 호출) | Scope(\.counter1, action: \.counter1) { Counter() } 한 줄 |
| 변경 경로 추적 | 아무 곳에서나 counter1.count = 5 가능 | 모든 변경이 부모 Action을 거친다 — 로그·테스트 한 곳 |
| 대가 | 코드가 짧다 | State·Action·Scope 세 곳을 매번 손댄다 |
2. store.scope — 자식 뷰에 스토어를 넘긴다 🔥 핵심
리듀서를 합쳤으면 뷰도 나눈다. 자식 뷰 CounterView는 StoreOf<Counter>만 받는다. 부모 뷰는 자기 스토어에서 store.scope(\.counter1, action: \.counter1)로 자식 스토어를 잘라 넘긴다. 이름은 비슷하지만 층이 다르다 — Scope는 로직을, store.scope는 뷰에 줄 스토어를 자른다.
struct CounterView: View {
let store: StoreOf<Counter> // 부모를 모른다
var body: some View {
HStack {
Button("−") { store.send(.decrementButtonTapped) }
Text("\(store.count)").monospacedDigit()
Button("+") { store.send(.incrementButtonTapped) }
}
}
}
struct TwoCountersView: View {
let store: StoreOf<TwoCounters>
var body: some View {
Form {
CounterView(store: store.scope(\.counter1, action: \.counter1))
CounterView(store: store.scope(\.counter2, action: \.counter2))
}
.buttonStyle(.borderless)
}
}
#Preview {
TwoCountersView(store: Store(initialState: TwoCounters.State()) { TwoCounters() })
}순서가 body에 적힌 대로인 것은 ReducerBuilder 소스에서 확인된다. 두 리듀서를 r0._reduce → r1._reduce 차례로 부르고 이펙트는 merge한다(소스 기준).
1.5부터 자식 스토어는 루트를 참조만 하고, 상태를 읽을 때마다 루트 → 자식 변환을 다시 한다. 그래서 무거운 계산 프로퍼티로 store.scope(\.computedChild, …)를 하면 그 계산이 읽을 때마다 반복된다. 문서 권고: 스코프는 저장 프로퍼티만 따라가고, 계산은 잎(leaf) 뷰 쪽으로 민다(TCA 문서 · Performance · Store scoping).
| SwiftUI(@Observable) | TCA | |
|---|---|---|
| 자식 뷰에 넘기는 것 | 자식 모델 참조 CounterModelView(model: m.counter1) | 잘라낸 스토어 store.scope(\.counter1, action: \.counter1) |
| 자식 뷰가 할 수 있는 일 | 모델의 모든 프로퍼티·메서드 | 자기 State 읽기 + 자기 Action 보내기뿐 |
| 갱신 범위 | 둘 다 Observation — body가 읽은 프로퍼티만 추적 | |
3. 부모가 자식 액션에 반응하기 — delegate 액션 🔥 핵심
부모 Action에 case edit(ProfileEdit.Action)이 있으니, 부모 Reduce는 자식의 모든 액션을 switch로 볼 수 있다. 이게 편하지만 함정이다. 부모가 자식의 버튼 액션을 보고 "저장 버튼이니 이름을 반영하자"고 짐작하면, 자식이 검증을 추가하거나 버튼 이름을 바꿀 때 부모가 조용히 틀린다.
// 부모 Reduce 안
case .edit(.saveButtonTapped):
state.name = state.edit.draft // 자식이 "저장 불가"라 판단해도 부모는 모른다
return .none"A better pattern is to use so-called "delegate actions" for the child feature to directly tell the parent what it wants done." — Meet TCA 튜토리얼 · Child-to-parent communication
풀이: 자식이 "부모님, 이걸 해 주세요"를 액션으로 명시한다. 부모는 그 목록만 듣는다.
@Reducer
struct ProfileEdit {
@ObservableState
struct State: Equatable {
var draft = ""
}
enum Action {
case draftChanged(String)
case saveButtonTapped
case delegate(Delegate)
@CasePathable
enum Delegate: Equatable {
case saved(String) // "이 이름으로 저장해 줘"
}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .draftChanged(text):
state.draft = text
return .none
case .saveButtonTapped:
let name = state.draft.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return .none } // 검증은 자식 몫
return .send(.delegate(.saved(name)))
case .delegate:
return .none // 자식은 자기 delegate에 반응하지 않는다
}
}
}
}@Reducer
struct ProfileSettings {
@ObservableState
struct State: Equatable {
var name = "Guest"
var edit = ProfileEdit.State()
}
enum Action {
case edit(ProfileEdit.Action)
}
var body: some ReducerOf<Self> {
Scope(\.edit, action: \.edit) { ProfileEdit() }
Reduce { state, action in
switch action {
case let .edit(.delegate(.saved(name))):
state.name = name
state.edit.draft = ""
return .none
case .edit:
return .none // 나머지는 자식 내부 사정
}
}
}
}같은 일을 @Observable로 하면 부모가 자식에 콜백 클로저를 꽂는다. 동작은 같지만 순환 참조를 막는 [weak self]가 필요하고, 호출 기록이 어디에도 남지 않는다.
@Observable
final class ProfileEditModel {
var draft = ""
var onSaved: (String) -> Void = { _ in } // 부모가 꽂아 준다
func saveButtonTapped() {
let name = draft.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
onSaved(name)
}
}
@Observable
final class ProfileSettingsModel {
var name = "Guest"
let edit = ProfileEditModel()
init() {
edit.onSaved = { [weak self] name in // 빠뜨리면 순환 참조
self?.name = name
self?.edit.draft = ""
}
}
}① 자식은 case .delegate: return .none — 자기 delegate에 로직을 넣지 않는다. ② 부모는 .child(.delegate(…))만 매칭하고 나머지 자식 액션은 case .child: return .none. ③ 닫기만 하는 경우는 delegate 대신 @Dependency(\.dismiss)가 있다 — Day 10에서 다룬다.
4. 옵셔널 자식 — ifLet과 if let store
자식이 항상 있지 않을 때가 있다. 다운로드 패널, 편집 모드, 로그인 후에만 보이는 영역이다. State를 Child.State?로 두고, Scope 대신 부모 리듀서 뒤에 .ifLet을 붙인다. Scope는 옵셔널이 아닌 key path만 받는다.
@Reducer
struct Downloader {
@ObservableState
struct State: Equatable {
var progress = 0.0
}
enum Action {
case startButtonTapped
case progressUpdated(Double)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .startButtonTapped:
return .run { send in
for step in 1...10 {
try await Task.sleep(for: .milliseconds(300))
await send(.progressUpdated(Double(step) / 10))
}
}
case let .progressUpdated(value):
state.progress = value
return .none
}
}
}
}
@Reducer
struct DownloadScreen {
@ObservableState
struct State: Equatable {
var download: Downloader.State?
}
enum Action {
case download(Downloader.Action)
case toggleButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .toggleButtonTapped:
state.download = state.download == nil ? Downloader.State() : nil
return .none
case .download:
return .none
}
}
.ifLet(\.download, action: \.download) {
Downloader()
}
}
}struct DownloadScreenView: View {
let store: StoreOf<DownloadScreen>
var body: some View {
Form {
Button("다운로드 패널 켜기/끄기") { store.send(.toggleButtonTapped) }
if let childStore = store.scope(\.download, action: \.download) {
ProgressView(value: childStore.progress)
Button("시작") { childStore.send(.startButtonTapped) }
} else {
Text("패널 없음")
}
}
}
}ifLet은 단순한 "nil이면 건너뛰기"가 아니다. 소스 문서가 세 가지를 약속한다(TCA 소스 · ifLet).
- 자식 먼저, 부모 나중: 부모가 먼저 돌아 자식을 nil로 만들면 자식이 자기 액션을 처리할 수 없기 때문이다. 위치를 사람이 고르는
Scope와 달리 순서가 강제된다. - 이펙트 자동 취소: "It automatically cancels all child effects when it detects the child's state is nil'd out." 위 코드에서 진행 중에 패널을 끄면
.run루프가 취소된다. - 경고: 자식 상태가 nil인데 자식 액션이 들어오면 런타임 경고(
reportIssue)를 낸다.
state.download = nil 한 줄만 쓰면 된다.@MainActor @Observable
final class DownloaderModel {
var progress = 0.0
private var task: Task<Void, Never>?
func startButtonTapped() {
task = Task { [weak self] in
for step in 1...10 {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
self?.progress = Double(step) / 10
}
}
}
func cancel() { task?.cancel() }
}
@MainActor @Observable
final class DownloadScreenModel {
var download: DownloaderModel?
func toggleButtonTapped() {
if let download {
download.cancel() // 빠뜨리면 화면 밖에서 계속 돈다
self.download = nil
} else {
download = DownloaderModel()
}
}
}var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
return .none
}
// .ifLet(\.download, action: \.download) { Downloader() } ← 없음
// → .download(.startButtonTapped)는 부모의 `case .download: return .none`에서 끝난다
}화면 전환(시트·팝오버)용 옵셔널 자식은 @Presents와 .ifLet(\.$destination, …)를 쓴다. 같은 이름의 다른 오버로드이며 Day 10에서 다룬다.
5. CombineReducers와 로직 공유 — 액션 대신 메서드
CombineReducers: 묶음에 modifier 한 번
body는 이미 여러 리듀서를 차례로 합친다. CombineReducers가 필요한 때는 여러 리듀서 묶음에 modifier를 한 번에 걸고 싶을 때다. 구현은 빌더 결과를 저장했다가 그대로 위임하는 것뿐이다(TCA 소스 · CombineReducers).
@Reducer
struct Dashboard {
@ObservableState
struct State: Equatable {
var left = Counter.State()
var right = Counter.State()
}
enum Action {
case left(Counter.Action)
case right(Counter.Action)
}
var body: some ReducerOf<Self> {
CombineReducers {
Scope(\.left, action: \.left) { Counter() }
Scope(\.right, action: \.right) { Counter() }
}
._printChanges() // 묶음 전체에 한 번
}
}로직 공유: 공유 액션을 보내지 말 것
여러 액션이 같은 계산을 해야 할 때 return .send(.recalculate)처럼 공유 액션을 만들고 싶어진다. 문서는 이를 권하지 않는다. 액션을 보내는 일은 메서드 호출보다 무겁고(여러 층을 지나며 가로채질 수 있다), 공유 로직이 항상 원래 로직 뒤에만 올 수 있고, 테스트에 receive(\.recalculate) 같은 내부 사정이 끼어든다.
"It is far better to share logic via simple methods, for example on your Reducer conformance." — TCA 문서 · Performance · Sharing logic with actions
풀이: 리듀서 타입에 inout State를 받고 Effect<Action>을 돌려주는 메서드를 두고, 각 case에서 불러라.
@Reducer
struct Checkout {
@ObservableState
struct State: Equatable {
var quantity = 1
var usesCoupon = false
var total = 10_000
}
enum Action {
case quantityChanged(Int)
case couponToggled(Bool)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .quantityChanged(value):
state.quantity = value
return self.recalculate(&state)
case let .couponToggled(isOn):
state.usesCoupon = isOn
return self.recalculate(&state)
}
}
}
private func recalculate(_ state: inout State) -> Effect<Action> {
state.total = state.quantity * 10_000 - (state.usesCoupon ? 2_000 : 0)
return .none // 필요하면 .run도 돌려줄 수 있다
}
}@Observable 모델에서 private 메서드로 뽑던 것과 모양이 같다. 차이는 이 메서드가 상태를 직접 바꾸지 않고 inout으로 받은 복사본을 고친다는 점뿐이다. 부모가 자식 로직을 불러야 할 때는 .send(.child(.refresh))가 불가피하지만, 성능이 문제면 양쪽에서 부를 수 있는 헬퍼로 뽑으라고 문서가 덧붙인다.
✅ 복습 · 5분
Scope와 store.scope는 각각 무엇을 자르나? 하나만 쓰면 어떻게 되나?
Scope는 리듀서 층에서 부모 State·Action의 자식 몫을 잘라 자식 로직을 돌린다. store.scope는 뷰 층에서 자식 뷰에 줄 스토어를 자른다. store.scope만 쓰고 Scope를 빼면, 자식 뷰의 액션이 부모 스토어까지는 가지만 자식 리듀서가 없어 아무 상태도 바뀌지 않는다.Scope는 "이 방 일은 이 직원이 처리"라는 배치표, store.scope는 그 방 손님에게 주는 전용 호출벨이다. 호출벨만 있고 직원이 없으면 아무도 오지 않는다.delegate 액션을 쓰는 이유는? 자식이 case .delegate:에서 .none만 돌려주는 이유는?
@Reducer
struct OrderProbe {
@ObservableState
struct State: Equatable {
var counter = Counter.State()
var seen: [Int] = []
}
enum Action {
case counter(Counter.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, _ in
state.seen.append(state.counter.count) // 부모가 본 count 기록
return .none
}
Scope(\.counter, action: \.counter) { Counter() }
}
}🔮 코드 예측: 위 OrderProbe에 .counter(.incrementButtonTapped)를 두 번 보내면 seen은? 두 리듀서의 순서를 바꾸면?
Reduce가 위에 있으므로 부모가 먼저 돈다. 1회차: 부모가 count 0을 기록 → Counter가 1로. 2회차: 1을 기록 → 2로. 그래서 seen == [0, 1]. Scope를 위로 올리면 자식이 먼저 올린 값을 보므로 [1, 2]다. TestStore로 쓰면 첫 send는 await store.send(.counter(.incrementButtonTapped)) { $0.counter.count = 1; $0.seen = [0] }로 통과한다. ReducerBuilder가 적힌 순서대로 _reduce를 부르기 때문이다(소스 기준).🔁 Day 4 복습: 자식 뷰에서 TextField("이름", text: $store.draft)를 쓰려면 자식 기능에 무엇이 있어야 하나?
BindableAction을 채택하고 case binding(BindingAction<State>)를 가지며, body에 BindingReducer()가 있어야 한다. 뷰는 @Bindable var store: StoreOf<Child>로 받는다. 오늘 예제처럼 draftChanged(String) 액션을 유지하고 싶다면 $store.draft.sending(\.draftChanged)를 쓴다.BindingReducer)를 달고, 손(뷰)에 @Bindable 리모컨을 쥐여 줘야 한다.🛠 미니 과제 (선택 · 10분)
상황: 장바구니 화면 Cart에 수량 선택 패널 QuantityPicker가 떴다 사라진다. Xcode 프로젝트에 TCA 패키지가 추가되어 있다고 가정한다.
QuantityPicker: Statevalue: Int, 액션plusTapped,confirmTapped,delegate(.confirmed(Int)). 확인하면 delegate만 보낸다.Cart:quantity: Int,picker: QuantityPicker.State?.editTapped에서 현재 수량으로 패널을 연다. delegate를 받으면 수량을 반영하고 패널을 닫는다(ifLet사용).- 확인: TestStore로 "열기 → + 두 번 → 확인" 흐름이 통과해야 한다.
정답 코드 보기
import Testing
@Reducer
struct QuantityPicker {
@ObservableState
struct State: Equatable {
var value: Int
}
enum Action {
case plusTapped
case confirmTapped
case delegate(Delegate)
@CasePathable
enum Delegate: Equatable {
case confirmed(Int)
}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .plusTapped:
state.value += 1
return .none
case .confirmTapped:
return .send(.delegate(.confirmed(state.value)))
case .delegate:
return .none
}
}
}
}
@Reducer
struct Cart {
@ObservableState
struct State: Equatable {
var quantity = 1
var picker: QuantityPicker.State?
}
enum Action {
case editTapped
case picker(QuantityPicker.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .editTapped:
state.picker = QuantityPicker.State(value: state.quantity)
return .none
case let .picker(.delegate(.confirmed(value))):
state.quantity = value
state.picker = nil
return .none
case .picker:
return .none
}
}
.ifLet(\.picker, action: \.picker) { QuantityPicker() }
}
}
@MainActor
@Test func cartFlow() async {
let store = TestStore(initialState: Cart.State()) { Cart() }
await store.send(.editTapped) { $0.picker = QuantityPicker.State(value: 1) }
await store.send(.picker(.plusTapped)) { $0.picker?.value = 2 }
await store.send(.picker(.plusTapped)) { $0.picker?.value = 3 }
await store.send(.picker(.confirmTapped))
await store.receive(\.picker.delegate.confirmed) {
$0.quantity = 3
$0.picker = nil
}
}