← 홈으로
UIKit · Scrolling

UIScrollView · UITableView · UICollectionView

스크롤의 본질(bounds 이동)부터 셀 재사용 메커니즘, self-sizing, Diffable Data Source, Compositional Layout, 스크롤 60fps 성능까지 — 동작 원리와 심화를 정리한다. (2026-06-21)

UIScrollView 셀 재사용 스크롤 성능

0. 큰 그림 — 셋의 관계

UIScrollView // 스크롤의 모든 것(bounds 이동) ├─ UITableView // 1차원(세로) 리스트 + 셀 재사용 └─ UICollectionView // 임의 레이아웃 + 셀 재사용 (레이아웃 객체가 위치 결정)

TableView·CollectionView는 UIScrollView의 서브클래스다. 즉 스크롤 동작은 ScrollView에서 오고, 그 위에 "셀 재사용 + 데이터소스 기반 lazy 생성"을 얹은 것. CollectionView는 TableView를 일반화해 레이아웃을 외부 객체(UICollectionViewLayout)에 위임한다.

⚠️ ScrollView 자체는 재사용·dataSource가 없다. 그냥 스크롤되는 빈 컨테이너라, 개발자가 addSubview로 직접 뷰를 넣고 그 뷰들은 전부 메모리에 상주한다(화면 밖이든 안이든). 재사용·dataSource·lazy 생성은 Table/Collection이 추가한 기능이다.
  • ScrollView: 재사용 ❌ · 직접 addSubview(다 상주) · 뷰 적을 때(배너·폼)
  • Table/Collection: 재사용 ✅ · dataSource가 cellForItem/Row보일 때만 lazy 생성 · 데이터 많을 때

"데이터소스 기반 lazy 생성" = 모든 셀을 미리 안 만들고, 보일 때가 되면 cellForItemAt(/cellForRowAt)이 불려 그때 dequeue(재사용)+구성하는 것. 보일 셀만큼만 그때그때 호출된다.

UITableViewUICollectionView
레이아웃세로 1열 고정Layout 객체가 자유 결정(그리드·가로·커스텀)
셀 재사용
supplementary섹션 헤더/푸터임의(헤더·푸터·배지 등)

1. UIScrollView — 스크롤의 본질은 "bounds 이동"

스크롤은 콘텐츠를 움직이는 게 아니라, scroll view의 bounds.origin을 움직이는 것이다. 뷰는 자식을 bounds 좌표계 기준으로 그리므로, bounds.origin을 (0,100)으로 옮기면 자식이 위로 100만큼 올라가 보인다.

scrollView.contentOffset == scrollView.bounds.origin // 사실상 같은 값 contentOffset = (0, 100) → bounds.origin=(0,100) → 아래로 100 스크롤된 상태
핵심: 콘텐츠는 고정, "보는 창(bounds)"만 이동. 콘텐츠(서브뷰)는 scroll view 좌표계에서 제자리 고정이고, scroll view가 자기 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.

originsize
frame(20, 30)(300, 500)부모 좌표계에서 내 위치 + 화면상 크기
bounds(0, 0) // 스크롤 전(300, 500)보는 시작점 + 보이는 창 크기 (size는 frame과 같음)
contentSize(300, 2000)스크롤 가능한 전체 콘텐츠 크기 (별개)

핵심 프로퍼티

프로퍼티의미
contentSize스크롤 가능한 전체 콘텐츠 크기. 이게 frame보다 커야 스크롤됨
contentOffset현재 스크롤 위치(=bounds.origin)
contentInset콘텐츠 가장자리 여백(상단 바·키보드 회피 등)
adjustedContentInset iOS 11+safe area를 반영한 최종 inset. contentInsetAdjustmentBehavior로 제어
contentLayoutGuide / frameLayoutGuideAuto Layout으로 scroll view 구성 시 기준(콘텐츠 크기 vs 보이는 프레임)

contentInset — 콘텐츠 둘레 여백

contentInset = 콘텐츠 둘레(상하좌우)에 추가하는 여백(스크롤뷰 가장자리 ↔ 콘텐츠 사이). 스크롤 가능 범위를 그만큼 늘린다.

// 키보드 회피 — 가장 흔한 용도 func keyboardWillShow(_ note: Notification) { let kb = (note.userInfo?[.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height ?? 0 scrollView.contentInset.bottom = kb // 하단 여백 추가 → 가린 콘텐츠 끌어올림 scrollView.verticalScrollIndicatorInsets.bottom = kb } func keyboardWillHide() { scrollView.contentInset.bottom = 0 } // 원복

스크롤 범위 계산 (bounds 500, contentSize 1000, inset top:50·bottom:30):

inset 없음inset 있음계산
최상단 offset.y0-50top inset만큼 더 위로
최하단 offset.y500530contentSize(1000) - bounds(500) + bottom(30)
// 최하단 = 스크롤 끝까지 내린 상태 = 창 아래가 "스크롤 가능 영역 끝"에 닿을 때 inset 없음 : 콘텐츠 끝 1000 → 창 위(offset) = 1000 - 500 = 500 bottom 30 : 끝에 여백 30 덧붙임(1030) → 창 위 = 1030 - 500 = 530 창이 [530~1030] 봄: 530~1000=콘텐츠, 1000~1030=inset 여백(빈 공간)
직관: bottom inset = 콘텐츠 끝에 빈 종이를 덧붙여 스크롤을 그만큼 더 내릴 수 있게 하는 것. 키보드(300)가 가리면 bottom=300으로 300 더 내려 마지막 콘텐츠를 키보드 위로 올린다. RefreshControl도 당길 때 top inset을 일시 추가하는 원리.

adjustedContentInset 심화 iOS 11+

실제 적용되는 inset = adjustedContentInset (읽기 전용). 시스템이 자동 계산.

// 공식 adjustedContentInset = contentInset + (behavior에 따른 safeAreaInsets) // 예: 노치 기기, .automatic일 때 scrollView.contentInset = .zero scrollView.adjustedContentInset // top:47, bottom:34 (safe area 자동 추가)

contentInsetAdjustmentBehavior 옵션

adjustedContentInset용도
.automatic (기본)contentInset + safeAreaInsets일반적인 스크롤뷰
.alwayscontentInset + safeAreaInsets항상 safe area 반영
.nevercontentInset (그대로)전체 화면(이미지 뷰어), 직접 관리
.scrollableAxes스크롤 가능 축만 조정특수 케이스

⭐ 핵심: contentInset=0이어도 adjustedContentInset≠0

scrollView.contentInset = .zero // .automatic (기본값) 일 때 scrollView.adjustedContentInset.top // 47 (노치) scrollView.adjustedContentInset.bottom // 34 (홈 인디케이터) // .never 일 때 scrollView.adjustedContentInset // .zero (진짜 0)

⭐ 초기 contentOffset은 음수

adjustedContentInset.top이 있으면, 스크롤 최상단에서 contentOffset.y음수다.

// 스크롤 최상단 (맨 위) contentOffset.y = -adjustedContentInset.top // 예: -47 // 스크롤 최하단 (맨 아래) contentOffset.y = contentSize.height + adjustedContentInset.bottom - bounds.height // ⚠️ 최하단 계산에 .top은 안 들어감!

