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

Day 9 · 1부 · 리스트와 iOS 17 스크롤

긴 목록을 List와 lazy 스택 중 알맞게 고르고, ForEach를 요소당 뷰 1개로 유지하며, 선언형 스크롤 API로 스냅되는 캐러셀을 만든다.
진행률
0 / 0 완료
UIKit에서는 UITableView가 셀을 재사용하고, 버벅이면 cellForRowAt에 브레이크포인트를 걸었다. SwiftUI에는 셀 재사용 API도, 쓸모 있는 백트레이스도 없다. 대신 요소당 뷰 개수, 뷰가 읽는 데이터(의존성), body의 비용 세 가지만 지키면 긴 목록이 빠르다. 1부는 목록을 만드는 도구 — List와 lazy 스택 고르기, ForEach 규칙, iOS 17 선언형 스크롤 API를 다룬다.

1부 목표 · 29분

  • 화면 요구사항을 보고 List와 ScrollView+LazyVStack 중 하나를 고르고, 그 이유를 한 문장으로 댄다.
  • ForEach 안의 if·인라인 filter·AnyView를 찾아 "요소당 뷰 1개" 구조로 고친다.
  • onDelete·onMove·EditButton·List(selection:)로 UITableView 편집(스와이프 삭제·재정렬·다중 선택)을 옮긴다.
  • iOS 17 스크롤 API(contentMargins, scrollTargetBehavior, scrollPosition(id:), scrollTransition)로 스냅되는 캐러셀을 만든다.

1. List냐 LazyVStack이냐 🔥 핵심

List는 UITableView/UICollectionView 리스트 레이아웃에 해당하는 완성품이다. 선택, 스와이프 액션, 재정렬, 당겨서 새로고침, 섹션 스타일이 들어 있다. ScrollView+LazyVStack은 재료다. 모양은 전부 직접 그리지만, 행 구분선·인셋 같은 시스템 스타일에서 자유롭다.

UIKitSwiftUI언제
UITableView (plain/grouped)List + Section설정 화면, 받은편지함처럼 시스템 모양이 맞을 때
trailingSwipeActionsConfiguration….swipeActions(edge:)행 스와이프 버튼
UIRefreshControl.refreshable { await … }당겨서 새로고침
UICollectionView + 커스텀 셀ScrollView + LazyVStack/LazyHStack피드, 카드, 가로 셸프처럼 디자인이 자유로운 목록
셀 재사용 dequeueReusable…없음 — 보이는 행만 만들고 나머지는 추정개발자가 신경 쓸 API가 없다
backgroundView로 빈 화면ContentUnavailableView목록이 비었을 때
✅ List 한 벌 — Section · swipeActions · refreshable · 빈 화면
import SwiftUI

struct Todo: Identifiable, Hashable {
    let id = UUID()
    var title: String
    var isDone = false
}

struct TodoListView: View {
    @State private var todos: [Todo] = [Todo(title: "우유"), Todo(title: "운동")]

    var body: some View {
        List {
            Section("오늘") {
                ForEach($todos) { $todo in
                    Text(todo.title)
                        .strikethrough(todo.isDone)
                        .swipeActions(edge: .leading) {
                            Button("완료") { todo.isDone.toggle() }
                                .tint(.green)
                        }
                        .swipeActions(edge: .trailing) {
                            Button("삭제", role: .destructive) {
                                todos.removeAll { $0.id == todo.id }
                            }
                        }
                }
            }
        }
        .refreshable {
            await reload()          // 끝날 때까지 스피너가 돈다
        }
        .overlay {
            if todos.isEmpty {
                ContentUnavailableView("할 일이 없다", systemImage: "checkmark.circle")
            }
        }
    }

    private func reload() async {
        try? await Task.sleep(for: .seconds(1))
    }
}

Lazy는 "보이는 만큼 + 조금"만 만든다

ScrollView의 내용은 기본적으로 즉시(eager) 평가된다. WWDC23 · Beyond scroll views는 이를 바꾸는 방법으로 lazy stack을 든다. VStack에 1,000개를 넣으면 1,000개의 init과 body가 첫 화면 전에 돈다.

