
Equatable과 Container-Presenter 패턴으로 렌더링 최적화하기
SwiftUI는 선언적 UI 프레임워크입니다. 상태가 변경되면 프레임워크가 알아서 화면을 업데이트해주죠. 편리합니다. 하지만 이 편리함에는 함정이 있습니다. 불필요한 재렌더링이 발생하면 성능이 저하됩니다.
이번 WWDC 2025에서 Apple은 Liquid Glass를 선보였습니다.
물흐르는듯한 유리 UI 덕분에 컨텐츠를 가리는 일이 많이 줄었죠(가독성은 개선 중인거 같지만요!)
리퀴드 글래스는 UI/UX를 많이 개선해 주었지만, 성능 문제가 있습니다.
기존 View에 Liquid Glass를 얹고, 동적으로 계산하기 위해서 많은 GPU 리소스를 잡아먹기 때문입니다.
따라서 Liquid Glass를 적극적으로 도입하여 개발하는 앱은 성능 이슈와 렌더링 최적화를 고민하며 개발해야 합니다.
이 글에서는 세 가지 최적화 기법을 다룹니다
Airbnb는 이 기법들을 적용하여 메인 Search 화면에서 15% scroll hitch 감소를 달성했습니다.
SwiftUI는 뷰의 body를 다시 평가할지 결정하기 위해 Diffing 알고리즘을 사용합니다.
상태 변경 → Diffing → 변경된 부분만 재렌더링
기본적으로 SwiftUI는 Reflection 기반 비교를 수행합니다
| 타입 | 비교 방식 |
|---|---|
| Equatable 타입 | == 연산자 사용 |
| 일반 Value 타입 | 프로퍼티를 재귀적으로 비교 |
| Reference 타입 | 참조 동일성 비교 |
| 클로저 | ❌ 비교 불가 (항상 다른 것으로 인식) |
struct UserCard: View {
let name: String
var onTap: () -> Void // 이게 문제!
var body: some View {
Button(name, action: onTap)
}
}
부모 뷰가 재평가될 때마다 onTap 클로저는 새로운 인스턴스로 생성됩니다. SwiftUI는 클로저를 비교할 수 없으므로, 매번 다른 뷰로 인식하여 불필요한 재렌더링이 발생합니다.
Airbnb Engineering Blog에서는 이를 다음과 같이 설명합니다
"If a view contains any value that isn't diffable, the entire view becomes non-diffable."
뷰에 비교 불가능한 값이 하나라도 있으면, 전체 뷰가 비교 불가능해집니다.
뷰가 Equatable을 준수하면, SwiftUI는 기본 Reflection 대신 사용자 정의 == 연산자를 사용합니다.
// SwiftUI의 내부 동작 (의사 코드이며, 실제와 다를 수 있음)
func shouldReevaluateBody<V: View>(old: V, new: V) -> Bool {
if let oldEquatable = old as? any Equatable,
let newEquatable = new as? any Equatable {
return oldEquatable != newEquatable // Equatable 비교 사용
}
return reflectionBasedComparison(old, new) // 기본 Reflection 비교
}
struct ProfileCard: View, Equatable {
let id: UUID
let username: String
let bio: String
var onTap: () -> Void
var body: some View {
VStack {
Text(username).font(.headline)
Text(bio).font(.caption)
}
.onTapGesture(perform: onTap)
}
static func == (lhs: ProfileCard, rhs: ProfileCard) -> Bool {
// 클로저(onTap)는 비교에서 제외
// UI에 영향을 주는 프로퍼티만 비교
lhs.id == rhs.id &&
lhs.username == rhs.username &&
lhs.bio == rhs.bio
}
}
핵심 포인트:
lhs와 rhs가 같으면 → body 재평가 스킵onTap 클로저는 비교에서 제외 (UI 출력에 영향 없음)SwiftUI는 EquatableView 래퍼와 .equatable() 모디파이어를 제공합니다:
// EquatableView 래퍼 사용
EquatableView(content: ProfileCard(
id: user.id,
username: user.name,
bio: user.bio,
onTap: { print("Tapped") }
))
// 또는 .equatable() 모디파이어 사용
ProfileCard(
id: user.id,
username: user.name,
bio: user.bio,
onTap: { print("Tapped") }
).equatable()
이 래퍼들은 뷰가 Equatable을 준수할 때, SwiftUI에게 "이 뷰는 커스텀 비교를 사용해라"라고 명시적으로 알려줍니다.
다음 항목들은 == 연산자에서 제외해야 합니다
onTap, onDismiss)URLSession, 로깅용 Logger 등)lastAccessedDate)ID 프로퍼티가 있다면 가장 먼저 비교하세요. Short-circuit 평가로 성능이 향상됩니다
static func == (lhs: Post, rhs: Post) -> Bool {
lhs.id == rhs.id && // 1. ID가 다르면 바로 false
lhs.content == rhs.content && // 2. 기본 타입
lhs.tags == rhs.tags // 3. 컬렉션
}
ID가 다르면 나머지 프로퍼티는 비교하지 않습니다.
Equatable 구현이 번거롭다면, 구조를 바꿔보는 것이 좋습니다.
React에서 유래한 이 패턴은 뷰를 두 가지 역할로 분리합니다:
| 역할 | Container (Smart) | Presenter (Dumb) |
|---|---|---|
| 상태 관리 | O | X |
| 데이터 Fetch | O | X |
| 비즈니스 로직 | O | X |
| UI 렌더링 | X | O |
| Equatable 적용 | 어려움 | 쉬움 |