bounds/frame은 inset과 무관

scrollView.contentInset.top = 100 // 변하는 것 scrollView.adjustedContentInset.top // += 100 // 스크롤 가능 범위 늘어남 // 안 변하는 것 scrollView.bounds.height // 그대로 (scrollView 자체 크기) scrollView.frame.height // 그대로 scrollView.contentSize // 그대로 (콘텐츠 크기)
bounds.height = scrollView 전체 높이 (노치/홈 인디케이터 영역 포함). safe area와 무관. contentSize = 콘텐츠 자체 크기 (inset과 별개). 스크롤 가능 범위 = adjustedContentInset.top + contentSize + adjustedContentInset.bottom이지만, contentSize 프로퍼티 값 자체는 변하지 않는다.

실전 예시

// 1. 키보드 + safe area 동시 처리 func keyboardWillShow(_ note: Notification) { let kbHeight = ... // 키보드가 safe area를 덮으므로, safe area bottom 빼기 let bottomInset = kbHeight - view.safeAreaInsets.bottom scrollView.contentInset.bottom = bottomInset } // 2. 스크롤 최하단 도달 감지 func scrollViewDidScroll(_ sv: UIScrollView) { let maxOffset = sv.contentSize.height + sv.adjustedContentInset.bottom - sv.bounds.height if sv.contentOffset.y >= maxOffset { print("최하단 도달") } } // 3. 전체 화면 이미지 뷰어 (safe area 무시) scrollView.contentInsetAdjustmentBehavior = .never

스크롤 메커니즘

주의: scrollViewDidScroll은 스크롤 중 매 프레임(~16.7ms마다) 불린다. 여기에 무거운 계산을 넣으면 즉시 프레임 드롭. (레이아웃·렌더 파이프라인과 직결 — 뷰 레이아웃 심화 참고)

Auto Layout으로 ScrollView 구성 (자주 헷갈림)

// 콘텐츠 크기는 contentLayoutGuide에, 보이는 폭은 frameLayoutGuide에 건다 content.leadingAnchor == scrollView.contentLayoutGuide.leadingAnchor content.trailingAnchor == scrollView.contentLayoutGuide.trailingAnchor content.widthAnchor == scrollView.frameLayoutGuide.widthAnchor // 가로 스크롤 방지

SnapKit 버전:

scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } // scrollView frame 고정 scrollView.addSubview(contentView) contentView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) // 4변 → contentSize 결정 make.width.equalTo(scrollView.frameLayoutGuide) // 폭=보이는 폭 → 가로 스크롤 방지 } // contentView 안 자식들을 top~bottom 체인으로 쌓고, 마지막 뷰 bottom을 핀 lastView.snp.makeConstraints { $0.bottom.equalToSuperview() } // ★ 높이 확정
가이드정체여기 걸면
contentLayoutGuide스크롤되는 콘텐츠 영역콘텐츠 크기 → contentSize 결정
frameLayoutGuide보이는 프레임(=bounds.size)보이는 창 크기 기준(폭 고정→가로 스크롤 방지)
⚠️ "마지막 뷰 bottom 핀"이 높이를 정한다 (없으면 ambiguous): contentView는 고정 높이가 없다. footer.bottom == contentView.bottomcontentView.bottom을 참조하는 게 아니라 "둘이 같다"는 방정식 — footer 위치(자기 제약으로 확정)가 거꾸로 contentView.bottom(=높이)을 정의한다. 자식 체인이 bottom-up으로 높이를 만들어 contentSize가 된다.
header.height=200 → header.bottom=200 footer.top=216, height=800 → footer.bottom=1016 footer.bottom == contentView.bottom → contentView 높이 = 1016 = contentSize.height

마지막 bottom 핀을 빠뜨리면 → 높이 모호(ambiguous) → contentSize 미정 → 스크롤 안 됨. scroll view Auto Layout 단골 실수.

2. 셀 재사용(reuse) — 모든 것의 핵심

수천 개 행이 있어도 화면에 보이는 셀(+ 약간의 버퍼)만 메모리에 존재한다. 스크롤로 화면을 벗어난 셀은 파괴하지 않고 재사용 큐(reuse pool)에 넣었다가, 새로 들어오는 위치에 재활용한다.

