반응형

SwiftUI Study – 리스트 성능 최적화를 위한 셀 안정성(Cell Identity)·업데이트 최소화·비동기 작업 관리 전략

 

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

SwiftUI의 List는 편리하지만, 대규모 데이터나 실시간 업데이트 환경에서는 다음과 같은 문제가 발생한다.

  • 셀이 깜빡임 또는 재렌더링 과도
  • 빠르게 스크롤할 때 비동기 작업 중첩 실행
  • 이미지/네트워크 요청이 반복되어 성능 저하
  • 아이디(identity)가 불안정하여 diffing 실패
  • 셀마다 상태가 섞여 의도치 않은 UI 변경

대부분은 정체성(Identity)  비동기 로딩 방식을 올바르게 설계하지 않아 발생한다.

SwiftUI는 선언형이라 그리는 방식이 아니라

데이터 변화에 따라 뷰 트리를 재생성하는 방식이기 때문에

리스트 성능 최적화는 필수이다.

 

2. 잘못된 패턴 예시

❌ 예시 1: ForEach에서 id를 잘못 지정해 깜빡임 발생

List {
    ForEach(items, id: \.self) { item in     // ❌ self는 불안정한 ID
        Row(item: item)
    }
}

문제점

  • 값 타입에서 내용이 조금만 변해도 다른 셀로 인식됨
  • 전체 셀이 재렌더링되면서 깜빡임
  • diffing 효율이 떨어져 불필요한 애니메이션 발생

❌ 예시 2: Row 내부에서 비동기 로딩을 직접 수행

struct WrongRow: View {
    let item: Item
    @State private var detail: Detail?

    var body: some View {
        VStack {
            Text(item.title)
            Text(detail?.note ?? "로딩 중…")
        }
        .task {
            detail = try? await api.fetchDetail(item.id)   // ❌ 셀마다 실행됨
        }
    }
}

문제점

  • 스크롤 시 동일 API가 반복 호출
  • 셀 재사용/재생성으로 동일 작업이 중복됨
  • 빠르게 스크롤하면 수십 개 요청이 동시에 발생
  • UI는 깜빡이고 성능은 크게 저하

❌ 예시 3: 셀 상태가 리스트 외부 상태와 충돌

struct WrongRow: View {
    @State var isExpanded = false     // ❌ 셀이 사라지면 상태가 초기화됨

    var body: some View {
        VStack { ... }
    }
}

문제점

  • 스크롤로 셀이 사라졌다 다시 나타나면 상태 초기화
  • 확장/축소 UI가 불안정
  • 셀 상태는 View 내부가 아닌 상위 레이어에서 관리되어야 함

 

3. 올바른 패턴 예시

✅ 예시 1: 안정적인 ID(identity) 사용

struct Item: Identifiable {
    let id: UUID
    let title: String
}

List(items) { item in
    Row(item: item)
}

장점

  • SwiftUI가 diffing을 정확하게 수행
  • 셀 깜빡임·재렌더링 최소화
  • 아이디가 변하지 않아 업데이트가 필요한 부분만 변경

✅ 예시 2: 비동기 요청은 셀이 아닌 상위 ViewModel에서 수행

@MainActor
final class ItemsViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var details: [UUID: Detail] = [:]

    func load() async {
        items = await api.fetchItems()

        for item in items {
            Task {
                let detail = try? await api.fetchDetail(item.id)
                await MainActor.run {
                    details[item.id] = detail
                }
            }
        }
    }
}

사용:

struct ItemsView: View {
    @StateObject private var vm = ItemsViewModel()

    var body: some View {
        List(vm.items) { item in
            Row(item: item, detail: vm.details[item.id])
        }
        .task { await vm.load() }
    }
}

장점

  • 비동기 로딩은 단 한 곳에서 수행
  • Row는 단순히 “표현”만 담당 → 깜빡임 없음
  • 스크롤과 무관하게 안정적으로 로딩됨

✅ 예시 3: 셀 내부 상태를 외부에서 관리 (식별 가능한 상태)

struct ItemRow: View {
    let item: Item
    @Binding var isExpanded: Bool

    var body: some View {
        VStack {
            Text(item.title)
            if isExpanded { Text("더보기 내용") }
        }
        .onTapGesture { isExpanded.toggle() }
    }
}

상위에서 관리:

@State private var expanded: [UUID: Bool] = [:]

List(items) { item in
    ItemRow(item: item, isExpanded: Binding(
        get: { expanded[item.id] ?? false },
        set: { expanded[item.id] = $0 }
    ))
}

장점

  • 셀이 사라졌다 나타나도 상태가 유지됨
  • 스크롤 위치와 관계없이 일관된 UI
  • 복잡한 리스트에서도 안정성 향상

✅ 예시 4: 이미지 캐싱 포함한 효율적 셀 렌더링

(28번 팁에서 만든 로더 활용)

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            CachedAsyncImage(url: item.thumbnail, size: CGSize(width: 80, height: 80))
                .frame(width: 60, height: 60)
            Text(item.title)
        }
    }
}

장점

  • 스크롤 시 깜빡임 제거
  • 반복 요청 없음
  • 메모리 안정성 증가

 

4. 실전 적용 팁

✔ 팁 1 – ID는 절대 변하면 안 되는 안정적인 값이어야 한다

특히 정렬·필터링 시 ID가 변경되지 않도록 주의.

✔ 팁 2 – 비동기 로딩은 Row가 아니라 ViewModel에서

Row는 가볍게 유지해야 스크롤 성능이 좋아진다.

✔ 팁 3 – 셀 내부의 상태는 상위 View에서 관리

Row 내부에 @State를 넣으면 UI 불안정의 주요 원인.

✔ 팁 4 – 이미지 로딩은 캐시 + 다운샘플링이 필수

리스트 성능에 가장 큰 영향을 미친다.

✔ 팁 5 – 스크롤 중 무거운 연산 절대 금지

Layout 연산, JSON 디코딩, 이미지 리사이즈 등은 백그라운드에서 처리.

 

5. 정리

  • SwiftUI 리스트 성능 최적화의 핵심은 정체성(Identity), 비동기 로딩 관리,
    그리고 셀 상태를 어디에 둘 것인가이다.
  • 네트워크/이미지 로딩을 셀에서 수행하면 성능 문제는 반드시 발생하므로
    ViewModel 단일 지점에서 처리하도록 구조를 설계해야 한다.
  • 안정적인 ID와 캐시 전략을 도입하면 깜빡임 없는 자연스러운 스크롤 경험을 만들 수 있다.
반응형
Posted by 까칠코더
,