LODY/정리

애플 플랫폼 백과사전 / Demystify SwiftUI (WWDC21 10022)

Demystify SwiftUI (WWDC21 10022)

원문: Demystify SwiftUI — WWDC21 #10022 발표자: Matt Ricketson, Luca Bernardi, Raj Ramamurthy (SwiftUI 팀) 공개: 2021년 6월

SwiftUI 동작 원리 세션 중 가장 많이 인용되는 한 편. 이 세션이 던지는 질문 한 줄로 요약하면 다음과 같다.

"SwiftUI가 당신의 코드를 들여다볼 때, 무엇을 보는가?"

답은 세 가지. Identity, Lifetime, Dependencies. 이 셋이 맞물려 "무엇이 · 어떻게 · 언제 바뀌어야 하는가"를 결정한다.


Part 1: Identity (Matt)

문제 정의: 같은 뷰인가 다른 뷰인가

두 아이콘이 화면에 있다. 이 둘이 서로 다른 두 개의 뷰인지, 하나의 뷰가 상태만 바뀐 것인지에 따라 전이(transition)가 전혀 달라진다.

  • 다른 뷰라고 보면 → 페이드 인/아웃 같은 독립 전이
  • 같은 뷰라고 보면 → 위치만 슬라이드로 이동하는 연속 전이

이 구분을 view identity 라고 부른다.

같은 identity를 가진 뷰 = 하나의 개념적 UI 요소의 서로 다른 상태 다른 identity를 가진 뷰 = 구분되는 UI 요소들

SwiftUI는 identity 정보로 transition 경로와 state 유지 여부를 결정한다.

두 가지 identity

  • Explicit identity: 커스텀 식별자·data-driven ID. .id(_:) 모디파이어나 ForEach(_, id:)
  • Structural identity: 타입 구조와 위치로 암묵적으로 얻는 identity. if/else, switch, ViewBuilder의 조합

UIKit·AppKit이 쓰는 포인터 identity는 class 기반이라 가능한 것. SwiftUI view는 struct라서 포인터가 없고, 대신 값 자체가 아니라 "이 위치의 뷰"라는 구조적 identity에 기댄다.

Explicit identity: ForEach(_, id:).id(_:)

List {
    ForEach(rescueDogs, id: \.tagID) { dog in
        DogRow(dog: dog)
    }
}

tagID가 각 행의 identity가 된다. 컬렉션이 바뀌어도 SwiftUI는 tagID를 기준으로 무엇이 들어왔고 무엇이 나갔는지를 판단한다.

ScrollViewReader { proxy in
    ScrollView {
        Text("Header").id("header")
        // ...
    }
    Button("Scroll to Top") {
        proxy.scrollTo("header")
    }
}

.id("header")는 스크롤·애니메이션 대상 뷰를 명시적으로 참조하고 싶을 때 쓴다. 모든 뷰에 id를 붙일 필요는 없다 — 외부에서 가리켜야 할 때만.

Structural identity: ViewBuilder_ConditionalContent

if/else는 서로 다른 두 뷰의 자리를 구조적으로 구분한다.

var body: some View {
    if isAdoptingMode {
        AdoptionDirectory()
    } else {
        DogList()
    }
}

SwiftUI가 보는 타입은 실제로는 다음과 같다.

_ConditionalContent<AdoptionDirectory, DogList>

ViewBuilder가 로직 구문을 단일 generic 뷰로 조립해 준 것. some View는 이 복잡한 제네릭 타입을 감춰 주는 placeholder다.

중요한 결론 하나. true 분기와 false 분기는 영원히 다른 identity를 가진다. isAdoptingMode가 토글되면 기존 뷰는 파괴되고 새 뷰가 만들어진다.

같은 뷰, 상태만 바꾸기

위와 달리 단일 뷰의 상태만 바꾸는 쪽을 SwiftUI는 기본값으로 권장한다.

var body: some View {
    PawView()
        .foregroundColor(isGood ? .green : .red)
}

이쪽은 identity가 유지되므로 state도 유지되고 transition도 매끄럽다.

AnyView의 함정

AnyViewtype-erasing wrapper다. 뷰의 정적 타입 정보를 지워버려서 SwiftUI가 구조적 identity를 얻지 못하게 만든다.

// 안티패턴
func dogBreedView() -> AnyView {
    switch dog.breed {
    case .borderCollie:
        return AnyView(
            HStack {
                BorderCollieView()
                if nearSheep { SheepView() }
            }
        )
    case .pug:
        return AnyView(PugView())
    default:
        return AnyView(DefaultDogView())
    }
}