// 보이는 영역(visible rect)이 바뀔 때마다: 1. 화면 밖으로 나간 셀 → reuse pool에 넣음 (prepareForReuse 호출) 2. 새로 들어올 위치 → pool에서 꺼내(없으면 생성) cellForRow/Item 호출 3. 데이터로 셀 구성 → 화면에 배치 let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) // pool에 있으면 재사용, 없으면 새로 생성. identifier별로 pool 관리
⚠️ prepareForReuse 함정: 재사용 셀은 이전 데이터의 잔상을 가진다. 셀 안 비동기 이미지 로딩을 안 취소하면, A행용으로 시작한 다운로드가 재사용된 B행 셀에 늦게 도착해 엉뚱한 이미지가 깜빡인다.
override func prepareForReuse() { super.prepareForReuse() imageView.image = nil // 잔상 제거 imageTask?.cancel() // 이전 비동기 작업 취소 }

셀 lifecycle

생성/재사용 → cellForRow/Item(구성) → willDisplay → [화면] → didEndDisplaying → reuse pool → prepareForReuse → ...

willDisplay/didEndDisplaying는 "곧 보임/방금 사라짐" 알림 — 무거운 구성보다 이 타이밍에 하는 게 유리한 작업(애니 시작, 로깅 등)이 있다.

3. UITableView 심화

그럼 TableView 레이아웃은 누가 관리하나? → UITableView 자기 내부. 별도 객체가 아니라 테이블뷰 구현에 세로 누적 배치 알고리즘이 내장돼 있다. "높이 정보 제공"과 "실제 배치"를 구분하면:
누가
높이 정보 제공개발자 — rowHeight / delegate heightForRowAt / self-sizing(제약)
실제 배치(frame 계산·셀 위치)UITableView 내부 (높이를 세로 누적 → 셀 frame 계산, visible rect 행만 배치, contentSize 합산)

delegate는 "이 행 높이 60" 같은 파라미터만 주고, 배치 알고리즘은 테이블뷰 것이라 교체 불가. 이걸 외부 객체로 뺀 게 CollectionView다.

높이 — self-sizing

방식동작
rowHeight = 고정값가장 빠름(계산 없음)
rowHeight = .automaticDimension + estimatedRowHeightself-sizing: 셀의 Auto Layout 제약으로 높이를 런타임 계산
estimatedRowHeight가 왜 중요한가: 테이블은 처음에 전체 contentSize(스크롤바 길이)를 알아야 한다. 모든 셀 높이를 미리 계산하면 비싸니, 추정값으로 일단 잡고 실제로 보일 때 정확히 측정한다. estimated가 실제와 너무 다르면 스크롤 중 contentOffset 점프가 생긴다.

self-sizing 심화

// systemLayoutSizeFitting — 제약을 풀어 "최적 높이" 계산 let size = cell.contentView.systemLayoutSizeFitting( CGSize(width: tableWidth, height: 0), // 폭 고정, 높이 모름 withHorizontalFittingPriority: .required, // 폭은 반드시 이 값 verticalFittingPriority: .fittingSizeLevel) // 높이는 제약 만족 최소로 // → 그 폭에서 멀티라인 텍스트가 몇 줄인지까지 계산해 높이 산출. top~bottom 제약 완결 필요
⭐ contentOffset 보정이 왜? (스크롤 위치인데): contentOffset은 콘텐츠 좌표계 기준 위치다. 위쪽 안 보이던 셀들이 추정(80)→실제(120)로 갱신되면 콘텐츠 좌표계(위쪽 길이)가 커진다 → 같은 셀을 계속 보려면 offset도 그만큼 옮겨야(보정). 안 맞으면 점프.
위쪽 셀 10개: 추정 80×10=800 → 보던 셀 A는 y=800, offset=800 실제 측정 120×10=1200 → A는 이제 y=1200 (콘텐츠 좌표 바뀜!) offset 800 그대로면 A 아닌 딴 걸 봄 → A 유지하려면 offset=1200 보정 → 안 맞으면 점프

그래서 위로 스크롤 시 점프가 심함(지나간 위쪽 셀 추정 오차). estimated를 실제 평균에 가깝게 주면 흔들림이 작아진다.

Prefetching iOS 10+

UITableViewDataSourcePrefetching클래스 아닌 프로토콜. prefetchDataSource로 연결. 곧 보일 행의 데이터(이미지 등)를 미리 준비해 빈 셀 깜빡임을 줄인다.

업데이트 — batch & Diffable

// 전통: 수동 batch update (인덱스 계산 직접 → 충돌나면 크래시) tableView.performBatchUpdates { tableView.insertRows(at: [...], with: .fade) } // 모던: Diffable Data Source (iOS 13+) — 스냅샷 차이를 자동 계산·애니 var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() snapshot.appendSections([.main]); snapshot.appendItems(items) dataSource.apply(snapshot, animatingDifferences: true)
Diffable의 핵심: Item은 Hashable이어야 한다. 이전/이후 스냅샷의 해시 기반 diff로 삽입·삭제·이동을 자동 계산 → "인덱스 직접 계산하다 크래시(NSInternalInconsistencyException)" 문제를 없앤다. 단 식별자(identity)와 내용(content)을 구분해야(같은 id인데 내용만 바뀌면 reconfigure).

4. UICollectionView 심화 — 레이아웃이 핵심

TableView와 재사용·데이터소스는 같고, 차이는 레이아웃을 UICollectionViewLayout 객체가 결정한다는 점. 위치·크기를 그 객체에 위임해 그리드·가로·폭포수 등 자유로운 배치가 가능하다.

TableView엔 레이아웃 객체가 없다 (고정이라서). UITableView는 세로 1열 리스트로 레이아웃이 고정이고 그 배치 로직이 테이블뷰 내부에 내장돼 있다(밖으로 빼서 교체 불가). 조절은 style·rowHeight·헤더/푸터 정도뿐.
UITableViewUICollectionView
레이아웃 객체❌ 없음(내장·고정)UICollectionViewLayout(필수, 교체 가능)
배치세로 1열 고정그리드·가로·폭포수·커스텀 자유

CollectionView는 그 배치 로직을 분리·필수 객체로 빼낸 일반화. 그래서 CollectionView로 세로 리스트도 만들 수 있어(FlowLayout 1열) 레이아웃 자유도 면에선 TableView의 상위집합에 가깝다. (단 레이아웃 없이는 못 만든다: init(frame:collectionViewLayout:))

레이아웃 객체의 동작

// 레이아웃은 "각 요소의 위치/크기(layoutAttributes)"를 계산해 제공 prepare() // 레이아웃 사전 계산(캐싱) collectionViewContentSize // 전체 콘텐츠 크기 layoutAttributesForElements(in rect) // 그 영역에 보이는 요소들의 속성 반환 ← 재사용 판단에 사용 layoutAttributesForItem(at:) // 특정 셀의 위치/크기/투명도/transform

레이아웃 종류

종류설명
UICollectionViewFlowLayout줄바꿈 그리드(아이템 크기·간격·스크롤 방향). 가장 기본
Compositional Layout iOS 13+Item → Group → Section 조합으로 복잡한 레이아웃을 선언적으로. 섹션마다 다른 레이아웃·orthogonal 가로 스크롤 가능
커스텀 UICollectionViewLayout폭포수(Pinterest)·원형 등 직접 구현(attributes 계산)
// Compositional Layout — item/group/section 구조 let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))) let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(80)), subitems: [item]) let section = NSCollectionLayoutSection(group: group) let layout = UICollectionViewCompositionalLayout(section: section)

self-sizing & invalidation

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)·배경색 지정
오프스크린 렌더링이란: GPU가 화면에 바로 합성하지 못하고 별도 버퍼에 미리 그린 뒤 다시 합성해야 하는 경우(마스킹·그림자 등). 컨텍스트 전환 비용이 커 스크롤에서 특히 치명적. 그림자는 layer.shadowPath를 지정하면 오프스크린을 피할 수 있다.

이미지 다운샘플

4000×3000 원본을 100×100 셀에 그대로 넣으면 디코딩된 비트맵이 메모리를 폭식(픽셀당 4바이트 × 1200만 = ~48MB)한다. ImageIO(CGImageSourceCreateThumbnailAtIndex)로 표시 크기로 다운샘플해 디코딩하면 메모리·시간 모두 절약. (큰 이미지 메모리 참고)

6. 체크리스트 (QA)

확정 사실 / 버전: Diffable Data Source·Compositional Layout·adjustedContentInset 등은 iOS 11~13+ 도입(표에 표기). 그 이전 타겟은 수동 batch update·FlowLayout·수동 inset을 써야 한다.

7. Q&A 심화 (6/22)

Q. setNeedsDisplay는 언제 호출?

draw(_:)를 오버라이드해서 Core Graphics로 직접 그릴 때만. 대부분의 앱에서 직접 호출할 일 거의 없음.

// 커스텀 차트/그래프처럼 직접 그릴 때 class ProgressView: UIView { var progress: CGFloat = 0 { didSet { setNeedsDisplay() } } // progress 바뀌면 다시 그려라 override func draw(_ rect: CGRect) { // Core Graphics로 호 그리기 } }
상황setNeedsDisplay 필요?
backgroundColor, alpha 변경❌ (layer 속성, GPU 처리)
UILabel.text 변경❌ (내부에서 처리)
draw(_:) 오버라이드해서 직접 그릴 때

Q. heightForRowAt이 100개 셀이면 100번 호출?

estimatedRowHeight 유무에 따라 다름.

상황호출 횟수
estimatedRowHeight = 0 (없음)100번 — 전체 contentSize 알려고 다 호출
estimatedRowHeight = 80 (있음)보이는 셀만 (예: 10번) — 나머지는 추정값 사용, 스크롤 시 추가 호출
// 확인하려면 func tableView(_ tv: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { print("heightForRowAt: \(indexPath.row)") // 몇 번 찍히나? return 60 }

