LODY/정리

애플 플랫폼 백과사전 / Demystify SwiftUI Performance (WWDC23 10160)

Demystify SwiftUI Performance (WWDC23 10160)

원문: Demystify SwiftUI Performance — WWDC23 #10160 공개: 2023년 6월

2021년 #10022가 이론이었다면, 이 세션은 진단 매뉴얼이다. 프로토타입에서는 안 보이던 성능 문제가 앱이 커지면서 갑자기 드러나는 순간, 어디를 먼저 찾아봐야 하는지 · 어떻게 측정하고 고치는지를 다룬다.

세션이 던지는 문제 해결 루프는 다음과 같다.

핵심은 "앱의 가정과 실제 동작 사이의 괴리"를 찾는 것. 그 괴리가 곧 버그다.


Part 1: Dependencies의 깊이 있는 재해석

각 뷰는 여러 dependency를 가진다

WWDC21 #10022에서 뷰의 입력(property)이 dependency라고 배웠다. 실제로는 dynamic property도 전부 dependency다.

struct DogView: View {
    @Environment(\.isPlayTime) private var isPlayTime
    var dog: Dog
 
    var body: some View {
        Text(dog.name)
            .font(nameFont)
        Text(dog.breed)
            .font(breedFont)
            .foregroundStyle(.secondary)
        ScalableDogImage(dog)
        DogDetailView(dog)
        LetsPlayButton()
            .disabled(dog.isTired)
    }
}

여기서 DogView의 dependency는 두 종류다.

  • 부모가 넘겨준 dog (뷰 값의 stored property)
  • @Environment(\.isPlayTime)가 environment graph에서 읽는 값

업데이트 프로세스를 시간축으로 풀면

  1. 새 view value 생성 — stored property들이 새 값으로 채워진다
  2. dynamic property 갱신 — graph에서 최신 environment·state 값을 가져와 property에 주입
  3. body 실행 — 자식 뷰를 만든다

이 과정이 재귀적으로 반복된다. 하지만 SwiftUI는 값이 바뀐 뷰만 재평가한다. value type이라 비교가 싸기 때문에 가능한 일.

_printChanges(): 의존성 추적의 결정적 도구

struct ScalableDogImage: View {
    @State private var scaleToFill = false
    var dog: Dog
 
    var body: some View {
        let _ = Self._printChanges()
        dog.image
            .resizable()
            .aspectRatio(contentMode: scaleToFill ? .fill : .fit)
            .frame(maxHeight: scaleToFill ? 500 : nil)
            .padding(.vertical, 16)
            .onTapGesture {
                withAnimation { scaleToFill.toggle() }
            }
    }
}

body 안에 let _ = Self._printChanges()를 넣거나, LLDB에서 expression Self._printChanges()를 치면 해당 뷰의 body가 왜 재평가됐는지가 콘솔에 찍힌다.

출력 예:

  • ScalableDogImage: @self — 뷰 값 자체가 바뀜 (stored property 중 하나가 달라짐)
  • ScalableDogImage: _scaleToFill@State가 바뀜
  • ScalableDogImage: @identity — 뷰가 새로 만들어짐 (identity 교체)

주의: _printChanges는 underscore-prefixed API다. 디버깅 전용이고 언제든 사라질 수 있다. 앱스토어 제출 빌드에 남기지 말 것. 런타임 비용도 있다.

불필요한 dependency 줄이기: 뷰 값을 최소 표면으로

ScalableDogImageDog 전체를 받는다면, Dog의 어느 필드가 바뀌든 이 뷰의 value가 새 값이 된다. 실제로는 이미지만 필요한데.

// Before
struct ScalableDogImage: View {
    var dog: Dog  // 과도한 의존성
    var body: some View {
        dog.image.resizable() // ...
    }
}
 
// After
struct ScalableDogImage: View {
    var image: Image  // 필요한 것만
    var body: some View {
        image.resizable() // ...
    }
}

호출부:

// Before
ScalableDogImage(dog)
 
