← TCA 14일 마스터 4 / 14

Day 4 · 바인딩

TCA 바인딩은 대입 대신 액션을 보낸다 — ad hoc 바인딩, BindableAction, 변경에 반응하기, @FocusState 동기화, 바인딩 테스트.
진행률
0 / 0 완료
SwiftUI 코스에서 폼은 @Bindable var model에 $model.name 한 줄이면 끝났다. 바인딩이 모델 프로퍼티에 직접 대입했기 때문이다. TCA에서 상태를 바꿀 수 있는 곳은 리듀서뿐인데, TextField는 쓰기가 되는 Binding을 원한다. 해법은 바인딩이 대입하는 대신 액션을 보내게 만드는 것이다. 오늘은 그 방법 두 가지(ad hoc · BindableAction)와, 바뀐 값에 반응하기, 포커스, 테스트까지 간다.

오늘의 목표 · 25분

  • 컴파일되지 않는 $store.text를 .sending(\.textChanged) 또는 BindableAction으로 고친다.
  • 필드마다 있던 xxxChanged 액션들을 case binding(BindingAction<State>) 하나와 BindingReducer()로 줄인다.
  • 특정 필드가 바뀔 때의 검증을 case .binding(\.field)와 .onChange(of:) 중 맞는 쪽으로 넣고, BindingReducer 위치 실수를 찾는다.
  • @FocusState를 store 상태와 .bind(_:to:)로 동기화하고, 포커스 로직을 TestStore로 확인한다.

1. 바인딩은 "쓰기" 대신 액션을 보낸다 🔥 핵심

Binding은 get과 set 한 쌍이다. SwiftUI @Observable에서는 set이 곧 model.name = newValue다. TCA에서는 뷰가 상태를 직접 바꿀 수 없으니, set이 store에 액션을 보내는 일이 되어야 한다. 이 원칙 하나가 오늘 배울 모든 API의 이유다.

SwiftUI (@Observable) TextField model.name set = 직접 대입 · get = 읽기 누가 언제 바꿨는지 남지 않는다 TCA TextField Reducer State set = 액션 변경 get = 읽기 모든 변경이 액션으로 기록된다
TCA 바인딩도 get은 똑같이 읽기다. 다른 것은 set — 대입 대신 액션이 리듀서를 지나간다.
SwiftUI(@Observable) — 바인딩이 프로퍼티에 직접 대입한다
@Observable
final class SettingsModel {
  var isHapticsEnabled = true
}

struct SettingsScreen: View {
  @Bindable var model: SettingsModel
  var body: some View {
    Toggle("햅틱", isOn: $model.isHapticsEnabled)
  }
}
❌ 같은 모양을 TCA에 — 컴파일 에러 (store 상태는 읽기 전용)
struct HapticsView: View {
  @Bindable var store: StoreOf<Haptics>   // Action이 BindableAction이 아니다
  var body: some View {
    Toggle("햅틱", isOn: $store.isHapticsEnabled)
    // error: cannot assign through dynamic lookup property: subscript is get-only
  }
}

가장 단순한 해법은 ad hoc 바인딩이다. 필드를 바꾸는 전용 액션을 만들고, .sending(\.액션)으로 "set이 오면 이 액션을 보내라"고 알려 준다(TCA 문서 · Bindings).

✅ ad hoc 바인딩 — 필드 전용 액션 + .sending
@Reducer
struct Haptics {
  @ObservableState
  struct State: Equatable {
    var isHapticsEnabled = true
  }
  enum Action {
    case isHapticsEnabledChanged(Bool)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .isHapticsEnabledChanged(isOn):
        state.isHapticsEnabled = isOn
        return .none
      }
    }
  }
}

struct HapticsToggleView: View {
  @Bindable var store: StoreOf<Haptics>   // $store를 쓰려면 @Bindable
  var body: some View {
    Toggle(
      "햅틱",
      isOn: $store.isHapticsEnabled.sending(\.isHapticsEnabledChanged)
    )
  }
}
⚠️ let store로는 $store가 없다

