원문: A Day in the Life of a SwiftUI View by Chris Eidhof 행사: SwiftConf.to, Toronto, 2023.08.12 저자 프로필: objc.io 공동 창업자, "Thinking in SwiftUI" 공저자
Apple WWDC 네 편이 제공자 관점의 설명서라면, 이 발표는 사용자 관점의 재구성이다. Chris Eidhof는 objc.io에서 공동저자 Florian과 함께 SwiftUI의 state·layout 시스템을 직접 재구현해 본 경험이 있고, 그 과정에서 만든 멘탈 모델을 그림 100여 장으로 풀어낸다.
핵심 개념 한 줄.
뷰는 일시적이다. 하지만 SwiftUI가 내부에 유지하는 render tree(Attribute Graph)는 영속이다.

Part 1: View Tree와 Render Tree
우리가 body에 적는 것은 view tree — struct로 만들어지고 body 실행이 끝나면 사라지는 일시적 구조.
SwiftUI가 실제로 유지하는 것은 render tree(내부 이름: Attribute Graph). 이것은 영속적이다. body가 여러 번 재실행되어도 render tree는 살아남는다.

두 트리 사이의 규칙은 단순하다.
- view tree의 각 노드 → render tree의 한 노드로 1:1 번역
- view tree는 struct, render tree는 SwiftUI 내부 객체
@State는 render tree 노드에 연결된 저장소
Part 2: MyAsyncImage 만들기로 흐름 따라가기
Chris는 AsyncImage를 직접 구현하는 예제로 view tree와 render tree의 관계를 한 단계씩 보여 준다.
1단계: 뼈대
struct MyAsyncImage: View {
let url: URL
var body: some View {
Image(uiImage: UIImage())
}
}let url은 뷰의 input. 수정하지 않으니까 let.
2단계: @State를 render tree에 꽂다
struct MyAsyncImage: View {
let url: URL
@State private var imageData: Data?
var body: some View { /* ... */ }
}
@State를 선언하는 순간 SwiftUI는 render tree의 해당 노드에 이 property를 위한 메모리를 할당한다. struct가 사라졌다가 다시 만들어져도 이 저장소는 유지된다.
3단계: 조건부 콘텐츠
var body: some View {
if let imageData = imageData,
let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
} else {
ProgressView()
}
}
SwiftUI 내부에서 이 if/else는 _ConditionalContent<Image, ProgressView> 타입으로 조립되고, render tree에도 대응되는 ConditionalContent 노드가 생긴다. WWDC21 #10022에서 본 structural identity가 여기서 형태를 드러낸다.
4단계: .task로 비동기 로딩
modifier는 conditional content에 직접 붙일 수 없으므로 래퍼가 필요하다.
var body: some View {
ZStack {
if let imageData = imageData,
let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
} else {
ProgressView()
}
}
.task {
let (data, _) = try await URLSession.shared.data(from: url)
imageData = data
}
}
중요한 비밀:
.task와.onAppear는 같은 방식으로 작동한다. 뷰가 나타나는 순간 클로저 실행을 시작한다. 내부에didAppear같은 state가 있어서 재실행을 막는다.
Part 3: Render tree 업데이트 규칙

Chris가 한 프레임씩 보여 주는 업데이트 흐름:
- 초기 렌더: view tree 생성 → render tree로 1:1 변환
- 뷰 appear:
.task클로저 실행 시작 - 데이터 도착:
imageDatastate 변경 → body invalidate (discard 아님) - body 재실행: 새 view tree 생성
- diff: SwiftUI가 새 view tree를 기존 render tree와 비교
- 반영: 달라진 노드만 render tree에 반영, 자동으로 transition 수행

핵심: SwiftUI는 body를 재실행하되, render tree를 버리고 다시 만들지 않는다. 변경된 부분만 추려 업데이트한다. 이것이
@State가 유지되고 애니메이션이 자연스럽게 이어지는 비결.
Part 4: .task의 함정 (URL이 바뀌어도 재실행되지 않는다)
struct ContentView: View {
@State private var showPhoto = true
var body: some View {
VStack {
Button("Toggle") { showPhoto.toggle() }
MyAsyncImage(url: showPhoto ? logoURL : photoURL)
}
}
}버튼을 눌러 URL이 바뀌지만 이미지는 그대로 유지된다. 버그. 왜 일어날까?