VStack — 전부 즉시 생성 LazyVStack — 보이는 만큼 Row 1 Row 2 Row 3 Row 4 Row 5 Row 6 Row 7 Row 8 화면 8개 모두 init · body 실행 Row 1 Row 2 Row 3 Row 4 아직 안 만듦 (높이는 평균으로 추정) 화면 prefetch 보이는 행 + 곧 보일 행만
LazyVStack은 화면 밖 행의 높이를 앞서 본 행들의 평균으로 추정한다. 그래서 스크롤 막대 길이와 content offset은 스크롤하는 동안 조금씩 보정된다.
📝 Apple 원문

"Since a LazyVStack doesn't load all of its views, the height of the subviews that are off-screen are estimated." — WWDC26 · Dive into lazy stacks and scrolling

화면 밖 행은 만들지 않으니 높이를 모른다. 그래서 추정한다. 이 추정 때문에 절대 content offset에 기대는 코드는 흔들린다(2부의 lazy stack 수칙 박스).

✅ 자유 디자인 피드 — LazyVStack + 고정되는 섹션 헤더
import SwiftUI

struct FeedView: View {
    let posts = (1...500).map { "게시물 \($0)" }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
                Section {
                    ForEach(posts, id: \.self) { post in
                        Text(post)
                            .frame(maxWidth: .infinity, minHeight: 80)
                            .background(.quaternary, in: .rect(cornerRadius: 12))
                    }
                } header: {
                    Text("최신").font(.headline)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding(8)
                        .background(.bar)
                }
            }
            .padding(.horizontal)
        }
    }
}
🔑 고르는 규칙

스와이프·선택·편집 모드가 필요하면 List. 셀 모양이 시스템과 다르고 위 기능이 필요 없으면 LazyVStack. 행이 수십 개 이하이고 고정이면 그냥 VStack도 된다.

편집 — 삭제 · 재정렬 · 다중 선택

UIKit의 commit editingStyle·moveRowAt·setEditing은 ForEach에 붙이는 모디파이어 두 개와 EditButton으로 바뀐다. onDelete·onMove는 List가 아니라 ForEach(DynamicViewContent)에 붙는다. 아래 API는 전부 iOS 13.0부터 있다(EditButton 문서).

UIKitSwiftUI메모
commit editingStyle: .delete.onDelete { offsets in }스와이프 삭제. 인자는 IndexSet
moveRowAt:to:.onMove { from, to in }(IndexSet, Int). 편집 모드에서 핸들이 뜬다
editButtonItem / setEditingEditButton() / @Environment(\.editMode)편집 ↔ 완료 토글
allowsMultipleSelectionDuringEditing + indexPathsForSelectedRowsList(selection: $ids), ids: Set<ID>선택 목록이 곧 @State
canEditRowAt / canMoveRowAt.deleteDisabled(_:) / .moveDisabled(_:)행 단위로 끈다
✅ 삭제 · 재정렬 · 다중 선택 한 화면
import SwiftUI

struct Chore: Identifiable {
    let id = UUID()
    var title: String
    var isPinned = false
}

struct ChoreEditor: View {
    @State private var chores = [Chore(title: "공지", isPinned: true),
                                 Chore(title: "빨래"), Chore(title: "청소")]
    @State private var selection = Set<Chore.ID>()

    var body: some View {
        NavigationStack {
            List(selection: $selection) {
                ForEach(chores) { chore in
                    Text(chore.title)
                        .deleteDisabled(chore.isPinned)   // canEditRowAt
                        .moveDisabled(chore.isPinned)     // canMoveRowAt
                }
                .onDelete { chores.remove(atOffsets: $0) }              // IndexSet
                .onMove { chores.move(fromOffsets: $0, toOffset: $1) }  // moveRowAt
            }
            .toolbar {
                EditButton()                      // 편집 ↔ 완료
                Button("선택 삭제") {
                    chores.removeAll { selection.contains($0.id) }
                    selection.removeAll()
                }
                .disabled(selection.isEmpty)
            }
        }
    }
}
평소 — 스와이프 편집 모드 — EditButton 빨래 삭제 청소 onDelete(IndexSet) 행은 이미 빠짐 → 배열에서 지운다 ✓ 빨래≡ 청소≡ selection: Set onMove 왼쪽 원 = 선택 · 오른쪽 ≡ = 이동
iPhone에서 다중 선택 원은 편집 모드에서만 뜬다. 키보드·트랙패드가 있는 iPad·Mac은 편집 모드 없이도 여러 행을 고른다.
📝 Apple 원문