Day 2처럼 let store로 받으면 $store 자체가 존재하지 않는다. 바인딩을 만들 뷰는 SwiftUI의 @Bindable var store로 받는다 — SwiftUI 코스 Day 5에서 @Observable 모델에 쓰던 그 래퍼다. iOS 16 이하를 지원하면 문서대로 @Perception.Bindable을 쓴다.

2. BindableAction + BindingReducer — 필드가 많을 때 🔥 핵심

설정 화면에 필드가 여섯 개면 ad hoc 방식은 액션 여섯 개, 리듀서 case 여섯 개가 된다. 모두 "받은 값을 그 필드에 넣는다"는 똑같은 일이다. 문서도 이렇게 말한다.

📝 TCA 문서 원문

"This is a lot of boilerplate for something that should be simple. Luckily, we can dramatically eliminate this boilerplate using BindableAction and BindingReducer." — Bindings

단순해야 할 일에 코드가 너무 많으니, BindableAction과 BindingReducer로 그 반복을 크게 없앤다는 뜻이다.

방법은 세 단계다. ① Action을 BindableAction으로 만들고 case binding(BindingAction<State>) 하나를 둔다. ② body에 BindingReducer()를 넣는다. ③ 뷰는 $store.필드를 그대로 쓴다.

✅ 액션 case 하나로 모든 필드 바인딩
enum Digest: String, CaseIterable, Hashable {
  case daily, weekly, off
}

@Reducer
struct ProfileSettings {
  @ObservableState
  struct State: Equatable {
    var digest = Digest.daily
    var displayName = ""
    var enableNotifications = false
    var protectMyPosts = false
  }
  enum Action: BindableAction {                 // ①
    case binding(BindingAction<State>)
  }

  var body: some ReducerOf<Self> {
    BindingReducer()                            // ②
  }
}

struct ProfileSettingsView: View {
  @Bindable var store: StoreOf<ProfileSettings>
  var body: some View {
    Form {                                      // ③
      TextField("표시 이름", text: $store.displayName)
      Toggle("알림", isOn: $store.enableNotifications)
      Toggle("내 글 보호", isOn: $store.protectMyPosts)
      Picker("요약 메일", selection: $store.digest) {
        ForEach(Digest.allCases, id: \.self) { Text($0.rawValue) }
      }
    }
  }
}

뷰 코드는 SwiftUI 폼과 글자 그대로 같다. 차이는 뒤에서 일어난다. 소스를 따라가면 BindableAction인 Store에는 set 가능한 서브스크립트가 생기고, 그 setter가 .binding(.set(keyPath, newValue)) 액션을 보낸다. BindingReducer는 그 액션에서 key path와 값을 꺼내 상태에 대입한다(Binding+Observation.swift, BindingReducer.swift).

① 사용자가 입력 $store.name 의 set(v) ② Store setter store.send(…) ③ 액션 .binding(.set(\.name, v)) ④ BindingReducer state.name = v ⑤ 뒤의 Reduce case .binding(\.name): 검증
뷰에는 대입처럼 보여도 실제로는 액션 하나가 리듀서를 순서대로 지나간다. 그래서 ⑤처럼 끼어들 자리가 생긴다.
비교SwiftUI (@Observable)TCA ad hocTCA BindableAction
뷰 코드$model.name$store.name.sending(\.nameChanged)$store.name
필드 추가 비용프로퍼티 1줄프로퍼티 + 액션 + case프로퍼티 1줄
변경이 액션으로 남나아니오예 (필드별 이름)예 (.binding(.set(\.name, …)))
조건—없음값이 Equatable & Sendable, BindingReducer() 필수
❌ BindingReducer를 빠뜨림 — 컴파일은 되지만 타이핑해도 값이 안 바뀐다
var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .binding:
      return .none          // 아무도 state에 대입하지 않는다
    }
  }
}
// DEBUG 런타임 경고:
// A binding action sent from a store was not handled. ...
// To fix this, invoke "BindingReducer()" from your feature reducer's "body".

