SwiftUI의 LazyVStack 및 LazyHStack이 무엇인지 알아보세요 — iOS, macOS, watchOS 및 tvOS에서 스크롤 가능한 목록, 그리드, 캐러셀을 효율적으로 렌더링하는 지연 스택입니다. 일반 VStack 및 HStack과 달리 지연 스택은 요소가 가시 영역에 나타날 때만 생성되므로 대규모 데이터 세트 작업 시 메모리 소비를 크게 줄입니다. 지연 스택의 아키텍처는 Layout 프로토콜을 기반으로 하며 ForEach 및 ScrollView를 통한 식별과 통합됩니다.
주요 포인트
LazyVStack 및 LazyHStack은 SwiftUI의 레이아웃 컨테이너로, 스크롤 가능한 영역에서 보일 때만 자식 뷰를 생성하고 표시합니다. LazyVStack은 요소를 세로로(위에서 아래로) 배열하고 LazyHStack은 가로로(왼쪽에서 오른쪽으로) 배열합니다.
두 스택 모두 Apple이 SwiftUI 2.0(iOS 14, macOS 11, watchOS 7, tvOS 14)에서 LazyVGrid 및 LazyHGrid와 함께 도입했습니다. 지연 스택이 등장하기 전에는 개발자가 대규모 목록을 효율적으로 처리하기 위해 UIViewRepresentable을 통해 UITableView 및 UICollectionView를 사용해야 했습니다. LazyVStack은 자동 지연 로딩이 있는 기본 SwiftUI 인터페이스를 제공하여 이러한 필요성을 제거했습니다.
Apple WWDC Session 10031(2020)에 따르면 지연 스택은 지연 뷰 생성 메커니즘을 사용합니다. SwiftUI는 소스 데이터(예: 모델 배열)를 저장하고 화면에 렌더링하기 직전에 뷰 인스턴스를 생성합니다. 스크롤 시 스택은 이미 생성된 뷰를 재사용하여 새 할당을 피합니다. 이로 인해 메모리 할당자와 Swift의 가비지 컬렉터에 대한 부하가 줄어듭니다.
지연 스택으로 작업하려면 항상 ScrollView 내부에 배치하세요. 스크롤이 없으면 화면 경계를 넘는 요소는 지연 생성되지 않고 그냥 잘립니다.
LazyVStack의 지연 로딩 메커니즘은 기하학에 기반합니다. SwiftUI는 ScrollView 컨테이너를 기준으로 각 자식 뷰의 위치를 추적합니다. 요소가 가시 영역 경계를 넘으면(약간의 버퍼 포함) 시스템이 해당 초기화자를 호출하고 콘텐츠를 렌더링합니다. 요소가 화면을 벗어나면 SwiftUI는 뷰를 파괴하지만 @State를 통해 상태를 보존합니다(보존 가능으로 표시된 경우).
이 접근 방식은 가시성에 관계없이 모든 자식 뷰가 컨테이너 초기화 시 즉시 생성되는 VStack과 다릅니다. 10,000개 요소 목록의 경우 VStack은 메모리에 10,000개의 뷰 인스턴스를 생성하지만 LazyVStack은 화면에 맞는 것만 생성합니다(보통 8~15개).
LazyVStack은 세 가지 구성 매개변수를 허용합니다: alignment(HorizontalAlignment — leading, center, trailing), spacing(CGFloat — 요소 간 간격), pinnedViews(PinnedScrollableViews — 섹션 헤더 고정). LazyHStack은 동일한 매개변수를 사용하지만 alignment는 VerticalAlignment(top, center, bottom)을 허용합니다.
LazyVStack과 VStack의 주요 차이점은 자식 요소 생성 전략입니다. VStack(eager stack)은 렌더링 시 모든 자식 뷰의 크기와 위치를 계산하므로 대규모 동적 목록에 부적합합니다. LazyVStack(lazy stack)은 요소가 보일 때까지 생성을 지연합니다.
1000개 텍스트 줄 목록을 사용하여 동작을 비교해 보겠습니다. VStack은 모든 1000개 줄을 즉시 메모리에 로드하고 각 줄의 초기화자를 호출하여 메모리를 할당합니다. 이로 인해 약한 기기(iPhone SE, iPad mini)에서 성능 저하 및 화면 시작 시간 증가가 발생합니다. LazyVStack은 보이는 10~12개 줄만 로드하고 나머지는 스크롤 시 생성합니다.
실제 테스트(Xcode Instruments, Allocations 프로필 사용)에 따르면 iPhone 12 mini에서 LazyVStack을 사용한 5000개 요소 목록은 3~5MB의 메모리를 소비하는 반면 동일한 내용의 VStack은 150~250MB를 소비합니다 — 50배 차이. 한편 LazyVStack의 초기 렌더링 시간은 ~50ms이고 동일 기기의 VStack은 ~800ms입니다.
정적 또는 짧은 목록(최대 10~15개 요소)에는 VStack을 선택하고 동적 또는 잠재적으로 긴 목록에는 LazyVStack을 선택하세요. Apple은 목록의 최대 크기를 확신할 수 없는 경우 기본적으로 LazyVStack을 사용할 것을 권장합니다.
VStack은 정적 인터페이스에 가장 적합합니다: 프로필 화면, 로그인 양식, 제품 카드 — 요소 수를 알고 10~15개를 초과하지 않는 경우. VStack은 기하학 추적 및 지연 로딩에 리소스를 낭비하지 않으므로 이러한 양의 초기 렌더링에서 더 빠르게 작동합니다. 또한 VStack은 ScrollView 외부에서도 올바르게 작동하지만(ZStack 또는 Group 내부 등) LazyVStack은 ScrollView 없이 목적을 잃습니다.
지연 스택은 요소 수가 많거나 예측할 수 없는 시나리오에 최적입니다: 소셜 미디어 피드, 제품 카탈로그, 채팅 목록, 미디어 파일 라이브러리, 이벤트 로그, 수천 개 레코드의 관리 패널.
특정 사용 사례: 메신저의 메시지 목록(수만 개의 메시지), 갤러리 앱의 이미지 캐러셀, 무한 로딩 뉴스 피드, 온라인 스토어의 주문 목록. LazyHStack은 특히 가로 캐러셀에 유용합니다 — 예를 들어 Instagram Stories 또는 프로모션 배너.
금기 사항: 요소 나타남 애니메이션이 있는 인터페이스(지연 스택은 추가 로직 없이 요소 삭제 상태 간 전환을 지원하지 않음), 모든 요소가 동시에 보여야 하는 경우(체크박스의 짧은 목록), 셀 재사용을 정밀하게 제어해야 하는 경우(이 경우 List 또는 Table이 더 나을 수 있음).
기본 예제는 최소 메모리 소비로 1000개 요소를 표시합니다. 주요 요소: ScrollView 스크롤 컨테이너, LazyVStack 지연 로딩용, ForEach 데이터 반복용 식별자 포함.
import SwiftUI
struct LazyListExample: View {
let items = Array(0..<1000)
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items, id: \.self) { index in
Text("요소 #\(index)")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
}
}
이 코드는 요소 간 8pt 간격의 LazyVStack이 포함된 ScrollView를 만듭니다. ForEach는 items 배열을 반복하고 각 인덱스에 대한 Text를 생성합니다. 지연 로딩 덕분에 1000개 요소 중 보이는 10~12개만 한 번에 메모리에 있습니다.
이 예제는 iOS 연락처와 유사하게 고정 헤더로 요소를 섹션별로 그룹화하는 방법을 보여줍니다. Section이 헤더와 콘텐츠를 정의하고 pinnedViews: .sectionHeaders가 스크롤 시 헤더를 화면 상단에 고정합니다.
import SwiftUI
struct SectionedList: View {
let cities = ["모스크바", "런던", "도쿄", "뉴욕", "파리"]
let countries = ["러시아", "영국", "일본", "미국", "프랑스"]
var body: some View {
ScrollView {
LazyVStack(pinnedViews: .sectionHeaders) {
Section(header: Text("도시").font(.title).bold()) {
ForEach(cities, id: \.self) { city in
Text(city).padding(8)
}
}
Section(header: Text("국가").font(.title).bold()) {
ForEach(countries, id: \.self) { country in
Text(country).padding(8)
}
}
}
}
}
}
고정 헤더(.sectionHeaders)는 UITableView section headers처럼 작동합니다: 섹션을 스크롤하면 헤더가 전체 섹션이 사라질 때까지 화면 상단에 "고정"되었다가 다음 섹션의 헤더로 교체됩니다. pinnedViews는 결합할 수 있습니다: .sectionHeaders와 .sectionFooters를 동시에.
LazyHStack은 가로 스크롤 — 이미지 캐러셀, 가로 카테고리 목록에 사용됩니다. alignment: .top 매개변수는 요소를 상단 가장자리에 정렬합니다.
import SwiftUI
struct HorizontalCarousel: View {
let colors: [Color] = [.red, .blue, .green, .orange, .purple, .pink]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16, alignment: .top) {
ForEach(0..<100, id: \.self) { index in
RoundedRectangle(cornerRadius: 12)
.fill(colors[index % colors.count])
.frame(width: 150, height: 200)
.overlay(Text("\(index + 1)").foregroundColor(.white).bold())
}
}
.padding(.horizontal)
}
.frame(height: 220)
}
}
이 코드는 LazyHStack을 사용한 가로 ScrollView를 만듭니다. 100개의 사각형 중 2~3개만 동시에 표시됩니다(화면 너비와 요소 크기에 따라 다름). 왼쪽으로 스크롤하면 새 요소가 지연 로딩됩니다. 컨테이너 높이는 고정(220pt)되어 가로 스크롤에서 무한 높이를 방지합니다.
PinnedScrollableViews는 LazyVStack 및 LazyHStack의 구성 옵션으로, 스크롤 시 섹션 헤더와 푸터 고정을 제어합니다. 두 가지 값이 지원됩니다: sectionHeaders(헤더가 컨테이너 시작 부분에 고정) 및 sectionFooters(푸터가 끝 부분에 고정).
고정 뷰 메커니즘은 LazyVStack에 중첩된 Section 컨테이너 내에서만 작동합니다. 각 Section에는 자동으로 고정 동작을 얻는 헤더 및/또는 푸터가 있습니다. SwiftUI는 ScrollView 경계를 기준으로 각 섹션의 위치를 추적하고 섹션 간 전환 시 고정 요소의 가시성을 전환합니다.
중요: pinnedViews는 레이아웃 계산 복잡성을 증가시킵니다. SwiftUI가 현재 어떤 헤더가 고정되어 있는지 지속적으로 재계산해야 하기 때문입니다. 기능이 실제로 필요할 때만 pinnedViews를 사용하세요 — 섹션이 없는 단순한 목록의 경우 이 매개변수를 생략하는 것이 좋습니다. Apple은 문서(Human Interface Guidelines, 2024)에서 알파벳 색인 및 날짜 기반 그룹화에 고정 헤더를 사용할 것을 권장합니다.
식별자의 올바른 사용은 LazyVStack의 가장 중요한 성능 요소입니다. ForEach의 각 요소는 안정적인 고유 id를 가져야 합니다. 기본 형(Int, String)과 함께 \.self를 사용하는 것은 허용되지만 데이터 모델의 경우 항상 Identifiable 프로토콜을 구현하세요. 불안정한 id(예: 매번 생성되는 UUID)는 SwiftUI가 업데이트할 때마다 모든 뷰를 다시 생성하게 만듭니다.
각 스택 요소의 body 내에서 무거운 계산을 피하세요. 요소에 복잡한 레이아웃이나 데이터 처리가 포함된 경우 로직을 자체 지연 로딩이 있는 별도의 뷰 구조로 추출하세요. 요소의 데이터가 변경되지 않은 경우 불필요한 다시 그리기를 방지하기 위해 EquatableView를 사용하세요.
LazyVStack 내부의 이미지에는 항상 비동기 로딩(AsyncImage) 또는 Kingfisher/Nuke를 통한 캐싱을 사용하세요. 각 요소는 화면에 나타날 때 동기식으로 이미지를 로드해서는 안 됩니다 — 스크롤 저크가 발생합니다. WWDC Session 10031에 따르면 프리페치 버퍼의 최적 크기는 현재 위치에서 앞뒤로 3~5화면입니다.
Xcode Instruments를 SwiftUI 프로필과 함께 사용하여 성능을 측정하세요. 메트릭에 주의하세요: body evaluations, allocations, 프레임 속도(FPS). 목표 값: 스크롤 시 FPS > 55, 요소당 렌더링 시간 < 1ms.
자주 묻는 질문
List는 내장 기능을 제공합니다: 스와이프 편집(swipeActions), .onDelete를 통한 삭제, .onMove를 통한 재정렬, 그룹화 스타일 .insetGrouped. LazyVStack은 편집 제스처에 대한 내장 지원이 없는 하위 수준 도구입니다. List는 내부적으로 LazyVStack을 사용하지만 네이티브 iOS 테이블 스타일을 추가합니다. 사용자 지정 셀 디자인이 필요하고 내장 편집이 필요하지 않은 경우 LazyVStack을 선택하세요. swipeActions, .onDelete 및 @FetchRequest 작업이 필요한 경우 List를 사용하세요.
지연 스택은 프리페칭을 사용합니다 — SwiftUI는 부드러운 스크롤을 보장하기 위해 작은 사전 버퍼(프리페치 버퍼)로 요소를 생성합니다. 버퍼 크기는 스크롤 속도와 기기 성능에 따라 자동 조정됩니다. Apple 프로파일링 데이터에 따르면 프리페치 버퍼는 일반적으로 스크롤 방향으로 1~3화면입니다. 너무 많은 보이지 않는 요소가 생성되는 경우 매번 생성되는 식별자나 뷰 초기화자의 무거운 계산이 있는지 확인하세요.
예, 하지만 제한이 있습니다. LazyVStack을 VStack 안에 중첩하는 것은 의미가 없습니다 — 외부 VStack이 내부 LazyVStack의 모든 요소를 즉시 생성하여 지연 로딩을 무효화합니다. VStack을 LazyVStack 안에 중첩하는 것은 괜찮으며 지연 메커니즘을 깨뜨리지 않습니다. LazyVStack을 다른 LazyVStack 안에 중첩하는 것은 중첩된 섹션에서 허용되지만 성능에 주의하세요: 각 수준이 기하학 추적에 오버헤드를 추가합니다.
SwiftUI는 LazyVStack에 내장 구분선을 제공하지 않습니다. 수동으로 추가하세요: ForEach의 각 요소 뒤에 Divider()를 배치하거나 각 요소에 .overlay(Divider(), alignment: .bottom) 수정자를 사용하세요. 사용자 지정 구분선의 경우 Rectangle().frame(height: 1).foregroundColor(.gray.opacity(0.3))을 그립니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.