"You must delete the corresponding item within action, as it will be called after the row has already been removed from the List." — onDelete(perform:) 문서

클로저가 불릴 때 행은 이미 화면에서 빠졌다. 배열에서 지우지 않으면 데이터와 화면이 어긋난다.

다중 선택은 List(selection:)에 Set<ID> 바인딩을 주면 켜진다. List 문서에 따르면 iPhone에서는 편집 모드에 들어가야 행 옆에 선택 원이 뜨고 탭으로 여러 개를 고른다. EditButton은 환경값 editMode(Binding<EditMode>?)를 토글할 뿐이다. 편집 중에만 보일 UI는 @Environment(\.editMode)를 읽고 editMode?.wrappedValue.isEditing으로 분기한다.

2. ForEach 규칙 — 요소당 뷰 개수를 일정하게 🔥 핵심

리스트 성능에서 가장 중요한 단일 규칙이다. Apple ForEach 문서에 직접 적혀 있다.

📝 Apple 원문

"Some containers like List or LazyVStack will query the elements within a for each lazily. To obtain maximal performance, ensure that the view created from each element in the collection represents a constant number of views."

List와 LazyVStack은 요소를 필요할 때 조회한다. 최고 성능을 내려면 요소 하나가 만드는 뷰 개수를 항상 같게 하라는 뜻이다.

이유는 WWDC23 · Demystify SwiftUI performance가 설명한다. List는 행을 필요할 때 만들지만 모든 행의 ID는 미리(eagerly) 모은다. 요소당 뷰 개수가 일정하면 ID만 훑으면 되지만, 일정하지 않으면 개수를 알려고 모든 행의 뷰를 실제로 만들어야 한다.

❌ 요소당 개수가 다르다 ✅ 요소당 항상 1개 ForEach { if 조건 { Row() } } 요소 A → 1개 요소 B → 0개 요소 C → 1개 요소 D → 0개 행 수를 곱셈으로 못 구한다 → 모든 행의 뷰를 만들어 센다 인라인 조건 · AnyView 모두 같은 문제 ForEach(filtered) { Row() } 요소 A → 1개 요소 C → 1개 요소 E → 1개 요소 F → 1개 행 수 = 요소 수 × 1 → ID만 훑으면 끝 필터는 모델에서 미리 해 둔다
WWDC23의 공식: List의 행 수 = 요소 수 × 요소당 뷰 수. 곱셈이 성립해야 SwiftUI가 뷰를 만들지 않고 행을 식별한다.
❌ 흔한 세 패턴 — 조건부 행 · AnyView · body 안의 인라인 filter
import SwiftUI

struct Dog: Identifiable {
    let id = UUID()
    var name: String
    var likesFetch: Bool
}

struct DogRow: View {
    let dog: Dog
    var body: some View { Text(dog.name) }
}

struct BadDogList: View {
    let dogs: [Dog]

    var body: some View {
        List {
            ForEach(dogs) { dog in
                if dog.likesFetch {            // ① 요소당 1개 또는 0개
                    AnyView(DogRow(dog: dog))  // ② 개수를 전혀 알 수 없다
                }
            }
            ForEach(dogs.filter { $0.likesFetch }) { dog in   // ③ 갱신마다 전체 순회
                DogRow(dog: dog)
            }
        }
    }
}
✅ 필터는 모델이 한 번, ForEach는 요소당 행 하나
import SwiftUI

struct Dog: Identifiable {
    let id = UUID()
    var name: String
    var likesFetch: Bool
}

@Observable final class Kennel {
    var dogs: [Dog] = [] {
        didSet { fetchLovers = dogs.filter(\.likesFetch) }   // 데이터가 바뀔 때만
    }
    private(set) var fetchLovers: [Dog] = []
}

struct GoodDogList: View {
    let kennel: Kennel

    var body: some View {
        List {
            ForEach(kennel.fetchLovers) { dog in
                HStack {                       // 행 안의 분기는 행 수에 영향 없음
                    Text(dog.name)
                    if dog.name.count > 5 {
                        Image(systemName: "star.fill")
                    }
                }
            }
        }
    }
}
🔑 진단 런치 인자 (Apple 문서 제공)

