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

Day 13 · 1부 · 성능 — 액션 비용·고빈도 액션·스코프·관찰 범위

TCA에서 액션은 메서드 호출이 아니다 — Performance 아티클의 네 함정과 @ObservableState의 관찰 범위를 SwiftUI 성능 규칙과 나란히 본다.
진행률
0 / 0 완료
@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. 액션은 메서드 호출이 아니다 — 로직 공유는 메서드로 🔥 핵심

📝 TCA 문서 원문

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

액션 하나가 지나가는 길 store.send 루트 리듀서 Scope 자식 리듀서 상태 변경+ 관찰자 알림 .send(.sharedComputation) = 이 길을 한 번 더 사용자는 한 번 눌렀는데 액션은 두 개 메서드 호출 case .buttonTapped sharedComputation(&state) 같은 리듀서 안에서 즉시 — 액션 1개
내부 로직을 위해 액션을 보내면 사용자가 한 일 하나에 액션 전체 경로를 두 번 지난다.

흔한 안티패턴은 이렇다. 버튼·토글·텍스트 필드 세 곳이 바뀔 때마다 같은 후처리를 하고 싶어서, 후처리를 전용 액션으로 만들고 각 case에서 .send로 이어 보낸다.

❌ 공유 로직을 전용 액션으로 — 사용자 행동 1번에 액션 2번
@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)를 단언해야 해서, 사용자 시나리오처럼 읽히지 않는다.

✅ 공유 로직은 리듀서 메서드 — inout State를 받고 Effect를 돌려준다
@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 { … }도 돌려줄 수 있다
  }
}
✅ 테스트가 사용자 대본처럼 읽힌다 — receive 단언이 사라짐
@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()로 양보한다.

❌ 리듀서 안에서 10만 번 반복 — main thread가 막힌다
case .analyzeButtonTapped:
  var total = 0
  for value in state.samples {
    total &+= value &* value        // 무거운 계산이라고 치자
  }
  state.result = total
  return .none
✅ .run으로 옮기고, 1,000번마다 양보, 결과는 액션으로
@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개).

매 단계 보고: 액션 100,000개 ▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮ (빈틈 없음) interval = max / 100: 액션 101개 (루프 100 + 완료 1) 사용자가 보는 진행률 막대는 둘이 거의 같다 — 차이는 액션 약 1,000배
진행률은 연속값이라 1%만 되어도 충분하다. 보고 간격은 결과를 바꾸지 않고 비용만 바꾼다.
✅ 전체 단계 수로 간격을 구해 최대 101번(루프 100번 + 완료 1번)만 보고
@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을 받으므로 아래처럼 쓴다.

✅ 드래그 중엔 로컬 @State, 손 떼면 액션 1번
@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).

부모 뷰 읽음: title, rows의 id 목록 행 1 뷰 행 2 뷰 행 3 뷰isDone 변경 → 다시 그림 id 목록이 같으니 부모는 그대로 단, 부모 body가 모든 행의 isDone을 직접 읽으면 부모도 그 필드들에 의존하게 된다
관찰은 "읽은 것"을 따라간다. 부모가 행의 세부 필드를 읽지 않고 행 스토어만 넘기면, 행 변경은 행 뷰에서 끝난다.

그래서 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))
✅ 저장 프로퍼티로 스코프하고, 계산은 잎(leaf) 쪽으로 민다
@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 한 줄

문서의 조언 그대로다. 스코프에 쓰는 계산 프로퍼티 안에 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.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부 예고

2부는 동시성과 측정이다. 스토어·리듀서·Send는 어느 actor에서 돌고 .run은 어디서 도는지, 왜 [count = state.count]처럼 캡처해야 하는지, 의존성은 왜 Sendable이어야 하고 actor와 어떻게 엮는지, Swift 6 엄격 모드에서 새로 걸리는 것들을 본다. 마지막으로 _printChanges와 signpost로 1부의 함정을 직접 찾아낸다.

출처 · 참고자료