원문: 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에서 읽는 값
업데이트 프로세스를 시간축으로 풀면
- 새 view value 생성 — stored property들이 새 값으로 채워진다
- dynamic property 갱신 — graph에서 최신 environment·state 값을 가져와 property에 주입
- 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 줄이기: 뷰 값을 최소 표면으로
ScalableDogImage가 Dog 전체를 받는다면, 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.isTired나 Dog.breed가 바뀌어도 ScalableDogImage의 value는 그대로다 → 재평가 안 됨.
작은 서브뷰(예: DogHeader(name:breed:))로 쪼개는 것도 같은 원리. 뷰 값을 해당 뷰가 실제로 사용하는 데이터만으로 좁히면 dependency 표면이 줄어 재평가 횟수가 줄어든다.
세 가지 실천 규칙
- 뷰 값을 실제로 쓰는 데이터로만 축약
- 큰 뷰는 dependency 관점에서 작게 쪼개라
- 새
Observable매크로(4편)를 쓰면body에서 실제로 읽은 property만 자동 추적된다
Part 2: Faster Updates
slow update의 3대 원인
- 비싼 dynamic property 인스턴스화 —
@StateObject초기화,@State초기값 계산 등 body안의 비싼 work — 문자열 보간, 필터링, I/O- 느린 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 lookup —
Bundle.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를 명시해서 과거 동작을 재현.
공식 한 줄
ForEachin List의 총 행 수 = element 수 × element당 뷰 수element당 뷰 수가 상수여야 SwiftUI가 content를 만들지 않고도 ID를 셀 수 있다.
진단 체크리스트
| 증상 | 먼저 확인할 곳 |
|---|---|
| 첫 화면 진입이 느리다 | body·init에서 동기 I/O 돌고 있지 않은지. .task로 옮기기 |
| 스크롤 끊김 | body 안의 문자열 보간·필터. List의 ForEach에 조건부 뷰 있는지 |
| 탭 후 반응이 늦다 | @StateObject 초기화 비용 |
| 뷰가 "자주" 다시 그려진다 | Self._printChanges()로 원인 파악 → 불필요한 dependency 좁히기 |
| List 로딩이 느리다 | ForEach content의 뷰 수가 상수인지 확인. filter는 data 단으로 |
| animation이 매끄럽지 않다 | identity가 안정적인지 — UUID() 같은 불안정 id 쓰고 있지 않은지 |
관련 읽을거리
한국어·영어 정리
- wwdcnotes — Demystify SwiftUI Performance. wwdcnotes.com — 섹션별 정리본
- Omar Radwan — SwiftUI의 Identity · Lifetime · Dependencies — 이 세션의 개념을 2021 세션과 통합 정리한 글의 번역
- Michael Rowe — Demystify SwiftUI Performance. michaelrowe01.com — 실무 적용 팁
성능 진단 도구
- 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
이 시리즈의 다음 편
- 4편 — Discover Observation in SwiftUI (WWDC23 #10149) — 본 세션이 "dependency를 좁혀라"라고 말했다면, 그 다음 세션은 Apple이 언어 차원에서 dependency 추적을 granular하게 바꿔버린 매크로를 소개한다