Scheme → Run → Arguments Passed On Launch에 -LogForEachSlowPath YES를 넣으면, 이 컨테이너 안에서 비일정 개수를 만드는 뷰를 만날 때 SwiftUI가 로그를 남긴다(ForEach 문서).

예외: 섹션을 만드는 중첩 ForEach는 괜찮다

WWDC23은 중첩 ForEach를 되도록 평평하게 하라고 하면서, 동적 섹션은 권장되는 예외로 든다. SwiftUI가 이 구조를 이해해서 빠르게 그린다.

✅ Section마다 ForEach — 권장되는 중첩
import SwiftUI

struct Pet: Identifiable {
    let id = UUID()
    var name: String
}

struct ToyGroup: Identifiable {
    let id: String          // 장난감 이름
    var pets: [Pet]
}

struct SectionedPets: View {
    let groups: [ToyGroup]

    var body: some View {
        List {
            ForEach(groups) { group in
                Section(group.id) {
                    ForEach(group.pets) { pet in
                        Text(pet.name)
                    }
                }
            }
        }
    }
}
⚠️ listRowBackground 위치

행을 스택으로 감싸 개수를 고정할 때, listRowBackground 같은 행 모디파이어는 스택 안이 아니라 스택 뒤에 붙인다(WWDC23 10160).

3. iOS 17 스크롤 API — 여백·스냅·위치·효과

UIKit에서는 contentInset, isPagingEnabled, scrollViewWillEndDragging(_:withVelocity:targetContentOffset:), contentOffset KVO로 하던 일이다. iOS 17은 이를 전부 선언형 모디파이어로 내놨다. 모두 iOS 17.0부터 쓸 수 있다.

UIKitSwiftUI (iOS 17)한 줄 요약
contentInset / verticalScrollIndicatorInsets.contentMargins(_:_:for:)콘텐츠와 인디케이터 여백을 따로 준다
isPagingEnabled.scrollTargetBehavior(.paging)스크롤뷰 크기 단위로 넘김
targetContentOffset 조작.scrollTargetBehavior(.viewAligned) + .scrollTargetLayout()개별 뷰 모서리에 스냅
scrollToItem(at:) + 현재 인덱스 계산.scrollPosition(id:)Binding 하나로 읽기·쓰기 양방향
scrollViewDidScroll에서 transform.scrollTransition보이는 영역 가장자리에서 시각 효과
셀 크기 = 컬렉션뷰 폭 계산.containerRelativeFrameGeometryReader 없이 컨테이너 기준 크기
ScrollView(.horizontal) { LazyHStack { … } } id 3 identity 단계 · 원래 크기 id 4 id 5 0.9배 · 흐림 contentMargins 콘텐츠만 안쪽으로 잘리지 않고 옆이 보임 scrollTargetLayout + .viewAligned 카드 앞 모서리에 멈춤 scrollPosition(id:) 맨 앞 카드 id ↔ @State 양방향 scrollTransition 가장자리에서 효과 크기 불변 효과만
네 모디파이어가 각각 여백·멈출 위치·현재 위치·시각 효과를 맡는다. 서로 독립이라 필요한 것만 골라 붙인다.

여백: padding이 아니라 contentMargins

WWDC23 10159: 스크롤뷰에 padding을 주면 스크롤뷰 자체가 줄어서 스크롤 중 콘텐츠가 잘린다. 스크롤뷰는 전체 폭을 쓰고, 콘텐츠 여백만 늘려야 다음 카드가 살짝 보인다.

❌ 스크롤뷰를 줄여 버림 — 양옆에서 카드가 잘린다
import SwiftUI

struct ClippedShelf: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0..<10, id: \.self) { i in
                    Color.orange.frame(width: 240, height: 140)
                }
            }
        }
        .padding(.horizontal, 20)   // 스크롤뷰 자체가 20pt씩 좁아진다
    }
}
✅ 콘텐츠 여백만 — 인디케이터는 원래 자리
import SwiftUI

