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

Day 3 · 1부 · 진실은 한 곳과 @State

진실이 둘이 되는 순간을 찾고, 뷰가 소유하는 값을 @State로 올바르게 선언한다.
진행률
0 / 0 완료
UIKit에서 화면 버그의 상당수는 "모델은 바뀌었는데 라벨은 그대로"였다. Apple은 이걸 한 문장으로 짚는다 — 뷰 컨트롤러의 존재 이유 자체가 데이터와 뷰를 맞추는 일이었다고. SwiftUI는 그 맞추는 코드를 없애는 대신 규칙 하나를 요구한다: 모든 데이터는 진실(source of truth)이 딱 한 곳에 있어야 한다. Day 3은 그 규칙과 기본 도구 @State·@Binding을 두 부로 나눠 익힌다. 1부는 규칙 자체와, 진실을 소유하는 @State를 다룬다.

1부 목표 · 23분

  • 뷰 코드를 보고 "이 값의 진실은 어디인가"를 짚고, 진실이 둘인 곳을 찾아 하나로 합친다.
  • WWDC20의 세 질문으로 let · @State · @Binding 중 무엇을 쓸지 고른다.
  • @State를 private + 초기값으로 선언하고, init에서 채운 @State가 왜 두 번째부터 무시되는지 설명한다.
  • 무거운 초기값을 옵셔널 + .task로 미뤄 매 init 평가를 피한다.

1. 진실은 한 곳 — SwiftUI의 첫 번째 규칙 🔥 핵심

WWDC19 WWDC19 · Data Flow Through SwiftUI는 데이터 도구를 소개하기 전에 원칙 두 개를 먼저 못 박는다. 이 두 줄이 오늘 전체의 뼈대다.

📝 Apple 원문

"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"로 적는다.

❌ UIKit — 진실이 둘 ✅ SwiftUI — 진실이 하나 모델 count = 3 라벨 text = "3" 갱신 되돌리기 한쪽만 바뀌면 즉시 어긋난다 호출 하나 빠뜨리는 게 곧 버그 두 값을 맞추는 코드 = glue logic @State count = 3 유일한 진실 Text("\(count)") 화면은 진실의 사진일 뿐 어긋날 두 번째 값이 없다 되돌리는 화살표 없음 = glue logic 없음
왼쪽은 화살표 하나를 빠뜨리면 버그다. 오른쪽에는 빠뜨릴 화살표 자체가 없다.

코드로 보면 차이가 더 선명하다. 같은 카운터를 두 방식으로 쓴다.

UIKit — count와 라벨이 따로 산다. didSet이 둘을 맞추는 glue 코드다
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 }
}
SwiftUI — 진실은 count 하나. 화면은 그걸 읽어서 매번 새로 설명된다
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는 이벤트가 오면 상태만 바꾸고, 뷰는 그 상태로부터 다시 설명된다. 세션은 이걸 한 방향으로만 도는 고리로 그린다.

① 사용자가 버튼을 탭 ② action 클로저 실행 count += 1 ③ State 값이 바뀜 SwiftUI가 변화를 감지 ④ 의존 뷰의 body 재계산 → 새 화면을 그린다 타이머 · 알림도 데이터는 한 방향으로만 모든 변경은 ③을 반드시 지난다
뷰를 직접 고치는 화살표가 없다. 모든 변화는 ③(상태)을 거쳐서만 화면에 닿는다 — 그래서 결과가 예측 가능하다.

새 뷰를 만들 때 던지는 세 질문

WWDC20 · Data Essentials in SwiftUI는 뷰를 하나 만들 때마다 세 질문을 던지라고 한다. 셋째 질문의 답이 곧 도구 선택이다.

질문답의 예도구
① 이 뷰는 무슨 데이터가 필요한가?책 제목, 진행률—
② 그 데이터를 바꾸는가?표시만 한다let 프로퍼티
③ 데이터는 어디서 오는가? (진실은 어디?)이 뷰만 쓰는 일시적 UI 값@State
③ 〃부모가 가진 값을 바꿔야 함@Binding
③ 〃앱의 모델 객체@Observable (Day 5)
📝 Apple 원문

"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다.