이 경고 문구는 소스의 BindableActionDebugger가 낸다. 바인딩 액션이 처리되지 않은 채 사라지면 DEBUG 빌드에서 알려 준다. 보라색 경고가 보이면 body부터 확인한다.

3. 바뀐 값에 반응하기 — case .binding(\.x)와 onChange

"최대값을 줄이면 슬라이더 값도 그 안으로 끌어내린다" 같은 추가 로직은 BindingReducer() 뒤에 Reduce를 두고, 특정 key path의 바인딩 액션을 패턴 매칭한다. 공식 케이스 스터디 Bindings-Forms의 방식이다.

✅ case .binding(\.stepCount) — 이미 반영된 새 값을 보고 보정
@Reducer
struct SliderForm {
  @ObservableState
  struct State: Equatable {
    var sliderValue = 5.0
    var stepCount = 10
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
    case resetButtonTapped
  }

  var body: some ReducerOf<Self> {
    BindingReducer()                 // 먼저 대입
    Reduce { state, action in        // 그다음 보정
      switch action {
      case .binding(\.stepCount):
        state.sliderValue = min(state.sliderValue, Double(state.stepCount))
        return .none
      case .binding:
        return .none
      case .resetButtonTapped:
        state = State()
        return .none
      }
    }
  }
}
액션: .binding(.set(\.stepCount, 3)) · 시작 상태 stepCount 10, sliderValue 5 A · BindingReducer 먼저 ✅ BindingReducer → stepCount = 3 Reduce가 3을 봄 → slider = min(5, 3) 결과: stepCount 3 · slider 3 B · Reduce 먼저 ❌ Reduce가 옛 값 10을 봄 → min(5, 10) BindingReducer → stepCount = 3 결과: stepCount 3 · slider 5 (최대 초과)
body 안 리듀서는 위에서 아래로 실행된다. 새 값을 보고 싶으면 BindingReducer를 위에 둔다.

onChange — "값이 실제로 달라졌을 때"만

두 번째 방법은 BindingReducer()에 .onChange(of:)를 붙이는 것이다. 1.26.2 소스의 _OnChangeReducer는 붙은 리듀서를 돌리기 전후 값을 비교해서, 달라졌을 때만 클로저를 부른다. 클로저는 옛 값과 inout state를 받고 이펙트를 돌려준다.

✅ onChange(of:) — 닉네임이 바뀔 때마다 검증 (1.26 시그니처)
@Reducer
struct NicknameForm {
  @ObservableState
  struct State: Equatable {
    var nickname = ""
    var isNicknameValid = false
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
  }

  var body: some ReducerOf<Self> {
    BindingReducer()
      .onChange(of: \.nickname) { oldValue, state in
        state.isNicknameValid = (2...12).contains(state.nickname.count)
        return .none                     // 필요하면 .run { … }도 가능
      }
  }
}
⚠️ 문서 예제와 시그니처가 다르다

Bindings 아티클은 .onChange(of: \.displayName) { oldValue, newValue in … } 형태를 보여 준다. 1.26.2 소스에서 그 형태(리듀서를 돌려주는 오버로드)는 "Use 'onChange' that directly returns an 'EffectOf<Feature>' instead" 메시지로 soft-deprecated다. 새 코드는 위처럼 { oldValue, state in … return 이펙트 }로 쓴다.

📝 TCA 문서 원문

"The onChange operator is only capable of detecting changes made by the reducer it is directly attached to." — OnChange.swift 문서 주석

BindingReducer()에 붙인 onChange는 바인딩으로 생긴 변화만 본다. "초기화" 버튼 액션이 nickname을 비워도 이 클로저는 불리지 않는다. 같은 문서는 매 액션마다 비교가 한 번씩 들어가니 BindingReducer 같은 잎 노드에, 비교가 싼 값에 붙이라고 권한다.

