Tags :

Date :

“왜 특정 셀 하나만 바꿨을 뿐인데, 전체가 다시 그려질까?”
PopPang 서비스를 개발하던 중, 특정 화면에서 메모리가 계속 증가하는 문제를 발견했습니다.
단순한 데이터 문제로 보였지만, 실제로는 SwiftUI의 렌더링 구조와 상태 구독 방식에서 발생한 문제였습니다.
이 글에서는 문제를 어떤 기준으로 원인을 좁혀갔으며, 불필요한 렌더링을 줄이기 위한 어떤 개선을 했는지 정리했습니다.

문제: 셀 하나만 변경되도 모든 셀이 다시 그려진다

  • 리스트의 특정 셀 좋아요룰 눌렀는데 전체 리스트의가 다시 렌더링됨
  • 스크롤 시 버벅임 + 메모리 증가 발생

의문

  • SwiftUI는 왜 이렇게 많이 다시 그릴까?
  • Diffing은 정확히 어떻게 동작할까?
  • 내가 뭘 잘못 쓰고 있는 걸까?
    👉 여기서부터 Diffing을 끝까지 파보기 시작했습니다

SwiftUI View LifeCycle

SwiftUI View LifeCycle 흐름

SwiftUI View 라이프사이클은 세 단계로 나뉘어집니다.

Appearing

  1. initialize(초기화) 시점에 View는 State에 연결되지 않고 뷰 계층 구조만 미리 구축.
  2. 초기화 이후 View와 State 연결
  3. body가 처음으로 호출되어 View Tree 생성
  4. View 계층을 반영하여 UI 렌더링 실행
  5. onAppear() 호출

Updating

  1. 사용자 액션이나 관찰중인 publisher에서 데이터가 방출되면 상태 변화가 발생
  2. 해당 상태를 보유한 View의 body가 먼저 호출되어 새로운 View 트리를 생성
  3. 이전 View트리와 새로운 View트리를 비교(diffing)
    3-1. 만약 Equatable 채택 시 개발자가 정의한 ==를 통해 동일성 비교 가능
    3-2. 동일한 경우 하위 View body 호출이 스킵될 수 있음 🔴
  4. 변경된 View만 invalidate(무효화) 처리
  5. invalidate된 View들을중심으로 새로운 View 계층을 업데이트하고 렌더링을 다시 수행

Disappearing

  1. View가 계층에서 완전히 제거된 후 onDisappear() 메서드가 호출됨
  2. 상위 뷰에서 하위 뷰 순서로 호출됨

SwiftUI View Diffing

SwiftUI 성능 핵심은 Updating 단계의 Diffing 입니다.
SwiftUI의 Diffing 방식은 공식적으로 완전히 공개되어 있지 않지만 알 수 있는 것은

  • SwiftUI는 모든 View를 매번 렌더링 하지 않는다
  • 이전 상태와 현재 상태를 비교하여 변경된 부분만 렌더링 한다
    이 특성을 활용하면
    -> View에 Equatable을 채택시켜 Diffing(비교) 과정을 개발자 레펠에서 관리할 수 있습니다.

SwiftUI Diffing 파고들기(참고)

var body: some View {
      VStack {
          Text("Hello")
          Image(systemName: "star")
          Button("Tap") {  }
      }
}
  1. View Tree(선언적 트리)
    • 개발자가 작성한 선언적 구조
    • 실제 UI가 아니라 값(value)
    • 매번 body가 호출될 때 새 View Tree가 생성됨
  2. Render Tree(실제 렌더링 구조)
    • 화면에 표시되는 실제 UI 계층
    • UIKit의 UIView, Core Animation Layer, CoreGraphics 등을 포함하는 실제 렌더링 가능한 객체들의 구조
  3. View Graph(내부 상태 관리 구조)
    • State / Environment / Layout 정보 관리
    • View Tree와 Render Tree를 연결하는 역할

핵심

  • View Turee는 매번 생성되지만 Render Tree는 Diffing을 통해 부분적으로 갱신

Body 재계산 규칙: 언제 invalidate 되는가?

Invalidation은 특정 View를 “무효화”하여, 다음 업데이트 사이클에서 body를 다시 계산하도록 표시하는 과정입니다. SwiftUI에서 Invalidation는 “상태가 바뀐 View와 그 하위”만 영향을 받습니다.
다음 중 하나라도 변경되면 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 재계산 -> 새로운 View Tree 생성 -> Diffing -> Render Tree 업데이트
만약 Diffing에서 같다로 판단되면 UI는 다시 그려지지 않습니다.


