반응형

SwiftUI Study – ScrollView 안에서 LazyVStack을 사용해 성능을 최적화하는 방법

 

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

ScrollView는 기본적으로 모든 자식 뷰를 즉시 렌더링(eager rendering) 합니다.

따라서 다음과 같은 상황에서는 성능 문제가 바로 드러납니다.

  • 많은 개수의 셀(예: 50개 이상)
  • 이미지가 포함된 셀
  • 복잡한 레이아웃을 가진 셀
  • 상태 업데이트가 잦은 화면

이때 가장 흔한 실수는 아래와 같습니다.

ScrollView + VStack 구조를 그대로 사용해

모든 셀을 한 번에 렌더링하여 메모리와 CPU를 불필요하게 소모하는 것

SwiftUI는 이를 해결하기 위해 LazyVStack / LazyHStack / LazyVGrid를 제공합니다.

Lazy 계열은 화면에 보이는 시점에 맞춰 지연 렌더링(lazy rendering) 하므로 성능이 크게 개선됩니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: ScrollView + VStack 조합

struct WrongScrollView: View {
    let items = Array(0..<500)

    var body: some View {
        ScrollView {
            VStack(spacing: 12) {
                ForEach(items, id: \.self) { i in
                    RowView(index: i)
                }
            }
        }
    }
}

struct RowView: View {
    let index: Int
    var body: some View {
        Text("행 \(index)")
            .frame(maxWidth: .infinity)
            .padding()
            .background(Color.blue.opacity(0.2))
            .cornerRadius(8)
    }
}

문제점

  • 500개의 RowView를 한 번에 모두 초기화
  • 화면에서 보이지 않는 뷰도 렌더링되어 CPU 낭비
  • 스크롤과 애니메이션 시 렌더링 지연 가능
  • 메모리 사용량 증가

실무에서 리스트가 200개만 넘어가도 체감될 만큼 성능이 떨어집니다.

❌ 예시 2: 이미지가 포함된 셀을 ScrollView + VStack으로 렌더링

ScrollView {
    VStack {
        ForEach(urls, id: \.self) { url in
            AsyncImage(url: url) { image in
                image.resizable()
            }
        }
    }
}

문제점

  • 모든 이미지 다운로드가 즉시 시작
  • 캐시를 사용해도 초기 렌더링으로 인해 UI가 느려짐
  • 사용자가 보지도 않는 이미지까지 로드됨

 

3. 올바른 패턴 예시

✅ 예시 1: LazyVStack 사용

struct CorrectScrollView: View {
    let items = Array(0..<500)

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {
                ForEach(items, id: \.self) { i in
                    RowView(index: i)
                }
            }
        }
    }
}

장점

  • 화면에 보여야 할 시점에만 셀을 렌더링
  • 셀의 초기화 비용이 크게 줄어 성능 향상
  • 스크롤 시 부드러운 인터랙션 유지
  • 메모리 사용량 감소

SwiftUI의 Lazy 계열은 UIKit의 UITableView/UICollectionView와 유사한 방식입니다.

✅ 예시 2: LazyVGrid 사용해 복잡한 격자 레이아웃도 최적화

let columns = [
    GridItem(.flexible()),
    GridItem(.flexible())
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 20) {
        ForEach(0..<200) { index in
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.blue.opacity(0.3))
                .frame(height: 120)
                .overlay(Text("아이템 \(index)"))
        }
    }
    .padding()
}

장점

  • 복잡한 그리드도 부드럽게 렌더링
  • 셀이 화면에 나타나야 할 때만 그려짐
  • iPad 분할 화면에서도 안정적

✅ 예시 3: LazyStack + onAppear 조합으로 페이징(Pagination) 구현

struct PagingScrollView: View {
    @State private var items = Array(0..<50)

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items, id: \.self) { item in
                    Text("아이템 \(item)")
                        .padding()
                        .onAppear {
                            if item == items.last {
                                loadMore()
                            }
                        }
                }
            }
        }
    }

    func loadMore() {
        let next = items.count
        items.append(contentsOf: next..<(next + 20))
    }
}

장점

  • UIKit CollectionView 수준으로 자연스러운 인피니티 스크롤
  • 렌더링 비용이 낮아 성능 유지 가능

 

4. 실전 적용 팁

✔ 팁 1 – 상황에따라 ScrollView 안에서 VStack을 쓰는 건 “거의 금지”

특히 다음 조건이 있을 때는 반드시 LazyVStack으로 교체:

  • 셀이 50개 이상
  • 이미지 포함
  • 동적 셀 크기
  • 애니메이션 예상
  • 페이징 기능 구현

✔ 팁 2 – LazyStack은 높이 계산 방식이 다름을 이해하기

VStack은 모든 자식의 높이를 즉시 계산하지만,

LazyVStack은 보여줄 셀만 계산하므로 성능이 좋다.

✔ 팁 3 – LazyStack 내부에서 id는 안정적인 값 사용

렌더링 효율을 위해 index가 아닌 고유 ID 사용 권장.

✔ 팁 4 – LazyStack 안에서 상태 변경은 최소화

셀 내부 상태 변경이 많으면 다시 렌더링되므로

가능하면 ViewModel로 상태를 단일화하는 것이 좋다.

✔ 팁 5 – scrollDismissesKeyboard(.interactively)와도 잘 호환됨

LazyStack 기반 화면은 레이아웃이 안정되어

키보드 dismiss 동작도 자연스럽다.

 

5. 정리

  • ScrollView + VStack은 많은 셀을 렌더링할 때 성능 병목의 주요 원인이다.
  • LazyVStack/LazyVGrid는 SwiftUI에서 리스트 성능을 최적화하는 핵심 도구다.
  • 지연 렌더링은 메모리·CPU 사용량을 크게 줄여 UI 반응성을 향상시킨다.
  • 실무에서는 ScrollView를 쓰면 기본적으로 LazyVStack을 고려하는 것이 정석이다.
반응형
Posted by 까칠코더
,