내용의 골격은 WWDC21 — Demystify SwiftUI 세션의 Identity · Lifetime · Dependencies 세 축이다. 이 세션은 SwiftUI를 "왜 이렇게 동작하는가" 수준에서 이해하려면 반드시 봐야 하는 하나다.
1. 먼저 사고 현장을 보자
아래는 실무에서 그대로 나오는 코드다. 편집 모드를 켰다 껐다 하면 입력하던 내용이 날아간다.
struct ProfileView: View {
@State private var isEditing = false
var body: some View {
VStack {
if isEditing {
EditForm() // 이 안에 @State private var draft = "" 가 있다
} else {
SummaryCard()
}
Button("전환") { isEditing.toggle() }
}
}
}UIKit 감각으로는 이상하다. EditForm을 지웠다 다시 만든 게 아니라 숨겼다 보여준 것 같은데 왜 값이 사라지나? 답: SwiftUI 눈에는 숨긴 게 아니라 없앤 것이다. 이유를 알려면 identity를 알아야 한다.
2. structural identity — 타입과 위치가 이름표다
SwiftUI는 뷰마다 "이름표"가 필요하다. 업데이트가 일어났을 때 새로 그린 설명서의 이 뷰가, 화면에 있는 그 뷰와 같은 것인지 판단해야 하기 때문이다. 이름표를 만드는 방식이 두 가지다.
| 종류 | 이름표를 만드는 근거 | 내가 쓰는 법 | 언제 |
|---|---|---|---|
| structural identity (구조적) | 뷰의 타입 + 계층 내 위치 | 아무것도 안 함 — 기본값 | 거의 항상 |
| explicit identity (명시적) | 내가 준 값 | .id(value), ForEach(_:id:) | 데이터 목록 · 다른 곳에서 참조해야 할 때 |
1챕터에서 "some View의 실제 타입이 끔찍하게 길다"고 했다. 그 긴 타입이 바로 이름표다. 낭비가 아니라 설계다.
3. if/else는 "숨기기"가 아니라 "다른 뷰"다
이제 1절의 사고를 설명할 수 있다. @ViewBuilder 안의 if/else는 두 분기를 하나의 합성 타입으로 만든다.
if isEditing { EditForm() } else { SummaryCard() }
// 실제 타입은 이렇다
// _ConditionalContent<EditForm, SummaryCard>
// ↑ true 분기 ↑ false 분기 — 서로 다른 이름표WWDC21 원문 표현으로는 "The if-else creates separate identities". 분기를 갈아타는 순간, SwiftUI는 한 뷰를 없애고 다른 뷰를 만든다. 없어진 뷰의 @State는 함께 파괴된다.
고치는 법 — 조건을 모디파이어로 내린다
WWDC21의 권고 그대로다: 모습만 바뀌는 거라면 분기하지 말고 값을 분기해라.
if isSelected {
ProfileCard(user: user).background(.green)
} else {
ProfileCard(user: user).background(.red)
}ProfileCard(user: user)
.background(isSelected ? .green : .red)"진짜 다른 화면"이면 if/else가 맞다. 로딩 화면 ↔ 콘텐츠, 로그인 ↔ 메인처럼 상태가 이어질 필요가 없는 전환은 분기가 정답이다.
"같은 것의 다른 모습"이면 모디파이어다. 선택/비선택, 펼침/접힘, 강조/보통처럼 상태가 이어져야 하는 변화는 조건을 값으로 내려라.
직접 만져 보는 실험
이 챕터는 읽는 것보다 손으로 한 번 확인하는 게 훨씬 빠르다. 아래 코드를 프리뷰에 붙이고, 두 입력란에 글자를 넣은 뒤 토글을 눌러 보라. 위 칸만 지워진다.
struct IdentityLab: View {
@State private var isOn = true
var body: some View {
VStack(spacing: 28) {
Toggle("토글해 보세요", isOn: $isOn)
VStack(alignment: .leading) {
Text("❌ if / else — 분기가 갈리면 입력이 사라진다").font(.caption)
if isOn {
DraftField(label: "A")
} else {
DraftField(label: "B")
}
}
VStack(alignment: .leading) {
Text("✅ 같은 뷰 + 값만 바뀜 — 입력이 유지된다").font(.caption)
DraftField(label: isOn ? "A" : "B")
}
}
.padding()
}
}
private struct DraftField: View {
let label: String
@State private var draft = "" // 이 상태의 수명이 관찰 대상
var body: some View {
TextField("입력 후 토글 (\(label))", text: $draft)
.textFieldStyle(.roundedBorder)
}
}위쪽은 사물함을 바꾼 것이다. A 사물함에서 B 사물함으로 옮기면 A에 넣어둔 물건은 없어진다.
아래쪽은 같은 사물함에 이름표만 바꿔 붙인 것이다. 사물함은 그대로니 안에 든 물건도 그대로다.
EditForm의 입력을 살리고 싶으면 두 갈래다. (1) draft를 EditForm이 아니라 ProfileView가 소유하게 올리고 @Binding으로 내려보낸다 — 2챕터의 "필요한 가장 높은 뷰에 둔다"가 이래서 나온 규칙이다. (2) 애초에 분기를 없앤다.
struct ProfileView: View {
@State private var isEditing = false
@State private var draft = "" // 분기 바깥 = 분기와 무관하게 살아남는다
var body: some View {
VStack {
if isEditing {
EditForm(draft: $draft) // 통로만 내려보낸다
} else {
SummaryCard()
}
Button("전환") { isEditing.toggle() }
}
}
}
struct EditForm: View {
@Binding var draft: String // 소유하지 않으니 파괴될 상태가 없다
var body: some View {
TextField("소개", text: $draft)
}
}4. explicit identity — .id() 와 ForEach
데이터 목록에서는 위치로 이름표를 만들 수 없다. 3번째 항목이 삭제되면 4번째가 3번 위치로 오는데, 그 둘은 다른 데이터다. 그래서 데이터 자신의 id를 이름표로 쓴다.
private struct NamedFont: Identifiable {
let name: String
let font: Font
var id: String { name } // 이게 이름표가 된다
}
ForEach(namedFonts) { namedFont in
Text(namedFont.name).font(namedFont.font)
}항목이 삽입·삭제·이동할 때 이름표가 밀린다. 삭제한 항목의 상태가 옆 항목에 붙고, 애니메이션이 엉키고, 셀 내부 상태가 뒤섞인다.
// 컴파일 에러를 피하려고 이렇게 쓰는 경우가 많다
ForEach(todos.indices, id: \.self) { i in
TodoRow(todo: todos[i]) // 이름표 = 0, 1, 2, 3 …
}
ForEach(Array(todos.enumerated()), id: \.offset) { _, todo in
TodoRow(todo: todo) // 위와 똑같은 문제
}
// 0번을 삭제하면:
// 삭제 전: [A=0] [B=1] [C=2]
// 삭제 후: [B=0] [C=1]
// → SwiftUI 눈에는 "0번 뷰의 내용이 A에서 B로 바뀌었다"로 보인다.
// A에 붙어 있던 셀 내부 @State(펼침 여부, 입력 중 텍스트)가 B로 옮겨 붙는다.struct Todo: Identifiable {
let id = UUID() // 생성 시 한 번 정해지고 절대 변하지 않는다
var title: String
var isDone: Bool
}
ForEach(todos) { todo in // Identifiable 이면 id 파라미터가 필요 없다
TodoRow(todo: todo)
}
// 삭제·이동·삽입이 일어나도 이름표가 데이터를 따라간다.서버가 id를 안 준다고 title이나 인덱스를 쓰지 마라. 제목은 편집되면 바뀌고(그 순간 뷰가 파괴·재생성된다), 인덱스는 위 문제가 생긴다. 클라이언트에서 let id = UUID()를 하나 붙이는 게 정답이다. 값 타입이면 Hashable로 삼을 안정적 조합(예: userID + createdAt)을 만들어도 된다.
.id()의 두 얼굴
.id()는 강력해서 반대로도 쓸 수 있다 — 일부러 identity를 갈아서 상태를 리셋하는 것이다.
DocumentEditor(document: current)
.id(current.id) // 문서가 바뀌면 편집기를 통째로 새로 만든다WWDC21의 지침은 "Only explicitly identify views you need to reference elsewhere, such as in ScrollViewReader"다. .id()를 갱신이 안 될 때의 만능 해결책으로 쓰면, 매번 뷰 계층을 파괴·재생성해서 성능과 애니메이션을 동시에 버린다. 갱신이 안 되는 진짜 원인은 대개 2챕터의 의존성 문제다.
지침이 콕 집어 언급한 정당한 용도가 이것이다 — 다른 곳에서 그 뷰를 가리켜야 할 때.
struct ChatView: View {
let messages: [Message]
var body: some View {
ScrollViewReader { proxy in // proxy 로 특정 뷰를 지목한다
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id) // ← 지목당하기 위한 이름표
}
}
}
Button("맨 아래로") {
if let last = messages.last {
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
}"이 뷰를 코드에서 가리킬 일이 있나?" → 있으면 .id()가 맞다(scrollTo 대상 등).
"갱신이 안 돼서 붙이는 건가?" → 그러면 잘못된 처방이다. 2챕터로 돌아가 body가 그 값을 실제로 읽는지 확인하라.
"내용이 완전히 다른 대상으로 바뀌어서 내부 상태를 버려야 하나?" → 이때는 .id()가 맞다(문서 편집기 예).
5. lifetime — 상태는 뷰가 아니라 identity에 매달려 있다
1챕터 마지막 질문의 답이 여기서 완성된다. WWDC21의 정리:
"View values are ephemeral; what persists is identity."
"@State ... provide persistent storage tied to a view's identity. SwiftUI allocates memory for state only when the identity is first created... When a view's identity ends, its state is destroyed."
→ state의 수명 = identity의 수명.
그래서 이 문장이 성립한다
"Views are stateless functions of their current properties; views aren't imperative sequences of events."
SwiftUI 화면은 "지금 상태의 사진"이다. "지금까지 일어난 일의 일기장"이 아니다.
그래서 "버튼을 눌렀으니 이제 라벨을 빨갛게 만들어야지"라고 생각하면 안 되고, "빨간 상태면 라벨은 빨갛다"고 적어두고 상태만 빨갛게 바꿔야 한다. 사진은 상태를 보고 다시 찍힌다.
그리고 @State는 사진이 아니라 사진틀에 붙어 있다. 사진틀을 치우면(identity 소멸) 안에 든 메모도 같이 버려진다.
6. AnyView를 피하는 이유
UIKit 개발자가 타입 에러를 만나면 반사적으로 손이 가는 게 AnyView다. WWDC21의 권고는 단호하다 — "Avoid it as much as possible". 이유는 Apple AnyView 문서 한 줄에 다 있다.
"Whenever the type of view used with an AnyView changes, the old hierarchy is destroyed and a new hierarchy is created for the new type."
AnyView는 타입을 지운다. 타입이 곧 이름표였으니, 이름표를 지우는 것이다. SwiftUI는 같은 뷰인지 판단할 근거를 잃고, 안전한 쪽(전부 새로 만들기)을 택한다. 3절의 사고가 구조적으로 상시 발생하는 상태가 된다.
func cell(for item: Item) -> AnyView {
if item.isHeader { return AnyView(HeaderCell(item)) }
return AnyView(NormalCell(item))
}@ViewBuilder
func cell(for item: Item) -> some View {
if item.isHeader { HeaderCell(item) }
else { NormalCell(item) }
}@ViewBuilder를 붙이면 반환 타입은 _ConditionalContent<HeaderCell, NormalCell>이 되고 이름표가 살아 있다. 손이 덜 가는 것도 아니다 — AnyView(를 지우고 어노테이션 하나 붙이는 게 전부다.
AnyView는 List/LazyVStack 안에서 특히 나쁘다. "요소당 뷰 개수를 알 수 없게" 만들어서 리스트 성능까지 깎는다. 5챕터에서 이 규칙을 다룬다.
이 챕터 요약
- SwiftUI는 타입 + 위치로 뷰의 이름표(structural identity)를 자동으로 만든다.
some View의 긴 타입이 그 이름표다. if/else는 서로 다른 이름표를 만든다 → 분기를 갈아타면 상태가 파괴되고 전환이 "교체"가 된다.- "같은 것의 다른 모습"은 분기하지 말고 조건을 모디파이어 값으로 내려라.
ForEach의 id는 데이터의 안정적 고유값이어야 한다. 인덱스는 안 된다.- 상태의 수명 = identity의 수명. identity를 지키면 상태가 지켜진다.
AnyView는 이름표를 지운다 → 계층 파괴·재생성.@ViewBuilder로 대체한다.