반응 방법언제 불리나어울리는 일
SwiftUI .onChange(of: model.nickname)뷰가 떠 있고 값이 바뀔 때뷰 전용 반응. 로직이 뷰에 남는다
TCA case .binding(\.nickname)그 필드의 바인딩 액션마다 (같은 값이어도)다른 필드 보정, 요청 시작
TCA .onChange(of: \.nickname)붙은 리듀서가 값을 실제로 바꿨을 때검증·저장처럼 "값이 달라졌을 때만" 할 일

4. 포커스 — @FocusState와 store 상태 잇기

@FocusState는 뷰 안에서만 쓸 수 있는 래퍼라 store 상태가 될 수 없다. 순수 SwiftUI에서는 "빈 칸으로 포커스 옮기기" 로직을 뷰 버튼 안에 쓴다(SwiftUI 코스 Day 11 2부).

SwiftUI — 포커스 로직이 뷰 안에 있다
struct SignInScreen: View {
  enum Field: Hashable { case username, password }
  @State private var username = ""
  @State private var password = ""
  @FocusState private var focusedField: Field?

  var body: some View {
    Form {
      TextField("아이디", text: $username)
        .focused($focusedField, equals: .username)
      SecureField("비밀번호", text: $password)
        .focused($focusedField, equals: .password)
      Button("로그인") {
        if username.isEmpty { focusedField = .username }
        else if password.isEmpty { focusedField = .password }
      }
    }
  }
}

TCA에서는 포커스를 평범한 상태(var focusedField: Field?)로 두고, 뷰의 @FocusState와 .bind(_:to:)로 양방향 동기화한다. 케이스 스터디 FocusState 그대로다. bind는 swift-navigation의 도구로, 처음 나타날 때 한 번 맞추고 이후 양쪽 onChange로 서로를 따라가게 한다(Bind.swift).

✅ 포커스 결정은 리듀서, 실제 포커스는 @FocusState — .bind로 동기화
@Reducer
struct SignIn {
  @ObservableState
  struct State: Equatable {
    var focusedField: Field?
    var password = ""
    var username = ""

    enum Field: Hashable {
      case username, password
    }
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
    case signInButtonTapped
  }

  var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      case .binding:
        return .none
      case .signInButtonTapped:
        if state.username.isEmpty {
          state.focusedField = .username
        } else if state.password.isEmpty {
          state.focusedField = .password
        }
        return .none
      }
    }
  }
}

struct SignInView: View {
  @Bindable var store: StoreOf<SignIn>
  @FocusState var focusedField: SignIn.State.Field?

  var body: some View {
    Form {
      TextField("아이디", text: $store.username)
        .focused($focusedField, equals: .username)
      SecureField("비밀번호", text: $store.password)
        .focused($focusedField, equals: .password)
      Button("로그인") { store.send(.signInButtonTapped) }
    }
    .bind($store.focusedField, to: $focusedField)   // 양방향 동기화
  }
}

얻는 것은 포커스 로직의 테스트다. 사용자가 아이디 칸을 탭하면 @FocusState → bind → $store.focusedField 바인딩 액션으로 store에도 기록되고, 리듀서가 focusedField를 바꾸면 반대로 키보드가 옮겨 간다. 대가는 뷰에 선언 두 개(@FocusState와 .bind)가 더 붙는 것이다.

5. 바인딩 테스트와 흔한 실수

바인딩 액션도 보통 액션처럼 TestStore로 보낸다. 문서 형식은 store.send(\.binding.필드, 값)이다. 아래는 4절 포커스 로직을 뷰 없이 확인한다. TestStore 자체는 Day 8에서 자세히 본다.

✅ 바인딩 + 포커스 로직 테스트 — 시뮬레이터 화면 없이
import Testing