평범한 var는 바꿀 수 없다 → @State로 저장소를 SwiftUI에 맡긴다
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"다.

@State 소유 + 읽기 전용 전달 + 쓰기 권한 전달
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는 일회용, 저장소는 하나다. 이 그림을 머리에 넣으면 아래 함정이 저절로 풀린다.

@State 저장소 — SwiftUI가 보관 (뷰가 화면에 있는 동안 하나) ① 초기값으로 생성 ② 재연결 · 초기값 무시 ③ 재연결 View struct #1 init → body → 버림 View struct #2 init → body → 버림 View struct #3 init → body → 버림 struct는 일회용, 저장소는 하나 → init에서 넣은 값은 두 번째부터 버려진다
부모의 body가 다시 돌 때마다 자식 struct는 새로 만들어지지만, @State 저장소는 처음 것 그대로다.

함정 ① init에서 @State를 채우기

UIKit 습관으로 "넘겨받은 값을 편집용 상태에 복사"하면 이렇게 된다. State.init(wrappedValue:) 문서가 이유를 적어 둔다: "SwiftUI initializes the state's storage only once for each container instance that you declare."

❌ 부모가 다른 name을 넘겨도 TextField는 첫 값에 멈춘다
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로 미룬다.

❌ 부모가 갱신될 때마다 Library()가 새로 만들어졌다가 버려진다
@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)권")
    }
}
✅ 옵셔널로 두고 처음 나타날 때 한 번만 만든다 (Apple 문서 패턴)
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일 때만 만든다
    }
}
🆕 최신 툴체인 Xcode 27 빌드

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로 미루는 쪽이 안전하다.

⚠️ @State를 쓸지 망설여질 때

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으로 선언하라는 이유는? "캡슐화 취향" 말고 동작 측면에서 답하라.
바깥에서 memberwise init으로 값을 넣으면 SwiftUI의 저장소 관리와 충돌한다. 저장소는 뷰가 처음 생길 때 한 번 만들어지고 이후 struct가 다시 만들어져도 재연결만 되므로, 바깥에서 넣은 값은 두 번째부터 무시된다. private은 그 잘못된 사용을 컴파일 단계에서 막는다.
쉽게 말하면 사물함 열쇠는 주인만 가진다. 남이 밖에서 물건을 넣으려 해도 이미 잠긴 사물함이라 처음 한 번 말고는 안 들어간다.
@State private var library = Library()는 저장소가 이미 있어도 무엇이 반복되나? 왜 문제이고, 어떻게 피하나?
부모가 다시 그려져 struct가 새로 만들어질 때마다 초기값 식 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는 각각 무엇을 표시하나?
"부모: 3", "자식: 0". Child struct는 매번 start: 1, 2, 3으로 새로 만들어지지만 value 저장소는 처음 만들어질 때(0) 한 번만 초기화된다. 자식이 부모 값을 따라가게 하려면 let start: Int로 받아 그대로 쓰면 된다.
쉽게 말하면 자식은 첫날 받은 메모만 간직하고, 그다음에 온 메모는 읽지도 않고 버린다.
🔁 지난 Day 복습: Day 2에서 본 "계산 프로퍼티로 추출"과 "서브뷰 struct로 추출". 추출한 조각 안에서 @State가 필요하다면 어느 쪽을 골라야 하나?
서브뷰 struct다. 계산 프로퍼티(var header: some View)는 부모 body의 일부일 뿐이라 자기 저장소를 가질 수 없고, 쓰는 상태는 전부 부모의 @State다. 별도 View struct로 추출하면 그 struct가 @State private var를 가질 수 있고, 부모의 값을 바꿔야 하면 오늘 배운 @Binding으로 받는다.
쉽게 말하면 계산 프로퍼티는 부모 방 안의 서랍이고, 서브뷰는 자기 방이 따로 있는 식구다. 자기 물건(상태)을 두려면 방이 있어야 한다.
➡️ 2부 예고

1부의 PlayerView에서 $isPlaying으로 넘긴 "통로"의 정체를 2부에서 푼다 — @Binding과 projectedValue, Preview용 .constant, 모양이 다른 진실을 잇는 Binding(get:set:), 그리고 파생값을 저장하지 않는 법.

출처 · 참고자료