@Observable 모델 메서드 안에서 끝냈다. await로 기다리고, 결과를 self.fact에 바로 넣었다. TCA의 리듀서는 동기 함수라서 기다릴 수 없고, 바깥세상과 하는 일은 Effect라는 값으로 돌려준다. 실행은 Store가 하고, 결과는 다시 액션이 되어 리듀서로 돌아온다. 오늘은 이 왕복과, 시작한 일을 멈추는 취소를 다룬다.오늘의 목표 · 25분
- 리듀서 안에서
await를 부르려던 코드를.run+ 응답 액션으로 고친다. inout state캡처 에러를 캡처 리스트([count = state.count])로 고치고, 던지는 에러를 액션으로 바꾼다..cancellable(id:)·.cancel(id:)·cancelInFlight로 취소 버튼, 검색 디바운스, 타이머를 만든다.- 알림 스트림 같은 오래 사는 이펙트를
.task { await store.send(.task).finish() }로 뷰 수명에 묶는다.
1. 리듀서는 일을 하지 않고 "할 일"을 돌려준다 🔥 핵심
Day 2에서 모든 case 끝에 return .none을 썼다. 그 반환 타입이 Effect<Action>이다. 리듀서는 상태를 바꾼 뒤 "이제 바깥에서 이 일을 해 줘"라는 주문서를 돌려주고, Store가 그 주문서를 실행한다. 소스를 열면 Effect 안에는 none · publisher · run 세 모양뿐이다(Effect.swift).
| 만드는 법 | 뜻 | 언제 |
|---|---|---|
.none | 할 일 없음 | 상태만 바꾸면 끝날 때 (대부분의 case) |
.run { send in … } | async 클로저를 실행. send로 액션을 몇 번이든 되돌림 | 네트워크, 타이머, 알림 스트림 — 실무의 거의 전부 |
.send(.action) | 액션 하나를 즉시 되돌림 | 자식→부모 delegate 액션(Day 5). 로직 공유용으로는 쓰지 말라고 문서가 경고한다 |
같은 "숫자 상식 불러오기"를 두 방식으로 쓰면 차이가 보인다. SwiftUI는 모델 메서드 하나가 기다리기와 대입을 다 한다.
@Observable @MainActor
final class FactModel {
var count = 0
var fact: String?
var isLoading = false
func factButtonTapped() async {
fact = nil
isLoading = true
defer { isLoading = false }
fact = try? await FactAPI.fetch(count) // 기다린 뒤 바로 대입
}
}
struct FactScreen: View {
@State private var model = FactModel()
var body: some View {
Button("숫자 상식") {
Task { await model.factButtonTapped() }
}
}
}Reduce { state, action in
switch action {
case .factButtonTapped:
let (data, _) = try await URLSession.shared.data(from: url)
state.fact = String(decoding: data, as: UTF8.self)
return .none
}
}
// error: cannot pass function of type '(inout State, Action) async throws -> Effect<Action>'
// to parameter expecting synchronous function typeTCA에서는 요청을 .run에 담고, 결과를 받을 응답 액션(factResponse)을 하나 더 만든다. 공식 튜토리얼과 같은 구조다(TCA 튜토리얼 · Adding side effects).
enum FactAPI {
static func fetch(_ number: Int) async throws -> String {
let url = URL(string: "http://number-trivia.com/\(number)")!
let (data, _) = try await URLSession.shared.data(from: url)
return String(decoding: data, as: UTF8.self)
}
}
@Reducer
struct NumberFact {
@ObservableState
struct State: Equatable {
var count = 0
var fact: String?
var isLoading = false
}
enum Action {
case factButtonTapped
case factResponse(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .factButtonTapped:
state.fact = nil
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await FactAPI.fetch(count)
await send(.factResponse(fact)) // 바깥 결과 → 액션
}
case let .factResponse(fact):
state.fact = fact // 상태 변경은 여기서만
state.isLoading = false
return .none
}
}
}
}
struct NumberFactView: View {
let store: StoreOf<NumberFact>
var body: some View {
VStack {
Button("숫자 상식") { store.send(.factButtonTapped) }
if store.isLoading { ProgressView() }
if let fact = store.fact { Text(fact) }
}
}
}"The Composable Architecture separates the simple, pure transformations of state from the complex, messy side effects." — Adding side effects
상태를 바꾸는 단순하고 순수한 일과, 바깥세상과 얽힌 지저분한 일을 분리한다는 뜻이다. 같은 상태와 같은 액션을 넣으면 리듀서는 늘 같은 결과를 낸다. 그래서 Day 8에서 TestStore가 "이 액션 뒤 상태는 이것"을 정확히 단언할 수 있다.
| 비교 | SwiftUI (@Observable) | TCA |
|---|---|---|
| 비동기 일이 사는 곳 | 모델 메서드 (async func) | 리듀서가 돌려준 .run |
| 결과 반영 | self.fact = … 직접 대입 | send(.factResponse(…)) → 리듀서가 대입 |
| 상태 변경 경로 | 메서드 어디서든 | 리듀서 한 곳 (액션 로그로 추적 가능) |
| 대가 | 코드가 짧다 | 응답 액션이 하나 더 필요하다 |
2. 결과를 되돌릴 때 — 캡처와 에러
state는 캡처 리스트로 복사해서 넘긴다
.run의 클로저는 @Sendable이라 let으로 묶인 Sendable 값만 캡처할 수 있다(TCA 문서 · Swift concurrency). 리듀서의 state는 inout이라 그대로 쓰면 걸린다. 1.26.2로 직접 빌드해 보면 Swift 5 모드에서도 escaping closure captures 'inout' parameter 'state' 에러로 멈춘다(Sendable 경고가 함께 뜬다). Swift 6 모드에서는 mutable capture of 'inout' parameter 'state' … 에러다.
case .delayButtonTapped:
return .run { send in
try await Task.sleep(for: .seconds(1))
await send(.delayed(state.count))
// Swift 5: error: escaping closure captures 'inout' parameter 'state'
// Swift 6: error: mutable capture of 'inout' parameter 'state' is not allowed
// in concurrently-executing code
}고치는 법은 위 ✅ 코드의 [count = state.count]다. [state]로 통째 복사해도 되지만, 필요한 값만 이름 붙여 캡처하는 쪽이 의도가 분명하다. 이펙트는 시작 순간의 스냅샷을 들고 간다 — 기다리는 동안 상태가 바뀌어도 이펙트 안 count는 그대로다.
던진 에러는 액션으로 바꾼다
1절 ✅ 코드에서 fetch가 실패하면 어떻게 될까? 소스 기준으로 .run은 CancellationError는 조용히 삼키고, 그 밖의 에러는 catch: 핸들러가 없으면 런타임 경고(테스트에서는 실패)로 보고한다. 이때 factResponse는 오지 않으니 isLoading이 true로 남는다. 케이스 스터디처럼 Result로 감싸 성공·실패를 둘 다 액션으로 보낸다.
@Reducer
struct SafeFact {
@ObservableState
struct State: Equatable {
var count = 0
var fact: String?
var isLoading = false
var errorMessage: String?
}
enum Action {
case factButtonTapped
case factResponse(Result<String, any Error>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .factButtonTapped:
state.isLoading = true
state.errorMessage = nil
return .run { [count = state.count] send in
await send(.factResponse(Result { try await FactAPI.fetch(count) }))
}
case let .factResponse(.success(fact)):
state.isLoading = false
state.fact = fact
return .none
case let .factResponse(.failure(error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
}
}
}
}다른 방법은 .run(operation:catch:)의 catch: 클로저다. { error, send in await send(.failed) }처럼 에러와 send를 받는다. SwiftUI 모델에서 try?로 뭉개던 실패가 TCA에서는 눈에 보이는 액션이 되고, 테스트가 그 액션을 단언하게 된다.
Effect는 Sendable 구조체이고, .run 클로저는 @Sendable, send를 담은 Send는 @MainActor다. 그래서 백그라운드에서 돌던 이펙트가 await send(…)로 메인 액터에 액션을 넘긴다. 이펙트가 쓰는 API 클라이언트도 Sendable이어야 한다 — 설계는 Day 7, Swift 6 엄격 동시성은 Day 13에서 다룬다.
3. 취소 — cancellable · cancel · cancelInFlight 🔥 핵심
이펙트에 ID를 붙이면(.cancellable(id:)) 다른 액션에서 그 ID로 멈출 수 있다(.cancel(id:)). ID는 Hashable & Sendable이면 무엇이든 되지만, 오타를 막으려고 리듀서 안에 enum CancelID를 두는 게 관례다. 아래는 공식 케이스 스터디 Effects-Cancellation을 줄인 것이다.
@Reducer
struct CancellableFact {
@ObservableState
struct State: Equatable {
var count = 0
var fact: String?
var isLoading = false
}
enum Action {
case cancelButtonTapped
case factButtonTapped
case factResponse(String)
case stepperChanged(Int)
}
private enum CancelID { case factRequest }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped:
state.isLoading = false
return .cancel(id: CancelID.factRequest)
case .factButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await FactAPI.fetch(count)
await send(.factResponse(fact))
}
.cancellable(id: CancelID.factRequest)
case let .factResponse(fact):
state.isLoading = false
state.fact = fact
return .none
case let .stepperChanged(value):
state.count = value
state.fact = nil
state.isLoading = false
return .cancel(id: CancelID.factRequest) // 옛 숫자의 답은 필요 없다
}
}
}
}Send는 호출될 때마다 guard !Task.isCancelled else { return }을 먼저 본다(Effect.swift). 네트워크 호출이 취소를 무시하고 끝까지 가더라도, 취소된 이펙트의 send는 아무 액션도 넣지 않는다. 또 1.18부터 루트 Store가 해제되면 진행 중 이펙트도 취소된다(Migrating to 1.18).
cancelInFlight: true = 마지막 것만 산다
.cancellable(id:cancelInFlight: true)는 같은 ID로 돌던 이전 이펙트를 먼저 취소하고 새로 시작한다. 여기에 "잠깐 기다리기"를 앞에 붙이면 디바운스가 된다. TCA에는 따로 debounce 연산자가 필요 없다. SwiftUI에서는 같은 일을 .task(id:)가 한다(SwiftUI 코스 Day 12).
@Observable @MainActor
final class SearchModel {
var query = ""
var results: [String] = []
func search() async {
do {
try await Task.sleep(for: .milliseconds(300))
results = ["\(query) 결과"]
} catch {} // 새 글자가 들어와 취소되면 여기로
}
}
struct SearchScreen: View {
@State private var model = SearchModel()
var body: some View {
TextField("검색", text: $model.query)
.task(id: model.query) { await model.search() }
}
}@Reducer
struct SearchDebounce {
@ObservableState
struct State: Equatable {
var query = ""
var results: [String] = []
}
enum Action {
case queryChanged(String)
case searchResponse([String])
}
@Dependency(\.continuousClock) var clock // Day 7 예고: 테스트에서 시간을 갈아끼운다
private enum CancelID { case search }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .queryChanged(query):
state.query = query
return .run { send in
try await clock.sleep(for: .milliseconds(300))
await send(.searchResponse(["\(query) 결과"]))
}
.cancellable(id: CancelID.search, cancelInFlight: true)
case let .searchResponse(results):
state.results = results
return .none
}
}
}
}| 비교 | SwiftUI .task(id:) | TCA cancelInFlight |
|---|---|---|
| 취소를 거는 곳 | 뷰 (값이 바뀌거나 뷰가 사라질 때) | 리듀서 (어느 액션에서든 ID로) |
| 다른 곳에서 멈추기 | 어렵다 — 뷰에 묶여 있다 | .cancel(id:) 한 줄 |
| 테스트 | 실제 시간을 기다리거나 뷰가 필요 | TestClock으로 0.3초를 즉시 흘린다 (Day 8) |
4. 오래 사는 이펙트 — 타이머와 알림 스트림
타이머: 켜기는 run, 끄기는 cancel
타이머는 스스로 끝나지 않는 이펙트다. 멈출 수단이 없으면 화면을 떠나도 계속 돈다. 그래서 반드시 ID를 붙이고, 끄는 액션에서 .cancel(id:)를 돌려준다. Effects-Timers처럼 clock.timer(interval:)를 for await로 돈다.
@Reducer
struct Stopwatch {
@ObservableState
struct State: Equatable {
var isRunning = false
var seconds = 0
}
enum Action {
case toggleButtonTapped
case timerTicked
}
@Dependency(\.continuousClock) var clock
private enum CancelID { case timer }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .toggleButtonTapped:
state.isRunning.toggle()
guard state.isRunning else { return .cancel(id: CancelID.timer) }
return .run { send in
for await _ in clock.timer(interval: .seconds(1)) {
await send(.timerTicked)
}
}
.cancellable(id: CancelID.timer, cancelInFlight: true)
case .timerTicked:
state.seconds += 1
return .none
}
}
}
}틱마다 애니메이션을 주려면 이펙트 안에서 await send(.timerTicked, animation: .default)를 쓴다. 뷰 쪽 store.send(_:animation:)는 1.26 소스에서 soft-deprecated이고 withAnimation { _ = store.send(…) }를 권한다.
알림 스트림: 뷰 수명에 묶기
"이 화면이 떠 있는 동안만 스크린샷 알림을 센다"는 요구는 이펙트의 끝을 뷰가 정해야 한다. store.send는 StoreTask를 돌려주고, finish()는 이펙트가 끝날 때까지 기다린다. 이걸 SwiftUI .task 안에서 기다리면, 뷰가 사라져 .task가 취소될 때 그 취소가 이펙트까지 전달된다(Effects-LongLiving).
@Reducer
struct ScreenshotCounter {
@ObservableState
struct State: Equatable { var count = 0 }
enum Action {
case task
case screenshotTaken
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .task:
return .run { send in
let shots = NotificationCenter.default.notifications(
named: UIApplication.userDidTakeScreenshotNotification
)
for await _ in shots {
await send(.screenshotTaken)
}
}
case .screenshotTaken:
state.count += 1
return .none
}
}
}
}
struct ScreenshotCounterView: View {
let store: StoreOf<ScreenshotCounter>
var body: some View {
Text("스크린샷 \(store.count)번")
.task { await store.send(.task).finish() }
}
}.onAppear { store.send(.task) }로 바꾸면 컴파일은 되지만, 반환된 StoreTask를 아무도 기다리지 않는다. store가 살아 있는 한 화면을 떠나도 for await는 계속 돌고, 다시 들어오면 두 번째 스트림이 겹쳐 한 번 찍을 때 두 번 센다. 스트림처럼 끝나지 않는 이펙트는 .task + finish()로 시작하거나, 끄는 액션에서 .cancel(id:)를 돌려준다.
| 오래 사는 일 | SwiftUI (@Observable) | TCA |
|---|---|---|
| 뷰 수명에 묶기 | .task { await model.observe() } | .task { await store.send(.task).finish() } |
| 버튼으로 켜고 끄기 | Task 핸들을 프로퍼티에 저장했다가 cancel() | .cancellable(id:) / .cancel(id:) — 핸들 보관 불필요 |
| 시간 테스트 | 실제 1초를 기다린다 | @Dependency(\.continuousClock)를 TestClock으로 교체 |
✅ 복습 · 5분
리듀서 안에서 try await URLSession…을 바로 부를 수 없는 이유는? 대신 무엇을 하나?
Reduce 클로저는 동기·non-throwing 함수라서 await가 들어가면 타입이 맞지 않아 컴파일되지 않는다. 대신 요청을 .run에 담아 돌려주고, 결과를 받을 응답 액션(factResponse)을 만들어 그 액션에서 상태를 바꾼다. 리듀서가 동기라서 같은 입력에 늘 같은 결과가 나오고, 테스트가 쉬워진다..cancellable(id: X, cancelInFlight: true)에서 cancelInFlight를 빼면 검색 디바운스는 어떻게 되나?
cancelInFlight: true가 "마지막 것만 산다"를 만든다.🔮 코드 예측: 3절 SearchDebounce에 0.1초 간격으로 "s" · "sw" · "swi"를 보내고 1초 기다리면 searchResponse는 몇 번 오고 results는 무엇인가?
["swi 결과"]다. "s"와 "sw" 이펙트는 300ms를 채우기 전에 다음 입력의 cancelInFlight로 취소되고, clock.sleep이 던진 CancellationError는 .run이 조용히 삼킨다. TestStore로 확인하면 아래처럼 된다. 응답이 하나라도 더 왔다면 exhaustive 검사가 "받지 않은 액션"으로 테스트를 실패시킨다(Day 8).
import Testing
@MainActor
@Test func searchDebounceTest() async {
let clock = TestClock()
let store = TestStore(initialState: SearchDebounce.State()) {
SearchDebounce()
} withDependencies: {
$0.continuousClock = clock
}
await store.send(.queryChanged("s")) { $0.query = "s" }
await clock.advance(by: .milliseconds(100))
await store.send(.queryChanged("sw")) { $0.query = "sw" }
await clock.advance(by: .milliseconds(100))
await store.send(.queryChanged("swi")) { $0.query = "swi" }
await clock.advance(by: .seconds(1))
await store.receive(\.searchResponse) { $0.results = ["swi 결과"] }
}🔁 Day 2 복습: 버튼을 눌렀을 때 store.send(.incrementButtonTapped) 다음에 일어나는 일을 순서대로 말하면? 오늘 배운 것이 어디에 끼어드나?
state.count를 바꾸고, @ObservableState 덕분에 count를 읽은 뷰가 다시 그려진다. 리듀서가 돌려준 값이 .none이면 끝이다. 오늘은 그 반환값 자리에 .run이 들어가 Store가 비동기 일을 실행하고, 결과를 다시 액션으로 보내 같은 순환을 한 번 더 돈다.🛠 미니 과제 (선택 · 10분)
카운트다운 타이머를 만든다. Xcode 프로젝트에 TCA 패키지를 추가했다고 가정하고, Preview 하나로 확인한다.
- "시작"을 누르면 1초마다
remaining이 1씩 줄어든다. - 0이 되면 타이머 이펙트가 스스로 멈춘다(
.cancel(id:)). - "정지"를 누르면 멈추고, 다시 "시작"하면 남은 값부터 이어간다. 연타해도 타이머는 하나뿐이다.
@Reducer
struct Countdown {
@ObservableState
struct State: Equatable {
var remaining = 10
var isRunning = false
}
enum Action {
case startStopButtonTapped
case tick
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .startStopButtonTapped:
// TODO: isRunning 토글, 타이머 시작 또는 취소
return .none
case .tick:
// TODO: 1 감소, 0이면 멈추기
return .none
}
}
}
}
struct CountdownView: View {
let store: StoreOf<Countdown>
var body: some View {
VStack(spacing: 16) {
Text("\(store.remaining)").font(.largeTitle).monospacedDigit()
Button(store.isRunning ? "정지" : "시작") { store.send(.startStopButtonTapped) }
}
}
}
#Preview {
CountdownView(store: Store(initialState: Countdown.State()) { Countdown() })
}확인 방법: Preview에서 시작 → 3초 뒤 정지 → 숫자가 7에서 멈추는지, 다시 시작해 0에서 스스로 멈추고 버튼이 "시작"으로 돌아오는지 본다.
정답 코드 보기
@Reducer
struct CountdownSolution {
@ObservableState
struct State: Equatable {
var remaining = 10
var isRunning = false
}
enum Action {
case startStopButtonTapped
case tick
}
@Dependency(\.continuousClock) var clock
private enum CancelID { case timer }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .startStopButtonTapped:
guard state.remaining > 0 else { return .none }
state.isRunning.toggle()
guard state.isRunning else { return .cancel(id: CancelID.timer) }
return .run { send in
for await _ in clock.timer(interval: .seconds(1)) {
await send(.tick)
}
}
.cancellable(id: CancelID.timer, cancelInFlight: true)
case .tick:
state.remaining -= 1
guard state.remaining == 0 else { return .none }
state.isRunning = false
return .cancel(id: CancelID.timer) // 0이면 스스로 멈춤
}
}
}
}