UIKit 렌더링 · 런루프 · 생명주기
래스터화 vs 디코딩과 layer.contents, CPU/GPU 분업, 업데이트 사이클 → 렌더 파이프라인, 메인 런루프, 터치 이벤트 & responder chain, Scene/Window/Process 생명주기를 문답으로 정리한 학습 문서. 헷갈리기 쉬운 구분(래스터화≠디코딩, hit-test view≠first responder, CG≠CA 포함관계)을 앞쪽에 명시한다.
§1. 래스터화 vs 디코딩, layer.contents
Q. 래스터화란 무엇인가?
벡터/기하로 "설명"된 콘텐츠 — 베지어 패스, 텍스트 글리프, 사각형·원 같은 도형 — 를 실제 화면이 표시할 수 있는 픽셀 격자(비트맵)로 변환하는 과정이다. "이 좌표에서 저 좌표로 곡선을 그려라"라는 명령을, "이 픽셀은 파랑, 저 픽셀은 반투명 파랑"이라는 구체적 픽셀 값으로 바꾸는 것.
디코딩은 다르다. JPEG/PNG는 이미 그림이지만 압축돼 있다. 디코딩은 그 압축을 풀어서(압축 해제) 비트맵으로 만드는 것. 벡터를 그리는 게 아니라 이미 있는 픽셀을 복원하는 것이다.
| 작업 | 입력 | 출력 | 예 |
|---|---|---|---|
| 래스터화 | 벡터/기하 설명 (패스·글리프·도형) | 비트맵 | 텍스트 그리기, CGContext 드로잉, 그라디언트 |
| 디코딩 | 압축된 이미지 데이터 (JPEG/PNG) | 비트맵 | UIImage(named:) 표시 준비 |
Q. draw(_:) / layer.contents / 백킹 스토어는 어떤 관계인가?
draw(_:)를 오버라이드하면 UIKit이 그 뷰의 레이어에 백킹 스토어(backing store) — 즉 비트맵 버퍼 — 를 만든다. 그 다음 Core Graphics(CPU) 가 draw(_:) 안에서 호출한 그리기 명령을 래스터화해서 그 버퍼를 픽셀로 채운다. 이렇게 채워진 비트맵이 바로 layer.contents다.
draw(_:) = "레이어의 contents(백킹 스토어 비트맵)를 CPU가 래스터화해서 채우는 작업".layer.contents가 채워지는 3갈래
| # | 경로 | 누가 | 예 |
|---|---|---|---|
| ① | CPU 래스터화 — draw(_:)로 커스텀 그리기 | CPU (Core Graphics) | 커스텀 UIView 서브클래스, UILabel/UITextView 내부 |
| ② | 디코딩된 비트맵 직접 세팅 | CPU 디코딩 | UIImageView — 디코딩한 이미지 비트맵을 contents로 |
| ③ | 아예 없음 — 순수 색/코너는 GPU가 합성 때 직접 채움 | GPU (렌더 서버) | backgroundColor + cornerRadius만 있는 뷰 → 비트맵 contents 없음 |
③이 중요하다. backgroundColor와 cornerRadius만 준 뷰는 백킹 스토어(비트맵 contents)가 아예 없다. 렌더 서버가 합성하는 순간 "이 사각형 영역을 이 색으로, 모서리는 둥글게" 라고 GPU가 직접 그려낸다. CPU 래스터화도, 비트맵 저장도 없다.
Q. UILabel은 어떻게 그려지나?
UILabel은 텍스트를 글리프(glyph) → 비트맵으로 래스터화(CPU) 한다. 폰트의 글자 모양(벡터 윤곽)을 실제 픽셀로 찍는 것. label.text를 바꾸면 내부적으로 setNeedsDisplay가 호출되고, 다음 디스플레이 패스에서 텍스트를 재래스터화해 새 contents를 만든다.
Q. CG 작업 = 항상 layer.contents 수정? UIView 작업은 다 래스터화?
- CG = "어떤 비트맵에 CPU 래스터화". draw(_:) 안일 때만 그 비트맵이 layer.contents. draw 밖(UIGraphicsImageRenderer)이면 별도 비트맵(UIImage)이라 직접 assign해야 함.
- UIView 작업은 다 래스터화하지 않는다 — 두 종류로 갈린다:
| 종류 | 예 | 래스터화(contents)? | 처리 |
|---|---|---|---|
| 기하·스타일 | frame·bounds·center·transform·backgroundColor·cornerRadius·alpha·opacity·isHidden | ❌ 안 함 | GPU가 기존 텍스처 재배치·합성만 |
| 콘텐츠 생성 | draw(_:) override·UILabel.text(글리프)·UIImageView.image(디코딩) | ✅ 함(CPU) | 백킹스토어 비트맵 채움/교체=contents |
"어디·어떻게 배치(기하·스타일)"=GPU, contents 안 건드림 / "무엇을 그리나(콘텐츠)"=CPU 래스터화, contents 수정. backgroundColor 변경은 래스터화 아님, draw로 원 그리기는 래스터화.
Q. setNeedsDisplay ↔ draw — 오버라이드하면 자동 호출?
draw(_:)는 디스플레이 패스에서 그 레이어 needsDisplay 플래그가 켜져 있을 때 UIKit이 호출. setNeedsDisplay가 그 플래그를 켠다. 오버라이드 자체가 setNeedsDisplay를 부르진 않는다.
draw(_:) 오버라이드
→ 첫 표시: UIKit이 needsDisplay ON → draw 1회 자동 (백킹스토어 생성)
→ 이후: setNeedsDisplay 호출해야 → 다음 디스플레이 패스에 draw 재호출
→ contentMode=.redraw면 bounds 변경 시 자동 setNeedsDisplay
§2. CPU vs GPU 분업
Q. CPU가 하는 그리기 vs GPU가 하는 그리기?
| CPU (Core Graphics / Quartz 2D) | GPU (렌더 서버) | |
|---|---|---|
| 하는 일 | 콘텐츠를 비트맵으로 만드는 일 — 텍스트·패스·그라디언트 래스터화 + 이미지 디코딩 | 만들어진 비트맵/레이어들을 합성 — 블렌딩·변환(transform)·마스킹·코너 |
| 비유 | "텍스처(픽셀 덩어리) 생성" | "텍스처 조립 → 프레임버퍼" |
Q. 레이어로 하는 GPU 합성이 CPU 래스터화/디코딩을 대체할 수 있나?
경우에 따라 다르다. 핵심은 "GPU가 스스로 렌더할 수 있는 것"이냐다.
| 콘텐츠 | 대체 가능? | 이유 |
|---|---|---|
| 도형/색/그라디언트 ( CAShapeLayer.path, backgroundColor, cornerRadius) | ✅ 가능 | 선언적 속성이라 렌더 서버가 합성 시 스스로 렌더 → 메인 CPU의 draw 래스터화를 대체 |
| 이미지 디코딩 | ❌ 불가 | GPU가 샘플링하려면 디코딩된 비트맵이 필요. 디코딩 자체는 CPU 몫 (백그라운드 스레드 디코딩만 가능) |
| 텍스트 | ❌ 불가 | 글리프 래스터화도 CPU 몫 |
그래서 가능하면 draw(_:)의 CPU 래스터화보다 선언적 레이어(CAShapeLayer, 레이어 속성)를 쓰는 게 유리하다: 메인 스레드 CPU 래스터화를 회피하고 오프메인(GPU)으로 넘길 수 있기 때문.
Q. Core Graphics는 Core Animation 안에 들어있나?
아니다. 별개(형제) 프레임워크다. 포함 관계가 아니라 협력 관계.
| 프레임워크 | 역할 |
|---|---|
| Core Graphics (Quartz 2D) | 2D 래스터화 = CPU 그리기. 패스·텍스트·그라디언트를 비트맵으로. |
| Core Animation (QuartzCore) | 레이어 트리 관리 + 애니메이션. 앱 쪽 CA는 커밋(레이어 트리를 렌더 서버로 IPC 전송)까지. 실제 합성은 렌더 서버가 GPU로. |
흐름: draw(_:)가 CG로 백킹 스토어를 래스터화 → 앱 쪽 CA가 레이어 트리를 커밋(렌더 서버로 전달) → 렌더 서버(별도 프로세스)가 GPU로 실제 합성.
Q. 글리프(glyph)란?
폰트가 정의한 "문자의 그림(모양)". 문자 "A"·"가"를 어떻게 그릴지 벡터 외곽선(패스)으로 정의한 게 글리프. 텍스트 렌더링=문자→(폰트로) 글리프 매핑→글리프 외곽선을 픽셀로 래스터화(CPU). 한 문자가 여러 글리프(리거처 fi→fi)가 되기도. 문자=의미 단위, 글리프=그리는 단위.
Q. GPU 대체 가능한 걸 CPU로 래스터화도 되나? draw 바깥에서도?
보통은 GPU에 맡기는 게 유리(메인 CPU 회피). 하지만 CPU 래스터화도 되고 draw 바깥에서도 된다.
// (a) draw(_:) 안 CG로 직접 그리기
// (b) draw 바깥에서 비트맵(UIImage)으로 굽기 — 백그라운드 가능
let img = UIGraphicsImageRenderer(size: size).image { ctx in
UIColor.systemBlue.setFill(); ctx.fill(CGRect(origin: .zero, size: size))
} // → imageView.image = img 또는 layer.contents = img.cgImage
| 선언 레이어(CAShapeLayer·CAGradientLayer) | CPU 비트맵 굽기 | |
|---|---|---|
| 처리 | GPU가 합성 시 렌더 | CG(CPU)가 픽셀로 |
| 특성 | 벡터·해상도 독립·메모리 적음 | 고정 해상도·메모리 사용·오프메인 가능 |
| 좋을 때 | 단순 도형·색·그라디언트 | 복잡·정적(미리 구워 재사용)·여러 레이어 평탄화 |
즉 단순 도형/색/그라디언트는 GPU 레이어가 유리, 복잡·정적이라 미리 굽거나 평탄화(오버드로·블렌딩 감소)가 이득이면 CPU 래스터화(shouldRasterize도 같은 아이디어).
Q. draw에서 쓸 수 있는 작업이 fillEllipse·strokePath·NSString.draw뿐? draw 밖에서도 되나?
보여준 건 일부. CG 컨텍스트로 2D로 그릴 수 있는 건 다 된다:
| 종류 | 대표 API |
|---|---|
| 패스/도형 | fill·stroke·fillEllipse·fillPath·strokePath, move(to:)·addLine·addCurve·addArc·addRoundedRect(UIBezierPath) |
| 채움/선 속성 | setFillColor·setStrokeColor·setLineWidth·setLineCap·setLineDash |
| 그라디언트 | CGGradient + drawLinearGradient·drawRadialGradient |
| 이미지 | image.draw(in:)·ctx.draw(cgImage, in:) |
| 텍스트 | NSString/NSAttributedString.draw, 고급=Core Text(CTLineDraw) |
| 그림자/블렌드/클리핑/변환/상태 | setShadow·setBlendMode·clip·translateBy·scaleBy·rotate·saveGState/restoreGState |
draw 밖에서도 된다 — 단 "컨텍스트 출처"가 다르다. 그리는 API는 안/밖 동일.
| draw(_:) 안 | draw 밖 | |
|---|---|---|
| 컨텍스트 | UIKit이 준 뷰 백킹스토어(UIGraphicsGetCurrentContext) | 내가 만든 별도 비트맵(UIGraphicsImageRenderer) |
| 결과 | 자동으로 layer.contents | UIImage/CGImage 산출 → 직접 assign |
| 호출/스레드 | setNeedsDisplay→UIKit이 디스플레이 패스에(메인) | 내가 원하는 시점(백그라운드 가능) |
UIGraphicsGetCurrentContext()는 유효 컨텍스트 없으면 nil → draw 밖에서 그냥 부르면 nil. 먼저 UIGraphicsImageRenderer 블록(또는 구식 UIGraphicsBeginImageContextWithOptions) 안이라야 CG 작업이 유효. 밖에서 하면 백그라운드에서 미리 구워 메인 부담 제거 가능.§3. 업데이트 사이클 & 렌더 파이프라인
Q. 렌더 사이클 순서와 "그 사이클에 모인 변경"이란?
① 제약 패스 : setNeedsUpdateConstraints → updateConstraints (bottom-up, 자식→부모)
② 레이아웃 패스 : setNeedsLayout → 프레임 결정 → layoutSubviews (top-down, 부모→자식)
③ 디스플레이 패스 : setNeedsDisplay → draw(_:) (CPU 래스터화 → contents)
④ 커밋 : CATransaction commit → 렌더 서버로 레이어 트리 전달 → GPU 합성
①②는 레이아웃(무엇이 어디에, 얼마나 큰지)을 정하고, ③은 그 뷰의 픽셀 콘텐츠를 만들며, ④에서 모든 변경을 한 번에 렌더 서버로 커밋한다.
contents(비트맵)만이 아니다. 레이어 트리 전반의 모든 속성 변경 — 위치/크기(frame)·opacity·transform·색·contents·계층 구조(add/remove) — 이 한 사이클 동안 쌓였다가 CATransaction으로 한 번에 커밋된다. 그래서 프레임 중간의 개별 변경이 화면에 따로따로 반영되지 않고 일괄 적용된다.Q. 여기서 "프레임(그 사이클)"은 view.frame인가?
아니다. 여기서 말하는 "프레임"은 디스플레이 프레임 — 화면 갱신 한 번(~16.67ms @ 60Hz) 을 뜻한다. view.frame(뷰의 사각형)이나 CALayer와는 전혀 다른 개념이다. 한 프레임 = 한 번의 화면 갱신 주기.
Q. 언제 끊김(프레임 드랍)이 생기나?
- GPU 합성이 vsync보다 느릴 때 — 합성할 게 너무 무거워(오버드로우·오프스크린 등) 16.67ms 안에 못 끝냄.
- 업데이트 사이클(메인)이 느릴 때 — 메인 스레드에서 무거운
draw래스터화/레이아웃/블로킹 작업이 시간을 잡아먹어 커밋이 늦어짐.
둘 중 하나라도 마감(vsync)을 놓치면 그 프레임을 건너뛰어(드랍) 이전 프레임이 한 번 더 표시된다 → 버벅임으로 느껴진다.
§4. 런루프
Q. 메인 런루프는 하나뿐인가? "직접 돌린다"는 건 무슨 뜻인가?
런루프는 스레드당 하나다. 그리고 메인 스레드의 런루프만 UIApplicationMain이 앱 시작 시 자동으로 돌려준다. 백그라운드 스레드에서 런루프를 쓰려면(예: Timer로 스레드를 살려두고 이벤트를 계속 처리) 직접 RunLoop.current.run()을 호출해야 한다. 안 하면 그 스레드의 런루프는 돌지 않아 이벤트가 처리되지 않는다.
메인 런루프가 한 사이클(iteration)에서 하는 일
- 입력 이벤트 처리 — 터치·제스처 등을 앱에 전달
- 업데이트 사이클 실행 — §3의 제약/레이아웃/디스플레이 패스
- 레이어 트리를 렌더 서버로 커밋 —
CATransactioncommit
Q. Timer와 perform 계열은?
둘 다 런루프 기반이다. 런루프에 등록되어 런루프가 돌 때 처리된다.
Timer
Timer는 런루프에 등록돼 지정 interval 후 fire한다. 단 런루프가 바쁘면(블로킹) 그만큼 지연된다(정밀 타이머가 아님).
.tracking 모드로 바뀐다 → .default 모드로 등록된 타이머는 스크롤 동안 멈춘다. 해결: .common 모드로 등록.
RunLoop.current.add(timer, forMode: .common) // 스크롤 중에도 계속 fireperform 계열
| API | 동작 |
|---|---|
perform(_:with:afterDelay:) | 런루프에 타이머를 추가해 지정 시간 뒤 셀렉터 실행 (지연) |
perform(_:on:with:waitUntilDone:) | 특정 스레드의 런루프에서 셀렉터 실행 |
perform(_:with:) | 즉시 셀렉터 실행 (런루프와 무관하게 바로) |
지연 perform은 cancelPreviousPerformRequests(withTarget:)로 취소할 수 있다.
DispatchQueue.asyncAfter·Task.sleep 같은 GCD/Swift Concurrency로 대체한다.§5. 터치 이벤트 & responder chain
Q. 터치가 뷰에 전달되는 과정은?
UIApplication → UIWindow → hitTest(_:with:)를 재귀 호출
→ 포인트를 포함하는 "가장 깊은(앞쪽) 뷰" = hit-test view
UIWindow가 hitTest를 최상위부터 재귀적으로 내려가며, 터치 포인트를 포함하는 서브뷰 계층에서 가장 깊고(=가장 앞쪽에 그려진) 뷰를 찾는다. 이게 hit-test view다.
hitTest가 뷰를 후보로 인정하는 조건
| 조건 | 의미 |
|---|---|
isUserInteractionEnabled == true | 상호작용 꺼져 있으면 통과 못 함 |
isHidden == false | 숨겨진 뷰는 제외 |
alpha ≥ 0.01 | 거의 투명하면 제외 |
point(inside:with:) == true | 포인트가 뷰 경계 안에 있어야 함 |
becomeFirstResponder()로 지정되는 키보드/텍스트 포커스 대상으로, 터치 hit-test와 별개의 메커니즘이다.Q. hit 뷰가 터치를 처리 못 하면?
responder chain을 따라 위로 전파된다:
hit 뷰 → superview → (그 뷰의) 뷰컨트롤러 → window → application → appDelegate
각 responder에서 처리 여부는 touchesBegan(_:with:) 등의 구현이나 UIControl의 target-action이 결정한다. 처리하지 않으면(구현이 super로 넘기면) 체인의 다음 responder로 넘어간다.
§6. Scene / Window / Process 생명주기
Q. UIScene 구조와 계층은?
UIScene(iOS 13+)은 멀티윈도우를 지원하기 위해 도입됐다. iPad/Mac Catalyst에서 같은 앱을 여러 개 나란히 띄울 수 있다. 앱 두 개를 나란히 = 씬 두 개(각각 UIWindowScene). 한 씬에는 여러 윈도우가 있을 수 있고, 한 프로세스에 여러 씬이 같은 주소 공간에서 공존한다.
계층 구조
프로세스 (1)
└─ 앱 / AppDelegate (1) ← 프로세스 수준 생명주기
└─ 씬 / SceneDelegate (N) ← UI 씬(윈도우) 수준 생명주기
└─ 윈도우 (씬당 1+)
└─ 뷰 계층
| 단위 | 개수 | 담당 |
|---|---|---|
| 프로세스 | 1 | 앱 실행 인스턴스 (주소 공간) |
| AppDelegate | 1 | 앱(프로세스) 수준 생명주기 · 앱 시작/종료, 푸시 등록 등 |
| SceneDelegate | N (씬마다) | UI 씬(윈도우) 수준 생명주기 |
| 윈도우 | 씬당 1+ | UIWindow · 루트 뷰 계층 호스팅 |
Q. AppDelegate와 SceneDelegate의 역할 차이는?
- AppDelegate — 프로세스/앱 수준. 앱 실행(
didFinishLaunching), 씬 세션 구성 등. - SceneDelegate — 개별 UI 씬(윈도우) 수준.
scene(_:willConnectTo:),sceneDidBecomeActive,sceneDidEnterBackground등.
AppDelegate.window = ...로 하던 루트 뷰컨트롤러 설정을, 이제 SceneDelegate.scene(_:willConnectTo:)에서 UIWindow(windowScene:)로 만든다. 씬마다 윈도우가 따로 있어야 멀티윈도우가 되기 때문.func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene) // 씬에 묶인 윈도우
window.rootViewController = RootViewController()
window.makeKeyAndVisible()
self.window = window
}