← TCA 14일 마스터 13 / 14 · 2부

Day 13 · 2부 · 동시성과 측정 — MainActor·Sendable·signpost

리듀서와 이펙트 사이의 동시성 경계, Swift 6에서 걸리는 진단과 처방, 그리고 _printChanges·signpost로 성능 함정을 직접 찾는 법.
진행률
0 / 0 완료
1부에서 무거운 계산을 리듀서에서 .run으로 옮겼다. 그 순간 코드는 두 세계의 경계를 넘는다. 스토어와 리듀서는 main actor에 있고, .run의 클로저는 그 밖에서 돈다. 경계를 넘는 값은 모두 Sendable이어야 하고, Swift 6에서는 이걸 어기면 경고가 아니라 에러다. 2부는 Swift Concurrency와 TCA의 경계 규칙, 그리고 1부의 함정을 눈으로 확인하는 측정 도구 _printChanges·signpost를 다룬다.

2부 목표 · 24분

  • 스토어·리듀서·Send·.run이 각각 어느 실행 맥락에서 도는지 그림으로 그린다.
  • 이펙트에서 inout state를 캡처해 나는 빌드 에러를 캡처 리스트로 고친다.
  • 의존성 클라이언트를 @Sendable 클로저로 만들고, 내부 캐시는 actor로 감싼다.
  • _printChanges()와 signpost()를 붙여 불필요한 액션과 끝나지 않는 이펙트를 찾는다.

1. 누가 어디서 도나 — 스토어는 main actor, .run은 그 밖 🔥 핵심

소스 기준으로 Store는 @preconcurrency @MainActor로 선언되어 있다. 1.14 마이그레이션 가이드가 이 변화를 설명한다. 원래부터 main thread 전용이었지만 컴파일러는 몰랐고, 1.14부터 타입에 적혔다(TCA 문서 · Migrating to 1.14). SwiftUI에서 View 프로토콜이 iOS 18 SDK부터 @MainActor로 표시된 것과 같은 흐름이다(WWDC24 · What's new in SwiftUI).

📝 TCA 문서 원문

"Prior to version 1.14 of the library the Store type has only ever been meant to be used on the main thread, but that contract was not enforced in any major way."

1.14 전에도 스토어는 main thread에서만 쓰는 게 약속이었지만 강제되지 않았다. 지금은 컴파일러가 강제한다. 리듀서도 스토어가 돌리므로 main thread에서 실행된다(Performance 아티클: "Reducers are run on the main thread").

main actor store.send(.tap) 리듀서.run 반환 리듀서.response 처리 협동 스레드 풀 (nonisolated) .run { send in … }네트워크 · 계산 · Task.yield 캡처한 값 (Sendable만) await send(.response) Send는 @MainActor 타입 — 풀에서 부르려면 await로 main actor에 올라탄다
상태를 고치는 곳(리듀서)은 항상 main actor, 오래 걸리는 일은 풀에서. 둘 사이는 Sendable 값과 액션만 오간다.

이 그림이 1부 규칙의 이유다. 리듀서가 main actor에서 돌기 때문에 거기서 계산하면 UI가 멈춘다. .run의 operation은 소스 기준 @escaping @Sendable (Send<Action>) async throws -> Void이고, 어떤 actor에도 묶이지 않은 async 클로저라 협동 스레드 풀에서 돈다. 결과를 돌려보내는 Send는 @MainActor 구조체라서 await send(…)로 부른다.

✅ 결과는 await send로 — 애니메이션을 붙여 보낼 수도 있다
struct Fact: Equatable, Sendable {
  var number: Int
  var text: String
}

@Reducer
struct FactFeature {
  @ObservableState
  struct State: Equatable {
    var number = 0
    var fact: Fact?
    var isLoading = false
  }
  enum Action { case factButtonTapped, factResponse(Fact) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .factButtonTapped:
        state.isLoading = true                       // main actor: 상태 변경
        return .run { [number = state.number] send in
          try await Task.sleep(for: .milliseconds(300))   // 풀: 기다림
          let fact = Fact(number: number, text: "\(number)은 좋은 숫자다")
          await send(.factResponse(fact), animation: .default)   // main actor로 복귀
        }
      case let .factResponse(fact):
        state.isLoading = false
        state.fact = fact
        return .none
      }
    }
  }
}
개념SwiftUI (@Observable)TCA
UI 쪽 격리View가 @MainActor (iOS 18 SDK~)Store가 @MainActor (1.14~)
상태를 고치는 곳@MainActor 모델 메서드리듀서 (main thread)
비동기 작업 시작.task { }·Task { } — 만든 곳의 actor 상속.run { send in } — nonisolated, 풀에서
결과 반영await 뒤 main actor로 재개해 대입await send(.response(…)) → 리듀서
무거운 계산 오프로딩전용 actor에 보내 await (SwiftUI Day 12).run 안에서 + Task.yield()
⚠️ Xcode 16 미만 + 엄격 모드에서의 뷰 헬퍼

