반응형

SwiftUI Study – 고급 렌더링 아키텍처: Render Tree, Body Recompute, Diffing, Invalidation 깊이 분석

 

1. 왜 이 주제가 중요한가 (문제 배경)

SwiftUI는 “선언형 UI”라는 개념으로 설명되지만,
실제 내부에서는 다음 세 가지 핵심 요소가 동작하며 성능과 UI 일관성을 좌우합니다.

1) View Tree (개발자가 작성한 선언적 트리)
2) Render Tree (실제 렌더링을 위한 내부 구조체)
3) Diffing 엔진 (이전 렌더 트리와 새로운 렌더 트리를 비교하는 알고리즘)

이 구조를 이해하면:

  • 불필요한 body 재계산을 줄이고
  • 화면 전체가 “원치 않게 리렌더링”되는 문제를 막고
  • ObservableObject, State, Binding이 실제로 어떤 범위에 영향을 주는지
    정확하게 예측할 수 있으며
  • 성능 최적화를 위해 어디를 손봐야 하는지 명확히 알 수 있다.

즉, 이 글은 SwiftUI의 렌더링 엔진 내부 모델을 이해해 성능/일관성을 극대화하는 고급 팁이다.

 

2. SwiftUI의 세 가지 세계: View Tree vs Render Tree vs View Graph

SwiftUI는 “View” 자체를 렌더링하지 않는다.
실제 렌더링하는 것은 “새로운 Render Tree” 이다.

2-1. View Tree

개발자가 작성한 선언적 구조:

var body: some View {
    VStack {
        Text("Hello")
        Image(systemName: "star")
        Button("Tap") { … }
    }
}

이는 실제 UI가 아니라, 값(value)일 뿐이다.
View struct는 값 타입이며, 매번 body가 호출될 때 새 View Tree가 만들어진다.

2-2. Render Tree (또는 Render Graph)

SwiftUI가 실제 화면에 그리기 위해 사용하는 내부 자료 구조.
UIKit의 UIView, Core Animation Layer, CoreGraphics 등을 포함하는
실제 렌더링 가능한 객체들의 구조이다.

SwiftUI는 View Tree를 기반으로 Render Tree를 생성하고,
이전 Render Tree와 Diffing을 수행하여 필요한 부분만 업데이트한다.

2-3. View Graph (SwiftUI Runtime Graph)

WWDC 문서에 언급되는 “ViewGraph”는
View Tree와 Render Tree 사이에서 상태(State), 환경(Environment), Layout 정보를 관리하는 구조체 집합이다.

요약:

  • View Tree → 선언적 트리
  • View Graph → 상태/환경/레이아웃을 유지하는 내부 객체
  • Render Tree → 실제 화면에 표시되는 뷰 계층

이 중 “성능 최적화”에서 가장 중요한 것은:

❗ SwiftUI는 View Tree 전체를 재생성하지만,
Render Tree는 Diffing을 통해 필요한 부분만 갱신한다.

3. Body 재계산 규칙 – 언제 invalidate 되는가?

다음 중 하나라도 변경되면 body가 다시 계산된다:

1) @State 값 변경
2) @Binding 값 변경
3) @StateObject가 참조하는 ObservableObject에서 @Published 값 변경
4) Environment 값 변경
5) 부모 View의 body가 재계산되면서 이 View의 body도 재계산되는 경우
6) .id(), .animation(), .task(id:) 등 identity/behavior modifier가 변경 

여기서 핵심:

❗ body 재계산 = 리렌더링이 아니다.

많은 개발자가:

body가 재계산되면 UI도 모두 다시 그려진다

라고 생각하지만, 실제로는:

body 재계산 → 새로운 View Tree 생성 → Diffing → Render Tree 업데이트

Diffing에서 “같다(equal)”고 판단되면 UI는 다시 그려지지 않는다.

3-1. Body Recompute와 Render Update의 차이

예:

var body: some View {
    Text("Hello")
}

부모에서 State가 바뀌어 이 View의 body가 30번 재계산되더라도,
Text(Hello) 는 항상 같은 값이므로 Render Tree는 변화 없음으로 판단한다.

 

4. SwiftUI Diffing 엔진 – 어떻게 변경을 판단하는가?

SwiftUI diffing의 핵심 판단 요소:

1) 뷰 타입(type)
2) identity (id)
3) Equatable 여부 / EquatableView 사용 여부
4) modifier의 구조
5) layout 정보

특히 identity는 List, ForEach에서 중요하다.

4-1. 구조적 identity(Structural Identity)

다음은 SwiftUI에게 동일한 identity로 인식된다:

VStack {
    Text("A")
}

하지만 다음은 다른 identity:

Group {
    Text("A")
}

또는 modifier를 교체하거나 순서를 바꾸면 identity가 달라질 수 있다:

Text("Hi").padding().background(Color.red)
Text("Hi").background(Color.red).padding()

겉보기 UI는 같아도 Render Tree 입장에서는 다른 구조이다.

4-2. EquatableView의 역할

EquatableView(content: MyView(value: x))

