UIScrollView · UITableView · UICollectionView
스크롤의 본질(bounds 이동)부터 셀 재사용 메커니즘, self-sizing, Diffable Data Source, Compositional Layout, 스크롤 60fps 성능까지 — 동작 원리와 심화를 정리한다. (2026-06-21)
0. 큰 그림 — 셋의 관계
TableView·CollectionView는 UIScrollView의 서브클래스다. 즉 스크롤 동작은 ScrollView에서 오고, 그 위에 "셀 재사용 + 데이터소스 기반 lazy 생성"을 얹은 것. CollectionView는 TableView를 일반화해 레이아웃을 외부 객체(UICollectionViewLayout)에 위임한다.
addSubview로 직접 뷰를 넣고 그 뷰들은 전부 메모리에 상주한다(화면 밖이든 안이든). 재사용·dataSource·lazy 생성은 Table/Collection이 추가한 기능이다.
- ScrollView: 재사용 ❌ · 직접 addSubview(다 상주) · 뷰 적을 때(배너·폼)
- Table/Collection: 재사용 ✅ · dataSource가
cellForItem/Row로 보일 때만 lazy 생성 · 데이터 많을 때
"데이터소스 기반 lazy 생성" = 모든 셀을 미리 안 만들고, 보일 때가 되면 cellForItemAt(/cellForRowAt)이 불려 그때 dequeue(재사용)+구성하는 것. 보일 셀만큼만 그때그때 호출된다.
| UITableView | UICollectionView | |
|---|---|---|
| 레이아웃 | 세로 1열 고정 | Layout 객체가 자유 결정(그리드·가로·커스텀) |
| 셀 재사용 | ✅ | ✅ |
| supplementary | 섹션 헤더/푸터 | 임의(헤더·푸터·배지 등) |
1. UIScrollView — 스크롤의 본질은 "bounds 이동"
스크롤은 콘텐츠를 움직이는 게 아니라, scroll view의 bounds.origin을 움직이는 것이다. 뷰는 자식을 bounds 좌표계 기준으로 그리므로, bounds.origin을 (0,100)으로 옮기면 자식이 위로 100만큼 올라가 보인다.
bounds.origin을 옮겨 콘텐츠의 다른 부분을 보여준다. 움직이는 건 frame이 아니라 bounds. (iOS 좌표는 y가 아래로 증가 → bounds.origin.y=100=아래로 100 스크롤)
비유: 콘텐츠=긴 두루마리(고정), bounds=그 위를 미끄러지는 창문/돋보기. 두루마리는 가만히, 창문이 내려가며 아래 내용이 보임.
frame vs bounds vs contentSize (헷갈림 정리)
예: 부모의 왼쪽 20·위 30에 300×500 scroll view 배치, 전체 콘텐츠는 300×2000.
| origin | size | 뜻 | |
|---|---|---|---|
frame | (20, 30) | (300, 500) | 부모 좌표계에서 내 위치 + 화면상 크기 |
bounds | (0, 0) // 스크롤 전 | (300, 500) | 보는 시작점 + 보이는 창 크기 (size는 frame과 같음) |
contentSize | — | (300, 2000) | 스크롤 가능한 전체 콘텐츠 크기 (별개) |
- frame과 bounds는 size가 같고 origin만 다르다(회전·transform 없을 때). frame.origin=부모 기준 위치 / bounds.origin=보는 시작점(스크롤로 변함)
contentSize가bounds.size보다 커야 스크롤됨 → 그 차이만큼bounds.origin(=contentOffset)이 이동 가능- 비유: frame=액자가 벽(20,30)에 걸림 / bounds=액자 안에서 보는 시작점 / contentSize=액자 뒤 숨은 긴 그림 전체
핵심 프로퍼티
| 프로퍼티 | 의미 |
|---|---|
contentSize | 스크롤 가능한 전체 콘텐츠 크기. 이게 frame보다 커야 스크롤됨 |
contentOffset | 현재 스크롤 위치(=bounds.origin) |
contentInset | 콘텐츠 가장자리 여백(상단 바·키보드 회피 등) |
adjustedContentInset iOS 11+ | safe area를 반영한 최종 inset. contentInsetAdjustmentBehavior로 제어 |
contentLayoutGuide / frameLayoutGuide | Auto Layout으로 scroll view 구성 시 기준(콘텐츠 크기 vs 보이는 프레임) |
contentInset — 콘텐츠 둘레 여백
contentInset = 콘텐츠 둘레(상하좌우)에 추가하는 여백(스크롤뷰 가장자리 ↔ 콘텐츠 사이). 스크롤 가능 범위를 그만큼 늘린다.
스크롤 범위 계산 (bounds 500, contentSize 1000, inset top:50·bottom:30):
| inset 없음 | inset 있음 | 계산 | |
|---|---|---|---|
| 최상단 offset.y | 0 | -50 | top inset만큼 더 위로 |
| 최하단 offset.y | 500 | 530 | contentSize(1000) - bounds(500) + bottom(30) |
bottom=300으로 300 더 내려 마지막 콘텐츠를 키보드 위로 올린다. RefreshControl도 당길 때 top inset을 일시 추가하는 원리.adjustedContentInset 심화 iOS 11+
실제 적용되는 inset = adjustedContentInset (읽기 전용). 시스템이 자동 계산.
contentInsetAdjustmentBehavior 옵션
| 값 | adjustedContentInset | 용도 |
|---|---|---|
.automatic (기본) | contentInset + safeAreaInsets | 일반적인 스크롤뷰 |
.always | contentInset + safeAreaInsets | 항상 safe area 반영 |
.never | contentInset (그대로) | 전체 화면(이미지 뷰어), 직접 관리 |
.scrollableAxes | 스크롤 가능 축만 조정 | 특수 케이스 |
⭐ 핵심: contentInset=0이어도 adjustedContentInset≠0
⭐ 초기 contentOffset은 음수
adjustedContentInset.top이 있으면, 스크롤 최상단에서 contentOffset.y는 음수다.
bounds/frame은 inset과 무관
adjustedContentInset.top + contentSize + adjustedContentInset.bottom이지만, contentSize 프로퍼티 값 자체는 변하지 않는다.실전 예시
스크롤 메커니즘
- 내부
panGestureRecognizer가 드래그를 받아contentOffset을 갱신 → bounds 이동 → 재배치/재합성 - 손 뗀 뒤 deceleration(감속) 애니메이션, 경계에서 bounce
- delegate:
scrollViewDidScroll(매 프레임 호출 — 무거운 작업 금지),willBeginDragging,didEndDecelerating등
scrollViewDidScroll은 스크롤 중 매 프레임(~16.7ms마다) 불린다. 여기에 무거운 계산을 넣으면 즉시 프레임 드롭. (레이아웃·렌더 파이프라인과 직결 — 뷰 레이아웃 심화 참고)Auto Layout으로 ScrollView 구성 (자주 헷갈림)
SnapKit 버전:
| 가이드 | 정체 | 여기 걸면 |
|---|---|---|
contentLayoutGuide | 스크롤되는 콘텐츠 영역 | 콘텐츠 크기 → contentSize 결정 |
frameLayoutGuide | 보이는 프레임(=bounds.size) | 보이는 창 크기 기준(폭 고정→가로 스크롤 방지) |
contentView는 고정 높이가 없다. footer.bottom == contentView.bottom은 contentView.bottom을 참조하는 게 아니라 "둘이 같다"는 방정식 — footer 위치(자기 제약으로 확정)가 거꾸로 contentView.bottom(=높이)을 정의한다. 자식 체인이 bottom-up으로 높이를 만들어 contentSize가 된다.
마지막 bottom 핀을 빠뜨리면 → 높이 모호(ambiguous) → contentSize 미정 → 스크롤 안 됨. scroll view Auto Layout 단골 실수.
2. 셀 재사용(reuse) — 모든 것의 핵심
수천 개 행이 있어도 화면에 보이는 셀(+ 약간의 버퍼)만 메모리에 존재한다. 스크롤로 화면을 벗어난 셀은 파괴하지 않고 재사용 큐(reuse pool)에 넣었다가, 새로 들어오는 위치에 재활용한다.
셀 lifecycle
willDisplay/didEndDisplaying는 "곧 보임/방금 사라짐" 알림 — 무거운 구성보다 이 타이밍에 하는 게 유리한 작업(애니 시작, 로깅 등)이 있다.
3. UITableView 심화
| 누가 | |
|---|---|
| 높이 정보 제공 | 개발자 — rowHeight / delegate heightForRowAt / self-sizing(제약) |
| 실제 배치(frame 계산·셀 위치) | UITableView 내부 (높이를 세로 누적 → 셀 frame 계산, visible rect 행만 배치, contentSize 합산) |
delegate는 "이 행 높이 60" 같은 파라미터만 주고, 배치 알고리즘은 테이블뷰 것이라 교체 불가. 이걸 외부 객체로 뺀 게 CollectionView다.
높이 — self-sizing
| 방식 | 동작 |
|---|---|
rowHeight = 고정값 | 가장 빠름(계산 없음) |
rowHeight = .automaticDimension + estimatedRowHeight | self-sizing: 셀의 Auto Layout 제약으로 높이를 런타임 계산 |
self-sizing 심화
- 왜 contentSize가 필요? 스크롤바 길이(=bounds/contentSize 비율)·스크롤 범위(끝)·셀 위치 계산에 필요 → self-sizing은 높이를 모르니 추정값으로라도 잡아야 함
- frame/고정 높이로 주면? Auto Layout 계산 스킵(빠름·수동).
rowHeight=44·heightForRowAt숫자 반환은 systemLayoutSizeFitting 안 함. self-sizing(automaticDimension)만 제약으로 유도 - estimated 없으면(=0)? contentSize 알려고 로드 시 모든 셀 높이 미리 계산(1만 개=1만 번) → 멈춤. estimated 주면 추정 후 보이는 셀만 lazy 계산. (iOS 11+는 기본 자동 추정)
그래서 위로 스크롤 시 점프가 심함(지나간 위쪽 셀 추정 오차). estimated를 실제 평균에 가깝게 주면 흔들림이 작아진다.
Prefetching iOS 10+
UITableViewDataSourcePrefetching — 클래스 아닌 프로토콜. prefetchDataSource로 연결. 곧 보일 행의 데이터(이미지 등)를 미리 준비해 빈 셀 깜빡임을 줄인다.
prefetchRowsAt(필수): 곧 보일 indexPath들 → 백그라운드 미리 로딩 /cancelPrefetchingForRowsAt(선택): 방향 바뀌면 취소- cellForRow보다 더 일찍, 데이터만 준비(셀 생성은 여전히 cellForRow). CollectionView도 동일 프로토콜(
UICollectionViewDataSourcePrefetching)
업데이트 — batch & Diffable
NSInternalInconsistencyException)" 문제를 없앤다. 단 식별자(identity)와 내용(content)을 구분해야(같은 id인데 내용만 바뀌면 reconfigure).4. UICollectionView 심화 — 레이아웃이 핵심
TableView와 재사용·데이터소스는 같고, 차이는 레이아웃을 UICollectionViewLayout 객체가 결정한다는 점. 위치·크기를 그 객체에 위임해 그리드·가로·폭포수 등 자유로운 배치가 가능하다.
rowHeight·헤더/푸터 정도뿐.
| UITableView | UICollectionView | |
|---|---|---|
| 레이아웃 객체 | ❌ 없음(내장·고정) | ✅ UICollectionViewLayout(필수, 교체 가능) |
| 배치 | 세로 1열 고정 | 그리드·가로·폭포수·커스텀 자유 |
CollectionView는 그 배치 로직을 분리·필수 객체로 빼낸 일반화. 그래서 CollectionView로 세로 리스트도 만들 수 있어(FlowLayout 1열) 레이아웃 자유도 면에선 TableView의 상위집합에 가깝다. (단 레이아웃 없이는 못 만든다: init(frame:collectionViewLayout:))
레이아웃 객체의 동작
레이아웃 종류
| 종류 | 설명 |
|---|---|
UICollectionViewFlowLayout | 줄바꿈 그리드(아이템 크기·간격·스크롤 방향). 가장 기본 |
| Compositional Layout iOS 13+ | Item → Group → Section 조합으로 복잡한 레이아웃을 선언적으로. 섹션마다 다른 레이아웃·orthogonal 가로 스크롤 가능 |
커스텀 UICollectionViewLayout | 폭포수(Pinterest)·원형 등 직접 구현(attributes 계산) |
self-sizing & invalidation
- self-sizing cell:
estimatedItemSize또는 Compositional의.estimated→ Auto Layout으로 크기 측정 invalidateLayout()→ 레이아웃 다시 계산 예약(회전·데이터 변경 시). 부분 무효화는invalidationContext로 비용 최소화
5. 스크롤 60fps — 성능 심화
스크롤 중에는 매 프레임(60Hz=16.7ms, ProMotion 120Hz=8.3ms) 안에 앱과 Render Server가 각자 작업을 끝내야 한다. 못 끝내면 프레임 드롭 = 버벅임(jank).
- 앱 (Run Loop, CPU): Layout + Draw + CATransaction commit → VSync 전에 완료
- Render Server (별도 프로세스, GPU): 합성(Composition) + 화면 출력 → Run Loop 밖!
CPU-bound(레이아웃 복잡, 셀 생성 무거움)든 GPU-bound(레이어 많음, 오프스크린 렌더링)든 어느 쪽이 늦어도 프레임 드롭.
| 병목 | 대처 |
|---|---|
| 셀 구성이 무거움(cellForItem) | 구성 가볍게, 무거운 계산은 미리(prefetch)·백그라운드 |
| 이미지 디코딩(메인 스레드) | 백그라운드 디코딩·다운샘플(큰 이미지를 표시 크기로 축소) |
| 오프스크린 렌더링 | cornerRadius+masksToBounds·그림자·마스크는 오프스크린 유발 → shadowPath 지정, 미리 렌더된 이미지 사용 |
| self-sizing 높이 재계산 | estimated 정확히, 높이 캐싱 |
| 블렌딩(투명 뷰 다수) | 불투명(isOpaque=true)·배경색 지정 |
layer.shadowPath를 지정하면 오프스크린을 피할 수 있다.이미지 다운샘플
4000×3000 원본을 100×100 셀에 그대로 넣으면 디코딩된 비트맵이 메모리를 폭식(픽셀당 4바이트 × 1200만 = ~48MB)한다. ImageIO(CGImageSourceCreateThumbnailAtIndex)로 표시 크기로 다운샘플해 디코딩하면 메모리·시간 모두 절약. (큰 이미지 메모리 참고)
6. 체크리스트 (QA)
- 스크롤의 본질이 bounds 이동이고
contentOffset==bounds.origin임을 설명할 수 있다 - 셀 재사용 흐름(visible rect → pool → dequeue → 구성)과
prepareForReuse에서 잔상 제거·비동기 취소를 한다 automaticDimension+estimatedRowHeight의 self-sizing 동작과 estimated 부정확 시 contentOffset 점프를 안다- Diffable Data Source가 Hashable 해시 diff로 batch 크래시를 없애는 원리, identity vs content 구분을 안다
- CollectionView 레이아웃을
UICollectionViewLayout이 결정하고, Compositional Layout(item/group/section)을 쓸 수 있다 - 스크롤 60fps를 위해 오프스크린 렌더링·메인 스레드 이미지 디코딩·블렌딩을 피한다
scrollViewDidScroll이 매 프레임 불린다는 걸 알고 무거운 작업을 넣지 않는다- 큰 이미지를 다운샘플해 메모리·디코딩 비용을 줄인다
7. Q&A 심화 (6/22)
Q. setNeedsDisplay는 언제 호출?
draw(_:)를 오버라이드해서 Core Graphics로 직접 그릴 때만. 대부분의 앱에서 직접 호출할 일 거의 없음.
| 상황 | setNeedsDisplay 필요? |
|---|---|
backgroundColor, alpha 변경 | ❌ (layer 속성, GPU 처리) |
UILabel.text 변경 | ❌ (내부에서 처리) |
| draw(_:) 오버라이드해서 직접 그릴 때 | ✅ |
Q. heightForRowAt이 100개 셀이면 100번 호출?
estimatedRowHeight 유무에 따라 다름.
| 상황 | 호출 횟수 |
|---|---|
estimatedRowHeight = 0 (없음) | 100번 — 전체 contentSize 알려고 다 호출 |
estimatedRowHeight = 80 (있음) | 보이는 셀만 (예: 10번) — 나머지는 추정값 사용, 스크롤 시 추가 호출 |
Q. layout.prepare()의 layout은 collectionViewLayout?
맞음. CollectionView 내부에서 collectionViewLayout.prepare()를 호출하는 것.
Q. layout.prepare()가 layoutAttributes를 얻는 행위?
정확히는 "계산해서 캐싱"하는 행위.
이후 CollectionView가 layoutAttributesForElements(in:) 호출 → 캐시에서 필터링해서 반환.
Q. layout.prepare()는 언제 호출?
CollectionView가 자동 호출:
- 최초 표시 전
invalidateLayout()호출 후reloadData()후- bounds 변경 시 (
shouldInvalidateLayout이 true면) - 아이템 삽입/삭제 후
Q. TableView의 contentSize는 언제 계산?
TableView가 자동 계산:
- 최초 표시 전 (viewDidLoad 후, viewWillAppear 즈음)
reloadData()후insertRows/deleteRows후
Q. FlowLayout vs CompositionalLayout vs 커스텀?
| FlowLayout | Compositional | 커스텀 | |
|---|---|---|---|
| 방식 | 줄바꿈 그리드 | Item→Group→Section 선언적 | 4가지 메서드 직접 구현 |
| 유연성 | 낮음 | 높음 | 최고 |
| 용도 | 단순 그리드 | 섹션별 다른 레이아웃, orthogonal | 원형, 폭포수, 3D |
| iOS | 6+ | 13+ | 6+ |
Q. UICollectionView 관련 클래스 정리
| 이름 | 종류 | 역할 |
|---|---|---|
UICollectionViewLayout | 클래스 | 레이아웃 추상 베이스 |
UICollectionViewFlowLayout | 클래스 | Layout 서브클래스 (그리드) |
UICollectionViewDelegateFlowLayout | 프로토콜 | FlowLayout용 delegate (sizeForItem) |
UICollectionViewDataSource | 프로토콜 | 데이터 제공 (cellForItem) |
UICollectionViewDelegate | 프로토콜 | 이벤트 처리 (didSelect) |
Q. "layout 메서드 오버라이드"란?
커스텀 Layout 만들 때 4가지 메서드를 오버라이드한다는 뜻:
TableView는 이런 객체가 없고, heightForRowAt 같은 delegate 메서드로 값만 전달.
⭐ Q. TableView에서 "높이만 알면 frame 자동 계산" — 왜?
TableView 배치 = 세로 1열 누적. 규칙이 고정이라 "높이"만 알면 나머지 자동.
- TableView = 책장에 책 꽂기 → "이 책 두께 얼마?"만 알면 됨. 위치는 순서대로 자동.
- CollectionView = 빈 방에 가구 배치 → "이 가구 어디에 얼마 크기로?" 전부 알아야 함.
그래서 TableView는 heightForRowAt만 있으면 되고, CollectionView는 layoutAttributes(x, y, width, height, transform...)가 필요.
8. Q&A 심화 2 (2026-06-23)
Q. frame 직접 설정 vs Auto Layout — layer.frame 언제 바뀜?
- frame 직접 설정:
view.frame = ...→ 즉시 layer.frame 변경 - Auto Layout: 제약조건 변경 → needsLayout → 레이아웃 패스에서 frame 변경
view.frame = newFrame 하면: ① layer.frame 즉시 변경 ② setNeedsLayout() 호출 (서브뷰 재배치용)
Q. Auto Layout (makeConstraints) 전체 흐름
- 제약조건 변경/추가 → setNeedsLayout() 자동 호출
- Run Loop BeforeWaiting 도달
- updateConstraints() — 바텀업 ↑ (자식 → 부모)
- layoutSubviews() — 탑다운 ↓ (부모 → 자식) — 여기서 frame 계산!
- 각 뷰의 layer.frame 변경됨
Q. heightForRowAt과 estimatedRowHeight 관계
"둘 중 하나만"이 아니라 "같이 쓸 수 있다"!
| 상황 | heightForRowAt 호출 대상 |
|---|---|
| estimated 있음 | 보이는 셀만 (스크롤하면 추가 호출) |
| estimated 없음 | 모든 셀 (1000개면 1000번!) |
Q. rowHeight = 80 설정하면 heightForRowAt 호출 안 됨?
맞다! heightForRowAt을 구현 안 했으면.
heightForRowAt구현됨? → YES → delegate 반환값 사용 (rowHeight 무시)- NO →
rowHeight확인automaticDimension→ systemLayoutSizeFitting 사용- 숫자값 (80) → 그 값 사용
Q. heightForRowAt과 systemLayoutSizeFitting 관계
별개다! heightForRowAt 구현하면 systemLayoutSizeFitting 호출 안 됨.
| heightForRowAt | rowHeight | 높이 결정 방식 |
|---|---|---|
| 구현함 | 무관 | delegate 반환값 |
| 없음 | automaticDimension | systemLayoutSizeFitting |
| 없음 | 80 | 80pt 고정 |
Q. UICollectionViewDelegateFlowLayout — FlowLayout이 "가지고 있다"?
아니다. "질문한다"가 맞다.
UICollectionViewDelegateFlowLayout= 프로토콜 (역할 정의)- ViewController가 이 프로토콜을 채택
- FlowLayout이 CollectionView의 delegate에게 질문 (캐스팅해서)
Q. layoutAttributesForElements — "적용"하는 거야?
"적용"이 아니라 "반환"한다. CollectionView가 반환받은 attributes로 셀 배치.
Q. shouldInvalidateLayout — 뭐야? 언제 호출?
"레이아웃 다시 계산할까?" 물어보는 메서드. bounds 변경 시 호출.
| 변경 | 원인 | shouldInvalidateLayout 호출 |
|---|---|---|
| bounds | 스크롤, 회전, 내부 리사이즈 | ✅ 호출됨 |
| frame | 부모가 크기 바꿈 | ❌ 직접 호출 안 됨 |
Q. UICollectionViewLayout vs UICollectionViewFlowLayout
| UICollectionViewLayout | UICollectionViewFlowLayout | |
|---|---|---|
| 용도 | 서브클래싱용 베이스 | 바로 사용 가능한 그리드 |
| 배치 로직 | 없음 (직접 구현) | 그리드 자동 계산 |
| 프로퍼티 | 없음 | itemSize, spacing 등 |
UICollectionViewLayout() 직접 사용? 컴파일되지만 모든 셀이 (0,0)에 겹침. 쓸모없음.
Q. bounds.origin 변경해도 layoutSubviews 안 불리면 누가 그려?
layoutSubviews와 "그리기"는 별개다!
- layoutSubviews = 서브뷰 "배치" (frame 설정)
- 그리기 = Core Animation이 GPU로 렌더링 (레이아웃과 무관)
Q. cell.frame을 VC에서 바꿔도 TableView가 덮어씀?
맞다. TableView가 layoutSubviews에서 cell.frame을 재설정.
- TableView = 선생님 (책상 배치 담당)
- Cell = 학생 책상
- 학생: "제 책상 저기로 옮길게요" (cell.frame 변경) → 선생님: "안 돼, 내가 정한 자리로" (덮어씀)
- 학생: "그럼 책상 위에서 제 물건 배치할게요" (contentView 내부) → 선생님: "그건 네 자유" (OK)
Q. Extension으로 프로토콜 채택하면 성능 유리?
아니다. 코드 정리용일 뿐, 컴파일 결과 동일.
Q. draw(_:) (drawRect) 설명
레이아웃과 별개. "그리기" 담당.
| 더티 플래그 | 호출 메서드 | 역할 |
|---|---|---|
| needsUpdateConstraints | updateConstraints() | 제약조건 |
| needsLayout | layoutSubviews() | 위치/크기 |
| needsDisplay | draw(_:) | 그리기 (Core Graphics) |
draw(_:)는 커스텀 도형, 차트 등 직접 그릴 때만 오버라이드. 대부분은 안 씀.
Q. 스크롤하면 layoutSubviews 호출?
UIScrollView/TableView/CollectionView는 맞다.
Q. 서브뷰 frame 재배치(탑다운)는 동기적?
맞다. 전부 동기적(synchronous)으로 메인 스레드에서 실행.
Q. layoutSubviews 호출 시점에 frame은 이미 결정됨?
- 자신의 frame: ✅ 이미 결정됨 (부모가 설정해줌)
- 서브뷰의 frame: ❌ 아직 이전 값 (내가 여기서 설정)
흐름: GrandParent가 Parent.frame 설정 → Parent.layoutSubviews() 호출 (이때 self.frame 확정) → Parent가 Child.frame 설정 → Child.layoutSubviews() 호출...
Q. Layer와 Sublayer 관계?
UIView 계층 = CALayer 계층. 뷰마다 layer가 있고, 뷰 계층이 곧 레이어 계층.
| 상황 | view.layer.sublayers |
|---|---|
| 아무것도 안 함 | nil 또는 [] |
addSubview(child) | [child.layer] |
layer.addSublayer(k) | [k] |
| 둘 다 | [child.layer, k] |
UIView 1개 = CALayer 1개 (자기 자신의 layer). sublayer는 자식이 있을 때만.
Q. clipsToBounds vs masksToBounds?
동일하다. 같은 것이다.
| 프로퍼티 | 레벨 | 언제 씀 |
|---|---|---|
clipsToBounds | UIView | 보통 이거 씀 |
masksToBounds | CALayer | CALayer 직접 다룰 때 |
모든 sublayer를 자른다 — subview의 layer든, 직접 추가한 CALayer든 상관없이 경계 밖은 전부 잘림.
9. Q&A 심화 3 (2026-06-27) — intrinsic / systemLayoutSizeFitting / boundingRect
intrinsic content size
뷰가 콘텐츠 기준으로 갖는 고유 크기("나 이만큼 필요해"를 Auto Layout에 알림). 라벨·버튼·이미지뷰·스위치처럼 콘텐츠가 크기를 정하는 뷰가 가진다. 일반 UIView는 없음(UIView.noIntrinsicMetric).
- 덕분에 위치만 제약해도 크기가 자동 → 제약 수 감소 (일반 UIView는 너비·높이 직접 제약 필요)
- CHCR: intrinsic보다 커지지 마(Content Hugging) / 작아지지 마(Compression Resistance) — 두 라벨이 공간 나눌 때 누가 늘어날지 결정
- 콘텐츠 바뀌면
invalidateIntrinsicContentSize()(라벨 자동) / 커스텀 뷰는override var intrinsicContentSize - 텍스트 위아래 간격(폰트 line height)도 라벨 intrinsic 높이에 포함
systemLayoutSizeFitting vs boundingRect
systemLayoutSizeFitting | boundingRect | |
|---|---|---|
| 소속 | UIView 인스턴스 메서드 | NSString/NSAttributedString 메서드 |
| 대상 | 뷰(서브뷰·제약 포함) | 순수 텍스트 |
| 기준 | Auto Layout 제약을 풀어 크기 계산 | 텍스트 + 폰트로 크기 계산 |
| 결과 | 그 뷰의 크기 | 텍스트가 차지하는 크기 |
systemLayoutSizeFitting=뷰(제약 기반), boundingRect=텍스트(폰트 기반). intrinsic은 단일 뷰 고유 크기, systemLayoutSizeFitting은 intrinsic+제약을 종합해 푼 결과.CollectionView는 셀 크기를 어떻게 계산? (TableView처럼 systemLayoutSizeFitting?)
CollectionView는 크기 결정을 레이아웃 객체가 한다. 세 모드:
| 방식 | 어떻게 | self-sizing? |
|---|---|---|
| 고정 크기 | flowLayout.itemSize = CGSize(...) | ❌ |
| delegate | sizeForItemAt — 개발자가 크기 계산해 반환 | ❌ |
| self-sizing | estimatedItemSize = .automaticSize / Compositional .estimated | ✅ |
✅ self-sizing 모드면 TableView처럼 systemLayoutSizeFitting 기반이다(단 명시적으로 켜야 함 — 기본 아님).
- TableView의
estimated → 보일 때 systemLayoutSizeFitting과 같은 패턴. 단 중간에 셀의preferredLayoutAttributesFitting(_:)이 측정값을 레이아웃에 전달하는 다리 역할 - TableView는 높이만(세로 1열), CollectionView는 폭·높이 둘 다 self-size 가능(2D)
10. CollectionView 레이아웃 동작 — 스크롤→배치 전체 흐름 Q&A (2026-06-28)
핵심 Q&A
| 질문 | 답 |
|---|---|
| hitTest는 panGesture를 찾나? | 아니. 터치 지점의 뷰를 찾음(1회). 버튼이면 버튼 액션, 스크롤뷰면 panGesture, 셀이면 tap. 그 뷰가 가진 처리기가 동작 |
| 스크롤뷰 layout 패스가 셀 frame 정하나? | ✅ CollectionView의 layoutSubviews가 레이아웃 객체(prepare→layoutAttributesForElements)로 attributes를 얻고 attributes.frame으로 셀 배치. attributes=frame+transform+alpha+zIndex |
| attributes를 셀에 적용하는 메서드? | cell.apply(_ layoutAttributes:) |
| 누가 호출하나? | 전부 CollectionView(UIKit 내부)가 호출. prepare/layoutAttributesForElements(레이아웃 구현), preferred/apply(셀 구현). 개발자는 구현만, 직접 호출은 invalidateLayout()만 |
| layoutAttributesForElements vs preferredLayoutAttributesFitting | 전자=레이아웃이 "이 영역 전체 배치도" 반환 / 후자=셀이 "내 실제 크기" 피드백(self-sizing). 후자가 전자의 추정 attributes를 받아 systemLayoutSizeFitting으로 실제 크기 계산 |
| shouldInvalidate true/false 의미 | false(그리드 기본)=무효화 X, 캐시 재사용 / true(효과)=invalidate→prepare 재계산. FlowLayout은 size 변경 시 true, 스크롤(origin)엔 false |
| shouldInvalidateLayout 언제 호출? | forBoundsChange:는 bounds 변경(스크롤·회전)마다. 별도로 forPreferredLayoutAttributes(self-sizing 보정), invalidateLayout(직접), 데이터 변경 |
추가 Q&A (내부 동작)
| 질문 | 답 |
|---|---|
| 이벤트는 gesture recognizer만 받나? | 아니. UIResponder의 touchesBegan/Moved/Ended(raw 터치)와 UIControl 이벤트(버튼 target-action)도 받음. gesture가 우선 평가하고, 인식되면 그 뷰의 touchesCancelled 호출 |
| "스크롤=contentOffset 갱신" 코드는? | UIScrollView 내부 비공개 구현 — panGestureRecognizer 액션이 contentOffset 갱신. 우리가 보는 건 panGestureRecognizer 프로퍼티 + scrollViewDidScroll 콜백 |
| invalidateLayout = 더티 플래그? | ✅ setNeedsLayout 같은 레이아웃 더티 → 다음 패스에서 CollectionView.layoutSubviews() → 무효면 prepare→attributes→apply |
| cell.apply 호출 시점? | dequeue 직접이 아니라, CollectionView가 셀 배치 시 내부적으로 자동 호출 (dequeue→cellForItem 구성→apply) |
shouldInvalidateLayout(forBoundsChange:)를 호출할 뿐, 그리드(FlowLayout)는 false라 invalidate 안 함(셀 위치 고정). invalidate는 ① shouldInvalidate true(효과) ② self-sizing 보정 ③ 데이터 변경 때.
| 그리드 스크롤 | 효과 스크롤 | self-sizing 보정 | |
|---|---|---|---|
| shouldInvalidate | false | true | true |
| prepare 재호출 | ❌ | ✅ | ✅ |
| layoutAttributesForElements | ✅(캐시 필터) | ✅(재계산) | ✅(재계산) |
→ 그리드 스크롤은 prepare(전체 재계산) 없이, layoutAttributesForElements가 캐시에서 새 visible rect만 필터해 새 셀 배치. invalidate ≠ layoutAttributesForElements 호출.
invalidate(forPreferredLayoutAttributes)→prepare 발생(단 그 셀 크기는 한 번 보정 후 수렴).shouldInvalidateLayout(forBoundsChange:)를 거치지 않고 직접 invalidate(데이터 변경은 boundsChange가 아님).
이벤트 전달 & 제스처 (터치 → 스크롤/탭)
| 질문 | 답 |
|---|---|
| 제스처는 어느 뷰까지 받나? | hit 뷰부터 superview chain 따라 window까지 모든 뷰의 제스처 인식기가 평가. cell→contentView→collectionView(panGesture)→…→window |
| 제스처 인식기 없는 뷰는? | 제스처 경로에선 통과(평가 안 함)→상위 인식기로 전파. 단 hit 뷰면 raw touchesBegan은 받음(기본은 next responder로). isUserInteractionEnabled=false면 hitTest에서 제외 |
| responder는 뗄 때만 받나? | 아니. touchesBegan/Moved/Ended/Cancelled 4개 — 이동(Moved)도 추적. pan을 raw touch로 직접 구현도 가능(제스처가 그걸 캡슐화) |
| 왜 touchesCancelled로 끝나나? | 제스처가 터치를 가로채면 원래 처리하던 뷰는 무효 → "취소해" 통보(셀 highlight 해제). ended였으면 탭으로 오해해 didSelect 실행 → 그래서 스크롤 중 셀 선택 안 됨 |
| 스크롤=contentOffset 갱신 코드? | UIScrollView 내부 비공개 구현(panGesture 액션이 갱신). 보이는 건 panGestureRecognizer 프로퍼티 + scrollViewDidScroll |
11. hitTest · GestureRecognizer 전체 흐름 Q&A (2026-06-30)
터치 이벤트 전체 흐름
1. 하드웨어 → 시스템
터치 발생 → IOKit → SpringBoard → 앱의 Main RunLoop
2. UIApplication → UIWindow
UIApplication.sendEvent(_:) → UIWindow.sendEvent(_:) → hitTest(_:with:) 시작
3. hitTest (Top-Down, 역순 재귀)
Window
└─ point(inside:with:) ✅
└─ subviews.reversed() 순회
└─ ViewA: point(inside:) ❌
└─ ViewB: point(inside:) ✅
└─ ButtonC: ✅ → 반환!
* userInteractionEnabled = false → 스킵
* isHidden = true → 스킵
* alpha < 0.01 → 스킵
4. 터치 전달 + GestureRecognizer 경쟁
hitTest 뷰(ButtonC) 찾음
↓
【동시에 시작】
├─ ButtonC.touchesBegan 호출
└─ GestureRecognizer들도 터치 받음 (hitTest 뷰 ~ Window까지 모든 GR)
5. GR 인식 성공 시
GR.state = .recognized
↓
cancelsTouchesInView = true (기본값)
↓
ButtonC.touchesCancelled 호출
Q. hitTest(_:with:) 어떤 클래스에서 시작?
UIWindow 인스턴스에서 시작. UIWindow가 UIView를 상속하므로 hitTest 메서드를 가짐.
UIWindow.sendEvent(_:)
↓
self.hitTest(point, with: event) // UIWindow에서 시작
↓
subviews.reversed().forEach { $0.hitTest(...) } // 재귀
Q. Button과 SuperView 둘 다 TapGR 있으면?
둘 다 action 호출됩니다 (기본 동작):
┌─────────────────────────────────┐ │ SuperView (TapGR ①) │ │ └─ ButtonC (TapGR ②) │ └─────────────────────────────────┘ ButtonC 탭 시: - TapGR ① → .recognized ✅ → action1 호출 - TapGR ② → .recognized ✅ → action2 호출 → 둘 다 호출됨!
하나만 반응하게 하려면:
// 방법 1: require(toFail:)
superViewTapGR.require(toFail: buttonTapGR)
// 방법 2: delegate
func gestureRecognizer(_ gr: UIGestureRecognizer,
shouldReceive touch: UITouch) -> Bool {
return !(touch.view is UIButton) // Button이면 SuperView GR 무시
}
Q. touchesCancelled 호출 조건?
GR이 recognized + cancelsTouchesInView = true (기본값):
gestureRecognizer.cancelsTouchesInView = true // 기본값 → 뷰 터치 취소 gestureRecognizer.cancelsTouchesInView = false // 뷰도 터치 계속 받음
Q. GR이 touchesBegan보다 우선순위 높음?
"우선순위"보다는 "가로채기 권한":
터치 발생
↓
【동시에 전달】
├─ GR.touchesBegan
└─ View.touchesBegan
↓
GR이 recognize 성공
↓
View.touchesCancelled (GR이 가로챔)
동시에 받지만, GR이 인식하면 뷰 터치를 취소할 권한이 있음.
Q. Button GR 실패, SuperView GR 성공 → Button touchesBegan 취소?
✅ 취소됩니다:
┌─────────────────────────────────┐ │ SuperView (PanGR) ← 인식 성공 │ │ └─ ButtonC (TapGR) ← 인식 실패 │ └─────────────────────────────────┘ 1. ButtonC.touchesBegan 호출됨 2. 사용자가 드래그 (Pan) 3. ButtonC의 TapGR 실패 4. SuperView의 PanGR 성공 (.recognized) 5. ButtonC.touchesCancelled 호출 ← 취소!
SuperView의 GR이 성공하면 하위 뷰들의 터치도 취소됩니다.
Q. Button과 SuperView가 같은 GR 타입이면 하나만 반응?
❌ 기본적으로 둘 다 반응합니다:
SuperView.addGestureRecognizer(tapGR1) // action1 ButtonC.addGestureRecognizer(tapGR2) // action2 // ButtonC 탭 시 → action1, action2 둘 다 호출!
| 질문 | 답 |
|---|---|
| hitTest 시작 클래스 | UIWindow (UIView 상속) |
| 부모/자식 둘 다 GR | 둘 다 호출 (기본) |
| touchesCancelled 조건 | GR recognized + cancelsTouchesInView |
| GR vs touchesBegan | 동시 전달, GR이 가로채기 권한 |
| 부모 GR만 성공 | 자식 뷰 터치도 취소됨 |
| 같은 GR 타입 | 둘 다 반응 (막으려면 require(toFail:)) |
12. 스크롤 hitch — 프레임 예산·주범·해결 (2026-07-19)
1) hitch = "프레임 예산 초과"
- 60fps → 프레임당 16.67ms, 120fps(ProMotion) → 8.33ms(더 빡빡).
- 한 프레임 작업이 예산을 넘기면 그 프레임을 제때 못 그림 → 툭 끊김 = hitch.
2) 왜 스크롤에서? — 스크롤은 메인 스레드에서 돈다
CATransaction의 Layout→Display를 메인이 매 프레임 처리. 프레임마다 메인이 하는 무거운 일이 곧 원인.
3) 주범 5가지
| 주범 | 무엇 |
|---|---|
| 동기 이미지 디코딩 | 메인에서 UIImage 디코딩(압축→비트맵) |
| 오토레이아웃 | 복잡한 제약 풀이를 매 프레임 |
| 오프스크린 렌더링 | cornerRadius+masksToBounds, shadow(shadowPath 없이) |
| 블렌딩 | 반투명 레이어 겹침(alpha<1) 합성 비용 |
| 무거운 셀 구성 | cellForRow에서 텍스트 사이징·파싱·정렬 |
4) 해결책 (원인별)
- 이미지: 메인 동기 디코딩 금지 → 백그라운드 디코딩+다운샘플(ImageIO), 완료 후 메인 set. prepareForDisplay(iOS15+)/Kingfisher DownsamplingImageProcessor.
- 오토레이아웃: 셀 높이 캐싱, 제약 단순화, 심하면 수동 frame·미리 계산한 layout attributes.
- 오프스크린 제거: 둥근 이미지는 미리 둥글게 그린 이미지/CAShapeLayer 마스크, 그림자는 layer.shadowPath 지정. shouldRasterize는 정적 복잡 레이어만(남용 역효과).
- 블렌딩 감소: isOpaque = true+backgroundColor 지정, alpha 남발 금지.
- 셀 경량화+prefetch: dequeueReusableCell, 무거운 계산은 미리/백그라운드, UICollectionViewDataSourcePrefetching으로 다가올 데이터 선로드.
5) 진단 도구 (측정 먼저)
- Instruments → Core Animation: Color Offscreen-Rendered Yellow(오프스크린) / Color Blended Layers Red(블렌딩).
- Instruments → Animation Hitches / Time Profiler(메인 병목), CADisplayLink로 FPS.
✅ 체크 질문
- 60fps 프레임 예산? 넘기면? → 16.67ms, 넘기면 프레임 드롭=hitch
- cornerRadius+masksToBounds가 왜 끊기게? → 오프스크린 렌더링 패스 유발(매 프레임 별도 버퍼 합성)
- 이미지가 스크롤 중 뚝뚝 뜬다 원인·해결? → 메인 동기 디코딩 → 백그라운드 디코딩+다운샘플
📌 §12 후속 — 오프스크린 vs CPU draw / 블렌딩
Q. 오프스크린 렌더링(GPU)은 CPU draw보다 나은가?
"둘 중 뭐가 낫냐"보다 둘 다 피하고 single-pass 합성으로 가는 게 목표. 세 가지 구분:
| 방식 | 어디서 | 비용 |
|---|---|---|
| 일반 합성(single-pass) | GPU | 가장 쌈(텍스처 한 번에 합성) |
| 오프스크린 렌더링 | GPU | 비쌈(별도 버퍼에 먼저 그린 뒤 다시 합성=패스 추가) |
| CPU draw(draw(_:)/CoreGraphics) | CPU(메인) | 비쌈 + 메인 스레드 블록 |
- 오프스크린이 비싼 이유: 일반 합성은 텍스처를 한 번에 얹지만, cornerRadius+masksToBounds·shadow는 합쳐진 결과를 별도 버퍼에 만든 뒤 다시 가져와 합성(추가 패스+버퍼 할당+렌더 타깃 전환).
- CPU draw보다 "덜 나쁜" 면: GPU 오프스크린은 메인을 안 막음(CPU draw는 메인 블록으로 스크롤 자체 멈춤). 하지만 프레임마다×많은 셀이면 GPU가 예산 못 맞춰 hitch.
- 정답은 오프스크린 자체 제거: shadow→layer.shadowPath, 둥근 모서리→미리 둥근 이미지/코너 마스크 → GPU single-pass.
Q. 블렌딩(blending)이란?
반투명 레이어를 화면에 합칠 때 아래 픽셀과 위 픽셀을 섞는 GPU 작업.
- 비유: 불투명=페인트 덧칠(아래 안 봄), 반투명=유리 겹침(아래 색 봐서 섞음).
- 성능 문제: 반투명 뷰가 여러 겹(overdraw)이면 픽셀마다 읽고 섞기 누적 → GPU 부하 → hitch. 흔한 원인: 투명 배경 라벨(글자 안티에일리어싱), alpha 남발.
- 해결: isOpaque = true+backgroundColor(투명 배경 금지), 겹친 반투명 줄이기. Instruments Color Blended Layers=빨강.
Q. 셀 높이 — 테이블뷰 vs 컬렉션뷰
| UITableView | UICollectionView | |
|---|---|---|
| 차원 | 1D(세로 리스트) | 2D(자유 레이아웃) |
| 높이 주체 | 테이블뷰가 행별 높이 관리 | 레이아웃 객체(FlowLayout/Compositional/Custom)가 크기 결정 |
| 고정 | rowHeight(가장 빠름) | itemSize / sizeForItemAt |
| 동적 | automaticDimension + estimatedRowHeight | estimatedItemSize / automaticSize |
| self-sizing 계산 | systemLayoutSizeFitting | preferredLayoutAttributesFitting |
- 테이블뷰: estimatedRowHeight로 실제 계산을 lazy → 초기 빠름. 추정 틀리면 스크롤바 튐.
- 컬렉션뷰: 크기가 레이아웃 객체 소관이라 유연하지만 self-sizing 무효화(invalidation) 비용 조심.
- 둘 다 높이 계산 캐싱이 이득.
Q. CAShapeLayer는 왜 오프스크린이 아닌가?
CAShapeLayer는 벡터 path를 GPU가 한 번에(single-pass) 직접 래스터화. cornerRadius+masksToBounds는 이미 합성된 콘텐츠를 둥글게 오려야 해서 별도 버퍼 왕복(오프스크린)이 필요하지만, CAShapeLayer는 모양 자체를 바로 그림(합성 결과를 오려내는 게 아님) → 오프스크린 버퍼 왕복 없음.
Q. layer.shadowPath는 왜 오프스크린을 방지?
shadowPath 없으면 GPU가 그림자 모양을 콘텐츠 알파에서 추론해야 함 → 콘텐츠를 오프스크린으로 렌더해 윤곽 추출 후 블러(비쌈, 콘텐츠 바뀌면 재계산). shadowPath 지정=그림자 모양(CGPath)을 미리 명시 → 모양 추출 단계를 건너뛰어 오프스크린 방지.
Q. shouldRasterize란?
layer.shouldRasterize = true = 레이어(+서브레이어)를 한 번 비트맵으로 렌더해 캐시하고 이후 재사용(매 프레임 재렌더 X). 정적 복잡 레이어=이득(한 번 굽고 재사용), 자주 바뀌는 콘텐츠=역효과(캐시 무효화→매 프레임 재래스터화)+메모리. rasterizationScale=screen scale 안 주면 흐림. 캐시 굽는 것 자체는 오프스크린이나 재사용되면 남는 장사.
Q. "블렌딩 줄이기" = GPU 작업이 많아지나?
맞다. 블렌딩은 GPU 픽셀 연산량. 반투명 픽셀마다 아래를 읽고 섞기 → overdraw 누적 → GPU 부하 → hitch. "줄이기"=isOpaque=true+배경색으로 GPU가 아래를 안 읽고 덮어쓰게(섞기→덮어쓰기), 겹친 반투명·투명 배경 라벨 제거. → GPU 작업이 줄어 부드러워짐.
13. 스크롤 성능 파이프라인 — 셀 재사용 규칙·prefetch·in-flight dedup (2026-07-20)
1) 셀 1만 개 처리 & 재사용이 강제하는 규칙
- 셀 재사용(reuse pool): 화면에 보이는 10~15개만 실제로 존재. 사라진 셀을 큐에 넣어 재사용하고 내용만 갈아끼움 → 메모리 절약(1만 개 다 만들지 않음).
- 함정: 큐에서 꺼낸 셀엔 이전 행 내용이 남아있음(중고 셀). 초기화된 새 셀이 아니다.
- 황금 규칙 2가지:
- 모든 분기에 대해 상태를 명시적으로 설정. if만 있고 else가 없으면 이전 셀 상태가 유령처럼 남는다(예: 배지).
- prepareForReuse()에서 리셋(이미지 nil, 진행 중 로딩 cancel 등).
- 안 지키면: 스크롤 시 엉뚱한 이미지가 잠깐 보임, 배지·라벨 유령.
2) prefetching — cellForItemAt에서도 로딩해야 하는 이유
- 시스템이 스크롤 속도·방향을 예측해 곧 보일 항목을 미리 알려줌 → 백그라운드 선(先)로딩.
- prefetch는 "보장이 아니라 힌트": 안 불릴 수도, 불렸다 취소될 수도 있음 → cellForItemAt에서도 반드시 로딩 트리거(누락 방지).
- 취소 콜백이 짝으로 중요: 예측이 빗나가 지나쳐 버린 항목의 로딩을 취소해야 대역폭·CPU 낭비 방지.
- prefetch 로딩과 cellForItemAt 로딩은 같은 캐시·같은 in-flight 관리를 공유해야 함. 안 그러면 같은 URL을 두 번 다운로드(예: https://cdn.example.com/img/1.jpg).
3) in-flight 관리 & dedup actor
in-flight = 지금 진행 중(다운로드·디코딩 중, 아직 미완료)인 요청. 관리 = 진행 중이면 새로 시작하지 말고 그 작업을 공유(중복 제거·dedup).
- reentrancy "방지"가 아니라 "대응": actor는 await에서만 suspend된다. .inProgress(task)를 첫 suspend 전에 동기적으로 써두므로, 그 사이 들어온 두 번째 호출이 같은 task를 await → 중복 다운로드 방지.
- 핵심 2가지: ① 디코딩·다운샘플을 같은 task 안에(공유 호출도 완전히 디코딩된 이미지를 받음) ② 실패 시 cache 제거(재시도 가능하게).
14. Diffable Data Source 심화 · 간격 구분 (2026-07-20)
전통 DataSource의 문제
numberOfItems·cellForItemAt 구현 + 변경 시 배열과 뷰 행 개수를 손으로 동기화해야 함.
Diffable(iOS 13+) — "지금 상태"를 스냅샷으로 선언
⭐ "apply는 수동, diff는 자동" (자동 감지 아님)
- 여전히 내가: 데이터 바뀌면 새 스냅샷 만들어 apply() 호출(트리거 수동).
- 프레임워크가: 이전↔새 스냅샷 비교 → 어느 인덱스 insert/delete/move인지 계산 + 애니메이션.
applyInitialSnapshot()은 내장 API 아님 — 스냅샷 만들어 apply하는 코드를 뺀 직접 만든 헬퍼(이름 자유). 첫 로드는 animatingDifferences: false가 자연스러움.
Hashable 필수 조건 + 함정
- Section·Item 둘 다 Hashable — 프레임워크가 해시/동등성으로 "같은 아이템인지" 식별해 추가/삭제/이동 판단.
- 식별자 중복(항상 같은 값) → 크래시 "non-unique item identifiers".
- 정체성에 가변 내용 섞음(Hashable에 title 포함, title 변경) → "다른 아이템"으로 착각 → 삭제+삽입(전체 리로드 애니). 내용만 갱신을 원했는데.
- 베스트: Item 정체성은 안정적 고유 ID로, 내용 변경은 iOS 15+
reconfigureItems/reloadItems. 실무선 모델 대신 ID를 Item으로 쓰기도.
Hashable = ① 해시값(Int) 생성 + ② 동등 비교(==, Equatable 상속). struct/enum은 저장 프로퍼티 전부 Hashable이면 자동 합성. 정체성을 id만으로 하려면 hash(into:)+== 를 id 기준 커스텀.
무엇을 대체하나 / dataSource는 어디서 생성
| 전통 | Diffable |
|---|---|
| numberOfSections/numberOfItems | 스냅샷의 섹션/아이템 개수 자동 |
| cellForItemAt | dataSource 생성 시 셀 제공자 클로저 |
| reloadData | apply(snapshot) |
| performBatchUpdates+insert/delete/move(수동) | apply(snapshot) diff 자동 |
dataSource는 viewDidLoad에서 한 번 생성해 프로퍼티로 보관(cellForItemAt에서 X). 생성자 클로저 = 셀 제공자 = cellForItemAt 대체(프레임워크가 필요 시 알아서 호출).
FlowLayout — minimumLineSpacing vs minimumInteritemSpacing
- 세로 스크롤: lineSpacing=줄과 줄 사이=행 사이 세로 간격, interitemSpacing=같은 줄 안 아이템끼리=같은 행 안 가로 간격.
- 가로 스크롤이면 뒤집힘(line=열 사이 가로, interitem=같은 열 안 세로).
- 규칙: line=스크롤 방향 줄 사이, interitem=한 줄 안 아이템 사이. "minimum"인 건 정렬하며 더 벌릴 수 있어서.
15. reconfigure / CellRegistration / orthogonal 정리 (2026-07-20)
reloadItems vs reconfigureItems (iOS 15+)
| reloadItems / reloadData | reconfigureItems (iOS 15+) | |
|---|---|---|
| 동작 | 셀을 재사용 큐로 보내고 새로 dequeue해 재구성 | 기존 셀 그대로 두고 제공자만 재실행 |
| 비용 | 깜빡임·비쌈 | 싸고 부드러움 |
| 용도 | 셀 교체(타입·구조 변경) | 같은 아이템 내용만 변경(좋아요 수 등) |
테이블뷰도 UITableViewDiffableDataSource 스냅샷에 reconfigureItems 있음(iOS 15+). "정체성 유지 + 내용만 갱신"이 §14 Hashable 함정 대응과 연결.
CellRegistration (iOS 14+)
셀 등록+구성을 한 곳에서 — 재사용 식별자 문자열 제거, 타입 안전, Diffable 궁합.
orthogonalScrollingBehavior — 중첩 컬렉션뷰 회피
- NSCollectionLayoutSection 프로퍼티. 그 섹션만 메인 스크롤과 직각(메인 세로 → 섹션 가로 스트립).
- 대안(중첩 컬렉션뷰: 안쪽이 자기 dataSource·delegate·재사용 따로) 없이 한 줄로 가로 스크롤 + 같은 Diffable 공유 → 깔끔.
- 값:
.continuous·.paging·.groupPaging(Centered). 한 섹션이 세로·가로 동시는 아님(바깥 세로 + 섹션 가로 띠).
실전 설계 요약 (피드형: 세로 무한 + 상단 가로 스토리 + 가변 카드)
| 축 | 설계 |
|---|---|
| 뷰 구조 | CollectionView + CompositionalLayout. 스토리 섹션 orthogonal .continuous, 피드 세로 그룹(중첩 회피) |
| 데이터소스 | Diffable. 추가/삭제=새 스냅샷 apply, 내용 변경=reconfigureItems. Item 정체성=ID |
| 셀 크기 | group .estimated self-sizing(systemLayoutSizeFitting). 초복잡하면 수동 프레임+높이캐싱 |
| 이미지 | 다운샘플+백그라운드 디코딩+2단 캐시(키=url+w+h)+in-flight dedup. CDN 크기쿼리 |
| 스크롤 | prefetch 선로딩 + prepareForReuse 취소 + 완료 시 URL identity 확인. 메인 디코딩 금지 |
| 페이지네이션 | willDisplay(마지막 근처)/prefetch에서 다음 페이지 + 중복요청 가드(isLoading) → 스냅샷 append |
16. Self-Sizing Cell 전체 동작 흐름 (2026-07-21)
암기용 순서
단계별 포인트
- 처음엔 실제 크기를 모른다: FlowLayout은 셀 안 텍스트를 아직 모름 → estimatedItemSize로 "아마 이 정도" 예상 attributes(예: 각 height 100).
- Layout Attributes가 먼저 → 그다음 cellForItem(반대 아님). 레이아웃이 "어디에 뭐가 있는지"를 알아야 그 위치 셀만 dequeue.
- cellForItemAt에서 실제 데이터 설정(titleLabel.text 등) → 제약이 내용에 맞게 결정 → 이제야 셀이 "몇 pt 필요한지" 앎.
- CollectionView가 preferredLayoutAttributesFitting 호출 — Layout이 아니라 CollectionView 시스템이 호출 주체.
- 셀이
contentView.systemLayoutSizeFitting으로 실제 높이 계산(예: 예상 100 → 실제 185). - Original vs Preferred attributes 비교.
- shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) → true면 무효화. 짝으로
invalidationContext(forPreferredLayoutAttributes:...)가 바뀐 셀 이후만 다시 계산하도록 context 생성(전체 재계산 아님). - invalidate 후 prepare → layoutAttributesForElements 재수행(필요 영역만).
누가 무엇을 호출하나
| 주체 | 호출/역할 |
|---|---|
| Layout | prepare()·layoutAttributesForElements()·layoutAttributesForItem() — 예상 위치·크기 attributes 생성 |
| CollectionView | cellForItemAt(셀 생성·데이터)·preferredLayoutAttributesFitting(실제 크기 측정 요청) |
| Cell | Auto Layout(systemLayoutSizeFitting)으로 실제 크기 계산·preferred attributes 반환 |
Elements vs Item
layoutAttributesForElements(in:): 현재 rect에 필요한 모든 셀(+헤더/푸터/데코)의 attributes 배열.layoutAttributesForItem(at:): 특정 하나의 attributes. 커스텀 레이아웃은 보통 forElements 안에서 forItem을 호출.
systemLayoutSizeFitting을 직접 호출(preferredLayoutAttributesFitting 훅 없음) → automaticDimension. 나머지 흐름(예상→실제→보정)은 동일.관련 문서
- 뷰 레이아웃 전체 과정 심화 — 레이아웃/렌더 파이프라인(스크롤 성능의 기반)
- 큰 이미지 메모리 · UIImage 파이프라인
- 커스텀 CollectionView/TableView 설계 & 구현 — Waterfall/Circular Layout 전체 구현, 레이아웃 전환