Q. layout.prepare()의 layout은 collectionViewLayout?

맞음. CollectionView 내부에서 collectionViewLayout.prepare()를 호출하는 것.

Q. layout.prepare()가 layoutAttributes를 얻는 행위?

정확히는 "계산해서 캐싱"하는 행위.

override func prepare() { cache.removeAll() // ① 캐시 비우기 for item in 0..<collectionView!.numberOfItems(inSection: 0) { let attrs = UICollectionViewLayoutAttributes(...) attrs.frame = CGRect(...) // ② 위치/크기 계산 cache.append(attrs) // ③ 캐시에 저장 } }

이후 CollectionView가 layoutAttributesForElements(in:) 호출 → 캐시에서 필터링해서 반환.

Q. layout.prepare()는 언제 호출?

CollectionView가 자동 호출:

Q. TableView의 contentSize는 언제 계산?

TableView가 자동 계산:

// estimated 있을 때 흐름 tableView.reloadData() ↓ contentSize = estimatedRowHeight × numberOfRows // 추정 ↓ (화면 표시) ↓ 보이는 셀만 heightForRowAt 호출 → 실제 높이 ↓ contentSize 보정 (스크롤 시 계속 갱신)

Q. FlowLayout vs CompositionalLayout vs 커스텀?

FlowLayoutCompositional커스텀
방식줄바꿈 그리드Item→Group→Section 선언적4가지 메서드 직접 구현
유연성낮음높음최고
용도단순 그리드섹션별 다른 레이아웃, orthogonal원형, 폭포수, 3D
iOS6+13+6+
선택 가이드: 그리드/리스트만? → FlowLayout. 섹션별 다른 레이아웃? → Compositional. 원형/폭포수/3D? → 커스텀.

Q. UICollectionView 관련 클래스 정리

UICollectionView (뷰) ├── collectionViewLayout: UICollectionViewLayout (레이아웃 객체) │ ├── UICollectionViewFlowLayout (서브클래스) │ │ └── UICollectionViewDelegateFlowLayout (프로토콜) │ ├── UICollectionViewCompositionalLayout (서브클래스) │ └── 커스텀 Layout (직접 서브클래싱) ├── dataSource: UICollectionViewDataSource (프로토콜) └── delegate: UICollectionViewDelegate (프로토콜)
이름종류역할
UICollectionViewLayout클래스레이아웃 추상 베이스
UICollectionViewFlowLayout클래스Layout 서브클래스 (그리드)
UICollectionViewDelegateFlowLayout프로토콜FlowLayout용 delegate (sizeForItem)
UICollectionViewDataSource프로토콜데이터 제공 (cellForItem)
UICollectionViewDelegate프로토콜이벤트 처리 (didSelect)

Q. "layout 메서드 오버라이드"란?

커스텀 Layout 만들 때 4가지 메서드를 오버라이드한다는 뜻:

class MyLayout: UICollectionViewLayout { override func prepare() { } override var collectionViewContentSize: CGSize { } override func layoutAttributesForElements(in rect: CGRect) -> [...]? { } override func layoutAttributesForItem(at indexPath: IndexPath) -> ...? { } }

TableView는 이런 객체가 없고, heightForRowAt 같은 delegate 메서드로 값만 전달.

⭐ Q. TableView에서 "높이만 알면 frame 자동 계산" — 왜?

TableView 배치 = 세로 1열 누적. 규칙이 고정이라 "높이"만 알면 나머지 자동.

// TableView 내부 (의사코드) var y: CGFloat = 0 for row in 0..<numberOfRows { let height = heightForRow(at: row) // 개발자가 제공하는 건 이것뿐 // 나머지는 자동 let frame = CGRect( x: 0, // 항상 0 y: y, // 누적 합 width: bounds.width, // 항상 전체 폭 height: height // 개발자 제공 ) cells[row].frame = frame y += height } contentSize.height = y
비유:
  • 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 변경
// frame 직접 설정 — 즉시 반영 someView.frame = CGRect(x: 100, y: 100, width: 200, height: 200) print(someView.layer.frame) // 이미 바뀌어 있음! // view.frame은 layer.frame의 래퍼 var frame: CGRect { get { return layer.frame } set { layer.frame = newValue } // 즉시! }

view.frame = newFrame 하면: ① layer.frame 즉시 변경 ② setNeedsLayout() 호출 (서브뷰 재배치용)

Q. Auto Layout (makeConstraints) 전체 흐름

// 제약조건 설정 myView.snp.makeConstraints { make in make.width.equalTo(200) } print(myView.frame.width) // 아직 0 또는 이전 값! (즉시 안 바뀜) // 레이아웃 패스 후에야 200이 됨
흐름 (순서 중요!):
  1. 제약조건 변경/추가 → setNeedsLayout() 자동 호출
  2. Run Loop BeforeWaiting 도달
  3. updateConstraints() — 바텀업 ↑ (자식 → 부모)
  4. layoutSubviews() — 탑다운 ↓ (부모 → 자식) — 여기서 frame 계산!
  5. 각 뷰의 layer.frame 변경됨

Q. heightForRowAt과 estimatedRowHeight 관계

"둘 중 하나만"이 아니라 "같이 쓸 수 있다"!

상황heightForRowAt 호출 대상
estimated 있음보이는 셀만 (스크롤하면 추가 호출)
estimated 없음모든 셀 (1000개면 1000번!)
// estimated 있음 → 효율적 tableView.estimatedRowHeight = 50 // 처음: 안 보이는 셀은 50pt로 추정 → contentSize 빠르게 계산 // 스크롤: 보이게 되면 그때 heightForRowAt 호출 // estimated 없음 → contentSize 알려면 전부 물어봐야 함 // heightForRowAt(0), (1), ..., (999) → 1000번 호출!

Q. rowHeight = 80 설정하면 heightForRowAt 호출 안 됨?

맞다! heightForRowAt을 구현 안 했으면.

우선순위:
  1. heightForRowAt 구현됨? → YES → delegate 반환값 사용 (rowHeight 무시)
  2. NO → rowHeight 확인
    • automaticDimension → systemLayoutSizeFitting 사용
    • 숫자값 (80) → 그 값 사용

Q. heightForRowAt과 systemLayoutSizeFitting 관계

별개다! heightForRowAt 구현하면 systemLayoutSizeFitting 호출 안 됨.

heightForRowAtrowHeight높이 결정 방식
구현함무관delegate 반환값
없음automaticDimensionsystemLayoutSizeFitting
없음8080pt 고정

Q. UICollectionViewDelegateFlowLayout — FlowLayout이 "가지고 있다"?

아니다. "질문한다"가 맞다.

// FlowLayout 내부 (개념적) override func prepare() { // collectionView의 delegate가 DelegateFlowLayout 채택했는지 확인 if let flowDelegate = collectionView?.delegate as? UICollectionViewDelegateFlowLayout { let size = flowDelegate.collectionView?(collectionView!, layout: self, sizeForItemAt: indexPath) } else { // 없으면 self.itemSize 사용 } }
관계 정리:
  • UICollectionViewDelegateFlowLayout = 프로토콜 (역할 정의)
  • ViewController가 이 프로토콜을 채택
  • FlowLayout이 CollectionView의 delegate에게 질문 (캐스팅해서)

Q. layoutAttributesForElements — "적용"하는 거야?

"적용"이 아니라 "반환"한다. CollectionView가 반환받은 attributes로 셀 배치.

// Layout이 하는 일: 계산 + 반환 override func layoutAttributesForElements(in rect: CGRect) -> [...]? { return cache.filter { $0.frame.intersects(rect) } // 반환만! } // CollectionView가 하는 일: 반환받은 attributes로 셀 배치

Q. shouldInvalidateLayout — 뭐야? 언제 호출?

"레이아웃 다시 계산할까?" 물어보는 메서드. bounds 변경 시 호출.

// 기본 구현 func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return false // 기본값: 스크롤해도 레이아웃 유지 } // true 반환하면: // invalidateLayout() → prepare() 재호출 → layoutAttributesForElements 재호출
변경원인shouldInvalidateLayout 호출
bounds스크롤, 회전, 내부 리사이즈✅ 호출됨
frame부모가 크기 바꿈❌ 직접 호출 안 됨

Q. UICollectionViewLayout vs UICollectionViewFlowLayout

UICollectionViewLayoutUICollectionViewFlowLayout
용도서브클래싱용 베이스바로 사용 가능한 그리드
배치 로직없음 (직접 구현)그리드 자동 계산
프로퍼티없음itemSize, spacing 등

UICollectionViewLayout() 직접 사용? 컴파일되지만 모든 셀이 (0,0)에 겹침. 쓸모없음.

Q. bounds.origin 변경해도 layoutSubviews 안 불리면 누가 그려?

layoutSubviews와 "그리기"는 별개다!

구분:
  • layoutSubviews = 서브뷰 "배치" (frame 설정)
  • 그리기 = Core Animation이 GPU로 렌더링 (레이아웃과 무관)
// 일반 UIView view.bounds.origin = CGPoint(x: 0, y: 100) // → layoutSubviews 호출? 기본적으로 NO (서브뷰 재배치 필요 없으니까) // → 화면에 반영? YES! Core Animation이 알아서 렌더링 // UIScrollView는 특수! // bounds.origin 변경 시 setNeedsLayout() 호출하도록 오버라이드됨 // → 새로 보이는 셀 생성, 안 보이는 셀 재사용 처리

Q. cell.frame을 VC에서 바꿔도 TableView가 덮어씀?

맞다. TableView가 layoutSubviews에서 cell.frame을 재설정.

// cellForRowAt에서 cell.frame.origin.x = 50 // 이렇게 해도 // TableView.layoutSubviews()에서 cell.frame = CGRect(x: 0, y: 계산값, width: bounds.width, height: 높이) // → 개발자가 설정한 x=50 무시됨, x=0으로 재설정
비유:
  • TableView = 선생님 (책상 배치 담당)
  • Cell = 학생 책상
  • 학생: "제 책상 저기로 옮길게요" (cell.frame 변경) → 선생님: "안 돼, 내가 정한 자리로" (덮어씀)
  • 학생: "그럼 책상 위에서 제 물건 배치할게요" (contentView 내부) → 선생님: "그건 네 자유" (OK)

Q. Extension으로 프로토콜 채택하면 성능 유리?

아니다. 코드 정리용일 뿐, 컴파일 결과 동일.

// 방법 1: 클래스 선언부에서 class VC: UIViewController, UITableViewDelegate { } // 방법 2: extension에서 class VC: UIViewController { } extension VC: UITableViewDelegate { } // 컴파일 결과: 완전히 동일. 메모리/성능 차이 없음.

Q. draw(_:) (drawRect) 설명

레이아웃과 별개. "그리기" 담당.

더티 플래그호출 메서드역할
needsUpdateConstraintsupdateConstraints()제약조건
needsLayoutlayoutSubviews()위치/크기
needsDisplaydraw(_:)그리기 (Core Graphics)

draw(_:)는 커스텀 도형, 차트 등 직접 그릴 때만 오버라이드. 대부분은 안 씀.

Q. 스크롤하면 layoutSubviews 호출?

UIScrollView/TableView/CollectionView는 맞다.

// layoutSubviews 호출 조건 // 1. bounds.size 변경 // 2. bounds.origin 변경 (UIScrollView 특수!) // 3. 서브뷰 추가/제거 // 4. setNeedsLayout() 명시적 호출 // 5. 제약조건 변경 // UIScrollView는 스크롤(bounds.origin 변경) 시 layoutSubviews 호출하도록 설계됨 // → 새로 보이는 셀 dequeue, 안 보이는 셀 reuse pool로

Q. 서브뷰 frame 재배치(탑다운)는 동기적?

맞다. 전부 동기적(synchronous)으로 메인 스레드에서 실행.

layoutSubviews() 호출 (Parent) │ ├─ child1.frame = ... // 동기 ├─ child2.frame = ... // 동기 │ ▼ child1.layoutSubviews() 호출 // 동기 │ ▼ child2.layoutSubviews() 호출 // 동기 // 전부 끝나야 다음 코드 진행

Q. layoutSubviews 호출 시점에 frame은 이미 결정됨?

핵심:
  • 자신의 frame: ✅ 이미 결정됨 (부모가 설정해줌)
  • 서브뷰의 frame: ❌ 아직 이전 값 (내가 여기서 설정)
class ParentView: UIView { let child = UIView() override func layoutSubviews() { super.layoutSubviews() // 이 시점에서: print(self.frame) // ✅ 이미 확정됨 (부모가 설정) print(self.bounds) // ✅ 이미 확정됨 print(child.frame) // ⚠️ 아직 이전 값! // 내가 서브뷰 frame 설정 child.frame = CGRect(x: 10, y: 10, width: bounds.width - 20, height: 50) print(child.frame) // ✅ 이제 새 값 } }

흐름: GrandParent가 Parent.frame 설정 → Parent.layoutSubviews() 호출 (이때 self.frame 확정) → Parent가 Child.frame 설정 → Child.layoutSubviews() 호출...

Q. Layer와 Sublayer 관계?

UIView 계층 = CALayer 계층. 뷰마다 layer가 있고, 뷰 계층이 곧 레이어 계층.

// 모든 UIView는 layer를 가짐 let view = UIView() print(view.layer) // CALayer 객체 // addSubview하면 sublayer도 자동 추가 let parent = UIView() let child = UIView() parent.addSubview(child) // 내부적으로: parent.layer.addSublayer(child.layer) print(parent.layer.sublayers) // [child.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?

동일하다. 같은 것이다.

view.clipsToBounds = true // 내부적으로: // view.layer.masksToBounds = true // 확인 print(view.clipsToBounds) // true print(view.layer.masksToBounds) // true // UIView.clipsToBounds 내부 구현 (개념) var clipsToBounds: Bool { get { return layer.masksToBounds } set { layer.masksToBounds = newValue } }
프로퍼티레벨언제 씀
clipsToBoundsUIView보통 이거 씀
masksToBoundsCALayerCALayer 직접 다룰 때

모든 sublayer를 자른다 — subview의 layer든, 직접 추가한 CALayer든 상관없이 경계 밖은 전부 잘림.

9. Q&A 심화 3 (2026-06-27) — intrinsic / systemLayoutSizeFitting / boundingRect

intrinsic content size

뷰가 콘텐츠 기준으로 갖는 고유 크기("나 이만큼 필요해"를 Auto Layout에 알림). 라벨·버튼·이미지뷰·스위치처럼 콘텐츠가 크기를 정하는 뷰가 가진다. 일반 UIView는 없음(UIView.noIntrinsicMetric).

systemLayoutSizeFitting vs boundingRect

systemLayoutSizeFittingboundingRect
소속UIView 인스턴스 메서드NSString/NSAttributedString 메서드
대상뷰(서브뷰·제약 포함)순수 텍스트
기준Auto Layout 제약을 풀어 크기 계산텍스트 + 폰트로 크기 계산
결과그 뷰의 크기텍스트가 차지하는 크기
// systemLayoutSizeFitting — 뷰 제약 기반 (셀 self-sizing이 내부에서 사용) let size = cell.contentView.systemLayoutSizeFitting( CGSize(width: tableWidth, height: 0), withHorizontalFittingPriority: .required, // 폭 고정 verticalFittingPriority: .fittingSizeLevel) // 높이는 제약 만족 최소 // boundingRect — 순수 텍스트 측정 let h = (text as NSString).boundingRect( with: CGSize(width: 300, height: .greatestFiniteMagnitude), // 폭 고정, 높이 무한 options: [.usesLineFragmentOrigin], attributes: [.font: UIFont.systemFont(ofSize: 16)], context: nil).height
한 줄: 둘 다 "표시 전 크기 미리 재기"지만 — systemLayoutSizeFitting=뷰(제약 기반), boundingRect=텍스트(폰트 기반). intrinsic은 단일 뷰 고유 크기, systemLayoutSizeFitting은 intrinsic+제약을 종합해 푼 결과.

CollectionView는 셀 크기를 어떻게 계산? (TableView처럼 systemLayoutSizeFitting?)

CollectionView는 크기 결정을 레이아웃 객체가 한다. 세 모드:

방식어떻게self-sizing?
고정 크기flowLayout.itemSize = CGSize(...)
delegatesizeForItemAt — 개발자가 크기 계산해 반환
self-sizingestimatedItemSize = .automaticSize / Compositional .estimated

self-sizing 모드면 TableView처럼 systemLayoutSizeFitting 기반이다(단 명시적으로 켜야 함 — 기본 아님).

estimatedItemSize 설정 → 추정 크기로 일단 배치 셀이 나타날 때 → preferredLayoutAttributesFitting(_:) 호출 → 셀이 systemLayoutSizeFitting으로 제약 기반 실제 크기 계산 → layoutAttributes에 반영 → 재배치

10. CollectionView 레이아웃 동작 — 스크롤→배치 전체 흐름 Q&A (2026-06-28)

터치 다운 → hitTest로 "받을 뷰" 결정(1회) → 그 뷰+상위 체인의 gesture recognizer 경쟁 (pan vs tap) 스크롤이면: panGesture → contentOffset(=bounds.origin) 변경 → shouldInvalidateLayout(forBoundsChange:) 호출 (스크롤마다) false(그리드)=캐시 재사용 / true(효과)=invalidate → [layout 패스] prepare()[캐시 재계산] → layoutAttributesForElements(visible rect) → 셀 dequeue → (self-sizing) preferred로 실제 크기 협상 → cell.apply(attributes 적용)

핵심 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(직접), 데이터 변경
self-sizing 무한루프 안 나는 이유: 셀 실제 크기는 한 번 측정하면 안정 → 보정 후 preferred가 같은 값 반환 → 더 invalidate 안 함 → 수렴. (보이는 셀 5개여도 layoutAttributesForElements가 5번이 아니라, 보정을 모아 일괄 재계산)
prepare의 목적 = 무효가 된 캐시(attributes 배열)를 새로 계산. prepare → layoutAttributesForElements 재호출 → 셀 배치 → (self-sizing) preferred. 크기는 수렴, 스크롤 효과(Parallax)는 매 스크롤마다 재계산이 정상.
Parallax 효과 = 배경/전경이 다른 속도로 움직여 깊이감(stretchy header, 캐러셀 포커스 등). 레이아웃이 contentOffset 보고 셀 transform/alpha 계산 → shouldInvalidate true로 스크롤마다 재계산. CollectionView frame·bounds.size 고정, bounds.origin(contentOffset)만 변함(contentSize는 별개).

추가 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)
⚠️ 정정 — "스크롤 → invalidate"는 항상 아님: 스크롤은 shouldInvalidateLayout(forBoundsChange:)를 호출할 뿐, 그리드(FlowLayout)는 false라 invalidate 안 함(셀 위치 고정). invalidate는 ① shouldInvalidate true(효과) ② self-sizing 보정 ③ 데이터 변경 때.
그리드 스크롤효과 스크롤self-sizing 보정
shouldInvalidatefalsetruetrue
prepare 재호출
layoutAttributesForElements✅(캐시 필터)✅(재계산)✅(재계산)

→ 그리드 스크롤은 prepare(전체 재계산) 없이, layoutAttributesForElements가 캐시에서 새 visible rect만 필터해 새 셀 배치. invalidate ≠ layoutAttributesForElements 호출.

⭐ 정밀화 — 그리드라도 self-sizing이면 prepare 발생: "그리드 스크롤=prepare 없음"은 고정 크기(itemSize)일 때만. self-sizing(estimatedItemSize)이면 새로 보이는 셀의 preferred 보정으로 추정≠실제일 때 invalidate(forPreferredLayoutAttributes)→prepare 발생(단 그 셀 크기는 한 번 보정 후 수렴).
reloadData 흐름: shouldInvalidateLayout(forBoundsChange:)거치지 않고 직접 invalidate(데이터 변경은 boundsChange가 아님).
reloadData() → 데이터 다시 읽기 + 레이아웃 invalidate(직접) + setNeedsLayout → layoutSubviews → prepare()[전체 재계산] → layoutAttributesForElements → dequeue → cellForItem → cell.apply → (self-sizing) preferred → 필요시 invalidate→prepare

이벤트 전달 & 제스처 (터치 → 스크롤/탭)

터치 다운 → hitTest로 "받을 뷰" 결정(1회) → 터치가 두 경로로 동시 전달: (a) Gesture 경로: hit 뷰 → superview chain 따라 window까지 각 뷰의 제스처 인식기가 평가 (collectionView panGesture 등) (b) Responder 경로: hit 뷰의 touchesBegan/Moved/Ended/Cancelled → 드래그(touchesMoved) → panGesture 인식 → 스크롤 시작 → 동시에 hit 뷰의 touchesCancelled 호출(제스처가 터치 가로챔) → 가만히 떼면 → touchesEnded → tap(didSelectItemAt)
질문
제스처는 어느 뷰까지 받나?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)

질문 형태: "리스트 스크롤이 끊기는데 원인과 해결?" → 프레임 예산 초과 개념 + 주범 5가지 + 측정.

1) hitch = "프레임 예산 초과"