또는:

.myEquatable()

로 감싸면 SwiftUI는:

  • 입력 값이 동일하면 “이 View는 변경 없음”으로 인식
  • 해당 subtree의 diffing·render update를 모두 스킵

즉, 렌더 트리 전체에서 변경 전파를 막는 “방화벽” 역할을 한다.

 

5. Invalidation 범위 – 어디까지 다시 계산되는가?

SwiftUI에서 invalidation은 “상태가 바뀐 View와 그 하위”만 영향을 받는다.

하지만:

  • 어떤 경우에는 부모 → 자식 전체가 재계산되며
  • 어떤 경우에는 자식 뷰만 재계산된다.

핵심 규칙:

✔ @State는 해당 View struct에서만 invalidation을 일으킨다

✔ @ObservedObject는 이 뷰뿐 아니라 바인딩된 모든 자식에도 invalidation 전파

✔ @StateObject는 “View 재생성에도 살아남지만”, invalidation 전파는 ObservableObject 기반

즉:

  • 작은 뷰에 @ObservedObject 넣는 것은 위험
  • 큰 뷰는 상태를 최대한 분리해 invalidation을 최소화해야 한다

 

6. State·Binding·ObservedObject·StateObject의 성능 영향 요약

@State

  • View struct 하나만 invalidation
  • 가장 안전하고 가벼움

@Binding

  • 부모 State 변경 시 자식도 invalidation
  • 지나친 중첩은 body 경쟁을 유발

@ObservedObject

  • @Published 변경 시 연결된 모든 View 트리가 invalidation
  • 성능 문제의 주범이 되는 경우가 많음

@StateObject

  • ObservableObject의 생명주기는 유지
  • invalidation은 @ObservedObject와 동일
  • “큰 변경”이 있는 객체는 자식뷰로 분리하는 것이 맞다

 

7. Layout 성능 – 왜 복잡한 Modifier 체인은 느려지는가?

SwiftUI의 modifier는 단순한 함수 호출이 아니다.

예:

Text("Hello")
    .padding()
    .background(Color.red)
    .clipShape(RoundedRectangle(cornerRadius: 8))

각 modifier는 새로운 View 타입을 생성하며,
Diffing 엔진은 modifier 체인 전체를 비교해야 한다.

modifier를 20개 넘게 쌓으면 Render Tree 구성 비용이 실제로 증가한다.

해결책:

  • Custom modifier로 묶기
  • Container View로 분리
  • 필요 시 UIViewRepresentable로 특정 작업을 직접 담당

 

8. 실전에서 사용하는 성능 최적화 전략 (엔진 기반)

✔ 전략 1 – “변하지 않는 subtree를 고립시키기”

@State private var count = 0

var body: some View {
    VStack {
        ChangingView(count: count)
        StaticView()   // 이 부분은 항상 동일 → 분리해두면 diff 비용 감소
    }
}

정적 뷰를 별도 구조체로 분리하면 diffing 비용이 줄고 invalidation 전파를 차단한다.

✔ 전략 2 – 상태를 상위에서 갖지 말고 “최소 단위로 내려보내기”

부모에 많은 @State가 있으면
자식 body가 의존하지 않는 값 때문에도 재계산된다.

✔ 전략 3 – ForEach의 id가 불안하면 diffing 비용이 기하급수적으로 증가

잘못된 예:

ForEach(items, id: \.self) { item in … }

추천:

ForEach(items) { item in … }   // Identifiable 권장

또는:

ForEach(items, id: \.id) { … }

✔ 전략 4 – EquatableView 또는 .equatable() 를 적극적으로 사용

특히:

  • 큰 셀
  • 계산 비용이 큰 하위 뷰
  • 변경될 가능성이 적은 UI

✔ 전략 5 – ViewBuilder 안에서는 비싼 계산 금지

비싼 JSON 파싱, 정렬, 알고리즘은
body 안에서 절대 실행하면 안 된다.

StateObject, Task, background thread 등을 활용해
미리 계산해 전달해야 한다.

 

9. 정리

SwiftUI 성능을 이해하려면 “화면이 어떻게 보이는가”가 아니라
“내부적으로 어떻게 재계산하고 비교하고 렌더링하는가”를 알아야 한다.

핵심 요약:

  • View struct는 매번 새로 생성되지만, Render Tree는 diffing을 통해 필요한 부분만 갱신된다.
  • body 재계산은 자연스러운 것이며, 이는 곧바로 렌더링 비용과 연결되지 않는다.
  • invalidation 전파 범위를 줄이는 것이 SwiftUI 성능의 핵심이다.
  • State 종류별 영향 범위를 이해해야 예측 가능한 UI를 만들 수 있다.
  • identity·modifier·layout 체계는 diffing 비용을 좌우하므로 주의가 필요하다.

이 내용을 이해하면 SwiftUI가 “느려지는 이유”를 직관적으로 이해하게 되고,
대규모 앱에서도 안정적인 성능을 유지할 수 있다.

반응형
Posted by 까칠코더
,