← SwiftUI 14일 마스터 4 / 14 · 2부

Day 4 · 2부 · lifetime과 explicit identity

@State의 수명을 identity로 설명하고, .id()·ForEach id를 올바르게 붙이며 AnyView를 걷어낸다.
진행률
0 / 0 완료
1부에서 SwiftUI는 뷰의 타입과 위치를 이름표(structural identity)로 쓰고, 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은 이걸 더 정확히 정의한다.

📝 Apple 원문

"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에 붙은 저장소다.

identity 시작 ① @State 할당 ② onAppear ③ task 시작 identity 끝 ① onDisappear ② task 취소 ③ @State 해제 @State 저장소 — 하나로 유지 값 #1 값 #2 값 #3 값 #4 … body 재평가마다 새 값 — 비교에 쓰고 버린다 시간 task(id:)의 값만 바뀌면: task만 취소·재시작, 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 }
            }
        }
    }
}
⚠️ .id()는 붙이는 자리가 중요하다

NoteEditor가 자기 body 안에서 .id(note.id)를 붙이면 자기 @State는 리셋되지 않는다. body 안의 모디파이어는 자식(TextEditor) 쪽 identity만 바꾼다. 자기 저장소는 부모가 정한 identity에 붙어 있으니, 호출하는 쪽에서 붙여야 한다.

❌ "갱신이 안 돼서" 붙이는 .id(UUID()) — 매 업데이트마다 전부 새로 만든다
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).

로그로 확인 — "다시 그리기"와 "id 바꾸기"의 차이
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의 주제다.

task(id:) — 상태는 유지하고 작업만 다시 시작
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)"
        }
    }
}
👀 Preview 팁

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 — 읽을 때마다 새 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) }
    }
}
삽입 전 A · 0 B · 1 C · 2 ← 맨 앞에 Z 삽입 ❌ 인덱스 id Z · 0 A · 1 B · 2 C · 3 새것은 3뿐 → 끝에 삽입된 듯 ✅ 안정 id Z · z A · a B · b C · c 새것은 z뿐 → 앞에 삽입 점선 = SwiftUI 눈에 "내용이 바뀐 행" — 행 안의 @State가 엉뚱한 데이터에 붙는다
id가 데이터를 따라가야 행의 상태와 애니메이션이 제자리에 붙는다. 인덱스는 위치를 따라간다.
✅ 생성 시 한 번 정해지고 변하지 않는 고유 id
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로 쓸 게 없는데요"

서버가 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는 구조를 볼 수 없다.

📝 Apple 원문

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

❌ 분기마다 AnyView — SwiftUI는 반환 타입 AnyView 하나만 본다
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 + switch — 구조가 타입에 그대로 남는다
@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 모디파이어) 또는 상태를 분기 밖으로
색 변화가 보간되지 않고 툭 바뀐다분기 = 교체 전환뷰 하나 + 모디파이어 값 변경
행 추가 시 전체가 깜빡인다계산 프로퍼티 랜덤 idlet id = UUID() 저장
삭제 후 옆 행이 이전 행의 펼침 상태를 가짐인덱스 id데이터의 안정 id
편집기를 다른 항목으로 열었는데 이전 초안이 남음identity가 그대로부모에서 .id(item.id)
화면이 매번 깜빡이고 느리다.id(UUID()), 불필요한 AnyView제거 — 원인은 대개 의존성

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
"뷰의 lifetime"과 "뷰 struct의 lifetime"은 어떻게 다른가? @State는 어느 쪽에 묶여 있나?
뷰 struct(값)는 body가 평가될 때마다 새로 생기고 비교 후 버려지는 일회용이다. 뷰의 lifetime은 identity가 유지되는 기간이다. @State는 identity에 붙은 저장소라서 identity가 시작될 때 할당되고 끝날 때 해제된다. struct가 몇 번 새로 만들어지든 상관없다.
쉽게 말하면 사진(struct)은 매일 새로 찍지만, 사물함(상태)은 그 학생이 전학 가기(identity 끝) 전까지 계속 같은 것을 쓴다.
좋은 ForEach id의 조건 두 가지는? var id: UUID { UUID() }는 어느 조건을 어기고, 증상은?
세션 기준으로 ① 시간이 지나도 변하지 않고(stable) ② 고유해야 한다(unique). 계산 프로퍼티 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을 켜면 세 버튼은 각각 무엇을 표시하나?
위에서부터 "n = 0", "n = 0", "n = 3". 첫째는 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 하나로 고친다.

시작 코드 — 인덱스 id 때문에 행 상태가 밀린다
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() }

확인 방법: Live Preview에서 "운동" 행 메모에 "30분"을 입력 → "맨 앞에 추가" → "완료 표시" 토글. "30분"이 계속 "운동" 행에 있으면 성공이다.

정답 코드 보기
정답 — 저장된 UUID id + 값 분기
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)에서 다시 쓰인다.

출처 · 참고자료