← SwiftUI 로드맵 3 / 5

03 · identity와 lifetime

입력하던 텍스트가 사라지는 버그의 정체. SwiftUI가 '같은 뷰'를 판단하는 기준, if/else가 상태를 파괴하는 이유, 상태 수명이 identity 수명과 같다는 규칙, AnyView를 피하는 이유.
진행률
0 / 0 완료
"입력하던 텍스트가 갑자기 사라졌다." "스크롤이 맨 위로 튀었다." "애니메이션이 자연스럽게 안 되고 깜빡 바뀐다." 이 세 버그는 증상이 달라 보이지만 원인이 하나다 — SwiftUI가 그 뷰를 다른 뷰로 판단했다. 이 챕터는 SwiftUI가 "같은 뷰"를 어떻게 정하는지, 그리고 그 판단이 상태 수명을 어떻게 좌우하는지를 다룬다.
📝 이 챕터의 출처

내용의 골격은 WWDC21 — Demystify SwiftUI 세션의 Identity · Lifetime · Dependencies 세 축이다. 이 세션은 SwiftUI를 "왜 이렇게 동작하는가" 수준에서 이해하려면 반드시 봐야 하는 하나다.

1. 먼저 사고 현장을 보자

아래는 실무에서 그대로 나오는 코드다. 편집 모드를 켰다 껐다 하면 입력하던 내용이 날아간다.

❌ 편집 모드를 껐다 켜면 EditForm의 입력이 사라진다
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의 실제 타입이 끔찍하게 길다"고 했다. 그 긴 타입이 바로 이름표다. 낭비가 아니라 설계다.

VStack Text Text Toggle 위치 0 위치 1 위치 2 같은 Text 타입인데 이름표는 다르다 이름표 = 타입 + 위치 → 내가 아무것도 안 해도 자동으로 붙는다 그래서 첫 줄 텍스트를 바꿔도 둘째 줄은 건드려지지 않는다
구조적 identity는 코드 모양이 곧 이름표다. 이게 "선언형인데 어떻게 효율적으로 갱신하지?"라는 질문의 답이다.

3. if/else는 "숨기기"가 아니라 "다른 뷰"다

이제 1절의 사고를 설명할 수 있다. @ViewBuilder 안의 if/else두 분기를 하나의 합성 타입으로 만든다.

if/else가 실제로 만드는 타입
if isEditing { EditForm() } else { SummaryCard() }

// 실제 타입은 이렇다
// _ConditionalContent<EditForm, SummaryCard>
//                     ↑ true 분기   ↑ false 분기 — 서로 다른 이름표

WWDC21 원문 표현으로는 "The if-else creates separate identities". 분기를 갈아타는 순간, SwiftUI는 한 뷰를 없애고 다른 뷰를 만든다. 없어진 뷰의 @State는 함께 파괴된다.

❌ if / else — 이름표가 갈린다 ✅ 모디파이어로 조건 이동 EditForm @State draft="안녕" SummaryCard (아직 없음) isEditing 토글 파괴됨 draft 도 함께 소멸 SummaryCard 새로 생성 다시 돌아와도 draft 는 "" 로 시작한다 전환 애니메이션도 "교체"로 처리된다 _ConditionalContent<A, B> ProfileCard @State 유지 · 이름표 하나 조건 변경 ProfileCard 같은 뷰 · 배경색만 바뀜 상태가 살아남는다 색 변화가 부드럽게 보간된다 .background(cond ? .green : .red)
왼쪽과 오른쪽은 "화면 결과"가 같아 보여도 SwiftUI 내부에서는 완전히 다른 일이 벌어진다.

고치는 법 — 조건을 모디파이어로 내린다

WWDC21의 권고 그대로다: 모습만 바뀌는 거라면 분기하지 말고 값을 분기해라.

