@State로 만든다는 걸 봤다. 그런데 자식 뷰가 부모의 진실을 고쳐야 한다면? 복사하면 진실이 둘이 된다. 2부는 소유하지 않고 읽고 쓰는 통로 — Binding과, 저장하지 말고 계산해야 할 파생값을 다룬다.2부 목표 · 21분
- 자식 뷰의
@State복사본을@Binding으로 바꾸고,$가 넘기는 게 값이 아니라 통로라는 걸 코드로 보인다. $config.note처럼 기존 Binding에서 프로퍼티 단위 Binding을 만든다.- Preview에
.constant를 쓰고,Binding(get:set:)으로 모양이 다른 진실에 컨트롤을 연결한다. onChange로 맞추던 파생값을 계산 프로퍼티로 바꾼다.
1. @Binding — 소유하지 않고 읽고 쓰는 통로 🔥 핵심
Apple 문서 · Binding의 정의: "A binding connects a property to a source of truth stored elsewhere, instead of storing data directly." 저장소가 없다. 남의 저장소로 가는 읽기·쓰기 통로만 있다. 1부의 PlayerView 예제를 다시 그리면 이렇다.
$는 값이 아니라 "그 값으로 가는 통로"를 넘긴다. 자식이 통로에 쓰면 부모의 진실이 바뀌고, 결과가 다시 흘러 내려온다.$ 의 정체 = projectedValue
WWDC20: "The dollar sign in the call creates a Binding from the State because the projected value of the State property wrapper is a Binding." $isPlaying은 _isPlaying.projectedValue의 줄임말이고, State.projectedValue의 타입이 Binding<Value>다. @Binding 프로퍼티에 다시 $를 붙이면 또 Binding이 나오므로, 통로를 몇 단계든 내려보낼 수 있다.
흔한 오답 두 개 — WWDC20 ProgressEditor 예제
WWDC20은 편집 시트(ProgressEditor)에 부모의 설정값을 넘기는 두 가지 오답을 먼저 보여 준다. (1) 그냥 프로퍼티로 넘기면 값 타입이라 복사본만 바뀐다. (2) 자식에 @State를 두면 "State creates a new source of truth" — 새 진실이 생긴다. 둘 다 부모에게 변경이 돌아가지 않는다.
struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress = 0.0
mutating func present(initialProgress: Double) {
progress = initialProgress
note = ""
isEditorPresented = true
}
}
struct ProgressEditor: View {
@State var config: EditorConfig // 부모 것과 별개의 새 진실
var body: some View {
TextEditor(text: $config.note) // 자기 복사본만 바뀐다
}
}struct ProgressEditor: View {
@Binding var config: EditorConfig // 소유하지 않는다
var body: some View {
Form {
TextEditor(text: $config.note) // Binding에서 Binding을 꺼낸다
Slider(value: $config.progress, in: 0...1)
}
}
}
struct BookView: View {
@State private var editorConfig = EditorConfig() // 진실은 여기
var body: some View {
Button("진행률 업데이트") {
editorConfig.present(initialProgress: 0.3)
}
.sheet(isPresented: $editorConfig.isEditorPresented) {
ProgressEditor(config: $editorConfig)
}
}
}$config.note가 되는 이유는 Binding이 @dynamicMemberLookup이기 때문이다(Binding 선언부). WWDC20 표현으로 "SwiftUI lets us build a new Binding from an existing Binding". 관련 값 여러 개를 struct 하나로 묶으면(EditorConfig) 불변식을 한 곳에서 지키고 따로 테스트할 수 있다는 것도 같은 세션의 조언이다.
| 상황 | UIKit | SwiftUI |
|---|---|---|
| 자식이 부모 값을 바꿔야 함 | delegate 프로토콜 정의 → 부모 채택 → 콜백에서 대입 → 화면 갱신 | @Binding 한 줄 + 부모가 $값 전달 |
| 컨트롤 값 읽기/쓰기 | addTarget(_:action:for: .valueChanged) + isOn 대입 | Toggle(isOn: $flag) |
| 같은 값을 여러 뷰가 표시 | 값 바뀔 때마다 각 뷰에 set (빠뜨리면 버그) | 모두 같은 진실을 읽는다 |
| 자식이 값을 복사해 보관 | 흔한 패턴 | 안티패턴 — 진실이 둘 |
"Toggle doesn't need to know or care where that Boolean lives or comes from. All it needs to do is know how to read the value and change the value." — Toggle은 Bool이 어디 사는지 모른다. 읽고 쓰는 법만 안다. 그래서 Binding을 받는 컴포넌트는 어떤 진실에도 붙일 수 있다. (WWDC19 · Data Flow Through SwiftUI)
Toggle(isOn:)처럼 Binding을 받는 기본 컨트롤은 켜짐/꺼짐 상태를 VoiceOver가 자동으로 읽는다. 직접 만든 아이콘 전용 재생 버튼은 Label("재생", systemImage: "play.fill").labelStyle(.iconOnly)로 만들어 읽을 이름을 남긴다.
2. Binding을 만드는 다른 방법 — .constant와 get/set
$ 말고도 Binding을 직접 만들 일이 두 번 있다. Preview에서 고정값을 넣을 때, 그리고 진실의 모양이 컨트롤이 원하는 모양과 다를 때다.
Binding.constant — Preview용 고정 통로
Binding.constant(_:)는 "Creates a binding with an immutable value"다. 문서도 용도를 Preview로 든다. 쓰기는 무시되므로 모양 확인용이다.
struct PlayButton: View {
@Binding var isPlaying: Bool
var body: some View {
Button(isPlaying ? "Pause" : "Play") { isPlaying.toggle() }
}
}
#Preview("재생 중") { PlayButton(isPlaying: .constant(true)) }
#Preview("정지") { PlayButton(isPlaying: .constant(false)) }탭해서 동작까지 보고 싶으면 @State를 가진 작은 호스트 뷰로 감싼다. iOS 17 SDK에서 바로 되는 방법이다.
private struct PlayButtonPreviewHost: View {
@State private var isPlaying = false
var body: some View {
VStack {
PlayButton(isPlaying: $isPlaying)
Text(isPlaying ? "재생 중" : "정지")
}
}
}
#Preview("동작 확인") { PlayButtonPreviewHost() }Previewable() 매크로를 쓰면 호스트 뷰 없이 #Preview 안에 @Previewable @State var isPlaying = false를 바로 선언할 수 있다. 문서상 배포 타깃은 iOS 17.0부터지만, 매크로 자체는 Xcode 16 SDK에 들어 있어 iOS 17 SDK(Xcode 15)로는 빌드되지 않는다.
#Preview("이름")으로 상태별 Preview를 여러 개 두면 캔버스 상단 탭으로 전환된다. .constant로 "빈 값·긴 값·에러 상태"를 하나씩 고정해 두면 레이아웃이 깨지는 경우를 바로 잡는다.
Binding(get:set:) — 진실의 모양을 바꿔 연결
Binding.init(get:set:)은 "Creates a binding with closures that read and write the binding value"다. 진실은 Set<String> 하나인데 태그마다 Toggle(Bool)을 달고 싶을 때가 전형적이다. 태그별 Bool을 @State로 따로 두면 진실이 여러 개가 된다.
struct TagFilterBad: View {
@State private var selected: Set<String> = []
@State private var swiftOn = false // selected와 중복된 진실
@State private var uikitOn = false // 〃
var body: some View {
Form {
Toggle("Swift", isOn: $swiftOn)
Toggle("UIKit", isOn: $uikitOn)
}
.onChange(of: swiftOn) { // glue logic이 돌아왔다
if swiftOn { selected.insert("Swift") } else { selected.remove("Swift") }
}
}
}struct TagFilter: View {
let allTags = ["Swift", "UIKit", "SwiftUI"]
@State private var selected: Set<String> = []
var body: some View {
Form {
ForEach(allTags, id: \.self) { tag in
Toggle(tag, isOn: binding(for: tag))
}
Text("선택: \(selected.sorted().joined(separator: ", "))")
}
}
private func binding(for tag: String) -> Binding<Bool> {
Binding(
get: { selected.contains(tag) }, // 읽기: Set에서 계산
set: { isOn in // 쓰기: Set을 고친다
if isOn { selected.insert(tag) } else { selected.remove(tag) }
}
)
}
}· get은 진실에서 계산만 한다. 여기서 네트워크를 부르거나 값을 캐싱하면 두 번째 진실이 생긴다.
· set은 진실을 고치기만 한다. 화면을 직접 바꾸려 하지 않는다 — 화면은 진실을 다시 읽어 알아서 바뀐다.
· 새 SDK에서는 이 두 클로저가 @Sendable로 선언돼 있다(선언부). 뷰 안에서 만들면 메인 액터 격리가 추론되므로 위 코드처럼 쓰면 된다.
3. 파생값은 계산 프로퍼티로 + 흔한 실수
"개수", "필터된 목록", "비었는지"처럼 진실에서 계산할 수 있는 값을 @State에 저장하면 진실이 하나 더 생긴다. WWDC19가 원칙 두 번째에서 경고한 "Duplicated source of truth can lead to bug and inconsistency"가 정확히 이것이다. 계산할 수 있으면 저장하지 말고 계산한다.
struct Todo: Identifiable {
let id = UUID()
var title: String
var isDone = false
}
struct TodoListBad: View {
@State private var items: [Todo] = [Todo(title: "우유"), Todo(title: "빵")]
@State private var query = ""
@State private var filtered: [Todo] = [] // 파생값을 저장 → 진실 2개
var body: some View {
List(filtered) { Text($0.title) }
.searchable(text: $query)
.onChange(of: query, initial: true) { refilter() }
.onChange(of: items.count) { refilter() } // 제목 편집은 놓친다
}
private func refilter() {
filtered = query.isEmpty ? items : items.filter { $0.title.contains(query) }
}
}struct TodoList: View {
@State private var items: [Todo] = [Todo(title: "우유"), Todo(title: "빵")]
@State private var query = ""
private var visible: [Todo] { // 저장하지 않는다
query.isEmpty ? items : items.filter { $0.title.contains(query) }
}
private var doneCount: Int { items.filter(\.isDone).count }
var body: some View {
List(visible) { Text($0.title) }
.searchable(text: $query)
.navigationTitle("완료 \(doneCount)/\(items.count)")
}
}먼저 계산 프로퍼티로 쓰고, 실제로 느린지 측정한 뒤 옮긴다. 옮길 곳은 @State 복사본이 아니라 모델(Day 5)이고, 느린 업데이트를 찾는 법은 Day 9에서 다룬다. 수십~수백 개 배열 필터는 body에서 계산해도 된다.
흔한 실수 모음
| 증상 | 원인 | 처방 |
|---|---|---|
| 부모가 값을 바꿨는데 자식 화면이 안 바뀐다 | 자식이 init에서 @State로 복사 (1부 함정 ①) | @Binding 또는 let으로 받기 |
| 자식에서 편집했는데 부모가 모른다 | 자식에 @State 복사본 | @Binding |
onChange로 값 두 개를 맞추는 코드가 늘어난다 | 파생값을 저장 | 계산 프로퍼티 / get·set Binding |
| 앱 재실행 후 데이터가 사라진다 | 영속 데이터를 @State에 | 모델·저장소로 (Day 5, Day 13) |
@State에 ObservableObject를 넣었더니 갱신이 안 된다 | 문서: 참조가 바뀔 때만 갱신 | @Observable로 (레거시는 @StateObject) |
State 문서 Note: ObservableObject를 State에 넣으면 "the view will only update when the reference to the object changes". @Published 프로퍼티가 바뀌어도 화면은 그대로다. iOS 17 기준 새 코드는 @Observable + @State를 쓴다 — Day 5의 주제다.
✅ 복습 · 5분
자식 뷰가 부모 값을 바꿔야 할 때 자식에 @State var config를 두면 무엇이 잘못되나?
@State는 새 진실을 만든다. 자식은 부모 값의 복사본을 자기 진실로 삼아 편집하고, 부모의 진실은 그대로다. 자식은 @Binding var config로 받고 부모가 $config를 넘겨야 한다.Binding(get:set:)의 get 안에서 값을 캐싱하거나 네트워크를 부르면 무엇이 깨지나?
get은 진실에서 계산만 해야 한다. 캐시를 두면 그 캐시가 두 번째 진실이 되어 원본과 어긋날 수 있다. set도 진실을 고치기만 하고, 화면은 진실을 다시 읽어 바뀐다.struct MuteRow: View {
@State private var isMuted = false
var body: some View {
VStack {
Toggle("음소거", isOn: .constant(isMuted))
Text(isMuted ? "조용" : "소리 켜짐")
}
}
}🔮 코드 예측: 위 코드에서 Toggle을 탭하면 Toggle과 Text는 어떻게 되나?
.constant(isMuted)는 "Creates a binding with an immutable value" — 쓰기가 무시되므로 isMuted는 false에 머문다. 탭으로 바꾸려면 isOn: $isMuted로 진짜 통로를 넘긴다.🔁 1부 복습: 부모가 name을 "철수"→"영희"로 바꿨는데 자식 TextField는 "철수" 그대로다. 자식은 init(name:)에서 _draft = State(initialValue: name)을 한다. 원인과 처방은?
@State 저장소는 컨테이너 인스턴스마다 한 번만 초기화된다(State.init(wrappedValue:) 문서). 두 번째 이후 init에 넘긴 값은 무시되고, 편집 결과도 부모로 가지 않아 진실이 둘이 된다. 부모 값을 편집해야 하면 @Binding var name, 표시만 하면 let name으로 받는다. 저장 버튼 전까지 독립된 초안이 필요할 때만 복사가 맞고, 대상이 바뀌면 부모가 .id()로 새로 만든다(Day 4).🛠 미니 과제 (선택 · 10분)
아래 "온도 변환기"는 진실이 둘이다. 섭씨를 바꾸면 화씨가 안 맞고, 화씨 슬라이더를 움직이면 섭씨가 안 맞는다. Preview 하나로 고친다.
struct TemperatureConverter: View {
@State private var celsius = 20.0
@State private var fahrenheit = 68.0 // celsius에서 계산할 수 있는 값
var body: some View {
Form {
Slider(value: $celsius, in: -30...50) { Text("섭씨") }
Text("섭씨 \(celsius, specifier: "%.1f")°")
Slider(value: $fahrenheit, in: -22...122) { Text("화씨") }
Text("화씨 \(fahrenheit, specifier: "%.1f")°")
}
}
}
#Preview { TemperatureConverter() }- 요구 1 — 저장하는
@State는celsius하나만 남긴다. - 요구 2 — 화씨 슬라이더는 그대로 동작해야 한다(
Binding(get:set:)사용). - 요구 3 — 화씨 라벨은 계산 프로퍼티에서 읽는다.
확인 방법: Preview를 Live로 두고 두 슬라이더를 번갈아 움직인다. 어느 쪽을 움직여도 두 숫자가 항상 F = C × 9/5 + 32를 만족하면 성공이다.
정답 코드 보기
struct TemperatureConverter: View {
@State private var celsius = 20.0 // 유일한 진실
private var fahrenheit: Double { // 파생값
celsius * 9 / 5 + 32
}
private var fahrenheitBinding: Binding<Double> {
Binding(
get: { celsius * 9 / 5 + 32 },
set: { celsius = ($0 - 32) * 5 / 9 } // 쓰기는 진실로 되돌린다
)
}
var body: some View {
Form {
Slider(value: $celsius, in: -30...50) { Text("섭씨") }
Text("섭씨 \(celsius, specifier: "%.1f")°")
Slider(value: fahrenheitBinding, in: -22...122) { Text("화씨") }
Text("화씨 \(fahrenheit, specifier: "%.1f")°")
}
}
}
#Preview { TemperatureConverter() }다음 Day 4에서는 오늘 두 번 미뤄 둔 질문 — "@State 저장소는 정확히 언제 만들어지고 언제 버려지나" — 을 identity와 lifetime으로 답한다. Day 5에서는 오늘의 규칙을 참조 타입 모델(@Observable)로 넓힌다.