부모가 MyAsyncImage에 새 URL을 넘기면 structure 수준에서는 같은 자리의 같은 타입이다. SwiftUI는 이 뷰의 render tree 노드를 유지한다. .task 클로저는 render tree 노드의 didAppear state에 의해 보호받아 다시 실행되지 않는다. 이전 imageData가 그대로 남아서 옛 이미지가 계속 보인다.
.task는 뷰가 appear할 때 한 번 실행된다. property가 바뀌어도 재실행되지 않는다. 이것이 의존성 문제의 근원.
해법 1: .id(url) (난폭함)
var body: some View {
ZStack { /* ... */ }
.id(url)
.task { /* ... */ }
}.id(url)은 explicit identity를 URL에 바인딩. URL이 바뀌면 SwiftUI는 render tree의 해당 서브트리를 통째로 버리고 새로 만든다.

새 노드가 만들어지면 didAppear가 초기화되고 .task도 다시 실행된다.
트레이드오프: render tree 전체가 파괴·재생성되므로 애니메이션이 끊기고 @State가 초기화된다. 낭비가 크다.
해법 2: .onChange(of:) (명시적)
var body: some View {
/* ... */
.onAppear { loadData() }
.onChange(of: url) { loadData() }
}iOS 17에서는 한 줄로 줄일 수 있다.
.onChange(of: url, initial: true) { loadData() }해법 3: .task(id:) (권장)
.task(id: url) {
let (data, _) = try await URLSession.shared.data(from: url)
imageData = data
}id 파라미터가 바뀌면 .task는 기존 task를 취소하고 클로저를 다시 시작한다. render tree 자체는 그대로 유지. structural identity는 건드리지 않으면서 task만 재실행.

세 해법 비교
.id(url)— 난폭함. state·animation 전부 초기화.onChange(of:)— 명시적. 직접 reload 호출.task(id:)— 가장 좋음. task만 재실행, 나머지는 유지
Part 5: 레이아웃 (Proposal-Reporting 시스템)
SwiftUI 레이아웃은 부모가 제안하고 자식이 보고하는 양방향 대화다.

- 부모가 자식에게 사이즈를 제안 (proposal)
- 자식은 제안을 보고 자기가 쓸 사이즈를 보고 (report)
- 부모는 보고받은 사이즈를 존중해서 자식을 배치
Image: 제안을 무시한다
resizable이 아닌 Image는 제안을 무시하고 intrinsic size(비트맵 고유 크기)를 그대로 보고한다.
Image(uiImage: image) // 제안 무시, 항상 비트맵 크기
nil × nil을 제안받으면 "네가 원하는 크기 보고해 달라"는 의미. Image는 이때 자기 고유 크기를 보고한다.
.resizable(): 제안을 받아들인다
Image(uiImage: image).resizable()이 modifier를 붙이면 Image는 부모의 제안에 맞춰 늘어나거나 줄어든다. nil × nil 제안 시에는 여전히 원본 크기로 돌아간다.
.aspectRatio(contentMode: .fit): 두 번 제안하는 modifier
resizable Image에 .aspectRatio(contentMode: .fit)를 붙이면 재밌는 일이 벌어진다.

- aspectRatio modifier는 Image의 비율을 모른다
- 먼저
nil × nil을 Image에 제안해 intrinsic size를 알아낸다 (예: 265 × 80) - 그 비율을 계산해서 부모가 준 제안 영역 안에 맞는 직사각형을 찾는다
- 그 크기를 다시 Image에 제안해 최종 렌더
modifier가 자식에게 두 번 질문한 셈. SwiftUI 레이아웃의 유연함은 이런 탐색적 제안에 기반한다.
커스텀 뷰에 resizable() 구현하기
MyAsyncImage에도 같은 패턴을 적용할 수 있다.
struct MyAsyncImage: View {
let url: URL
@State private var imageData: Data?
private var _resizable = false
var body: some View {
if let imageData = imageData,
let uiImage = UIImage(data: imageData) {
if _resizable {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(uiImage: uiImage)
}
} else {
ProgressView()
}
}
}
extension MyAsyncImage {
func resizable() -> MyAsyncImage {
var copy = self
copy._resizable = true
return copy
}
}standard SwiftUI API처럼 쓸 수 있다.
MyAsyncImage(url: photoURL)
.resizable()
.aspectRatio(contentMode: .fit)비결: _resizable은 private property지만 modifier-like 메서드가 struct를 복사·수정해서 돌려준다. SwiftUI의 modifier 패턴이 내부적으로 하는 일과 같다.
Part 6: Stack의 flexibility
HStack·VStack은 자식들에게 공간을 어떻게 배분할지 결정해야 한다. SwiftUI의 방식은 단순하다.