struct PeekingShelf: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0..<10, id: \.self) { i in
                    Color.orange.frame(width: 240, height: 140)
                }
            }
        }
        .contentMargins(.horizontal, 20, for: .scrollContent)
    }
}

safeAreaPadding도 비슷하게 보이지만, 스크롤뷰는 safe area를 콘텐츠와 인디케이터 모두에 적용한다. 둘을 따로 주고 싶으면 contentMargins의 for: 인자(.scrollContent / .scrollIndicators)를 쓴다(10159).

스냅 · 위치 · 효과를 한 번에

✅ iOS 17 캐러셀 — 폰 1장 / 아이패드 2장, 스냅, 현재 id 표시
import SwiftUI

struct Palette: Identifiable, Hashable {
    let id: Int
    var color: Color { Color(hue: Double(id) / 10, saturation: 0.6, brightness: 0.9) }
}

struct GalleryView: View {
    private let palettes = (0..<10).map(Palette.init)
    @State private var currentID: Palette.ID?
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        VStack(alignment: .leading) {
            Text("현재: \(currentID.map(String.init) ?? "-")")
                .padding(.horizontal)
            ScrollView(.horizontal) {
                LazyHStack(spacing: 12) {
                    ForEach(palettes) { palette in
                        RoundedRectangle(cornerRadius: 16)
                            .fill(palette.color)
                            .aspectRatio(3 / 2, contentMode: .fit)
                            .containerRelativeFrame(.horizontal,
                                                    count: sizeClass == .regular ? 2 : 1,
                                                    spacing: 12)
                            .scrollTransition { content, phase in
                                content
                                    .scaleEffect(phase.isIdentity ? 1 : 0.9)
                                    .opacity(phase.isIdentity ? 1 : 0.6)
                            }
                    }
                }
                .scrollTargetLayout()             // 스냅·위치 계산 대상 = 이 스택의 자식들
            }
            .contentMargins(.horizontal, 20, for: .scrollContent)
            .scrollTargetBehavior(.viewAligned)   // 손을 떼면 카드 모서리에 멈춤
            .scrollPosition(id: $currentID)       // 읽기: 스크롤하면 갱신 / 쓰기: 대입하면 이동
            .scrollIndicators(.hidden)

            Button("처음으로") {
                withAnimation { currentID = palettes.first?.id }
            }
            .padding(.horizontal)
        }
    }
}
📝 Apple 원문

"When using lazy stacks, it's important to use the scroll target layout modifier. Views outside the visible region have not yet been created." — WWDC23 · Beyond scroll views

lazy stack에서는 화면 밖 뷰가 아직 없다. 그래서 개별 뷰에 .scrollTarget()을 붙이는 대신, 레이아웃(스택) 전체에 .scrollTargetLayout()을 붙여야 어디에 멈출지 계산된다.

🚨 scrollTransition 안에서 레이아웃을 바꾸지 말 것

scrollTransition의 content는 VisualEffect다. scaleEffect·opacity·rotationEffect·offset처럼 콘텐츠 크기를 바꾸지 않는 효과만 된다. .font처럼 크기를 바꾸는 모디파이어는 빌드되지 않는다(10159). 또 lazy stack에서는 효과로 뷰를 원래 자리 밖(보이는 영역 안쪽)으로 밀어 넣지 않는다 — lazy stack은 원래 위치로 가시성을 판단한다(WWDC26 321).

♿ 접근성 한 줄

.scrollIndicators(.hidden)은 마우스가 연결되면 인디케이터를 다시 보여준다. 마우스로는 인디케이터 없이 가로 스크롤이 어렵기 때문이다. 정말 숨기려면 .never를 쓰되, 이전/다음 버튼(위 예제의 scrollPosition 쓰기)을 대안으로 둔다(10159).

🆕 최신 API iOS 18+

iOS 18은 ScrollPosition 타입과 .scrollPosition(_:)을 추가했다. id뿐 아니라 가장자리·특정 오프셋으로도 이동한다. 또 .onScrollGeometryChange, .onScrollTargetVisibilityChange, .onScrollVisibilityChange로 스크롤 상태에 반응한다(모두 iOS 18.0+). iOS 17 타깃에서는 if #available(iOS 18, *)로 감싼다.

iOS 18+ — ScrollPosition으로 섹션 헤더까지 이동
import SwiftUI