Swift는 함수의 단일 반환 타입을 요구하기 때문에 이렇게 쓰기 쉽다. 해결책은 @ViewBuilder attribute를 직접 붙이는 것.

@ViewBuilder
func dogBreedView() -> some View {
    switch dog.breed {
    case .borderCollie:
        HStack {
            BorderCollieView()
            if nearSheep { SheepView() }
        }
    case .pug:
        PugView()
    default:
        DefaultDogView()
    }
}

이제 타입 시그니처에 조건 분기 구조가 그대로 드러난다. SwiftUI가 각 분기의 identity를 구조적으로 식별할 수 있고, 컴파일러 경고/에러도 잘 잡힌다.

AnyView를 가능하면 쓰지 마라. 가독성도, 성능도, 타입 안전성도 모두 손해다.

용어 설명

  • type erasure: 구체적인 타입을 숨기고 프로토콜 껍질만 노출하는 기법. Swift에서는 AnyView, AnyPublisher 등이 대표적
  • ViewBuilder: Swift result builder의 한 종류. { Text(); Image(); if ... { ... } } 같은 DSL 구문을 하나의 generic View로 조립
  • _ConditionalContent: SwiftUI가 if/else의 두 분기를 담기 위해 쓰는 내부 generic 타입

Part 2: Lifetime (Luca)

view value vs view identity

view value는 일시적, identity는 지속적.

body 실행 시 SwiftUI는 뷰의 새 value를 만든다. intensity: 25로 만들어진 PurringViewintensity: 50으로 만들어진 PurringView는 value로는 서로 다른 두 개다. SwiftUI는 비교용으로 잠시 둘을 보관했다가 비교가 끝나면 value를 파괴한다.

하지만 identity는 연속이다. "이 뷰는 위치·타입 구조상 같은 자리"라는 사실은 유지된다.

뷰의 lifetime = identity가 유지되는 기간

@State·@StateObject와 lifetime

@State/@StateObject는 SwiftUI가 identity에 바인딩해서 영속 저장소를 제공하는 도구다.

  • identity가 처음 만들어질 때 초기값으로 저장소 할당
  • identity가 유지되는 동안 저장소 유지
  • identity가 바뀌면 저장소도 교체

identity가 바뀌면 state가 사라진다: 분기의 숨은 비용

var body: some View {
    if dayTime {
        VStack {
            @State var title = "Day"
            Text(title)
        }
    } else {
        VStack {
            @State var title = "Night"
            Text(title)
        }
    }
}

dayTime이 토글될 때마다 SwiftUI는 이전 분기의 저장소를 버리고 새 분기의 초기값으로 state를 다시 만든다. 분기를 잘못 쓰면 상태가 소리 없이 사라지는 이유가 여기 있다.

ForEach와 identity의 관계

ForEach(0..<dogs.count, id: \.self) { index in
    DogRow(dogs[index])
}

이 형태는 상수 범위일 때만 안전하다. 동적 범위로 쓰면 warning이 뜬다.

ForEach(rescueDogs, id: \.tagID) { dog in
    DogRow(dog: dog)
}

keypath가 hashable이면 SwiftUI는 그 값을 identity로 쓴다.

ForEach(rescueDogs) { dog in
    DogRow(dog: dog)
}

DogIdentifiable이면 keypath를 생략할 수 있다. Identifiable 채택이 SwiftUI 전반에 걸쳐 퍼포먼스와 정합성의 기반이 된다.


Part 3: Dependencies (Raj)

dependency = 뷰의 입력

struct DogView: View {
    var dog: Dog
    var treat: Treat
 
    var body: some View {
        Button("Give Treat") {
            dog.reward(with: treat)
        }
    }
}

dogtreat는 이 뷰의 dependency다. 어느 쪽이 바뀌면 body는 새 계층을 만들어야 한다. Buttonaction 클로저는 dependency 변경을 트리거하는 entry point.

트리가 아니라 그래프

뷰 계층은 겉보기엔 트리지만, 하나의 dependency에 여러 뷰가 동시에 의존하는 순간 그래프가 된다.

점선이 dependency. dog이 바뀌면 DogViewDogBadge만 invalidate된다. treat이 바뀌면 TreatViewDogView만. SwiftUI는 필요한 뷰만 재평가하고 나머지는 그대로 둔다.

이 dependency graph가 SwiftUI의 render tree다. Vitaly Batrakov가 Behind the scenes of UI Part 2에서 "Attribute Graph"라고 부르는 것과 사실상 같은 구조다. Chris Eidhof가 A Day in the Life of a SwiftUI View에서 시각화한 "render tree"도 마찬가지.

