SwiftUI는 느리지 않다 — 당신의 코드가 느리다
I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source line you already provided) so I can convert it into Korean while preserving the original formatting?
SwiftUI 렌더링: 빠른 것과 느린 것
SwiftUI의 렌더링 엔진은 빠릅니다. 느려지는 원인은 초당 수백 번 다시 그리도록 요구하는 작업입니다.
SwiftUI 작동 방식
SwiftUI에는 한 번만 호출되는 viewDidLoad가 없습니다. 대신 선언형 차분 시스템을 사용합니다:
- State 변화 –
@State,@Binding,@ObservedObject또는 기타 진실 소스를 업데이트합니다. body가 호출됨 – SwiftUI는 해당 상태에 의존하는 모든 뷰에 대해body를 다시 평가합니다.- 차분 수행 – SwiftUI는 새로운 뷰 트리를 이전 트리와 비교합니다.
- 차이점만 렌더링 – 변경된 것이 없으면 아무 것도 다시 그리지 않습니다.
이는 body를 평가하는 비용이 적을 때 우아하고 효율적입니다.
문제는? SwiftUI는 body 계산이 가벼운지 무거운지를 알 수 없습니다. body 안에 비용이 많이 드는 작업을 넣으면 모든 상태 변화마다 다시 실행됩니다.
예시: 개인 재무 대시보드
struct DashboardView: View {
@State private var selectedPeriod: Period = .thisMonth
@State private var showDetails = false
let transactions: [Transaction]
var body: some View {
// ALL of this recalculates on EVERY body call
// — even when the user just toggles `showDetails`!
let filtered = transactions.filter {
$0.date >= selectedPeriod.startDate &&
$0.date $1.value }
ScrollView {
VStack(spacing: 16) {
PeriodPicker(selection: $selectedPeriod)
SummaryCard(title: "Total", value: totalSpent)
SummaryCard(title: "Daily Avg", value: dailyAverage)
ForEach(byCategory, id: \.key) { cat, amt in
CategoryRow(name: cat, amount: amt)
}
Button("Details") { withAnimation { showDetails.toggle() } }
if showDetails { MerchantList(transactions: filtered) }
}
}
}
}
사용자가 “Details” 버튼을 탭하면 → showDetails가 변경되고 → body가 다시 평가되어 → 트랜잭션 데이터가 변하지 않았음에도 모든 집계가 다시 실행됩니다.
해결 방법: 기간이 바뀔 때 미리 계산하기
struct DashboardView: View {
@State private var selectedPeriod: Period = .thisMonth
@State private var showDetails = false
@State private var summary: DashboardSummary?
let transactions: [Transaction]
var body: some View {
ScrollView {
VStack(spacing: 16) {
PeriodPicker(selection: $selectedPeriod)
if let summary {
SummaryCard(title: "Total", value: summary.totalSpent)
SummaryCard(title: "Daily Avg", value: summary.dailyAverage)
ForEach(summary.categoryBreakdown, id: \.category) { item in
CategoryRow(name: item.category, amount: item.amount)
}
Button("Details") { withAnimation { showDetails.toggle() } }
if showDetails {
MerchantList(merchants: summary.topMerchants)
}
} else {
ProgressView()
}
}
}
.task(id: selectedPeriod) {
summary = await computeSummary(
from: transactions,
for: selectedPeriod
)
}
}
}
이제 body는 구조체를 읽고 뷰에 매핑할 뿐이며—필터링, 그룹화, 정렬이 전혀 없습니다. showDetails 토글은 즉시 반응하고, 무거운 작업은 selectedPeriod가 바뀔 때만(메인 스레드가 아닌 곳에서) 실행됩니다.
일반적인 성능 팁
1. 비용이 많이 드는 객체 캐시하기
// ❌ New DateFormatter on every body call
Text(DateFormatter.localizedString(from: date,
dateStyle: .long,
timeStyle: .short))
// ✅ Static – created once
private static let formatter: DateFormatter = {
let f = DateFormatter()
// configure f …
return f
}()
Text(Self.formatter.string(from: date))
이는 NumberFormatter, JSONDecoder, 정규표현식 패턴 등에도 동일하게 적용됩니다—상태를 갖지 않는 비용이 많이 드는 모든 것에 해당합니다.
2. body 안에서 .sorted() / .filter() 직접 사용 피하기
// ❌ Sorts on every evaluation
List(items.sorted(by: { $0.date > $1.date })) { ... }
// ✅ Sort when data changes
@State private var sortedItems: [Item] = []
.onChange(of: items) { _, new in
sortedItems = new.sorted(by: { $0.date > $1.date })
}
body 안에 이런 코드가 보인다면 이는 코드 냄새입니다. 이를 .onChange, .task 혹은 전용 뷰 모델로 옮기고 결과를 @State에 저장하세요.
3. 뷰를 작게 유지하기
// ❌ AvatarImage rebuilds when unrelated state changes
struct ProfileView: View {
@State private var isEditing = false
var body: some View {
VStack {
AvatarImage(url: user.avatarURL) // unnecessary rebuild!
Toggle("Edit", isOn: $isEditing)
}
}
}
// ✅ Extract it – now it only rebuilds when `avatarURL` changes
struct AvatarImage: View {
let url: URL
var body: some View {
AsyncImage(url: url)
.frame(width: 80, height: 80)
.clipShape(Circle())
}
}
작고 독립적인 뷰는 불필요한 재계산을 줄여줍니다.
4. @ObservableObject보다 @Observable 사용하기 (iOS 17+)
// ❌ With ObservableObject, any @Published change forces a full view refresh
class UserModel: ObservableObject {
@Published var name: String
@Published var bio: String
}
// ✅ @Observable tracks per‑property, so only the views that read a changed
// property are refreshed
@Observable class UserModel {
var name: String
var bio: String
}
세분화된 추적은 다시 그려야 하는 UI 양을 감소시킵니다.
5. 비동기 작업에 .task(id:) 사용하기
// Runs off the main thread, cancels automatically when `id` changes
.task(id: selectedPeriod) {
summary = await computeSummary(from: transactions,
for: selectedPeriod)
}
이렇게 하면 무거운 작업이 body 밖에서 수행되고, 관련 입력이 변경되면 자동으로 취소됩니다.
TL;DR
body는 가벼워야 합니다 – 내부에서 무거운 계산, 정렬, 필터링 또는 객체 생성을 피하세요.- 캐시하거나 미리 계산하십시오—입력이 변경될 때 비용이 많이 드는 결과를 (
@State,.task,.onChange등 사용) 캐시하거나 미리 계산합니다. - 큰 뷰를 작은 서브‑뷰로 나누세요—실제로 업데이트가 필요한 부분만 다시 계산됩니다.
@Observable을 활용하세요—속성별 변경 추적을 위해 (iOS 17+)..task(id:)로 비동기 작업을 실행하여 메인 스레드가 응답성을 유지하도록 합니다.
이러한 패턴을 따르면 SwiftUI의 차이점 엔진이 가장 잘하는 일을 하게 됩니다—실제로 변경된 부분만 렌더링합니다.
SwiftUI 성능 팁
“
body가 초당 100번 호출되는 것처럼 다루세요. 실제로 그런 경우도 있습니다.”
흔한 함정
// Fires on every body evaluation
let _ = Task { await loadData() }
// ✅ Only runs when `selectedCategory` changes
.task(id: selectedCategory) { await loadData(for: selectedCategory) }
진단 방법
- Instruments → SwiftUI 템플릿 → View Body invocations 를 엽니다.
- 뷰의
body가 2번 호출될 것으로 기대했는데 200번 호출된다면, 문제가 발견된 것입니다.
절대 직관에 의존해 최적화하지 마세요—프로파일러가 실제 상황을 보여주게 하세요.
모범 사례
body를 순수하고 가볍게 유지: 사전에 계산된 상태만 읽고 이를 뷰에 매핑해야 합니다.body안에서 비용이 많이 드는 작업을 피하세요. 무거운 작업은task,onAppear또는 뷰 모델로 옮기세요..task(id:)(또는 유사한 모디파이어)를 사용해 의존성이 변경될 때만 비동기 작업을 실행하세요.
요약
SwiftUI가 느린 것이 아니라, 잘못된 위치에서 비용이 많이 드는 작업을 하면 성능이 저하됩니다.
렌더링 사이클을 이해하면 성능 문제가 사라지고, 프레임워크를 비난하는 대신 부드러운 앱을 배포할 수 있습니다.
도움이 되었나요? 팔로우해서 더 많은 SwiftUI 심층 탐구를 받아보시고, 댓글에 성능 전쟁 이야기를 남겨 주세요!