❌ 뷰를 분기 — identity 두 개
if isSelected {
    ProfileCard(user: user).background(.green)
} else {
    ProfileCard(user: user).background(.red)
}
✅ 값을 분기 — identity 하나, 상태 유지 + 색 보간
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에 넣어둔 물건은 없어진다.

아래쪽은 같은 사물함에 이름표만 바꿔 붙인 것이다. 사물함은 그대로니 안에 든 물건도 그대로다.

⚠️ 1절 사고의 해결

EditForm의 입력을 살리고 싶으면 두 갈래다. (1) draftEditForm이 아니라 ProfileView가 소유하게 올리고 @Binding으로 내려보낸다 — 2챕터의 "필요한 가장 높은 뷰에 둔다"가 이래서 나온 규칙이다. (2) 애초에 분기를 없앤다.

해결 (1) — 상태를 분기 바깥으로 올린다
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를 이름표로 쓴다.

ForEach는 요소가 Identifiable이거나 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)
}
🚨 절대 하지 말 것 — 인덱스를 id로 쓰기

항목이 삽입·삭제·이동할 때 이름표가 밀린다. 삭제한 항목의 상태가 옆 항목에 붙고, 애니메이션이 엉키고, 셀 내부 상태가 뒤섞인다.

❌ 인덱스를 이름표로 — 0번을 지우면 모든 행의 이름표가 한 칸씩 밀린다
// 컴파일 에러를 피하려고 이렇게 쓰는 경우가 많다
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로 옮겨 붙는다.
✅ 데이터 자신의 안정적 고유 id
struct Todo: Identifiable {
    let id = UUID()          // 생성 시 한 번 정해지고 절대 변하지 않는다
    var title: String
    var isDone: Bool
}

ForEach(todos) { todo in     // Identifiable 이면 id 파라미터가 필요 없다
    TodoRow(todo: todo)
}

// 삭제·이동·삽입이 일어나도 이름표가 데이터를 따라간다.
⚠️ "id로 쓸 만한 게 없는데요"

서버가 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의 수명.

시간 identity 생성 identity 소멸 상태 저장소 (@State) — 하나로 계속 유지된다 뷰 값 #1 뷰 값 #2 뷰 값 #3 뷰 값 #4 body 가 호출될 때마다 새로 만들고 버린다 (일회용) 함께 파괴 → identity를 지키면 상태가 지켜진다. identity를 갈면 상태가 사라진다.
세로 점선 두 개 사이가 뷰의 "생애"다. 뷰 값은 그 안에서 수십 번 교체되지만, 저장소는 하나다.

그래서 이 문장이 성립한다

📝 WWDC21의 핵심 한 문장

"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 문서 한 줄에 다 있다.

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

AnyView는 타입을 지운다. 타입이 곧 이름표였으니, 이름표를 지우는 것이다. SwiftUI는 같은 뷰인지 판단할 근거를 잃고, 안전한 쪽(전부 새로 만들기)을 택한다. 3절의 사고가 구조적으로 상시 발생하는 상태가 된다.

❌ AnyView로 타입 통일 — 계층이 파괴·재생성된다
func cell(for item: Item) -> AnyView {
    if item.isHeader { return AnyView(HeaderCell(item)) }
    return AnyView(NormalCell(item))
}
✅ @ViewBuilder — 타입 정보를 유지한 채 분기
@ViewBuilder
func cell(for item: Item) -> some View {
    if item.isHeader { HeaderCell(item) }
    else { NormalCell(item) }
}

@ViewBuilder를 붙이면 반환 타입은 _ConditionalContent<HeaderCell, NormalCell>이 되고 이름표가 살아 있다. 손이 덜 가는 것도 아니다 — AnyView(를 지우고 어노테이션 하나 붙이는 게 전부다.

⚠️ 5챕터 예고

AnyViewList/LazyVStack 안에서 특히 나쁘다. "요소당 뷰 개수를 알 수 없게" 만들어서 리스트 성능까지 깎는다. 5챕터에서 이 규칙을 다룬다.

이 챕터 요약

출처 · 참고자료