이 챕터는 "한 프레임이 어떤 경로로 화면에 뜨는가"를 뼈대로, 프레임 예산·CPU와 GPU의 분업·이미지 디코드와 다운샘플링·커밋 트랜잭션 단계별 병목까지 렌더링 성능의 공통 언어를 정리한다. 스크롤이 왜 끊기는지, 어디를 재보고 어디를 고쳐야 하는지를 판단하는 기준을 만드는 게 목표다.
Q1. 화면의 한 Frame은 어떤 단계를 거쳐 표시되는가?
앱 프로세스는 런루프가 한 바퀴 돌 때마다 끝에서 CATransaction을 커밋한다. 이 커밋 트랜잭션은 Layout → Display → Prepare → Commit 순서로 진행되고, 완성된 CALayer 트리를 IPC로 별도 프로세스인 Render Server에 넘긴다. Render Server는 그 트리를 디코드해 GPU draw call로 바꾸고, GPU가 정점·픽셀 처리와 합성으로 프레임버퍼를 채운다. 디스플레이 컨트롤러는 VSync마다 그 프레임버퍼를 스캔아웃한다. 이 단계들은 파이프라인이라, 커밋된 프레임이 실제로 표시되기까지 보통 2~3 VSync가 걸린다.
CS 원리
래스터 디스플레이는 프레임버퍼(픽셀 배열)를 일정 주기(refresh rate)로 위에서 아래로 스캔아웃해 화면을 갱신한다. 렌더러가 버퍼를 쓰는 도중에 스캔아웃이 겹치면 화면이 위아래로 어긋나는 tearing이 생긴다. 이를 막으려고 더블/트리플 버퍼링을 두고, 스캔아웃 중인 front buffer는 건드리지 않은 채 back buffer에 렌더한 뒤 VSync 순간에 교체(swap)한다. 렌더링 자체도 "장면 기술 → 기하 변환 → 래스터화 → 합성 → 출력"처럼 여러 단계로 나뉘고, 이를 파이프라인으로 겹쳐 돌리면 처리량은 오르지만 한 프레임의 지연(latency)은 여러 주기로 늘어난다.
iOS에서는
iOS는 앱이 픽셀을 직접 그리지 않고 CALayer 트리만 기술하게 하는 Core Animation 아키텍처다. 실제 픽셀 렌더는 앱과 분리된 Render Server 프로세스가 담당한다. 런루프에 등록된 CATransaction 옵저버가 프레임 끝에서 커밋을 트리거하면, 커밋 트랜잭션 4단계가 돌고 그 결과가 Render Server로 넘어간다. Render Server는 이를 Metal 커맨드로 인코딩해 GPU에 제출하고, 화면 출력은 VSync에 동기화된다. 이 흐름은 WWDC 테크톡 Explore UI animation hitches and the render loop에서 Commit → Render Prepare → Render Execute 단계로 정의한다. 핵심 함의: 앱이 커밋 뒤 잠깐 멈춰도 이미 넘어간 애니메이션은 Render Server가 계속 진행할 수 있다.
실험 · 도구
Instruments의 Animation Hitches / Core Animation 템플릿으로 hitch(예상보다 늦게 표시된 프레임)를 시간(ms) 단위로 잡는다. 어느 프로세스가 늦었는지 — 앱의 커밋인지 Render Server인지 — 를 트랙별로 볼 수 있다. Xcode의 디버그 옵션 "Color Blended Layers", "Color Offscreen-Rendered"로 GPU 쪽 부담 원인을 눈으로 확인하고, os_signpost로 셀 구성처럼 앱 코드의 구간 시간을 직접 계측한다. 예상 결과: 앱이 프레임 예산을 넘기면 커밋 트랙이 길어지고 그 프레임이 드롭돼 화면 갱신 간격이 벌어진다.
프로젝트 적용
① 커밋은 메인 스레드에서 일어난다. 무거운 계산·파싱·디코드는 커밋 이전에 백그라운드에서 끝내라.
import UIKit
// 커밋 트랜잭션(Layout → Display → Prepare → Commit)은 전부 메인에서 돈다.
// 여기에 디코드·파싱이 끼면 그 프레임은 예산을 넘긴다.
@MainActor
final class FeedCell: UICollectionViewCell {
private let imageView = UIImageView()
private var currentID: UUID?
// ❌ 커밋 경로에서 디코드가 일어난다
func configureBad(_ item: Item) {
imageView.image = UIImage(contentsOfFile: item.path)
// UIImage 생성은 lazy지만, 실제 디코드가 Prepare 단계에서 메인에 걸린다
}
// ✅ 백그라운드에서 픽셀까지 확정한 뒤 넘긴다
func configure(_ item: Item) {
currentID = item.id
imageView.image = nil
Task.detached(priority: .userInitiated) {
guard let raw = UIImage(contentsOfFile: item.path) else { return }
// byPreparingForDisplay가 디코드를 여기서 끝낸다
let ready = await raw.byPreparingForDisplay()
await MainActor.run {
guard self.currentID == item.id else { return } // 재사용 검증
self.imageView.image = ready // 메인은 대입만
}
}
}
}
// ✅ 텍스트 레이아웃도 미리 계산해 둘 수 있다
struct PreparedRow {
let item: Item
let titleHeight: CGFloat // 백그라운드에서 미리 측정
let image: UIImage? // 미리 디코드
}
func prepare(_ items: [Item], width: CGFloat) async -> [PreparedRow] {
await withTaskGroup(of: PreparedRow.self) { group in
for item in items {
group.addTask {
let h = measure(item.title, width: width)
let img = await UIImage(contentsOfFile: item.path)?.byPreparingForDisplay()
return PreparedRow(item: item, titleHeight: h, image: img)
}
}
var out: [PreparedRow] = []
for await r in group { out.append(r) }
return out
}
}
struct Item { let id: UUID; let path: String; let title: String }
func measure(_ s: String, width: CGFloat) -> CGFloat { 0 }② 레이어 트리를 얕고 단순하게 유지하라. Commit 단계 비용은 트리 깊이·레이어 수에 비례한다.
import UIKit
// Commit 단계는 레이어 트리를 재귀적으로 인코딩해 렌더 서버로 보낸다.
// 비용이 레이어 개수와 깊이에 비례한다.
// ❌ 컨테이너를 겹겹이 쌓는 구조 — 셀 하나에 레이어 8개
final class DeepCell: UICollectionViewCell {
func buildBad() {
let outer = UIView(), middle = UIView(), inner = UIView()
let bg = UIView(), border = UIView()
contentView.addSubview(outer)
outer.addSubview(middle)
middle.addSubview(inner)
inner.addSubview(bg)
bg.addSubview(border)
}
}
// ✅ 배경·테두리·모서리는 뷰를 추가하지 말고 레이어 속성으로
final class FlatCell: UICollectionViewCell {
private let titleLabel = UILabel()
func build() {
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.layer.borderColor = UIColor.separator.cgColor
contentView.backgroundColor = .secondarySystemBackground
contentView.addSubview(titleLabel) // 레이어 2개로 끝
}
}
// ✅ 구분선을 뷰로 만들지 않는다
// ❌ 셀마다 1px 높이 UIView 추가 → 행 수만큼 레이어 증가
// ✅ tableView.separatorStyle 또는 레이어 border 사용
// ✅ 여러 정적 요소는 하나의 이미지로 미리 합성한다
func composedBackground(size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { ctx in
UIColor.systemBackground.setFill()
ctx.fill(CGRect(origin: .zero, size: size))
UIColor.separator.setStroke()
ctx.cgContext.stroke(CGRect(origin: .zero, size: size))
}
}
// 확인: Debug View Hierarchy에서 뷰 개수를 보고,
// Instruments의 Core Animation에서 Commit 시간을 확인한다③ 매 프레임 호출되는 경로(scrollViewDidScroll·CADisplayLink)에서 레이아웃을 무효화하지 마라. 매 프레임 재커밋을 유발한다. UIView.animate 블록은 시작 시 한 번만 실행되므로 그 자체가 매 프레임 비용은 아니다.
import UIKit
// 애니메이션이 도는 동안 setNeedsLayout을 부르면
// 매 프레임 재커밋이 일어나 애니메이션 자체가 무거워진다.
final class PanelController: UIViewController {
private let panel = UIView()
private var heightConstraint: NSLayoutConstraint!
// ❌ 시작 상태를 확정하지 않고 애니메이션한다 (블록 자체는 한 번만 실행된다)
func expandBad() {
UIView.animate(withDuration: 0.3) {
self.heightConstraint.constant = 300
self.view.setNeedsLayout() // 제약 변경이 이미 무효화하므로 불필요한 호출
self.view.layoutIfNeeded()
}
}
// ✅ 제약 변경 후 layoutIfNeeded를 한 번만 — 시스템이 보간한다
func expand() {
view.layoutIfNeeded() // 시작 상태 확정
heightConstraint.constant = 300
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded() // 종료 상태로 보간
}
}
// ✅ 더 싼 방법: 레이아웃을 아예 안 건드리는 속성으로 애니메이션
func slide() {
UIView.animate(withDuration: 0.3) {
self.panel.transform = CGAffineTransform(translationX: 0, y: -120)
// transform·opacity·backgroundColor는 레이아웃 무효화가 없다
// → 렌더 서버가 알아서 굴리고, 메인은 커밋 한 번으로 끝난다
}
}
// ✅ 스크롤 중 헤더 크기 조절처럼 매 프레임 바뀌는 것도 transform으로
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let scale = max(0.8, 1 - scrollView.contentOffset.y / 500)
panel.transform = CGAffineTransform(scaleX: scale, y: scale) // ✅
// ❌ heightConstraint.constant = ... 는 매 프레임 레이아웃을 유발한다
}
}"내 코드가 화면 픽셀까지 다 렌더한다"는 오해. 앱은 레이어 트리를 기술할 뿐이고, 실제 픽셀 합성은 별도 프로세스(Render Server)와 GPU가 한다. 그래서 앱이 순간 멈춰도 이미 커밋된 암시적 애니메이션은 계속 돌 수 있다. 반대로 drawRect(_:)를 오버라이드하면 그 콘텐츠만은 앱이 CPU로 직접 래스터화한다 — 이 경우엔 앱의 일이 맞다.
학교 축제 무대를 떠올려 보자. 우리 앱은 무대에 직접 올라가는 게 아니라 "3번 조명 켜고, 배우는 왼쪽으로" 같은 지시서만 쓴다. 그 지시서가 CALayer 트리(화면에 뭐가 어디 있는지 적어 둔 목록)고, 그걸 받아 실제로 조명을 켜고 배경을 옮기는 건 옆 건물에 있는 스태프(Render Server)와 손 빠른 인부 수천 명(GPU)이다. 그래서 연출자가 잠깐 자리를 비워도, 이미 넘긴 지시서대로 무대는 계속 돌아간다. 앱 메인 스레드가 200ms 멈춰도 애니메이션이 안 끊기는 이유가 이거다.
여기서 제일 헷갈리는 건 "커밋했는데 왜 두세 번이나 기다려야 화면에 뜨느냐"일 것이다. 칸이 나뉜 컨베이어라서 그렇다. 지시서 쓰기 → 스태프 준비 → 인부들 실행 → 관객 눈. 한 칸에 한 턴씩만 머무니, 내 지시서가 관객에게 닿으려면 커튼(VSync)이 두세 번 열려야 한다. 대신 내 지시서가 두 번째 칸으로 넘어간 순간 연출자는 벌써 다음 장면을 쓰고 있어서, 관객 입장에서 장면은 매 커튼마다 바뀐다. 비유가 깨지는 데도 하나 있다 — drawRect(_:)를 오버라이드하면 연출자가 직접 붓을 들고 배경 그림까지 그려서 넘기는 셈이라, 그 부분만은 진짜 앱(CPU)의 일이다.
꼬리 질문
암시적 애니메이션이 도는 도중 앱 메인 스레드가 200ms 블로킹되면 화면은 어떻게 보이는가?
이미 커밋되어 Render Server로 넘어간 애니메이션은 계속 매끄럽게 돌아간다. 커밋된 CAAnimation은 Render Server가 시작·끝 값과 타이밍 함수를 받아 독립적으로 보간하기 때문에, 앱이 멈춰도 그 200ms 동안 화면 위 애니메이션 자체는 진행된다.
대신 그 사이에 발생하는 입력·스크롤·새 레이아웃 같은 새 프레임을 커밋해야 하는 상호작용은 전부 얼어붙는다. 터치가 씹히고, 진행 중이던 애니메이션이 끝나 다음 상태를 커밋해야 하는 순간이 블로킹 구간과 겹치면 그때 눈에 띄게 튄다.
더블 버퍼링에서 GPU가 렌더를 제때 못 끝내면 무슨 일이 벌어지고, 트리플 버퍼링은 이를 어떻게 완화하는가?
더블 버퍼링은 front(스캔아웃 중)·back(렌더 중) 두 장뿐이라, GPU가 VSync 전에 back buffer를 못 끝내면 swap할 게 없어 이전 front를 한 주기 더 표시한다(프레임 반복 = 드롭). 게다가 GPU는 그 back buffer가 여전히 다음 스캔아웃 대상으로 묶여 있어 다음 프레임 작업을 시작하지 못하고 기다리므로, 한 번 놓치면 파이프라인이 stall되기 쉽다.
트리플 버퍼링은 여분의 버퍼를 한 장 더 둬서, GPU가 한 프레임을 늦게 끝내도 CPU/GPU가 곧바로 다음 버퍼에 작업을 이어갈 수 있게 한다. 드롭 한 번이 연쇄 stall로 번지는 걸 막아 처리량을 지켜주지만, 대신 버퍼가 하나 늘어난 만큼 표시 지연(latency)과 메모리가 늘어난다.
SwiftUI는 이 파이프라인의 어느 지점에서 UIKit/Core Animation과 만나는가?
SwiftUI는 자체 diffing으로 뷰 트리 변화를 계산하지만, 최종 출력은 결국 같은 Core Animation 레이어 트리와 커밋 트랜잭션으로 내려간다. 렌더 백엔드가 곧 CA/Metal이라, VSync·frame budget·Render Server 파이프라인은 UIKit과 완전히 동일하게 적용된다.
구체적으로 iOS 앱은 SwiftUI 뷰라도 호스팅 컨테이너(UIHostingController) 아래에서 실행되고, 실제 백킹은 CALayer다. 따라서 앞에서 본 이미지 디코드·오프스크린·레이어 수 같은 병목은 SwiftUI에서도 그대로 나타나며, 다만 개발자가 레이어를 직접 만지는 대신 @State 변화 → body 재평가라는 상위 추상으로 그 커밋을 트리거한다는 점이 다르다.
Q2. 60Hz와 120Hz에서 Frame budget은 각각 얼마인가?
frame budget은 리프레시 주기의 역수다. 60Hz면 1000÷60 ≈ 16.67ms, 120Hz면 1000÷120 ≈ 8.33ms가 한 프레임을 만들 수 있는 총 시간이다. 이 예산 안에 앱의 커밋 작업과 GPU 렌더가 각자 자기 몫을 끝내야 VSync를 놓치지 않는다. 파이프라인이라 각 단계가 대략 한 주기를 갖지만, 어느 단계든 자기 주기를 넘기면 그 프레임은 드롭되고 hitch가 생긴다. ProMotion(iPhone Pro, iPad Pro)은 고정 120이 아니라 가변(iPhone Pro는 10~120Hz)이라는 점도 중요하다.
CS 원리
budget = 1 / refresh rate. 이 시간 안에 렌더 파이프라인의 각 stage가 완료돼야 다음 VSync에 맞춰 새 프레임을 내보낼 수 있다. 넘기면 그 주기에는 이전 프레임이 한 번 더 표시되고(프레임 반복), 체감상 끊김으로 나타난다. 리프레시가 2배가 되면 예산은 정확히 절반이 된다.
| 리프레시 | 프레임 budget | 대표 예 |
|---|---|---|
| 60 Hz | ≈ 16.67 ms | 대부분의 iPhone, 표준 iPad |
| 90 Hz | ≈ 11.11 ms | 일부 Android 기기 (Apple ProMotion의 네이티브 rate는 아님) |
| 120 Hz | ≈ 8.33 ms | ProMotion (iPhone 17 전 라인업, 역대 iPhone Pro, iPad Pro) |
iOS에서는
대부분의 구형·보급형 iPhone은 60Hz, ProMotion 지원 모델은 10~120Hz 가변이다(iPad Pro는 하한이 24Hz로 더 높다). 다만 ProMotion은 더 이상 Pro 전용이 아니다 — 2025년 iPhone 17부터는 기본 모델과 iPhone Air까지 전 라인업이 120Hz다. 주의할 함정: ProMotion iPhone에서도 앱 애니메이션은 기본적으로 60fps로 캡된다. 120Hz까지 쓰려면 Info.plist에 CADisableMinimumFrameDurationOnPhone을 true로 넣어야 한다. 이 키만 있으면 UIKit 애니메이션과 스크롤은 시스템이 알아서 120Hz까지 올리고, CADisplayLink로 직접 프레임을 그리는 경우에만 preferredFrameRateRange로 원하는 범위를 추가로 힌트한다. 또 하나: 16.67ms를 앱이 통째로 쓸 수 있는 건 아니다. Render Server·GPU 몫이 별도 주기로 필요하므로, 실무 목표치는 그보다 짧게(60Hz에서 커밋 8~10ms 이내 등) 잡는 게 안전하다. 참고로 ProMotion의 실제 리프레시 rate는 연속값이 아니라 최소 프레임 지속시간에 맞춰 양자화된 이산값(120·80·60·48·40·30·24·20·16·15·12·10Hz 등)으로 정해진다.
실험 · 도구
실제 프레임 간격은 CADisplayLink의 timestamp 차이로 직접 잴 수 있다. 정상이면 16.67 또는 8.33ms 근처, 드롭이 나면 그 배수(33.3ms 등)로 튄다.
final class FPSMonitor {
private var link: CADisplayLink?
private var last: CFTimeInterval = 0
// CADisplayLink는 target(self)을 강하게 retain한다. self가 link를,
// link가 self를 서로 잡아 순환 참조가 생기므로 반드시 stop()으로 끊어야 한다.
func start() {
let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
link.add(to: .main, forMode: .common)
self.link = link
}
func stop() {
link?.invalidate() // 순환을 끊어 FPSMonitor(self)가 해제되게 한다
link = nil
}
@objc private func tick(_ link: CADisplayLink) {
guard last != 0 else { last = link.timestamp; return } // 첫 프레임은 기준만 잡고 건너뜀
let delta = link.timestamp - last // 두 프레임 사이 실제 간격(초)
last = link.timestamp
print("frame interval: \(delta * 1000) ms") // 16.67 / 8.33 근처여야 정상
}
}viewDidDisappear) stop()을 명시적으로 호출해야 하며, 순환 때문에 deinit은 저절로 불리지 않는다. 순환이 싫으면 weak proxy 타깃을 쓴다.프로젝트 적용
① 120Hz를 타겟하면 예산이 반토막(8.33ms)이다. 스크롤 셀 구성처럼 매 프레임 반복되는 작업의 상한을 그 안으로 맞춰야 한다.
import UIKit
// 60Hz → 16.67ms
// 120Hz → 8.33ms (예산이 정확히 반토막)
// 스크롤 셀 구성처럼 매 프레임 반복되는 작업이 이 안에 들어와야 한다.
enum FrameBudget {
// UIScreen.main은 iOS 26에서 deprecated — 호출부에서 view.window?.windowScene?.screen을 넘긴다
static func current(for screen: UIScreen) -> Duration {
let hz = screen.maximumFramesPerSecond
return .milliseconds(Int(1000.0 / Double(hz)))
}
/// 실제 예산은 더 짧게 잡는다 — 시스템 작업·변동성 여유
static func target(for screen: UIScreen) -> Duration {
let hz = screen.maximumFramesPerSecond
return .microseconds(Int(1000.0 / Double(hz) * 700)) // 예산의 70%
}
}
// ✅ hot path에 예산 검사를 걸어 회귀를 개발 중에 잡는다
@MainActor
final class FeedCell: UICollectionViewCell {
func configure(_ item: Item) {
#if DEBUG
let elapsed = ContinuousClock().measure { applyConfiguration(item) }
// 화면은 컨텍스트에서 얻는다 (UIScreen.main은 iOS 26에서 deprecated)
let budget = window?.windowScene?.screen.map(FrameBudget.target(for:)) ?? .milliseconds(11)
if elapsed > budget {
print("⚠️ 셀 구성 \(elapsed) — 예산 \(budget) 초과")
}
#else
applyConfiguration(item)
#endif
}
private func applyConfiguration(_ item: Item) {}
}
// ✅ 예산을 나눠 쓴다는 감각
// 8.33ms 안에 들어가야 하는 것:
// · cellForItemAt 반환 ~1ms
// · layoutSubviews ~1ms
// · draw / 텍스트 렌더 ~2ms
// · 커밋 인코딩 ~1ms
// · 나머지는 여유
//
// ⚠️ GPU 렌더는 별도 주기다. 앱이 8ms를 다 써도 GPU가 못 따라가면 드롭된다.
struct Item {}② "60fps 고정"을 코드에 하드코딩하지 마라(예: 애니메이션 스텝을 1/60로 가정). 가변 리프레시에서 깨진다. delta time 기반으로 계산하라.
import UIKit
// ProMotion은 10~120Hz 사이에서 가변이다. 60을 상수로 박으면
// 기기·상황에 따라 애니메이션 속도가 달라진다.
// ❌ 1/60을 가정
final class BrokenAnimator {
private var x: CGFloat = 0
private let speed: CGFloat = 120 // "초당 120pt"
@objc func step() {
x += speed / 60 // 💥 120Hz에서는 2배 빨라진다
}
}
// ✅ delta time 기반 — 어떤 주사율에서도 같은 속도
final class Animator {
private var x: CGFloat = 0
private let speed: CGFloat = 120
private var link: CADisplayLink?
func start() {
let l = CADisplayLink(target: self, selector: #selector(step(_:)))
l.add(to: .main, forMode: .common)
link = l
}
@objc private func step(_ link: CADisplayLink) {
let dt = link.targetTimestamp - link.timestamp // 실제 프레임 간격
x += speed * CGFloat(dt)
}
func stop() { link?.invalidate(); link = nil }
}
// ✅ 시간 기반 보간 — 총 시간을 고정하면 주사율과 무관해진다
final class Interpolator {
private var startTime: CFTimeInterval = 0
private let duration: CFTimeInterval = 0.3
func begin(_ link: CADisplayLink) { startTime = link.timestamp }
func progress(_ link: CADisplayLink) -> CGFloat {
let t = (link.timestamp - startTime) / duration
return CGFloat(min(1, max(0, t)))
}
}
// ✅ 애초에 Core Animation에 맡기면 이 문제가 없다
func preferCoreAnimation(_ view: UIView) {
UIView.animate(withDuration: 0.3) {
view.transform = CGAffineTransform(translationX: 100, y: 0)
// 렌더 서버가 주사율에 맞춰 보간한다
}
}③ 고프레임이 필요 없는 정적 화면에서 굳이 120Hz를 강제하면 전력만 더 쓴다.
import UIKit
// 고주사율은 전력을 더 쓴다. 필요한 화면에서만 올리고, 아니면 낮춰야 한다.
final class ChartView: UIView {
private var link: CADisplayLink?
// ✅ 상호작용 중에만 높게
func beginInteraction() {
link?.preferredFrameRateRange = CAFrameRateRange(
minimum: 80, maximum: 120, preferred: 120)
}
// ✅ 멈춰 있으면 낮게 — 또는 아예 링크를 멈춘다
func endInteraction() {
link?.preferredFrameRateRange = CAFrameRateRange(
minimum: 10, maximum: 30, preferred: 15)
}
func idle() {
link?.invalidate() // ✅ 갱신할 게 없으면 아예 끈다
link = nil
}
}
// ✅ 열 상태·저전력 모드에 반응해 스스로 낮춘다
func adaptToSystemState(_ link: CADisplayLink) {
if ProcessInfo.processInfo.isLowPowerModeEnabled {
link.preferredFrameRateRange = CAFrameRateRange(
minimum: 15, maximum: 30, preferred: 30)
return
}
switch ProcessInfo.processInfo.thermalState {
case .serious, .critical:
link.preferredFrameRateRange = CAFrameRateRange(
minimum: 20, maximum: 30, preferred: 30)
default:
link.preferredFrameRateRange = CAFrameRateRange(
minimum: 60, maximum: 120, preferred: 120)
}
}
// ⚠️ 120Hz를 쓰려면 Info.plist에 opt-in이 필요하다
// CADisableMinimumFrameDurationOnPhone = YES
// (없으면 앱이 60fps로 캡된다 — "120Hz 기기인데 왜 60인가"의 답)
// ⚠️ opt-in한 뒤에는 '항상 120'이 아니라 '필요할 때만 120'으로 관리해야 한다.
// 안 그러면 정적 화면에서도 배터리를 태운다."120Hz 기기니까 항상 120fps로 부드럽다"는 오해. ProMotion은 콘텐츠에 따라 리프레시를 낮춰 전력을 아끼는 가변 방식이고, 앱은 기본적으로 60fps로 캡돼 있어 명시적으로 opt-in해야 한다. 또 "앱이 16.67ms를 다 써도 된다"도 위험하다 — 커밋·Render Prepare·GPU Execute는 파이프라인이라 각자 별도 주기를 갖지만, 프레임 간 변동성·시스템 CPU 경합·커밋에 딸린 준비 작업 때문에 예산을 꽉 채우면 조금만 튀어도 드롭된다. 그래서 헤드룸을 두고 목표치를 더 짧게 잡는다.
버스로 생각하면 편하다. 60Hz 화면에서는 16.67ms마다 버스가 한 대씩 온다. 그 안에 짐(프레임 하나)을 다 못 싸면 버스를 놓치고, 다음 버스가 올 때까지 화면에는 아까 그림이 한 번 더 걸려 있다. 이게 프레임 드롭이고, 눈에는 "툭 걸리는" 느낌으로 온다. 120Hz는 버스가 두 배 자주 오는 대신 짐 쌀 시간이 8.33ms로 반토막 난다. 그러니까 120Hz는 "편해진다"가 아니라 "마감이 빡세진다"에 가깝다. GPU가 12ms 걸리는 무거운 화면이라면 버스를 자주 보내 봐야 매번 놓치니, 결국 두 대에 한 번씩만 타게 된다.
두 번째로 헷갈리는 건 "그럼 16.67ms를 내가 다 써도 되냐"인데, 그러면 안 된다. 이 노선엔 갈아타기가 있다. 앱이 짐을 싸서 실으면 Render Server가 다음 버스에서 이어받고, GPU가 또 그다음 버스에서 마무리한다. 구간마다 자기 몫의 시간이 있는 건 맞지만, 짐 싸는 걸 출발 직전까지 꽉 채워 놓으면 신호 한 번만 걸려도 놓친다. 그래서 60Hz에서도 커밋은 8~10ms 안쪽으로 여유(헤드룸)를 두고 목표를 잡는다. ProMotion도 "항상 120"이 아니라 탈 사람이 없으면 배차 간격을 알아서 늘렸다 줄였다 하는 노선이라고 보면 된다.
꼬리 질문
커밋은 8ms 안에 끝나는데 GPU 렌더가 12ms 걸리면, 60Hz와 120Hz에서 각각 무슨 일이 일어나는가?
병목은 파이프라인에서 가장 느린 stage가 결정한다. GPU 12ms가 지배 항이다. 60Hz(예산 16.67ms)에서는 12ms < 16.67ms라 GPU가 한 주기 안에 끝나므로, 커밋이 8ms여도 파이프라인은 매 VSync마다 프레임을 내보내 60fps가 유지된다.
120Hz(예산 8.33ms)에서는 GPU 12ms > 8.33ms라 한 주기를 넘긴다. 매 프레임마다 마감을 놓쳐 실질적으로 두 주기에 한 번꼴로만 완성되므로 약 60fps 수준으로 떨어지고, ProMotion이라면 시스템이 아예 리프레시를 60Hz 부근으로 낮춰 맞추기도 한다. 즉 120Hz를 켠다고 GPU가 무거운 화면이 빨라지지는 않는다.
가변 리프레시 환경에서 물리 기반 애니메이션을 프레임레이트와 무관하게 일정하게 만들려면 어떻게 계산해야 하는가?
프레임 수가 아니라 실제 경과 시간(delta time)으로 적분해야 한다. 매 tick마다 dt = 이번 timestamp − 이전 timestamp를 구해 위치 += 속도 * dt처럼 물리 상태를 갱신하면, 프레임 간격이 8.33ms든 16.67ms든 같은 실시간에 같은 위치가 나온다.
주의점: 단순 오일러 적분은 dt가 크게 튈 때(백그라운드 복귀·드롭) 발산하거나 결과가 달라질 수 있다. 그래서 dt를 상한으로 clamp하거나, 정확도가 중요하면 고정 스텝으로 여러 번 적분하는 fixed-timestep + 보간 방식을 쓴다. Apple 프레임워크라면 이미 시간 기반인 UIViewPropertyAnimator나 스프링(CASpringAnimation)을 쓰는 게 대개 더 안전하다.
스크롤 중에만 120Hz를 쓰고 정지하면 낮추는 전략을 CADisplayLink로 어떻게 구성하겠는가?
CADisplayLink.preferredFrameRateRange를 스크롤 상태에 따라 바꿔 준다. scrollViewWillBeginDragging·감속 중에는 CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120)로 올리고, scrollViewDidEndDecelerating 등 정지 시점에 60 근처로 다시 내린다. 시스템은 이 힌트와 다른 앱·전력 상태를 종합해 실제 rate를 이산값 중에서 고른다.
전제 조건이 두 가지 있다. Info.plist에 CADisableMinimumFrameDurationOnPhone이 true여야 iPhone에서 120Hz가 열리고, range는 기기가 실제로 지원하는 리프레시(iPhone ProMotion 기준 120·80·60·48·40·30·24·20·16·15·12·10Hz) 안에서 잡아야 시스템이 안정적인 rate로 수렴한다. 정지 상태에서 굳이 120을 유지하면 전력만 낭비하므로 내리는 쪽이 핵심이다.
CADisableMinimumFrameDurationOnPhone이라는 열쇠를 꽂아 둬야 신청 자체가 접수된다.Q3. CPU와 GPU는 렌더링에서 각각 무엇을 담당하는가?
CPU는 "무엇을 그릴지" 준비한다 — 오토레이아웃 제약 풀이, 텍스트·경로 레이아웃, drawRect(_:)의 Core Graphics 래스터화, 이미지 디코드, 레이어 트리 구성과 커밋. GPU는 "픽셀을 실제로 채운다" — 텍스처 샘플링, 레이어 합성(블렌딩), 트랜스폼·마스크·그라디언트, 프래그먼트 셰이딩, 프레임버퍼 출력. Core Animation의 합성은 GPU 가속이고, drawRect는 CPU 래스터화라 남발하면 비싸다. 병목이 CPU냐 GPU냐에 따라 처방이 완전히 달라지므로, 최적화 전에 어느 쪽인지부터 판별해야 한다.
CS 원리
CPU는 소수의 강력한 코어로 분기 많은 순차 로직에 강하고, GPU는 수많은 작은 코어로 같은 연산을 대량 병렬(SIMD)로 처리하는 데 강하다. 래스터화(삼각형을 픽셀로)와 프래그먼트 셰이딩(픽셀별 색 계산)은 픽셀마다 독립적이라 병렬성이 높아 GPU에 적합하다. 반대로 "이 뷰가 어디에 얼마 크기로 놓이는가" 같은 준비·판단은 CPU가 맡는다.
iOS에서는
Core Graphics(Quartz 2D)는 CPU로 비트맵을 그린다. 이 비트맵이 백킹스토어가 되어 GPU 텍스처로 올라간다. Core Animation의 레이어 합성·트랜스폼·투명도 처리는 Metal을 통해 GPU가 한다. 여기서 비싼 패턴이 offscreen rendering이다 — 마스크와 그림자를 함께 쓰거나(예: cornerRadius+masksToBounds+shadow 조합), group opacity를 켜면 GPU가 별도 버퍼에 먼저 그린 뒤 합치는 추가 패스를 강제한다. shouldRasterize는 그 결과를 캐시해 재사용하되, 매 프레임 내용이 바뀌면 캐시가 계속 무효화돼 오히려 손해다.
실험 · 도구
Instruments의 GPU/Metal System Trace로 GPU 시간을, Time Profiler로 CPU 시간을 각각 확인한다. Xcode 디버그 옵션으로 눈으로 판별할 수도 있다 — "Color Offscreen-Rendered"(오프스크린 패스가 노란색으로 표시), "Color Blended Layers"(빨간 영역이 블렌딩 부담), "Color Hits Green and Misses Red"(rasterization 캐시 적중/실패). GPU가 붉게 도배되면 합성·블렌딩·오프스크린이 병목, 반대로 메인 스레드 Time Profiler가 무거우면 CPU 쪽(레이아웃·디코드·drawRect)이 병목이다.
프로젝트 적용
① 오프스크린 렌더를 유발하는 조합(둥근 모서리 + 마스크 + 그림자)을 피하고, 그림자는 shadowPath를 지정해 GPU가 경로를 다시 계산하지 않게 하라.
import UIKit
// 그림자는 기본적으로 뷰의 알파 채널을 훑어 모양을 계산한다.
// 경로를 미리 알려 주면 그 계산이 사라진다.
final class CardView: UIView {
// ❌ 경로 없는 그림자 — GPU가 매 프레임 알파를 분석한다
func styleBad() {
layer.cornerRadius = 12
layer.masksToBounds = true
layer.shadowOpacity = 0.2 // 위 masksToBounds 때문에 이 그림자는 통째로 잘려 아예 안 보인다
// (마스킹을 풀어도 shadowPath가 없으면 알파 분석 → 오프스크린 패스)
layer.shadowRadius = 8
}
// ✅ 경로를 명시하고 masksToBounds는 끈다
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = 12
layer.masksToBounds = false
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
layer.shadowOpacity = 0.2
layer.shadowRadius = 8
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowColor = UIColor.black.cgColor
}
// ⚠️ bounds가 바뀌면 shadowPath도 다시 만들어야 한다 → layoutSubviews에서
}
// ✅ 모서리 클리핑과 그림자를 둘 다 원하면 뷰를 두 겹으로 나눈다
final class ShadowedCard: UIView {
private let shadowHost = UIView() // 그림자 담당 (클리핑 없음)
private let content = UIView() // 클리핑 담당 (그림자 없음)
override func layoutSubviews() {
super.layoutSubviews()
shadowHost.frame = bounds
content.frame = bounds
shadowHost.layer.shadowPath = UIBezierPath(
roundedRect: bounds, cornerRadius: 12).cgPath
shadowHost.layer.shadowOpacity = 0.2
shadowHost.layer.masksToBounds = false
content.layer.cornerRadius = 12
content.layer.masksToBounds = true
}
}
// ⚠️ 최신 iOS에서는 단순 cornerRadius(이미지·서브레이어 없이)는 최적화되어
// 오프스크린을 강제하지 않는다. 문제는 마스킹·그림자와 조합될 때다.
// "항상 비싸다"로 외우지 말고 도구로 확인한다.
// 확인: 시뮬레이터 → Debug → Color Offscreen-Rendered Yellow② 반투명 레이어를 겹겹이 쌓아 블렌딩을 늘리지 마라. 불투명(isOpaque = true)으로 둘 수 있으면 두라.
import UIKit
// 반투명 레이어가 겹칠 때마다 GPU가 아래 픽셀과 섞어야 한다.
// 불투명하면 아래를 아예 읽지 않아도 된다.
final class RowView: UIView {
// ❌ 배경색 없이 투명하게 두면 아래 레이어까지 합성해야 한다
func setupBad() {
backgroundColor = .clear
isOpaque = false
}
// ✅ 불투명하게 둘 수 있으면 둔다
func setup() {
backgroundColor = .systemBackground // 알파 1.0인 색
isOpaque = true // GPU에 "아래를 읽지 마라"
}
}
// ⚠️ isOpaque = true인데 실제로 투명 영역이 있으면 렌더 결과가 깨진다.
// "배경을 완전히 채운다"가 보장될 때만 켠다.
// ✅ 라벨·이미지뷰도 배경을 채우면 블렌딩이 준다
func configureLabels(_ label: UILabel, on background: UIColor) {
label.backgroundColor = background // .clear 대신
label.isOpaque = true
}
// ✅ 알파를 쓰는 대신 색 자체를 섞어 둔다
func preferSolidColor() -> UIColor {
// ❌ UIColor.black.withAlphaComponent(0.05) → 블렌딩 발생
// ✅ 배경과 미리 섞은 불투명 색
UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(white: 0.12, alpha: 1)
: UIColor(white: 0.96, alpha: 1)
}
}
// ⚠️ 그림자·둥근 모서리가 있으면 isOpaque를 켤 수 없다(모서리가 투명해야 한다).
// 그 경우엔 겹치는 반투명 레이어 '개수'를 줄이는 데 집중한다.
// 확인: 시뮬레이터 → Debug → Color Blended Layers
// 빨간색이 진할수록 여러 겹이 섞이고 있다는 뜻이다③ drawRect는 정말 커스텀 드로잉이 필요할 때만. 색·모서리·그림자는 레이어 속성으로 GPU에 맡기는 편이 싸다.
import UIKit
// drawRect는 CPU가 비트맵을 그리고, 그 비트맵(백킹스토어)이 메모리를 차지한 뒤
// GPU 텍스처로 올라간다. 레이어 속성은 GPU가 직접 처리한다.
// ❌ 단순한 도형을 drawRect로 — CPU 래스터화 + 백킹스토어 메모리
final class BadBadge: UIView {
override func draw(_ rect: CGRect) {
let path = UIBezierPath(ovalIn: rect)
UIColor.systemRed.setFill()
path.fill()
}
}
// ✅ 레이어 속성으로 — GPU가 처리하고 백킹스토어도 없다
final class Badge: UIView {
override func layoutSubviews() {
super.layoutSubviews()
backgroundColor = .systemRed
layer.cornerRadius = bounds.width / 2
}
}
// ✅ 그라디언트도 전용 레이어가 있다
final class GradientHeader: UIView {
override class var layerClass: AnyClass { CAGradientLayer.self }
override func layoutSubviews() {
super.layoutSubviews()
guard let gradient = layer as? CAGradientLayer else { return }
gradient.colors = [UIColor.systemBlue.cgColor, UIColor.systemTeal.cgColor]
gradient.startPoint = CGPoint(x: 0, y: 0)
gradient.endPoint = CGPoint(x: 1, y: 1)
}
}
// ✅ 테두리·마스크도 레이어로
func styleWithLayers(_ view: UIView) {
view.layer.borderWidth = 1
view.layer.borderColor = UIColor.separator.cgColor
view.layer.cornerCurve = .continuous // iOS 13+ 부드러운 모서리
}
// ✅ drawRect가 정당한 경우: 진짜 커스텀 드로잉
// 차트 곡선, 서명 캡처, 복잡한 벡터 도형 등
final class ChartView: UIView {
var points: [CGPoint] = []
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
guard let first = points.first else { return }
path.move(to: first)
for p in points.dropFirst() { path.addLine(to: p) }
UIColor.systemBlue.setStroke()
path.stroke()
}
// ⚠️ 이 경우에도 자주 바뀌지 않으면 결과를 이미지로 캐시하는 걸 검토한다
}"모든 렌더는 GPU가 다 한다"거나 "cornerRadius는 항상 오프스크린을 유발한다"는 오해. 준비 단계(레이아웃·디코드·drawRect)의 상당 부분은 CPU가 한다. 그리고 최신 iOS에서는 단순한 cornerRadius(이미지·복잡한 서브레이어 없이)는 최적화되어 오프스크린을 강제하지 않는다 — 문제가 되는 건 마스킹·그림자 등과 조합될 때다. "항상/절대"로 외우지 말고 도구로 확인하라.
큰 벽화를 그린다고 해 보자. CPU는 도면을 그리고 "여기는 파랑, 저기는 빨강, 이 글자는 이 위치"를 정하는 감독이다. 머리는 좋은데 손이 한두 개뿐이다. GPU는 붓 하나씩 든 인부 수천 명이라, "이 칸 파랑" 같은 단순한 일을 동시에 후두둑 해치운다. 픽셀은 옆 픽셀 색을 몰라도 자기 색을 칠할 수 있어서 이 방식이 통한다. drawRect(_:)는 감독이 인부를 안 쓰고 직접 붓을 드는 경우라, 자주 시키면 감독 한 명이 과로한다.
offscreen rendering(화면에 바로 못 칠하고 따로 도화지에 그렸다가 오려 붙이는 것)이 왜 비싼지도 여기서 보인다. 그림자를 예로 들면, shadowPath를 안 주면 시스템은 그림자 윤곽을 모른다. 그래서 "일단 이 레이어를 도화지에 그려 보고 → 불투명한 부분의 테두리를 떠서 → 흐리게 만든 다음 → 붙이자"가 된다. 도화지 한 장이 매 프레임 추가되는 셈이다. shadowPath로 "윤곽은 이 모양"이라고 미리 알려 주면 그려 보는 단계가 통째로 사라진다. shouldRasterize는 오려 둔 종이를 서랍에 넣어 뒀다 재사용하는 건데, 그림이 매 프레임 바뀌면 넣자마자 버리게 되어 손해만 난다.
꼬리 질문
같은 화면이 저사양 기기에서만 끊긴다면, CPU 병목인지 GPU 병목인지 어떻게 좁혀 들어가겠는가?
먼저 실기기에서 Time Profiler(메인 스레드 CPU)와 GPU/Metal System Trace를 동시에 붙여 어느 트랙이 프레임 예산을 먹는지 본다. 메인 스레드에 layoutSubviews·디코드·drawRect 스택이 길면 CPU, GPU 트랙의 프레임 시간이 예산을 넘으면 GPU 쪽이다.
도구 없이 좁힐 때는 변수를 하나씩 제거한다. "Color Blended Layers"·"Color Offscreen-Rendered"를 켜 화면이 빨강·노랑으로 뒤덮이면 GPU 합성 부담 신호다. 반대로 셀 내용을 단색 placeholder로 바꿔 그리기 부하를 없앴는데도 끊기면 레이아웃·데이터 준비 쪽(CPU)이 범인이다. 저사양 기기는 GPU fill-rate와 메모리 대역폭이 특히 약해, 같은 화면이라도 블렌딩·오프스크린에서 먼저 무너지는 경우가 많다.
shouldRasterize = true가 성능을 오히려 악화시키는 구체적 상황은?
shouldRasterize는 레이어를 한 번 비트맵으로 구워 캐시하고 재사용하는 최적화다. 따라서 매 프레임 내용이 바뀌는 레이어에 걸면 캐시가 계속 무효화되어, 캐시를 만드는 오프스크린 패스만 매 프레임 추가되고 재사용 이득은 0이라 순손해다. 애니메이션으로 크기·트랜스폼이 계속 변하는 뷰나, 텍스트가 자주 갱신되는 셀이 대표적이다.
또 하나의 함정은 캐시 크기 한도다. 래스터 캐시는 화면의 약 2.5배 픽셀로 제한되어, 큰 레이어를 여럿 rasterize하면 캐시에서 밀려나 미스가 반복된다. "Color Hits Green and Misses Red"에서 빨강이 계속 뜨면 이 상황이다.
그림자에 shadowPath를 주는 것이 왜 GPU 비용을 줄이는가?
shadowPath를 지정하지 않으면 Core Animation은 그림자 모양을 알기 위해 레이어의 알파 채널(실제 그려진 픽셀의 불투명 영역)을 분석해야 하고, 이 과정이 오프스크린 패스를 강제한다. 매 프레임 레이어를 별도 버퍼에 렌더해 윤곽을 뽑고 블러를 적용한 뒤 합치는 비싼 경로다.
shadowPath로 그림자 윤곽을 명시적 CGPath로 주면, 시스템은 알파 분석을 건너뛰고 그 경로를 바로 블러 처리하면 되므로 오프스크린 패스가 사라진다. 특히 사각형·둥근 사각형처럼 경로가 고정된 뷰에서 효과가 크고, 경로가 매번 바뀌면 갱신 비용이 상쇄될 수 있으니 크기 변화 시 shadowPath도 함께 업데이트해야 한다.
shadowPath는 "윤곽은 이 둥근 사각형이야"라고 미리 종이에 그려 주는 것이라 그려 보는 단계가 통째로 사라진다. 대신 뷰 크기가 바뀌면 그 종이도 새로 그려 줘야 실루엣이 어긋나지 않는다.Q4. 이미지 Decode가 스크롤을 끊기게 만드는 이유는 무엇인가?
JPEG·PNG·HEIC는 압축 포맷이라 GPU가 바로 못 쓴다. UIImage에 파일을 담아도 실제 디코드(압축 해제 → 픽셀 비트맵)는 지연되어, Core Animation이 렌더 직전 커밋의 Prepare 단계에서, 기본적으로 메인 스레드에서 일어난다. 스크롤 도중 큰 이미지가 셀에 등장하면 이 디코드가 런루프를 수 ms~수십 ms 붙잡아 커밋이 프레임 예산을 넘긴다 → 프레임 드롭 → 끊김. 해법은 디코드를 미리 백그라운드로 옮기거나(byPreparingForDisplay), 다운샘플링으로 디코드 비용 자체를 줄이는 것이다.
CS 원리
압축 이미지 디코드는 엔트로피 디코딩·역변환(JPEG의 IDCT 등)을 포함한 CPU 집약 작업이다. 결과 비트맵은 폭×높이×픽셀당바이트만큼의 메모리를 차지한다(8비트 RGBA면 픽셀당 4바이트). 예로 12MP 사진(4032×3024)은 디코드하면 약 48MB다. 이 무거운 작업이 메인 스레드(=UI 런루프)에서 돌면 그 시간만큼 화면 갱신이 멈춘다.
iOS에서는
디코드는 이미지가 실제로 화면에 붙어 렌더될 때 lazy하게 일어난다 — imageView.image = ... 시점이 아니라 그 레이어가 커밋/Prepare될 때다. 이걸 앞당겨 백그라운드에서 끝내는 공식 API가 iOS 15+의 byPreparingForDisplay()(비동기) / preparingForDisplay()다. 예전에는 이미지를 CGContext에 한 번 그려 강제 디코드하는 트릭을 썼다. 이미지 처리 모범 사례는 WWDC 2018 Image and Graphics Best Practices에 정리돼 있다.
let raw = UIImage(contentsOfFile: path)!
Task.detached(priority: .utility) {
let ready = await raw.byPreparingForDisplay() // 백그라운드에서 디코드 완료
await MainActor.run {
if let ready { cell.imageView.image = ready } // 메인 스레드는 표시만
}
}실험 · 도구
Time Profiler로 스크롤 중 메인 스레드 스택을 보면 ImageIO/CGImageSource 계열 디코드 프레임이 잡힌다 — 이게 병목의 증거다. Core Animation FPS gauge로 셀이 등장하는 순간 드롭이 몰리는지 확인하고, os_signpost로 셀 구성 시간을 구간 계측한다. 백그라운드 디코드로 바꾸면 메인 스레드에서 디코드 스택이 사라지고 드롭이 줄어드는 걸 확인할 수 있다.
프로젝트 적용
① 리스트·그리드 이미지는 반드시 백그라운드에서 디코드·다운샘플 후 메인에 넘겨라. SDWebImage·Kingfisher 같은 라이브러리는 백그라운드 디코드는 해 주지만 다운샘플은 기본이 아니다 — Kingfisher는 DownsamplingImageProcessor, Nuke는 ImageProcessors.Resize처럼 목표 크기를 명시적으로 지정해야 한다.
import UIKit
import ImageIO
// UIImage를 만드는 것만으로는 디코드가 일어나지 않는다.
// 실제 픽셀 생성이 메인의 커밋 단계로 미뤄지는 게 스크롤 끊김의 원인이다.
actor ImagePipeline {
static let shared = ImagePipeline()
private var cache = NSCache<NSString, UIImage>()
func image(for url: URL, size: CGSize, scale: CGFloat) async -> UIImage? {
let key = "\(url.absoluteString)|\(Int(size.width))x\(Int(size.height))" as NSString
if let hit = cache.object(forKey: key) { return hit }
// ✅ 다운샘플 — 디코드 비용과 메모리를 함께 줄인다
guard let image = downsample(url, size: size, scale: scale) else { return nil }
// ✅ 픽셀을 여기서 확정한다 (메인에서 디코드가 안 일어나게)
let ready = await image.byPreparingForDisplay() ?? image
cache.setObject(ready, forKey: key)
return ready
}
private nonisolated func downsample(_ url: URL,
size: CGSize, scale: CGFloat) -> UIImage? {
let srcOpt = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOpt) else { return nil }
let opt = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) * scale
] as CFDictionary
return CGImageSourceCreateThumbnailAtIndex(src, 0, opt).map { UIImage(cgImage: $0) }
}
}
// ⚠️ SDWebImage·Kingfisher·Nuke 같은 라이브러리는 백그라운드 디코드는 기본으로 해 주지만, 다운샘플은 프로세서를 지정해야 한다.
// 직접 만들 이유가 없다면 그쪽을 쓰는 편이 낫다.② UICollectionViewDataSourcePrefetching으로 곧 보일 셀의 이미지를 미리 디코드해 두라.
import UIKit
// 셀이 화면에 나타난 뒤에 로드를 시작하면 이미 늦다.
// 곧 보일 셀의 이미지를 미리 준비해 두면 스크롤이 매끄러워진다.
@MainActor
final class FeedViewController: UIViewController,
UICollectionViewDataSourcePrefetching {
private var items: [Item] = []
private var prefetchTasks: [IndexPath: Task<Void, Never>] = [:]
func collectionView(_ cv: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
for path in indexPaths {
guard prefetchTasks[path] == nil else { continue }
let item = items[path.item]
prefetchTasks[path] = Task {
_ = await ImagePipeline.shared.image(
for: item.url, size: cellSize, scale: displayScale)
}
}
}
// ✅ 스크롤 방향이 바뀌면 취소한다 — 안 하면 낭비가 쌓인다
func collectionView(_ cv: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
for path in indexPaths {
prefetchTasks[path]?.cancel()
prefetchTasks[path] = nil
}
}
private var cellSize: CGSize { CGSize(width: 120, height: 120) }
private var displayScale: CGFloat { view.traitCollection.displayScale }
}
// ✅ 등록을 잊지 말 것
func setup(_ collectionView: UICollectionView, _ vc: FeedViewController) {
collectionView.prefetchDataSource = vc
collectionView.isPrefetchingEnabled = true // 기본값이지만 명시
}
// ✅ 테이블뷰도 동일한 프로토콜이 있다 (UITableViewDataSourcePrefetching)
// ⚠️ prefetch는 '힌트'다. 시스템이 부를 수도, 안 부를 수도 있다.
// cellForItemAt에서도 반드시 로드 경로가 있어야 한다.
struct Item { let url: URL }
enum ImagePipeline {
static let shared = ImagePipelineImpl()
}
struct ImagePipelineImpl {
func image(for url: URL, size: CGSize, scale: CGFloat) async -> UIImage? { nil }
}③ 셀 재사용 시 이전 비동기 디코드 결과가 엉뚱한 셀에 꽂히지 않도록, 완료 시점에 대상 index/식별자를 검증하라.
import UIKit
// 셀은 재사용된다. 비동기 로드가 끝났을 때 그 셀이 이미 다른 항목을
// 표시하고 있을 수 있다 — 그러면 엉뚱한 이미지가 꽂힌다.
@MainActor
final class FeedCell: UICollectionViewCell {
private let imageView = UIImageView()
private var currentID: UUID?
private var loadTask: Task<Void, Never>?
func configure(_ item: Item) {
// ✅ ① 이전 로드를 취소한다
loadTask?.cancel()
// ✅ ② 이전 이미지를 지운다 (잔상 방지)
imageView.image = nil
// ✅ ③ 현재 식별자를 기록한다
currentID = item.id
loadTask = Task { [item] in
let image = await ImagePipeline.shared.image(for: item.url)
// ✅ ④ 완료 시점에 '아직 그 항목인가'를 검증한다
guard !Task.isCancelled, self.currentID == item.id else { return }
self.imageView.image = image
}
}
// ✅ ⑤ 재사용 직전에도 정리한다
override func prepareForReuse() {
super.prepareForReuse()
loadTask?.cancel()
loadTask = nil
currentID = nil
imageView.image = nil
}
}
// ✅ 인덱스가 아니라 '안정적 식별자'로 검증해야 한다
// ❌ guard indexPath == self.indexPath — 인덱스는 삽입/삭제로 바뀐다
// ✅ guard item.id == self.currentID — id는 항목을 따라간다
struct Item { let id: UUID; let url: URL }
enum ImagePipeline {
static let shared = ImagePipelineImpl()
}
struct ImagePipelineImpl { func image(for url: URL) async -> UIImage? { nil } }"UIImage를 만들면 디코드가 끝난다"는 오해. 디코드는 표시 직전까지 미뤄진다(lazy). 또 "백그라운드 스레드에서 UIImage를 생성만 하면 해결된다"도 부족하다 — 생성만으로는 디코드가 트리거되지 않으므로, 실제 픽셀을 강제로 만들어 두는 byPreparingForDisplay()나 다운샘플처럼 픽셀을 확정하는 처리가 있어야 메인 스레드 부담이 사라진다.
JPEG·PNG·HEIC는 진공 포장된 이불이라고 보면 된다. 꽉 눌러 놨으니 부피는 작은데, 그 상태로는 덮을 수가 없다. GPU도 마찬가지로 압축된 바이트를 그대로 화면에 못 쓴다. 그런데 UIImage를 만드는 건 포장된 이불을 장바구니에 담은 것까지다. 진짜로 봉지를 뜯어 부풀리는 일(decode)은 그 이미지가 화면에 나가기 직전, 커밋의 Prepare 단계까지 미뤄진다.
문제는 그 봉지 뜯기가 하필 계산대 한 줄, 그러니까 메인 스레드에서 벌어진다는 점이다. 스크롤할 때마다 새 셀이 줄 맨 앞에서 이불 봉지를 뜯고 있고, 그 뒤에 서 있던 프레임들이 전부 밀린다. 16.67ms짜리 계산대에서 20ms를 쓰면 그 프레임은 그냥 못 나간다. 그래서 byPreparingForDisplay()로 집에 오기 전에 미리 뜯어 부풀려 두고, 메인 스레드는 이미 펴진 이불을 침대에 올리기만 하게 만든다. "백그라운드에서 UIImage만 만들면 되지 않나"가 안 통하는 이유도 같다 — 장바구니에 담아 두기만 하면 봉지는 여전히 안 뜯긴 채로 계산대까지 따라온다.
꼬리 질문
같은 파일 크기라도 PNG보다 JPEG 디코드가 더 무겁거나 가벼울 수 있는 이유는?
디코드 비용은 파일 바이트가 아니라 압축 알고리즘의 복잡도와 결과 픽셀 수에 달렸다. JPEG는 DCT 기반 손실 압축이라 엔트로피 디코딩 + 역DCT(IDCT) + 색공간 변환(YCbCr→RGB) + 업샘플링을 거쳐, 같은 바이트라도 연산 단계가 많다. PNG는 DEFLATE(무손실) 압축이라 압축 해제 + 필터 역적용이 주 비용이라 상대적으로 단순한 경우가 많다.
다만 "무겁다"는 화소 해상도가 지배한다. 같은 파일 크기면 JPEG가 훨씬 큰 해상도를 담고 있을 수 있어(손실 압축이 효율적이라), 디코드하면 더 큰 비트맵이 나와 더 무거워진다. 반대로 해상도가 같다면 알고리즘 특성상 차이는 크지 않을 수 있다. 결국 포맷 이름만으로 단정하지 말고 실제 픽셀 크기와 프로파일로 판단해야 한다.
비동기 디코드를 넣었는데도 빠르게 스크롤하면 여전히 끊긴다면, 다음으로 무엇을 의심하겠는가?
먼저 디코드를 다운샘플 없이 원본 해상도로 하고 있는지 본다. 백그라운드로 옮겼어도 4000px 이미지를 매번 통째로 디코드하면 CPU와 메모리 대역폭이 포화되고, 큰 텍스처를 GPU로 올리는 것도 비싸다. 다음은 프리페치 부재다. prefetching 없이 셀이 화면에 들어오는 순간 디코드를 시작하면 빠른 스크롤에선 제때 못 끝낸다.
그 외 의심 순서: 셀 재사용 시 취소되지 않은 이전 디코드 작업이 쌓여 스레드 풀이 막히는지, 메인 스레드로 결과를 넘길 때 이미지가 아직 lazy 상태여서 커밋 시점에 재디코드가 일어나는지, 그리고 오프스크린·블렌딩 같은 GPU 병목이 스크롤과 겹치는지. 즉 병목이 디코드에서 다운샘플·프리페치·GPU 합성 쪽으로 옮겨간 것을 의심한다.
애니메이션 GIF/APNG처럼 여러 프레임 이미지는 디코드 전략이 어떻게 달라져야 하는가?
단일 이미지처럼 전 프레임을 한꺼번에 디코드해 메모리에 올리면 프레임 수만큼 메모리가 폭증한다. 그래서 재생 타이밍에 맞춰 필요한 프레임만 미리 조금(예: 다음 몇 장) 디코드해 채워 두는 슬라이딩 버퍼 방식으로 간다. CGImageSource로 인덱스별 프레임과 프레임 지속시간을 읽고, 별도 디스플레이 타이머로 표시한다.
추가 고려: APNG·GIF는 프레임이 이전 프레임 위에 덧그려지는 disposal/blend 규칙이 있어, 임의 프레임으로 점프하려면 이전 상태를 재구성해야 한다. 메모리·CPU·전력의 균형을 화면 크기와 재생 여부(화면 밖이면 정지)에 따라 조절하는 게 핵심이며, 리스트에서는 다운샘플과 결합해 프레임당 픽셀 수부터 줄이는 게 좋다.
Q5. 이미지 Downsampling은 무엇이며 왜 필요한가?
다운샘플링은 원본 이미지를 표시할 크기에 맞춰 더 작은 픽셀 해상도로 디코드하는 것이다. 100×100 뷰에 4000×3000 원본을 그대로 디코드하면 약 48MB짜리 비트맵을 메모리에 올리고, GPU는 매 프레임 그걸 축소 샘플링해야 한다. ImageIO의 CGImageSourceCreateThumbnailAtIndex로 목표 크기만큼만 디코드하면, 애초에 full-resolution 비트맵을 메모리에 만들지 않아 메모리와 디코드 비용이 함께 급감한다. 리스트·그리드 썸네일에서 특히 효과가 크다.
CS 원리
디코드된 비트맵의 메모리는 파일 용량이 아니라 픽셀 수에 비례한다. 표시 크기보다 큰 해상도를 들고 있는 만큼은 순수 낭비다. 목표 크기로 리샘플(다운스케일)해 두면 메모리 점유, 메모리 대역폭, 그리고 GPU가 샘플링할 텍스처 크기가 모두 줄어든다.
iOS에서는
핵심은 CGImageSourceCreateThumbnailAtIndex에 kCGImageSourceThumbnailMaxPixelSize로 최대 픽셀 크기를 지정하고 kCGImageSourceShouldCacheImmediately로 그 시점에 디코드까지 끝내는 것이다. 이렇게 하면 full-resolution 이미지를 거치지 않고 곧장 축소된 비트맵을 얻는다. UIGraphicsImageRenderer로 다시 그려 축소할 수도 있지만, 그 방식은 원본을 먼저 full-decode해야 하는 경우가 많아 메모리 피크를 줄이는 목적에는 ImageIO가 유리하다. (WWDC 2018 Image and Graphics Best Practices)
func downsample(at url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let srcOpt = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOpt) else { return nil }
let maxPixel = max(pointSize.width, pointSize.height) * scale
let opt = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // 이 시점에 디코드까지 완료
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF 회전 반영
kCGImageSourceThumbnailMaxPixelSize: maxPixel
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opt) else { return nil }
return UIImage(cgImage: cg, scale: scale, orientation: .up) // scale을 빼면 3x 기기에서 크기가 3배로 어긋난다
}실험 · 도구
Instruments Allocations / VM Tracker(또는 Memory graph)로 이미지가 차지하는 메모리를 다운샘플 전후로 비교한다. 예상 결과: 원본 그대로 로드하면 이미지 하나가 수십 MB를 잡지만, 목표 크기(스케일 포함)로 다운샘플하면 그 크기 제곱에 비례해 수십~수백 KB 수준으로 떨어진다. 스크롤 중 메모리 증가 곡선과 디코드 시간이 함께 줄어드는 걸 볼 수 있다.
프로젝트 적용
① 목표는 뷰의 point 크기가 아니라 픽셀 크기다. Retina를 고려해 screen.scale(2x/3x)을 곱해라. 너무 작게 다운샘플하면 화면에서 뭉개진다.
import UIKit
import ImageIO
// 100×100 point 뷰라도 3x 기기에서는 300×300 픽셀이 필요하다.
// point로 다운샘플하면 화면에서 뭉개진다.
func downsample(at url: URL,
toDisplaySize pointSize: CGSize,
scale: CGFloat) -> UIImage? {
let srcOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, srcOptions) else {
return nil
}
// ✅ 픽셀 기준으로 계산한다
let maxPixelSize = max(pointSize.width, pointSize.height) * scale
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else {
return nil
}
// ✅ scale을 함께 넘겨야 UIKit이 올바른 point 크기로 표시한다
return UIImage(cgImage: cgImage, scale: scale, orientation: .up)
}
// ✅ 배율은 화면이 아니라 '그 뷰의 trait'에서 가져온다 (외부 디스플레이 대응)
@MainActor
func configure(_ imageView: UIImageView, url: URL) {
let scale = imageView.traitCollection.displayScale
imageView.image = downsample(at: url,
toDisplaySize: imageView.bounds.size,
scale: scale)
}
// ⚠️ bounds가 아직 0일 수 있다 — layoutSubviews 이후나 예상 크기를 쓴다
// ⚠️ 너무 작게 잡으면 뭉개진다. 확대 제스처가 있으면 여유를 둔다.
func sizeForZoomable(_ base: CGSize, maxZoom: CGFloat = 2) -> CGSize {
CGSize(width: base.width * maxZoom, height: base.height * maxZoom)
}② 서버가 썸네일 URL을 준다면 그걸 먼저 써라. 클라이언트 다운샘플은 원본만 있을 때의 차선책이다.
import Foundation
// 클라이언트 다운샘플은 '원본을 이미 받은 뒤'의 처리다.
// 원본 전송 비용(대역폭·시간·배터리)은 그대로 든다.
// 서버가 썸네일을 줄 수 있으면 그게 항상 낫다.
struct MediaItem: Decodable {
let id: UUID
let originalURL: URL
let thumbnailURL: URL? // ✅ 서버가 제공
let variants: [Variant]? // ✅ 여러 크기를 주는 경우
struct Variant: Decodable {
let width: Int
let url: URL
}
}
extension MediaItem {
/// 표시 크기에 가장 알맞은 URL을 고른다
func url(forDisplayWidth points: CGFloat, scale: CGFloat) -> URL {
let neededPixels = Int(points * scale)
if let variants, !variants.isEmpty {
// 필요한 크기 이상 중 가장 작은 것
let best = variants
.filter { $0.width >= neededPixels }
.min(by: { $0.width < $1.width })
return (best ?? variants.max(by: { $0.width < $1.width }))!.url
}
if neededPixels <= 400, let thumbnailURL { return thumbnailURL }
return originalURL // 최후의 수단
}
}
// ✅ 이미지 CDN을 쓰면 URL 파라미터로 요청할 수도 있다
func cdnURL(_ base: URL, width: Int, scale: CGFloat) -> URL {
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "w", value: String(Int(CGFloat(width) * scale))),
URLQueryItem(name: "fm", value: "webp"),
URLQueryItem(name: "q", value: "80")
]
return components.url!
}
// ⚠️ 서버 팀과 합의할 것: 어떤 크기 변형을 제공할지, 캐시 정책은 어떻게 할지.
// 클라가 임의 크기를 요청하면 CDN 캐시 적중률이 떨어진다 → 몇 개 규격으로 고정.③ 원본 해상도는 확대·상세 화면에서만 로드하고, 리스트에서는 항상 다운샘플본을 캐시해 재사용하라.
import UIKit
// 리스트에서 원본을 로드하면 메모리와 대역폭이 동시에 터진다.
// 4000×3000 원본 하나 = 약 48MB 비트맵. 화면에 10개면 480MB.
@MainActor
final class GalleryViewController: UIViewController {
private var items: [MediaItem] = []
// ✅ 리스트 — 항상 다운샘플본, 캐시해서 재사용
func configureThumb(_ cell: UICollectionViewCell,
imageView: UIImageView, item: MediaItem) {
let scale = imageView.traitCollection.displayScale
let url = item.url(forDisplayWidth: 120, scale: scale)
Task {
imageView.image = await ImagePipeline.shared.thumbnail(url, size: CGSize(width: 120, height: 120))
}
}
// ✅ 상세 — 여기서만 원본. 그것도 화면 크기에 맞춰서
func showDetail(_ item: MediaItem) {
let detail = DetailViewController(item: item)
navigationController?.pushViewController(detail, animated: true)
}
}
@MainActor
final class DetailViewController: UIViewController {
private let item: MediaItem
private let scrollView = UIScrollView()
private let imageView = UIImageView()
init(item: MediaItem) { self.item = item; super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
// ✅ ① 먼저 썸네일을 즉시 보여 준다 (이미 캐시에 있다)
Task {
let thumbURL = item.url(forDisplayWidth: 120, scale: 3)
imageView.image = await ImagePipeline.shared.thumbnail(
thumbURL, size: CGSize(width: 120, height: 120))
// ✅ ② 그다음 고해상도로 교체 — 화면 크기 × 최대 줌 배율까지만
let target = view.bounds.size
imageView.image = await ImagePipeline.shared.image(
item.originalURL, size: target, scale: view.traitCollection.displayScale)
}
}
// ✅ ③ 화면을 벗어나면 큰 이미지를 놓는다
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
imageView.image = nil
}
}
struct MediaItem {
let originalURL: URL
func url(forDisplayWidth w: CGFloat, scale: CGFloat) -> URL { originalURL }
}
enum ImagePipeline { static let shared = ImagePipelineImpl() }
struct ImagePipelineImpl {
func thumbnail(_ url: URL, size: CGSize) async -> UIImage? { nil }
func image(_ url: URL, size: CGSize, scale: CGFloat) async -> UIImage? { nil }
}"UIImageView가 알아서 작게 보여주니 메모리도 그만큼 준다"는 오해. 화면에 작게 표시돼도 디코드된 비트맵은 원본 해상도 그대로 메모리를 점유한다. 표시 크기와 메모리 점유는 별개다. 그래서 표시 전에 다운샘플로 픽셀 수 자체를 줄여야 한다.
사진관에 인화를 맡겼다고 해 보자. 액자는 우표만 한데(100×100 뷰), 인화는 전지 크기로 뽑아 왔다(4032×3024). 액자에 끼우면 눈에는 작게 보이지만, 방 안에는 전지 한 장이 통째로 쌓여 있다. 이 "방에 쌓인 종이"가 메모리다. 4032×3024는 약 1200만 픽셀이고 픽셀 하나에 4바이트(RGBA)면 대략 48MB — 작게 보인다고 이 숫자가 줄지는 않는다. UIImageView가 알아서 작게 그려 주는 건 액자 이야기지 종이 이야기가 아니다.
다운샘플링은 사진관에 처음부터 "우표 크기로 뽑아 주세요"라고 주문하는 것이다. CGImageSourceCreateThumbnailAtIndex가 그 주문서고, 큰 종이를 애초에 만들지 않으니 방도 안 차고 뽑는 시간도 짧다. 반면 UIGraphicsImageRenderer로 줄이는 건 전지로 일단 뽑아 온 다음 가위로 오려 내는 쪽이라, 잠깐이라도 48MB가 방에 들어왔다 나간다(메모리 피크). 주문할 때 단위 하나만 조심하면 된다 — 액자가 100pt여도 3배 Retina 화면은 한 점에 픽셀을 3개 쓰므로 300px로 주문해야 한다. 100px로 주문하면 늘려 그려져 뭉개진다.
꼬리 질문
같은 이미지를 여러 크기로 반복 표시한다면 다운샘플 결과를 어떻게 캐시하고 키를 무엇으로 잡겠는가?
키는 "이미지 식별자 + 목표 픽셀 크기"의 조합으로 잡는다. 원본 URL·에셋 이름·콘텐츠 해시 같은 식별자에, point 크기가 아니라 scale을 곱한 실제 픽셀 크기(그리고 필요하면 content mode)를 붙인다. 같은 사진이라도 썸네일과 상세용 크기가 다르면 별도 엔트리가 된다.
저장은 NSCache가 적합하다. 메모리 압박 시 자동으로 항목을 비워 주기 때문이다. 표시 크기 종류가 많으면 몇 개의 대표 버킷으로 반올림해 캐시 엔트리 폭발을 막고, 디스크 캐시까지 둔다면 키를 파일명 안전한 문자열로 정규화한다.
NSCache에 넣어 두면 방이 좁아질 때 알아서 오래된 걸 비워 준다. 단, 크기 종류가 스무 가지씩 생기면 봉투가 폭발하니 "작음·중간·큼" 정도로 반올림해 묶는다.kCGImageSourceShouldCacheImmediately를 켜고 끄는 것이 디코드 타이밍과 메모리에 각각 어떤 영향을 주는가?
켜면(true) 썸네일을 만드는 그 호출 시점에 곧바로 디코드까지 끝내 픽셀 비트맵을 확정한다. 그래서 이 작업을 백그라운드에 두면 나중에 메인 스레드 커밋에서 디코드가 재발하지 않는다. 대신 그 순간 메모리에 디코드된 비트맵이 즉시 올라온다.
끄면(false 또는 기본값) 디코드가 실제 렌더 직전으로 미뤄져(lazy), 메모리 점유를 뒤로 늦추는 대신 그 디코드가 메인 스레드 커밋 타이밍에 터질 위험이 있다. 스크롤 리스트처럼 "백그라운드에서 미리 다 굽고 메인은 표시만" 하려는 목적에서는 켜는 게 맞고, 언제 보일지 모르는 대량 이미지의 메모리 피크를 늦추고 싶으면 끄는 선택도 있다.
다운샘플 코드에서 kCGImageSourceCreateThumbnailFromImageAlways를 true로 두는 것과, 파일에 박힌 EXIF 임베디드 썸네일을 우선 재사용하는 kCGImageSourceCreateThumbnailFromImageIfAbsent로 바꾸는 것은 결과 품질·디코드 비용·메모리 피크에서 각각 어떻게 다르고, 리스트 썸네일과 상세 화면에서 각각 무엇을 택하겠는가?
kCGImageSourceCreateThumbnailFromImageAlways는 임베디드 썸네일을 무시하고 항상 원본 이미지를 디코드해 요청한 kCGImageSourceThumbnailMaxPixelSize로 다시 만든다. 그래서 표시 크기에서 품질이 일정하게 보장되는 대신 소스 디코드·리샘플 비용과 메모리 피크를 매번 치른다. 반면 kCGImageSourceCreateThumbnailFromImageIfAbsent는 파일에 EXIF 임베디드 썸네일(카메라 사진은 대개 있고 흔히 160px 안팎)이 있으면 그걸 꺼내(요청 크기보다 크면 그 크기로 축소해) 써서 원본 full decode를 건너뛰므로 디코드가 사실상 공짜에 가깝고 메모리 피크도 미미하다. 대신 kCGImageSourceThumbnailMaxPixelSize는 어디까지나 상한이라 없는 화소를 만들어 주지는 않는다 — 임베디드가 요청 크기보다 작으면 그 작은 비트맵이 그대로 돌아와 뷰에서 늘려 그려지며 뭉개진다. 임베디드 썸네일이 원본 편집(크롭·회전) 이후 갱신되지 않아 실제 이미지와 어긋나 있을 수도 있다.
따라서 선택은 임베디드 썸네일 크기에 달렸다. Retina 리스트 셀이 요구하는 픽셀(예: 100pt × 3배 = 300px)보다 임베디드가 크면 후자로 스크롤 디코드 비용을 크게 아끼고, 그보다 작아 뭉개진다면 리스트에서도 ...FromImageAlways로 품질을 지키는 편이 낫다. 확대·핀치가 되는 상세 화면은 작은 임베디드 썸네일로는 어림없으니 ...FromImageAlways(또는 필요 크기의 full-decode)가 정답이다.
...IfAbsent는 "그거 있으면 그냥 써"라서 거의 공짜다. 문제는 그게 160px인데 셀은 300px가 필요하면 늘려 그려져 뭉개진다는 것 — 없는 화소를 만들어 주지는 않는다. 그래서 목록은 임베디드가 충분히 클 때만 재사용하고, 손가락으로 확대하는 상세 화면은 무조건 원본에서 새로 뽑는다.Q6. Layout, Display, Commit 단계의 병목은 어떻게 다른가?
세 단계는 모두 커밋 트랜잭션의 하위 단계지만 병목 원인이 다르다. Layout은 layoutSubviews와 오토레이아웃 제약 풀이가 CPU 시간을 먹는다(깊은 뷰 계층·제약 폭증에 취약). Display는 drawRect/Core Graphics로 백킹스토어 비트맵을 CPU 래스터화하며 커스텀 드로잉·텍스트·넓은 영역에 비싸고 메모리도 쓴다. Commit은 완성된 레이어 트리를 재귀적으로 인코딩해 Render Server로 IPC 전송하며 레이어 개수·백킹스토어 전송량에 비례한다. 한마디로 Layout=계산, Display=래스터화+메모리, Commit=트리 크기+IPC.
CS 원리
파이프라인의 각 stage는 병목의 성격이 다르다 — 어떤 단계는 순수 계산량, 어떤 단계는 픽셀 생성과 메모리, 어떤 단계는 직렬화와 프로세스 간 전송이 지배한다. 그래서 "느리다"는 관찰만으로는 부족하고 어느 stage가 예산을 먹는지 특정해야 올바른 처방이 나온다.
iOS에서는
커밋 트랜잭션은 Layout → Display → Prepare → Commit 4단계다. Layout에서 오토레이아웃은 제약 기반 solver(Cassowary 계열)로 서로 독립적인 제약 묶음은 개수에 거의 선형으로 늘지만, 계층을 가로질러 얽힌 제약이나 매 사이클 제약을 만들었다 없앴다 하는 패턴에서는 비용이 급격히 뛴다(엔진 자체는 iOS 12에서 크게 개선됐다). Display는 drawRect(_:)를 오버라이드뿐 아니라 UILabel의 텍스트 조판처럼 백킹스토어에 픽셀을 그려야 하는 콘텐츠가 있을 때 실행되며 백킹스토어 비트맵을 만든다 — 오버라이드가 없으면 이 단계는 대부분 건너뛴다. Prepare는 이미지 디코드·GPU가 못 쓰는 포맷 변환을 처리한다(그래서 Q4·Q5가 여기 연결된다). Commit은 레이어 트리를 재귀적으로 인코딩해 IPC로 넘기므로 트리가 깊고 넓을수록 비싸다.
| 단계 | 주요 작업 | 병목 원인 | 주 리소스 | 완화 |
|---|---|---|---|---|
| Layout | layoutSubviews, 오토레이아웃 제약 풀이 | 깊은 뷰 계층, 계층을 가로지르는 제약 의존·제약 churn | 메인 스레드 CPU | 계층 얕게, 셀 높이 선계산, 제약 재사용 |
| Display | drawRect·Core Graphics로 백킹스토어 비트맵 생성 | 커스텀 드로잉·텍스트·넓은 draw 영역, 메모리 | 메인 CPU + 메모리 | 레이어 속성으로 대체, 텍스트·이미지 캐시 |
| Commit | 레이어 트리 인코딩 → Render Server로 IPC | 레이어 개수, 백킹스토어 전송량 | CPU 직렬화 + IPC | 레이어 수 축소, 큰 백킹스토어 회피 |
실험 · 도구
Time Profiler에서 메인 스레드 스택을 보면 어느 단계가 무거운지 이름으로 드러난다 — layoutSubviews/오토레이아웃 엔진(Layout), -[CALayer display]·Core Graphics(Display), CA::Transaction::commit(Commit). Core Animation 템플릿의 FPS와 병행해 보고, os_signpost로 의심 구간을 감싸 구간별 시간을 직접 잰다. 뷰 계층이 병목이면 Xcode View Debugger로 실제 계층 깊이를 확인하라.
프로젝트 적용
① Layout: 뷰 계층을 얕게, 셀 높이는 미리 계산해 캐시하고, 애니메이션 중 불필요한 setNeedsLayout을 막아라.
import UIKit
// Layout 단계 비용 = 뷰 계층 깊이 × 제약 개수 × 재계산 횟수
// ✅ ① 셀 높이를 미리 계산해 캐시한다
@MainActor
final class HeightCache {
private var cache: [UUID: CGFloat] = [:]
private var cachedWidth: CGFloat = 0
func height(for item: Item, width: CGFloat, font: UIFont) -> CGFloat {
// 너비가 바뀌면(회전 등) 전부 무효화
if width != cachedWidth { cache.removeAll(); cachedWidth = width }
if let h = cache[item.id] { return h }
let h = (item.title as NSString).boundingRect(
with: CGSize(width: width, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: font],
context: nil
).height.rounded(.up) + 24
cache[item.id] = h
return h
}
}
// ✅ ② 자동 높이가 필요하면 추정치를 정확히 준다 — 스크롤바 점프가 줄어든다
func setupTable(_ tableView: UITableView) {
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 88 // 실제 평균에 가깝게
}
// ✅ ③ 제약 대신 frame 계산이 훨씬 싼 경우가 있다 (셀처럼 단순·반복적인 레이아웃)
final class FastCell: UICollectionViewCell {
private let icon = UIImageView()
private let title = UILabel()
override func layoutSubviews() {
super.layoutSubviews()
icon.frame = CGRect(x: 12, y: 12, width: 40, height: 40)
title.frame = CGRect(x: 60, y: 12, width: bounds.width - 72, height: 40)
}
}
// ✅ ④ 애니메이션 중 무효화를 막는다 (transform 사용)
struct Item { let id: UUID; let title: String }② Display: 색·모서리·그림자·그라디언트는 drawRect가 아니라 레이어 속성으로. 반복되는 텍스트/드로잉 결과는 이미지로 캐시하라.
import UIKit
// Display 단계는 drawRect/Core Graphics로 백킹스토어 비트맵을 CPU가 그린다.
// 비용은 그리는 면적과 복잡도에 비례하고, 메모리도 그만큼 쓴다.
// ✅ ① 반복되는 드로잉 결과는 이미지로 한 번만 만들어 재사용한다
enum BadgeRenderer {
private static var cache: [String: UIImage] = [:]
@MainActor
static func badge(count: Int, color: UIColor) -> UIImage {
let key = "\(count)|\(color.hashValue)"
if let hit = cache[key] { return hit }
let size = CGSize(width: 24, height: 24)
let image = UIGraphicsImageRenderer(size: size).image { ctx in
color.setFill()
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
let text = "\(count)" as NSString
text.draw(at: CGPoint(x: 8, y: 4),
withAttributes: [.foregroundColor: UIColor.white,
.font: UIFont.systemFont(ofSize: 12)])
}
cache[key] = image
return image
}
}
// ✅ ② 단순 도형은 아예 그리지 않고 레이어 속성으로
func styleWithLayer(_ view: UIView) {
view.backgroundColor = .systemRed
view.layer.cornerRadius = view.bounds.width / 2 // drawRect 불필요
}
// ✅ ③ drawRect를 써야 하면 다시 그리는 영역을 좁힌다
final class ChartView: UIView {
func updateLastPoint() {
// ❌ setNeedsDisplay() — 전체를 다시 그린다
setNeedsDisplay(CGRect(x: bounds.width - 40, y: 0,
width: 40, height: bounds.height)) // ✅ 일부만
}
}
// ✅ ④ 텍스트가 많은 셀은 미리 렌더한 NSAttributedString을 재사용한다
final class TextCache {
private var cache: [UUID: NSAttributedString] = [:]
func attributed(_ item: Item, font: UIFont) -> NSAttributedString {
if let hit = cache[item.id] { return hit }
let s = NSAttributedString(string: item.title, attributes: [.font: font])
cache[item.id] = s
return s
}
}
struct Item { let id: UUID; let title: String }③ Commit: 화면당 레이어 수를 줄이고, 거대한 백킹스토어(전체 화면 크기의 비트맵)를 매 프레임 새로 만들어 전송하지 마라.
import UIKit
// Commit은 완성된 레이어 트리를 인코딩해 렌더 서버로 IPC 전송한다.
// 비용 = 레이어 개수 + 전송할 백킹스토어 크기
// ✅ ① 화면당 레이어 수를 줄인다
final class CompactCell: UICollectionViewCell {
// ❌ 아이콘·배지·구분선·배경을 각각 UIView로 → 레이어 5개
// ✅ 정적인 것들은 한 장의 이미지로 합성 → 레이어 2개
private let composited = UIImageView()
private let dynamicLabel = UILabel()
}
// ✅ ② 거대한 백킹스토어를 매 프레임 새로 만들지 않는다
final class BadOverlay: UIView {
// ❌ 전체 화면 크기 비트맵을 매번 다시 그려 전송
func updateBad() { setNeedsDisplay() } // 프레임마다 호출되면 치명적
}
final class GoodOverlay: UIView {
// ✅ 변하는 부분만 작은 레이어로 분리하고, 나머지는 그대로 둔다
private let staticBackground = UIImageView() // 한 번 만들고 재사용
private let movingIndicator = UIView() // 작고, transform으로만 움직인다
func update(progress: CGFloat) {
movingIndicator.transform = CGAffineTransform(
translationX: bounds.width * progress, y: 0)
// 레이아웃 무효화도, 재드로잉도, 백킹스토어 전송도 없다
}
}
// ✅ ③ 화면 밖 뷰는 아예 만들지 않는다 (셀 재사용의 본질)
// 스크롤뷰에 1000개 뷰를 미리 넣어 두면 Commit이 1000개 레이어를 매번 인코딩한다
// ✅ ④ 숨긴 뷰는 hidden보다 제거가 쌀 때가 있다
func hideProperly(_ view: UIView, permanently: Bool) {
if permanently {
view.removeFromSuperview() // 트리에서 빠져 인코딩 대상이 아니게 된다
} else {
view.isHidden = true // 트리에는 남는다 (자주 토글하면 이쪽)
}
}
// 확인: Instruments → Core Animation → Commit 시간
// 또는 Animation Hitches의 "Commit" 구간"프레임이 느리면 다 GPU 탓"이라는 오해. Layout·Display·Commit은 대부분 메인 스레드의 CPU/메모리/IPC 병목이라 GPU와 무관한 경우가 많다. 반대로 이 세 단계가 예산 안에 끝나도 GPU 합성이 느려 드롭될 수 있다. 그래서 "CPU 쪽(커밋)이 늦었나, GPU 쪽이 늦었나"를 도구로 나눠 보는 게 최적화의 출발점이다.
Q1의 무대 비유를 그대로 이어 보자. 커밋 트랜잭션은 "한 장면을 준비해서 옆 건물로 넘기는 일"이고, 그 안이 다시 몇 칸으로 나뉜다.
- Layout — 배우와 소품을 어디에 몇 cm 간격으로 세울지 자로 재는 일. 순수 계산이라, 등장인물이 30명이면 서로 위치가 얽혀 계산량이 확 뛴다.
- Display — 배경 그림판을 실제로 붓으로 칠해 만드는 일. 시간도 들고 그림판 놓아둘 자리(메모리)도 먹는다.
- Commit — 다 정한 지시서를 옆 건물 스태프에게 넘기는 일. 종이가 몇 장이냐(레이어 개수)에 비례해 오래 걸린다.
"화면이 버벅인다"는 말만으로는 못 고치는 이유가 여기 있다. 자 재느라 늦은 건데 그림판을 줄이면 아무 소용이 없고, 종이가 너무 많아서 늦은 건데 계산을 줄여 봐야 헛수고다. 그래서 Time Profiler로 어느 칸에서 시간이 새는지부터 찾고 처방을 고른다. 하나 더 짚자면, "나는 drawRect를 안 썼으니 Display는 공짜"라고 생각하기 쉬운데 UILabel의 글자도 결국 누군가 붓으로 칠해 그림판에 올린다. 글자 많은 화면은 drawRect가 없어도 Display에서 시간을 쓴다.
꼬리 질문
Time Profiler에서 CA::Transaction::commit이 유난히 길다면, Layout·Display·Commit 중 무엇을 먼저 의심하고 어떻게 구분하겠는가?
CA::Transaction::commit은 이 세 단계 전체를 감싸는 상위 프레임이라, 그것만 길다는 건 세부 stage 어딘가가 비싸다는 뜻이다. 그 아래 하위 스택을 펼쳐 이름으로 구분한다 — layoutSubviews·오토레이아웃 엔진이 보이면 Layout, -[CALayer display]·Core Graphics(CG…)가 보이면 Display, 레이어 트리 인코딩/직렬화가 대부분이면 Commit 본연의 비용이다.
순서상 먼저 Layout을 의심하는 게 실전적이다. 깊은 뷰 계층·제약 폭증이 가장 흔한 원인이고 View Debugger로 즉시 확인되기 때문이다. Display는 drawRect 오버라이드가 있는 뷰에서만 나타나므로 그런 뷰가 없으면 후순위, Commit 자체가 지배적이면 레이어 개수·백킹스토어 전송량을 본다.
CA::Transaction::commit은 "장면 준비 전체"라고 적힌 큰 상자 이름이다. 그게 길다는 건 상자 안 어딘가가 느리다는 뜻이지, 범인 이름이 아니다. 상자를 열어 안쪽 항목을 봐야 한다 — 자 재기(layoutSubviews)냐, 붓칠(-[CALayer display])이냐, 종이 넘기기냐. 경험상 자 재는 쪽이 제일 자주 범인이고 View Debugger로 바로 확인된다.drawRect를 오버라이드하지 않은 순수 오토레이아웃 화면에서도 Display 단계가 비용을 낼 수 있는가?
낼 수 있다. Display가 앱의 drawRect만 도는 단계는 아니기 때문이다. 시스템 컨트롤이 내부적으로 콘텐츠를 그리는 경우가 대표적이다 — UILabel의 텍스트 조판·렌더, attributedText, CATextLayer는 백킹스토어에 CPU로 래스터화되고 이건 Display 단계의 비용이다.
또 cornerRadius+마스크·그림자·그라디언트 레이어처럼 백킹스토어나 오프스크린을 요구하는 레이어 속성도 이 단계에서 그리기 부담을 만든다. 즉 "내가 drawRect를 안 썼으니 Display는 공짜"라는 가정은 틀리고, 텍스트가 많거나 그리는 콘텐츠가 있는 화면은 여전히 Display에서 시간을 쓴다.
UILabel이 그 일을 대신 해 줄 뿐, 공짜로 생기는 게 아니다. 그러니 drawRect를 한 줄도 안 썼어도 글자가 빽빽한 화면은 Display 칸에서 시간을 쓴다.같은 UI를 셀 하나에 30개 서브뷰 vs 3개 레이어로 구성했을 때, 세 단계 각각의 비용은 어떻게 달라지는가?
Layout: 30개 서브뷰는 각각 프레임·제약을 풀어야 해 오토레이아웃 solver의 제약 수가 급증하고 초선형으로 비싸진다. 3개 레이어는 계산할 노드가 적어 훨씬 싸다. Commit: 커밋은 레이어 트리를 재귀 인코딩해 IPC로 넘기므로 노드가 10배면 인코딩·전송량도 그만큼 늘어 30개 쪽이 확연히 무겁다.
Display는 방식에 따라 갈린다. 30개 서브뷰 중 drawRect나 텍스트 렌더가 있는 뷰만 백킹스토어를 그리므로, 단순 색·모서리 뷰뿐이면 Display 자체는 크지 않을 수 있다. 반대로 3개 레이어에 커스텀 드로잉을 몰아넣으면 오히려 Display가 커질 수도 있다. 일반적으로 서브뷰를 줄여 레이어를 합치는 최적화는 Layout·Commit에서 가장 큰 이득을 준다.