SwiftUI 성능: LazyVStack과 복잡한 리스트 최적화
LazyVStack과 SwiftUI 리스트를 위한 최적화 기법. 메모리 소비를 줄이고 스크롤 성능을 향상시키며 흔한 함정을 피합니다.

리스트는 iOS 애플리케이션에서 가장 자주 사용되는 컴포넌트 중 하나를 나타냅니다. SwiftUI의 LazyVStack과 List는 데이터 컬렉션을 표시하기 위한 고성능 솔루션을 제공하지만 잘못된 사용은 사용자 경험을 빠르게 저하시킬 수 있습니다. 이러한 컴포넌트의 내부 메커니즘을 이해하면 흔한 함정을 피하고 부드러운 인터페이스를 구축하는 데 도움이 됩니다.
이 글은 SwiftUI 리스트를 위한 필수 최적화 기법인 레이지 로딩, 뷰 재활용, 식별자 관리 및 대규모 데이터셋을 위한 고급 패턴을 소개합니다.
SwiftUI에서 레이지 로딩 이해하기
레이지 로딩은 뷰가 화면에 표시될 때만 인스턴스화합니다. 모든 자식 뷰를 즉시 생성하는 VStack과 달리 LazyVStack은 이 생성을 지연시켜 메모리 소비와 초기 렌더링 시간을 크게 줄입니다.
import SwiftUI
// ❌ Problem: VStack instantiates all 10,000 views immediately
struct NonLazyListView: View {
let items = (1...10000).map { "Item \($0)" }
var body: some View {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
// Each view is created at launch
ExpensiveRowView(title: item)
}
}
}
}
}
// ✅ Solution: LazyVStack creates views on demand
struct LazyListView: View {
let items = (1...10000).map { "Item \($0)" }
var body: some View {
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
// Only visible views are created
ExpensiveRowView(title: item)
}
}
}
}
}성능 차이는 수백 개의 요소만으로도 두드러집니다. 10,000개의 항목으로 VStack은 시작하는 데 몇 초가 걸릴 수 있지만 LazyVStack은 즉시 응답합니다.
레이지 로딩의 영향 측정하기
Instruments는 메모리와 CPU 사용량을 정확하게 측정할 수 있게 합니다. 다음은 차이를 보여주는 테스트 뷰입니다:
struct ExpensiveRowView: View {
let title: String
// Simulating expensive initialization
init(title: String) {
self.title = title
// Log to visualize when the view is created
print("Creating row: \(title)")
}
var body: some View {
HStack {
// Image with processing
Circle()
.fill(
LinearGradient(
colors: [.blue, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 50, height: 50)
VStack(alignment: .leading) {
Text(title)
.font(.headline)
Text("Subtitle with computation")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}VStack으로 실행하면 10,000개의 로그가 즉시 모두 나타납니다. LazyVStack을 사용하면 표시되는 요소(화면 크기에 따라 약 15-20개)만 처음에 로그되고 스크롤하면서 더 많이 나타납니다.
LazyVStack은 생성된 뷰를 표시된 후 메모리에 유지합니다. 셀을 적극적으로 재활용하는 List와 달리 LazyVStack 내의 뷰는 부모 컴포넌트가 파괴될 때까지 유지됩니다.
안정적인 식별자의 중요성
식별자는 SwiftUI 리스트 업데이트의 중심 메커니즘을 형성합니다. 불안정한 식별자는 불필요한 뷰 재생성을 일으키고 잘못된 애니메이션이나 스크롤 위치 손실 같은 시각적 버그를 트리거할 수 있습니다.
// ❌ Problem: using index as identifier
struct UnstableIdentifierView: View {
@State private var items = ["A", "B", "C", "D"]
var body: some View {
List {
// Index changes if an element is deleted
ForEach(items.indices, id: \.self) { index in
Text(items[index])
}
}
}
}
// ❌ Problem: using UUID() in ForEach
struct RegeneratedIdentifierView: View {
let items = ["A", "B", "C", "D"]
var body: some View {
List {
// UUID() generates a new ID on each render
ForEach(items, id: \.self) { item in
// Subtle issue if items contain duplicates
Text(item)
}
}
}
}
// ✅ Solution: model with stable identifier
struct Item: Identifiable {
let id: UUID // Created once
var name: String
init(name: String) {
self.id = UUID()
self.name = name
}
}
struct StableIdentifierView: View {
@State private var items = [
Item(name: "A"),
Item(name: "B"),
Item(name: "C"),
Item(name: "D")
]
var body: some View {
List {
// id is stable for the item's lifetime
ForEach(items) { item in
Text(item.name)
}
}
}
}고유하고 영구적인 식별자를 사용하면 SwiftUI가 업데이트, 애니메이션 및 비교 중에 요소를 정확하게 구별할 수 있습니다.
Equatable로 셀 최적화하기
SwiftUI는 재렌더링이 필요한지 결정하기 위해 뷰를 비교합니다. 기본적으로 이 비교는 리플렉션을 사용하므로 비용이 많이 들 수 있습니다. Equatable을 구현하면 최적화되고 명시적인 비교가 가능합니다.
// Data model
struct Contact: Identifiable, Equatable {
let id: UUID
var name: String
var email: String
var avatarURL: URL?
var lastActivity: Date
// Custom comparison: ignore lastActivity
// if other properties are identical
static func == (lhs: Contact, rhs: Contact) -> Bool {
lhs.id == rhs.id &&
lhs.name == rhs.name &&
lhs.email == rhs.email &&
lhs.avatarURL == rhs.avatarURL
// lastActivity intentionally excluded
}
}
// Optimized cell view
struct ContactRow: View, Equatable {
let contact: Contact
// Explicit comparison to avoid unnecessary re-renders
static func == (lhs: ContactRow, rhs: ContactRow) -> Bool {
lhs.contact == rhs.contact
}
var body: some View {
HStack(spacing: 12) {
// Async avatar
AsyncImage(url: contact.avatarURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "person.circle.fill")
.foregroundStyle(.gray)
default:
ProgressView()
}
}
.frame(width: 44, height: 44)
.clipShape(Circle())
// Contact information
VStack(alignment: .leading, spacing: 2) {
Text(contact.name)
.font(.body.weight(.medium))
Text(contact.email)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
// List using EquatableView
struct ContactListView: View {
let contacts: [Contact]
var body: some View {
List {
ForEach(contacts) { contact in
// EquatableView prevents re-renders if contact unchanged
EquatableView(content: ContactRow(contact: contact))
}
}
}
}이 최적화는 특히 계산이나 이미지가 포함된 셀에서 빠른 스크롤 중 CPU 부하를 크게 줄입니다.
iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
비동기 이미지 로딩 관리하기
이미지는 종종 리스트의 성능 병목 지점이 됩니다. 잘못된 처리는 스크롤 끊김과 과도한 메모리 소비를 유발합니다.
import SwiftUI
// Singleton image cache
actor ImageCache {
static let shared = ImageCache()
private var cache = NSCache<NSString, UIImage>()
private init() {
// Memory limit: 50 MB
cache.totalCostLimit = 50 * 1024 * 1024
}
func image(for url: URL) -> UIImage? {
cache.object(forKey: url.absoluteString as NSString)
}
func setImage(_ image: UIImage, for url: URL) {
// Cost estimation: image bytes
let cost = Int(image.size.width * image.size.height * 4)
cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
}
}
// Optimized image view with caching
struct CachedAsyncImage: View {
let url: URL?
let size: CGSize
@State private var image: UIImage?
@State private var isLoading = false
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else if isLoading {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay(ProgressView())
} else {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
}
.frame(width: size.width, height: size.height)
.clipped()
.task(id: url) {
await loadImage()
}
}
private func loadImage() async {
guard let url else { return }
// Check cache
if let cached = await ImageCache.shared.image(for: url) {
self.image = cached
return
}
isLoading = true
defer { isLoading = false }
// Download and resize
do {
let (data, _) = try await URLSession.shared.data(from: url)
// Resize to save memory
if let original = UIImage(data: data),
let resized = await resizeImage(original, to: size) {
await ImageCache.shared.setImage(resized, for: url)
self.image = resized
}
} catch {
// Handle error silently
}
}
private func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage? {
// Use screen scale
let scale = await UIScreen.main.scale
let targetSize = CGSize(
width: size.width * scale,
height: size.height * scale
)
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let renderer = UIGraphicsImageRenderer(size: targetSize)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
continuation.resume(returning: resized)
}
}
}
}이 구현은 메모리 캐싱, 사전 크기 조정, 비동기 로딩을 결합하여 부드러운 스크롤 경험을 제공합니다.
예측을 위한 지능적인 프리페칭
매우 긴 리스트의 경우 프리페칭은 이미지가 표시되기 전에 로드합니다:
// Prefetching coordinator
@Observable
final class ImagePrefetcher {
private var prefetchTasks: [URL: Task<Void, Never>] = [:]
private let prefetchDistance = 10 // Number of items ahead
func prefetchImages(for items: [Contact], visibleRange: Range<Int>) {
// Calculate prefetch range
let prefetchStart = max(0, visibleRange.lowerBound - prefetchDistance)
let prefetchEnd = min(items.count, visibleRange.upperBound + prefetchDistance)
// Launch prefetch for items in range
for index in prefetchStart..<prefetchEnd {
guard let url = items[index].avatarURL else { continue }
// Avoid duplicates
guard prefetchTasks[url] == nil else { continue }
prefetchTasks[url] = Task {
// Check if already cached
if await ImageCache.shared.image(for: url) != nil {
return
}
// Prefetch
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
await ImageCache.shared.setImage(image, for: url)
}
} catch {
// Ignore prefetch errors
}
}
}
// Cancel out-of-range prefetches
cancelOutOfRangePrefetches(validRange: prefetchStart..<prefetchEnd, items: items)
}
private func cancelOutOfRangePrefetches(validRange: Range<Int>, items: [Contact]) {
let validURLs = Set(
items[validRange].compactMap { $0.avatarURL }
)
for (url, task) in prefetchTasks {
if !validURLs.contains(url) {
task.cancel()
prefetchTasks.removeValue(forKey: url)
}
}
}
}List와 LazyVStack 사이의 선택
List와 LazyVStack 사이의 선택은 사용 사례에 따라 다릅니다. 각 컴포넌트는 알아둘 가치가 있는 특정한 장점을 가지고 있습니다.
// ✅ List: ideal for interactive content
// - Automatic cell recycling
// - Native swipe actions support
// - Built-in separators and styles
struct ContactsWithSwipeActions: View {
@State private var contacts: [Contact] = []
var body: some View {
List {
ForEach(contacts) { contact in
ContactRow(contact: contact)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteContact(contact)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
favoriteContact(contact)
} label: {
Label("Favorite", systemImage: "star")
}
.tint(.yellow)
}
}
}
.listStyle(.plain)
}
private func deleteContact(_ contact: Contact) {
contacts.removeAll { $0.id == contact.id }
}
private func favoriteContact(_ contact: Contact) {
// Favorite logic
}
}
// ✅ LazyVStack: ideal for custom layouts
// - Full control over spacing and padding
// - No imposed styles
// - Better performance for simple display
struct CustomFeedView: View {
let posts: [Post]
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(posts) { post in
PostCard(post: post)
}
}
.padding(.horizontal)
}
}
}
// Post model for example
struct Post: Identifiable {
let id: UUID
let author: String
let content: String
let imageURL: URL?
}
struct PostCard: View {
let post: Post
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
HStack {
Circle()
.fill(Color.blue)
.frame(width: 40, height: 40)
Text(post.author)
.font(.headline)
Spacer()
}
// Content
Text(post.content)
// Optional image
if let imageURL = post.imageURL {
CachedAsyncImage(url: imageURL, size: CGSize(width: 300, height: 200))
.cornerRadius(12)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
}
}List는 셀을 적극적으로 재활용하여 로컬 상태(@State)에 문제를 일으킬 수 있습니다. List 셀의 @State 값은 예기치 않게 재사용될 수 있습니다. 상태는 데이터 모델이나 ViewModel에 저장하는 것이 좋습니다.
최적화된 섹션과 헤더
콘텐츠를 섹션으로 구성하면 가독성이 향상되지만 잘못 구현하면 성능에 영향을 미칠 수 있습니다. 고정된 헤더와 섹션 관리에는 특별한 주의가 필요합니다.
// Grouped data model
struct GroupedContacts {
let letter: String
let contacts: [Contact]
}
// View with optimized sections
struct SectionedContactList: View {
let groupedContacts: [GroupedContacts]
var body: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(groupedContacts, id: \.letter) { group in
Section {
// Section content
ForEach(group.contacts) { contact in
ContactRow(contact: contact)
.padding(.horizontal)
.padding(.vertical, 8)
// Custom separator
if contact.id != group.contacts.last?.id {
Divider()
.padding(.leading, 68)
}
}
} header: {
// Optimized pinned header
SectionHeader(title: group.letter)
}
}
}
}
}
}
// Lightweight header for performance
struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.headline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
}
}
// Optimized grouping function
extension Array where Element == Contact {
func groupedByFirstLetter() -> [GroupedContacts] {
// Dictionary for O(n) grouping
var groups: [String: [Contact]] = [:]
for contact in self {
let letter = String(contact.name.prefix(1)).uppercased()
groups[letter, default: []].append(contact)
}
// Sort groups alphabetically
return groups
.map { GroupedContacts(letter: $0.key, contacts: $0.value) }
.sorted { $0.letter < $1.letter }
}
}고정된 헤더(pinnedViews: [.sectionHeaders])는 스크롤 중에도 표시 상태를 유지하여 긴 리스트에서의 탐색을 향상시킵니다.
페이지네이션과 무한 스크롤
대규모 데이터셋의 경우 페이지네이션은 모든 데이터를 메모리에 로드하는 것을 방지합니다. 구현은 사용자에게 투명해야 합니다.
// ViewModel handling pagination
@Observable
final class PaginatedListViewModel {
private(set) var items: [Contact] = []
private(set) var isLoading = false
private(set) var hasMorePages = true
private var currentPage = 0
private let pageSize = 20
private let dataService: ContactDataService
init(dataService: ContactDataService) {
self.dataService = dataService
}
func loadInitialData() async {
guard items.isEmpty else { return }
await loadNextPage()
}
func loadMoreIfNeeded(currentItem: Contact) async {
// Trigger loading when approaching the end
guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else {
return
}
// Load 5 items before the end
let thresholdIndex = items.count - 5
if index >= thresholdIndex {
await loadNextPage()
}
}
private func loadNextPage() async {
guard !isLoading, hasMorePages else { return }
isLoading = true
defer { isLoading = false }
do {
let newItems = try await dataService.fetchContacts(
page: currentPage,
limit: pageSize
)
items.append(contentsOf: newItems)
currentPage += 1
hasMorePages = newItems.count == pageSize
} catch {
// Handle error
}
}
}
// View with infinite scroll
struct InfiniteContactList: View {
@State private var viewModel: PaginatedListViewModel
init(dataService: ContactDataService) {
_viewModel = State(initialValue: PaginatedListViewModel(dataService: dataService))
}
var body: some View {
List {
ForEach(viewModel.items) { contact in
ContactRow(contact: contact)
.task {
// Check if more loading needed
await viewModel.loadMoreIfNeeded(currentItem: contact)
}
}
// Loading indicator at end of list
if viewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding()
}
}
.task {
await viewModel.loadInitialData()
}
}
}
// Protocol for data service
protocol ContactDataService {
func fetchContacts(page: Int, limit: Int) async throws -> [Contact]
}이 패턴은 인터페이스를 차단하지 않고 부드러운 로딩을 보장하며 효율적인 메모리 관리를 가능하게 합니다.
iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
Instruments로 프로파일링하기
성능 문제를 식별하려면 정확한 측정 도구가 필요합니다. Instruments는 SwiftUI에 적합한 여러 템플릿을 제공합니다.
// Measurement points for debugging
struct PerformanceMonitor {
// Measure view creation time
static func measureViewCreation<T: View>(
_ name: String,
@ViewBuilder content: () -> T
) -> T {
let start = CFAbsoluteTimeGetCurrent()
let view = content()
let elapsed = CFAbsoluteTimeGetCurrent() - start
#if DEBUG
if elapsed > 0.016 { // More than 16ms = frame drop
print("⚠️ [\(name)] View creation took \(elapsed * 1000)ms")
}
#endif
return view
}
}
// Extension to trace renders
extension View {
func debugRender(_ label: String) -> some View {
#if DEBUG
let _ = Self._printChanges()
print("🔄 Rendering: \(label)")
#endif
return self
}
func measureRender(_ label: String) -> some View {
modifier(RenderMeasureModifier(label: label))
}
}
struct RenderMeasureModifier: ViewModifier {
let label: String
@State private var renderCount = 0
func body(content: Content) -> some View {
content
.onAppear {
renderCount += 1
#if DEBUG
print("📊 [\(label)] Render count: \(renderCount)")
#endif
}
}
}Instruments 최적화 체크리스트
효과적인 SwiftUI 리스트 프로파일링을 위해:
- Time Profiler: CPU를 가장 많이 소비하는 함수 식별
- Allocations: 스크롤 중 메모리 증가 확인
- SwiftUI Instrument: body 평가 시각화
- Core Animation: 프레임 드롭 감지
// Instrumented view for profiling
struct ProfiledContactList: View {
let contacts: [Contact]
var body: some View {
let _ = Self._printChanges() // Shows changes triggering re-render
List {
ForEach(contacts) { contact in
ContactRow(contact: contact)
.measureRender("ContactRow-\(contact.id)")
}
}
}
}이 SwiftUI 디버깅 API는 어떤 속성이 변경되어 body 재평가를 트리거했는지 콘솔에 출력합니다. 불필요한 재렌더링을 식별하는 데 필수적입니다.
drawingGroup으로 고급 최적화하기
많은 시각 효과가 있는 복잡한 뷰의 경우 drawingGroup()은 뷰를 Metal 레이어로 래스터화하여 성능을 크게 향상시킬 수 있습니다.
// Cell with complex visual effects
struct ComplexVisualRow: View {
let item: Item
var body: some View {
HStack(spacing: 16) {
// Circle with gradient and shadow
Circle()
.fill(
RadialGradient(
colors: [.blue, .purple, .pink],
center: .center,
startRadius: 0,
endRadius: 25
)
)
.frame(width: 50, height: 50)
.shadow(color: .purple.opacity(0.5), radius: 8, y: 4)
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.headline)
// Progress bar with gradient
GeometryReader { geometry in
Capsule()
.fill(Color.gray.opacity(0.2))
.overlay(alignment: .leading) {
Capsule()
.fill(
LinearGradient(
colors: [.green, .yellow, .orange],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: geometry.size.width * item.progress)
}
}
.frame(height: 8)
}
}
.padding()
// Rasterization for performance
.drawingGroup()
}
}
// List using optimized cells
struct OptimizedComplexList: View {
let items: [Item]
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
ComplexVisualRow(item: item)
}
}
.padding()
}
}
}
struct Item: Identifiable {
let id: UUID
let name: String
let progress: Double
}drawingGroup()은 그라디언트, 그림자, 블러 효과를 결합한 뷰에 특히 효과적입니다.
결론
SwiftUI 리스트 최적화는 레이지 로딩, 재활용, 뷰 비교 메커니즘에 대한 깊은 이해를 기반으로 합니다. 소개된 기법을 사용하면 60 FPS의 부드러운 스크롤을 유지하면서 수천 개의 요소를 처리할 수 있는 인터페이스를 구축할 수 있습니다.
SwiftUI 성능 체크리스트
- ✅ 컬렉션에는
VStack대신LazyVStack이나List를 사용 - ✅ 안정적이고 고유한 ID로
Identifiable구현 - ✅ 복잡한 셀에
Equatable채택 - ✅ 표시 전에 이미지를 캐시하고 크기 조정
- ✅ 지능적인 프리페칭으로 데이터 사전 로드
- ✅ 상호작용(스와이프)에는
List, 사용자 정의 레이아웃에는LazyVStack선택 - ✅ 섹션 헤더에
pinnedViews사용 - ✅ 대규모 데이터셋에 페이지네이션 구현
- ✅ Instruments로 정기적으로 프로파일링
- ✅ 복잡한 시각 효과가 있는 뷰에
drawingGroup()적용
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

SwiftUI 커스텀 ViewModifier: 디자인 시스템을 위한 재사용 가능한 패턴
일관된 디자인 시스템을 위해 SwiftUI에서 커스텀 ViewModifier를 구축합니다. iOS 뷰를 효율적으로 스타일링하기 위한 패턴, 베스트 프랙티스, 실용적인 예시를 다룹니다.

SwiftUI @Observable vs @State: 2026년에 무엇을 언제 사용할까
SwiftUI에서 @Observable과 @State의 차이를 마스터하고 iOS 앱에 적합한 상태 관리 도구를 선택해보세요.

SwiftUI: iOS를 위한 모던 인터페이스 구축 가이드
SwiftUI로 모던 UI를 구축하는 방법을 알아봅니다. 선언적 구문, 컴포넌트, 애니메이션, iOS 18 모범 사례를 종합적으로 다룹니다.