@State·@Binding을 두 부로 나눠 익힌다. 1부는 규칙 자체와, 진실을 소유하는 @State를 다룬다.1부 목표 · 23분
- 뷰 코드를 보고 "이 값의 진실은 어디인가"를 짚고, 진실이 둘인 곳을 찾아 하나로 합친다.
- WWDC20의 세 질문으로
let·@State·@Binding중 무엇을 쓸지 고른다. @State를private+ 초기값으로 선언하고,init에서 채운@State가 왜 두 번째부터 무시되는지 설명한다.- 무거운 초기값을 옵셔널 +
.task로 미뤄 매 init 평가를 피한다.
1. 진실은 한 곳 — SwiftUI의 첫 번째 규칙 🔥 핵심
WWDC19 WWDC19 · Data Flow Through SwiftUI는 데이터 도구를 소개하기 전에 원칙 두 개를 먼저 못 박는다. 이 두 줄이 오늘 전체의 뼈대다.
"Every time you read a piece of data in your view, you're creating a dependency for that view." — 뷰에서 데이터를 읽는 순간 그 뷰는 그 데이터에 의존한다. 데이터가 바뀌면 뷰도 바뀌어야 한다.
"Regardless of where the source of truth lives, you should always have a single source of truth." — 진실이 뷰 안에 있든 바깥 모델에 있든, 진실은 언제나 하나여야 한다.
같은 세션은 UIKit 방식을 정면으로 비교한다: "The whole purpose of ViewController is to keep your data in sync with your view." 우리가 매일 쓰던 didSet에서 라벨 갱신, 델리게이트 콜백에서 모델 갱신 — 그게 전부 두 진실을 맞추는 코드였다. Apple 문서 Model data는 이 코드를 "glue logic"이라고 부르고, SwiftUI 도구의 목적을 "reducing the amount of glue logic you write"로 적는다.
코드로 보면 차이가 더 선명하다. 같은 카운터를 두 방식으로 쓴다.
import UIKit
final class CounterViewController: UIViewController {
private let countLabel = UILabel()
private var count = 0 {
didSet { countLabel.text = "\(count)" } // 이 줄을 빠뜨리면 화면이 거짓말을 한다
}
override func viewDidLoad() {
super.viewDidLoad()
countLabel.text = "\(count)" // 초기 동기화도 직접
let button = UIButton(type: .system)
button.setTitle("+1", for: .normal)
button.addTarget(self, action: #selector(increment), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [countLabel, button])
stack.axis = .vertical
view.addSubview(stack)
}
@objc private func increment() { count += 1 }
}struct CounterView: View {
@State private var count = 0 // 유일한 진실
var body: some View {
VStack {
Text("\(count)") // 읽었다 → 의존성이 생겼다
Button("+1") { count += 1 } // 진실만 바꾼다. 라벨은 안 건드린다
}
}
}뷰는 "상태의 함수"다
WWDC19의 핵심 문장은 "Views are a function of state, not a sequence of events"다. UIKit은 이벤트가 오면 뷰를 직접 고쳤다(서브뷰 추가, alpha 변경). SwiftUI는 이벤트가 오면 상태만 바꾸고, 뷰는 그 상태로부터 다시 설명된다. 세션은 이걸 한 방향으로만 도는 고리로 그린다.
새 뷰를 만들 때 던지는 세 질문
WWDC20 · Data Essentials in SwiftUI는 뷰를 하나 만들 때마다 세 질문을 던지라고 한다. 셋째 질문의 답이 곧 도구 선택이다.
| 질문 | 답의 예 | 도구 |
|---|---|---|
| ① 이 뷰는 무슨 데이터가 필요한가? | 책 제목, 진행률 | — |
| ② 그 데이터를 바꾸는가? | 표시만 한다 | let 프로퍼티 |
| ③ 데이터는 어디서 오는가? (진실은 어디?) | 이 뷰만 쓰는 일시적 UI 값 | @State |
| ③ 〃 | 부모가 가진 값을 바꿔야 함 | @Binding |
| ③ 〃 | 앱의 모델 객체 | @Observable (Day 5) |
"You can use properties for data that doesn't change. Use State for transient data owned by the view. Use Binding for mutating data owned by another view." — 바뀌지 않는 건 그냥 프로퍼티, 이 뷰가 소유하는 일시적 값은 State, 남이 소유한 값을 바꿀 땐 Binding. (WWDC20 · Data Essentials in SwiftUI)
2. @State — 소유 · private · 초기값은 딱 한 번 🔥 핵심
View는 struct다. struct의 프로퍼티를 body나 버튼 클로저에서 바꾸려 하면 컴파일러가 막는다(self가 불변). WWDC19는 이 에러를 "좋은 일"이라고 한다 — 뷰 계층을 직접 바꾸지 말라는 원칙을 컴파일러가 지켜 주는 것이다. 바뀌는 값이 필요하면 SwiftUI에게 저장소를 맡겨야 하고, 그 표시가 @State다.
struct PlayToggle: View {
var isPlayingPlain = false // 평범한 프로퍼티
@State private var isPlaying = false // SwiftUI가 저장소를 관리
var body: some View {
Button(isPlaying ? "Pause" : "Play") {
// isPlayingPlain.toggle() ← 컴파일 에러: 'self' is immutable
isPlaying.toggle() // OK: 저장소는 struct 바깥에 있다
}
}
}Apple 문서의 규칙 네 개
Apple 문서 · State의 문장을 그대로 규칙으로 옮기면 이렇다.
| 규칙 | Apple 문서 근거 | 어기면 |
|---|---|---|
| private으로 선언 | "Declare state as private to prevent setting it in a memberwise initializer, which can conflict with the storage management that SwiftUI provides" | 바깥에서 넣은 값이 첫 번째 이후 무시된다(아래 함정) |
| 초기값을 선언부에 | "Initialize state by providing a default value in the state's declaration" | init 대입은 스냅샷이 된다 |
| 필요한 가장 높은 뷰에 | "Declare state as private in the highest view in the view hierarchy that needs access to the value" | 형제 뷰와 공유 불가 → 복사본이 생긴다 |
| 로컬 UI 값에만 | "Use state only for storage that's local to a view and its subviews" | 앱 데이터가 뷰 수명에 묶여 사라진다 |
자식에게는 두 가지 방식으로 나눠 준다 — 읽기만 하면 값 그대로, 바꿔야 하면 Binding. 문서 표현으로는 "either directly for read-only access, or as a binding for read-write access"다.
struct PlayerView: View {
@State private var isPlaying = false // 진실은 여기 하나
var body: some View {
VStack {
TitleLabel(isPlaying: isPlaying) // 읽기만 → 값 복사로 충분
PlayButton(isPlaying: $isPlaying) // 바꿔야 함 → $로 통로를 넘긴다
}
}
}
struct TitleLabel: View {
let isPlaying: Bool // 부모가 바뀌면 새 값으로 다시 만들어진다
var body: some View {
Text("에피소드 1")
.foregroundStyle(isPlaying ? .primary : .secondary)
}
}
struct PlayButton: View {
@Binding var isPlaying: Bool
var body: some View {
Button(isPlaying ? "Pause" : "Play") { isPlaying.toggle() }
}
}저장소는 struct 바깥에 산다
WWDC20은 @State의 정체를 이렇게 설명한다: 렌더링이 끝나면 struct는 사라지고, 다음에 필요하면 struct를 다시 만들어 "reconnects it to the existing storage". 즉 struct는 일회용, 저장소는 하나다. 이 그림을 머리에 넣으면 아래 함정이 저절로 풀린다.
body가 다시 돌 때마다 자식 struct는 새로 만들어지지만, @State 저장소는 처음 것 그대로다.함정 ① init에서 @State를 채우기
UIKit 습관으로 "넘겨받은 값을 편집용 상태에 복사"하면 이렇게 된다. State.init(wrappedValue:) 문서가 이유를 적어 둔다: "SwiftUI initializes the state's storage only once for each container instance that you declare."
struct NameEditor: View {
@State private var draft: String
init(name: String) {
_draft = State(initialValue: name) // 저장소가 처음 생길 때만 쓰인다
}
var body: some View {
TextField("이름", text: $draft)
// 부모가 "철수" → "영희"로 바꿔 다시 만들어도 draft는 "철수" 그대로
// 편집해도 부모의 name은 안 바뀐다 → 진실이 둘
}
}struct NameEditor: View {
@Binding var name: String // 복사본이 없다
var body: some View {
TextField("이름", text: $name) // 편집이 곧바로 부모의 진실을 바꾼다
}
}
struct ProfileScreen: View {
@State private var name = "철수"
var body: some View {
Form {
NameEditor(name: $name)
Text("미리보기: \(name)") // 같은 진실을 읽는다 — 항상 일치
}
}
}저장 버튼을 누를 때만 원본에 반영해야 하는 편집 화면이라면 복사본이 맞다. 이때 초안은 원본의 사본이 아니라 그 자체로 독립된 진실이다. 다만 편집 대상이 바뀔 때 초안을 새로 시작하게 하려면 부모가 .id(대상.id)로 뷰를 새로 만들어야 한다 — 그 원리가 Day 4(identity)의 주제다.
함정 ② 무거운 초기값
문서는 한 번 더 경고한다: "A State property always instantiates its default value when SwiftUI instantiates the view." 저장소가 이미 있어도 초기값 식 자체는 struct가 만들어질 때마다 평가된다. 가벼운 값이면 무시해도 되지만, 초기화에 네트워크·파일 접근·큰 할당이 얽혀 있으면 문서 권장대로 .task로 미룬다.
@Observable final class Library {
var books: [String] = []
init() { /* 디스크에서 목록을 읽는 무거운 작업이라고 하자 */ }
}
struct LibraryScreen: View {
@State private var library = Library() // 매 init마다 평가된다
var body: some View {
Text("\(library.books.count)권")
}
}struct LibraryScreen: View {
@State private var library: Library?
var body: some View {
Text("\(library?.books.count ?? 0)권")
.task { if library == nil { library = Library() } } // .task는 나타날 때마다 불린다 → nil일 때만 만든다
}
}Apple 문서 · State()에 따르면 Xcode 27 이상으로 빌드하면 @State가 property wrapper 대신 State() 매크로로 처리된다. 매크로 문서는 "A State() property instantiates its default value the first time SwiftUI instantiates the view"라고 적는다 — 함정 ②의 반복 평가가 첫 번째에만으로 바뀐다. 문서상 지원 OS 범위는 property wrapper와 같다(iOS 13+). 코드는 그대로 두면 되지만, 팀의 빌드 Xcode가 섞여 있다면 여전히 무거운 초기값은 .task로 미루는 쪽이 안전하다.
WWDC19의 조언: "if you find yourself reaching for state, please take a step back and consider, does this data really need to be owned by this view?" 버튼이 눌린 중인지(highlight), 시트가 열렸는지, 필터 선택값처럼 이 뷰만 알면 되는 값이 @State 자리다. 서버에 저장할 데이터는 모델(Day 5)의 자리다. Managing user interface state 문서도 "Don't use state properties for persistent storage because the life cycle of state variables mirrors the view life cycle"이라고 못 박는다.
✅ 복습 · 5분
@State를 private으로 선언하라는 이유는? "캡슐화 취향" 말고 동작 측면에서 답하라.
private은 그 잘못된 사용을 컴파일 단계에서 막는다.@State private var library = Library()는 저장소가 이미 있어도 무엇이 반복되나? 왜 문제이고, 어떻게 피하나?
Library()가 평가된다. 저장소는 처음 것을 계속 쓰므로 새로 만든 인스턴스는 곧바로 버려진다. 초기화가 무거우면 매번 헛일이다. 문서 패턴대로 @State private var library: Library?로 두고 .task에서 nil일 때만 만든다.struct Parent: View {
@State private var count = 0
var body: some View {
VStack {
Text("부모: \(count)")
Child(start: count)
Button("부모 +1") { count += 1 }
}
}
}
struct Child: View {
@State private var value: Int
init(start: Int) { _value = State(initialValue: start) }
var body: some View { Text("자식: \(value)") }
}🔮 코드 예측: 위 코드에서 "부모 +1"을 세 번 누르면 두 Text는 각각 무엇을 표시하나?
Child struct는 매번 start: 1, 2, 3으로 새로 만들어지지만 value 저장소는 처음 만들어질 때(0) 한 번만 초기화된다. 자식이 부모 값을 따라가게 하려면 let start: Int로 받아 그대로 쓰면 된다.🔁 지난 Day 복습: Day 2에서 본 "계산 프로퍼티로 추출"과 "서브뷰 struct로 추출". 추출한 조각 안에서 @State가 필요하다면 어느 쪽을 골라야 하나?
var header: some View)는 부모 body의 일부일 뿐이라 자기 저장소를 가질 수 없고, 쓰는 상태는 전부 부모의 @State다. 별도 View struct로 추출하면 그 struct가 @State private var를 가질 수 있고, 부모의 값을 바꿔야 하면 오늘 배운 @Binding으로 받는다.1부의 PlayerView에서 $isPlaying으로 넘긴 "통로"의 정체를 2부에서 푼다 — @Binding과 projectedValue, Preview용 .constant, 모양이 다른 진실을 잇는 Binding(get:set:), 그리고 파생값을 저장하지 않는 법.