iOS UIKit Internals Study

UIKit 렌더링 · 런루프 · 생명주기

래스터화 vs 디코딩layer.contents, CPU/GPU 분업, 업데이트 사이클 → 렌더 파이프라인, 메인 런루프, 터치 이벤트 & responder chain, Scene/Window/Process 생명주기를 문답으로 정리한 학습 문서. 헷갈리기 쉬운 구분(래스터화≠디코딩, hit-test view≠first responder, CG≠CA 포함관계)을 앞쪽에 명시한다.

§1. 래스터화 vs 디코딩, layer.contents

핵심 구분 먼저: 래스터화(rasterize) = 벡터/기하 → 픽셀 (패스·글리프·도형 같은 "설명"을 픽셀 격자로 변환). 디코딩(decode) = 압축 → 픽셀 (JPEG/PNG 압축 데이터를 풀어 비트맵으로). 서로 다른 작업이지만 결과물은 둘 다 비트맵이다.

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 없음

③이 중요하다. backgroundColorcornerRadius만 준 뷰는 백킹 스토어(비트맵 contents)가 아예 없다. 렌더 서버가 합성하는 순간 "이 사각형 영역을 이 색으로, 모서리는 둥글게" 라고 GPU가 직접 그려낸다. CPU 래스터화도, 비트맵 저장도 없다.

Q. UILabel은 어떻게 그려지나?

UILabel은 텍스트를 글리프(glyph) → 비트맵으로 래스터화(CPU) 한다. 폰트의 글자 모양(벡터 윤곽)을 실제 픽셀로 찍는 것. label.text를 바꾸면 내부적으로 setNeedsDisplay가 호출되고, 다음 디스플레이 패스에서 텍스트를 재래스터화해 새 contents를 만든다.

정리: 화면에 뭔가 보이려면 결국 비트맵이 필요하다. 그 비트맵을 만드는 두 방식이 래스터화(벡터→픽셀)디코딩(압축→픽셀). 단, 순수 색·코너처럼 비트맵 없이 GPU가 합성 때 직접 그리는 경우도 있다.

Q. CG 작업 = 항상 layer.contents 수정? 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
⚠️ draw(_:)를 오버라이드하면(비어있어도) UIKit이 백킹스토어(비트맵 버퍼)를 할당("내가 CPU로 그리겠다" 신호) → 불필요한 빈 draw 오버라이드는 메모리 낭비.

§2. CPU vs GPU 분업

Q. CPU가 하는 그리기 vs GPU가 하는 그리기?

CPU (Core Graphics / Quartz 2D)GPU (렌더 서버)
하는 일콘텐츠를 비트맵으로 만드는 일 — 텍스트·패스·그라디언트 래스터화 + 이미지 디코딩만들어진 비트맵/레이어들을 합성 — 블렌딩·변환(transform)·마스킹·코너
비유"텍스처(픽셀 덩어리) 생성""텍스처 조립 → 프레임버퍼"
한 줄: CPU = 텍스처 만들기(래스터화·디코딩), GPU = 텍스처 조립·합성. 최종적으로 GPU가 합성한 결과가 프레임버퍼에 담겨 화면에 나간다.

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로 실제 합성.

정밀 구분(앱 CA는 합성 안 함): Core Animation은 양쪽에 존재 — 앱 프로세스(트리 관리·커밋만) / 렌더 서버(server-side CA가 GPU 구동해 합성). "앱이 부르는 CA가 픽셀을 합성"은 아니고, 커밋해 렌더 서버로 넘기면 거기서 GPU가 합성.
정리: "텍스처를 만드는 건 CPU(CG), 조립·합성은 GPU(렌더 서버)". 앱 쪽 CA = 커밋(전달). 도형·색은 GPU가 대체 렌더 가능하지만, 디코딩·텍스트는 끝까지 CPU가 필요하다.

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.contentsUIImage/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. 언제 끊김(프레임 드랍)이 생기나?

둘 중 하나라도 마감(vsync)을 놓치면 그 프레임을 건너뛰어(드랍) 이전 프레임이 한 번 더 표시된다 → 버벅임으로 느껴진다.

연결: §2에서 "선언적 레이어가 유리"한 이유가 여기서 드러난다 — 메인 스레드 CPU 래스터화(③ 디스플레이 패스)를 줄이면 업데이트 사이클이 빨라져 드랍이 줄어든다.

§4. 런루프

Q. 메인 런루프는 하나뿐인가? "직접 돌린다"는 건 무슨 뜻인가?

런루프는 스레드당 하나다. 그리고 메인 스레드의 런루프만 UIApplicationMain이 앱 시작 시 자동으로 돌려준다. 백그라운드 스레드에서 런루프를 쓰려면(예: Timer로 스레드를 살려두고 이벤트를 계속 처리) 직접 RunLoop.current.run()을 호출해야 한다. 안 하면 그 스레드의 런루프는 돌지 않아 이벤트가 처리되지 않는다.

메인 런루프가 한 사이클(iteration)에서 하는 일

주의: 메인 런루프가 하는 건 "커밋까지". 실제 합성은 렌더 서버(별도 프로세스)/GPU가 한다. 런루프가 GPU 합성을 직접 하지 않는다.

