원문: 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의 함정
AnyView는 type-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로 만들어진 PurringView와 intensity: 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)
}Dog가 Identifiable이면 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)
}
}
}dog와 treat는 이 뷰의 dependency다. 어느 쪽이 바뀌면 body는 새 계층을 만들어야 한다. Button의 action 클로저는 dependency 변경을 트리거하는 entry point.
트리가 아니라 그래프
뷰 계층은 겉보기엔 트리지만, 하나의 dependency에 여러 뷰가 동시에 의존하는 순간 그래프가 된다.
점선이 dependency. dog이 바뀌면 DogView와 DogBadge만 invalidate된다. treat이 바뀌면 TreatView와 DogView만. 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 값 전달
핵심 요약
| 개념 | 질문 | 답 |
|---|---|---|
| Identity | SwiftUI가 두 뷰를 같다/다르다로 보는 기준은? | explicit(.id, ForEach id:) + structural(타입·위치) |
| Lifetime | @State가 언제부터 언제까지 살아 있나? | identity가 유지되는 기간 = 뷰의 lifetime |
| Dependencies | body가 언제 다시 실행되나? | 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
같은 개념을 구현 레벨에서 보기
- Vitaly Batrakov — Behind the scenes of UI Part 2: SwiftUI — 이 세션의 "dependency graph"를 "Attribute Graph"·"render tree"라는 내부 이름으로 부르며,
_UIHostingView위에서 어떻게 render loop가 돌아가는지 - Omar Radwan — SwiftUI의 Identity · Lifetime · Dependencies — 이 세션의 개념을 더 간결한 예제로 재구성한 글의 번역
공식 자료
- Apple Docs — ViewBuilder
- Apple Docs — AnyView
- WWDC19 #216 — SwiftUI Essentials (SwiftUI가 왜 value type을 쓰는지 배경)
- WWDC24 #10144 / #10145 — 최신 SwiftUI 세션 (참고)
이 시리즈의 다음 편
- 3편 — Demystify SwiftUI Performance (WWDC23 #10160) — 2편에서 이론으로 본 identity·dependencies를 실제 최적화 관점에서 측정·진단·수정하는 매뉴얼