// After
ScalableDogImage(dog.image)

이제 Dog.isTiredDog.breed가 바뀌어도 ScalableDogImage의 value는 그대로다 → 재평가 안 됨.

작은 서브뷰(예: DogHeader(name:breed:))로 쪼개는 것도 같은 원리. 뷰 값을 해당 뷰가 실제로 사용하는 데이터만으로 좁히면 dependency 표면이 줄어 재평가 횟수가 줄어든다.

세 가지 실천 규칙

  1. 뷰 값을 실제로 쓰는 데이터로만 축약
  2. 큰 뷰는 dependency 관점에서 작게 쪼개라
  3. Observable 매크로(4편)를 쓰면 body에서 실제로 읽은 property만 자동 추적된다

Part 2: Faster Updates

slow update의 3대 원인

  1. 비싼 dynamic property 인스턴스화@StateObject 초기화, @State 초기값 계산 등
  2. body 안의 비싼 work — 문자열 보간, 필터링, I/O
  3. 느린 identification — 특히 List/Table에서 ID 수집에 시간이 걸리는 경우

이들은 서로 얽혀 있다. body 안에서 계산되는 dynamic property는 body가 실행될 때마다 재계산된다.

안티패턴: body 안에서 데이터 로딩

// ❌ body가 실행될 때마다 fetch
struct DogRootView: View {
    @State private var model = FetchModel()
 
    var body: some View {
        DogList(model.dogs)
    }
}
 
@Observable class FetchModel {
    var dogs: [Dog] = []
 
    init() {
        fetchDogs() // body 진입 시점에 초기화되면서 블로킹
    }
 
    func fetchDogs() {
        // 오래 걸리는 동기 작업
    }
}

@State의 초기화가 body 실행 경로에서 일어나기 때문에, 뷰가 처음 뜰 때 메인 스레드가 막힌다.

수정: .task modifier로 비동기 분리

struct DogRootView: View {
    @State private var model = FetchModel()
 
    var body: some View {
        DogList(model.dogs)
            .task { await model.fetchDogs() }
    }
}
 
@Observable class FetchModel {
    var dogs: [Dog] = []
 
    init() {}  // 비싼 작업 빼기
 
    func fetchDogs() async {
        // 비동기로 옮김
    }
}

.task는 뷰가 나타날 때 비동기 작업을 시작하고, 뷰가 사라지면 자동으로 취소까지 해 준다. hang·hitch를 피하는 첫 번째 도구.

자주 놓치는 비싼 작업들

  • 문자열 보간"\(date.formatted()) - \(title)" 같은 코드를 body마다 돌리지 말 것. 캐싱
  • Bundle lookupBundle.main.url(forResource:) 같은 호출도 반복되면 비싸다
  • heap allocation — class 인스턴스를 body에서 매번 만들면 allocation 비용 누적

Part 3: List와 Table의 identity 메커니즘

List는 ID를 미리 다 수집한다

List {
    ForEach(dogs) {
        DogCell(dog: $0)
    }
}

List는 표시할 행 수와 각 행의 ID를 먼저 전부 파악해야 한다. 그래서 ForEach의 data collection을 eagerly 순회하면서 각 element의 ID를 모은다. 대신 각 cell의 content 자체는 on-demand — 화면에 보이는 부분 + 버퍼만 실제로 만들어진다.

여기서 핵심 규칙이 나온다.

ForEach의 content closure는 element 1개당 "상수 개수"의 뷰를 만들어야 List가 빠르다.

상수면 SwiftUI가 element 개수만 세면 ID 수를 계산할 수 있다. 비상수면 모든 뷰를 전부 만들어야 ID를 셀 수 있다 — eager evaluation 강제 → 느려진다.

안티패턴: ForEach 안의 조건부 뷰

// ❌ element당 뷰 수가 0 또는 1로 가변
List {
    ForEach(dogs) { dog in
        if dog.likesFetch {
            DogCell(dog)
        }
    }
}

