긴 목록을 위한 SwiftUI 성능 튜닝: 실용적인 해결책
SwiftUI 긴 목록 성능 튜닝: 재렌더링, 안정적 행 정체성, 페이징, 이미지 로딩, 구형 iPhone에서의 부드러운 스크롤을 위한 실용적 해결책.

실제 SwiftUI 앱에서 “느린 리스트”의 모습
SwiftUI에서 “느린 리스트”는 보통 버그가 아닙니다. 손가락 속도를 UI가 따라오지 못할 때 발생합니다. 스크롤할 때 리스트가 망설이거나 프레임이 떨어지고 전체적으로 무겁게 느껴지면 그 신호입니다.
일반적인 징후:
- 특히 구형 기기에서 스크롤이 버벅거립니다
- 행이 깜박이거나 잠깐 잘못된 내용을 보여줍니다
- 탭이 지연되거나 스와이프 액션이 늦게 시작됩니다
- 기기가 뜨거워지고 배터리가 평소보다 빠르게 닳습니다
- 스크롤할수록 메모리 사용량이 증가합니다
각 행이 “작아 보인다” 하더라도 긴 리스트가 느껴지는 건 단지 픽셀을 그리는 비용뿐만이 아닙니다. SwiftUI는 각 행이 무엇인지 파악하고, 레이아웃을 계산하고, 폰트와 이미지를 해결하고, 포맷팅 코드를 실행하고, 데이터가 바뀔 때 diff를 계산해야 합니다. 그 작업 중 일부가 과도하게 자주 일어나면 리스트는 병목이 됩니다.
두 가지 아이디어를 구분하면 도움이 됩니다. SwiftUI에서 “재렌더링(re-render)”은 종종 뷰의 body가 다시 계산되는 것을 의미합니다. 그 자체는 보통 비용이 크지 않습니다. 비용이 큰 부분은 재계산이 유발하는 작업들입니다: 무거운 레이아웃, 이미지 디코딩, 텍스트 측정, 또는 SwiftUI가 정체성이 바뀐 것으로 판단해 많은 행을 재생성하는 경우입니다.
예를 들어 2,000개의 메시지가 있는 채팅을 생각해 보세요. 매초 새 메시지가 도착하고 각 행은 타임스탬프를 포맷하고, 여러 줄 텍스트를 측정하고, 아바타를 로드합니다. 항목을 하나만 추가해도 범위가 넓지 않게 상태 변경을 범위 지정하지 못하면 많은 행이 재평가되고 일부는 다시 그려질 수 있습니다.
목표는 마이크로 최적화가 아닙니다. 부드러운 스크롤, 즉각적인 탭 응답, 실제로 변경된 행만 건드리는 업데이트가 목표입니다. 아래의 수정은 안정적인 정체성, 더 가벼운 행, 불필요한 업데이트 감소, 그리고 제어된 로딩에 초점을 맞춥니다.
주요 원인: 정체성(identity), 행당 작업량, 업데이트 폭주
SwiftUI 리스트가 느리다면 보통 원인은 "너무 많은 행"이 아니라 스크롤 중에 일어나는 여분의 작업입니다: 행 재구성, 레이아웃 재계산, 이미지 재로딩 등이 반복됩니다.
대부분의 근본 원인은 세 가지 범주로 들어갑니다:
- 불안정한 정체성: 행에 일관된
id가 없거나 변경 가능한 값에 대해\.self를 사용합니다. SwiftUI는 이전 행과 새로운 행을 매칭하지 못해 불필요하게 재생성합니다. - 행당 과도한 작업: 날짜 포맷팅, 필터링, 이미지 리사이징, 네트워크/디스크 작업을 행 뷰 내부에서 수행합니다.
- 업데이트 폭주: 입력, 타이머 틱, 진행률 업데이트 등 한 가지 변경이 빈번한 상태 업데이트를 트리거해 리스트가 반복적으로 새로고침됩니다.
예: 2,000건의 주문이 있고 각 행이 통화 포맷팅, 서식 있는 문자열 생성, 이미지 페치 등을 하며 부모 뷰에 "마지막 동기화" 타이머가 초당 한 번 업데이트된다고 합시다. 주문 데이터가 바뀌지 않아도 그 타이머가 리스트를 자주 무효화해 스크롤을 버벅이게 만들 수 있습니다.
List와 LazyVStack의 차이
List는 단순한 스크롤 뷰 이상입니다. 테이블/컬렉션 동작과 시스템 최적화에 맞춰 설계되어 있어 대규모 데이터셋을 더 적은 메모리로 다루지만 정체성과 잦은 업데이트에 민감할 수 있습니다.
ScrollView + LazyVStack는 레이아웃과 시각적 요소를 더 세밀하게 제어할 수 있게 해주지만, 반대로 불필요한 레이아웃 작업이나 값 변경을 쉽게 발생시킬 수 있습니다. 구형 기기에서는 이런 추가 작업이 더 빨리 나타납니다.
UI를 재작성하기 전에 먼저 측정하세요. 안정적인 ID를 사용하고, 작업을 행 밖으로 옮기고, 상태 변화 빈도를 줄이는 작은 수정만으로도 문제를 해결할 수 있습니다.
SwiftUI가 효율적으로 diff할 수 있도록 행 정체성 고치기
긴 리스트가 버벅거릴 때 정체성이 원인인 경우가 많습니다. SwiftUI는 ID를 비교해 어떤 행을 재사용할지 결정합니다. 그 ID가 바뀌면 SwiftUI는 해당 행을 새 것으로 처리해 이전 것을 버리고 불필요하게 다시 만듭니다. 이로 인해 랜덤한 재렌더링, 스크롤 위치 손실, 이유 없는 애니메이션이 발생할 수 있습니다.
가장 쉬운 해결책: 각 행의 id를 안정적이고 데이터 소스에 결부되게 만드세요.
흔한 실수: 뷰 안에서 ID를 생성하는 것:
ForEach(items) { item in
Row(item: item)
.id(UUID())
}
이렇게 하면 매 렌더마다 새로운 ID가 생성되어 모든 행이 "달라진" 것으로 간주됩니다.
모델에 이미 있는 ID(데이터베이스 기본키, 서버 ID, 안정적인 슬러그 등)를 사용하세요. 없다면 뷰 안에서 매번 생성하지 말고 모델을 만들 때 한 번 생성하세요.
struct Item: Identifiable {
let id: Int
let title: String
}
List(items) { item in
Row(item: item)
}
인덱스 사용에 주의하세요. ForEach(items.indices, id: \.self)는 위치에 기반한 정체성을 만듭니다. 삽입, 삭제, 정렬이 일어나는 경우 행이 "이동"하게 되어 SwiftUI가 잘못된 뷰를 재사용할 수 있습니다. 배열이 절대 재정렬되지 않는 상황이 아니라면 인덱스는 피하세요.
id: \.self를 쓰려면 요소의 Hashable 값이 시간이 지나도 안정적이어야 합니다. 필드가 업데이트될 때 해시가 바뀌면 행 정체성도 바뀝니다. Equatable/Hashable 구현은 변경 가능한 속성(예: name, isSelected) 대신 단일 안정 ID를 기반으로 하세요.
체크리스트:
- ID는 뷰가 아닌 데이터 소스에서 나옵니다 (뷰에서
UUID()를 생성하지 마세요) - 행 내용이 바뀌어도 ID는 변하지 않습니다
- 리스트가 재정렬되지 않는 상황이 아니라면 배열 위치에 의존하지 마세요
재렌더링을 줄이고 행 뷰를 가볍게 만들기
긴 리스트가 느린 이유는 종종 각 행이 body가 재평가될 때 너무 많은 작업을 하기 때문입니다. 목표는 각 행을 다시 빌드할 때 비용이 작게 하는 것입니다.
숨은 비용 중 하나는 "큰" 값을 행에 전달하는 것입니다. 대형 struct, 깊게 중첩된 모델, 무거운 계산형 프로퍼티는 UI가 변하지 않아도 여분의 작업을 트리거합니다. 문자열 재생성, 날짜 파싱, 이미지 리사이징, 복잡한 레이아웃 트리가 생각보다 자주 일어납니다.
느린 작업을 body 밖으로 이동하기
무언가 느리다면 행 body 안에서 반복적으로 만들지 마세요. 데이터가 도착할 때 미리 계산하거나 뷰모델에 캐시하거나 작은 헬퍼로 메모이제이션하세요.
빠르게 누적되는 행 수준 비용:
- 행당 새
DateFormatter또는NumberFormatter생성 body에서 무거운 문자열 포맷팅(joins, 정규식, 마크다운 파싱)body안에서.map/.filter로 파생 배열 생성- 뷰에서 큰 블롭을 읽고 변환(예: JSON 디코딩)
- 많은 중첩 스택과 조건부로 이뤄진 과도한 레이아웃
간단한 예: 포매터를 static으로 유지하고 행에는 미리 포맷된 문자열을 전달하세요.
enum Formatters {
static let shortDate: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
}
struct OrderRow: View {
let title: String
let dateText: String
var body: some View {
HStack {
Text(title)
Spacer()
Text(dateText).foregroundStyle(.secondary)
}
}
}
행을 분리하고 Equatable을 활용하기
작은 부분(예: 배지 카운트)만 변경된다면 그 부분을 서브뷰로 분리해 나머지 행은 안정적으로 유지하세요.
값 기반 UI인 경우 서브뷰를 Equatable로 만들거나 EquatableView로 래핑하면 입력이 바뀌지 않았을 때 SwiftUI가 작업을 건너뛰는데 도움이 됩니다. 단, equatable 입력은 전체 모델이 아니라 작고 특정한 값으로 제한하세요.
전체 리스트 갱신을 트리거하는 상태 업데이트 제어하기
행은 괜찮은데도 무언가가 리스트 전체를 자꾸 새로 고치게 할 수 있습니다. 스크롤 중에는 작은 추가 업데이트도 구형 기기에서 버벅임으로 이어질 수 있습니다.
한 가지 흔한 원인은 모델을 너무 자주 재생성하는 것입니다. 부모 뷰가 재생성되고 뷰가 소유한 뷰모델에 @ObservedObject를 사용하면 SwiftUI가 뷰모델을 재생성하고 구독을 리셋해 새로운 publish를 유발할 수 있습니다. 뷰가 모델을 소유한다면 @StateObject를 사용해 한 번만 생성되도록 하세요. 외부에서 주입된 객체에는 @ObservedObject를 사용하세요.
또 다른 성능 저하 요인은 너무 자주 퍼블리시하는 것입니다. 타이머, Combine 파이프라인, 진행률 업데이트는 초당 여러 번 발생할 수 있습니다. 퍼블리시된 프로퍼티가 리스트에 영향을 주거나 화면에서 공유되는 ObservableObject에 있으면 매 틱마다 리스트가 무효화될 수 있습니다.
예: 각 키 입력마다 query를 업데이트해 5,000개 항목을 즉시 필터링하면 사용자가 입력하는 동안 리스트가 계속 리-디프됩니다. 입력을 디바운스하고 잠시 멈춘 후 필터링 결과로 리스트를 업데이트하세요.
도움되는 패턴:
- 빠르게 변하는 값은 리스트를 구동하는 객체에서 분리하세요(작은 객체나 로컬
@State사용) - 검색/필터는 디바운스해 입력이 멈춘 후에 업데이트하세요
- 고빈도 타이머 퍼블리시는 피하거나 실제로 값이 바뀔 때만 업데이트하세요
- 행별 상태는 해당 행의
@State로 두고 전역 값을 자주 바꾸지 마세요 - 큰 모델을 분리하세요: 리스트 데이터용
ObservableObject하나, 화면 레벨 UI 상태용 다른 객체 하나
아이디어는 단순합니다: 스크롤 시간에는 조용히 유지하세요. 중요한 변화가 없었다면 리스트는 일을 하지 않아야 합니다.
컨테이너 선택: List 대 LazyVStack
어떤 컨테이너를 선택하느냐에 따라 iOS가 대신 해주는 작업량이 달라집니다.
List는 표준 테이블처럼 보이는 UI에 보통 안전한 선택입니다: 텍스트, 이미지, 스와이프 액션, 선택, 구분자, 편집 모드, 접근성 등. 내부적으로 오랜 기간 튜닝된 플랫폼 최적화를 누릴 수 있습니다.
ScrollView와 LazyVStack은 카드, 혼합 콘텐츠 블록, 특수 헤더, 피드 스타일 레이아웃처럼 맞춤 레이아웃이 필요할 때 좋습니다. “Lazy”는 화면에 들어올 때 행을 빌드한다는 의미지만 List와 동일한 동작을 항상 제공하지는 않습니다. 아주 큰 데이터셋에서는 더 많은 메모리를 쓰거나 구형 기기에서 더 눈에 띄는 버벅임이 생길 수 있습니다.
간단한 결정 규칙:
- 설정, 인박스, 주문 목록처럼 클래식 테이블 화면에는
List를 사용하세요 - 커스텀 레이아웃과 혼합 콘텐츠가 필요하면
ScrollView+LazyVStack을 사용하세요 - 수천 개 항목이 있고 단순 테이블이면 먼저
List로 시작하세요 - 픽셀 단위 제어가 필요하면
LazyVStack을 시도하고 메모리와 프레임 드랍을 측정하세요
또한 스크롤을 조용히 만들기 위해 스타일링도 확인하세요. 그림자, 블러, 복잡한 오버레이 같은 행별 효과는 추가 렌더링 작업을 강제할 수 있습니다. 깊이감을 주고 싶다면 전체 행이 아니라 작은 요소(예: 아이콘)에 무거운 효과를 적용하세요.
구체적 예: 5,000행짜리 “Orders” 화면은 List에서 행이 재사용되기 때문에 부드럽게 유지되는 경우가 많습니다. 같은 UI를 LazyVStack으로 바꾸고 카드 스타일 행에 큰 그림자와 다중 오버레이를 추가하면 코드가 깔끔해 보여도 버벅임이 생길 수 있습니다.
메모리 급증을 피하는 부드러운 페이징
페이징은 렌더링할 행 수를 줄여 리스트를 빠르게 유지하고, 메모리에 들고 있는 모델 수도 줄이며, SwiftUI의 diff 작업을 줄여줍니다.
명확한 페이징 계약으로 시작하세요: 고정 페이지 크기(예: 30~60개), "더 이상 결과 없음" 플래그, 페칭 중에만 보이는 로딩 행.
흔한 함정은 마지막 행이 화면에 나타났을 때만 다음 페이지를 트리거하는 것입니다. 그건 종종 너무 늦습니다. 대신 마지막 몇 개 행 중 하나가 나타날 때 로드를 시작하세요.
간단한 패턴:
@State private var items: [Item] = []
@State private var isLoading = false
@State private var reachedEnd = false
func loadNextPageIfNeeded(currentIndex: Int) {
guard !isLoading, !reachedEnd else { return }
let threshold = max(items.count - 5, 0)
guard currentIndex >= threshold else { return }
isLoading = true
Task {
let page = try await api.fetchPage(after: items.last?.id)
await MainActor.run {
let newUnique = page.filter { p in !items.contains(where: { $0.id == p.id }) }
items.append(contentsOf: newUnique)
reachedEnd = page.isEmpty
isLoading = false
}
}
}
이 패턴은 중복 행(겹치는 API 결과), 여러 onAppear 호출로 인한 레이스 컨디션, 한 번에 너무 많은 로드를 트리거하는 문제를 피합니다.
당기기 새로고침(pull to refresh)이 있다면 페이징 상태를 신중히 재설정하세요(아이템 비우기, reachedEnd 재설정, 가능하면 진행 중인 작업 취소). 백엔드를 제어할 수 있다면 안정적 ID와 커서 기반 페이징은 UI를 눈에 띄게 부드럽게 만듭니다.
이미지, 텍스트, 레이아웃: 행 렌더링을 가볍게 유지하기
긴 리스트가 느려지는 가장 흔한 원인은 리스트 컨테이너 자체가 아니라 행입니다. 이미지 처리: 디코딩, 리사이징, 드로잉은 특히 구형 기기에서 스크롤 속도를 따라가지 못합니다.
원격 이미지를 로드한다면 스크롤 중 메인 스레드에서 무거운 작업이 발생하지 않도록 하세요. 또한 44–80pt 썸네일에 전체 해상도의 이미지를 내려받지 마세요.
예: 각 행이 아바타를 보여주는 “Messages” 화면. 각 행이 2000x2000 이미지를 다운로드하고 스케일 다운한 뒤 블러나 그림자를 적용하면 데이터 모델이 단순해도 리스트는 버벅입니다.
이미지 작업을 예측 가능하게 만들기
영향이 큰 습관:
- 서버나 빌드 타임에 표시 크기에 가까운 썸네일을 제공하세요
- 가능한 경우 디코딩과 리사이징은 메인 스레드 밖에서 하세요
- 썸네일을 캐시해 빠른 스크롤 중 재페치나 재디코딩을 피하세요
- 자리 표시자는 최종 크기와 일치시켜 깜박임과 레이아웃 점프를 방지하세요
- 행의 이미지에 무거운 수식어(그림자, 마스크, 블러)를 피하세요
레이아웃 안정화로 쓰레싱 방지
행 높이가 계속 바뀌면 SwiftUI는 측정보다 그리기에 더 많은 시간을 쓸 수 있습니다. 썸네일은 고정 프레임을 쓰고, 라인 제한을 일관되게 두며, 간격을 안정적으로 유지하세요. 텍스트가 확장될 수 있다면 1~2줄로 제한해 단일 업데이트가 불필요한 추가 측정 작업을 유발하지 않게 하세요.
자리 표시자(플레이스홀더)도 중요합니다. 나중에 아바타로 바뀌는 회색 원이 동일한 프레임을 차지하면 행이 스크롤 중에 재배치되지 않습니다.
측정 방법: 실제 병목을 드러내는 Instruments 체크
"느리다"는 느낌만으로 최적화하면 추측에 의존하게 됩니다. Instruments는 메인 스레드에서 어떤 작업이 수행되는지, 빠른 스크롤 동안 어떤 객체가 할당되는지, 드롭된 프레임의 원인이 무엇인지 알려줍니다.
실제(특히 지원하는 구형) 기기에서 기준선을 정의하세요. 반복 가능한 작업을 하나 정합니다: 화면을 열고 위에서 아래로 빠르게 스크롤, 한번 더 로드-모어를 트리거, 다시 위로 스크롤. 최악의 히치 포인트, 메모리 피크, UI 응답성을 기록하세요.
유용한 Instruments 뷰 세 가지
함께 사용하세요:
- Time Profiler: 스크롤 중 메인 스레드 스파이크를 찾아보세요. 레이아웃, 텍스트 측정, JSON 파싱, 이미지 디코딩이 여기에서 자주 발견됩니다.
- Allocations: 빠른 스크롤 중 임시 객체의 급증을 관찰하세요. 반복 포맷팅, 새로운 attributed string 생성, 행당 모델 재생성 등을 가리킵니다.
- Core Animation: 드롭된 프레임과 긴 프레임 시간을 확인하세요. 렌더링 부담인지 데이터 작업인지 구분하는 데 도움이 됩니다.
스파이크를 발견하면 호출 트리를 클릭해 이것이 화면당 한 번 발생하는지, 아니면 행당·스크롤당 한 번씩 반복되는지를 물어보세요. 후자라면 부드러운 스크롤을 깨는 원인입니다.
스크롤과 페이징 이벤트에 사인포스트 추가하기
많은 앱이 스크롤 중에 추가 작업을 합니다(이미지 로드, 페이징, 필터링). 사인포스트는 타임라인에서 그 순간을 볼 수 있게 해 줍니다.
import os
let log = OSLog(subsystem: "com.yourapp", category: "list")
os_signpost(.begin, log: log, name: "LoadMore")
// fetch next page
os_signpost(.end, log: log, name: "LoadMore")
각 변경 후 한 번에 하나씩 다시 테스트하세요. FPS가 개선되지만 Allocations가 나빠지면 버벅임을 메모리 압력으로 바꾼 것입니다. 기준선 수치를 유지하고 숫자가 올바른 방향으로 가는 변경만 적용하세요.
리스트 성능을 조용히 죽이는 흔한 실수들
어떤 문제는 눈에 띕니다(큰 이미지, 엄청난 데이터셋). 다른 문제는 데이터가 커졌을 때만 드러나며 특히 구형 기기에서 나타납니다.
1) 불안정한 행 ID
뷰 안에서 ID를 생성하는 고전적인 실수(예: id: .self를 참조 타입에 사용하거나 UUID()를 행 바디에서 생성). SwiftUI는 업데이트를 diff할 때 정체성을 사용합니다. ID가 바뀌면 행을 새 것으로 처리하고 레이아웃 캐시를 버릴 수 있습니다.
모델에서 안정적인 ID(서버/DB ID 또는 항목 생성 시 한 번 생성한 UUID)를 사용하세요. 없으면 추가하세요.
2) onAppear 안의 과도한 작업
onAppear는 행이 스크롤로 들어오고 나가면서 예상보다 자주 호출됩니다. 각 행이 onAppear에서 이미지 디코딩, JSON 파싱, DB 조회 같은 무거운 작업을 시작하면 반복적인 스파이크가 발생합니다.
무거운 작업은 행 밖으로 이동하세요. 데이터가 로드될 때 미리 계산하고 캐시하며 onAppear는 페이징 트리거처럼 가벼운 작업만 하세요.
3) 행 편집에 전체 리스트 바인딩
모든 행에 큰 배열에 대한 @Binding을 연결하면 작은 편집이 큰 변경처럼 보일 수 있습니다. 그럴 경우 많은 행이 재평가되고 때로는 전체 리스트가 새로고침됩니다.
행에는 불변 값을 전달하고 변경은 경량 액션(예: toggleFavorite(for id:))으로 보냅니다. 행 고유의 상태는 진정으로 해당 행에 속할 때만 그 안에 두세요.
4) 스크롤 중 과다한 애니메이션
애니메이션은 리스트에서 비싸게 작용할 수 있습니다. 전체 리스트에 animation(.default, value:)를 적용하거나 자주 변하는 값마다 암묵적 애니메이션을 쓰면 스크롤이 끈적해질 수 있습니다.
단순하게 유지하세요:
- 변경된 한 행에 애니메이션을 범위를 좁히세요
- 빠른 스크롤 중에는 애니메이션을 피하세요(특히 선택/하이라이트)
- 자주 변하는 값에 대해 암묵 애니메이션을 조심하세요
- 복합 효과보다는 단순한 전환을 선호하세요
실제 예: 채팅 스타일 리스트에서 각 행이 onAppear에서 네트워크를 호출하고, UUID()를 id로 사용하며, “읽음” 상태 변화를 애니메이션하면 끊임없는 행 치환이 발생합니다. 정체성 수정, 작업 캐싱, 애니메이션 제한으로 동일한 UI가 즉시 더 부드럽게 느껴질 수 있습니다.
빠른 체크리스트, 간단한 예시, 다음 단계
몇 가지만 한다면 여기서 시작하세요:
- 각 행에 안정적이고 고유한 id 사용(배열 인덱스나 매번 생성되는 UUID 금지)
- 행 작업을 작게 유지: 무거운 포맷팅, 큰 뷰 트리,
body의 비싼 계산은 피하세요 - 퍼블리시 제어: 타이머, 입력, 네트워크 진행 같은 빠른 상태는 전체 리스트를 무효화하지 않도록 하세요
- 페이징과 프리패치를 통해 메모리 사용을 평탄하게 유지하세요
- Instruments로 변경 전후를 측정해 추측이 아닌 데이터에 근거해 판단하세요
예시 상황: 지원 인박스가 20,000개의 대화를 갖고 각 행은 제목, 마지막 메시지 미리보기, 타임스탬프, 읽지 않은 배지, 아바타를 보여주며 사용자가 검색하고 스크롤 중 새 메시지가 도착한다고 합시다. 느린 버전은 보통 몇 가지를 동시에 합니다: 키 입력마다 행을 재구성하고, 텍스트를 과도하게 측정하며, 너무 많은 이미지를 너무 일찍 가져옵니다.
코드베이스를 뜯어고치지 않고도 실용적인 계획:
- 기준선: Instruments(Time Profiler + Core Animation)로 짧은 스크롤과 검색 세션을 녹화하세요.
- 정체성 수정: 모델에 서버/DB에서 온 진짜 id가 있는지 확인하고
ForEach가 일관되게 사용하도록 하세요. - 페이징 추가: 처음에 최신 항목 50~100개만 로드하고 사용자가 끝에 가까워지면 더 불러오세요.
- 이미지 최적화: 작은 썸네일 사용, 결과 캐시, 메인 스레드에서의 디코딩 회피.
- 재측정: 레이아웃 패스 감소, 뷰 업데이트 감소, 구형 기기에서 더 안정된 프레임 타임을 확인하세요.
완전한 제품(백엔드와 웹 어드민 패널 포함)을 만드는 경우 데이터 모델과 페이징 계약을 초기에 설계하면 도움이 됩니다. AppMaster (appmaster.io) 같은 플랫폼은 그 풀스택 워크플로를 지원하도록 설계되어 있습니다: 데이터를 시각적으로 정의하고 배포하거나 자체 호스팅할 수 있는 실제 소스 코드를 생성할 수 있습니다.
자주 묻는 질문
먼저 행의 정체성(id)을 고정하세요. 모델에서 가져온 안정적인 id를 사용하고 뷰에서 ID를 생성하지 마세요. ID가 바뀌면 SwiftUI는 행을 새 것으로 간주해 불필요하게 많이 재생성합니다.
body 재계산 자체는 보통 비용이 크지 않습니다. 문제는 그 재계산이 유발하는 작업입니다. 무거운 레이아웃, 텍스트 측정, 이미지 디코딩, 그리고 불안정한 정체성으로 많은 행을 다시 만드는 일이 프레임 드랍을 일으킵니다.
행의 id로는 뷰 안에서 UUID()를 쓰거나 배열 인덱스를 쓰지 마세요(데이터가 삽입·삭제·정렬될 수 있다면 특히). 서버나 데이터베이스의 ID, 또는 모델 생성 시 한 번 정한 UUID처럼 업데이트 간에 변하지 않는 값을 사용하세요.
id: .self는 값의 해시가 편집 가능한 필드의 변화에 따라 달라질 수 있으므로 성능을 악화시킬 수 있습니다. 필요하다면 Hashable을 단일한 안정적 식별자를 기반으로 하세요. name, isSelected 같은 변경 가능한 속성에 기반하면 안 됩니다.
비싼 작업은 body 안에서 반복하지 마세요. 날짜/숫자 포맷터를 매번 새로 만들지 말고, 미리 포맷된 문자열을 전달하거나 뷰모델에서 캐시하세요. body 안에서 큰 배열을 map/filter로 만드는 것도 피하세요.
onAppear는 스크롤 중 행이 화면에 들어올 때 자주 호출됩니다. 각 행에서 이미지 디코딩이나 데이터베이스 조회 같은 무거운 작업을 시작하면 반복적인 스파이크가 발생합니다. onAppear는 페이징 트리거처럼 가벼운 작업만 하세요.
빠르게 변하는 퍼블리시 값(타이머, 입력 중인 텍스트, 진행률 등)이 리스트에 공유되어 있으면 빈번한 무효화가 발생합니다. 이러한 값은 리스트를 구동하는 주된 객체에 두지 말고 작게 분리하거나 디바운스하세요. 큰 ObservableObject는 역할에 따라 나누는 것이 좋습니다.
UI가 전형적인 테이블(텍스트, 이미지, 스와이프, 선택, 구분자 등)이라면 List가 보통 안전한 선택입니다. 커스텀 레이아웃이나 혼합 콘텐츠가 필요하면 ScrollView + LazyVStack을 쓰세요. 후자는 더 많은 제어를 주지만 메모리 사용과 레이아웃 작업을 스스로 관리해야 합니다.
마지막 행이 나타났을 때만 다음 페이지를 로드하면 사용자에게 지연이 느껴질 수 있습니다. 끝에서 몇 행 전(예: 마지막 5개)에 도달했을 때 미리 로드하고, isLoading과 reachedEnd를 체크해 중복 호출을 막으세요. 결과는 안정적인 ID로 중복 제거하세요.
실기기에서 기준을 정하고 Instruments로 메인 스레드 스파이크, 할당 급증, 프레임 드랍을 확인하세요. Time Profiler는 어떤 함수가 스크롤을 막는지, Allocations는 행당 일회성 객체 생성을 보여주고, Core Animation은 렌더링 문제인지 데이터 처리 문제인지를 구분해 줍니다.