2) 왜 스크롤에서? — 스크롤은 메인 스레드에서 돈다

매 프레임(16.7ms 안에): 메인 스레드: 셀 구성 + 레이아웃 계산 + display(draw) + 커밋 // 여기 무거우면 예산 초과 → hitch 렌더 서버(별도): 합성 → 디스플레이

CATransaction의 Layout→Display를 메인이 매 프레임 처리. 프레임마다 메인이 하는 무거운 일이 곧 원인.

3) 주범 5가지

주범무엇
동기 이미지 디코딩메인에서 UIImage 디코딩(압축→비트맵)
오토레이아웃복잡한 제약 풀이를 매 프레임
오프스크린 렌더링cornerRadius+masksToBounds, shadow(shadowPath 없이)
블렌딩반투명 레이어 겹침(alpha<1) 합성 비용
무거운 셀 구성cellForRow에서 텍스트 사이징·파싱·정렬

4) 해결책 (원인별)

5) 진단 도구 (측정 먼저)

한 줄: hitch = 프레임 예산 초과. 스크롤은 메인 스레드라 "프레임마다 메인이 하는 무거운 일"이 원인 → 이미지 디코딩·오토레이아웃·오프스크린·블렌딩·셀 구성을 줄이고 Instruments(노랑/빨강)로 측정.

