2부 목표 · 19분
- 버벅이는 화면에서
Self._printChanges()로 body가 다시 돈 이유(@self/_프로퍼티)를 읽는다. - 모델 전체를 받는 뷰, 배열 전체를 간접으로 읽는 행을 찾아 의존성을 좁힌다.
- body 안의 포매터 생성·필터 같은 일을 모델로 옮기고, 히치가 생기는 이유를 프레임 마감으로 설명한다.
- lazy stack 수칙(onAppear로 모양 바꾸지 않기, 행 모델 init은 싸게)을 Day 1·Day 3 규칙과 함께 적용한다.
1. 성능 ① 의존성 — "왜 body가 또 돌았나"
UIKit에서는 브레이크포인트의 백트레이스로 원인을 찾았다. WWDC25 · Optimize SwiftUI performance with Instruments는 SwiftUI에서는 이게 안 통한다고 말한다. SwiftUI는 선언형이라 백트레이스에는 AttributeGraph 내부 프레임만 보인다. 올바른 질문은 "무엇이 내 body를 outdated로 표시했나"다.
_printChanges — 가장 값싼 첫 도구
Self._printChanges()는 SwiftUI가 이 뷰의 body를 요청한 이유를 콘솔에 찍는다. 출력에서 @self는 "뷰 구조체 값(저장 프로퍼티)이 바뀜", _이름은 "그 동적 프로퍼티(@State 등)가 바뀜"을 뜻한다(10160).
import SwiftUI
struct CounterView: View {
@State private var count = 0
var body: some View {
let _ = Self._printChanges() // 예: "CounterView: _count changed."
Button("탭 \(count)") { count += 1 }
}
}밑줄로 시작하는 API는 언제든 사라질 수 있고, 런타임 비용도 있다. Apple은 "never submit a call to this method to the App Store"라고 못 박는다(10160). 앱스토어에 올리는 코드에 남기지 않는다.
필요한 것만 넘겨 의존성 좁히기
10160의 예: 이미지만 쓰는 뷰에 Dog 전체를 넘기면, 좋아하는 간식이 바뀌어도 @self changed가 찍히며 body가 돈다. 실제로 쓰는 값만 넘기면 끝난다.
import SwiftUI
struct Dog {
var name: String
var favoriteTreat: String
var image: Image
}
struct ScalableDogImage: View {
let dog: Dog // 필요한 건 image 하나뿐
@State private var scaleToFill = false
var body: some View {
dog.image
.resizable()
.aspectRatio(contentMode: scaleToFill ? .fill : .fit)
.onTapGesture { scaleToFill.toggle() }
}
}import SwiftUI
struct ScalableDogImage: View {
let image: Image
@State private var scaleToFill = false
var body: some View {
image
.resizable()
.aspectRatio(contentMode: scaleToFill ? .fill : .fit)
.onTapGesture { scaleToFill.toggle() }
}
}
// 부모: ScalableDogImage(image: dog.image)@Observable도 "읽은 것"에 의존한다 — 간접 읽기 주의
WWDC25 306의 Landmarks 예: 각 행이 model.isFavorite(landmark)를 호출했고, 그 함수가 favorites 배열 전체를 읽었다. 그래서 하트 하나를 누르면 화면의 모든 행 body가 다시 돌았다.
import SwiftUI
struct Landmark: Identifiable, Hashable {
let id: Int
var name: String
}
@Observable final class ModelData {
var favorites: [Landmark] = []
func isFavorite(_ landmark: Landmark) -> Bool {
favorites.contains(landmark) // 배열 전체 읽기 = 배열 전체에 의존
}
func toggleFavorite(_ landmark: Landmark) {
if let i = favorites.firstIndex(of: landmark) { favorites.remove(at: i) }
else { favorites.append(landmark) }
}
}
struct LandmarkRow: View {
@Environment(ModelData.self) private var model
let landmark: Landmark
var body: some View {
HStack {
Text(landmark.name)
Spacer()
Button {
model.toggleFavorite(landmark)
} label: {
Image(systemName: model.isFavorite(landmark) ? "heart.fill" : "heart")
}
}
}
}import SwiftUI
struct Landmark: Identifiable, Hashable {
let id: Int
var name: String
}
@Observable final class LandmarkRowModel: Identifiable {
let landmark: Landmark
var isFavorite = false
var id: Int { landmark.id }
init(landmark: Landmark) { self.landmark = landmark }
}
@Observable final class ModelData {
let rows: [LandmarkRowModel]
var favorites: [Landmark] { rows.filter(\.isFavorite).map(\.landmark) }
init(landmarks: [Landmark]) {
rows = landmarks.map(LandmarkRowModel.init)
}
}
struct LandmarkRow: View {
let row: LandmarkRowModel // 자기 모델만 읽는다
var body: some View {
HStack {
Text(row.landmark.name)
Spacer()
Button {
row.isFavorite.toggle()
} label: {
Image(systemName: row.isFavorite ? "heart.fill" : "heart")
}
}
}
}
struct LandmarkList: View {
let model: ModelData
var body: some View {
List(model.rows) { row in LandmarkRow(row: row) }
}
}@Environment로 무언가를 읽는 뷰는 환경의 어떤 값이 바뀌어도 "내가 읽는 값이 바뀌었나" 확인 비용을 낸다. body를 다시 돌리지 않더라도 이 확인이 쌓인다. 그래서 Apple은 geometry 값이나 타이머처럼 매우 자주 바뀌는 값을 환경에 넣지 말라고 한다(WWDC25 306).
2. 성능 ② 느린 업데이트와 Instruments
화면이 60/120Hz로 그려지려면 매 프레임 마감 전에 이벤트 처리와 UI 업데이트(바뀐 뷰의 body 실행 포함)가 끝나야 한다. 한 번의 긴 body, 또는 짧지만 너무 많은 body가 마감을 넘기면 이전 프레임이 한 번 더 보인다. 이것이 히치(hitch)다(WWDC25 306).
느린 업데이트의 흔한 원인 (WWDC23 10160)
.task에서 async로import SwiftUI
struct DistanceRow: View {
let name: String
let meters: Double
private var distance: String {
let number = NumberFormatter()
number.maximumFractionDigits = 1
let formatter = MeasurementFormatter()
formatter.numberFormatter = number
return formatter.string(from: Measurement(value: meters, unit: UnitLength.meters))
}
var body: some View {
Text("\(name) · \(distance)") // 스크롤 중 수십 행 × 매번 생성
}
}import SwiftUI
@Observable final class DistanceCache {
@ObservationIgnored private let formatter: MeasurementFormatter = {
let number = NumberFormatter()
number.maximumFractionDigits = 1
let formatter = MeasurementFormatter()
formatter.numberFormatter = number
return formatter
}()
private(set) var texts: [Int: String] = [:] // landmark id → "1.2 km"
func update(meters: [Int: Double]) { // 위치가 바뀔 때만 호출
texts = meters.mapValues {
formatter.string(from: Measurement(value: $0, unit: UnitLength.meters))
}
}
}
struct DistanceRow: View {
let id: Int
let name: String
let cache: DistanceCache
var body: some View {
Text("\(name) · \(cache.texts[id] ?? "—")") // 읽기만
}
}Instruments 26의 SwiftUI 템플릿은 SwiftUI·Time Profiler·Hangs·Hitches를 한 번에 기록한다. 레인은 Update Groups(SwiftUI가 일하는 구간)와 Long View Body Updates / Long Representable Updates / Other Long Updates이고, 주황·빨강이 히치·행 위험이 큰 업데이트다. 기록하려면 Xcode 26과 SwiftUI 트레이스를 지원하는 최신 OS 기기가 필요하다(WWDC25 306).
순서: ⌘I(Release 빌드) → SwiftUI 템플릿 → 문제 동작 녹화 → Long View Body Updates에서 뷰 선택 → Set Inspection Range → Time Profiler에서 그 구간 콜스택 확인. "왜 갱신됐나"는 Cause & Effect Graph가 보여준다(예: Gesture → @Observable 변경 → 행 body ×N). 자세한 사용법은 Understanding and improving SwiftUI performance.
WWDC26 · Dive into lazy stacks and scrolling가 정리한 lazy stack 수칙이다. 동작 원리에 관한 내용이라 iOS 17 코드에도 그대로 적용된다.
· 행 뷰 안에서 조건으로 데이터를 거르지 않는다. 요소당 뷰가 0~1개가 되면 lazy stack이 인덱스 계산을 위해 지나간 행을 더 오래 붙잡고, 화면 밖 행의 body가 다시 돌 수 있다. 데이터 단계(모델, SwiftData Predicate)에서 거른다. body에서 옵셔널을 풀어 조건부로 그리는 것도 같은 효과다.
· 행 준비를 onAppear로 미루지 않는다. lazy stack은 보이기 전에 body와 레이아웃을 미리(prefetch) 계산한다. onAppear에서 내용과 크기를 바꾸면 그 준비가 버려진다. 무한 스크롤의 "마지막 행에서 다음 페이지 로드" 같은 용도는 괜찮다.
· 화면 밖으로 나간 행의 @State는 결국 사라진다. 남아야 할 상태는 모델이나 바깥 뷰의 Binding으로 옮긴다.
· 절대 content offset·content size에 기대지 않는다. 추정값이라 흔들린다. "몇 번째 행이 보이는가" 같은 상대 정보를 쓴다.
import SwiftUI
@Observable final class StepModel {
let id: Int
var title: String
init(id: Int) { // 가벼운 준비만: 캐시 조회 등
self.id = id
self.title = "Step \(id)"
}
}
struct StepRow: View {
@State private var model: StepModel
init(id: Int) {
_model = State(initialValue: StepModel(id: id))
}
var body: some View {
Text(model.title)
.frame(maxWidth: .infinity, minHeight: 60)
}
}충돌하지 않는다. 세 규칙은 같은 사실에서 나온다 — 뷰의 init은 부모가 갱신될 때마다 다시 불리고, State(initialValue:)로 넘긴 객체는 첫 번째 것만 살아남는다(Day 3). 그래서 여기서도 StepModel.init은 캐시 조회 정도로 싸게 유지한다. 네트워크 요청·디코딩 같은 무거운 일을 넣으면 버려질 객체를 위해 매번 비용을 치른다(Day 1). 세션의 요지는 "모양과 크기를 onAppear에서 바꾸지 말라"이지, "init에서 무거운 일을 하라"가 아니다.
Preview는 Debug 빌드라 성능 판단 근거가 못 된다. _printChanges로 갱신 횟수는 Preview 콘솔에서도 확인할 수 있지만, 시간은 반드시 기기에서 ⌘I(Release)로 잰다.
✅ 복습 · 5분
@Observable 모델을 쓰는데 하트 하나를 누르면 모든 행의 body가 다시 돈다. 왜 그렇고, 어떻게 고치나?
model.isFavorite(landmark)를 호출했고, 그 함수가 favorites 배열 전체를 읽었다. @Observable은 body가 실행되는 동안 읽은 프로퍼티를 추적하므로, 함수 안의 간접 읽기도 의존성이 된다. 그래서 배열이 바뀌면 모든 행이 갱신된다. 행마다 자기 @Observable 모델(isFavorite 프로퍼티)을 주면 바뀐 행만 다시 돈다(WWDC25 306).body 하나가 1ms로 짧은데도 히치가 생길 수 있는 이유는?
🔮 코드 예측: 위 CounterView에서 버튼을 두 번 누르면 콘솔에 무엇이 찍히나? (처음 표시될 때 한 줄은 제외)
CounterView: _count changed.가 두 번 찍힌다. body를 다시 부른 원인이 @State 프로퍼티 count(저장 이름 _count)의 변경이기 때문이다. 부모가 새 값을 넘겨서 다시 돌았다면 @self changed가 찍힌다.🔁 1부 복습: LazyVStack에서 절대 content offset이나 content size에 기대는 코드가 흔들리는 이유는?
scrollPosition(id:))를 쓴다.🛠 미니 과제 (선택 · 10분)
아래 시작 코드는 동작하지만 두 가지 규칙을 어긴다. Preview 하나로 고친다.
import SwiftUI
struct Card: Identifiable {
let id: Int
var isHidden: Bool { id % 3 == 0 }
}
struct CardShelf: View {
let cards = (0..<30).map(Card.init)
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(cards) { card in
if !card.isHidden {
RoundedRectangle(cornerRadius: 12).fill(.blue)
.frame(width: 260, height: 160)
.overlay(Text("\(card.id)").foregroundStyle(.white))
}
}
}
}
.padding(.horizontal, 20)
}
}
#Preview { CardShelf() }요구사항
- 숨김 카드는 데이터 단계에서 걸러 요소당 뷰 1개로 만든다.
- 양옆 여백은 스크롤뷰를 줄이지 않고 준다(다음 카드가 살짝 보이게).
- 손을 떼면 카드 앞 모서리에 멈추고, 화면 위쪽에 현재 카드 번호를 표시한다.
확인 방법: Preview에서 스와이프 후 카드가 여백 선에 딱 맞춰 멈추는지, 번호 텍스트가 따라 바뀌는지 본다. 스킴에 -LogForEachSlowPath YES를 넣고 시뮬레이터로 돌리면 고치기 전후 로그 차이도 볼 수 있다.
정답 코드 보기
import SwiftUI
struct Card: Identifiable {
let id: Int
var isHidden: Bool { id % 3 == 0 }
}
struct CardShelf: View {
private let visibleCards = (0..<30).map(Card.init).filter { !$0.isHidden } // 한 번만
@State private var currentID: Card.ID?
var body: some View {
VStack(alignment: .leading) {
Text("현재 카드: \(currentID.map(String.init) ?? "-")")
.padding(.horizontal, 20)
ScrollView(.horizontal) {
LazyHStack {
ForEach(visibleCards) { card in
RoundedRectangle(cornerRadius: 12).fill(.blue)
.frame(width: 260, height: 160)
.overlay(Text("\(card.id)").foregroundStyle(.white))
}
}
.scrollTargetLayout()
}
.contentMargins(.horizontal, 20, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $currentID)
}
}
}
#Preview { CardShelf() }