SwiftUI에서 고급 리스트 및 페이지네이션
Source: Dev.to
리스트는 간단해 보이지만, 실제 피드를 만들려고 하면 문제가 발생합니다.
그때 마주치는 문제들:
- 무한 스크롤링 버그
- 중복 행
- 페이지네이션이 너무 자주 트리거됨
- 스크롤 위치가 튀는 현상
- 무거운 행으로 인한 성능 저하
- 비동기 레이스
- 로딩 상태가 곳곳에 존재
- 오프라인 + 페이지네이션 충돌
이 글에서는 production SwiftUI apps가 리스트와 페이지네이션을 어떻게 처리하는지 보여줍니다 — 깔끔하고, 빠르며, 예측 가능하고, 확장 가능한 방식으로.
🧠 핵심 원칙
- 데이터를 점진적으로 로드한다
- 스크롤을 절대 차단하지 않는다
- 식별자를 안정적으로 유지한다
- 중복 요청을 피한다
- 오류를 우아하게 처리한다
- 오프라인 데이터를 지원한다
- 대용량 데이터셋에서도 부드럽게 유지한다
🧱 1. 올바른 리스트 아키텍처
관심사를 분리하세요:
View
↓
ViewModel (pagination state)
↓
Service (fetch pages)
The ViewModel owns pagination logic — not the view.
📦 2. 페이지네이션 상태 모델
@Observable
class FeedViewModel {
var items: [Post] = []
var isLoading = false
var hasMore = true
var page = 0
}
이렇게 하면 페이지네이션이:
- 예측 가능
- 디버깅 가능
- 테스트 가능
🔄 3. 페이지를 안전하게 가져오기
@MainActor
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
defer { isLoading = false }
do {
let response = try await service.fetch(page: page)
items.append(contentsOf: response.items)
hasMore = response.hasMore
page += 1
} catch {
errors.present(map(error))
}
}
핵심 규칙
- 중복 호출을 방지
- 메인 액터에서 상태 업데이트
- append, 절대 교체하지 않음
hasMore를 명시적으로 추적
📜 4. 뷰에서 페이지네이션 트리거 (안전하게)
List {
ForEach(items) { item in
RowView(item: item)
.onAppear {
if item == items.last {
Task { await viewModel.loadNextPage() }
}
}
}
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
}
}
This:
- 동적 행 높이를 지원합니다
- 회전에도 정상 작동합니다
GeometryReader함정을 피합니다
🆔 5. 아이덴티티는 협상할 수 없습니다
잘못된 아이덴티티는 리스트 성능을 저하시킵니다.
ForEach(items, id: \.id) { ... }
규칙
- ID는 안정적이어야 합니다
UUID를 인라인으로 생성하지 마세요- 배열 인덱스를 사용하지 마세요
- ID를 변경하지 마세요
잘못된 아이덴티티가 초래하는 문제:
- 중복된 행
- 애니메이션 깜빡임
- 스크롤 점프
- 행 간 상태 누수
⚠️ 6. 행 내부에서 무거운 작업 피하기
절대 이렇게 하지 마세요:
RowView(item: item)
.task {
await expensiveWork()
}
대신
- ViewModel에서 미리 계산하기
- 결과를 캐시하기
- 행에 가벼운 데이터만 전달하기
행은 다음과 같이 해야 합니다:
- 비용이 적음
- 순수함
- 렌더링이 빠름
🧊 7. 스켈레톤 및 플레이스홀더 행
if items.isEmpty && isLoading {
ForEach(0..<5) { _ in
SkeletonRow()
}
}
This:
- 레이아웃을 유지합니다
- 더 빠르게 느껴집니다
- UI 점프를 방지합니다
📶 8. 오프라인 + 페이지네이션
오프라인일 때:
- 캐시된 페이지 로드
- 페이지네이션 비활성화
- 스크롤 부드럽게 유지
예시:
if !network.isOnline {
hasMore = false
}
온라인으로 복귀하면 자동으로 재시도합니다.
🔁 9. 전체를 초기화하지 않는 Pull‑to‑Refresh
.refreshable {
page = 0
hasMore = true
items.removeAll()
await loadNextPage()
}
Avoid:
- ViewModel 재구성
- 식별자 재설정
- 스크롤 상태를 불필요하게 초기화하는 것
⚖️ 10. 대규모 리스트 성능 규칙
- ✅ 대규모 데이터셋에는
List사용 - ✅ 커스텀 레이아웃에는
ScrollView안에LazyVStack사용 선호 - ✅ 행에서
GeometryReader사용 피하기 - ✅ 행을 얕게 유지
- ✅ 이미지를 적극적으로 캐시
- ✅ 행마다 환경 업데이트 피하기
- ✅ 중첩 리스트 피하기
SwiftUI 리스트는 올바르게 사용하면 매우 빠릅니다.
🧪 11. 페이지네이션 로직 테스트
Because logic lives in the ViewModel:
func test_pagination_appends() async {
let vm = FeedViewModel(service: MockService())
await vm.loadNextPage()
await vm.loadNextPage()
XCTAssertEqual(vm.items.count, 40)
}
UI가 필요 없고, 스크롤 시뮬레이션도 필요 없습니다—순수 로직 테스트입니다.
🧠 멘탈 모델 요약
스스로에게 물어보세요:
- 페이지네이션 상태는 누가 관리하나요?
- 중복 요청이 발생할 수 있나요?
- 식별자는 안정적인가요?
- 행이 가벼운가요?
- 오프라인이 이를 방해할 수 있나요?
모든 답변이 명확하면 → 리스트가 확장됩니다.
🚀 최종 생각
Advanced lists aren’t about clever tricks. They’re about:
- 명확한 소유권
- 안정적인 식별자
- 예측 가능한 페이지네이션
- 최소한의 행 작업
- 깔끔한 비동기 처리
Get these right, and your SwiftUI feeds will feel native, fast, and rock‑solid — even with tens of thousands of rows.