WithViewStore, IfLetStore, @BindingState, @PresentationState. 2부는 이런 옛 TCA 코드를 읽고 현행 API로 옮기는 법을 다룬다. 그리고 14일을 지도 한 장으로 정리하고 다음에 공부할 것을 고른다.2부 목표 · 24분
- 코드 조각의 API만 보고 "1.7 이전 코드인지" 판별하고, 어떤 마이그레이션 가이드를 펼칠지 고른다.
ViewStore·WithViewStore·IfLetStore·ForEachStore·@BindingState·@PresentationState를 현행 API로 한 줄씩 옮긴다.- 큰 코드베이스를 루트부터 바깥 → 안쪽 순서로 점진 이전하는 이유를 설명한다.
- 14일 동안 배운 도구를 "어떤 문제에 어떤 도구" 표로 꺼내 쓴다.
1. 옛 TCA 코드 알아보기 — 연대 감별 🔥 핵심
TCA는 1.x 안에서도 API가 크게 바뀌었다. 옛 API는 지우지 않고 Internal/Deprecations.swift에 모아 뒀다. 1.26.2에서 이 파일은 3,941줄이다(소스 기준). 그래서 옛 코드도 경고와 함께 아직 컴파일된다. 이 페이지의 "📜 옛 API" 코드 블록은 모두 1.26.2로 실제 컴파일해 확인했다. 볼트의 "Deprecated API Map"과 "TCA Version History" 노트가 이 절의 뼈대다.
왜 ViewStore가 있었고, 왜 사라졌나
Observation이 없던 시절에는 스토어 상태 전체가 바뀔 때마다 뷰를 다시 그리면 낭비였다. 그래서 뷰가 쓸 부분만 담은 ViewState 구조체를 손으로 만들고, WithViewStore(store, observe: ViewState.init)가 이전 값과 ==로 비교해 달라졌을 때만 objectWillChange를 보냈다(소스 기준: removeDuplicates: { $0 == $1 }). Swift 5.9 Observation이 나오자 "실제로 읽은 필드"를 자동으로 추적할 수 있게 됐고, 1.7에서 @ObservableState가 이 일을 넘겨받았다.
ViewState는 버그가 아니라 당시의 성능 최적화였다. 지금은 SwiftUI의 @Observable과 같은 방식으로 추적한다(SwiftUI 코스 Day 5)."Often people complain of boilerplate in TCA, especially with regards a legacy concept known as “view stores”. … they were deprecated a long time ago after Swift 5.9 released with the Observation framework." — TCA 문서 · FAQ
"TCA는 보일러플레이트가 많다"는 평은 대부분 view store 시절 이야기이고, Observation이 나온 뒤 오래전에 폐기됐다는 뜻이다. 옛 블로그 글을 읽을 때 날짜부터 확인해야 하는 이유다.
2. 대응표 — 옛 API를 현행 API로
근거는 1.4 · 1.5 · 1.6 · 1.7 마이그레이션 가이드다. 1.7 가이드는 "1.6까지 먼저 끝내고 오라"고 적는다. 즉 키패스 문법(1.4~1.6)을 먼저 정리한 뒤 관찰 도구(1.7)로 넘어간다.
| 옛 코드 | 현행 (1.26) | 가이드 |
|---|---|---|
.ifLet(\.child, action: /Action.child) | .ifLet(\.child, action: \.child) — 케이스 키패스 | 1.4 |
case row(id:action:) + forEach(…, action: /Action.row) | case rows(IdentifiedActionOf<Row>) + forEach(\.rows, action: \.rows) | 1.4 |
TaskResult | Swift 표준 Result | 1.4 |
store.receive(.child(.response(.success(x)))) | receive(\.child.response.success) · 값까지 보려면 receive(\.child.response.success, x) | 1.4 · 1.6 |
store.scope(state: \.child, action: { .child($0) }) | store.scope(state: \.child, action: \.child) — 키패스라 캐시됨 | 1.5 |
Scope(state:action:) · store.scope(state:action:) | 라벨 없는 Scope(\.child, action: \.child) · store.scope(\.child, action: \.child) — 1.26에서 이름만 바뀜(Day 5) | 소스 |
WithViewStore(store, observe:) { viewStore in … } | 삭제. store.count · store.send 직접 | 1.7 |
ViewState의 계산값 | State의 계산 프로퍼티 | 1.7 |
IfLetStore | if let s = store.scope(\.child, action: \.child) | 1.7 |
ForEachStore | ForEach(store.scope(\.rows, action: \.rows)) | 1.7 |
SwitchStore · CaseLet | switch store.case { … } (1.8의 @Reducer enum) | 1.7 |
@PresentationState var child | @Presents var child | 1.7 |
.sheet(store: store.scope(state: \.$child, …)) | .sheet(item: $store.scope(\.child, …)) — $가 store 쪽으로 | 1.7 |
.alert(store:) · .confirmationDialog(store:) | .alert($store.scope(…)) · .confirmationDialog($store.scope(…)) | 1.7 |
NavigationStackStore | NavigationStack(path: $store.scope(…)) | 1.7 |
@BindingState + viewStore.$text | 평범한 var + @Bindable + $store.text | 1.7 |
viewStore.binding(get:send:) | $store.tab.sending(\.tabChanged) | 1.7 |
WithViewStore(…, send: Action.view) | @ViewAction(for:) + send(…) | 1.7 |
UIKit store.publisher.x.sink · store.ifLet(then:) | observe { } + if let + store.scope(…) (1부 3절) | 1.7 |
① WithViewStore + @BindingState
// 1.7 이전 스타일 — State에 @ObservableState가 없고, 뷰는 ViewStore를 거친다
@Reducer
struct Profile {
struct State: Equatable {
var firstName = ""
var lastName = ""
@BindingState var nickname = "" // 바인딩할 필드마다 표시
@BindingState var isPublic = false
var saveCount = 0
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case saveButtonTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .saveButtonTapped:
state.saveCount += 1
return .none
}
}
}
}
struct ProfileView: View {
let store: StoreOf<Profile>
struct ViewState: Equatable { // 뷰가 볼 상태를 손으로 고른다
let fullName: String
@BindingViewState var nickname: String
@BindingViewState var isPublic: Bool
init(store: BindingViewStore<Profile.State>) {
fullName = "\(store.firstName) \(store.lastName)"
_nickname = store.$nickname
_isPublic = store.$isPublic
}
}
var body: some View {
WithViewStore(store, observe: ViewState.init) { viewStore in
Form {
Text(viewStore.fullName)
TextField("닉네임", text: viewStore.$nickname)
Toggle("공개", isOn: viewStore.$isPublic)
Button("저장") { viewStore.send(.saveButtonTapped) }
}
}
}
}@Reducer
struct Profile {
@ObservableState // ① 추가
struct State: Equatable {
var firstName = ""
var lastName = ""
var nickname = "" // ② @BindingState 삭제
var isPublic = false
var saveCount = 0
var fullName: String { "\(firstName) \(lastName)" } // ③ ViewState의 계산값은 State로
}
enum Action: BindableAction { // BindableAction · BindingReducer는 남긴다
case binding(BindingAction<State>)
case saveButtonTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .saveButtonTapped:
state.saveCount += 1
return .none
}
}
}
}
struct ProfileView: View {
@Bindable var store: StoreOf<Profile> // ④ 바인딩이 필요하면 @Bindable
var body: some View { // ⑤ WithViewStore·ViewState 삭제
Form {
Text(store.fullName)
TextField("닉네임", text: $store.nickname)
Toggle("공개", isOn: $store.isPublic)
Button("저장") { store.send(.saveButtonTapped) }
}
}
}1.7 가이드의 Important 박스다. @BindingState는 지우지만 Action의 BindableAction 채택과 BindingReducer()는 남겨야 바인딩이 동작한다(Day 4). 배포 타깃이 iOS 16 이하라면 @Bindable 대신 @Perception.Bindable을 쓰고, body를 WithPerceptionTracking { }으로 감싼다(Observation backport).
② IfLetStore · ForEachStore · sheet(store:) · @PresentationState
// 아래 두 버전이 함께 쓰는 자식 기능
@Reducer
struct Row {
@ObservableState
struct State: Equatable, Identifiable { let id: Int; var title = "" }
enum Action { case tapped }
var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}
@Reducer
struct Editor {
@ObservableState
struct State: Equatable { var text = "" }
enum Action { case saveButtonTapped }
var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}
struct RowView: View {
let store: StoreOf<Row>
var body: some View { Text(store.title) }
}
struct EditorView: View {
let store: StoreOf<Editor>
var body: some View { Text(store.text) }
}@Reducer
struct Inbox {
struct State: Equatable {
var banner: Row.State?
var rows: IdentifiedArrayOf<Row.State> = []
@PresentationState var editor: Editor.State?
@PresentationState var alert: AlertState<Action.Alert>?
}
enum Action {
case alert(PresentationAction<Alert>)
case banner(Row.Action)
case editor(PresentationAction<Editor.Action>)
case rows(IdentifiedActionOf<Row>)
enum Alert: Equatable { case confirmDelete }
}
var body: some ReducerOf<Self> {
Reduce { _, _ in .none }
.ifLet(\.banner, action: \.banner) { Row() }
.forEach(\.rows, action: \.rows) { Row() }
.ifLet(\.$editor, action: \.editor) { Editor() }
.ifLet(\.$alert, action: \.alert)
}
}
struct InboxView: View {
let store: StoreOf<Inbox>
var body: some View {
List {
IfLetStore(store.scope(state: \.banner, action: \.banner)) { bannerStore in
RowView(store: bannerStore)
}
ForEachStore(store.scope(state: \.rows, action: \.rows)) { rowStore in
RowView(store: rowStore)
}
}
.sheet(store: store.scope(state: \.$editor, action: \.editor)) { editorStore in
EditorView(store: editorStore)
}
.alert(store: store.scope(state: \.$alert, action: \.alert))
}
}@Reducer
struct Inbox {
@ObservableState
struct State: Equatable {
var banner: Row.State?
var rows: IdentifiedArrayOf<Row.State> = []
@Presents var editor: Editor.State? // @PresentationState → @Presents
@Presents var alert: AlertState<Action.Alert>?
}
enum Action {
case alert(PresentationAction<Alert>)
case banner(Row.Action)
case editor(PresentationAction<Editor.Action>)
case rows(IdentifiedActionOf<Row>)
enum Alert: Equatable { case confirmDelete }
}
var body: some ReducerOf<Self> { // 리듀서 쪽은 한 글자도 안 바뀐다
Reduce { _, _ in .none }
.ifLet(\.banner, action: \.banner) { Row() }
.forEach(\.rows, action: \.rows) { Row() }
.ifLet(\.$editor, action: \.editor) { Editor() }
.ifLet(\.$alert, action: \.alert)
}
}
struct InboxView: View {
@Bindable var store: StoreOf<Inbox>
var body: some View {
List {
if let bannerStore = store.scope(\.banner, action: \.banner) { // IfLetStore → if let
RowView(store: bannerStore)
}
ForEach(store.scope(\.rows, action: \.rows)) { rowStore in // ForEachStore → ForEach
RowView(store: rowStore)
}
}
.sheet(item: $store.scope(\.editor, action: \.editor)) { editorStore in // \.$editor 아님
EditorView(store: editorStore)
}
.alert($store.scope(\.alert, action: \.alert))
}
}옛 코드는 state: \.$editor(projected value)로 scope했다. 새 sheet(item: $store.scope(…))에서는 \.editor다. $가 키패스에서 빠지고 $store 쪽으로 옮겨 간다. 리듀서의 .ifLet(\.$editor, action: \.editor)는 그대로 $를 쓴다. 옮길 때 가장 자주 헷갈리는 한 글자다. 참고로 @PresentationState 타입 자체는 deprecated가 아니다. @Presents 매크로가 펼쳐 놓는 저장 타입이 PresentationState다(소스 기준). 문제는 프로퍼티 래퍼가 @ObservableState 매크로와 같이 쓰이지 못한다는 점이다.
③ viewStore.binding → sending
@Reducer
struct Tabs {
struct State: Equatable { var tab = 0 }
enum Action { case tabChanged(Int) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .tabChanged(tab):
state.tab = tab
return .none
}
}
}
}
struct TabsView: View {
let store: StoreOf<Tabs>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
TabView(selection: viewStore.binding(get: \.tab, send: { .tabChanged($0) })) {
Text("홈").tag(0)
Text("설정").tag(1)
}
}
}
}@Reducer
struct Tabs {
@ObservableState
struct State: Equatable { var tab = 0 }
enum Action { case tabChanged(Int) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .tabChanged(tab):
state.tab = tab
return .none
}
}
}
}
struct TabsView: View {
@Bindable var store: StoreOf<Tabs>
var body: some View {
TabView(selection: $store.tab.sending(\.tabChanged)) { // 읽기는 tab, 쓰기는 액션으로
Text("홈").tag(0)
Text("설정").tag(1)
}
}
}큰 코드베이스는 바깥 → 안쪽으로
1.7 가이드의 "Incrementally migrating" 절은 한 번에 다 바꾸지 말라고 한다. 옛 기능(objectWillChange로 동기 무효화)과 새 기능(withObservationTracking)이 섞이면 body가 필요 이상으로 여러 번 재계산될 수 있다. 무효화 방식이 달라 스레드 홉을 사이에 두고 따로따로 일어나기 때문이다. 가이드의 경험상 새 기능이 옛 자식을 품는 쪽이 반대보다 낫다. 그래서 루트 기능부터 @ObservableState로 바꾸고 잎(leaf) 기능 쪽으로 내려간다. 한 기능을 바꾼 뒤 이상하면, 그 기능과 맞물린 기능 몇 개를 더 바꿔 본다.
| 옛 TCA를 순수 SwiftUI로 옮기던 시절의 비유 | TCA 안에서의 대응 |
|---|---|
ObservableObject + @Published → @Observable (SwiftUI 코스 Day 5) | ViewStore + ViewState → @ObservableState |
@ObservedObject var model → 그냥 var model / @Bindable | WithViewStore { viewStore in } → let store / @Bindable var store |
$model.text (ObservedObject 투영) | viewStore.$text → $store.text |
3. 14일 총정리 지도 + 다음에 공부할 것
문제에서 도구로 — 꺼내 쓰는 표
| 이런 문제가 생기면 | 꺼낼 도구 | Day |
|---|---|---|
| 상태가 어디서 바뀌는지 추적이 안 된다 | Action enum + Reduce + _printChanges() | 1 · 2 |
| API 호출·타이머를 어디 둘지 | .run { send in } · .cancellable(id:) | 3 |
| 폼 필드가 많다 | BindableAction · BindingReducer · $store.x | 4 |
| 부모가 자식 이벤트에 반응해야 한다 | Scope · delegate 액션 · ifLet | 5 |
| 행마다 로직이 있는 목록 | IdentifiedArray · forEach · IdentifiedAction | 6 |
| 네트워크·시계·UUID 때문에 테스트가 흔들린다 | @Dependency · @DependencyClient · withDependencies | 7 |
| 흐름 전체를 고정하는 테스트 | TestStore · receive · TestClock · exhaustivity | 8 · 9 |
| 시트·알럿·드릴다운 | @Presents · @Reducer enum Destination · AlertState | 10 |
| 깊고 재귀적인 push, 딥링크 | StackState · StackAction · NavigationStack(path:) | 11 |
| 여러 화면이 같은 데이터, 앱 재시작 후 유지 | @Shared · .appStorage · .fileStorage · withLock | 12 |
| 화면이 버벅인다, Swift 6 경고 | 메서드로 로직 공유 · 스코프 비용 · signpost · @MainActor·Sendable | 13 |
| 앱이 커진다, UIKit이 섞였다, 옛 코드가 있다 | 모듈 분리 · observe { } · 이 페이지의 대응표 | 14 |
다음에 공부할 것
@Dependency·@Shared만 먼저 들여오는 길도 있다.ComposableArchitecture2Deprecations 트레이트를 켜 두면 2.0에서 바뀔 API에 미리 경고가 뜬다. enum 목적지 scope 문법($store.scope(\.$destination, action: \.destination).edit)도 여기서 바뀐다.✅ 복습 · 5분
옛 코드에 struct ViewState: Equatable와 WithViewStore(store, observe: ViewState.init)가 있다. 이 구조는 원래 무엇을 위한 것이었고, 옮길 때 ViewState 안의 계산값은 어디로 가나?
==로 달라졌을 때만 뷰를 다시 그리게 하는 성능 최적화였다. 옮길 때 ViewState는 지우고, 계산값(예: fullName)은 State의 계산 프로퍼티로 옮긴다(1.7 가이드 "Computed view state").50개 기능을 가진 앱을 1.7 도구로 옮긴다. 잎 기능부터 할까, 루트부터 할까? 이유는?
objectWillChange)과 새 방식(withObservationTracking)이 섞이면 body가 여러 번 재계산될 수 있는데, 1.7 가이드의 경험상 새 기능이 옛 자식을 품는 쪽이 반대보다 문제가 적다. 그 전에 1.4~1.6의 키패스 문법부터 정리한다.🔮 코드 예측: 2절 ①의 옛 ProfileView에서 "저장"을 눌러 saveCount만 1 늘었다. WithViewStore의 content는 다시 계산되나? 현행 ProfileView에서는?
ViewState에는 fullName·nickname·isPublic만 있어서 이전 값과 ==로 같다. WithViewStore는 removeDuplicates: { $0 == $1 }로 중복을 걸러 objectWillChange를 보내지 않는다(소스 기준). 현행 버전: body가 saveCount를 읽지 않으므로 추적 대상이 아니다. 같은 결과를 옛 코드는 사람이 고른 ViewState로, 새 코드는 런타임 추적으로 얻는다. 리듀서 로직은 같으므로 TestStore에서 send(.saveButtonTapped) { $0.saveCount = 1 }은 두 버전 모두 통과한다.🔁 1부 복습: SwiftUI의 @Bindable var store와 .sheet(item: $store.scope(…))는 UIKit 뷰 컨트롤러에서 각각 무엇으로 바뀌나? 상태 변화를 라벨에 반영하는 곳은?
@UIBindable var store와 present(item: $store.scope(…)) { store in … }다. 상태 반영은 viewDidLoad에서 한 번 부르는 observe { [weak self] in … } 클로저에서 한다.🛠 미니 과제 (선택 · 10분)
옛 API로 쓴 카운터를 현행 API로 옮긴다. 세 번째 탭에 알럿이 뜬다.
// 시작 코드 (옛 API · 1.26.2에서 경고와 함께 컴파일된다)
@Reducer
struct Tally {
struct State: Equatable {
var count = 0
@PresentationState var alert: AlertState<Action.Alert>?
}
enum Action {
case alert(PresentationAction<Alert>)
case incrementButtonTapped
enum Alert: Equatable {}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .alert:
return .none
case .incrementButtonTapped:
state.count += 1
if state.count == 3 {
state.alert = AlertState { TextState("3번 눌렀다") }
}
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}
}
struct TallyView: View {
let store: StoreOf<Tally>
var body: some View {
WithViewStore(store, observe: \.count) { viewStore in
VStack {
Text("\(viewStore.state)")
Button("+") { viewStore.send(.incrementButtonTapped) }
}
.alert(store: store.scope(state: \.$alert, action: \.alert))
}
}
}- 요구 1: State에
@ObservableState를 붙이고@PresentationState를@Presents로 바꾼다. - 요구 2: 뷰에서
WithViewStore를 없애고@Bindable var store+.alert($store.scope(\.alert, action: \.alert))로 바꾼다. - 요구 3: 리듀서 로직은 건드리지 않는다. 아래 TestStore 테스트가 옮기기 전후 모두 통과해야 한다.
확인 방법: Preview에서 + 를 세 번 눌러 알럿이 뜨는지 보고, 알럿을 닫은 뒤 한 번 더 눌러 다시 뜨지 않는지(count가 4) 확인한다. 테스트 타깃에서 thirdTapShowsAlert를 돌린다.
정답 보기
@Reducer
struct Tally {
@ObservableState
struct State: Equatable {
var count = 0
@Presents var alert: AlertState<Action.Alert>?
}
enum Action {
case alert(PresentationAction<Alert>)
case incrementButtonTapped
enum Alert: Equatable {}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .alert:
return .none
case .incrementButtonTapped:
state.count += 1
if state.count == 3 {
state.alert = AlertState { TextState("3번 눌렀다") }
}
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}
}
struct TallyView: View {
@Bindable var store: StoreOf<Tally>
var body: some View {
VStack {
Text("\(store.count)")
Button("+") { store.send(.incrementButtonTapped) }
}
.alert($store.scope(\.alert, action: \.alert))
}
}
#Preview {
TallyView(store: Store(initialState: Tally.State()) { Tally() })
}
@MainActor
@Test func thirdTapShowsAlert() async {
let store = TestStore(initialState: Tally.State()) { Tally() }
await store.send(.incrementButtonTapped) { $0.count = 1 }
await store.send(.incrementButtonTapped) { $0.count = 2 }
await store.send(.incrementButtonTapped) {
$0.count = 3
$0.alert = AlertState { TextState("3번 눌렀다") }
}
}