Body Recompute와 Render Update의 차이

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

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

SwiftUI 내장 비교 알고리즘

// SwiftUI 내부 diffing 로직 (의사코드)
func shouldUpdateView<V: View>(_ oldView: V, _ newView: V) -> Bool {
    // 1. Equatable 타입이면 == 연산자 사용
    if V.self is Equatable.Type {
        return oldView != newView
    }
    
    // 2. 값 타입(struct)이면 재귀적으로 프로퍼티 비교
    if V.self is ValueType {
        return compareProperties(oldView, newView)
    }
    
    // 3. 참조 타입(class)이면 참조 동일성 비교
    if V.self is ReferenceType {
        return oldView !== newView
    }
    
    // 4. 클로저는 비교 불가능 - 항상 다르다고 가정
    if containsClosures(V.self) {
        return true
    }
}
// 예시
struct CellView: View, Equatable {
    static func == (lhs: CellView, rhs: CellView) -> Bool {
        lhs.item == rhs.item
    }
}

SwiftUI는 상태 변경 시 invalidation된 View부터 body를 재계산하여 새로운 View Tree를 생성합니다.
이후 이전 View Tree와 새로운 View Tree를 비교(diffing)하여 실제 UI 업데이트 여부를 결정합니다.

이때 Diffing 기준은 기본적으로 SwiftUI의 내장 비교 알고리즘에 따라 결정되며,
Equatable을 채택한 경우에는 가장 먼저 == 연산자를 통해 비교가 수행됩니다.

즉, Equatable을 채택하지 않은 경우에는 View를 구성하는 프로퍼티들을 기반으로 구조 비교가 수행되지만,
Equatable을 채택하면 개발자가 정의한 비교 기준이 우선 적용됩니다.

따라서 상위 View(리스트)의 body는 호출되더라도,
자식 View(CellView)는 Equatable 비교 결과가 동일하면 body 호출 자체가 생략됩니다.

기존 팝팡 예시

// MARK: - Cell (ViewModel 참조)
struct CellView: View {
    
    let item: Item
    @ObservedObject var vm: ListViewModel   // ⭐️ 모든 셀이 같은 vm 구독
    
    var body: some View {
        print("🔥 body 호출: \(item.id)")
        
        return HStack {
            Text(item.name)
            
            Spacer()
            
            Button {
                vm.toggleLike(id: item.id)   // ⭐️ ViewModel 메서드 호출
            } label: {
                Image(systemName: item.isLiked ? "heart.fill" : "heart")
                    .foregroundColor(item.isLiked ? .red : .gray)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
    }
}
  • 문제: 모든 셀이 동일한 @ObservableObject를 구독하면서 단일 상태 변경에도 objectWillChange가 전체 View invalidation을 유발하여 리스트 전체가 다시 렌더링되는 문제가 발생
  • 원인: @ObservableObject는 상태 변경 시 diffing 결과와 관계없이 구독 중인 모든 View의 body를 재호출하며, 이로 인해 Identifiable / Equatable 기반의 렌더링 최적화가 무력화됨
  • 즉 @ObservableObject를 Cell에서 직접 구독하면 diffing 최적화가 동작하지 않는다

해결

// MARK: - Cell (ViewModel 참조)
struct CellView: View, Equatable {
    static func == (lhs: CellView, rhs: CellView) -> Bool {
        return lhs.item == rhs.item
    }
    
    
    let item: Item
    let action: () -> Void
    
    var body: some View {
        print("🔥 body 호출: \(item.id)")
        
        return HStack {
            Text(item.name)
            
            Spacer()
            
            Button {
                action()
            } label: {
                Image(systemName: item.isLiked ? "heart.fill" : "heart")
                    .foregroundColor(item.isLiked ? .red : .gray)
            }
        }
        .padding()
        .background(Color.gray.opacity(0.1))
    }
}
  • Cell에서 @ObservedObject를 제거하고 값 타입 데이터만 전달받도록 구조를 변경
  • 사용자 액션은 closure로 상위 View에 위임하여 ViewModel 접근을 분리
  • Cell에 Equatavle을 적용하고 closure는 비교에서 제외하여 diffing 기준을 분리

Reference

  • https://lzufs.tistory.com/2
  • https://kka7.tistory.com/670
  • https://green1229.tistory.com/563
  • https://green1229.tistory.com/589
  • https://eunjin3786.tistory.com/559
  • https://medium.com/@mini-min/swiftui-ios-17-부터-새로워진-swiftui의-observation-상태-관리-이해하기-observable-bindable-cab86b79bad3
  • https://lzufs.tistory.com/2