Container에는 @State, @StateObject 같은 상태 관리 래퍼가 있습니다. 이들은 변경될 때마다 body 재평가를 트리거합니다. Equatable을 구현해도 의미가 없습니다.
반면 Presenter는 순수 함수처럼 동작합니다. 입력(props)이 같으면 출력(UI)도 같습니다. Equatable 구현이 자연스럽습니다.
// ❌ 안티패턴: Computed Property로 뷰 분리
struct ParentView: View {
@State private var counter: Int = 0
var body: some View {
VStack {
headerSection // counter 변경 시 재평가됨
contentSection // counter 변경 시 재평가됨
counterSection // counter 변경 시 재평가됨
}
}
private var headerSection: some View {
Text("Header") // counter와 무관하지만 재평가됨!
}
private var contentSection: some View {
Text("Content") // counter와 무관하지만 재평가됨!
}
private var counterSection: some View {
Text("Counter: \\(counter)")
}
}
왜 문제인가?
@State가 변경되면 모든 Computed Property가 재평가됨Swift by Sundell에서는 이를 다음과 같이 설명합니다:
"Computed properties are just that — computed — there's no form of caching, meaning that each value will always be recomputed each time it's being accessed."
// ✅ 권장: View Struct로 분리
struct ParentView: View {
@State private var counter: Int = 0
var body: some View {
VStack {
HeaderPresenter() // 독립적 Diffing
ContentPresenter() // 독립적 Diffing
CounterPresenter(counter: counter) // counter가 변경될 때만 재평가
}
}
}
struct HeaderPresenter: View, Equatable {
var body: some View {
Text("Header") // counter 변경 시 재평가 안됨!
}
}
struct ContentPresenter: View, Equatable {
var body: some View {
Text("Content") // counter 변경 시 재평가 안됨!
}
}
struct CounterPresenter: View, Equatable {
let counter: Int
var body: some View {
Text("Counter: \\(counter)") // counter가 변경될 때만 재평가
}
}
장점:
struct BadExampleView: View {
@State private var counter: Int = 0
var body: some View {
VStack(spacing: 20) {
headerSection // counter 변경 → 재평가 O
userCardsSection // counter 변경 → 재평가 O
actionButtonsSection // counter 변경 → 재평가 O
counterSection // counter 변경 → 재평가 O
}
}
private var userCardsSection: some View {
ForEach(0..<3, id: \\.self) { index in
HStack {
Text("User \\(index)")
Button("Tap") { print("Tapped") } // 클로저: 비교 불가
}
}
}
// ... 나머지 computed properties
}
// Container
struct GoodExampleContainer: View {
@State private var counter: Int = 0
var body: some View {
VStack(spacing: 20) {
HeaderPresenter(title: "Container-Presenter 패턴")
UserCardsPresenter(users: users, onUserTap: handleUserTap)
ActionButtonsPresenter(icons: ["star", "heart"])
CounterPresenter(counter: counter, onIncrement: { counter += 1 })
}
}
private func handleUserTap(_ user: User) {
print("Selected: \\(user.name)")
}
}
// Presenter
struct UserCardPresenter: View, Equatable {
let id: UUID
let name: String
var onTap: () -> Void
var body: some View {
HStack {
Text(name)
Button("Tap", action: onTap)
}
}
static func == (lhs: UserCardPresenter, rhs: UserCardPresenter) -> Bool {
lhs.id == rhs.id && lhs.name == rhs.name
// onTap 클로저는 비교에서 제외
}
}
| 항목 | Bad (Computed Property) | Good (Container-Presenter) |
|---|---|---|
| Counter 변경 시 Header 재평가 | O (불필요) | X |
| Counter 변경 시 UserCards 재평가 | O (불필요) | X |
| Counter 변경 시 ActionButtons 재평가 | O (불필요) | X |
| Counter 변경 시 Counter 재평가 | O | O |
| 클로저 Diffing 문제 | 발생 | Equatable로 해결 |
| SwiftUI 독립 Diffing | 불가능 | 가능 |
스크롤 가능한 콘텐츠를 구현할 때, List를 우선적으로 고려해야 합니다.
| 항목 | List | LazyVStack |
|---|---|---|
| 기반 기술 | UITableView/UICollectionView | 순수 SwiftUI |
| 뷰 재사용 | O (효율적) | X (메모리 누적) |
| 빠른 스크롤 점프 | 효율적 | 이전 모든 뷰 계산 필요 |
| id 모디파이어 | 사용 시 lazy loading 깨짐 | 안전하게 사용 가능 |
Fatbobman은 이를 다음과 같이 설명합니다:
"List, leveraging UITableView, outperforms in both memory efficiency and user experience, thanks to its view reuse mechanism."
WWDC23 "Demystify SwiftUI Performance"에서 제시한 핵심 공식
행 개수 = 요소 개수 × 요소당 생성되는 뷰 개수 → 요소당 뷰 개수는 반드시 상수여야 함
List/Table은 모든 ID를 미리 수집하므로, 요소당 뷰 개수가 가변적이면 성능이 저하됩니다.
// 문제 1: ForEach 내 조건부 뷰
List {
ForEach(dogs) { dog in
if dog.isFavorite { // 가변적인 뷰 개수
DogCell(dog)
}
}
}
// → List가 모든 뷰를 즉시 생성해야 함 (lazy loading 깨짐)
// 문제 2: List에서 id 모디파이어 사용
List {
ForEach(items) { item in
ItemRow(item: item)
.id(item.id) // 모든 뷰가 즉시 생성됨
}
}
// 해결책 1: 모델에서 필터링 (캐싱됨)
@Observable class DogModel {
var dogs: [Dog] = []
var favoriteDogs: [Dog] {
dogs.filter { $0.isFavorite }
}
}
List {
ForEach(model.favoriteDogs) { dog in
DogCell(dog) // 상수: 요소당 1개의 뷰
}
}
// ✅ 해결책 2: Identifiable 프로토콜 활용
struct Item: Identifiable {
let id: UUID
let name: String
}
List {
ForEach(items) { item in // id 자동 인식
ItemRow(item: item)
}
}