if/else의 두 분기는 서로 다른 뷰라는 걸 봤다. 2부는 그 이름표에 무엇이 매달려 있는지 — lifetime과 @State — 를 보고, 이름표를 직접 붙이는 explicit identity(.id(), ForEach의 id)와 이름표를 지워 버리는 AnyView를 다룬다.2부 목표 · 23분
- "Day 3에서
init으로 넣은@State가 무시된 이유"를 identity와 lifetime으로 설명하고,.id()로 상태를 의도적으로 리셋한다. onAppear·task가 identity 시작/끝에 묶여 있음을 로그로 확인하고,.id(x)와.task(id: x)의 차이를 말한다.ForEach의 id를 보고 불안정(랜덤·인덱스)·비고유(이름) id를 찾아 안정적인 id로 고친다.AnyView를@ViewBuilder+switch로 걷어낸다.
1. lifetime — 상태는 identity에 매달려 있다 🔥 핵심
WWDC20 Data Essentials in SwiftUI의 한 줄: "the lifetime of a view is separate from the lifetime of a struct that defines it." WWDC21은 이걸 더 정확히 정의한다.
"View values are ephemeral and you should not rely on their lifetime. But what you can control is their identity." — 뷰 값(struct)은 일회용이다. 제어할 수 있는 건 identity다.
"Whenever we talk about the lifetime of a view, we are referring to the duration of the identity associated with that view." — 뷰의 수명이란 곧 identity가 유지되는 기간이다.
"State and StateObject are the persistent storage associated with your view's identity." — @State는 identity에 붙은 저장소다.
세션은 같은 뷰를 두 분기에 둔 예제로 결론을 낸다: 분기를 오갈 때마다 저장소가 초기값으로 새로 만들어진다. "The takeaway here is that whenever the identity changes, the state is replaced." Day 3 1부에서 본 두 현상(함정 ① init 값 무시와 초안 편집기 초기화)도 이 한 규칙으로 풀린다. 방향만 반대다 — identity가 그대로면 저장소가 남아 새 초기값이 무시되고, identity가 바뀌면 저장소가 새로 생겨 초기화된다.
| Day 3에서 본 현상 | identity로 본 이유 |
|---|---|
init에서 State(initialValue:)로 넣은 값이 두 번째부터 무시 | 부모가 다시 그려도 자식의 identity(타입+위치)는 그대로 → 저장소도 그대로 → 초기값은 다시 안 쓰인다 |
| 초안 편집기를 다른 대상으로 재사용하면 이전 초안이 남음 | 대상이 바뀌어도 identity가 같다 → 부모가 .id(대상.id)로 identity를 바꿔 줘야 리셋 |
.id() — identity를 일부러 바꿔 상태를 리셋
Apple 문서 · id(_:): "When the proxy value specified by the id parameter changes, the identity of the view — for example, its state — is reset."
struct Note: Identifiable {
let id: UUID
var text: String
}
struct NoteEditor: View {
let note: Note
@State private var draft: String
init(note: Note) {
self.note = note
_draft = State(initialValue: note.text) // identity가 새로 생길 때만 쓰인다
}
var body: some View { TextEditor(text: $draft) }
}
struct NotesScreen: View {
let notes: [Note]
@State private var selected: Note?
var body: some View {
VStack {
if let selected {
NoteEditor(note: selected)
.id(selected.id) // 부모가 identity를 바꿔 준다 → draft 새로 시작
}
ForEach(notes) { note in
Button(note.text) { selected = note }
}
}
}
}NoteEditor가 자기 body 안에서 .id(note.id)를 붙이면 자기 @State는 리셋되지 않는다. body 안의 모디파이어는 자식(TextEditor) 쪽 identity만 바꾼다. 자기 저장소는 부모가 정한 identity에 붙어 있으니, 호출하는 쪽에서 붙여야 한다.
struct ChartCard: View {
let values: [Double]
var body: some View {
ChartBody(values: values)
.id(UUID()) // body가 돌 때마다 새 identity → 상태 소실 + 애니메이션 없음 + 비용
}
}
struct ChartBody: View {
let values: [Double]
@State private var highlighted: Int?
var body: some View {
HStack {
ForEach(values.indices, id: \.self) { i in
Capsule().frame(width: 8, height: values[i])
.opacity(highlighted == i ? 1 : 0.5)
.onTapGesture { highlighted = i }
}
}
}
}화면이 안 바뀌는 진짜 원인은 대개 의존성이다(body가 그 값을 실제로 읽는가? — Day 5). .id()는 "내용이 완전히 다른 대상으로 바뀌어서 내부 상태를 버려야 할 때"와 "코드에서 그 뷰를 가리켜야 할 때(ScrollViewReader의 scrollTo나 iOS 17 scrollPosition(id:)로 특정 행까지 스크롤할 때 — 스크롤 API는 Day 9 1부 3절)"에만 쓴다.
onAppear · task와 identity
task 문서: "If the task doesn't finish before SwiftUI removes the view or the view changes identity, SwiftUI cancels the task." 즉 onAppear/task는 struct가 만들어질 때가 아니라 identity가 시작될 때 돈다. 부모가 100번 다시 그려도 identity가 같으면 다시 불리지 않는다. 단, 화면에서 사라졌다 다시 나타나면(탭 전환, 내비게이션 복귀, lazy 스택 스크롤) identity가 같아도 다시 불린다 — Day 1 번역표의 "나타날 때마다"다. .id()가 바뀌면 이전 identity가 끝나고(onDisappear·task 취소) 새 identity가 시작된다(onAppear·task).
struct LifeLogView: View {
let label: String
@State private var taps = 0
var body: some View {
Button("\(label) · 탭 \(taps)") { taps += 1 }
.onAppear { print("onAppear", label) }
.onDisappear { print("onDisappear", label) }
.task {
print("task 시작", label)
try? await Task.sleep(for: .seconds(60))
print("task 종료(취소 포함)", label)
}
}
}
struct LifeLogHost: View {
@State private var redraws = 0
@State private var version = 0
var body: some View {
VStack(spacing: 16) {
LifeLogView(label: "v\(version)")
.id(version) // version이 바뀔 때만 새 identity
Button("부모 다시 그리기 (\(redraws))") { redraws += 1 } // 로그 없음, taps 유지
Button("id 바꾸기") { version += 1 } // 옛 뷰 onDisappear + 새 뷰 onAppear(두 로그의 순서는 문서가 보장하지 않는다), taps = 0
}
}
}
#Preview { LifeLogHost() }identity 관점의 .task(id:): .id(x)는 identity를 새로 만들어 @State까지 리셋하고 onAppear/onDisappear도 다시 부른다. .task(id: x)는 identity를 그대로 두고 작업만 취소 후 재시작한다 — 아래에서 userID가 바뀌어도 isFavorite는 유지된다. 수명·취소·로딩 상태 모델링은 Day 12의 주제다.
struct UserDetail: View {
let userID: Int
@State private var name = "불러오는 중…"
@State private var isFavorite = false // userID가 바뀌어도 유지된다
var body: some View {
VStack {
Text(name)
Toggle("즐겨찾기 보기", isOn: $isFavorite)
}
.task(id: userID) { // userID가 바뀌면 이전 작업 취소 후 재시작
try? await Task.sleep(for: .milliseconds(300))
name = "사용자 \(userID)"
}
}
}print 로그는 Preview에서도 Xcode 콘솔(디버그 영역)에 찍힌다. 위 LifeLogHost를 Live Preview로 두고 두 버튼을 눌러 보면 identity 시작/끝이 눈으로 보인다.
2. explicit identity — ForEach에는 안정적이고 고유한 id
데이터 목록은 위치로 이름표를 만들 수 없다. 0번이 삭제되면 1번이 0번 자리로 오는데, 둘은 다른 데이터다. 그래서 ForEach는 "Either the collection's elements must conform to Identifiable or you need to provide an id parameter"를 요구한다. 세션: "Choosing a good identifier is your opportunity to control the lifetime of your views and data."
| ForEach 형태 | 이름표 | 언제 |
|---|---|---|
ForEach(0..<5) | 범위의 offset | 상수 범위만. 동적 범위는 오류이고 WWDC21부터 경고가 뜬다 |
ForEach(items, id: \.serial) | 키패스가 가리키는 Hashable 값 | 요소가 Identifiable이 아닐 때 |
ForEach(items) | Identifiable.id | 기본 형태 |
좋은 id의 조건은 두 가지다. 세션: "An identifier shouldn't change over time; a new identifier represents a new item with a new lifetime. And lastly, identifiers need to be unique."
// ① 계산 프로퍼티의 랜덤 id — 읽을 때마다 새 id → 데이터가 바뀔 때마다 전부 깜빡인다
struct Pet: Identifiable {
var name: String
var id: UUID { UUID() }
}
// ② 인덱스 id — 위치가 이름표 → 맨 앞에 삽입해도 "끝에 추가"로 애니메이션된다
struct PetList: View {
@State private var pets = [Pet(name: "보리"), Pet(name: "콩이")]
var body: some View {
List {
ForEach(pets.indices, id: \.self) { i in Text(pets[i].name) }
}
}
}
// ③ 이름 id — 같은 이름이 두 개면 하나가 안 보이거나 뒤섞인다
struct Treat {
var name: String
var expiry: Date
}
struct TreatJar: View {
let treats: [Treat]
var body: some View {
List(treats, id: \.name) { Text($0.name) }
}
}struct Pet: Identifiable {
let id = UUID() // 저장 프로퍼티 — 한 번 정해지면 끝
var name: String
}
struct Treat: Identifiable {
let id: Int // 서버의 serial number 같은 영속 id가 있으면 그걸 쓴다
var name: String
var expiry: Date
}
struct PetList: View {
@State private var pets = [Pet(name: "보리"), Pet(name: "콩이")]
var body: some View {
List {
ForEach(pets) { pet in Text(pet.name) }
Button("맨 앞에 추가") {
withAnimation { pets.insert(Pet(name: "새 친구"), at: 0) }
}
}
}
}서버가 id를 안 준다고 제목이나 인덱스를 쓰지 않는다. 제목은 편집되면 바뀌고(그 순간 행이 새 identity가 된다), 인덱스는 위 그림의 문제가 생긴다. 세션 권장은 "a stable identifier, like one from a database or derived from stable properties". 없으면 모델을 만들 때 let id = UUID()를 붙인다 — 저장 프로퍼티로.
ForEach 문서는 List/LazyVStack 안에서 "ensure that the view created from each element in the collection represents a constant number of views"를 권한다. 요소마다 if로 0개 또는 1개를 만들면 느린 경로를 탄다. -LogForEachSlowPath YES 실행 인자로 진단할 수 있다. 성능 측면은 Day 9에서 다룬다.
3. AnyView의 비용 + 흔한 실수
타입 에러를 만나면 AnyView로 감싸고 싶어진다. 하지만 identity 관점에서 AnyView는 이름표를 지우는 도구다. 타입이 이름표였으니 타입을 지우면 SwiftUI는 구조를 볼 수 없다.
"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." — 감싼 뷰의 타입이 바뀌면 기존 계층을 부수고 새로 만든다. (Apple 문서 · AnyView)
세션은 이유 세 가지를 든다: 코드가 읽기 어렵다, 컴파일러 진단(에러·경고)을 가린다, "using AnyView when you don't need to can result in worse performance". 결론은 "we recommend avoiding AnyViews whenever possible".
enum Breed { case borderCollie, poodle, mixed }
struct Dog {
var name: String
var breed: Breed
var sheepNearby = false
}
func breedView(for dog: Dog) -> AnyView {
var result: AnyView
if dog.breed == .borderCollie {
if dog.sheepNearby {
result = AnyView(HStack { Text("🐕 \(dog.name)"); Text("🐑") })
} else {
result = AnyView(Text("🐕 \(dog.name)"))
}
} else if dog.breed == .poodle {
result = AnyView(Text("🐩 \(dog.name)").italic())
} else {
result = AnyView(Label(dog.name, systemImage: "pawprint"))
}
return result
}@ViewBuilder
func breedView(for dog: Dog) -> some View {
switch dog.breed {
case .borderCollie:
HStack {
Text("🐕 \(dog.name)")
if dog.sheepNearby { Text("🐑") } // HStack을 분기하지 말고 안에서 분기
}
case .poodle:
Text("🐩 \(dog.name)").italic()
case .mixed:
Label(dog.name, systemImage: "pawprint")
}
}세션이 보여 준 개선 순서 그대로다: (1) 바깥 HStack을 분기하지 않고 안쪽에서 조건부로 추가, (2) 함수에 @ViewBuilder를 붙여 return과 AnyView 제거, (3) switch로 정리 — "switch statements are really just syntactic sugar for conditional statements"라 결과 타입은 같다.
흔한 실수 모음
| 증상 | 원인 (identity 관점) | 처방 |
|---|---|---|
| 토글하면 입력·스크롤 위치가 사라진다 | if/else로 같은 뷰를 두 분기에 둠 (1부) | 값 분기(삼항·inert 모디파이어) 또는 상태를 분기 밖으로 |
| 색 변화가 보간되지 않고 툭 바뀐다 | 분기 = 교체 전환 | 뷰 하나 + 모디파이어 값 변경 |
| 행 추가 시 전체가 깜빡인다 | 계산 프로퍼티 랜덤 id | let id = UUID() 저장 |
| 삭제 후 옆 행이 이전 행의 펼침 상태를 가짐 | 인덱스 id | 데이터의 안정 id |
| 편집기를 다른 항목으로 열었는데 이전 초안이 남음 | identity가 그대로 | 부모에서 .id(item.id) |
| 화면이 매번 깜빡이고 느리다 | .id(UUID()), 불필요한 AnyView | 제거 — 원인은 대개 의존성 |
✅ 복습 · 5분
"뷰의 lifetime"과 "뷰 struct의 lifetime"은 어떻게 다른가? @State는 어느 쪽에 묶여 있나?
body가 평가될 때마다 새로 생기고 비교 후 버려지는 일회용이다. 뷰의 lifetime은 identity가 유지되는 기간이다. @State는 identity에 붙은 저장소라서 identity가 시작될 때 할당되고 끝날 때 해제된다. struct가 몇 번 새로 만들어지든 상관없다.좋은 ForEach id의 조건 두 가지는? var id: UUID { UUID() }는 어느 조건을 어기고, 증상은?
UUID()는 읽을 때마다 새 값이라 ①을 어긴다 — 데이터가 바뀔 때마다 모든 행이 새 identity가 되어 깜빡이고 행 상태가 사라진다. let id = UUID()처럼 저장 프로퍼티로 한 번만 정한다.struct Counter: View {
@State private var n = 0
var body: some View { Button("n = \(n)") { n += 1 } }
}
struct Host: View {
@State private var flag = false
var body: some View {
VStack {
if flag { Counter() } else { Counter() }
Counter().id(flag)
Counter()
Toggle("flag", isOn: $flag)
}
}
}🔮 코드 예측: 위 코드에서 세 Counter를 각각 3번씩 탭한 뒤 Toggle을 켜면 세 버튼은 각각 무엇을 표시하나?
if/else의 true/false 자리가 다른 identity라 새 저장소로 시작한다. 둘째는 .id(flag)의 값이 바뀌어 identity가 리셋된다. 셋째는 타입과 위치가 그대로라 상태가 유지된다.🔁 1부 복습: 선택된 카드만 초록색으로 보이게 하려고 if isSelected { ProfileCard().background(.green) } else { ProfileCard().background(.red) }를 썼다. 카드 안의 펼침 상태는 어떻게 되고, 어떻게 고치나?
if/else는 _ConditionalContent의 서로 다른 자리 — identity가 2개라 분기를 갈아타면 이전 카드와 그 @State가 사라진다. ProfileCard().background(isSelected ? .green : .red)로 값을 분기하면 identity 1개로 상태가 남고 색도 보간된다.🛠 미니 과제 (선택 · 10분)
아래 할 일 목록은 각 행이 "메모" 입력칸(@State)을 갖는다. 두 번째 행 메모에 글자를 넣고 "맨 앞에 추가"를 누르면 메모가 엉뚱한 행으로 옮겨 붙는다. Preview 하나로 고친다.
struct TaskItem {
var title: String
}
struct TaskRow: View {
let title: String
@State private var memo = ""
var body: some View {
VStack(alignment: .leading) {
Text(title).font(.headline)
TextField("메모", text: $memo)
}
}
}
struct TaskBoard: View {
@State private var tasks = [TaskItem(title: "장보기"), TaskItem(title: "운동")]
@State private var showDone = false
var body: some View {
List {
Toggle("완료 표시", isOn: $showDone)
ForEach(tasks.indices, id: \.self) { i in
if showDone {
TaskRow(title: "✅ " + tasks[i].title)
} else {
TaskRow(title: tasks[i].title)
}
}
Button("맨 앞에 추가") {
withAnimation { tasks.insert(TaskItem(title: "새 할 일 \(tasks.count)"), at: 0) }
}
}
}
}
#Preview { TaskBoard() }- 요구 1 — "맨 앞에 추가" 후에도 메모가 원래 행에 붙어 있어야 한다(안정 id).
- 요구 2 — "완료 표시" 토글을 눌러도 메모가 지워지지 않아야 한다(분기 제거).
- 요구 3 — 새 행이 목록 맨 앞에서 나타나는 애니메이션이어야 한다.
확인 방법: Live Preview에서 "운동" 행 메모에 "30분"을 입력 → "맨 앞에 추가" → "완료 표시" 토글. "30분"이 계속 "운동" 행에 있으면 성공이다.
정답 코드 보기
struct TaskItem: Identifiable {
let id = UUID() // 요구 1·3: 데이터를 따라가는 이름표
var title: String
}
struct TaskBoard: View {
@State private var tasks = [TaskItem(title: "장보기"), TaskItem(title: "운동")]
@State private var showDone = false
var body: some View {
List {
Toggle("완료 표시", isOn: $showDone)
ForEach(tasks) { task in
TaskRow(title: (showDone ? "✅ " : "") + task.title) // 요구 2: 뷰 하나, 값만 분기
}
Button("맨 앞에 추가") {
withAnimation { tasks.insert(TaskItem(title: "새 할 일 \(tasks.count)"), at: 0) }
}
}
}
}Day 5에서는 오늘 잠깐 언급한 세 번째 축 dependencies를 @Observable로 이어 간다 — "body가 읽은 것만 의존성이 된다". 오늘의 identity 규칙은 Day 9(리스트 성능)와 Day 14(transition)에서 다시 쓰인다.