@MainActor
@Test func signInFocusTest() async {
  let store = TestStore(initialState: SignIn.State()) {
    SignIn()
  }
  await store.send(\.binding.username, "blob") {
    $0.username = "blob"
  }
  await store.send(.signInButtonTapped) {
    $0.focusedField = .password        // 비밀번호가 비었으니 그쪽으로
  }
}
증상원인고치기
subscript is get-only 컴파일 에러Action이 BindableAction이 아님.sending(\.액션) 또는 BindableAction 채택
$store를 찾을 수 없음let store로 받음@Bindable var store
입력해도 값이 그대로 + 보라색 경고BindingReducer() 누락body 맨 위에 추가
보정 로직이 한 박자 늦다BindingReducer가 Reduce 아래순서를 바꾼다 (3절 그림)
$store.x만 안 된다값 타입이 Equatable/Sendable이 아님타입에 준수를 추가하거나 ad hoc 액션

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
BindableAction이 아닌 기능에서 $store.text가 컴파일되지 않는 이유는?
store가 상태를 읽기 전용으로만 내보내기 때문이다. Binding은 set이 필요한데, TCA에서 상태를 바꾸는 유일한 길은 액션이다. Action이 BindableAction이면 Store에 set 가능한 서브스크립트가 생겨 set이 .binding(.set(…)) 액션으로 바뀌고, 아니면 .sending(\.textChanged)로 어떤 액션을 보낼지 직접 알려 줘야 한다.
쉽게 말하면 교실 게시판은 선생님만 고칠 수 있다. 학생이 바꾸고 싶으면 "이렇게 바꿔 주세요" 쪽지(액션)를 내야 한다.
case .binding(\.nickname)과 .onChange(of: \.nickname)의 차이는?
패턴 매칭은 그 필드의 바인딩 액션이 올 때마다 실행된다(같은 값이 다시 들어와도). onChange는 붙은 리듀서를 돌리기 전후 값을 비교해 실제로 달라졌을 때만 실행되고, 붙은 리듀서가 만든 변화만 본다. 다른 필드를 보정하거나 요청을 시작하면 패턴 매칭, "바뀌었을 때만 검증·저장"이면 onChange가 어울린다.
쉽게 말하면 하나는 "초인종이 울릴 때마다" 나가 보고, 다른 하나는 "택배가 실제로 왔을 때만" 나가 본다.
🔮 예측용 코드 — 3절 SliderForm에서 순서만 바꿨다
@Reducer
struct SliderFormFlipped {
  @ObservableState
  struct State: Equatable {
    var sliderValue = 5.0
    var stepCount = 10
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .binding(\.stepCount):
        state.sliderValue = min(state.sliderValue, Double(state.stepCount))
        return .none
      case .binding:
        return .none
      }
    }
    BindingReducer()
  }
}
🔮 코드 예측: 위 기능에서 스테퍼로 stepCount를 10 → 3으로 바꾸면 sliderValue는?
5.0 그대로다. Reduce가 먼저 실행될 때 state.stepCount는 아직 10이라 min(5, 10) = 5이고, 그 뒤 BindingReducer가 stepCount에 3을 넣는다. 슬라이더 값이 최대 3을 넘은 상태가 된다. 아래 테스트가 통과한다는 것으로 확인할 수 있다 — exhaustive 검사라 sliderValue가 바뀌었다면 실패한다.
TestStore로 확정
import Testing