1.14 가이드의 유일한 호환성 주의다. 뷰의 body가 아닌 계산 프로퍼티·메서드에서 store를 읽는데 Xcode 16 미만이라 View가 아직 main actor로 표시되지 않았다면, 그 헬퍼에 @MainActor를 붙인다. Xcode 16 이상 SDK에서는 View가 main actor라 필요 없다.

2. Sendable 규칙 — 이펙트가 캡처하는 것, 의존성이 가진 것

공식 아티클의 핵심 문장이다. "the closure can only capture Sendable variables that are bound with let." @Sendable 클로저는 let으로 묶인 Sendable 값만 캡처할 수 있다(TCA 문서 · Adopting Swift concurrency). 여기에 걸리는 곳은 두 군데, 이펙트 안에서 상태를 읽을 때와 의존성을 쓸 때다.

상태는 캡처 리스트로 복사해 넣는다

리듀서의 state는 inout 파라미터라서 그대로 캡처할 수 없다. 이 코드를 실제 TCA 1.26.2로 빌드하면 Swift 5·6 모드 모두 빌드 에러다. Swift 5 모드는 "escaping closure captures 'inout' parameter 'state'", Swift 6 모드는 "mutable capture of 'inout' parameter 'state' is not allowed in concurrently-executing code"로 메시지만 다르다.

❌ inout state를 이펙트에서 읽음 — Swift 5·6 모두 빌드 에러 (escaping closure captures / mutable capture of 'inout' parameter 'state')
case .buttonTapped:
  return .run { send in
    try await Task.sleep(for: .seconds(1))
    await send(.delayed(state.count))
  }
✅ 캡처 리스트 — 상태 전체([state]) 또는 필요한 값만
@Reducer
struct DelayFeature {
  @ObservableState
  struct State: Equatable { var count = 0 }
  enum Action { case buttonTapped, delayed(Int) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .buttonTapped:
        return .run { [count = state.count] send in      // 이 시점 값의 복사본
          try await Task.sleep(for: .seconds(1))
          await send(.delayed(count))
        }
      case .delayed:
        return .none
      }
    }
  }
}

SwiftUI에도 같은 해법이 나온다. WWDC25의 visualEffect 예제는 main actor 프로퍼티 pulse를 @Sendable 클로저에서 읽어 진단이 나고, 해법이 { [pulse] content, _ in … } 캡처 리스트다(WWDC25 · Explore concurrency in SwiftUI, SwiftUI 코스 Day 12). 두 경우 모두 "공유된 변수 대신, 그 순간의 값 복사본을 넘긴다"는 한 가지 원칙이다. 캡처한 값은 복사본이므로, 이펙트가 도는 동안 상태가 바뀌어도 이펙트 안의 count는 그대로다.

🚨 클래스 인스턴스를 캡처하지 않는다

final class Counter { var value = 0 } 같은 non-Sendable 클래스를 .run에 캡처하면 Swift 6 모드에서 "capture of 'c' with non-Sendable type 'Counter' in a '@Sendable' closure" 에러다(실측). 이펙트에서 공유해야 하는 가변 데이터라면 Day 12의 @Shared 참조를 캡처해 withLock으로 고치거나, actor로 감싼다.

의존성은 Sendable이어야 한다

