2. Self.Body를 쓰는 이유
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor @preconcurrency var body: Self.Body { get }
}View는 associatedtype 기반이라 body는 "어떤 View든"이 아니라 "이 타입이 정한 구체 Body"여야 한다.
some View를 쓰는 이유"왜 굳이 some을 쓰는가"를 문법 설명이 아니라 타입 시스템 관점에서 정리한다. 핵심은 표기는 숨기고 컴파일러 타입 정보는 유지다.
2026-04-30
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor @preconcurrency var body: Self.Body { get }
}View는 associatedtype 기반이라 body는 "어떤 View든"이 아니라 "이 타입이 정한 구체 Body"여야 한다.
var body: View로 쓰면 existential(타입 소거) 의미가 강해진다. SwiftUI는 뷰 트리의 구체 타입 정보를 활용해 diff/최적화를 한다.
@ViewBuilder
var content: some View {
if isLoading {
ProgressView()
} else {
Text("Done")
}
}분기마다 반환 뷰 타입이 달라도, 빌더가 내부적으로 단일 합성 타입(ConditionalContent 등)으로 묶어줘서 some View 규칙을 만족시킨다.
| 항목 | some View | AnyView / any View |
|---|---|---|
| 타입 정보 | 컴파일 타임 유지 | 소거됨 |
| 주 용도 | 기본 body 반환 | 동적 슬롯/이종 컬렉션 |
| 비용 | 낮은 편 | 추가 추상화 비용 가능 |
| 질문 | 핵심 답 |
|---|---|
| "컴파일에는 알리고 표기상 숨긴다"는 뜻? | 맞다. 외부 API 표면은 단순화하고, 컴파일러 타입 정보는 유지한다. |
| some은 쓸일이 없지 않나? | 앱 내부 일반 코드에선 적을 수 있지만 SwiftUI body/API 반환에서는 핵심 패턴이다. |
| var body: some View여야 하는 이유? | View 프로토콜의 associatedtype 요구를 만족하면서도 구체 타입 노출을 피하기 위해서다. |
주인님 이해: "opaque 타입은 선언부에는 구체타입을 알고있으나, 호출부에서는 프로토콜 타입만 알게 해서 구체타입을 몰라도 사용할 수 있게 하는 것 맞아?"
결론: 절반은 정확, 한 군데가 결정적으로 부정확. "호출부는 프로토콜 타입만 안다"는 표현은 some View가 아니라 any View(existential)를 설명하는 말이다.
some View는 구체 타입이 하나로 고정되어 있고 컴파일러는 그게 뭔지 정확히 안다. 단지 프로그래머(호출부 소스코드)가 그 이름을 못 볼 뿐. "타입을 지우는" 게 아니라 "이름만 가리는" 것이다.
정확히 고친 문장: opaque 타입은 구현부(함수 몸통)가 구체 타입을 하나로 확정하고, 컴파일러는 그걸 알지만 호출부 코드에서는 그 타입 이름을 숨긴다. 호출부는 "이건 View다"까지만 쓸 수 있다.
| some View (opaque) | any View (existential) | |
|---|---|---|
| 구체 타입 | 하나로 고정 (컴파일 타임) | 여러 개 가능, 런타임에 바뀜 |
| 컴파일러가 아는가 | 안다 | 모른다 (박스로 감쌈) |
| 타입 정체성(identity) | 보존됨 | 사라짐 (type-erased) |
| 디스패치/성능 | 정적 디스패치, 오버헤드 X | 동적 디스패치 + 박싱 오버헤드 |
func f<T>(_ x: T) → 호출부가 T를 결정한다.func f() -> some View → 구현부가 타입을 결정하고 호출부는 모른다. 방향이 정반대.// ✅ OK — return 경로가 하나의 구체 타입(Text)만 반환
func makeView() -> some View { Text("hi") }
// ❌ 컴파일 에러 — some은 "하나의 타입"이어야 하는데 둘이 다름
func makeView(flag: Bool) -> some View {
if flag { Text("hi") } // Text
else { Image("x") } // Image → 타입이 갈림!
}
// ✅ any면 됨 — 실존 타입은 서로 다른 타입을 담을 수 있으니까
func makeView(flag: Bool) -> any View {
if flag { Text("hi") } else { Image("x") }
}
any가 아니라 some을 쓰는 진짜 이유body의 실제 타입은 VStack<TupleView<(Text, Button<Text>)>> 처럼 끔찍하다.
some View로 숨긴다 (편의성).any View면 identity가 사라져 diffing이 깨지고 매 프레임 박싱 비용도 든다. opaque는 "컴파일러는 알되 코드만 숨김"이라 이 요구를 완벽히 만족한다.한 줄 요약: opaque는 타입을 "지우는" 게 아니라 "이름만 가리는" 것. 컴파일러는 여전히 하나의 구체 타입을 안다 — 그래서 성능과 identity가 살아있다. "프로토콜 타입만 알게 된다"는 any View 쪽 설명이다.
맞다. 제네릭은 입력을 추상화(호출부가 T를 채움), opaque는 출력을 추상화(구현부가 타입을 채움). 그래서 opaque = 역방향 제네릭(reverse generic).
| 타입을 결정/아는 쪽 | 모르는 쪽 | |
|---|---|---|
제네릭 f<T>(_:T) | 호출부 (f(3)→T=Int) | 구현부 (제약된 것만 사용 가능) |
opaque f()->some P | 구현부 (return Text()) | 호출부 (이름 모름) |
"컴파일러가 두 값이 정확히 같은 구체 타입임을 보증하는가." some View는 하나의 구체 타입으로 고정되므로, 같은 함수가 반환한 두 값이 "같은 타입"임을 컴파일러가 안다(배열에 함께 담기, == 비교 등 성립). any View는 Text일 수도 Image일 수도 있어 이 보증이 사라짐(type-erased).
구체 타입 그 자체 전부 — Text인지 VStack<...>인지, 그 타입의 메모리 크기·레이아웃, 어느 메서드의 어느 구현을 호출할지까지. 그래서 정적 디스패치 · 인라이닝 · 스택 할당 · 스페셜라이즈가 가능. any View면 "View를 따른다"만 알아 매번 witness table 경유 → 동적 디스패치.
SwiftUI는 View 값(struct) 트리를 들고 있다가, 상태가 바뀌면 body를 다시 실행해 새 트리를 만들고 이전 트리와 diff한다. 각 노드의 구체 타입 + 트리 위치가 그 뷰의 정체성(structural identity):
@State 유지@State 리셋이 판단에 구체 타입이 필요한데 some View라 타입이 정적으로 고정돼 가능. 전부 AnyView로 감싸면 노드 타입이 AnyView로 뭉개져 정체성·애니메이션·상태 보존 최적화가 어려워진다(= AnyView 남발이 성능/버그 원인).
맞다. any View/AnyView를 만들 때 existential container(박스)를 생성한다. 64-bit 레이아웃:
┌─────────────────────────────┐
│ inline value buffer 3 words │ = 24 byte
│ 값 ≤24byte → 여기 직접 저장 │
│ 값 >24byte → 힙에 얹고 포인터만 │
├─────────────────────────────┤
│ metadata pointer 1 word │ → VWT(Value Witness Table) 도달
│ │ 값의 copy/move/destroy = 메모리관리
├─────────────────────────────┤
│ PWT pointer(s) 프로토콜당 1개 │ → Protocol Witness Table
│ │ 프로토콜 메서드 → 구체 구현, 동적 디스패치
└─────────────────────────────┘
VStack<TupleView<...>>)은 보통 24byte 초과 → 박싱 시 힙 할당까지 발생any View 비용 = ① 박스 생성(크면 힙 할당) + ② witness table 간접 디스패치 + ③ diffing 최적화 방해. some View는 셋 다 없음.
아니다. 프로토콜 준수 ≠ 같은 타입. Text와 Image는 둘 다 View를 따르지만 완전히 다른 타입. "같은 타입"은 구체 타입이 물리적으로 동일(Text==Text)한 것. 프로토콜 준수는 "같은 계약을 만족"할 뿐.
비유: 사람과 개가 둘 다 동물을 따라도 사람≠개. any Animal은 "동물이다"까지만 보장. → 그래서 any View는 type identity를 잃고(=구체 타입 보장 못 함), some View는 "정확히 그 하나의 타입"을 보장한다.
맞다. 구체 타입을 아니까 정적 디스패치(주소 컴파일 타임 확정, PWT 조회 없음) + 인라이닝까지 가능.
| 디스패치 | 조회 방식 |
|---|---|
| 정적 (some·구체타입·final·값타입 메서드) | 컴파일 타임 주소 확정, 조회 없음 |
| 동적 — 클래스 | vtable 조회 |
| 동적 — 프로토콜 존재타입(any) | witness table(PWT) 조회 |
두 층으로 나눠야 정확하다:
상태 변경
→ 그 상태를 읽는 뷰들의 body만 재실행 (dependency 추적)
→ 그 서브트리 자식 struct는 전부 새로 생성 (값이라 쌈)
→ 이전 struct 트리와 diff
→ 실제로 바뀐 백킹 객체만 갱신 (렌더 통째 재생성 X)
아니다. 같은 뷰(같은 identity)다. 정체성은 타입 + 위치로 정해지지 속성값으로 정해지지 않는다.
Text("A") → Text("B")
// 같은 위치, 같은 타입(Text) → "같은 Text 뷰"
// 내용만 A→B로 diff해서 in-place 업데이트
// 이 뷰 안의 @State는 그대로 유지됨 ✅
이게 의도된 동작: TextField 텍스트가 바뀌어도 "같은 뷰"라 커서·포커스 유지, 애니메이션도 이어짐. 내부 값 바뀔 때마다 "다른 뷰"로 취급하면 매번 파괴·재생성돼 상태가 다 날아감.
.id(value). id가 바뀌면 타입이 같아도 "다른 뷰"로 취급 → 파괴+재생성+상태 리셋 (explicit identity).
AnyView로 감싸면 정적 타입이 전부 AnyView 하나로 뭉개지기 때문. SwiftUI의 structural identity는 노드의 정적(컴파일 타임) 타입으로 판단하는데:
// some View 세계 — 정적 타입에 구조가 다 드러남
VStack<TupleView<(Text, Image)>> // "여기 Text, 여기 Image" 다 앎
// AnyView 세계 — 전부 AnyView로 붕괴
AnyView(Text(...)) // 정적 타입: AnyView
AnyView(Image(...)) // 정적 타입: AnyView ← 정적으로는 구별 불가
AnyView(Text)→AnyView(Image)로 바뀌어도 정적 타입은 둘 다 AnyView라, 컴파일 타임 타입만으론 "내용물 타입이 바뀌었다"를 알 수 없다. 알려면 런타임에 박스를 열어 dynamic type 비교 필요.
@State 깨지기 쉬움한 줄: some은 타입에 트리 구조가 통째로 새겨져 SwiftUI가 컴파일 타임에 정체성 판단 / AnyView는 그 구조를 AnyView 하나로 지워 정적으로 볼 게 없어짐.
@State 값이 바뀌어도 identity는 그대로다(그래서 상태가 살아남는다). identity가 바뀌는 건 오직
① 위치 이동(if/else 분기 갈아탐·다른 컨테이너) ② .id() 변경 ③ ForEach 요소 id 변경일 때뿐.
훅킹(hooking) = 어떤 동작이 일어나는 지점에 끼어들어 추가 코드를 함께 실행하는 것. count를 읽으면 실제로는 _count(=State<Int>)의 wrappedValue getter가 호출되는데, SwiftUI가 만든 그 getter 안에는 값 반환 외에 "지금 body 평가 중인 이 뷰가 나(count)에 의존한다"고 그래프에 등록하는 코드가 얹혀 있다. 즉 접근이 곧 함수 호출이라(property wrapper라서) 그 함수에 의존성 기록 로직을 걸어둔 것. 메서드 스위즐링이 아니라 래퍼 getter에 로직이 있는 것.
완전히 다르다.
.id()(explicit)로 결정.if isOn { CounterView() } // if-분기 슬롯 → identity A
else { CounterView() } // else-분기 슬롯 → identity B
// 타입은 같은 CounterView, identity는 A/B로 다름
// → 토글하면 "다른 뷰"로 취급돼 @State 리셋
여기서 "위치"는 화면 좌표가 아니라 소스 코드의 구조적 자리(어느 분기냐). 런타임에 화면엔 하나만 보여도 SwiftUI는 두 분기를 별개의 identity 슬롯 2개로 취급한다. 그래서 결과 화면이 같아도 identity가 다르다.
소유(owns) vs 참조(references)를 구분해야 한다.
| 구분 | 대상 | identity 노드와의 관계 |
|---|---|---|
| 원본 소유 | @State, @StateObject, @GestureState | 노드가 직접 저장(소유) — source of truth |
| 참조만 | @Binding | 남의 @State를 가리키는 포인터(자기 저장소 없음) |
| 참조만 | @ObservedObject | 관찰만, 생명주기 소유 X(부모가 다시 만들면 갈아탐) |
| 참조만 | @Environment | 환경에서 읽기만 |
그래서 노드가 사라져도 원본이 상위에 있으면 참조 대상은 안 죽는다(Q9의 핵심).
SwiftUI는 각 뷰 슬롯에 보이지 않는 식별 토큰을 붙인다. 업데이트 때 그 자리의 토큰이 그대로면 → 같은 뷰 → 노드·@State 유지, 내용만 in-place 갱신. 토큰이 달라지면(분기 갈아탐/.id()/ForEach id 변경) → "여긴 이제 다른 뷰다" → 옛 노드 파괴(@State 폐기) → 새 노드 생성(초기값부터). 값 변화로는 안 일어나고 자리/식별자 변화로만 일어난다.
@State var count: Int이면 _count의 타입이 State<Int>이고, 이 State<Int> 자체가 property wrapper(래퍼)다. 이 래퍼가 Int 값과 백킹 스토어 핸들을 감싸 관리한다. @State는 _count = State(wrappedValue: 0)을 만들어주는 문법 설탕. (백킹 스토어는 하나 — 이전 답의 "2번(항목 번호)의 백킹 스토어"를 "두 번"으로 오해했던 부분 교정)
아니다. 일반 저장 프로퍼티는 그냥 struct 값의 일부라 struct와 함께 매번 재생성·폐기된다. 그래프 스토리지에 지속 저장되는 건 @State·@StateObject 같은 상태 래퍼뿐. 일반 프로퍼티 값은 부모가 매번 넘겨주는 입력(스스로 기억 안 함). 단 SwiftUI는 직전 struct 값을 diff 비교용으로만 잠깐 들고 있다(상태 보존이 아니라 비교 스냅샷).
재호출된다. body 재호출 기준은 "입력(저장 프로퍼티) 값이 직전과 달라졌나"지 "@State냐 일반이냐"가 아니다. k가 이전과 다르면 diff에서 "달라짐" → body 재실행. 같으면 스킵. 차이는 변화를 누가 일으키냐뿐 — 일반 프로퍼티는 부모가 새 값을 넘겨야 바뀌고(자기 스스로 못 바꿈), @State는 자기 안에서 바뀔 수 있다.
전부 새로 생긴다. struct 재생성(항상) + 슬롯 타입이 ProgressView→Text로 바뀜 → 다른 identity → 옛 ProgressView 노드 파괴 + 하위 노드도 같이 폐기 → 새 Text 노드와 하위 노드가 fresh identity로 생성. 서브트리 통째 교체라 상태·애니메이션이 끊긴다("무거운" 변화).
struct Parent: View {
@State private var count = 0 // Parent 노드가 소유
var body: some View {
if isOn { ChildView(count: $count) } // 자식은 @Binding(참조)만
else { OtherView(count: $count) }
}
}
"ChildView와 OtherView의 identity 노드가 같다"는 틀리다. 둘은 다른 타입 + 다른 분기라 서로 다른 identity 노드이고, 토글마다 파괴·생성된다. 그런데도 count가 안 날아가는 이유는:
count는 Parent의 @State → Parent 노드가 소유.isOn이 바뀌어도 Parent 자신의 위치·identity는 그대로 → Parent 노드 유지 → count 저장소 유지.@Binding으로 Parent의 count를 가리키기만 함 → 자식 노드가 파괴·생성돼도 원본은 Parent에 있어 count는 멀쩡.한 줄: count 보존은 자식이 노드를 공유해서가 아니라, 원본을 안정적인 상위(Parent) 노드가 들고 있어서다. 자식이 각자 @State를 가졌다면 그건 토글마다 리셋된다. 이게 "상태 끌어올리기"의 원리 — 상태 소유권을 identity가 튼튼한 상위 뷰로 이동.
private func → 정적 디스패치 맞다. 서브클래스에서 안 보여 오버라이드 불가 → 컴파일러가 대상 확정(final과 같은 이유). 그냥 func(오버라이드 가능)는 vtable, @objc dynamic은 objc_msgSend.private let(저장 프로퍼티) → 디스패치 대상이 아니다. 객체 시작 주소 + 고정 오프셋으로 직접 접근(vtable/msgSend 안 거침). 동적 디스패치가 생기는 건 class의 computed property나 오버라이드 가능한 접근자.안정적·고유한 id를 쓰면 유지된다. 항목이 자리를 옮겨도 SwiftUI가 id로 매칭해 같은 노드를 새 위치로 이동 → @State·스크롤·애니메이션이 항목을 따라간다. 반대로 인덱스를 id로 쓰면 자리를 바꿨을 때 상태가 "자리"에 눌러앉아 엉뚱한 행에 붙는 버그. → 실무: 서버 UUID 같은 고유·안정 id를 써라.
SwiftUI 애니메이션은 "같은 identity 뷰가 값 A→B로 변했다"를 보고 A↔B를 부드럽게 보간한다. identity가 바뀌면(노드 파괴·재생성) 보간할 '이전 값'이 없어져 진행 중이던 애니메이션이 뚝 끊기고 새 뷰가 초기 상태에서 튀어나온다. = identity 변경으로 보간 기준점이 사라져 애니메이션이 이어지지 못하는 것.
Text("hi") // Text
Text("hi").padding() // ModifiedContent<Text, _PaddingLayout>
Text("hi").padding().opacity(0.5)
// ModifiedContent<ModifiedContent<Text, _PaddingLayout>, _OpacityEffect>
모디파이어마다 ModifiedContent<...> 래퍼 타입으로 감싼다. 하지만 SwiftUI는 이걸 같은 뷰의 "치장"으로 봐 identity에 영향 없음(패딩 넣었다고 @State 리셋 X). body 타입이 이렇게 길어지니 some View로 가리는 것.
맞다. VStack은 노드 하나이면서, 아래 자식이 매달리므로 서브트리의 뿌리. 노드는 트리의 한 점이고, 자식을 가지면 그 점이 서브트리의 root가 된다. "전체 트리 안에 VStack 노드가 있고 그 아래로 서브트리가 뻗는다"가 맞음.
마찬가지다. 단 트리거(출발점)가 다르다. @State는 트리거가 그 뷰 자신. 일반 프로퍼티는 스스로 못 바뀌고, 값이 달라지는 유일한 경로는 상위 @State가 바뀌어 상위 body가 다시 돌며 새 값을 주입하는 것. 즉 일반 프로퍼티 변화는 항상 상위 상태 변화의 하류라, 재생성 범위도 그 상위 뷰부터 아래로. 전체 앱 트리는 절대 아님.
| 변화 대상 | 재호출 기준 | "사용할 때만"? |
|---|---|---|
| @State(의존성 추적) | 지난 body에서 읽은(read) 뷰만 무효화 | ✅ 맞음 |
| 부모 주입 일반 프로퍼티 | 새 struct vs 옛 struct 필드 비교 → 값 다르면 재호출 | ❌ 사용 안 해도 재호출 |
즉 "@State는 사용할 때만"은 맞고, "일반 변수도 사용할 때만"은 부정확. 부모 주입 프로퍼티는 값이 다르면 body에서 안 써도 재호출(.equatable()로만 최적화).
$count. wrapper는 wrappedValue(→count, Int)와 projectedValue(→$count, @State는 Binding<Int>)를 노출. $count = _count.projectedValue.body를 실제로 실행하는 순간. 그 window 동안의 상태 읽기(getter)가 의존성으로 기록됨._count 밑줄: @State var count가 자동 생성하는 3개 중 _count=래퍼 본체(State<Int>, 실제 저장), count=계산(=_count.wrappedValue), $count=_count.projectedValue. 밑줄은 "내부 저장용" 관례.= 0은 예시. 초기값 타입이 제네릭 결정: = 0→State<Int>, = ""→State<String>, = false→State<Bool>.View는 불변 값 타입. @State/@Binding 없는 일반 프로퍼티는 init에서 정해지면 런타임에 못 바꾼다.
struct MyView: View {
var k = 0
var body: some View {
Button("tap") { k += 1 } // ❌ 컴파일 에러: self is immutable
}
}
"일반 프로퍼티 값 변경" 자체가 스스로는 불가능. 값이 달라지는 유일한 길은 부모의 재주입. 바꾸려면 @State여야 하고, 그러면 dirty→재호출로 이어진다.
핵심: 상태는 "선언한 뷰의 노드"에 저장된다. 누가 소유하느냐가 전부.
// 경우 A — 자식이 소유 → 토글 시 자식 노드째 삭제 → 증발
struct ChildView: View { @State private var count = 0 }
if isOn { ChildView() } else { OtherView() }
// 경우 B — 부모가 소유, 자식은 @Binding 참조
struct Parent: View {
@State private var count = 0 // count는 Parent 노드에 저장 ★
var body: some View {
if isOn { ChildView(count: $count) } // 자식은 포인터만
else { OtherView(count: $count) }
}
}
경우 B에서도 ChildView 노드는 토글 시 파괴된다(맞음). 하지만 count는 ChildView가 아니라 Parent 노드에 산다. 그리고 Parent는 if/else를 담는 그릇이지 그 안에 있는 게 아니라, 토글에도 Parent identity는 그대로 → count 생존 → 새 OtherView가 같은 Parent count에 다시 바인딩 → 값 유지.
경우 A: [ChildView 노드 { count=5 }] ← 토글 시 노드째 삭제 → 증발
경우 B: [Parent 노드 { count=5 }] ← 토글에도 생존 → 유지
└ [ChildView 노드 { $count →↑ }] ← 이 노드만 삭제(포인터만 사라짐)
state lifting = 상태를 "잘 죽는 자식"에서 꺼내 "안 죽는 부모"로 이사시키는 것. 자식이 죽어도 상태가 안 죽는 이유는 상태가 더 이상 자식 안에 없기 때문.
State<Int>는 Int를 직접 담지 않고 SwiftUI 관리 저장소의 "박스"를 가리키는 핸들만 든 작은 struct. View struct가 매 업데이트 재생성돼 _count도 새 struct가 되지만, 박스 참조를 채우는 건 초기화자(=0)가 아니라 SwiftUI — body 실행 직전에 identity 노드의 기존 박스를 새 State에 다시 연결(re-inject). =0은 박스 최초 생성 때만 쓰이고 이후 무시. (리모컨은 매번 새로 쥐어줘도 TV(박스)는 노드에 그대로 → 채널 유지)
안정적 id를 쓰면 각 행의 @State(펼침/토글 등)가 화면 자리가 아니라 항목(id)에 붙어, 목록을 정렬해 항목이 이동해도 그 상태가 항목을 쫓아간다. 인덱스 id면 상태가 "자리"에 눌러앉아 엉뚱한 항목이 펼쳐진 채 남는다.
SwiftUI가 struct를 초당 수십·수백 번 재생성해도 앱이 도는 건 @State가 그 재생성을 건너 살아남기 때문. 깨지면 즉시 버그: TextField 입력·커서 증발 / 스크롤 리셋 / 토글 원위치 / @StateObject 재생성→네트워크 재호출·데이터 소실 / 애니메이션 끊김. 원인은 거의 항상 identity 불안정(ForEach id·if/else 안 뷰·AnyView·.id() 오용) → SwiftUI 디버깅의 핵심.
그렇다. ModifiedContent<Content, Modifier>는 View 준수. Text().padding()도 여전히 View라 모디파이어 체이닝이 "View 세계"에 머문다.
struct 안이 아니라 SwiftUI 내부 의존성 그래프(attribute graph). 각 @State 박스에 "나를 읽은 뷰들의 명단"이 딸리고, body 평가 중 읽으면 "이 뷰→이 값" 간선이 그려진다. 값이 바뀌면 그 명단을 무효화→body 재평가 예약. 뷰 struct는 명단을 안 가짐(그래프가 가짐).
body 평가 중 getter가 호출되는 순간 등록된다. identity 생성 때(X)도, 쓰기 때(X — 쓰기는 무효화 트리거)도 아니다. 그리고 등록은 매 body 평가마다 지웠다 다시 그린다 → "이번 body에서 실제로 읽은 것"만 정확히 구독.
[body 평가] count 읽음(getter) → "이 뷰→count" 간선 등록
count에 write(setter) → 등록된 뷰들 무효화 → body 재평가
→ 재평가하며 다시 getter → 간선 다시 등록(매번 갱신)
getter=명단 작성(매 body 갱신), setter=명단의 뷰들 재실행 트리거.
1. 부모 body 실행 → 자식 struct를 새 값(주입 프로퍼티 포함)으로 생성
2. 비교: 부모가 준 필드가 이전과 다른가? / 자식 자신 @State가 dirty인가?
3. 다르면 → 자식 body 호출 → 손자 struct 생성 → 재귀
같으면 → 자식 body 스킵
즉 ① struct 생성 → ② 필드/상태 diff → ③ 달라졌으면 body 호출. (자신 @State 변화는 부모 없이도 직접 dirty로 이 흐름에 합류)
@State의 projectedValue(=$count)는 그래프 스토리지의 같은 박스를 가리키는 Binding<Int>. 자식에게 $count를 넘기면 자식이 그 박스를 직접 읽고 쓴다.
SwiftUI가 추적하는 상태는 property wrapper(@State·@Binding·@StateObject·@ObservedObject·@Environment)뿐. 일반 프로퍼티는 "부모가 주는 불변 입력". 변경 경로:
let vm = MyClass()의 vm 내부는 바꿀 수 있으나 SwiftUI가 그 변화를 모름 → UI 자동 갱신 X. → @StateObject/@Observable을 써야 하는 이유.한 줄: 일반 프로퍼티 = SwiftUI에겐 "읽기 전용 입력". 변경으로 UI를 움직이려면 반드시 상태 래퍼를 거쳐야 한다.
// ❌ 인덱스를 id로 — 위험
ForEach(0..<items.count, id: \.self) { i in RowView(items[i]) }
ForEach(Array(items.enumerated()), id: \.offset) { ... }
// ❌ 값 자체를 id로 — 값 바뀌거나 중복이면 위험
ForEach(items, id: \.self) { ... } // items가 String 등 값타입일 때
// ✅ 안정적 고유 id
struct Item: Identifiable { let id = UUID() }
ForEach(items) { item in RowView(item) } // 자동 id: \.id
항목을 삽입/삭제/정렬하면 같은 항목이 다른 인덱스를, 다른 항목이 같은 인덱스를 갖게 된다. SwiftUI는 id로 매칭하니 "id=1 자리"의 @State가 새로 그 자리에 온 다른 항목에 달라붙는다(펼침·입력값이 엉뚱한 행에). "ForEach id 잘못" = 불안정한 id(인덱스·바뀌는 값·중복) 지정 → @State 리셋 버그의 흔한 원인. 해결: Identifiable + 안정적 UUID.
구체 예시 — 각 행이 "펼침"을 자기 @State로 가짐. B를 펼친 상태(id=1)에서 맨 위 A를 삭제하면:
초기: id=0→A(접힘) id=1→B(펼침)★ id=2→C(접힘)
A 삭제 후 (인덱스 id): B는 index0→id=0, C는 index1→id=1
SwiftUI가 id로 매칭 → @State는 "자리"에 그대로 남음:
id=0: A→B 로 바뀜, 그 자리 @State=접힘 유지 → B가 접혀버림(펼침 소실!)
id=1: B→C 로 바뀜, 그 자리 @State=펼침 유지 → C가 펼쳐짐(B 상태 뺏음!)
= @State가 항목(B)이 아니라 자리(id=1)에 눌러앉아 항목 이동 시 엉뚱한 곳에 붙음. UUID id면 B의 id가 위치 무관 고정 → B UUID↔B UUID 매칭 → 펼침이 B를 따라 이동(정상). 인덱스 id="상태가 자리에", UUID id="상태가 항목에".
부모 body 실행 → 자식 struct를 새로 construct(주입값·바인딩 포함)
→ 새 자식 struct vs 옛 자식 struct의 "저장 프로퍼티(입력)" 비교
- 부모 주입 일반 프로퍼티 / @Binding·@ObservedObject 참조 등
→ 다르면 자식 body 재호출, 같으면 스킵
단 하나 구분: 자신의 @State 변화는 이 "비교"로 감지하는 게 아니다. @State는 "입력"이 아니라 "소유한 상태"라 비교 대상이 아니고, 바뀌면 비교 없이 곧장 그 뷰를 dirty로 표시(직접 무효화 경로). → 부모 주입/바인딩=struct 비교로 판단, 자신 @State=직접 무효화.
엄밀히는 아니다. 기존 struct를 유지한 채 body만 재실행한다. "struct 재construct"는 부모 body가 그 자식을 다시 만들 때 일어난다. 자기 @State만 바뀌면 부모는 안 도니 자기 struct는 새로 안 만들어지고, SwiftUI가 보관 중인 기존 뷰 값으로 body만 재실행(그 body가 자식 struct들은 새로 생성). 일반 저장 프로퍼티는 스스로 못 바뀌므로(불변 값타입) 이 경로에 애초에 안 낀다.
wrappedValue getter) 뷰뿐. 바인딩을 넘기는 것($count)은 projectedValue(포인터) 접근이지 "읽기"가 아니다 — 의존성 추적은 wrappedValue getter에만 걸린다.
struct Parent: View {
@State private var count = 0
var body: some View { ChildView(count: $count) } // $count 전달만, count 값 안 읽음
}
struct ChildView: View {
@Binding var count: Int
var body: some View { Text("hi") } // count 안 씀
}
count가 바뀌면: 부모—count 값 안 읽음→의존자 아님→body 안 돎→자식 재construct 안 함. 자식—count 안 씀→의존자 아님→body 안 돎. 결과: 아무도 body 재호출 안 하고 자식 struct 재생성도 없음.
"identity 노드의 그래프 스토리지 업데이트되지?" → 그건 된다. count의 박스(Parent 노드 저장소)에 새 값이 써지는 것 자체가 "count 변화"의 정의. 다만 그 박스를 읽는 뷰가 없어 화면 갱신은 없음. 자식은 count 저장소를 따로 안 가짐(바인딩=포인터)이라 Parent 박스 하나만 갱신(이중 업데이트 없음).
| 상황 | 자식 struct 재생성 | 자식 body 재호출 |
|---|---|---|
| 자식이 count 씀 + count 변함 | 부모 돌면 예 | 예(의존자) |
| 자식이 count 안 씀 + count 변함 | 아니오 | 아니오 |
| 부모가 다른 이유로 body 돎 + 입력 같음 | 예(재construct) | 아니오(diff 같음 스킵*) |
| 부모 body 돎 + 입력 다름 | 예 | 예 |
* 자식이 @Binding만 든 경우 바인딩 동등성 비교가 까다로워 SwiftUI가 보수적으로 body를 돌리기도 함(.equatable()로 통제).
한 줄: 부모 @State가 변한다고 부모가 도는 게 아니라 그 값을 실제로 읽은 뷰만 돈다. $count를 넘기기만 하고 아무도 count를 읽지 않으면 박스 값만 갱신되고 화면은 그대로.
struct Parent: View {
@State private var name = "Kim"
var body: some View { ChildView(name: name) } // 부모가 name을 "읽어서" 주입
}
struct ChildView: View {
let name: String
var body: some View { Text("Hello \(name)") }
}
name이 "Kim"→"Lee"일 때:
1. name 박스 값 변경("Kim"→"Lee")
2. name을 "읽은" 뷰 무효화 → Parent가 ChildView(name: name)에서 name 읽음 → Parent 무효화
3. Parent body 재실행 → 새 ChildView(name:"Lee") construct ← 자식 재construct (예)
4. 입력 비교: 새 name "Lee" vs 옛 "Kim" → 다름
5. → ChildView body 재호출 → Text("Hello Lee") ← body 재호출 (예)
6. 렌더 층이 "Hello Kim"→"Hello Lee" 화면 갱신
세 번째 줄(입력 같음)과 대조: 부모가 다른 이유로 body 돌아도 자식에 주는 값이 그대로면 → 3번에서 재construct(예)하지만 4번 비교가 "같음" → 5번 body 스킵(아니오). struct 재생성은 둘 다 일어나지만(싸다), body 재호출은 입력이 달라졌을 때만. 마지막 줄=화면 갱신 정상 케이스, 세 번째 줄=값 안 바뀌어 body 건너뛰는 최적화 케이스.
| 대상 타입 | 생명주기 소유? | 언제 | 세대 | |
|---|---|---|---|---|
@State | 값 타입 + @Observable 객체 | ✅ 소유(1번 생성·유지) | 뷰 로컬 상태 | 구·신 공통 |
@StateObject | 참조(ObservableObject) | ✅ 소유 | 뷰가 직접 만드는 뷰모델 | 구형 |
@ObservedObject | 참조(ObservableObject) | ❌ 관찰만 | 밖에서 받은 객체 | 구형 |
@Observable | class 매크로 | (소유는 @State가) | 뷰모델 정의 | 신형(iOS 17+) |
Int·String·Bool·struct·enum 등 값 타입. SwiftUI 관리 박스에 소유 → struct 재생성돼도 유지. 뷰 내부 로컬 상태(토글·입력·펼침)에 적합.
ObservableObject class. 이 뷰가 만들고 소유 → 첫 등장 때 1번 생성, struct가 몇 번 재생성돼도 같은 인스턴스 유지.
class CartViewModel: ObservableObject { @Published var items: [Item] = [] }
struct CartView: View {
@StateObject private var vm = CartViewModel() // 소유·1번 생성
}
역시 ObservableObject지만 생명주기 소유 안 함. 밖에서 만들어져 넘어온 객체를 받아 변화만 구독.
// ❌ 위험: 뷰가 만드는 객체를 @ObservedObject로
struct CartView: View {
@ObservedObject var vm = CartViewModel() // struct 재생성마다 vm 새로 생성!
}
뷰 struct는 계속 재생성되는데 @ObservedObject는 소유를 안 하니 재생성마다 CartViewModel()이 새로 만들어져 장바구니 초기화·네트워크 재요청·상태 소실. @StateObject는 첫 생성 1번만 하고 identity 노드에 소유·보관.
// 구형: class VM: ObservableObject { @Published var items = [] }
// 신형:
@Observable class CartViewModel {
var items: [Item] = [] // @Published 불필요 — 저장 프로퍼티 자동 추적
}
struct CartView: View {
@State private var vm = CartViewModel() // 소유: @State ★
var body: some View { CartRow(vm: vm) }
}
struct CartRow: View {
let vm: CartViewModel // 관찰: 그냥 let ★ (읽으면 자동 추적)
var body: some View { Text("\(vm.items.count)") }
}
struct EditView: View {
@Bindable var vm: CartViewModel // 양방향 바인딩
var body: some View { TextField("제목", text: $vm.title) }
}
| 상황 | 구형(iOS 16-) | 신형(@Observable) |
|---|---|---|
| 값 타입 로컬 상태 | @State | @State |
| 뷰모델을 내가 생성·소유 | @StateObject | @State |
| 뷰모델을 받아 관찰 | @ObservedObject | 그냥 let/var |
| 받은 객체에 양방향 바인딩 | @ObservedObject + $vm.x | @Bindable + $vm.x |
| 클래스 정의 | ObservableObject + @Published | @Observable |
핵심 3줄: ① @State=값 소유 / @StateObject=참조 소유(1번 생성) / @ObservedObject=참조 관찰만. ② 최대 함정: 뷰가 만드는 객체를 @ObservedObject로 두면 재생성마다 초기화 → 만드는 뷰는 @StateObject. ③ @Observable(신형): @Published·ObservableObject 없이 자동+정밀 추적, 소유는 @State·관찰은 let·바인딩은 @Bindable.
둘 다 맞다. identity 노드에 묶인 저장 공간이자, 힙에 할당된다(SwiftUI 런타임 관리). 뷰 struct(스택/인라인일 수 있음)와 별개로 박스는 힙에 살아 struct 재생성을 견딘다. "박스 = identity 노드에 매달린 힙 저장소" — 같은 대상.
맞다. @State·@StateObject 둘 다 identity(위치/id)가 안 바뀌면 유지되고, 지속 메커니즘은 동일(identity 노드 박스). 초기값이 1번만 쓰이는 것도 같다.
| 저장하는 것 | 추가로 하는 일 | |
|---|---|---|
@State | 값 또는 참조 | (구형 class 관찰은 못 함) |
@StateObject | class 인스턴스(참조) | objectWillChange 구독 + 생명주기 소유 |
핵심 차이 = "값 vs 참조" + "@StateObject는 objectWillChange를 구독". 구형에서 class에 @State 대신 @StateObject가 필요했던 건 @State가 @Published 변화를 구독 못 해서(신형 @Observable은 프레임워크가 구독 대행 → @State로도 됨).
@ObservedObject는 그 객체를 언제 만들고 없앨지 책임지지 않는다. @StateObject="내가 1번 만들고 내 identity 사는 동안 살려둠"(소유). @ObservedObject="넘어온 객체를 참조·구독만, 생성·유지는 딴 데(보통 부모 @StateObject) 책임" → "create once, persist" 보장 없음. 그래서 @ObservedObject로 직접 만들면 재생성마다 새로 생김.
받는 도구가 아니다. @Binding=값 타입 양방향 바인딩. ObservableObject 받기=@ObservedObject. @Observable 받기=그냥 let(관찰) 또는 @Bindable(양방향). @Binding var vm: SomeClass는 컴파일은 되나 참조(포인터)를 바꿔 끼우는 용도라 내부 @Published 변화를 구독 못 함 → 갱신 안 됨.
맞다. @StateObject=객체를 identity 노드 박스에 소유·보관(유지). @ObservedObject=박스에 소유 안 함, 객체는 밖(부모 @StateObject 등)에 살고 참조+구독만. 지속은 소유자(@StateObject) 몫. 그래서 실무: 만드는 곳=@StateObject, 받는 곳=@ObservedObject.
objectWillChange 발화 → 구독 뷰 무조건 재평가. 거칠다(coarse).객체 단위 구독(구형, 안 써도 갱신) vs 프로퍼티 단위 구독(신형, 읽은 것만). 이게 @Observable 최대 장점.
(a) 소유 — @State private var vm = VM(): @Observable 객체는 @StateObject가 아니라 @State로 소유. @State가 1번 생성 + 박스 보관(유지)을 담당(@StateObject의 소유·지속 역할). 왜 이제 @State로 되나? 구형엔 @State가 @Published 구독을 못 했지만, @Observable은 프로퍼티 읽는 시점에 프레임워크가 자동 관찰하므로 @State는 참조 보관만 하면 됨.
struct CartRow: View {
let vm: CartViewModel // (b) 관찰 — 래퍼 없음
var body: some View {
Text("\(vm.items.count)") // 이 "읽기"를 프레임워크가 감지 → items 바뀌면 이 뷰만 갱신
}
}
(b) 관찰(받기) — 그냥 let vm: VM: @ObservedObject 없이 저장 프로퍼티로 받고, body에서 프로퍼티를 읽는 순간 "이 뷰는 그 프로퍼티에 의존" 등록(§13 getter 훅킹과 동일). 읽기가 곧 구독. → @Observable에선 래퍼(@StateObject/@ObservedObject)가 사라지고 "소유=@State, 관찰=그냥 프로퍼티".
있다(iOS 17 Observation). @Observable 객체의 프로퍼티에 $로 양방향 바인딩할 때 쓴다. 그냥 let vm은 읽기(관찰)만; TextField·Toggle처럼 $가 필요하면 @Bindable.
struct EditView: View {
@Bindable var vm: CartViewModel
var body: some View { TextField("제목", text: $vm.title) }
}
@Binding과 @ObservedObject를 섞어 기억하기 쉽다. 무엇을 받았느냐에 따라 다르다:
| 예전에 받은 것 | 예전 방식 | iOS 17 @Observable |
|---|---|---|
| 값(String/Int) 양방향 | @Binding var text | 그대로 @Binding(값 바인딩 불변) |
| ObservableObject 관찰 | @ObservedObject var vm | 그냥 let vm(읽기 관찰) |
| 객체 프로퍼티에 $ 바인딩 | @ObservedObject + $vm.x | @Bindable var vm + $vm.x |
즉 "그냥 let/var면 돼?"의 답: 객체(ObservableObject→@Observable)를 받는 경우면 예. 하지만 값 바인딩이었으면 여전히 @Binding, 객체 프로퍼티에 $가 필요하면 @Bindable.
맞다. @State + @Observable → ✅(Observation이 추적). @State + 구형 ObservableObject → ❌ @Published 구독 못 함(@State는 참조만 들고 objectWillChange 안 들음) → 이땐 @StateObject 필요.
같은 말. SwiftUI가 그 뷰의 body(계산 프로퍼티)를 다시 실행하는 것.
없다 — 컴파일 에러. @StateObject는 ObservableObject 채택 타입만 받는데, @Observable class는 ObservableObject를 채택하지 않는다(신형 Observation 프로토콜). @Observable 소유 = @State가 유일. @StateObject는 구형 ObservableObject 전용.
사실: Observation은 iOS 17(WWDC 2023) 도입(@Observable·@Bindable, SwiftData도). 단 "그래서 17을 골랐다"는 원인-결과를 뒤집지 마라. 배포 타깃은 보통 사용자 기기 분포·필요 API로 정한다. 정직한 프레이밍:
아니다. 셋만 래퍼.
| 이름 | 정체 | 붙는 곳 |
|---|---|---|
@Observable | 매크로 | class 정의 |
ObservableObject | 프로토콜(@ 없음!) | class가 채택 |
@ObservedObject | 프로퍼티 래퍼 | 뷰 프로퍼티 |
@Binding | 프로퍼티 래퍼 | 뷰 프로퍼티 |
@Bindable | 프로퍼티 래퍼 | 뷰 프로퍼티 |
@Observable은 코드를 생성하는 매크로(프로퍼티를 감싸는 게 아님). ObservableObject는 프로토콜이라 원래 @도 안 붙는다(class VM: ObservableObject). @ObservableObject라는 래퍼는 없다.
프로퍼티 래퍼(@ObservedObject·@Bindable·@State·@StateObject)는 전부 var 필수. let으로 하면 "property wrapper can only be applied to a 'var'" 에러. let이 되는 건 래퍼 없는 순수 프로퍼티 — @Observable 객체를 그냥 받을 때뿐.
let vm: CartViewModel // ✅ @Observable 관찰(래퍼 없음, 재할당 안 하니 let 권장)
@Bindable var vm: CartViewModel // ✅ 양방향 필요 시 (var 필수)
@ObservedObject var vm: VM // ✅ 구형, var 필수
@ObservedObject let vm: VM // ❌ 컴파일 에러
객체는 참조 타입이라 var여도 복사되는 게 아니니 걱정 없음.
any / some / Generic / Dispatch 쉽게 이해하기
Protocol Oriented Programming Master