Q. Timer와 perform 계열은?

둘 다 런루프 기반이다. 런루프에 등록되어 런루프가 돌 때 처리된다.

Timer

Timer는 런루프에 등록돼 지정 interval 후 fire한다. 단 런루프가 바쁘면(블로킹) 그만큼 지연된다(정밀 타이머가 아님).

모드 함정: 스크롤 중에는 런루프가 .tracking 모드로 바뀐다 → .default 모드로 등록된 타이머는 스크롤 동안 멈춘다. 해결: .common 모드로 등록.
RunLoop.current.add(timer, forMode: .common)   // 스크롤 중에도 계속 fire

perform 계열

API동작
perform(_:with:afterDelay:)런루프에 타이머를 추가해 지정 시간 뒤 셀렉터 실행 (지연)
perform(_:on:with:waitUntilDone:)특정 스레드의 런루프에서 셀렉터 실행
perform(_:with:)즉시 셀렉터 실행 (런루프와 무관하게 바로)

지연 perform은 cancelPreviousPerformRequests(withTarget:)로 취소할 수 있다.

요즘 대안: 이들은 레거시. 지연/비동기는 DispatchQueue.asyncAfter·Task.sleep 같은 GCD/Swift Concurrency로 대체한다.

§5. 터치 이벤트 & responder chain

핵심 구분 먼저: hitTest = "누가 터치를 받을지" 찾기(hit-test view). responder chain = "받은 뷰가 처리 못 하면 누구에게 넘길지". 그리고 hit-test view ≠ first responder — first responder는 키보드/텍스트 입력 포커스를 가진 별개 개념이다.

Q. 터치가 뷰에 전달되는 과정은?

UIApplication → UIWindow → hitTest(_:with:)를 재귀 호출
   → 포인트를 포함하는 "가장 깊은(앞쪽) 뷰" = hit-test view

UIWindowhitTest를 최상위부터 재귀적으로 내려가며, 터치 포인트를 포함하는 서브뷰 계층에서 가장 깊고(=가장 앞쪽에 그려진) 뷰를 찾는다. 이게 hit-test view다.

hitTest가 뷰를 후보로 인정하는 조건

조건의미
isUserInteractionEnabled == true상호작용 꺼져 있으면 통과 못 함
isHidden == false숨겨진 뷰는 제외
alpha ≥ 0.01거의 투명하면 제외
point(inside:with:) == true포인트가 뷰 경계 안에 있어야 함
정정 포인트: hitTest가 찾는 건 hit-test view이지 first responder가 아니다. first responder는 becomeFirstResponder()로 지정되는 키보드/텍스트 포커스 대상으로, 터치 hit-test와 별개의 메커니즘이다.

Q. hit 뷰가 터치를 처리 못 하면?

responder chain을 따라 위로 전파된다:

hit 뷰 → superview → (그 뷰의) 뷰컨트롤러 → window → application → appDelegate

각 responder에서 처리 여부touchesBegan(_:with:) 등의 구현이나 UIControl의 target-action이 결정한다. 처리하지 않으면(구현이 super로 넘기면) 체인의 다음 responder로 넘어간다.

요약: hitTest = 누가 받을지 결정. responder chain = 받은 쪽이 안(못) 받으면 누구에게 넘길지 결정. 두 단계가 순서대로 작동한다.

§6. Scene / Window / Process 생명주기

Q. UIScene 구조와 계층은?

UIScene(iOS 13+)은 멀티윈도우를 지원하기 위해 도입됐다. iPad/Mac Catalyst에서 같은 앱을 여러 개 나란히 띄울 수 있다. 앱 두 개를 나란히 = 씬 두 개(각각 UIWindowScene). 한 씬에는 여러 윈도우가 있을 수 있고, 한 프로세스에 여러 씬같은 주소 공간에서 공존한다.

계층 구조

프로세스 (1)
  └─ 앱 / AppDelegate (1)          ← 프로세스 수준 생명주기
       └─ 씬 / SceneDelegate (N)    ← UI 씬(윈도우) 수준 생명주기
            └─ 윈도우 (씬당 1+)
                 └─ 뷰 계층
단위개수담당
프로세스1앱 실행 인스턴스 (주소 공간)
AppDelegate1앱(프로세스) 수준 생명주기 · 앱 시작/종료, 푸시 등록 등
SceneDelegateN (씬마다)UI 씬(윈도우) 수준 생명주기
윈도우씬당 1+UIWindow · 루트 뷰 계층 호스팅

Q. AppDelegate와 SceneDelegate의 역할 차이는?

이동 포인트: iOS 13+에서 window 셋업이 AppDelegate → SceneDelegate로 이동했다. 예전에 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
}
정리: 프로세스(1) → AppDelegate(1) → SceneDelegate(N) → 윈도우(씬당 1+) → 뷰. AppDelegate는 앱 전체, SceneDelegate는 화면(윈도우) 하나하나를 맡는다. 멀티윈도우 = 같은 프로세스 안에서 씬을 여러 개 띄우는 것.