의존성은 이펙트 안, 즉 풀에서 쓰인다. 그래서 DependencyValues에 등록하는 타입은 Sendable이어야 하고, 아니면 진단이 난다. 실측(Swift 6 모드) 위치는 get/set 줄이 아니라 두 곳이다. extension FactClient: DependencyKey 줄에서 "type 'FactClient' does not conform to the 'Sendable' protocol", liveValue 줄에서 "static property 'liveValue' is not concurrency-safe…". 해법은 인터페이스가 Sendable 데이터만 갖게 하고, 클로저 엔드포인트에 @Sendable을 붙이는 것이다.

❌ 클로저에 @Sendable이 없음 — Swift 6 모드에서 DependencyKey 채택 줄·liveValue 줄 에러 (실측)
struct FactClient {
  var fetch: (Int) async throws -> String
}
✅ @Sendable 클로저 + 가변 캐시는 actor 안에 — liveValue가 actor를 붙잡는다
actor ImageCache {
  private var storage: [URL: Data] = [:]
  func data(for url: URL) -> Data? { storage[url] }
  func insert(_ data: Data, for url: URL) { storage[url] = data }
}

@DependencyClient
struct ImageClient: Sendable {
  var load: @Sendable (_ url: URL) async throws -> Data
}

extension ImageClient: DependencyKey {
  static let liveValue: ImageClient = {
    let cache = ImageCache()                   // actor는 Sendable — 클로저가 캡처해도 된다
    return ImageClient(load: { url in
      if let hit = await cache.data(for: url) { return hit }
      let (data, _) = try await URLSession.shared.data(from: url)
      await cache.insert(data, for: url)
      return data
    })
  }()
}

extension DependencyValues {
  var imageClient: ImageClient {
    get { self[ImageClient.self] }
    set { self[ImageClient.self] = newValue }
  }
}

인터페이스는 클로저 묶음(Day 7) 그대로 두고, 가변 상태는 live 구현 안의 actor가 지킨다. 리듀서는 try await imageClient.load(url)만 알고, 테스트는 testValue로 actor 없는 가짜를 넣는다. SwiftUI 코스 Day 12에서 무거운 일을 actor ThumbnailRenderer로 보낸 것과 같은 역할 분담이다.

Swift 6에서 새로 걸리는 것 — 1.15 가이드

TCA 1.15부터 라이브러리 자체는 엄격 동시성에서 경고 없이 컴파일된다. 가이드가 앱 코드의 대표 사례로 드는 건 enum case를 함수처럼 넘기는 습관이다. 다만 이 진단은 구 컴파일러 기준이고, 현재 툴체인(Swift 6.4, SE-0418)에서는 재현되지 않는다. 클로저로 여는 쪽이 어느 버전에서도 안전하다(TCA 문서 · Migrating to 1.15).

진단 메시지(요지)원인처방
mutable capture of 'inout' parameter 'state' (Swift 6) / escaping closure captures 'inout' parameter 'state' (Swift 5).run에서 state 읽기[count = state.count] 캡처
capture of … non-Sendable type … in a '@Sendable' closure클래스·비Sendable 값 캡처값 타입으로 복사, @Shared, actor
type '…Client' does not conform to the 'Sendable' protocol (DependencyKey 채택 줄) / static property 'liveValue' is not concurrency-safe의존성 클로저에 @Sendable 없음엔드포인트마다 @Sendable
Converting non-sendable function value to '@Sendable …' (현재 툴체인 Swift 6.4에서는 재현되지 않음 — 구 컴파일러 한정).map(Action.response).map { .response($0) } 또는 InferSendableFromCaptures
setter for '…' is deprecated: Use '$shared.withLock'@Shared 직접 대입state.$x.withLock { … } (Day 12)
✅ publisher의 출력을 액션으로 — case를 함수로 넘기지 말고 클로저로 연다
@Reducer
struct ClockFeature {
  @ObservableState
  struct State: Equatable { var ticks = 0 }
  enum Action { case onAppear, tick(Date) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .onAppear:
        return .publisher {
          Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .map { .tick($0) }            // .map(Action.tick) 대신
        }
      case .tick:
        state.ticks += 1
        return .none
      }
    }
  }
}

3. 측정하기 — _printChanges와 signpost

1부의 함정들은 추측으로 찾지 않는다. SwiftUI 코스 Day 9에서 "측정 → 원인 → 고침 → 다시 측정" 루프를 봤다(WWDC23 · Demystify SwiftUI performance). TCA는 액션이 데이터(enum)라서 모든 액션에 한 번에 계측을 걸 수 있다. 도구는 두 개다.