@available(iOS 18, *)
struct JumpToShowcase: View {
    @State private var position = ScrollPosition(idType: String.self)

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<50, id: \.self) { Text("Step \($0)").frame(height: 60) }
                Text("쇼케이스").font(.title).id("showcase-header")
            }
        }
        .scrollPosition($position)
        .overlay(alignment: .bottom) {
            Button("쇼케이스로") {
                withAnimation { position.scrollTo(id: "showcase-header") }
            }
        }
    }
}

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
List가 "요소당 뷰 개수가 일정해야" 빠른 이유는?
List는 행 뷰는 필요할 때 만들지만 모든 행의 ID는 미리 모은다. 요소당 뷰 수가 일정하면 행 수 = 요소 수 × 상수라 ID만 훑으면 된다. 일정하지 않으면(if, AnyView) 개수를 알기 위해 모든 행의 뷰를 실제로 만들어야 한다.
쉽게 말하면 상자마다 사과가 꼭 1개씩 들었으면 상자 수만 세면 된다. 어떤 상자는 비어 있으면 전부 열어 봐야 한다.
가로 캐러셀에서 .padding(.horizontal, 20) 대신 무엇을 쓰고, 왜?
.contentMargins(.horizontal, 20, for: .scrollContent). padding은 스크롤뷰 자체를 좁혀서 스크롤 중 카드가 잘린다. contentMargins는 스크롤뷰는 전체 폭 그대로 두고 콘텐츠만 안쪽으로 밀어 다음 카드가 보이게 한다.
쉽게 말하면 창문을 작게 만드는 게 아니라, 창문은 그대로 두고 그림만 안쪽에 거는 것이다.
iPhone에서 List(selection: $ids)(Set)를 줬는데 탭해도 여러 행이 안 골라진다. 무엇이 빠졌나?
편집 모드다. iPhone에서 다중 선택 원은 편집 모드에서만 뜬다. 툴바에 EditButton()을 두거나 editMode 환경값을 .active로 바꾼다. 키보드·트랙패드가 있는 iPad·Mac은 편집 모드 없이도 된다.
쉽게 말하면 여러 개 고르기는 "고르기 모드" 버튼을 눌러야 켜진다.
코드 예측용
struct EvenList: View {
    var body: some View {
        List {
            ForEach(0..<6, id: \.self) { i in
                if i.isMultiple(of: 2) {
                    Text("\(i)")
                }
            }
        }
    }
}
🔮 코드 예측: 위 List에는 행이 몇 개 보이고, 성능 규칙상 무엇이 문제인가?
0·2·4 세 행이 보인다. 화면은 맞지만 요소당 뷰 개수가 1개 또는 0개로 일정하지 않다. 그래서 List는 "행 수 = 요소 수 × 상수" 계산을 못 하고, 개수를 알려고 모든 요소의 뷰를 실제로 만든다. ForEach(Array(stride(from: 0, to: 6, by: 2)), id: \.self)처럼 데이터 단계에서 거르면 요소당 1개가 된다. -LogForEachSlowPath YES로 돌리면 로그로도 확인된다.
쉽게 말하면 결과는 같아 보여도, SwiftUI는 빈 상자가 섞여 있어서 상자를 전부 열어 봐야 개수를 안다.
🔁 지난 Day 복습 (Day 4): ForEach(items, id: \.self)에서 같은 문자열이 두 번 나오면 무엇이 문제인가?
explicit identity가 겹친다. SwiftUI는 id로 뷰의 identity와 lifetime(그리고 거기 매달린 @State)을 관리하므로, id가 같으면 두 행을 구분하지 못해 상태·애니메이션이 엉킨다. 안정적이고 유일한 id(Identifiable의 id)를 쓴다.
쉽게 말하면 반에 이름이 똑같은 친구가 둘이면 선생님이 누구에게 숙제를 돌려줄지 헷갈린다.
➡️ 2부 예고

목록이 느려졌을 때 원인을 좁히는 법을 다룬다. Self._printChanges()로 "왜 body가 또 돌았나"를 찾고, 의존성을 좁히고, body 안의 무거운 일을 모델로 옮기고, Instruments로 히치를 잰다.

출처 · 참고자료