SwiftUI 성능 최적화 — 부드러운 UI, 재계산 감소
Source: Dev.to
SwiftUI는 빠릅니다 — 하지만 올바르게 사용할 때만 그렇습니다.
시간이 지나면 다음과 같은 문제를 겪게 됩니다:
- 끊기는 스크롤 성능
- 끊긴 애니메이션
- 느린 리스트
- 뷰가 너무 자주 새로고침
- 불필요한 재계산
- 비동기 작업이 UI를 차단
- 이미지로 인한 메모리 급증
- 120 Hz 애니메이션에서 프레임 손실
이 가이드는 실제 현장에서 적용하는 성능 규칙을 보여줍니다. 이 규칙들은 이론이 아니라 전체 SwiftUI 앱을 만들 때 실제로 마주치는 문제들을 해결합니다. UI를 버터처럼 부드럽게 만들어 봅시다. 🧈⚡
1. 무거운 뷰를 작은 서브뷰로 나누기
SwiftUI는 @State/@ObservedObject가 변경될 때 전체 뷰를 다시 렌더링합니다.
잘못된 예:
VStack {
Header()
ExpensiveList(items: items) // ← 거대한 뷰
}
.onChange(of: searchQuery) {
// 전체 뷰가 재계산됨
}
개선된 예:
VStack {
Header()
ExpensiveList(items: items) // 격리됨
}
최선의 예:
struct BigScreen: View {
var body: some View {
Header()
BodyContent() // 격리된 서브뷰가 추가 재계산을 방지
}
}
작고 재사용 가능한 컴포넌트가 큰 성능 향상을 가져옵니다.
2. @StateObject & @observable을 올바르게 사용하기
@StateObject는 재초기화되지 않아야 하는 객체에 사용합니다:
@StateObject var viewModel = HomeViewModel()
경량 모델에는 @observable 을 사용합니다:
@Observable
class HomeViewModel { … }
단순 값에는 @State를 사용하고 (복잡한 구조체는 피함) 무거운 객체를 @State에 절대 저장하지 마세요.
3. 비동기 작업을 메인 스레드 밖에서 수행하기
잘못된 예:
func load() async {
let data = try? API.fetch() // 느림
self.items = data // UI가 멈춤
}
올바른 예:
func load() async {
let data = try? await Task.detached { await API.fetch() }.value
await MainActor.run { self.items = data }
}
메인 스레드를 절대 차단하지 마세요 — SwiftUI는 메인 스레드에 의존합니다.
4. 무거운 그리기에 .drawingGroup() 사용하기
그라디언트, 블러 레이어, 큰 심볼, 복잡한 마스크 등을 렌더링하면 금방 비용이 커집니다.
MyComplexShape()
.drawingGroup()
GPU 렌더 패스를 강제하게 되어 → 훨씬 빠릅니다.
5. 이미지 최적화 (가장 흔한 지연 원인)
리사이즈된 썸네일을 사용하세요:
Image(uiImage: image.resized(to: 200))
ScrollView 안에서 큰 이미지를 로드하는 것을 피합니다. 대신:
.resizable().scaledToFit().interpolation(.medium)- 비동기 로딩 + 캐싱
원격 이미지는 URLCache 혹은 Nuke 같은 라이브러리를 사용합니다.
6. ScrollView 안에서 무거운 레이아웃 작업 피하기
흔히 하는 실수:
ScrollView {
ForEach(items) { item in
ExpensiveLayout(item: item)
}
}
스크롤 중에 비용이 큰 레이아웃을 수행하면 끊김이 발생합니다.
해결책:
- 수정자를 최소화
- 계산된 값을 캐시
- 자식 뷰를 격리된 컴포넌트로 분리
7. 필요할 때 LazyVGrid / LazyVStack을 List보다 선호하기
List는 훌륭합니다 — 하지만 그렇지 않을 때도 있습니다.
LazyVStack을 사용할 상황:
- 커스텀 애니메이션이 필요할 때
- 큰 컴포지션 레이아웃이 있을 때
- 행에 복잡한 컨테이너가 포함될 때
List를 사용할 상황:
- 행이 단순할 때
- 네이티브 셀 최적화를 활용하고 싶을 때
8. .id(UUID()) 로 뷰 재계산 피하기
MyView()
.id(UUID()) // BAD – 정체성을 파괴함
매 프레임마다 전체 뷰를 다시 로드하게 됩니다. .id(...)는 제어된 리셋이 필요할 때만 사용하세요.
9. 계산된 프로퍼티는 빠르게 만들기
잘못된 예:
var filtered: [Item] {
hugeArray.filter { … } // 비용이 큼
}
렌더링마다 재계산됩니다.
개선된 예:
@State private var filtered: [Item] = []
필요할 때만 업데이트:
.onChange(of: searchQuery) { _ in
filtered = hugeArray.filter { … }
}
10. Transaction 으로 애니메이션 비용 제어하기
기본 애니메이션은 끊길 수 있습니다. 부드럽게 만들려면:
withTransaction(Transaction(animation: .snappy)) {
isOpen.toggle()
}
맞춤형 애니메이션 트랜잭션은 레이아웃 점프를 줄여줍니다.
11. 대량 업데이트 시 애니메이션 끄기
withAnimation(.none) {
items = newItems
}
큰 리스트 작업 중에 발생하는 지연을 방지합니다.
12. Instruments 사용하기 (예, SwiftUI에서도 동작합니다)
다음 도구로 프로파일링하세요:
- SwiftUI “Dirty Views”
- Memory Graph
- Time Profiler
- Allocation Tracking
- FPS drops
지연의 90 %는 다음에서 비롯됩니다:
- 거대한 뷰
- 비용이 큰 초기화
- 이미지
- 메인 스레드 차단
✔️ 최종 성능 체크리스트
출시 전, 다음을 확인하세요:
- 메인 스레드 API 호출 없음
- 비용이 큰 계산 프로퍼티 없음
-
ScrollView내부 레이아웃 스러시 없음 - 이미지가 리사이즈되었거나 캐시됨
- 무거운 뷰가 컴포넌트로 분리됨
- ViewModel이
@StateObject혹은 @observable 사용 - 애니메이션이
.snappy혹은.spring이고 격리됨 - 리스트는 필요 시 lazy 컨테이너 사용