.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).
"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").
이 그림이 1부 규칙의 이유다. 리듀서가 main actor에서 돌기 때문에 거기서 계산하면 UI가 멈춘다. .run의 operation은 소스 기준 @escaping @Sendable (Send<Action>) async throws -> Void이고, 어떤 actor에도 묶이지 않은 async 클로저라 협동 스레드 풀에서 돈다. 결과를 돌려보내는 Send는 @MainActor 구조체라서 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() |
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"로 메시지만 다르다.
case .buttonTapped:
return .run { send in
try await Task.sleep(for: .seconds(1))
await send(.delayed(state.count))
}@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을 붙이는 것이다.
struct FactClient {
var fetch: (Int) async throws -> String
}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) |
@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:이 두 번 찍혀서 내부 액션이 바로 드러난다.
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 구간이 끝나지 않고 계속 열려 있다.
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 안에 두고, 결과만 액션으로 돌려보낸다.의존성 클라이언트가 내부 캐시(딕셔너리)를 가져야 한다. 어떻게 해야 Swift 6에서도 안전한가?
@Sendable 클로저만 가진 Sendable struct로 두고, 가변 캐시는 actor로 감싸 liveValue를 만들 때 생성해 클로저가 캡처하게 한다. actor는 Sendable이라 캡처해도 되고, 캐시 접근은 await로 한 줄로 선다. 테스트에는 actor 없는 testValue를 넣는다.case .tap:
return .run { send in
send(.done)
}🔮 코드 예측: 위 코드는 컴파일되나? 된다면 무엇이 일어나고, 안 된다면 무슨 에러인가?
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로 확인한다.
@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
}
}
}- 요구 1:
.countButtonTapped는.run을 돌려주고,text는 캡처 리스트로 복사해 넘긴다. - 요구 2: 단어를 하나씩 세면서 1,000개마다
await Task.yield()하고, 결과는.wordsCounted(Int)액션으로 보낸다. - 요구 3:
"a b c"로 시작한 TestStore에서send→receive(\.wordsCounted)로wordCount == 3을 단언한다.
확인 방법: 테스트가 통과하고, 프로젝트를 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을 추가하고 두 액션에서 각각 단언한다.