도구보여 주는 것찾는 함정SwiftUI 대응
._printChanges()받은 액션 + 상태 diff (콘솔)내부 액션 연쇄, 고빈도 액션, 쓸데없는 상태 변경Self._printChanges() — body가 왜 돌았나
._printChanges(.actionLabels)액션 이름만 한 줄씩액션 폭주 (슬라이더·진행률)—
.signpost()액션마다 처리 시간 구간, 이펙트 시작·출력·끝·취소 (Instruments)느린 리듀서, 끝나지 않는 이펙트Instruments SwiftUI 템플릿의 긴 body 업데이트
✅ 루트 리듀서에 둘 다 붙인다 — 개발 중에만 켜 두고 싶을 때
@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var fact = FactFeature.State()
  }
  enum Action { case fact(FactFeature.Action) }
  var body: some ReducerOf<Self> {
    Scope(\.fact, action: \.fact) { FactFeature() }
  }
}

@MainActor
let appStore = Store(initialState: AppFeature.State()) {
  AppFeature()
    ._printChanges()        // DEBUG 빌드에서만 출력 — 릴리스에선 그냥 통과
    .signpost("App")        // Instruments로 기록 중일 때만 signpost를 남긴다
}

_printChanges는 소스에 #if DEBUG로 감싸져 있어 릴리스에서는 출력하지 않는다. 기본 프린터 .customDump는 이런 식으로 찍는다. 1부 ❌ NoteEditor를 붙이면 버튼 한 번에 received action:이 두 번 찍혀서 내부 액션이 바로 드러난다.

콘솔 출력 예 (Swift가 아닌 로그 · 컴파일 안 됨) — 받은 액션과 상태 diff (−: 이전, +: 이후)
received action:
  AppFeature.Action.fact(.factButtonTapped)
  AppFeature.State(
    _fact: FactFeature.State(
      _number: 0,
      _fact: nil,
-     _isLoading: false
+     _isLoading: true
    )
  )

signpost는 소스 기준으로 log.signpostsEnabled일 때만 기록하므로, Instruments로 기록하지 않을 때는 거의 비용이 없다. 앱을 Profile로 빌드하고 빈 템플릿에 os_signpost 계측을 추가해 기록하면, 액션마다 "Action" 구간이, 이펙트마다 "Effect" 구간(Started from … → Finished/Cancelled)이 찍힌다. 소스 주석이 드는 대표 용도가 끝나지 않는 이펙트다. onAppear에서 위치 추적을 시작하고 onDisappear에서 끄지 않으면, Effect 구간이 끝나지 않고 계속 열려 있다.

시간 Action onAppear tick tick onDisappear Effect ① Started from onAppear … (Finished 없음 — 계속 열림) Effect ② Started → Finished 화면이 사라졌는데 ① 이펙트가 살아 있다
signpost 타임라인에서 끝나지 않는 이펙트는 오른쪽 끝까지 열린 막대로 보인다. 처방은 Day 3의 .cancellable(id:) + onDisappear에서 .cancel(id:)다.
🔑 측정 → 1부 처방 대응

