@Observable 모델에서 메서드를 부르는 일은 거의 공짜다. TCA에서 액션을 보내는 일은 그렇지 않다. 액션은 루트부터 여러 층의 리듀서를 지나가는 메시지이고, 리듀서는 main thread에서 돈다. 그래서 TCA 앱이 느려지는 원인은 대개 넷으로 모인다. 불필요한 액션, 리듀서 안의 무거운 계산, 초당 수십 번의 액션, 계산 프로퍼티를 따라간 스코프다. 1부는 공식 Performance 아티클의 네 함정과 @ObservableState의 관찰 범위를 SwiftUI 성능 규칙과 나란히 본다.1부 목표 · 23분
return .send(.sharedComputation)패턴을inout State를 받는 메서드로 바꾸고, 테스트가 짧아지는 이유를 설명한다.- 리듀서 안의 무거운 반복문을
.run+Task.yield()로 옮기고, 진행률 액션을 약 100번(루프 100번 + 완료 1번 = 최대 101번)으로 줄인다. - 슬라이더 드래그마다 액션을 보내는 코드를 로컬
@State+onEditingChanged로 고친다. - 계산 프로퍼티로 스코프한 코드를 찾아 저장 프로퍼티 스코프로 바꾸고,
@ObservableState가 무엇을 추적하는지 말한다.
1. 액션은 메서드 호출이 아니다 — 로직 공유는 메서드로 🔥 핵심
"Sending actions is not as lightweight of an operation as, say, calling a method on a class. Actions travel through multiple layers of an application, and at each layer a reducer can intercept and reinterpret the action."
액션 전송은 클래스 메서드 호출만큼 가볍지 않다. 액션은 앱의 여러 층을 지나가고, 층마다 리듀서가 가로채 해석할 수 있다(TCA 문서 · Performance).
흔한 안티패턴은 이렇다. 버튼·토글·텍스트 필드 세 곳이 바뀔 때마다 같은 후처리를 하고 싶어서, 후처리를 전용 액션으로 만들고 각 case에서 .send로 이어 보낸다.
@Reducer
struct NoteEditor {
@ObservableState
struct State: Equatable {
var count = 0
var isEnabled = false
var text = ""
var summary = ""
}
enum Action {
case buttonTapped
case toggleChanged
case textFieldChanged(String)
case sharedComputation // 사용자가 한 일이 아닌 내부 액션
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .buttonTapped:
state.count += 1
return .send(.sharedComputation)
case .toggleChanged:
state.isEnabled.toggle()
return .send(.sharedComputation)
case let .textFieldChanged(text):
state.text = text
return .send(.sharedComputation)
case .sharedComputation:
state.summary = "\(state.count) · \(state.isEnabled) · \(state.text.count)자"
return .none
}
}
}
}문서는 성능 말고도 두 가지 문제를 더 짚는다. 공유 로직이 항상 뒤에만 돌 수 있어서 "먼저 공유 로직, 그다음 핵심 로직" 순서를 표현할 수 없다. 그리고 테스트가 매번 receive(\.sharedComputation)를 단언해야 해서, 사용자 시나리오처럼 읽히지 않는다.
@Reducer
struct NoteEditorFixed {
@ObservableState
struct State: Equatable {
var count = 0
var isEnabled = false
var text = ""
var summary = ""
}
enum Action {
case buttonTapped
case toggleChanged
case textFieldChanged(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .buttonTapped:
state.count += 1
return sharedComputation(state: &state)
case .toggleChanged:
let effect = sharedComputation(state: &state) // 먼저 돌리는 것도 가능하지만
state.isEnabled.toggle() // 순서가 결과를 바꾼다: summary는 토글 전 값으로
// 계산돼 isEnabled=true인데 "0 · false · 0자"가 남는다
return effect
case let .textFieldChanged(text):
state.text = text
return sharedComputation(state: &state)
}
}
}
func sharedComputation(state: inout State) -> Effect<Action> {
state.summary = "\(state.count) · \(state.isEnabled) · \(state.text.count)자"
return .none // 필요하면 .run { … }도 돌려줄 수 있다
}
}@MainActor
func testNoteEditor() async {
let store = TestStore(initialState: NoteEditorFixed.State()) { NoteEditorFixed() }
await store.send(.buttonTapped) {
$0.count = 1
$0.summary = "1 · false · 0자"
}
await store.send(.textFieldChanged("hi")) {
$0.text = "hi"
$0.summary = "1 · false · 2자"
}
}부모가 자식 로직을 돌리려고 .send(.child(.refresh))를 보내는 것도 같은 비용이 든다. 성능이 문제라면 공유 로직을 양쪽에서 부를 수 있는 헬퍼로 뽑는다(Day 5의 "로직 공유는 메서드로"와 같은 원칙).
| 할 일 | SwiftUI (@Observable) | TCA |
|---|---|---|
| 내부 로직 재사용 | private func recompute() 호출 | func shared(state: inout State) -> Effect<Action> |
| 내부 로직을 액션으로 | 해당 없음 | .send(.shared) — 경로 2회 + 테스트 잡음 |
| 호출 비용 | 메서드 한 번 | 액션 한 번 = 루트부터 모든 리듀서 통과 |
2. 고빈도 액션과 무거운 계산
리듀서는 main thread에서 돈다
문서의 한 줄이 규칙이다. "Reducers are run on the main thread and so they are not appropriate for performing intense CPU work." 무거운 반복문을 리듀서에 두면 그동안 화면이 멈춘다. 계산은 .run 이펙트로 옮긴다. 이펙트는 협동 스레드 풀(cooperative thread pool)에서 돌고, 결과는 액션으로 돌려보낸다. 스레드 풀을 오래 붙잡지 않도록 가끔 Task.yield()로 양보한다.
case .analyzeButtonTapped:
var total = 0
for value in state.samples {
total &+= value &* value // 무거운 계산이라고 치자
}
state.result = total
return .none@Reducer
struct Analyzer {
@ObservableState
struct State: Equatable {
var samples: [Int] = Array(0..<100_000)
var result: Int?
}
enum Action {
case analyzeButtonTapped
case analysisResponse(Int)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .analyzeButtonTapped:
return .run { [samples = state.samples] send in // 필요한 값만 복사해 캡처
var total = 0
for (index, value) in samples.enumerated() {
total &+= value &* value
if index.isMultiple(of: 1_000) {
await Task.yield() // 스레드 풀에 양보
}
}
await send(.analysisResponse(total))
}
case let .analysisResponse(total):
state.result = total
return .none
}
}
}
}SwiftUI 코스 Day 12에서 "await는 기다리는 동안 양보일 뿐, 계산을 백그라운드로 보내지 않는다"고 했다. TCA에서는 .run의 클로저 자체가 main actor 밖에서 시작하므로, 무거운 일을 거기에 두면 된다. 왜 [samples = state.samples]처럼 캡처해야 하는지는 2부에서 본다.
진행률은 솎아서 보낸다
10만 단계짜리 작업이 매 단계 .progress를 보내면 액션이 10만 개다. 진행률은 0.0~1.0 사이에서만 움직이므로 그만큼의 정밀도는 필요 없다. 문서는 "최대 100번만 보고하도록 계산하라"고 한다. 아래 코드는 루프 안에서 100번, 끝에서 .progress(1) 1번을 보내므로 실제로는 최대 101번(루프 100번 + 완료 1번)이다(TestStore 실측 101개).
@Reducer
struct Importer {
@ObservableState
struct State: Equatable { var progress = 0.0 }
enum Action { case startButtonTapped, progress(Double) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .startButtonTapped:
return .run { send in
let total = 100_000
let interval = max(total / 100, 1)
for step in 0..<total {
// 한 단계 작업…
if step.isMultiple(of: interval) {
await send(.progress(Double(step) / Double(total)))
}
}
await send(.progress(1))
}
case let .progress(value):
state.progress = value
return .none
}
}
}
}슬라이더 — 확정된 값만 스토어로
Slider에 스토어 바인딩을 바로 주면 드래그하는 동안 수십~수백 개의 액션이 간다. 문제가 되면 드래그 중의 값은 뷰의 로컬 @State가 받고, 손을 뗄 때(onEditingChanged가 false) 한 번만 보낸다. 문서 예시는 클로저 인자를 생략했지만, 실제 onEditingChanged는 Bool을 받으므로 아래처럼 쓴다.
@Reducer
struct PhotoAdjust {
@ObservableState
struct State: Equatable { var opacity = 1.0 }
enum Action { case setOpacity(Double) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .setOpacity(value):
state.opacity = value // 저장·분석 같은 무거운 후처리도 여기서 1번만
return .none
}
}
}
}
struct PhotoAdjustView: View {
let store: StoreOf<PhotoAdjust>
@State private var opacity = 1.0
var body: some View {
Slider(value: $opacity, in: 0...1) { isEditing in
if !isEditing { store.send(.setOpacity(opacity)) }
}
.onAppear { opacity = store.opacity }
}
}문서의 기준은 "important logic and effects를 일으키는 액션"이다. 드래그 중의 중간값, 스크롤 오프셋, 매 프레임 타이머처럼 로직이 필요 없는 값은 뷰의 @State에 두어도 된다. SwiftUI에서 @Observable 모델 프로퍼티에 바로 쓰는 것도 body 무효화를 일으키므로, 자주 바뀌는 값을 뷰 로컬에 두는 원칙은 두 방식 모두에 통한다.
3. 관찰 범위와 스코프 비용
@ObservableState는 body가 읽은 필드만 추적한다
SwiftUI 코스 Day 9에서 "@Observable은 body가 읽은 프로퍼티를 추적한다"고 했다. TCA도 같다. store.title을 읽으면 스토어가 관찰 레지스트라에 접근을 등록하고, title이 바뀔 때만 그 뷰가 다시 계산된다. 소스 기준으로 두 가지 최적화가 더 있다(ObservableState.swift).
- 같은 값 대입은 조용히: 매크로가
Equatable필드에lhs != rhs검사를 생성한다.state.isLoading = false를 이미false일 때 해도 관찰자에게 알리지 않는다. - 자식 상태·컬렉션은 정체성으로 비교:
@ObservableState자식은_$id로,IdentifiedArray·StackState는 id 목록으로 "바뀌었나"를 판정한다. 행 하나의 필드가 바뀌어도 id 목록이 같으면 목록을 그리는 부모 쪽 필드는 바뀐 것으로 치지 않는다. 행 스토어를 읽는 행 뷰만 갱신된다.
그래서 WWDC25 세션의 교훈이 TCA에도 그대로 적용된다. Apple 예제에서 각 행이 model.isFavorite(landmark)를 불렀고, 그 함수가 즐겨찾기 배열 전체를 읽어서 하트 하나에 모든 행이 다시 그려졌다(WWDC25 · Optimize SwiftUI performance with Instruments). TCA에서도 부모 body에서 store.todos.filter(\.isDone).count처럼 모든 행을 훑으면 부모가 모든 행의 isDone에 의존한다. 이런 집계값은 상태에 저장해 두거나, 필요한 자식 뷰로 내려 보낸다.
스코프는 저장 프로퍼티를 따라간다
1.5부터 스코프된 스토어는 자기 상태를 따로 들고 있지 않고 루트 스토어에 대한 참조만 갖는다. 스코프된 스토어에서 상태를 읽을 때마다 루트 상태를 자식 상태로 즉석 변환한다. 스코프 키패스가 단순한 저장 프로퍼티면 문제없지만, 무거운 계산 프로퍼티면 접근할 때마다, 필드를 여러 개 읽으면 여러 번 다시 계산된다. 루트에 가까울수록 피해가 크다.
extension Dashboard.State {
var computedStats: Stats.State {
Stats.State(prices: prices.filter { $0 > 0 }.sorted()) // 읽을 때마다 전체 순회·정렬
}
}
// 뷰에서
StatsView(store: store.scope(\.computedStats, action: \.stats))@Reducer
struct Stats {
@ObservableState
struct State: Equatable {
var prices: [Int] = []
var total: Int { prices.reduce(0, +) } // 자식 안에서 계산
}
enum Action { case refreshTapped }
var body: some ReducerOf<Self> {
Reduce { _, _ in .none }
}
}
@Reducer
struct Dashboard {
@ObservableState
struct State: Equatable {
var prices: [Int] = []
var stats = Stats.State() // 저장 프로퍼티 — 가격이 바뀔 때 리듀서가 채운다
}
enum Action { case priceAdded(Int), stats(Stats.Action) }
var body: some ReducerOf<Self> {
Scope(\.stats, action: \.stats) { Stats() }
Reduce { state, action in
switch action {
case let .priceAdded(price):
state.prices.append(price)
state.stats.prices = state.prices.filter { $0 > 0 } // 바뀔 때 한 번만
return .none
case .stats:
return .none
}
}
}
}
struct StatsView: View {
let store: StoreOf<Stats>
var body: some View { Text("합계 \(store.total)") }
}
struct DashboardView: View {
let store: StoreOf<Dashboard>
var body: some View {
StatsView(store: store.scope(\.stats, action: \.stats)) // 단순 getter 키패스
}
}문서의 조언 그대로다. 스코프에 쓰는 계산 프로퍼티 안에 print를 넣고 앱을 돌려 보면 몇 번 불리는지 바로 보인다. SwiftUI 쪽 Self._printChanges()와 TCA의 _printChanges()·signpost 같은 측정 도구는 2부에서 정리한다.
| 증상 | SwiftUI에서의 원인·처방 | TCA에서의 원인·처방 |
|---|---|---|
| 상관없는 변경에 body가 돈다 | 모델 전체를 넘김 → 쓰는 값만 넘긴다(Day 9) | body에서 필요 이상 읽음 → 행 스토어만 넘기고 집계는 상태에 |
| 간접 읽기로 의존성 폭증 | 함수가 배열 전체를 읽음(WWDC25 306) | 계산 프로퍼티가 컬렉션 전체를 읽음 / 계산 프로퍼티 스코프 |
| 입력 중 버벅임 | 자주 바뀌는 값을 공유 모델·환경에 | 고빈도 액션 → 로컬 @State + 확정 시 1번 |
| 탭 순간 멈춤 | main actor에서 동기 계산 | 리듀서에서 계산 → .run + Task.yield() |
✅ 복습 · 5분
공유 로직을 전용 액션(.send(.shared)) 대신 리듀서 메서드로 두면 좋은 점 세 가지는?
receive 단언이 사라져 사용자 대본처럼 읽힌다. 메서드는 inout State를 받아 상태를 고치고 Effect<Action>를 돌려줄 수 있으며 의존성도 그대로 쓸 수 있다.스코프에 계산 프로퍼티를 쓰면 왜 1.5 이후 느려질 수 있나? 처방은?
🔮 코드 예측: 1절의 ❌ NoteEditor로 await store.send(.buttonTapped) { $0.count = 1 }만 쓰고 테스트를 끝내면 어떻게 되나?
.buttonTapped가 .send(.sharedComputation) 이펙트를 돌려주므로 TestStore는 .sharedComputation을 받는다. exhaustive 테스트에서는 받은 액션을 receive(\.sharedComputation) { $0.summary = "1 · false · 0자" }로 단언해야 하는데, 이를 빼먹었으니 테스트가 끝날 때 단언되지 않은 수신 액션이 남았다고 실패한다. ✅ 버전은 요약이 같은 액션 안에서 계산되므로 send 클로저에 summary만 더하면 끝난다.🔁 Day 12 복습: 이펙트 안에서 공유 값을 고치려면 무엇을 캡처하고, 테스트에서는 그 변경을 어떻게 단언하나?
.run { [count = state.$count] _ in count.withLock { $0 += 1 } }처럼 Shared 참조($count)를 캡처하고 withLock으로 고친다. 액션 없이 바뀐 변경이라 send 클로저로는 잡히지 않으므로, send 직후 store.assert { $0.$count.withLock { $0 = 1 } }로 단언한다(finish()는 그 뒤 — 먼저 부르면 실패). 빠뜨리면 TestStore가 추적된 변경을 단언하지 않았다고 실패시킨다.2부는 동시성과 측정이다. 스토어·리듀서·Send는 어느 actor에서 돌고 .run은 어디서 도는지, 왜 [count = state.count]처럼 캡처해야 하는지, 의존성은 왜 Sendable이어야 하고 actor와 어떻게 엮는지, Swift 6 엄격 모드에서 새로 걸리는 것들을 본다. 마지막으로 _printChanges와 signpost로 1부의 함정을 직접 찾아낸다.