LODY/정리

애플 플랫폼 백과사전 / A Day in the Life of a SwiftUI View

A Day in the Life of a SwiftUI View

원문: 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)는 영속이다.

첫 슬라이드 — ContentView 기본 예제


Part 1: View Tree와 Render Tree

우리가 body에 적는 것은 view tree — struct로 만들어지고 body 실행이 끝나면 사라지는 일시적 구조.

SwiftUI가 실제로 유지하는 것은 render tree(내부 이름: Attribute Graph). 이것은 영속적이다. body가 여러 번 재실행되어도 render tree는 살아남는다.

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가 render tree에 메모리를 할당

@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()
    }
}

조건부 콘텐츠가 ConditionalContent 노드를 만든다

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가 render tree의 didAppear 상태를 확인

중요한 비밀: .task.onAppear는 같은 방식으로 작동한다. 뷰가 나타나는 순간 클로저 실행을 시작한다. 내부에 didAppear 같은 state가 있어서 재실행을 막는다.


Part 3: Render tree 업데이트 규칙

body 재평가 시 view tree가 새로 만들어진다

Chris가 한 프레임씩 보여 주는 업데이트 흐름:

  1. 초기 렌더: view tree 생성 → render tree로 1:1 변환
  2. 뷰 appear: .task 클로저 실행 시작
  3. 데이터 도착: imageData state 변경 → body invalidate (discard 아님)
  4. body 재실행: 새 view tree 생성
  5. diff: SwiftUI가 새 view tree를 기존 render tree와 비교
  6. 반영: 달라진 노드만 render tree에 반영, 자동으로 transition 수행

새·옛 트리 diff 후 자동 삽입/삭제 (애니메이션 동반)

핵심: 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이 바뀌지만 이미지는 그대로 유지된다. 버그. 왜 일어날까?

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의 해당 서브트리를 통째로 버리고 새로 만든다.

.id가 서브트리를 통째로 교체

새 노드가 만들어지면 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만 재실행.

task(id:)가 render tree를 그대로 둔 채 task만 다시 시작

세 해법 비교

  • .id(url) — 난폭함. state·animation 전부 초기화
  • .onChange(of:) — 명시적. 직접 reload 호출
  • .task(id:) — 가장 좋음. task만 재실행, 나머지는 유지

Part 5: 레이아웃 (Proposal-Reporting 시스템)

SwiftUI 레이아웃은 부모가 제안하고 자식이 보고하는 양방향 대화다.

레이아웃 기초

  1. 부모가 자식에게 사이즈를 제안 (proposal)
  2. 자식은 제안을 보고 자기가 쓸 사이즈를 보고 (report)
  3. 부모는 보고받은 사이즈를 존중해서 자식을 배치

Image: 제안을 무시한다

resizable이 아닌 Image는 제안을 무시하고 intrinsic size(비트맵 고유 크기)를 그대로 보고한다.

Image(uiImage: image)   // 제안 무시, 항상 비트맵 크기

resizable이 아닌 Image는 제안을 무시

nil × nil을 제안받으면 "네가 원하는 크기 보고해 달라"는 의미. Image는 이때 자기 고유 크기를 보고한다.

.resizable(): 제안을 받아들인다

Image(uiImage: image).resizable()

이 modifier를 붙이면 Image는 부모의 제안에 맞춰 늘어나거나 줄어든다. nil × nil 제안 시에는 여전히 원본 크기로 돌아간다.

.aspectRatio(contentMode: .fit): 두 번 제안하는 modifier

resizable Image에 .aspectRatio(contentMode: .fit)를 붙이면 재밌는 일이 벌어진다.

aspectRatio modifier의 동작

  1. aspectRatio modifier는 Image의 비율을 모른다
  2. 먼저 nil × nil을 Image에 제안해 intrinsic size를 알아낸다 (예: 265 × 80)
  3. 그 비율을 계산해서 부모가 준 제안 영역 안에 맞는 직사각형을 찾는다
  4. 그 크기를 다시 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의 방식은 단순하다.

Stack의 flexibility 측정

  1. 각 자식에게 0 크기를 제안해 최소 크기 측정
  2. 각 자식에게 매우 큰 크기를 제안해 최대 크기 측정
  3. (최대 − 최소)가 그 자식의 "flexibility"
  4. 덜 유연한 자식부터 공간을 배정
  5. 유연한 자식들은 남은 공간을 나눠 가진다

왜 이 순서인가: 고정 크기 뷰(Text 같은)는 자기 몫을 먼저 받고, Spacer.frame(maxWidth: .infinity) 같은 유연한 뷰들이 나머지를 채운다. 직관과 일치한다.


Part 7: Environment vs Preferences (양방향 흐름)

SwiftUI는 두 가지 방향의 정보 전달 메커니즘을 가진다.

Environment: 위→아래

Environment는 부모에서 자식으로 흐른다

부모가 .environment(\.key, value) 같이 값을 주입하면 render tree를 타고 내려가 모든 자손이 접근할 수 있다. @Environment(\.key)@Environment(Type.self)로 읽는다.

Preferences: 아래→위

반대로 자식이 조상에게 값을 전달하고 싶을 때는 preference를 쓴다. 자식이 preference(key:value:)로 발행하고, 조상이 onPreferenceChange로 수집해서 여러 자식의 값을 합친다.

  • scroll view의 상단 오프셋을 부모가 알고 싶을 때
  • 커스텀 toolbar 항목을 자식이 결정하고 부모가 렌더링할 때
방향메커니즘도구
위 → 아래Environment.environment(...), @Environment(...)
아래 → 위Preferencespreference(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 layoutWWDC의 별도 레이아웃 세션들 (여기선 바로 풀어냄)
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"로 검색하면 파생 자료들이 계속 나온다

이 시리즈 전체

다른 시리즈와의 연결