received action이 한 번 탭에 두 번 이상 → 로직 공유를 메서드로. 드래그 중 액션이 줄줄이 → 로컬 @State. "Action" 구간이 길다 → 리듀서의 계산을 .run으로. 뷰가 너무 자주 갱신된다 → SwiftUI 쪽 Self._printChanges()로 무엇이 바뀌었는지 보고 읽는 범위를 줄인다. 계산 프로퍼티 스코프는 print 한 줄로 호출 횟수를 센다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
리듀서, .run의 클로저, Send는 각각 어느 실행 맥락에 있나? 그래서 무거운 계산은 어디에 두나?
리듀서는 @MainActor 스토어가 돌리므로 main thread, .run의 operation은 어떤 actor에도 묶이지 않은 @Sendable async 클로저라 협동 스레드 풀, Send는 @MainActor 구조체라 풀에서 await send(…)로 main actor에 올라타 부른다. 무거운 계산은 .run 안에 두고, 결과만 액션으로 돌려보낸다.
쉽게 말하면 계산대(main actor)에서는 계산만 빨리 하고, 오래 걸리는 포장은 뒷방(스레드 풀)에서 한다. 다 되면 벨(send)을 눌러 계산대에 알린다.
의존성 클라이언트가 내부 캐시(딕셔너리)를 가져야 한다. 어떻게 해야 Swift 6에서도 안전한가?
인터페이스는 @Sendable 클로저만 가진 Sendable struct로 두고, 가변 캐시는 actor로 감싸 liveValue를 만들 때 생성해 클로저가 캡처하게 한다. actor는 Sendable이라 캡처해도 되고, 캐시 접근은 await로 한 줄로 선다. 테스트에는 actor 없는 testValue를 넣는다.
쉽게 말하면 여러 사람이 쓰는 사물함은 관리인(actor)에게 맡긴다. 사람들은 관리인에게 줄 서서 부탁만 한다.
🔮 코드 예측용 (컴파일 에러 여부부터 예측)
case .tap:
  return .run { send in
    send(.done)
  }
🔮 코드 예측: 위 코드는 컴파일되나? 된다면 무엇이 일어나고, 안 된다면 무슨 에러인가?
컴파일되지 않는다. 실제 TCA 1.26.2로 타입 검사하면 Swift 5·6 모드 모두 "main actor-isolated instance method 'callAsFunction' cannot be called from outside of the actor" 에러다. Send가 @MainActor인데, .run의 클로저는 main actor 밖(nonisolated)이라 동기 호출이 불가능하다. await send(.done)로 고친다.
쉽게 말하면 뒷방에서 계산대 벨을 누르려면 계산대까지 걸어가야 한다. await가 "걸어가기"다.
🔁 1부 복습: 슬라이더 드래그마다 액션이 수백 개 간다. _printChanges로 어떻게 확인하고, 어떻게 고치나?
._printChanges(.actionLabels)를 붙이고 드래그하면 received action: …setOpacity…가 줄줄이 찍힌다. 고침은 드래그 중 값을 뷰의 로컬 @State가 받고, Slider의 onEditingChanged가 false일 때(손을 뗄 때) store.send(.setOpacity(opacity))를 한 번만 보내는 것이다.
쉽게 말하면 그림을 그리는 동안은 연습장에 그리고, 다 그리면 한 번만 선생님께 제출한다.

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

긴 글의 단어 수 세기가 리듀서 안에서 돌고 있다. 이펙트로 옮겨 main thread를 비우고, TestStore로 확인한다.

시작 코드 — 계산이 리듀서(main thread)에서 돈다
@Reducer
struct WordCounter {
  @ObservableState
  struct State: Equatable {
    var text = ""
    var wordCount: Int?
  }
  enum Action { case countButtonTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      state.wordCount = state.text.split(separator: " ").count
      return .none
    }
  }
}

확인 방법: 테스트가 통과하고, 프로젝트를 Swift 6 모드로 바꿔도 경고·에러가 없는지 본다.

정답 보기
정답 — 복사해 캡처, 풀에서 계산, 액션으로 반영
@Reducer
struct WordCounter {
  @ObservableState
  struct State: Equatable {
    var text = ""
    var wordCount: Int?
  }
  enum Action { case countButtonTapped, wordsCounted(Int) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .countButtonTapped:
        return .run { [text = state.text] send in
          var count = 0
          for (index, _) in text.split(separator: " ").enumerated() {
            count += 1
            if index.isMultiple(of: 1_000) { await Task.yield() }
          }
          await send(.wordsCounted(count))
        }
      case let .wordsCounted(count):
        state.wordCount = count
        return .none
      }
    }
  }
}

@MainActor
func testWordCounter() async {
  let store = TestStore(initialState: WordCounter.State(text: "a b c")) { WordCounter() }
  await store.send(.countButtonTapped)          // 상태 변화 없음
  await store.receive(\.wordsCounted) {
    $0.wordCount = 3
  }
}

리듀서의 countButtonTapped는 상태를 바꾸지 않으므로 send에 클로저를 달지 않는다. 로딩 표시가 필요하면 isCounting을 추가하고 두 액션에서 각각 단언한다.

출처 · 참고자료