← SwiftUI 14일 마스터 3 / 14 · 2부

Day 3 · 2부 · @Binding과 파생값

남의 진실을 읽고 쓰는 Binding을 만들고, 저장하던 파생값을 계산 프로퍼티로 바꾼다.
진행률
0 / 0 완료
1부에서 진실은 한 곳에 두고, 뷰가 소유하는 진실은 @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 예제를 다시 그리면 이렇다.

PlayerView (부모) @State private var isPlaying 저장소는 여기 하나뿐 TitleLabel (읽기만) let isPlaying: Bool PlayButton (읽고 쓰기) @Binding var isPlaying: Bool 값 복사 (isPlaying) 통로 ($isPlaying) 자식은 값을 보관하지 않는다 — 그래서 어긋날 수가 없다
$는 값이 아니라 "그 값으로 가는 통로"를 넘긴다. 자식이 통로에 쓰면 부모의 진실이 바뀌고, 결과가 다시 흘러 내려온다.

$ 의 정체 = 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" — 새 진실이 생긴다. 둘 다 부모에게 변경이 돌아가지 않는다.

❌ 자식이 @State로 복사 — 진실이 둘, 부모는 편집 결과를 모른다
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)    // 자기 복사본만 바뀐다
    }
}
✅ @Binding + 프로퍼티 단위 바인딩 ($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) 불변식을 한 곳에서 지키고 따로 테스트할 수 있다는 것도 같은 세션의 조언이다.

상황UIKitSwiftUI
자식이 부모 값을 바꿔야 함delegate 프로토콜 정의 → 부모 채택 → 콜백에서 대입 → 화면 갱신@Binding 한 줄 + 부모가 $값 전달
컨트롤 값 읽기/쓰기addTarget(_:action:for: .valueChanged) + isOn 대입Toggle(isOn: $flag)
같은 값을 여러 뷰가 표시값 바뀔 때마다 각 뷰에 set (빠뜨리면 버그)모두 같은 진실을 읽는다
자식이 값을 복사해 보관흔한 패턴안티패턴 — 진실이 둘
📝 Apple 원문

"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로 든다. 쓰기는 무시되므로 모양 확인용이다.

상태별 모양을 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에서 바로 되는 방법이다.

Preview에서 실제로 토글되게 — 상태를 가진 호스트 뷰
private struct PlayButtonPreviewHost: View {
    @State private var isPlaying = false
    var body: some View {
        VStack {
            PlayButton(isPlaying: $isPlaying)
            Text(isPlaying ? "재생 중" : "정지")
        }
    }
}

#Preview("동작 확인") { PlayButtonPreviewHost() }
🆕 최신 API Xcode 16+ SDK

Previewable() 매크로를 쓰면 호스트 뷰 없이 #Preview 안에 @Previewable @State var isPlaying = false를 바로 선언할 수 있다. 문서상 배포 타깃은 iOS 17.0부터지만, 매크로 자체는 Xcode 16 SDK에 들어 있어 iOS 17 SDK(Xcode 15)로는 빌드되지 않는다.

👀 Preview 팁

#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로 따로 두면 진실이 여러 개가 된다.

❌ 태그마다 Bool 상태 — Set과 Bool 여러 개를 계속 맞춰야 한다
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") }
        }
    }
}
✅ 진실은 Set 하나. Toggle에는 계산된 통로를 준다
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 바인딩의 규칙

· get은 진실에서 계산만 한다. 여기서 네트워크를 부르거나 값을 캐싱하면 두 번째 진실이 생긴다.
· set은 진실을 고치기만 한다. 화면을 직접 바꾸려 하지 않는다 — 화면은 진실을 다시 읽어 알아서 바뀐다.
· 새 SDK에서는 이 두 클로저가 @Sendable로 선언돼 있다(선언부). 뷰 안에서 만들면 메인 액터 격리가 추론되므로 위 코드처럼 쓰면 된다.

3. 파생값은 계산 프로퍼티로 + 흔한 실수

"개수", "필터된 목록", "비었는지"처럼 진실에서 계산할 수 있는 값을 @State에 저장하면 진실이 하나 더 생긴다. WWDC19가 원칙 두 번째에서 경고한 "Duplicated source of truth can lead to bug and inconsistency"가 정확히 이것이다. 계산할 수 있으면 저장하지 말고 계산한다.

❌ 파생값을 저장 ✅ 파생값은 계산 @State items @State filtered onChange로 복사 (query 바뀔 때도) 진실 2개 → 동기화 코드가 생긴다 복사 한 곳 빠뜨리면 목록이 거짓말 @State items visible doneCount isEmpty 점선 = 저장 안 함, body가 읽을 때 계산 어긋날 복사본이 없다
진실 하나에서 여러 값이 흘러나오는 건 괜찮다. 문제는 흘러나온 값을 다시 저장하는 것이다.
❌ 필터 결과를 @State에 저장하고 onChange로 맞춘다
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) }
    }
}
✅ 진실은 items와 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은 친구 공책 자체를 빌려 쓰는 것이다.
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() }

확인 방법: Preview를 Live로 두고 두 슬라이더를 번갈아 움직인다. 어느 쪽을 움직여도 두 숫자가 항상 F = C × 9/5 + 32를 만족하면 성공이다.

정답 코드 보기
정답 — 진실은 celsius 하나
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)로 넓힌다.

출처 · 참고자료