← SwiftUI 14일 마스터 9 / 14 · 2부

Day 9 · 2부 · 성능 진단 루프

느려진 목록에서 body가 다시 돈 이유를 찾고, 의존성을 좁히고 무거운 일을 모델로 옮긴 뒤 Instruments로 다시 재는 루프를 익힌다.
진행률
0 / 0 완료
1부에서 목록을 만드는 도구와 "요소당 뷰 개수" 규칙을 봤다. 2부는 그래도 버벅일 때 원인을 좁히는 순서다. UIKit처럼 백트레이스를 따라가는 대신 "무엇이 이 body를 다시 돌게 했나"를 묻고, 의존성을 좁히고, body를 가볍게 만든 뒤 Instruments로 다시 잰다.

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로 표시했나"다.

증상 측정 원인 식별 최적화 재측정 아직 느리면 히치 · 행 · 느린 push Instruments (⌘I) _printChanges Cause & Effect 의존성↓ · body↓ 통과 → 루프 탈출
WWDC23 10160의 피드백 루프. 추측으로 고치지 말고, 고친 뒤에는 반드시 같은 조건으로 다시 잰다.

_printChanges — 가장 값싼 첫 도구

Self._printChanges()는 SwiftUI가 이 뷰의 body를 요청한 이유를 콘솔에 찍는다. 출력에서 @self는 "뷰 구조체 값(저장 프로퍼티)이 바뀜", _이름은 "그 동적 프로퍼티(@State 등)가 바뀜"을 뜻한다(10160).

디버그 전용 — body 맨 앞에 한 줄 (ViewBuilder 안이라 let _ = 형태)
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가 다시 돌았다.

❌ 모두가 배열 전체에 의존 ✅ 행마다 자기 모델 favorites: [Landmark] Row 1 Row 2 Row 3 Row 4 Row 3의 하트를 누르면 body 4번 실행 모델1 모델2 모델3 모델4 Row 1 Row 2 Row 3 Row 4 Row 3의 하트를 누르면 body 1번 실행
@Observable은 body가 읽은 프로퍼티를 추적한다. 함수 안에서 간접적으로 읽어도 똑같이 의존성이 생긴다.
❌ 행마다 favorites 배열 전체를 (함수로 간접) 읽는다
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")
            }
        }
    }
}
✅ 행마다 자기 @Observable 모델 — 바뀐 행만 갱신
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에 두지 않는다

@Environment로 무언가를 읽는 뷰는 환경의 어떤 값이 바뀌어도 "내가 읽는 값이 바뀌었나" 확인 비용을 낸다. body를 다시 돌리지 않더라도 이 확인이 쌓인다. 그래서 Apple은 geometry 값이나 타이머처럼 매우 자주 바뀌는 값을 환경에 넣지 말라고 한다(WWDC25 306).

2. 성능 ② 느린 업데이트와 Instruments

화면이 60/120Hz로 그려지려면 매 프레임 마감 전에 이벤트 처리와 UI 업데이트(바뀐 뷰의 body 실행 포함)가 끝나야 한다. 한 번의 긴 body, 또는 짧지만 너무 많은 body가 마감을 넘기면 이전 프레임이 한 번 더 보인다. 이것이 히치(hitch)다(WWDC25 306).

마감 마감 마감 마감 정상 이벤트 업데이트 히치 긴 body 제출 없음 → 이전 프레임이 두 번 보인다 = 히치
마감을 넘긴 업데이트는 다음 프레임까지 밀린다. body 1ms도 화면에 뷰가 많으면 합쳐져서 마감을 넘긴다.

느린 업데이트의 흔한 원인 (WWDC23 10160)

① 비싼 동적 프로퍼티 초기화
@State 초기값으로 무거운 객체 생성, 동기 데이터 로딩 → .task에서 async로
② body 안의 일
필터·정렬, 비싼 문자열 보간, 포매터 생성, Bundle 조회, 힙 할당 → 모델에서 미리 계산·캐시
③ 느린 식별
요소당 뷰 개수가 일정하지 않아 List가 모든 행을 만듦 → 1부 2절 규칙
④ 과한 의존성
안 쓰는 데이터까지 읽어 갱신이 잦음 → 1절처럼 좁히기
❌ 행 body가 돌 때마다 포매터 두 개를 새로 만든다 (WWDC25 306의 실제 병목)
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] ?? "—")")   // 읽기만
    }
}
🆕 SwiftUI Instrument Xcode 26

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.

