← TCA 14일 마스터 3 / 14

Day 3 · Effect — 비동기와 취소

리듀서는 할 일을 Effect 값으로 돌려주고 Store가 실행한다 — 응답 액션, 캡처, 에러, 취소, 타이머, 뷰 수명에 묶인 스트림까지.
진행률
0 / 0 완료
SwiftUI 코스에서는 비동기 일을 @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). 로직 공유용으로는 쓰지 말라고 문서가 경고한다
View Reducer 동기 · state만 바꾼다 State Effect 실행 (Store) async · 바깥세상 API · 타이머 ① 액션 ② 변경 ③ return .run ④ await ⑤ send(응답 액션) ⑥ 다시 그림
리듀서는 ③에서 주문서만 내고 끝난다. 기다리는 일(④)은 Store가 하고, 결과는 ⑤ 응답 액션으로 다시 리듀서를 지난다.

같은 "숫자 상식 불러오기"를 두 방식으로 쓰면 차이가 보인다. SwiftUI는 모델 메서드 하나가 기다리기와 대입을 다 한다.

SwiftUI(@Observable) — 메서드가 직접 기다리고 직접 대입한다
@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() }
    }
  }
}
❌ 리듀서 안에서 await — 컴파일 에러 (Reduce 클로저는 동기·non-throwing)
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 type

TCA에서는 요청을 .run에 담고, 결과를 받을 응답 액션(factResponse)을 하나 더 만든다. 공식 튜토리얼과 같은 구조다(TCA 튜토리얼 · Adding side effects).

✅ .run으로 요청 → factResponse로 결과를 되돌린다
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) }
    }
  }
}
📝 TCA 문서 원문

"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' … 에러다.

❌ inout state를 이펙트 안에서 읽는다 — 컴파일 에러(Swift 5·6 모두)
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로 감싸 성공·실패를 둘 다 액션으로 보낸다.

✅ Result로 감싸 실패도 액션으로 — isLoading이 영원히 남지 않는다
@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에서는 눈에 보이는 액션이 되고, 테스트가 그 액션을 단언하게 된다.

🔬 Sendable 한 줄 정리

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).

SwiftUI(@Observable) — .task(id:)가 query가 바뀔 때마다 이전 작업을 취소
@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() }
  }
}
✅ TCA — sleep + cancelInFlight로 디바운스 (뷰 없이도 동작)
@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
      }
    }
  }
}
시간 0.0s 0.1s 0.2s 0.5s "s" 입력 → sleep 0.3s ✕ "sw" 입력 ✕ "swi" 입력 searchResponse("swi") ✕ = 다음 입력의 cancelInFlight가 취소
같은 ID로 새 이펙트가 시작될 때마다 이전 것이 취소된다. 타이핑이 0.3초 멈춘 뒤의 마지막 요청만 응답을 보낸다.
비교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로 돈다.

✅ 스톱워치 — cancelInFlight로 버튼 연타에도 타이머는 하나만
@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).

✅ .task { await store.send(.task).finish() } — 뷰가 사라지면 for-await도 끝난다
@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() }
  }
}
뷰 화면에 떠 있음 .task await store.send(.task).finish() 이펙트 for await 알림 send(.screenshotTaken) × 3 사라짐 SwiftUI가 취소 함께 취소됨 onAppear에서 send만 하면 기다리는 쪽이 없어 이 취소 사슬이 끊긴다
StoreTask를 .task 안에서 기다리는 것이 "뷰 수명 = 이펙트 수명"을 만드는 연결 고리다.
🚨 흔한 실수 — onAppear에서 send만 한다

.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)을 만들어 그 액션에서 상태를 바꾼다. 리듀서가 동기라서 같은 입력에 늘 같은 결과가 나오고, 테스트가 쉬워진다.
쉽게 말하면 주방장(리듀서)은 장보러 나가지 않는다. 심부름 쪽지(Effect)를 써 주면 심부름꾼(Store)이 다녀와서 "사 왔어요"(응답 액션)라고 알려 준다.
.cancellable(id: X, cancelInFlight: true)에서 cancelInFlight를 빼면 검색 디바운스는 어떻게 되나?
새 입력이 와도 이전 이펙트가 취소되지 않는다. 글자마다 0.3초 뒤 응답이 하나씩 와서, "s" · "sw" · "swi" 결과가 모두 차례로 들어온다. 네트워크였다면 늦게 도착한 옛 응답이 최신 결과를 덮어쓸 수도 있다. 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).
TestStore + TestClock으로 확정 (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) 다음에 일어나는 일을 순서대로 말하면? 오늘 배운 것이 어디에 끼어드나?
Store가 리듀서를 불러 state.count를 바꾸고, @ObservableState 덕분에 count를 읽은 뷰가 다시 그려진다. 리듀서가 돌려준 값이 .none이면 끝이다. 오늘은 그 반환값 자리에 .run이 들어가 Store가 비동기 일을 실행하고, 결과를 다시 액션으로 보내 같은 순환을 한 번 더 돈다.
쉽게 말하면 Day 2는 "주문 → 요리 → 끝"이었고, 오늘은 요리 뒤에 "재료 더 사 와" 쪽지가 붙을 수 있다는 걸 배웠다.

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

카운트다운 타이머를 만든다. Xcode 프로젝트에 TCA 패키지를 추가했다고 가정하고, Preview 하나로 확인한다.

  1. "시작"을 누르면 1초마다 remaining이 1씩 줄어든다.
  2. 0이 되면 타이머 이펙트가 스스로 멈춘다(.cancel(id:)).
  3. "정지"를 누르면 멈추고, 다시 "시작"하면 남은 값부터 이어간다. 연타해도 타이머는 하나뿐이다.
시작 코드
@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에서 스스로 멈추고 버튼이 "시작"으로 돌아오는지 본다.

정답 코드 보기
정답 — 켜기는 run + cancelInFlight, 끄기는 cancel
@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이면 스스로 멈춤
      }
    }
  }
}

출처 · 참고자료