value type이 만드는 효율

뷰 struct는 비교용으로만 짧게 존재한다. SwiftUI는 이전 value와 새 value를 equatable하게 비교해서 실제로 바뀐 부분만 하위로 전파한다. 중간 뷰가 값이 동일하면 그 뷰의 body는 재평가되지 않는다.

이것이 "identity는 dependency graph의 척추"라는 문장의 의미다.


Part 4: Identity의 안정성과 유일성

불안정한 identifier의 폐해

struct Pet: Identifiable {
    var id = UUID()   // ❌ 생성 시마다 새 값
    var name: String
}

UUID()가 호출마다 새로 계산되면 매 렌더마다 identity가 갈아치워진다. 화면 전체가 깜빡이고 상태가 매번 초기화된다.

struct Pet: Identifiable {
    var id: Int  // DB에서 받은 안정적 값
    var name: String
}

persistent identifier 사용이 정답.

index 기반 identity의 함정

ForEach(pets.indices, id: \.self) { i in
    PetRow(pets[i])
}

맨 앞에 새 Pet을 insert하면 기존 index가 전부 어긋난다. SwiftUI는 "마지막 인덱스가 추가된 것"으로 잘못 판단해서 엉뚱한 자리에 삽입 애니메이션이 뜬다. index는 position-based라 unstable하다.

유일성: 같은 key를 가진 뷰 두 개 금지

struct Treat: Identifiable {
    var id: String { name }  // ❌ 같은 이름의 treat이 여러 개면 중복
    var name: String
    var emoji: String
}

중복 id 발생 시 SwiftUI가 일부 뷰를 렌더에서 누락할 수 있다. serial number나 UUID처럼 인스턴스별로 다른 값을 id로 써야 한다.

identifier의 두 조건

  • stable: 한 번 정해지면 바뀌지 않음
  • unique: 두 인스턴스가 같은 id를 공유하지 않음

Part 5: Structural identity (불필요한 분기 제거)

숨어 있는 if가 state를 죽인다

// 안티패턴
struct DimIfExpiredModifier: ViewModifier {
    var treat: Treat
 
    func body(content: Content) -> some View {
        if treat.isExpired {
            content.opacity(0.5)
        } else {
            content
        }
    }
}

isExpired가 바뀌면 _ConditionalContent의 분기가 갈아치워지면서 TreatCell의 state까지 함께 파괴된다.

inert modifier로 분기를 접기

var body: some View {
    TreatCell()
        .opacity(treat.isExpired ? 0.5 : 1.0)
}

opacity(1.0)inert modifier — 렌더 결과에 아무 영향이 없는 modifier. SwiftUI가 이런 modifier는 사실상 pruning해서 비용이 거의 없다. 분기 구조가 사라지면서 identity가 유지되고 state가 보존된다.

SwiftUI의 modifier는 대부분 싸다. 대신 if로 뷰 트리 구조 자체를 바꾸는 쪽이 훨씬 비싸다.

inert modifier 예시

  • .opacity(1)
  • .frame(width: nil, height: nil)
  • .disabled(false)
  • .hidden() vs 조건부 뷰 (후자가 더 비싸다)
  • .transformEnvironment(_:)로 조건부 environment 값 전달

핵심 요약

개념질문
IdentitySwiftUI가 두 뷰를 같다/다르다로 보는 기준은?explicit(.id, ForEach id:) + structural(타입·위치)
Lifetime@State가 언제부터 언제까지 살아 있나?identity가 유지되는 기간 = 뷰의 lifetime
Dependenciesbody가 언제 다시 실행되나?dependency graph에서 해당 뷰로 이어지는 입력 중 하나가 바뀔 때
성능 원칙identity는 왜 중요한가?invalidation 범위를 최소화 → body 재평가 범위를 최소화

관련 읽을거리

한국어 정리

  • naljin — [WWDC] Demystify SwiftUI — Identity. Medium — 세션의 Identity 파트를 Korean으로 꼼꼼하게 풀어 둠
  • naljin — [WWDC] Demystify SwiftUI — Lifetime. Medium — Lifetime 파트
  • Hassan Uriostegui — Demystify SwiftUI (영문이지만 짧은 퀵노트). Medium

같은 개념을 구현 레벨에서 보기

공식 자료

  • Apple Docs — ViewBuilder
  • Apple Docs — AnyView
  • WWDC19 #216 — SwiftUI Essentials (SwiftUI가 왜 value type을 쓰는지 배경)
  • WWDC24 #10144 / #10145 — 최신 SwiftUI 세션 (참고)

이 시리즈의 다음 편