likesFetch에 따라 element당 0개 혹은 1개의 뷰. SwiftUI가 ID를 세려면 모든 element의 조건을 계산해야 한다 → 전체를 미리 만들어야 한다.

AnyView를 쓰는 것도 같은 문제 — element당 뷰 수가 아예 미지수가 된다.

해법: 필터를 data 단에서

// ✅ 데이터 단에서 필터
let tennisBallDogs = dogs.filter { $0.likesFetch }
 
List {
    ForEach(tennisBallDogs) { dog in
        DogCell(dog)
    }
}

다만 body 안에서 filter하는 건 또 다른 안티패턴이다. 컬렉션이 커지면 매 재평가마다 O(n) 비용이 쌓인다.

// ❌ body 안에서 매번 필터
var body: some View {
    List {
        ForEach(model.dogs.filter { $0.likesFetch }) { dog in // 매 재평가마다
            DogCell(dog)
        }
    }
}

필터 결과는 model이 미리 캐시해 두는 게 맞다. 그럼 element당 뷰 수는 상수, filter는 필요할 때만.

중첩 ForEach의 예외: Section

중첩 ForEach는 일반적으로 피하는 게 맞지만, Section은 예외다.

List {
    ForEach(model.dogToys) { toy in
        Section(toy.name) {
            ForEach(model.dogs(toy: toy)) { dog in
                DogCell(dog)
            }
        }
    }
}

Section 안쪽의 ForEach는 SwiftUI가 특별히 이해한다. 섹션별 행 수가 동적이어도 빠르다.

Table의 ForEach 규칙

// iOS 17 이전
Table(of: Dog.self) {
    /* columns */
} rows: {
    ForEach(dogs) { dog in
        TableRow(dog)
    }
}
 
// iOS 17 이후 간결 문법 (이전 OS로도 back-deploy)
Table(of: Dog.self) {
    /* columns */
} rows: {
    ForEach(dogs)
}

TableRow는 항상 단일 행이므로 총 행 수 = element 수. ForEach(dogs) 형태로 줄여도 된다.

iOS 17의 의미 변경

Table(of: Dog.self) {
    /* columns */
} rows: {
    ForEach(dogs) { dog in
        TableRow(dog.bestFriend)
    }
}
  • iOS 16까지: 각 row의 ID가 dog.bestFriend.id
  • iOS 17부터: 각 row의 ID가 dog.id (ForEach 안쪽을 안 보고 결정)

성능 개선 목적이지만, 이전과 동작이 달라졌다. back-deploy가 필요하면 ForEach(dogs.map(\.bestFriend))로 매핑하거나 id keypath를 명시해서 과거 동작을 재현.

공식 한 줄

ForEach in List의 총 행 수 = element 수 × element당 뷰 수

element당 뷰 수가 상수여야 SwiftUI가 content를 만들지 않고도 ID를 셀 수 있다.


진단 체크리스트

증상먼저 확인할 곳
첫 화면 진입이 느리다body·init에서 동기 I/O 돌고 있지 않은지. .task로 옮기기
스크롤 끊김body 안의 문자열 보간·필터. ListForEach에 조건부 뷰 있는지
탭 후 반응이 늦다@StateObject 초기화 비용
뷰가 "자주" 다시 그려진다Self._printChanges()로 원인 파악 → 불필요한 dependency 좁히기
List 로딩이 느리다ForEach content의 뷰 수가 상수인지 확인. filter는 data 단으로
animation이 매끄럽지 않다identity가 안정적인지 — UUID() 같은 불안정 id 쓰고 있지 않은지

관련 읽을거리

한국어·영어 정리

성능 진단 도구

  • Fatbobman — Optimization and Debugging 컬렉션. fatbobman.com — SwiftUI 최적화 사례 모음
  • Apple Developer — Analyze Hangs in Instruments (WWDC23). developer.apple.com — Instruments로 hang 분석
  • Apple Developer — Explore UI animation hitches and the render loop (tech talk). developer.apple.com

이 시리즈의 다음 편