✅ 체크 질문

📌 §12 후속 — 오프스크린 vs CPU draw / 블렌딩

Q. 오프스크린 렌더링(GPU)은 CPU draw보다 나은가?

"둘 중 뭐가 낫냐"보다 둘 다 피하고 single-pass 합성으로 가는 게 목표. 세 가지 구분:

방식어디서비용
일반 합성(single-pass)GPU가장 쌈(텍스처 한 번에 합성)
오프스크린 렌더링GPU비쌈(별도 버퍼에 먼저 그린 뒤 다시 합성=패스 추가)
CPU draw(draw(_:)/CoreGraphics)CPU(메인)비쌈 + 메인 스레드 블록
한 줄: 오프스크린(GPU)은 CPU draw보다 메인을 안 막는 장점은 있으나 여전히 비싼 추가 패스. 목표는 둘 다 아닌 single-pass(shadowPath·미리 둥근 이미지).

Q. 블렌딩(blending)이란?

반투명 레이어를 화면에 합칠 때 아래 픽셀과 위 픽셀을 섞는 GPU 작업.

// 불투명(alpha=1, isOpaque=true): 아래 볼 필요 없이 덮어씀 → 쌈 // 반투명(alpha<1): 아래 픽셀 읽어서 섞음 → 비쌈 결과색 = 위색 × alpha + 아래색 × (1 - alpha) // 픽셀마다 계산
한 줄: 블렌딩=반투명 픽셀을 아래와 섞는 GPU 작업. 불투명이면 덮어써서 거의 공짜, 반투명 겹칠수록 비쌈 → isOpaque로 줄인다.