@MainActor
@Test func flippedOrderTest() async {
  let store = TestStore(initialState: SliderFormFlipped.State()) {
    SliderFormFlipped()
  }
  await store.send(\.binding.stepCount, 3) {
    $0.stepCount = 3                 // sliderValue는 5.0 그대로
  }
}
쉽게 말하면 숙제 검사를 숙제 제출보다 먼저 하면, 검사하는 사람은 옛 숙제를 본다.
🔁 Day 3 복습: 검색어를 $store.query로 바인딩하면서, 타이핑이 0.3초 멈추면 검색하려면 코드를 어디에 어떻게 넣나?
BindingReducer() 뒤 Reduce에서 case .binding(\.query):를 잡고, Day 3의 디바운스 이펙트를 돌려준다. return .run { [query = state.query] send in try await clock.sleep(for: .milliseconds(300)); await send(.searchResponse(…)) }.cancellable(id: CancelID.search, cancelInFlight: true). state는 inout이라 캡처 리스트로 값을 복사해 넘겨야 하고, cancelInFlight가 이전 글자의 검색을 취소한다.
쉽게 말하면 글자가 바뀔 때마다 알람을 새로 맞추고 이전 알람은 끈다. 0.3초 동안 아무도 안 건드리면 알람이 울려 검색한다.

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

가입 폼을 만든다. Xcode 프로젝트에 TCA 패키지를 추가했다고 가정하고, Preview(또는 TestStore)로 확인한다.

  1. email · password · agreesToTerms를 BindableAction으로 바인딩한다(필드별 액션 금지).
  2. password가 바뀔 때만 isPasswordLong(8자 이상)을 갱신한다 — onChange 사용.
  3. "가입" 버튼을 누르면 비어 있는 첫 칸(email → password 순)으로 포커스를 옮긴다.
시작 코드
@Reducer
struct SignUp {
  @ObservableState
  struct State: Equatable {
    var email = ""
    var password = ""
    var agreesToTerms = false
    var isPasswordLong = false
  }
  enum Action {
    case signUpButtonTapped
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .signUpButtonTapped:
        // TODO: 빈 칸으로 포커스
        return .none
      }
    }
  }
}

struct SignUpView: View {
  let store: StoreOf<SignUp>
  var body: some View {
    Form {
      Text(store.email)   // TODO: TextField · SecureField · Toggle로 바꾸기
      Button("가입") { store.send(.signUpButtonTapped) }
        .disabled(!store.agreesToTerms)
    }
  }
}

#Preview {
  SignUpView(store: Store(initialState: SignUp.State()) { SignUp() })
}

확인 방법: Preview에서 약관을 켜고 빈 폼으로 "가입"을 누르면 이메일 칸에 커서가 가는지, 이메일을 채우고 다시 누르면 비밀번호 칸으로 가는지, 비밀번호를 8자 넘게 치면 "충분" 표시가 켜지는지 본다.

정답 코드 보기
정답 — BindableAction + onChange + .bind
@Reducer
struct SignUpSolution {
  @ObservableState
  struct State: Equatable {
    var email = ""
    var password = ""
    var agreesToTerms = false
    var isPasswordLong = false
    var focus: Field?

    enum Field: Hashable { case email, password }
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
    case signUpButtonTapped
  }

  var body: some ReducerOf<Self> {
    BindingReducer()
      .onChange(of: \.password) { _, state in
        state.isPasswordLong = state.password.count >= 8
        return .none
      }
    Reduce { state, action in
      switch action {
      case .binding:
        return .none
      case .signUpButtonTapped:
        if state.email.isEmpty {
          state.focus = .email
        } else if state.password.isEmpty {
          state.focus = .password
        }
        return .none
      }
    }
  }
}

struct SignUpSolutionView: View {
  @Bindable var store: StoreOf<SignUpSolution>
  @FocusState var focus: SignUpSolution.State.Field?

  var body: some View {
    Form {
      TextField("이메일", text: $store.email)
        .focused($focus, equals: .email)
      SecureField("비밀번호", text: $store.password)
        .focused($focus, equals: .password)
      Text(store.isPasswordLong ? "충분" : "8자 이상")
      Toggle("약관 동의", isOn: $store.agreesToTerms)
      Button("가입") { store.send(.signUpButtonTapped) }
        .disabled(!store.agreesToTerms)
    }
    .bind($store.focus, to: $focus)
  }
}

#Preview {
  SignUpSolutionView(store: Store(initialState: SignUpSolution.State()) { SignUpSolution() })
}

출처 · 참고자료