🆕 lazy stack 심층 WWDC26

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에 기대지 않는다. 추정값이라 흔들린다. "몇 번째 행이 보이는가" 같은 상대 정보를 쓴다.

✅ 행 상태는 init에서 준비 (가볍게) — onAppear에서 모양을 바꾸지 않는다
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)
    }
}
⚠️ Day 1·Day 3 규칙과 충돌하지 않나?

충돌하지 않는다. 세 규칙은 같은 사실에서 나온다 — 뷰의 init은 부모가 갱신될 때마다 다시 불리고, State(initialValue:)로 넘긴 객체는 첫 번째 것만 살아남는다(Day 3). 그래서 여기서도 StepModel.init은 캐시 조회 정도로 싸게 유지한다. 네트워크 요청·디코딩 같은 무거운 일을 넣으면 버려질 객체를 위해 매번 비용을 치른다(Day 1). 세션의 요지는 "모양과 크기를 onAppear에서 바꾸지 말라"이지, "init에서 무거운 일을 하라"가 아니다.

👀 Preview 팁

Preview는 Debug 빌드라 성능 판단 근거가 못 된다. _printChanges로 갱신 횟수는 Preview 콘솔에서도 확인할 수 있지만, 시간은 반드시 기기에서 ⌘I(Release)로 잰다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
@Observable 모델을 쓰는데 하트 하나를 누르면 모든 행의 body가 다시 돈다. 왜 그렇고, 어떻게 고치나?
각 행이 model.isFavorite(landmark)를 호출했고, 그 함수가 favorites 배열 전체를 읽었다. @Observable은 body가 실행되는 동안 읽은 프로퍼티를 추적하므로, 함수 안의 간접 읽기도 의존성이 된다. 그래서 배열이 바뀌면 모든 행이 갱신된다. 행마다 자기 @Observable 모델(isFavorite 프로퍼티)을 주면 바뀐 행만 다시 돈다(WWDC25 306).
쉽게 말하면 반 전체 출석부 한 장을 모두가 들여다보면, 한 칸만 고쳐도 모두가 다시 확인해야 한다. 각자 자기 카드를 가지면 고친 사람만 확인하면 된다.
body 하나가 1ms로 짧은데도 히치가 생길 수 있는 이유는?
프레임마다 마감 전에 이벤트 처리와 바뀐 모든 뷰의 body 실행이 끝나야 한다. 하나는 짧아도 의존성이 넓어 수십~수백 개 body가 한꺼번에 돌면 합이 마감을 넘기고, 이전 프레임이 한 번 더 보인다. 그래서 body를 가볍게 하는 것과 함께 갱신되는 뷰 수(의존성)를 줄여야 한다.
쉽게 말하면 한 명당 1초짜리 숙제라도 100명이 한 줄로 검사받으면 쉬는 시간 안에 못 끝난다.
🔮 코드 예측: 위 CounterView에서 버튼을 두 번 누르면 콘솔에 무엇이 찍히나? (처음 표시될 때 한 줄은 제외)
CounterView: _count changed.가 두 번 찍힌다. body를 다시 부른 원인이 @State 프로퍼티 count(저장 이름 _count)의 변경이기 때문이다. 부모가 새 값을 넘겨서 다시 돌았다면 @self changed가 찍힌다.
쉽게 말하면 초인종 기록장에 "누가 눌렀는지"가 적힌다. 여기서는 매번 count가 눌렀다.
🔁 1부 복습: LazyVStack에서 절대 content offset이나 content size에 기대는 코드가 흔들리는 이유는?
lazy stack은 화면 밖 행을 아직 만들지 않았으니 그 높이를 모른다. 그래서 앞서 본 행들로 추정하고, 스크롤하며 실제 행을 만들 때마다 보정한다. 추정값이라 offset·size가 계속 바뀐다. "몇 번째 행이 보이나"처럼 id 기반 상대 정보(scrollPosition(id:))를 쓴다.
쉽게 말하면 아직 펼쳐 보지 않은 책 페이지 두께를 대충 짐작한 것이라, 넘기다 보면 "지금 몇 cm 지점"이라는 숫자가 계속 달라진다.

🛠 미니 과제 (선택 · 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() }

요구사항

확인 방법: 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() }

출처 · 참고자료