Q. 셀 높이 — 테이블뷰 vs 컬렉션뷰

UITableViewUICollectionView
차원1D(세로 리스트)2D(자유 레이아웃)
높이 주체테이블뷰가 행별 높이 관리레이아웃 객체(FlowLayout/Compositional/Custom)가 크기 결정
고정rowHeight(가장 빠름)itemSize / sizeForItemAt
동적automaticDimension + estimatedRowHeightestimatedItemSize / automaticSize
self-sizing 계산systemLayoutSizeFittingpreferredLayoutAttributesFitting

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만 개짜리 리스트를 부드럽게? 재사용·prefetch·이미지 로딩을 어떻게 엮나?" (프레임 예산·오프스크린·블렌딩은 §12 참조, 여기선 재사용 규칙·prefetch·in-flight 중복 제거만.)

1) 셀 1만 개 처리 & 재사용이 강제하는 규칙

// 유령 발생 예 — else 누락 if item.isNew { badge.isHidden = false } // else 없음 → 재사용된 셀에 이전 행의 배지가 그대로 남음(유령) // 고침 — 모든 분기 명시 badge.isHidden = !item.isNew // 항상 상태를 새로 씀 override func prepareForReuse() { super.prepareForReuse() imageView.image = nil // 이전 이미지 제거 loadTask?.cancel() // 진행 중 로딩 취소 }
정정: 재사용이 강제하는 건 "메인 작업 줄이기"가 아니라 "매번 완전한 상태 재설정"이다. 성능 최적화(백그라운드 디코딩 등)는 그 위에 얹는 별개 문제.

2) prefetching — cellForItemAt에서도 로딩해야 하는 이유