- 각 자식에게 0 크기를 제안해 최소 크기 측정
- 각 자식에게 매우 큰 크기를 제안해 최대 크기 측정
- (최대 − 최소)가 그 자식의 "flexibility"
- 덜 유연한 자식부터 공간을 배정
- 유연한 자식들은 남은 공간을 나눠 가진다
왜 이 순서인가: 고정 크기 뷰(
Text같은)는 자기 몫을 먼저 받고,Spacer나.frame(maxWidth: .infinity)같은 유연한 뷰들이 나머지를 채운다. 직관과 일치한다.
Part 7: Environment vs Preferences (양방향 흐름)
SwiftUI는 두 가지 방향의 정보 전달 메커니즘을 가진다.
Environment: 위→아래

부모가 .environment(\.key, value) 같이 값을 주입하면 render tree를 타고 내려가 모든 자손이 접근할 수 있다. @Environment(\.key)나 @Environment(Type.self)로 읽는다.
Preferences: 아래→위
반대로 자식이 조상에게 값을 전달하고 싶을 때는 preference를 쓴다. 자식이 preference(key:value:)로 발행하고, 조상이 onPreferenceChange로 수집해서 여러 자식의 값을 합친다.
- scroll view의 상단 오프셋을 부모가 알고 싶을 때
- 커스텀 toolbar 항목을 자식이 결정하고 부모가 렌더링할 때
| 방향 | 메커니즘 | 도구 |
|---|---|---|
| 위 → 아래 | Environment | .environment(...), @Environment(...) |
| 아래 → 위 | Preferences | preference(key:value:), onPreferenceChange |
이 발표가 WWDC 4편과 맞물리는 지점
| Chris의 개념 | 대응되는 WWDC 세션 |
|---|---|
| view tree(struct) = 일시, render tree(Attribute Graph) = 영속 | #10022 (identity/lifetime) + #10160 (dependency graph) |
@State는 render tree 노드의 저장소 | #10040 (source of truth) + #10022 (lifetime) |
.task가 재실행 안 됨 → .task(id:) | #10160 (slow update 방지) 문맥과 통함 |
| proposal-reporting layout | WWDC의 별도 레이아웃 세션들 (여기선 바로 풀어냄) |
| environment 위→아래, preference 아래→위 | #10040 @EnvironmentObject 섹션 + #10149 @Environment 재정리 |
이 발표의 가치는 네 편의 세션을 통합한 멘탈 모델을 한 번의 90분 강연으로 압축해 준다는 것. WWDC는 연도별로 끊겨 있고 일부만 보면 그림이 완성되지 않는데, Chris는 뷰 한 프레임이 살아가는 하루를 연속으로 보여 준다.
관련 읽을거리
Chris Eidhof의 다른 자료
- Thinking in SwiftUI (objc.io 출판) — 이 발표의 풀 버전 책. SwiftUI 멘탈 모델을 가장 체계적으로 다룬 외부 자료
- objc.io — Day in the Life of a View 글 버전 (오리지널 텍스트 포스트). chris.eidhof.nl
- A Day in the Life of a SwiftUI View 영상 버전 — 컨퍼런스 녹화 (YouTube 검색)
한국어 요약
- naljin·fatbobman 계열 블로그에 SwiftUI 내부 구조 정리글 다수. "SwiftUI Attribute Graph" / "SwiftUI render tree"로 검색하면 파생 자료들이 계속 나온다
이 시리즈 전체
- index — SwiftUI 렌더 트리와 리렌더링 심화
- 1편 — WWDC20 Data Essentials
- 2편 — WWDC21 Demystify SwiftUI
- 3편 — WWDC23 Demystify SwiftUI Performance
- 4편 — WWDC23 Discover Observation
- 6편 — Variadic Views in SwiftUI (Moving Parts)
다른 시리즈와의 연결
- Behind the scenes of UI 시리즈 — 이 시리즈가 "SwiftUI가 뷰를 어떻게 업데이트하는가"라면, 저쪽은 "그 업데이트가 실제 픽셀까지 어떻게 도달하는가". UIKit · Core Animation · Render Server · GPU까지.
- SwiftUI의 Identity · Lifetime · Dependencies — Omar Radwan의 글을 통해 Identity·Lifetime·Dependencies 개념을 복기