extension FeedVC: UICollectionViewDataSourcePrefetching { func collectionView(_ cv: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { indexPaths.forEach { ImagePrefetcher.shared.start(items[$0.item].imageURL) } // 미리 로딩 } func collectionView(_ cv: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { indexPaths.forEach { ImagePrefetcher.shared.cancel(items[$0.item].imageURL) } // 취소 } } // collectionView.prefetchDataSource = self

3) in-flight 관리 & dedup actor

in-flight = 지금 진행 중(다운로드·디코딩 중, 아직 미완료)인 요청. 관리 = 진행 중이면 새로 시작하지 말고 그 작업을 공유(중복 제거·dedup).

actor ImageLoader { enum Status { case inProgress(Task<UIImage, Error>) // Never가 아니라 Error(다운로드 실패 가능) case ready(UIImage) } private var cache: [String: Status] = [:] func load(_ url: String, size: CGSize) async throws -> UIImage { if let entry = cache[url] { switch entry { case .ready(let img): return img case .inProgress(let task): return try await task.value // 진행 중이면 공유 } } let task = Task { let data = try await download(url) return try await decodeAndDownsample(data, to: size) // 다운로드+디코딩+다운샘플 한 Task에 } cache[url] = .inProgress(task) // ★ await 전에 박아둠 → reentrancy에도 dedup 성립 do { let img = try await task.value cache[url] = .ready(img) return img } catch { cache[url] = nil // ★ 실패 시 캐시 제거(안 하면 실패가 영구 캐시됨) throw error } } }
⚠️ 흔한 실수: "inProgress 먼저 박으면 중복 방지" — 그 보호는 task 안에 든 작업까지만 유효하다. 디코딩을 별도 Task로 빼서 캐시 밖에 두면, 공유 호출이 미디코딩 결과를 받거나 디코딩이 중복된다 → 반드시 다운로드+디코딩을 한 task에 넣어라.
실무: Kingfisher가 in-flight dedup·캐시·백그라운드 디코딩·다운샘플을 전부 제공. 직접 짜는 이유는 내부 이해 + 세밀한 제어가 필요할 때.
한 줄: prefetch(힌트) + 셀 완전 재설정 + in-flight dedup(다운로드+디코딩 한 task) + 백그라운드 디코딩·다운샘플 + 취소 를 한 파이프라인으로. (프레임 예산·오프스크린·블렌딩은 §12 참조.)

14. Diffable Data Source 심화 · 간격 구분 (2026-07-20)

전통 DataSource의 문제

numberOfItems·cellForItemAt 구현 + 변경 시 배열과 뷰 행 개수를 손으로 동기화해야 함.

items.remove(at: 3) tableView.deleteRows(at: [IndexPath(row: 3, section: 0)], with: .automatic) // 배열과 개수 안 맞으면 💥 "invalid number of rows"

Diffable(iOS 13+) — "지금 상태"를 스냅샷으로 선언

enum Section { case main } struct Post: Hashable { let id: Int; let title: String } // Hashable 필수 let dataSource = UICollectionViewDiffableDataSource<Section, Post>(collectionView: cv) { cv, indexPath, post in let cell = cv.dequeueReusableCell(...) as! PostCell cell.configure(post); return cell } var snapshot = NSDiffableDataSourceSnapshot<Section, Post>() snapshot.appendSections([.main]); snapshot.appendItems(posts) dataSource.apply(snapshot, animatingDifferences: true) // diff는 프레임워크가

⭐ "apply는 수동, diff는 자동" (자동 감지 아님)

posts.append(newPost); posts.removeAll { $0.id == 5 } applyInitialSnapshot() // 최종 상태만 선언 → "5 삭제, 끝 추가"를 프레임워크가 diff·애니메이션

applyInitialSnapshot()내장 API 아님 — 스냅샷 만들어 apply하는 코드를 뺀 직접 만든 헬퍼(이름 자유). 첫 로드는 animatingDifferences: false가 자연스러움.

Hashable 필수 조건 + 함정

Hashable = ① 해시값(Int) 생성 + ② 동등 비교(==, Equatable 상속). struct/enum은 저장 프로퍼티 전부 Hashable이면 자동 합성. 정체성을 id만으로 하려면 hash(into:)+== 를 id 기준 커스텀.

무엇을 대체하나 / dataSource는 어디서 생성

전통Diffable
numberOfSections/numberOfItems스냅샷의 섹션/아이템 개수 자동
cellForItemAtdataSource 생성 시 셀 제공자 클로저
reloadDataapply(snapshot)
performBatchUpdates+insert/delete/move(수동)apply(snapshot) diff 자동

dataSource는 viewDidLoad에서 한 번 생성해 프로퍼티로 보관(cellForItemAt에서 X). 생성자 클로저 = 셀 제공자 = cellForItemAt 대체(프레임워크가 필요 시 알아서 호출).

FlowLayout — minimumLineSpacing vs minimumInteritemSpacing

15. reconfigure / CellRegistration / orthogonal 정리 (2026-07-20)

reloadItems vs reconfigureItems (iOS 15+)

reloadItems / reloadDatareconfigureItems (iOS 15+)
동작셀을 재사용 큐로 보내고 새로 dequeue해 재구성기존 셀 그대로 두고 제공자만 재실행
비용깜빡임·비쌈싸고 부드러움
용도셀 교체(타입·구조 변경)같은 아이템 내용만 변경(좋아요 수 등)

테이블뷰도 UITableViewDiffableDataSource 스냅샷에 reconfigureItems 있음(iOS 15+). "정체성 유지 + 내용만 갱신"이 §14 Hashable 함정 대응과 연결.

CellRegistration (iOS 14+)

셀 등록+구성을 한 곳에서 — 재사용 식별자 문자열 제거, 타입 안전, Diffable 궁합.

let reg = UICollectionView.CellRegistration<PostCell, Post> { cell, indexPath, post in cell.configure(post) } let ds = UICollectionViewDiffableDataSource<Section, Post>(collectionView: cv) { cv, ip, post in cv.dequeueConfiguredReusableCell(using: reg, for: ip, item: post) }

orthogonalScrollingBehavior — 중첩 컬렉션뷰 회피

실전 설계 요약 (피드형: 세로 무한 + 상단 가로 스토리 + 가변 카드)

설계
뷰 구조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)

핵심: 레이아웃이 먼저 prepare로 estimated 크기 attributes를 만들고 → 보이는 셀만 cellForItem → 셀이 실제 크기 계산 → 다르면 invalidate. "cellForItem이 먼저"가 아니라 Layout Attributes가 먼저.

암기용 순서

numberOfItems ↓ Layout.prepare() // estimatedItemSize로 예상 attributes ↓ layoutAttributesForElements(in:) // 보이는 rect의 attributes (내부에서 forItem 호출) ↓ CollectionView가 화면에 필요한 셀 판단 ↓ cellForItemAt // ★ 보이는 셀만(1만 개여도 10~15개). 여기서 configure(model) ↓ preferredLayoutAttributesFitting(_:) // ★ 호출 주체 = CollectionView(Layout 아님) ↓ contentView.systemLayoutSizeFitting(...) // 셀이 Auto Layout으로 실제 크기 계산 ↓ Original attributes vs Preferred 비교 ↓ shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) ↓ true invalidationContext(forPreferredLayoutAttributes:withOriginalAttributes:) // 바뀐 셀 이후만 무효화 ↓ invalidateLayout(with:) → prepare() → layoutAttributesForElements() 재수행 // 새 위치

단계별 포인트

  1. 처음엔 실제 크기를 모른다: FlowLayout은 셀 안 텍스트를 아직 모름 → estimatedItemSize로 "아마 이 정도" 예상 attributes(예: 각 height 100).
  2. Layout Attributes가 먼저 → 그다음 cellForItem(반대 아님). 레이아웃이 "어디에 뭐가 있는지"를 알아야 그 위치 셀만 dequeue.
  3. cellForItemAt에서 실제 데이터 설정(titleLabel.text 등) → 제약이 내용에 맞게 결정 → 이제야 셀이 "몇 pt 필요한지" 앎.
  4. CollectionView가 preferredLayoutAttributesFitting 호출Layout이 아니라 CollectionView 시스템이 호출 주체.
  5. 셀이 contentView.systemLayoutSizeFitting으로 실제 높이 계산(예: 예상 100 → 실제 185).
  6. Original vs Preferred attributes 비교.
  7. shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) → true면 무효화. 짝으로 invalidationContext(forPreferredLayoutAttributes:...)바뀐 셀 이후만 다시 계산하도록 context 생성(전체 재계산 아님).
  8. invalidate 후 prepare → layoutAttributesForElements 재수행(필요 영역만).

누가 무엇을 호출하나

주체호출/역할
Layoutprepare()·layoutAttributesForElements()·layoutAttributesForItem() — 예상 위치·크기 attributes 생성
CollectionViewcellForItemAt(셀 생성·데이터)·preferredLayoutAttributesFitting(실제 크기 측정 요청)
CellAuto Layout(systemLayoutSizeFitting)으로 실제 크기 계산·preferred attributes 반환

Elements vs Item

TableView 대응: estimatedRowHeight로 예상 → 테이블뷰가 셀 contentView에 systemLayoutSizeFitting직접 호출(preferredLayoutAttributesFitting 훅 없음) → automaticDimension. 나머지 흐름(예상→실제→보정)은 동일.

관련 문서