P0 08장이 "프레임이 어떤 단계를 거쳐 표시되는가"와 "60/120Hz 프레임 예산"까지였다면, 이 챕터는 그 아래를 판다. 렌더링을 하는 주체가 우리 앱이 아니라는 사실, 다섯 단계가 동시에 서로 다른 프레임을 처리한다는 사실, 그리고 20장에서 미뤄둔 render hitch가 왜 별도 문제인지를 다룬다. 여기서 나오는 수치는 대부분 Apple 문서에 명시된 값이다.
Q1. 렌더 서버는 왜 별도 프로세스인가?
앱 프로세스는 실제 렌더링을 하지 않는다. 앱은 뷰 계층의 변경을 렌더 서버라는 별도 프로세스에 커밋하고, 렌더 서버가 서버 측 Core Animation으로 그것을 받아 GPU에 그린다. 이 분리가 주는 것이 셋이다. (1) 앱이 멈춰도 애니메이션은 계속 돈다 — 이미 커밋된 애니메이션은 렌더 서버가 자기 시계로 진행시킨다. (2) 파이프라이닝이 가능하다 — 앱이 다음 프레임을 준비하는 동안 렌더 서버가 이전 프레임을 그린다. (3) 시스템이 프레임 예산을 통제한다 — 앱이 아무리 느려도 화면 갱신 자체는 시스템이 관리한다. 대가는 20장에서 본 render hitch다 — 우리 메인스레드가 멀쩡해도 렌더 서버가 늦으면 화면이 끊긴다.
CS 원리
이건 신뢰할 수 없는 클라이언트로부터 공유 자원을 보호하는 고전적 설계다. 디스플레이는 시스템 전체가 공유하는 자원이고, 앱 하나가 그것을 직접 제어하게 두면 그 앱이 멈출 때 화면 전체가 멈춘다.
그래서 대부분의 현대 OS가 컴포지터(compositor)를 별도 프로세스로 둔다 — 각 앱은 "내 화면은 이렇게 생겼다"는 서술만 제출하고, 컴포지터가 그것들을 합성해 최종 화면을 만든다.
iOS에서 그 서술이 레이어 트리다. 앱이 보내는 것은 픽셀이 아니라 "이 위치에 이 크기로 이 콘텐츠를 이 변환으로 놓아라"라는 구조다. 그래서 앱이 커밋을 한 번만 하면, 렌더 서버가 이후 수백 프레임을 앱 없이 진행할 수 있다.
애니메이션이 여기서 특별한 의미를 갖는다. CABasicAnimation을 커밋하는 것은 "0.3초 동안 이 값을 여기서 저기로 바꿔라"라는 명령을 한 번 보내는 것이지, 매 프레임 값을 계산해 보내는 것이 아니다. 그래서 앱이 그 사이에 블로킹돼도 애니메이션은 계속 돈다.
이 구조의 대가는 책임 분산이다. 화면이 끊겼을 때 원인이 두 곳일 수 있고, 어느 쪽인지 구분하지 않으면 엉뚱한 곳을 최적화하게 된다. 20장 Q1에서 본 commit hitch와 render hitch의 구분이 여기서 나온다.
iOS에서는
렌더 서버는 backboardd 안의 서브시스템으로 동작한다고 알려져 있다 (구현 세부 — Apple 미문서화, WWDC 세션과 커뮤니티 관측 기반). 우리가 의존해야 할 것은 프로세스 이름이 아니라 "별도 프로세스가 그린다"는 사실이다. 앱과의 관계에서 실무적으로 중요한 사실이 넷이다.
(1) 커밋은 런루프 틱에 암묵적으로 일어난다. 우리가 view.frame = …을 바꾸면 즉시 전송되는 것이 아니라, 트랜잭션에 모였다가 런루프가 한 바퀴 돌 때 플러시된다. 20장 Q2에서 본 런루프가 여기서도 중심이다. 명시적으로 보내려면 CATransaction.flush()를 쓸 수 있지만, 일반적인 코드에서는 필요 없다.
(2) 애니메이션은 앱 없이 진행된다. Core Animation 애니메이션을 걸어두면 렌더 서버가 매 프레임 보간해 그린다. 그래서 메인스레드가 200ms 막혀도 스피너는 계속 돈다. 반대로 말하면 "스피너가 도니까 앱이 살아 있다"는 판단은 틀렸다 — 20장의 행이 진행 중일 수 있다.
(3) 레이어의 실제 상태는 두 벌이다. 앱 쪽의 모델 레이어(layer)와 렌더 서버 쪽의 표시 상태가 있고, 애니메이션 중에는 둘이 다르다. layer.presentation()이 "지금 화면에 보이는 값"을 근사해 알려준다.
(4) 렌더 서버가 하는 일의 양은 앱이 정한다. 20장 Q1 꼬리질문에서 본 대로 — 별개 프로세스지만 앱을 대신해서 일하고, 그 작업이 너무 복잡하면 제때 못 끝낸다. 레이어 개수, 투명도, 마스크, 그림자, 블러가 전부 렌더 서버의 부하다.
실험 · 도구
"앱이 멈춰도 애니메이션은 돈다"를 직접 확인하면 구조가 손에 잡힌다.
import UIKit
final class RenderServerDemoViewController: UIViewController {
private let spinner = UIActivityIndicatorView(style: .large)
private let box = UIView()
override func viewDidLoad() {
super.viewDidLoad()
spinner.startAnimating() // Core Animation 애니메이션
// 레이어에 직접 애니메이션을 건다 — 커밋 후 렌더 서버가 스스로 진행시킨다
let rotate = CABasicAnimation(keyPath: "transform.rotation.z")
rotate.fromValue = 0
rotate.toValue = Double.pi * 2
rotate.duration = 2
rotate.repeatCount = .infinity
box.layer.add(rotate, forKey: "spin")
}
@IBAction func blockMainThread() {
// 메인스레드를 3초 완전히 막는다
let deadline = ContinuousClock.now + .seconds(3)
var x = 0
while ContinuousClock.now < deadline { x &+= 1 }
_ = x
// 관찰 결과:
// ✅ 스피너와 회전 애니메이션은 계속 돈다 (렌더 서버가 진행)
// ❌ 터치·스크롤은 전혀 반응하지 않는다 (메인 런루프가 막힘)
// → "애니메이션이 도니 앱이 살아 있다"는 판단이 왜 틀리는지 보인다
}
/// 모델 레이어와 표시 상태의 차이
func inspectLayerState() {
print("모델 값:", box.layer.transform) // 최종 목표 값
print("표시 값:", box.layer.presentation()?.transform) // 지금 화면에 보이는 값
// 애니메이션 중에는 둘이 다르다.
// 히트 테스트는 모델 값 기준이라 "보이는 곳"을 눌러도 안 먹는 이유가 이것이다.
}
}
# 렌더 서버 쪽 작업을 관찰한다 — Instruments
# Animation Hitches 템플릿을 열면 commit / render 단계가 분리되어 보인다
# 앱 트랙과 렌더 서버 트랙이 따로 표시되므로 어느 쪽이 늦는지 바로 안다
# 레이어 구성을 실시간으로 진단한다 — 시뮬레이터 Debug 메뉴
# Color Blended Layers : 반투명 합성이 겹친 곳 (빨강일수록 비쌈)
# Color Offscreen-Rendered : 오프스크린 렌더링 발생 지점 (Q4)
# Color Misaligned Images : 픽셀 정렬이 어긋나 재샘플링되는 이미지
# Color Copied Images : GPU가 못 쓰는 포맷이라 CPU가 변환한 이미지
프로젝트 적용
이 구조를 알면 판단이 바뀌는 지점 넷.
- 진단의 첫 질문을 "어느 프로세스인가"로 둔다. 스크롤이 끊길 때 메인스레드부터 보는 것이 습관인데, Animation Hitches로 commit/render를 먼저 구분하면 헛수고를 피한다. 20장 Q1 꼬리질문에서 본 대로 render hitch는 최적화 방향이 완전히 다르다.
- 애니메이션 존재를 앱 생존 신호로 쓰지 않는다. 사용자 제보 중 "화면은 움직이는데 아무것도 안 눌려요"가 정확히 이 상황이다 — 행이다(20장).
- Core Animation에 맡길 수 있는 것은 맡긴다. 타이머로 매 프레임 값을 바꾸는 대신 애니메이션을 걸면, 메인스레드 부하가 사라지고 앱이 잠깐 막혀도 부드럽게 유지된다.
- 레이어 복잡도를 예산으로 관리한다. 렌더 서버의 부하는 우리가 정한다. 특히 셀 하나의 레이어 수는 스크롤 성능에 직접 반영된다.
"우리 앱이 화면을 그린다"가 가장 근본적인 오해다. 앱은 레이어 트리라는 서술을 커밋할 뿐이고 실제 렌더링은 별도 프로세스가 한다. "메인스레드가 멀쩡하면 화면도 부드럽다"도 틀렸다 — render hitch는 메인스레드와 무관하다. "애니메이션이 도니까 앱이 반응 중"도 틀렸다 — 이미 커밋된 애니메이션은 앱이 막혀도 계속 돈다. "렌더 서버는 별개 프로세스니 우리가 통제할 수 없다"도 반쪽이다 — 작업량을 정하는 것은 앱이다. 또 layer.frame을 읽어 애니메이션 중 위치를 판단하는 것도 틀렸다 — 그건 모델 값이고 화면에 보이는 값은 presentation()이다.
극장 무대를 떠올려보자. 연출가(우리 앱)와 무대 팀(렌더 서버)이 따로 있다.
연출가는 직접 조명을 켜거나 배경을 옮기지 않는다. 대신 "3번 조명을 2초에 걸쳐 어둡게, 배경은 왼쪽으로"라는 지시서를 무대 팀에 넘긴다. 실제로 움직이는 건 무대 팀이다.
여기서 재밌는 일이 생긴다. 연출가가 대본을 읽다가 3초간 멍하니 있어도, 무대 팀은 이미 받은 지시서대로 조명을 계속 어둡게 하고 있다. 관객이 보기엔 공연이 잘 돌아간다.
그런데 이때 배우가 대사를 물어봐도 연출가는 대답을 못 한다. 무대는 움직이는데 지시는 안 나가는 상태다. 이게 "애니메이션은 도는데 터치가 안 되는" 상황이다.
그리고 반대 상황도 있다. 연출가는 멀쩡히 지시를 내리는데, 지시가 너무 복잡해서 무대 팀이 못 따라가는 경우다. "조명 200개를 각각 다른 색으로, 배경은 반투명 여덟 겹으로." 연출가 탓이 아니라고 하기도 어렵다 — 그 지시를 쓴 사람이 연출가니까.
꼬리 질문
애니메이션 중 터치가 "보이는 위치"에서 안 먹는 이유는?
히트 테스트가 모델 레이어 기준으로 이뤄지기 때문이다. 애니메이션은 렌더 서버가 진행시키고 화면에는 중간 값이 보이지만, 앱 프로세스의 layer.frame은 이미 최종 값이다(또는 애니메이션 시작 전 값이다).
그래서 버튼이 화면 왼쪽에서 오른쪽으로 이동하는 중에 사용자가 보이는 위치를 누르면, 앱은 모델상의 위치로 판정해 "아무것도 없다"고 결론 낸다. 반대로 목적지 위치를 미리 누르면 반응한다 — 사용자 입장에서는 매우 이상한 동작이다.
해결 방법이 셋 있다.
(1) 애니메이션 중 상호작용을 막는다. 가장 단순하고 대부분의 경우 옳다. 이동 중인 버튼을 누를 수 있어야 할 이유가 대개 없다.
(2) presentation() 기준으로 히트 테스트를 재정의한다. hitTest(_:with:)를 오버라이드해 layer.presentation()?.hitTest(point)로 판정한다. 정확하지만 코드가 늘고 성능 비용이 있다.
(3) UIView.animate의 .allowUserInteraction 옵션. UIKit 애니메이션에서는 이 옵션으로 상호작용을 허용할 수 있는데, 여전히 히트 테스트는 모델 기준이라 근본 해결은 아니다.
실무에서는 (1)이 기본이고, 드래그 가능한 UI처럼 정말 필요한 곳에만 (2)를 쓴다.
렌더 서버가 별도 프로세스라 생기는 보안·안정성 이점은?
세 가지가 있다.
(1) 앱 크래시가 화면을 망가뜨리지 않는다. 앱이 죽어도 렌더 서버는 살아 있으므로 시스템 UI와 다른 앱은 정상이다. 만약 각 앱이 디스플레이를 직접 제어한다면 앱 하나의 버그가 화면 전체를 깨뜨릴 수 있다.
(2) 앱이 다른 앱의 화면 내용을 볼 수 없다. 합성이 렌더 서버 안에서 일어나므로 앱은 자기 레이어 트리만 안다. 이게 P0 03장의 샌드박스가 화면 영역까지 확장된 형태다. 스크린샷을 찍는 API가 제한적인 것도 같은 이유다.
(3) 시스템이 우선순위를 통제한다. 렌더 서버가 자원 배분을 결정하므로, 앱 하나가 GPU를 독점해 시스템 UI를 멈추게 할 수 없다. 24장 Q7에서 본 "익스텐션은 GPU 우선순위가 낮다"도 이 통제의 일부다.
대가는 IPC 비용이다. 레이어 트리를 프로세스 경계 너머로 보내야 하므로 커밋에 비용이 든다. 그래서 레이어 수가 많거나 매 프레임 구조가 바뀌면 커밋 자체가 무거워진다 — Q3에서 다룰 내용이다.
설계 관점에서 보면 격리의 비용을 지불하고 안정성과 보안을 얻은 전형적 거래다. 24장에서 본 익스텐션 모델과 같은 계열의 결정이다.
타이머로 매 프레임 값을 바꾸는 것과 Core Animation에 맡기는 것은 무엇이 다른가?
부하가 걸리는 프로세스가 다르다.
타이머 방식(CADisplayLink로 매 프레임 frame을 갱신)은 프레임마다 메인스레드에서 값을 계산하고 커밋한다. 초당 120번 런루프를 깨우고, 120번 커밋이 일어난다. 메인스레드가 조금이라도 바쁘면 즉시 프레임을 놓친다.
Core Animation 방식은 한 번 커밋하고 끝이다. 렌더 서버가 자기 시계로 보간하므로 메인스레드는 그 동안 자유롭다. 앱이 200ms 막혀도 애니메이션은 부드럽다.
그래서 기본 선택은 Core Animation이다. 타이머 방식이 필요한 경우는 제한적이다.
- 값이 외부 입력에 따라 매 프레임 달라질 때 — 손가락을 따라가는 드래그, 센서 값 반영.
- Core Animation이 지원하지 않는 속성을 애니메이션할 때.
- 물리 시뮬레이션처럼 이전 프레임 결과가 다음 프레임 입력이 될 때.
이때도 계산을 최소화하고 커밋을 가볍게 유지해야 한다. 그리고 20장 Q2 꼬리질문에서 본 대로 CADisplayLink를 끝난 뒤 무효화하지 않으면 계속 런루프를 깨워 배터리를 먹는다.
참고로 UIViewPropertyAnimator는 중간 지점이다 — Core Animation 기반이면서 진행률을 제어할 수 있어, 제스처 연동 애니메이션에 적합하다.
Q2. 렌더 루프 다섯 단계는 어떻게 겹쳐서 돌아가는가?
Apple이 정의하는 렌더 루프는 Event → Commit → Render Prepare → Render Execute → Display 다섯 단계다. 핵심은 각 단계가 한 번에 하나의 프레임만 처리하지만, 모든 단계가 서로 다른 프레임을 두고 동시에 일한다는 것이다 — 공장의 조립 라인과 같다. 그래서 총 지연은 여러 프레임에 걸치지만 처리량은 프레임마다 하나가 된다. 예산 계산이 여기서 중요해진다 — 120Hz라면 화면은 8.3ms마다 갱신되고, 각 단계가 각각 8.3ms를 받는다. 총합 8.3ms가 아니다. 그리고 어느 한 단계라도 마감을 놓치면 프레임이 늦고, 그게 히치다.
CS 원리
이건 파이프라이닝이다. CPU 명령어 파이프라인과 원리가 같다 — 한 작업을 여러 단계로 쪼개고, 각 단계가 서로 다른 작업을 동시에 처리하게 한다.
파이프라이닝이 주는 것과 뺏는 것이 명확하다.
| 파이프라인 없음 | 파이프라인 있음 | |
|---|---|---|
| 한 프레임의 총 지연 | 짧음 (단계 합) | 길어짐 (여러 프레임에 걸침) |
| 처리량 | 낮음 (한 번에 하나) | 높음 (프레임마다 하나) |
| 각 단계 예산 | 총 예산을 나눠 씀 | 각각 전체 예산 |
| 병목의 영향 | 전체가 느려짐 | 한 단계만 늦어도 라인 전체가 밀림 |
세 번째 행이 실무적으로 가장 중요하다. 단계마다 프레임 하나 분량의 시간을 받는다는 뜻이므로, "메인스레드에서 8.3ms를 쓰면 렌더링할 시간이 없다"가 아니라 "메인스레드가 8.3ms를 쓰는 것 자체는 정상 예산 안"이다. 다만 그 예산을 넘기면 문제가 된다.
네 번째 행이 히치의 구조다. 조립 라인에서 한 공정이 늦으면 그 뒤 공정들이 전부 밀린다. 그래서 한 프레임의 지연이 여러 프레임의 지연으로 번진다 — Apple 문서가 "늦은 프레임 하나가 이후 프레임들을 건너뛰게 만드는 경우가 많다"고 설명하는 이유다.
iOS에서는
각 단계에서 무슨 일이 일어나고 누가 책임지는지를 정리하면 이렇다.
| 단계 | 하는 일 | 주체 | 늦으면 |
|---|---|---|---|
| Event | 터치 이벤트를 처리하고 UI 변경이 필요한지 판단 | 앱 메인스레드 | 행 · commit hitch |
| Commit | UI를 갱신하고 렌더 서버에 제출 | 앱 메인스레드 | commit hitch |
| Render Prepare | 다음 VSYNC에 렌더 서버가 GPU 드로잉을 준비 | 렌더 서버 | render hitch |
| Render Execute | GPU가 최종 이미지를 그림 | GPU | render hitch |
| Display | 다음 VSYNC에 화면에 표시 | 디스플레이 | — |
앞의 두 단계가 우리 코드이고, 뒤의 두 단계가 렌더 서버·GPU다. 20장 Q1에서 본 commit/render 구분이 정확히 이 경계다.
리프레시 레이트가 가변이라는 점도 중요하다. Apple 문서가 명시하듯 사용자 상호작용 중에는 기기가 지원하는 최대 리프레시 레이트로 갱신하고, 표시할 새 내용이 없으면 일부 디스플레이는 레이트를 낮춘다. 그래서 다음이 따라온다.
- 예산이 상황마다 다르다. 스크롤 중에는 120Hz라 8.3ms이지만, 정지 상태에서는 더 여유가 있을 수 있다. 가장 빡빡한 순간(상호작용 중)을 기준으로 설계해야 한다.
- 측정 시 리프레시 레이트를 함께 봐야 한다. 유휴 구간이 섞이면 평균 프레임 시간이 왜곡된다(20장 Q1 꼬리질문).
그리고 렌더 서버도 앱을 대신해 일한다는 점을 다시 짚어야 한다 — Apple 문서 표현대로 별개 프로세스여도 앱을 위해 작업하며, 그 작업이 너무 복잡하면 제때 못 끝낸다. 그래서 히치를 찾고 이해하려면 메인스레드의 작업과 렌더 서버의 작업을 둘 다 봐야 한다.
실험 · 도구
프레임 간격은 CADisplayLink로 직접 관찰할 수 있다. 단계별 분해는 Instruments가 필요하다 — 아래 코드가 재는 것은 프레임 간격이지 각 단계의 소요 시간이 아니다.
import UIKit
import QuartzCore
import OSLog
/// 커밋 구간과 실제 표시 시점을 각각 관찰한다
final class RenderLoopProbe {
private let log = Logger(subsystem: "com.example.shop", category: "render")
private var link: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
/// (1) 프레임 간격 관찰 — Display 단계의 실제 주기
func startFrameProbe() {
let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
link.add(to: .main, forMode: .common) // 스크롤 중에도 돌게 (20장 Q2)
self.link = link
}
@objc private func tick(_ link: CADisplayLink) {
defer { lastTimestamp = link.timestamp }
guard lastTimestamp != 0 else { return }
let delta = (link.timestamp - lastTimestamp) * 1000 // ms
// targetTimestamp - timestamp 가 이번 프레임에 허용된 예산이다
let budget = (link.targetTimestamp - link.timestamp) * 1000
if delta > budget * 1.5 {
log.notice("프레임 지연: \(delta, privacy: .public)ms (예산 \(budget, privacy: .public)ms)")
}
}
/// (2) 커밋에 건 애니메이션이 끝나는 시점 관찰
///
/// ⚠️ CATransaction.setCompletionBlock 은 "이 트랜잭션에 추가된 애니메이션이
/// 모두 끝났을 때" 불린다. 애니메이션이 없으면 **즉시** 실행되며,
/// 렌더 서버 도착이나 화면 표시 시점과는 무관하다.
/// 커밋 구간 자체의 소요 시간은 Instruments 의 Commit 트랙으로 봐야 한다.
func measureAnimationCompletion(_ work: () -> Void) {
let start = CACurrentMediaTime()
CATransaction.begin()
CATransaction.setCompletionBlock {
let elapsed = (CACurrentMediaTime() - start) * 1000
self.log.notice("애니메이션 완료까지 \(elapsed, privacy: .public)ms")
}
work()
CATransaction.commit()
}
}
CADisplayLink의 targetTimestamp - timestamp가 이번 프레임의 실제 예산이라는 점이 유용하다. 리프레시 레이트가 가변이므로 하드코딩한 16.67ms나 8.3ms 대신 이 값을 쓰면 상황에 맞는 판정이 된다.
# 단계별 분해는 Instruments가 가장 정확하다
# Animation Hitches 계측기:
# - Commit 트랙 (앱 메인스레드)
# - Render 트랙 (렌더 서버 + GPU)
# - 히치가 발생한 프레임과 그 원인 단계가 색으로 구분된다
#
# 함께 켜면 좋은 것:
# - Time Profiler : commit이 늦을 때 메인스레드의 어느 코드가 원인인지
# - Core Animation : 레이어 수, 오프스크린 렌더링 (Q4)
# - Metal/GPU : render execute가 늦을 때 GPU 부하
# 기기에서 실시간 프레임률 확인 (개발 중)
# 설정 › 개발자 › (그래픽 관련 항목) 또는
# 시뮬레이터 Debug › Color Blended Layers 등으로 간접 확인
프로젝트 적용
파이프라인 구조를 알면 달라지는 판단 넷.
- 예산을 단계별로 잡는다. "프레임당 8.3ms"가 아니라 "커밋에 8.3ms, 렌더에 8.3ms"다. 커밋이 6ms 걸린다고 즉시 문제인 것은 아니지만, 예산의 70%를 쓰고 있으므로 여유가 없다.
- 가장 빡빡한 순간을 기준으로 한다. 상호작용 중에는 최대 리프레시 레이트로 돌므로 예산이 가장 작다. 스크롤 중 실행되는 코드가 언제나 1순위 최적화 대상이다(20장 Q1).
- 지연을 프레임 단위로 생각한다. "몇 ms 늦었나"보다 "몇 프레임 밀렸나"가 사용자 체감에 가깝다. 한 프레임 지연도 연속 상호작용에서는 보인다.
- 단계를 구분해 측정한다. 구분 없이 "느리다"만 알면 최적화 방향을 못 정한다. Animation Hitches의 commit/render 분리가 첫 단계다.
"120Hz면 모든 작업을 8.3ms 안에 끝내야 한다"는 틀렸다. 각 단계가 각각 8.3ms를 받는다 — 파이프라인이라 동시에 진행되기 때문이다. "프레임 하나가 늦어도 하나만 늦다"도 틀렸다 — 뒤 단계들이 밀려 여러 프레임에 영향을 준다. "프레임 예산은 항상 16.67ms 또는 8.3ms"도 부정확하다 — 리프레시 레이트가 가변이라 상황마다 다르고, CADisplayLink의 targetTimestamp로 실제 값을 알 수 있다. 또 "렌더 서버가 늦는 건 우리가 어쩔 수 없다"도 틀렸다 — 렌더 서버의 작업량을 정하는 것은 앱이다.
김밥 공장을 떠올려보자. 공정이 다섯 개다. 재료 준비 → 밥 펴기 → 재료 올리기 → 말기 → 썰기.
여기서 중요한 건 다섯 사람이 동시에 일한다는 것이다. 1번 김밥을 썰고 있을 때, 2번 김밥은 말고 있고, 3번 김밥은 재료를 올리는 중이다.
그래서 김밥 하나가 완성되기까지는 다섯 공정 시간이 다 걸리지만, 완성품은 한 공정 시간마다 하나씩 나온다. 이게 파이프라인이다.
여기서 자주 하는 착각 — "김밥 하나에 10초니까 각 공정은 2초 안에 끝내야겠네". 아니다. 각 공정이 10초씩 써도 된다. 동시에 돌아가니까 완성품은 여전히 10초마다 하나씩 나온다.
대신 무서운 게 있다. 한 사람이 12초를 쓰면 어떻게 될까? 그 사람 뒤의 공정들이 전부 2초씩 기다린다. 그리고 앞사람이 만든 김밥이 쌓이기 시작한다. 한 명이 늦은 것이 라인 전체를 흔든다.
마지막으로 — 주문이 몰릴 때는 라인 속도를 올린다. 그러면 각 공정에 주어진 시간이 짧아진다. 그래서 가장 바쁠 때를 기준으로 준비해야 한다.
꼬리 질문
CADisplayLink의 targetTimestamp는 정확히 무엇을 뜻하는가?
timestamp는 직전 프레임이 표시된 시각이고, targetTimestamp는 지금 준비하는 프레임이 표시될 예정 시각이다. 그래서 둘의 차이가 이번 프레임에 허용된 시간이다.
이 값이 유용한 이유가 셋이다.
(1) 가변 리프레시 레이트에 자동 대응한다. 60Hz면 약 16.67ms, 120Hz면 약 8.3ms가 자동으로 나온다. 저전력 모드나 유휴 상태에서 레이트가 떨어져도 값이 따라간다. 하드코딩보다 항상 정확하다.
(2) 남은 시간을 계산할 수 있다. 프레임 작업 중간에 targetTimestamp - CACurrentMediaTime()을 보면 마감까지 얼마 남았는지 알 수 있다. 남은 시간이 부족하면 이번 프레임에서는 작업을 줄이는 적응형 렌더링이 가능해진다.
(3) 애니메이션 값 계산의 기준. 직접 애니메이션을 구현할 때 timestamp가 아니라 targetTimestamp를 기준으로 값을 계산해야 한다. 우리가 지금 만드는 프레임은 미래에 표시될 것이기 때문이다. timestamp를 쓰면 항상 한 프레임 뒤처진다.
@objc func tick(_ link: CADisplayLink) {
// ✅ 표시될 시점 기준으로 계산해야 정확하다
let t = link.targetTimestamp - startTime
layer.position.x = interpolate(at: t)
// 남은 예산을 보고 작업량을 조절할 수도 있다
let remaining = link.targetTimestamp - CACurrentMediaTime()
if remaining < 0.002 { skipExpensiveDetail() }
}프레임이 하나 늦으면 왜 여러 프레임이 영향을 받는가?
파이프라인이 동기화된 리듬으로 돌기 때문이다. 각 단계는 VSYNC에 맞춰 다음 단계로 넘기는데, 한 단계가 마감을 넘기면 그 넘김 시점을 놓친다. 그러면 다음 VSYNC까지 기다려야 하고, 그 사이 앞 단계에서 만든 프레임은 갈 곳이 없어 대기한다.
구체적 연쇄는 이렇다.
- 커밋이 마감을 2ms 넘겼다.
- 렌더 서버는 이번 VSYNC에 받을 것이 없어 이전 프레임을 다시 표시한다(프레임 반복).
- 늦은 커밋은 다음 VSYNC에 처리되고, 그 뒤 단계들이 한 칸씩 밀린다.
- 그 사이 앱은 다음 프레임을 이미 준비했는데 라인이 밀려 있어 추가 지연이 생긴다.
Apple 문서도 이를 명시한다 — 늦은 프레임으로 인한 지연은 종종 시스템이 이후 하나 이상의 프레임을 건너뛰게 만들고, 그래서 이 현상을 프레임 드롭이라고도 부른다. 다만 프레임 드롭은 시스템의 대응 중 하나일 뿐이고, 모든 히치가 프레임 드롭을 유발하지는 않는다는 단서도 함께 붙는다.
실무적 함의는 "가끔 한 번 늦는 것"의 비용이 생각보다 크다는 것이다. 평균 프레임 시간이 좋아도 가끔의 큰 지연이 연쇄를 만들어 여러 프레임을 망친다 — 20장 Q1 꼬리질문에서 본 "평균은 히치를 숨긴다"의 메커니즘이 이것이다.
리프레시 레이트가 가변이라는 사실이 애니메이션 구현에 주는 영향은?
세 가지를 조심해야 한다.
(1) 프레임 수로 시간을 세면 안 된다. "60프레임 동안 이동" 같은 코드는 120Hz에서 절반 시간에 끝난다. 반드시 시각 기반으로 계산해야 한다 — targetTimestamp나 CACurrentMediaTime()을 쓴다.
(2) 프레임당 증분을 고정하면 안 된다. x += 2 방식은 레이트가 바뀌면 속도가 달라진다. x = start + velocity * elapsed처럼 경과 시간에 비례하게 계산한다.
(3) 원하는 레이트를 요청할 수 있다. CADisplayLink.preferredFrameRateRange로 최소·최대·선호 레이트를 지정할 수 있다. 고주사율이 필요 없는 애니메이션이라면 낮은 레이트를 요청해 전력을 아낄 수 있다.
let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
// 30~60fps면 충분한 애니메이션이라면 굳이 120fps를 쓰지 않는다
link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
link.add(to: .main, forMode: .common)그리고 Core Animation에 맡기면 이 문제가 대부분 사라진다(Q1 꼬리질문). 렌더 서버가 실제 레이트에 맞춰 보간하므로 우리가 신경 쓸 필요가 없다. 직접 프레임을 그리는 경우에만 위 사항들이 문제가 된다.
측정 관점에서도 영향이 있다 — 20장 Q1에서 본 대로 유휴 구간의 낮은 레이트가 섞이면 평균이 왜곡되므로, 성능 지표는 상호작용 구간으로 한정해 보는 것이 정확하다.
Q3. CA commit의 네 단계에서 무엇이 일어나는가?
커밋 트랜잭션은 Layout → Display → Prepare → Commit 네 단계로 이뤄진다. Layout에서 오토레이아웃 해를 구하고 layoutSubviews가 불린다. Display에서 draw(_:)가 실행되어 CPU가 비트맵을 그린다. Prepare에서 이미지 디코딩·GPU 업로드 준비 같은 부가 작업이 일어난다. Commit에서 레이어 트리를 렌더 서버로 보낸다. 실무적으로 중요한 것은 Layout과 Display가 메인스레드의 CPU 시간을 먹는다는 점이다 — 20장에서 본 commit hitch의 실체가 대개 이 둘이다. 특히 draw(_:)를 오버라이드하면 CPU가 픽셀을 그리고, 이미지 디코딩은 Prepare에서 메인스레드를 잡는다(P0 08장 Q4).
CS 원리
커밋이 여러 단계로 나뉘는 이유는 의존 관계 때문이다. 각 단계의 결과가 다음 단계의 입력이라 순서를 지켜야 한다.
- 크기와 위치가 정해져야 무엇을 그릴지 안다 → Layout이 먼저.
- 그려질 내용이 정해져야 GPU에 올릴 것을 준비할 수 있다 → Display가 그다음.
- 모든 준비가 끝나야 일관된 상태로 전송할 수 있다 → Commit이 마지막.
여기서 트랜잭션이라는 이름의 의미가 나온다 — 여러 변경을 모아 원자적으로 적용한다. 뷰 열 개의 위치를 바꾸면 열 번 전송되는 것이 아니라 한 번에 간다. 그래서 화면에 중간 상태가 보이지 않는다.
P0 09장에서 본 데이터베이스 트랜잭션과 성질이 비슷하다 — 일관성을 위해 변경을 묶고, 커밋 시점에 한꺼번에 반영한다.
비용 구조에서 중요한 비대칭이 하나 있다. Layout과 Display는 CPU 작업이고 앱 프로세스에서 일어난다. 즉 메인스레드 예산을 직접 먹는다. 반면 실제 렌더링은 렌더 서버와 GPU가 하므로 우리 예산 밖이다. 그래서 커밋 비용을 줄이는 것과 렌더 비용을 줄이는 것은 다른 작업이다.
iOS에서는
단계별로 우리 코드가 개입하는 지점과 비용 요인을 정리하면 이렇다.
| 단계 | 불리는 것 | 비용 요인 | 줄이는 법 |
|---|---|---|---|
| Layout | layoutSubviews, 오토레이아웃 해 찾기 | 제약 개수, 중첩 깊이 | 제약 단순화, 셀 높이 캐시 |
| Display | draw(_:), CALayer.display() | CPU 래스터라이즈 | draw 오버라이드 회피, 이미지·레이어 속성 활용 |
| Prepare | 이미지 디코딩, GPU 업로드 준비 | 원본 이미지 디코딩 | 다운샘플링, 사전 디코딩 |
| Commit | 레이어 트리 직렬화·전송 | 레이어 개수, 구조 변경량 | 레이어 수 줄이기, 구조 재사용 |
Display 단계의 draw(_:)가 특히 오해가 많다. draw(_:)를 오버라이드하면 시스템이 백킹 스토어(비트맵)를 할당하고 CPU로 그린다. 이건 두 가지 비용이다 — 메모리(뷰 크기 × 4바이트)와 CPU 시간. 같은 결과를 layer.cornerRadius나 이미지로 얻을 수 있다면 그쪽이 압도적으로 싸다.
Prepare 단계의 이미지 디코딩이 P0 08장 Q4에서 본 "이미지 디코드가 스크롤을 끊는" 현상의 정확한 자리다. UIImage(named:)는 파일을 읽을 뿐 디코딩을 하지 않고, 실제로 화면에 그려질 때 디코딩이 일어난다 — 그게 이 단계다. 그래서 "이미지를 미리 로드했는데 왜 스크롤이 끊기나"의 답이 여기 있다.
해법은 백그라운드에서 미리 디코딩하는 것이다.
import UIKit
import ImageIO
/// 백그라운드에서 디코딩까지 끝낸 이미지를 만든다 — Prepare 단계 비용을 옮긴다
func decodedImage(at url: URL, maxPixel: Int) async -> UIImage? {
await Task.detached(priority: .userInitiated) {
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // ← 여기서 디코딩까지 끝낸다
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, options) else { return nil }
return UIImage(cgImage: cg)
}.value
}
// 셀에서는 이미 디코딩된 이미지를 붙이기만 한다
cell.imageView.image = await decodedImage(at: url, maxPixel: 600)
// → Prepare 단계에서 할 일이 없어 커밋이 가벼워진다
kCGImageSourceShouldCacheImmediately: true가 핵심이다 — 이 옵션이 썸네일 생성 시점에 디코딩까지 완료시키므로, 메인스레드의 Prepare 단계에서 할 일이 사라진다.
실험 · 도구
각 단계의 비용은 layoutSubviews·draw(_:)를 직접 감싸 재고, 나머지는 계측기로 본다.
import UIKit
import OSLog
/// Layout / Display 단계에 우리가 얼마나 쓰는지 직접 계측한다
final class InstrumentedView: UIView {
private static let log = Logger(subsystem: "com.example.shop", category: "commit")
override func layoutSubviews() {
let t0 = CACurrentMediaTime()
super.layoutSubviews()
let ms = (CACurrentMediaTime() - t0) * 1000
if ms > 2 { // 프레임 예산의 1/4 이상이면 경고
Self.log.notice("layoutSubviews \(ms, privacy: .public)ms — \(String(describing: type(of: self)), privacy: .public)")
}
}
override func draw(_ rect: CGRect) {
let t0 = CACurrentMediaTime()
super.draw(rect)
// 여기서 CPU가 픽셀을 그린다. 백킹 스토어도 할당돼 있다.
Self.log.notice("draw \((CACurrentMediaTime() - t0) * 1000, privacy: .public)ms size=\(NSCoder.string(for: rect.size), privacy: .public)")
}
}
# Instruments — Core Animation / Animation Hitches 계측기
# Commit 트랙을 열면 Layout / Display 하위 구간이 분리되어 보인다
# 어느 뷰의 layoutSubviews가 오래 걸리는지까지 추적된다
# 시뮬레이터 Debug 메뉴 — 시각적 진단
# Color Copied Images : GPU가 직접 못 쓰는 포맷이라 CPU가 변환한 이미지
# → Prepare 단계 비용. 포맷을 맞추면 사라진다
# Color Misaligned Images : 픽셀 정렬이 어긋나 재샘플링되는 이미지
# → frame을 정수 픽셀에 맞추면 사라진다
# Color Offscreen-Rendered : 오프스크린 렌더링 (Q4)
# 백킹 스토어 메모리 확인 — draw(_:) 오버라이드가 많으면 여기서 드러난다
# Instruments › Allocations › VM Tracker 에서 IOSurface / CoreAnimation 항목
Color Copied Images가 특히 유용한 진단이다. 서버에서 받은 이미지의 색 공간이나 비트 포맷이 GPU가 직접 쓸 수 없는 형태면, CPU가 매번 변환한다. 이 비용이 Prepare 단계에 그대로 얹힌다. 사전 디코딩 시 표준 포맷으로 정규화하면 사라진다.
프로젝트 적용
커밋 비용을 줄이는 실무 규칙 다섯.
draw(_:)오버라이드를 기본적으로 피한다. 둥근 모서리·테두리·그림자는 레이어 속성으로, 복잡한 도형은CAShapeLayer로, 그라디언트는CAGradientLayer로 처리한다. 이들은 렌더 서버가 그리므로 메인스레드 예산을 안 먹는다.- 이미지는 백그라운드에서 디코딩까지 끝낸다. 셀에 붙이는 시점에는 준비된 비트맵이어야 한다.
- 셀 높이를 캐시한다. 오토레이아웃으로 매번 높이를 계산하면 Layout 단계가 무거워진다. 특히 스크롤 중에는 프레임 예산을 직접 잡아먹는다.
- 레이어 수를 예산으로 관리한다. 셀 하나에 레이어 30개면 Commit 단계의 직렬화 비용이 는다. 배경·구분선 같은 것은 그리기보다 레이어가 낫지만, 레이어 수가 과하면 반대가 된다.
- 구조 변경을 최소화한다. 매 프레임 뷰를 추가·제거하면 레이어 트리 구조가 바뀌어 커밋이 무거워진다. 재사용하고 속성만 바꾸는 편이 훨씬 싸다.
"UIImage(named:)로 미리 로드하면 디코딩도 끝난다"가 대표적 오해다. 디코딩은 실제로 그려질 때(Prepare 단계) 일어난다. "draw(_:)로 직접 그리는 게 레이어보다 빠르다"도 대체로 틀렸다 — draw는 CPU가 그리고 백킹 스토어 메모리도 쓴다. "커밋은 그냥 전송이라 비용이 없다"도 틀렸다 — Layout과 Display가 커밋 트랜잭션 안이고 메인스레드 CPU를 먹는다. "레이어를 많이 쓰면 GPU가 다 해주니 괜찮다"도 반쪽이다 — 레이어 트리 직렬화 비용이 커밋에 얹힌다. 또 이미지 포맷이 안 맞으면 CPU가 매번 변환한다는 사실도 놓치기 쉽다.
택배를 부치는 과정을 생각해보자. 물건을 상자에 넣어 접수창구에 넘기는 것이 커밋이다. 그런데 그 전에 준비가 있다.
- Layout — 상자 크기 정하기. 물건이 몇 개고 어떻게 배치할지 정해야 상자 크기가 나온다. 물건이 많고 규칙이 복잡할수록 오래 걸린다.
- Display — 직접 그림 그리기. 포장지에 손으로 그림을 그리는 것. 내가 직접 해야 하고 시간이 든다. 스티커를 붙이면 훨씬 빠른데 굳이 그리는 경우가 많다.
- Prepare — 물건 꺼내 펴기. 압축포장된 물건을 펴서 넣어야 한다. 이게 이미지 디코딩이다. "미리 사뒀으니 준비됐다"고 생각하지만, 포장은 아직 안 뜯었다.
- Commit — 창구에 넘기기. 여기서 비로소 내 손을 떠난다.
중요한 건 앞의 세 단계가 전부 내 시간이라는 점이다. 창구 직원이 배송하는 건 내 시간이 아니지만, 포장하고 그리고 펴는 건 내가 한다.
그래서 시간을 아끼려면 — 그림 대신 스티커를 쓰고(레이어 속성), 포장은 미리 다른 데서 뜯어두고(백그라운드 디코딩), 상자 크기는 기억해뒀다 재사용(높이 캐시)하면 된다.
꼬리 질문
draw(_:) 대신 레이어 속성을 쓰면 비용이 어디로 옮겨가는가?
앱 프로세스(CPU)에서 렌더 서버·GPU로 옮겨간다. 그래서 우리 메인스레드 예산이 자유로워진다.
구체적으로 보면 이렇다.
draw(_:)— 백킹 스토어 할당(뷰 크기 × 4바이트) + CPU 래스터라이즈. 뷰가 200×200이면 160KB, 화면 전체면 수 MB다. 셀마다 이러면 메모리와 CPU 둘 다 문제가 된다.cornerRadius·borderWidth·backgroundColor— 렌더 서버가 합성 시 처리한다. 백킹 스토어가 없다.CAShapeLayer·CAGradientLayer— 렌더 서버가 그린다. 경로나 색 정보만 커밋하면 된다.
다만 무조건 레이어가 싼 것은 아니다. 옮겨간 비용이 렌더 서버에서 커지면 render hitch가 된다(20장 Q1). 특히 마스크·그림자·투명도 조합은 오프스크린 렌더링을 유발해 GPU 비용이 급증할 수 있다(Q4).
판단 기준은 "어느 쪽 예산에 여유가 있는가"다. Animation Hitches로 commit이 늦는지 render가 늦는지 보고, 여유 있는 쪽으로 옮긴다. 대부분의 앱에서는 메인스레드가 더 빡빡하므로 레이어 쪽이 유리하지만, 레이어 효과를 남발한 화면에서는 반대가 된다.
세 번째 선택지도 있다 — 미리 렌더링해 이미지로 만들어두는 것이다. 복잡하고 변하지 않는 그래픽이라면 백그라운드에서 한 번 그려 이미지로 캐시하면 양쪽 비용이 다 준다.
오토레이아웃이 Layout 단계를 무겁게 만드는 구조적 이유는?
오토레이아웃은 제약 조건 만족 문제를 푸는 것이라 계산량이 제약 수에 비선형으로 반응할 수 있다. 내부적으로 선형 방정식 시스템을 풀기 때문에, 제약이 서로 얽힐수록 비용이 커진다.
비용을 키우는 패턴이 넷 있다.
(1) 깊은 뷰 계층. 중첩이 깊으면 제약이 계층을 가로질러 전파되고, 한 곳의 변경이 넓은 범위의 재계산을 부른다.
(2) 모호하거나 충돌하는 제약. 해가 유일하지 않으면 시스템이 우선순위를 따져 결정해야 하고, 충돌이 있으면 경고를 만들며 추가 작업을 한다.
(3) 스크롤 중 동적 높이 계산. 셀마다 systemLayoutSizeFitting으로 높이를 구하면 셀이 보일 때마다 레이아웃 패스가 한 번 더 돈다.
(4) 매 프레임 제약 변경. constraint.constant를 애니메이션마다 바꾸면 프레임마다 재계산이다. transform으로 처리할 수 있는 것은 그렇게 하는 편이 훨씬 싸다 — transform은 레이아웃을 다시 풀지 않는다.
실무 대응은 (a) 계층을 얕게, (b) 셀 높이를 캐시, (c) 이동·크기 애니메이션은 transform으로, (d) 정말 무거운 곳은 수동 레이아웃이다. 마지막은 최후 수단이지만, 리스트 셀처럼 구조가 고정되고 반복되는 곳에서는 효과가 크다.
측정 없이 판단하지 않는 것이 중요하다 — 위의 layoutSubviews 계측으로 실제로 어느 뷰가 오래 걸리는지 확인한 뒤에 손대야 한다.
이미지 포맷이 GPU와 안 맞으면 구체적으로 무슨 일이 일어나는가?
GPU가 직접 샘플링할 수 없는 포맷이면 CPU가 변환한 사본을 만든다. 시뮬레이터의 Color Copied Images가 이 상황을 표시해준다.
변환이 필요해지는 전형적 원인이 넷이다.
- 비표준 색 공간. 서버 이미지가 특이한 프로파일을 갖고 있으면 sRGB 등으로 변환해야 한다.
- 지원되지 않는 픽셀 포맷. 채널 순서나 비트 깊이가 맞지 않는 경우.
- 알파 채널 처리. premultiplied가 아닌 알파는 변환이 필요할 수 있다.
- 정렬되지 않은 바이트 레이아웃. 행 바이트 수가 정렬 조건을 안 맞추면 재배치가 필요하다.
비용은 이미지 크기에 비례하고, 매 프레임 발생할 수도 있다. 스크롤 중이라면 치명적이다.
해법은 사전 정규화다. 백그라운드에서 디코딩할 때 GPU 친화적 포맷으로 다시 그려두면, 표시 시점에는 변환이 없다.
/// GPU가 바로 쓸 수 있는 포맷으로 정규화한다
func normalized(_ image: UIImage) -> UIImage {
let format = UIGraphicsImageRendererFormat.default()
format.opaque = true // 알파가 필요 없으면 불투명으로 — 합성 비용도 준다
format.scale = image.scale
return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in
image.draw(at: .zero) // 표준 포맷으로 다시 그린다
}
}format.opaque = true가 부수적으로 블렌딩 비용도 줄인다 — 시뮬레이터의 Color Blended Layers에서 확인할 수 있다. 불투명하다고 알려주면 렌더 서버가 아래 레이어를 합성할 필요가 없다.
Q4. 오프스크린 렌더링은 왜 비싼가?
GPU가 한 번에 그리지 못하고 별도 버퍼에 먼저 그린 뒤 다시 합성해야 하기 때문이다. 비용은 두 갈래다 — 버퍼 할당·전환(GPU 파이프라인이 렌더 타깃을 바꾸는 것 자체가 비쌈)과 추가 메모리. 유발 요인은 마스크, 둥근 모서리와 clipsToBounds의 특정 조합, shadowPath 없는 그림자, 그룹 투명도, shouldRasterize 등이다. 결정적인 점은 이게 render hitch로 나타난다는 것이다(20장 Q1) — 메인스레드는 완전히 멀쩡한데 화면이 끊긴다. 그래서 Time Profiler로 아무리 봐도 안 보이고, 시뮬레이터의 Color Offscreen-Rendered나 Animation Hitches의 render 트랙으로 찾아야 한다.
CS 원리
GPU는 렌더 타깃(그릴 대상 버퍼)을 정해두고 그린다. 정상 경로에서는 화면 버퍼 하나에 레이어들을 순서대로 합성하며 그린다 — 한 번의 패스로 끝난다.
그런데 어떤 효과는 "먼저 완성된 결과가 있어야 적용할 수 있다". 마스크가 대표적이다 — 마스크를 적용하려면 마스크 대상 전체가 그려져 있어야 하고, 그러려면 별도 버퍼에 먼저 그려야 한다.
그래서 흐름이 이렇게 된다.
- GPU가 렌더 타깃을 임시 버퍼로 전환한다.
- 대상 레이어들을 그 버퍼에 그린다.
- 효과(마스크·블러 등)를 적용한다.
- 렌더 타깃을 화면 버퍼로 되돌린다.
- 결과를 화면에 합성한다.
비싼 이유는 1번과 4번의 전환이다. GPU는 파이프라인 구조라 렌더 타깃 전환이 파이프라인 플러시를 유발할 수 있다 — 진행 중인 작업을 마무리하고 상태를 바꿔야 한다. 이건 CPU의 분기 예측 실패나 캐시 미스와 비슷한 성격의 비용이다(P0 12장).
여기에 메모리 대역폭 비용이 더해진다. 임시 버퍼를 쓰고 다시 읽는 것은 메모리를 왕복하는 것이고, 모바일 GPU는 대역폭이 성능을 좌우하는 경우가 많다.
그래서 오프스크린 렌더링은 개수보다 전환 횟수가 문제다. 한 화면에 오프스크린 유발 요소가 20개면 프레임마다 20번 전환이 일어난다.
iOS에서는
유발 요인과 대안을 정리하면 이렇다.
| 유발 요인 | 왜 | 대안 |
|---|---|---|
layer.mask | 마스크 적용에 완성된 결과가 필요 | 미리 마스킹된 이미지, CAShapeLayer 조합 |
shadowPath 없는 그림자 | 알파 채널로 그림자 모양을 계산해야 함 | shadowPath 지정 — 가장 효과 큰 최적화 |
그룹 투명도 (allowsGroupOpacity) | 자식들을 합쳐 놓고 투명도 적용 | 개별 알파 조정, 또는 필요 없으면 끄기 |
cornerRadius + clipsToBounds | 내용을 잘라내야 함 (조합·버전에 따라 다름) | 배경만 둥글면 오프스크린 없이 처리됨 |
shouldRasterize | 결과를 캐시하려고 일부러 오프스크린 | 잘 쓰면 이득, 잘못 쓰면 손해 |
블러 (UIVisualEffectView) | 주변 픽셀을 읽어야 함 | 정적 배경이면 이미지로 대체 |
shadowPath가 가장 효과가 큰 최적화다. 그림자를 그리려면 GPU가 레이어의 알파 채널을 분석해 모양을 알아내야 하는데, 우리가 모양을 미리 알려주면 그 분석이 통째로 사라진다.
// ❌ 오프스크린 렌더링 — GPU가 알파 채널을 분석해 그림자 모양을 계산한다
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.2
view.layer.shadowRadius = 8
view.layer.shadowOffset = CGSize(width: 0, height: 4)
// ✅ 모양을 미리 알려주면 분석이 필요 없다
view.layer.shadowPath = UIBezierPath(
roundedRect: view.bounds, cornerRadius: 12
).cgPath
// ⚠️ 주의: bounds가 바뀌면 shadowPath도 갱신해야 한다.
// 안 하면 그림자 모양이 어긋난 채 남는다.
override func layoutSubviews() {
super.layoutSubviews()
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
}
shouldRasterize는 양날의 검이다. 레이어를 일부러 오프스크린에 그려 비트맵으로 캐시하는 기능이라, 내용이 자주 안 바뀌면 이후 프레임에서 재사용되어 이득이다. 그런데 내용이 매 프레임 바뀌면 매번 다시 그리므로 순손실이다.
// shouldRasterize는 "정적이고 그리기 비싼" 경우에만
if isStaticComplexView {
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale // ⚠️ 안 하면 흐려진다
}
// 애니메이션 중이거나 내용이 자주 바뀌면 켜지 않는다 — 캐시 적중률이 0에 가깝다
rasterizationScale을 안 맞추면 Retina에서 흐릿하게 보인다 — 매우 흔한 실수다.
실험 · 도구
오프스크린 렌더링은 눈으로 보는 것이 가장 빠르다.
# 시뮬레이터 Debug 메뉴 (또는 Xcode › Debug › View Debugging)
# ☑︎ Color Offscreen-Rendered
# → 오프스크린 렌더링이 일어나는 영역이 노란색으로 표시된다
# → 스크롤하면서 노란 영역이 얼마나 많은지 본다
#
# ☑︎ Color Blended Layers
# → 반투명 합성이 겹친 곳이 빨갛게 표시된다 (겹칠수록 진해짐)
# → 불투명하게 만들 수 있는 곳을 찾는다
#
# ☑︎ Color Hits Green and Misses Red
# → shouldRasterize 캐시 적중(초록)/실패(빨강)
# → 빨간색이면 rasterize가 손해를 보고 있다는 뜻이다
# 실기기 GPU 부하 측정
# Instruments › Metal System Trace 또는 Animation Hitches
# render 트랙에서 GPU 시간이 예산을 넘는지 확인
Color Hits Green and Misses Red가 shouldRasterize 판단의 결정적 근거다. 빨간색이 보이면 캐시가 매번 무효화되고 있다는 뜻이므로 즉시 꺼야 한다.
비용 차이를 직접 재보는 것도 유용하다.
/// 그림자 방식별 비용을 비교하는 실험 뷰
final class ShadowBenchmarkView: UIView {
enum Mode { case noPath, withPath, plain } // 'none' 은 Optional.none 과 충돌해 피한다
func makeCards(mode: Mode, count: Int) {
subviews.forEach { $0.removeFromSuperview() }
for i in 0..<count {
let card = UIView(frame: CGRect(x: 16, y: 16 + i * 80, width: 300, height: 64))
card.backgroundColor = .systemBackground
card.layer.cornerRadius = 12
switch mode {
case .noPath:
card.layer.shadowColor = UIColor.black.cgColor
card.layer.shadowOpacity = 0.2
card.layer.shadowRadius = 8
// shadowPath 없음 → 카드마다 오프스크린 렌더링
case .withPath:
card.layer.shadowColor = UIColor.black.cgColor
card.layer.shadowOpacity = 0.2
card.layer.shadowRadius = 8
card.layer.shadowPath = UIBezierPath(
roundedRect: card.bounds, cornerRadius: 12).cgPath
// → 오프스크린 없음
case .plain:
break
}
addSubview(card)
}
}
}
// 각 모드로 100개를 만들어 스크롤하며 Animation Hitches의 render 트랙을 비교한다.
// Color Offscreen-Rendered를 켜면 .noPath 에서만 노란 영역이 뜬다.
프로젝트 적용
실무 규칙 다섯.
- 그림자에는 항상
shadowPath를 준다. 비용 대비 효과가 가장 크고 부작용이 없다. 단layoutSubviews에서 갱신하는 것을 잊지 않는다. - 리스트 셀에서 마스크를 피한다. 셀 하나의 오프스크린 비용이 화면에 보이는 셀 수만큼 곱해진다. 프로필 이미지를 둥글게 하려면 미리 둥글게 처리한 이미지를 쓰거나, 배경에
cornerRadius를 주는 방식으로 우회한다. shouldRasterize는 측정 후에만. Color Hits Green and Misses Red로 확인하고, 빨간색이면 끈다.- 불투명하게 만들 수 있으면 만든다.
isOpaque = true와 불투명 배경색은 블렌딩 비용을 없앤다(Q3 꼬리질문). - render hitch를 별도 지표로 본다. commit만 보면 이 영역이 통째로 안 보인다(20장 Q1). Animation Hitches에서 render 트랙을 항상 함께 확인한다.
"cornerRadius는 항상 오프스크린 렌더링을 유발한다"는 과거 정보다. 배경색만 둥글게 하는 경우처럼 오프스크린 없이 처리되는 조합이 있고, iOS 버전에 따라 최적화가 달라졌다 — 추측하지 말고 Color Offscreen-Rendered로 확인해야 한다. "shouldRasterize를 켜면 빨라진다"도 위험하다 — 내용이 바뀌면 매 프레임 다시 그려 오히려 느려진다. "오프스크린 렌더링은 메인스레드 문제"도 틀렸다 — GPU 쪽이라 Time Profiler에 안 보이고 render hitch로 나타난다. 또 shadowPath를 한 번만 설정하고 bounds 변경 시 갱신하지 않으면 그림자가 어긋난다.
사진관에서 인화하는 과정을 생각해보자. 보통은 한 번에 인화지에 찍어낸다.
그런데 어떤 효과는 그렇게 안 된다. 예를 들어 사진을 하트 모양으로 오려내려면, 먼저 사진을 통째로 인화한 다음 오려야 한다. 바로 하트 모양으로 찍을 수는 없다.
그래서 과정이 늘어난다. 임시 인화 → 오리기 → 다시 붙이기. 한 장이면 별것 아닌데, 100장을 이렇게 하면 시간이 몇 배가 된다.
여기서 그림자가 재밌는 사례다. 물건 뒤에 그림자를 그리려면 물건 모양을 알아야 한다. 그런데 사진관 입장에서는 물건 모양을 사진을 들여다보며 알아내야 한다. 오래 걸린다.
그런데 우리가 "이 물건은 모서리 둥근 사각형이에요"라고 미리 알려주면? 알아내는 과정이 통째로 사라진다. 이게 shadowPath다. 공짜로 얻는 최적화라 안 할 이유가 없다.
마지막으로 — 이 모든 일은 사진관에서 일어난다. 내가 아무리 빨리 주문서를 써도 사진관이 밀리면 사진은 늦게 나온다. 그래서 내 책상(메인스레드)만 보면 원인을 못 찾는다.
꼬리 질문
리스트 셀에서 프로필 이미지를 둥글게 만드는 가장 싼 방법은?
선택지를 비용 순으로 보면 이렇다.
(1) 미리 둥글게 처리한 이미지를 캐시한다 — 가장 싸다. 백그라운드에서 원본을 원형으로 마스킹해 그린 뒤 캐시한다. 표시 시점에는 그냥 이미지 하나라 오프스크린도 마스크도 없다. Q3의 사전 디코딩과 자연스럽게 합쳐진다.
func circularImage(from image: UIImage, size: CGSize) -> UIImage {
let format = UIGraphicsImageRendererFormat.default()
format.opaque = false
return UIGraphicsImageRenderer(size: size, format: format).image { ctx in
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).addClip()
image.draw(in: CGRect(origin: .zero, size: size))
}
}
// 백그라운드에서 만들어 캐시하고, 셀에는 결과만 붙인다(2) 불투명 배경 위에 구멍 뚫린 오버레이 — 배경색이 단색이면, 이미지 위에 가운데가 뚫린 사각형 이미지를 얹어 원형처럼 보이게 할 수 있다. 마스크 없이 합성만으로 처리된다. 배경이 단색일 때만 쓸 수 있다.
(3) cornerRadius + clipsToBounds — 가장 쓰기 쉽지만 조합에 따라 오프스크린이 발생할 수 있다. Color Offscreen-Rendered로 확인하고, 안 뜨면 그냥 써도 된다.
(4) layer.mask — 가장 비싸다. 셀에서는 피한다.
실무 권장은 (1)이다. 이미지 캐시 계층이 이미 있다면 거기에 "원형 변형"을 캐시 키의 일부로 넣으면 자연스럽게 통합된다. 그리고 Q3에서 본 디코딩·정규화·원형 처리를 한 번에 끝내면 셀 표시 비용이 거의 0이 된다.
shouldRasterize가 이득이 되는 조건을 어떻게 판단하는가?
세 조건이 동시에 성립해야 이득이다.
(1) 그리기가 비싸다. 레이어가 많거나 효과가 복잡해야 캐시할 가치가 있다. 단순한 뷰를 rasterize하면 캐시 관리 비용만 든다.
(2) 내용이 자주 안 바뀐다. 캐시가 유효한 프레임이 많아야 한다. 내용이 매 프레임 바뀌면 매번 다시 그리는 데다 캐시 저장 비용까지 추가된다.
(3) 크기·변환이 자주 안 바뀐다. 스케일이 바뀌면 캐시가 무효화된다. 그래서 확대·축소 애니메이션 중에는 최악이다.
판단 방법은 Color Hits Green and Misses Red로 직접 보는 것이다. 초록이면 캐시가 재사용되고 있고, 빨강이면 매번 다시 그리고 있다. 빨강이 보이면 즉시 끈다.
흔한 오용 패턴이 둘 있다.
- 스크롤 중인 셀에 rasterize. 셀이 재사용되면서 내용이 계속 바뀌므로 캐시가 매번 무효화된다.
- 애니메이션 중인 뷰에 rasterize. transform이 바뀌면 캐시를 다시 만들어야 한다.
그리고 rasterizationScale을 반드시 화면 스케일에 맞춘다. 기본값이 1.0이라 Retina에서 흐릿해지는데, 이건 성능이 아니라 품질 버그로 나타나 원인을 찾기 어렵다.
대안이 있다면 대개 그쪽이 낫다 — 미리 이미지로 렌더링해 캐시하는 방식(Q3 꼬리질문)이 통제 가능하고 예측 가능하다.
블러 효과의 비용을 줄이는 현실적 방법은?
블러는 주변 픽셀을 여러 개 읽어 평균내는 연산이라 본질적으로 비싸다. 반경이 커질수록 읽어야 할 픽셀이 늘어 비용이 급증한다. 그리고 오프스크린 렌더링이 필연적이다.
줄이는 방법이 넷 있다.
(1) 정적 배경이면 이미지로 대체한다. 배경이 바뀌지 않는다면 미리 블러 처리한 이미지를 만들어 쓴다. 런타임 비용이 0이 된다. 가장 효과적이다.
(2) 블러 영역을 줄인다. 화면 전체 블러 대신 필요한 영역만. 비용이 픽셀 수에 비례하므로 영역을 절반으로 줄이면 비용도 절반이다.
(3) 시스템 재료(UIVisualEffectView)를 쓴다. 직접 구현하는 것보다 최적화돼 있고, 시스템이 상황에 따라 품질을 조절한다. 커스텀 블러를 직접 구현하는 것은 대개 손해다.
(4) 애니메이션 중에는 끈다. 전환 애니메이션 중에 블러를 유지하면 매 프레임 블러를 다시 계산한다. 애니메이션 시작 시 스냅샷 이미지로 대체하고 끝나면 되돌리는 패턴이 흔하다.
그리고 기기별 차이가 크다는 점을 유념해야 한다. 최신 기기에서 매끄러운 블러가 구형에서는 render hitch를 만든다. 20장 Q7에서 본 대로 가장 느린 지원 기기에서 확인해야 한다.
측정은 Animation Hitches의 render 트랙과 Metal System Trace로 한다. 블러는 GPU 비용이라 Time Profiler에는 안 보인다.
Q5. 벡터 애니메이션은 무엇을 맞바꾸는가?
벡터 애니메이션은 미리 렌더된 픽셀 프레임(GIF·APNG·비디오)을 재생하는 대신, 도형·경로·키프레임 같은 벡터 데이터를 런타임에 실시간으로 그려낸다. 얻는 것은 해상도 독립성(어떤 크기에서도 선명), 작은 파일 크기, 런타임 변경 가능성(색·속도·구간을 코드로 조작)이다. 대신 CPU·GPU 시간을 매 프레임 지불한다 — 프리렌더는 디코딩만 하면 되지만 벡터는 계산해서 그려야 한다. 그래서 트레이드오프가 명확하다 — 파일 크기·유연성 ↔ 런타임 비용. 복잡한 애니메이션을 작은 화면에 여러 개 띄우면 프리렌더가 유리하고, 크기가 크거나 런타임 조작이 필요하면 벡터가 유리하다.
CS 원리
이건 시간-공간 트레이드오프의 전형이다. 미리 계산해두면(프리렌더) 공간을 쓰고 시간을 아끼고, 런타임에 계산하면(벡터) 시간을 쓰고 공간을 아낀다.
| 프리렌더 (GIF·APNG·비디오) | 벡터 (런타임 렌더) | |
|---|---|---|
| 파일 크기 | 해상도 × 프레임 수에 비례 | 도형 복잡도에만 비례 |
| 해상도 | 고정 — 확대하면 깨진다 | 독립 — 어떤 크기든 선명 |
| 런타임 CPU | 디코딩만 | 경로 계산 + 래스터라이즈 |
| 메모리 | 디코딩된 프레임 버퍼 | 벡터 데이터 (작음) |
| 런타임 변경 | 불가 | 색·속도·구간 조작 가능 |
| 비용 예측 | 쉬움 (크기로 결정) | 어려움 (내용에 따라 급변) |
마지막 행이 실무에서 자주 사고를 만든다. 프리렌더는 비용이 예측 가능하다 — 해상도와 프레임 수를 알면 대략 안다. 반면 벡터는 디자이너가 만든 내용에 따라 비용이 수십 배 달라진다. 도형 500개에 마스크가 겹친 애니메이션은 겉보기에 단순해도 렌더 비용이 폭발한다.
그리고 비용이 지불되는 위치도 다르다. 프리렌더 디코딩은 Q3에서 본 Prepare 단계 비용이고 백그라운드로 옮길 수 있다. 벡터 렌더링은 매 프레임 일어나므로 옮길 수 없다 — 프레임 예산 안에서 끝나야 한다.
iOS에서는
iOS에서 벡터 애니메이션을 다루는 방식은 크게 셋이다.
- Core Animation 기본 요소 —
CAShapeLayer+CABasicAnimation. 단순한 도형 애니메이션은 이걸로 충분하고, 렌더 서버가 그리므로 메인스레드 비용이 없다(Q1). - SF Symbols 애니메이션 — 시스템이 최적화한 벡터 심볼. 아이콘 수준의 애니메이션이면 가장 싸다.
- 서드파티 벡터 애니메이션 라이브러리 — 디자인 도구에서 내보낸 JSON을 런타임에 해석해 그린다. 복잡한 일러스트 애니메이션에 쓴다.
세 번째 계열에서 중요한 사실이 하나 있다 — 렌더 엔진이 둘로 나뉘는 구현이 흔하다. 대표적으로 Lottie는 Core Animation 엔진과 메인스레드 엔진 두 가지를 갖는다.
| Core Animation 엔진 | 메인스레드 엔진 | |
|---|---|---|
| 그리는 주체 | 렌더 서버 | 앱 메인스레드 |
| 메인스레드 비용 | 커밋 한 번 | 매 프레임 |
| 지원 기능 | 제한적 — 일부 효과 미지원 | 더 넓음 |
| 결과 | 엔진에 따라 렌더 결과가 다를 수 있다 | |
마지막 행이 실무의 함정이다. 같은 애니메이션 파일인데 엔진에 따라 다르게 보일 수 있다. 성능을 위해 Core Animation 엔진으로 바꿨더니 일부 효과가 사라지거나 다르게 렌더되는 상황이 생긴다.
이건 추상화 뒤에 성능 특성과 동작이 함께 숨는 전형적 사례다 — P0 17장의 추상화 논의가 여기서 실물로 나타난다. "애니메이션 파일을 넣으면 돌아간다"는 인터페이스 뒤에 완전히 다른 두 구현이 있고, 어느 쪽인지가 성능과 결과를 모두 바꾼다.
실험 · 도구
벡터 애니메이션의 비용은 반드시 실측해야 한다. 내용에 따라 편차가 크기 때문이다.
import UIKit
import QuartzCore
import OSLog
/// 애니메이션이 도는 동안 프레임 지연과 CPU 시간을 측정한다
final class AnimationCostProbe {
private let log = Logger(subsystem: "com.example.shop", category: "anim")
private var link: CADisplayLink?
private var last: CFTimeInterval = 0
private var dropped = 0
private var total = 0
func start() {
last = 0; dropped = 0; total = 0
let l = CADisplayLink(target: self, selector: #selector(tick(_:)))
l.add(to: .main, forMode: .common)
link = l
}
@objc private func tick(_ l: CADisplayLink) {
defer { last = l.timestamp }
guard last != 0 else { return }
total += 1
let budget = l.targetTimestamp - l.timestamp
if (l.timestamp - last) > budget * 1.5 { dropped += 1 }
}
func stop(_ label: String) {
link?.invalidate(); link = nil // ⚠️ 반드시 무효화 (20장 Q2)
let rate = total > 0 ? Double(dropped) / Double(total) * 100 : 0
log.notice("[\(label, privacy: .public)] 지연 프레임 \(dropped, privacy: .public)/\(total, privacy: .public) (\(rate, privacy: .public)%)")
}
}
// 사용: 같은 화면에서 애니메이션 개수를 늘려가며 측정한다
// [벡터 1개] 지연 프레임 0/300 (0.0%)
// [벡터 5개] 지연 프레임 12/300 (4.0%)
// [벡터 20개] 지연 프레임 148/300 (49.3%) ← 개수에 비선형으로 악화
# 비용 위치 확인 — 어느 프로세스가 바쁜지
# Instruments › Animation Hitches
# commit 트랙이 바쁘다 → 메인스레드 엔진일 가능성. 엔진을 바꿔본다.
# render 트랙이 바쁘다 → 렌더 서버 부하. 도형 복잡도를 줄인다.
#
# Instruments › Time Profiler
# 메인스레드 엔진이면 렌더 관련 스택이 크게 잡힌다.
# Core Animation 엔진이면 여기에 거의 안 잡힌다 (Q1의 렌더 서버 분리 때문).
# 파일 자체의 복잡도를 미리 본다 — 도형·레이어 수가 비용의 대리 지표다
python3 - <<'PY'
import json, sys
d = json.load(open("animation.json"))
layers = d.get("layers", [])
print("레이어 수:", len(layers))
print("총 프레임:", d.get("op", 0) - d.get("ip", 0))
print("크기:", d.get("w"), "x", d.get("h"))
# 레이어가 수백 개면 런타임 비용을 의심하고 실측한다
PY
프로젝트 적용
선택 기준과 운영 규칙 다섯.
- 용도로 고른다. 크기가 작고 개수가 많으면(리스트 셀의 작은 아이콘) 프리렌더나 SF Symbols가 유리하다. 크고 하나뿐이며 색을 런타임에 바꿔야 하면 벡터가 유리하다.
- 단순한 것은 Core Animation으로 직접 만든다. 로딩 스피너나 체크 표시 정도는
CAShapeLayer+CABasicAnimation이면 되고, 렌더 서버가 그리므로 메인스레드 비용이 0이다. 라이브러리를 쓸 이유가 없다. - 엔진 설정을 명시적으로 정하고 기록한다. 기본값에 맡기지 말고 어느 엔진을 쓰는지 코드에 남긴다. 성능과 렌더 결과가 둘 다 달라지므로 바꿀 때는 시각 검증도 함께 한다.
- 디자이너와 복잡도 예산을 합의한다. "레이어 100개 이하", "마스크 중첩 2단계 이하" 같은 기준을 정하면 만들고 나서 못 쓰는 상황을 막는다. 디자인 단계에서 정하는 것이 훨씬 싸다.
- 느린 기기에서 검증한다. 최신 기기에서 매끄러운 애니메이션이 구형에서 render hitch를 만든다. 20장 Q7의 원칙이 그대로 적용된다.
"벡터니까 가볍다"가 가장 흔한 오해다. 파일은 작지만 런타임 비용은 클 수 있다 — 파일 크기와 렌더 비용은 별개 축이다. "애니메이션 파일을 넣으면 어디서든 똑같이 보인다"도 틀렸다 — 렌더 엔진에 따라 결과가 다를 수 있다. "성능이 나쁘면 엔진만 바꾸면 된다"도 위험하다 — 지원 기능이 달라 렌더 결과가 바뀔 수 있으므로 시각 검증이 필요하다. "애니메이션 하나는 괜찮으니 열 개도 괜찮다"도 틀렸다 — 개수에 비선형으로 악화되는 경우가 많다. 또 디자인 단계에서 복잡도를 통제하지 않으면 완성된 뒤에는 고치기가 훨씬 비싸다.
애니메이션을 만드는 두 가지 방법이 있다.
방법 1 — 플립북. 종이마다 그림을 미리 그려놓고 빠르게 넘긴다. 넘기기만 하면 되니 보여주는 건 아주 쉽다. 대신 종이가 많이 필요하고(파일 크기), 확대하면 흐려진다. 그리고 색을 바꾸고 싶으면 전부 다시 그려야 한다.
방법 2 — 설계도. "원을 여기서 저기로, 2초 동안"이라고 규칙만 적어둔다. 종이 한 장이면 되고(작은 파일), 아무리 크게 그려도 선명하고, "색만 빨강으로"도 즉석에서 가능하다. 대신 매 순간 계산해서 그려야 한다.
그래서 "설계도가 무조건 좋다"가 아니다. 설계도가 도형 500개짜리면 그리는 데 시간이 오래 걸린다. 화면에 그런 걸 20개 동시에 띄우면 손이 못 따라간다.
여기에 함정이 하나 더 있다 — 그리는 사람이 두 명이다. 한 명은 빠르지만 못 그리는 기법이 있고, 다른 한 명은 다 그릴 수 있지만 느리다. 같은 설계도를 줘도 누가 그리느냐에 따라 결과가 조금 다르다. 그래서 "빠른 사람으로 바꿨더니 그림이 달라졌다"는 일이 생긴다.
꼬리 질문
단순한 애니메이션을 라이브러리 없이 Core Animation으로 만들면 얼마나 이득인가?
비용 구조가 근본적으로 달라진다. Q1에서 본 대로 Core Animation 애니메이션은 커밋 한 번으로 끝나고 렌더 서버가 진행시킨다. 메인스레드는 애니메이션이 도는 동안 완전히 자유롭다.
반면 벡터 애니메이션 라이브러리는 매 프레임 경로를 계산해야 하고, 메인스레드 엔진이라면 그 계산이 메인스레드에서 일어난다.
예를 들어 로딩 스피너를 보자.
// Core Animation — 커밋 한 번, 이후 메인스레드 비용 0
let spinner = CAShapeLayer()
spinner.path = UIBezierPath(arcCenter: center, radius: 16,
startAngle: 0, endAngle: .pi * 1.5,
clockwise: true).cgPath
spinner.strokeColor = UIColor.systemBlue.cgColor
spinner.fillColor = nil
spinner.lineWidth = 3
spinner.lineCap = .round
let rotate = CABasicAnimation(keyPath: "transform.rotation.z")
rotate.toValue = Double.pi * 2
rotate.duration = 1
rotate.repeatCount = .infinity
spinner.add(rotate, forKey: "spin") // 이후 앱이 막혀도 계속 돈다이득이 셋이다 — 메인스레드 비용 0, 앱이 잠깐 막혀도 부드러움(사용자에게 "살아 있다"는 인상), 바이너리 크기 절감(라이브러리 미포함, 26장).
기준은 "디자인 도구에서 만든 복잡한 일러스트인가, 코드로 표현 가능한 도형인가"다. 후자면 Core Animation이 거의 항상 낫다. 실무에서 스피너·체크 표시·프로그레스·펄스 같은 것들이 라이브러리로 처리되는 경우가 많은데, 대개 코드 30줄이면 된다.
애니메이션 개수가 늘 때 비용이 비선형으로 악화되는 이유는?
여러 요인이 겹치기 때문이다.
(1) 메모리 대역폭 포화. 각 애니메이션이 텍스처를 읽고 쓰는데, 모바일 GPU는 대역폭이 제한적이다. 개수가 늘어 대역폭이 포화되면 추가 부하가 선형 이상으로 나타난다.
(2) 오프스크린 전환 누적. 애니메이션에 마스크나 그룹 투명도가 있으면 각각 오프스크린 렌더링을 유발한다(Q4). 전환 비용이 개수만큼 곱해진다.
(3) 캐시 미스. 애니메이션이 하나면 관련 데이터가 캐시에 남지만, 여러 개가 번갈아 처리되면 서로의 캐시를 밀어낸다(P0 12장). 개수가 임계를 넘는 순간 급격히 나빠진다.
(4) 프레임 예산의 절벽. 이게 가장 눈에 띄는 요인이다. 예산 8.3ms 안에 들어오는 동안은 아무 문제가 없어 보이다가, 넘는 순간 히치가 생기고 연쇄 지연(Q2 꼬리질문)까지 겹쳐 체감이 급락한다. 비용 자체는 선형이어도 체감은 계단식이다.
실무 대응은 동시 재생 개수에 상한을 두는 것이다. 리스트에서 보이는 셀의 애니메이션만 재생하고 나머지는 정지 프레임으로 두거나, 화면당 N개까지만 재생한다.
// 보이는 셀만 재생 — 화면 밖 애니메이션은 정지시킨다
func collectionView(_ cv: UICollectionView, didEndDisplaying cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
(cell as? AnimatedCell)?.pauseAnimation()
}디자이너와 복잡도 예산을 합의할 때 무엇을 기준으로 정하는가?
추상적인 "가볍게 만들어주세요"는 작동하지 않는다. 측정 가능한 기준이 필요하다. 실무에서 쓸 만한 항목이 다섯이다.
(1) 레이어·도형 개수 상한. 파일에서 바로 셀 수 있어 검증이 쉽다. "레이어 100개 이하" 같은 기준을 정한다.
(2) 마스크·매트 사용 제한. 오프스크린 렌더링의 주원인이다(Q4). "마스크 중첩 2단계 이하" 정도.
(3) 표현 크기 명시. 같은 파일이라도 화면에서 차지하는 크기에 따라 비용이 다르다. "이 애니메이션은 최대 120×120으로 표시"를 미리 정해두면 디자이너가 그에 맞게 만든다.
(4) 동시 재생 개수. "리스트에서는 동시에 3개까지"처럼 정한다.
(5) 목표 기기. "지원 기기 중 가장 느린 모델에서 60fps 유지"가 최종 기준이다.
그리고 검증 절차를 파이프라인에 넣는 것이 핵심이다. 애니메이션 파일이 추가될 때 CI에서 레이어 수를 세어 상한을 넘으면 경고하면, 디자인 단계에서 조정된다. 완성 후에 "성능 때문에 못 쓴다"고 하면 재작업 비용이 크고 관계도 나빠진다.
가장 효과적인 것은 디자이너가 실기기에서 직접 확인하게 하는 것이다. 구형 기기 한 대를 두고 프리뷰 앱으로 바로 확인할 수 있게 해두면, 기준을 문서로 설명하는 것보다 훨씬 빠르게 감이 생긴다.
Q6. 렌더 히치는 어떻게 진단하는가?
순서가 정해져 있다. (1) commit인지 render인지 먼저 가른다 — Animation Hitches 계측기가 두 트랙을 분리해 보여준다. 이 구분 없이 최적화하면 엉뚱한 곳을 판다. (2) render라면 시각 진단 도구로 원인을 좁힌다 — Color Offscreen-Rendered(마스크·그림자), Color Blended Layers(반투명 중첩), Color Copied Images(포맷 불일치), Color Misaligned Images(정렬 어긋남). (3) 레이어 복잡도를 줄인다 — 오프스크린 제거, 불투명화, 레이어 수 감축. (4) 느린 기기에서 재검증한다. 핵심은 Time Profiler가 여기서 거의 쓸모없다는 점이다 — GPU와 렌더 서버 작업은 우리 프로세스의 CPU 샘플에 안 잡힌다.
CS 원리
진단의 본질은 탐색 공간 축소다(20장 Q7). 렌더링은 두 프로세스와 GPU가 얽혀 있어 후보가 많으므로, 가장 큰 부분집합을 먼저 배제하는 질문부터 던져야 한다.
가장 강력한 첫 질문이 "commit인가 render인가"다. 이 하나로 후보가 절반으로 줄고, 이후 사용할 도구와 최적화 방향이 완전히 갈린다.
| commit hitch | render hitch | |
|---|---|---|
| 병목 위치 | 앱 메인스레드 | 렌더 서버 · GPU |
| 주 원인 | Layout, Display(draw), 이미지 디코딩 | 오프스크린, 블렌딩, 레이어 수, 블러 |
| Time Profiler | 유효 | 거의 안 보인다 |
| 주 도구 | Time Profiler, Hangs | Color 시각 진단, Metal Trace |
| 행과의 관계 | 같은 원인 — 행을 고치면 함께 줄어든다 | 무관 — 별도로 잡아야 한다 |
마지막 행이 중요하다. 20장 Q1에서 본 Apple의 지침 — 행을 다 고치면 히치도 상당수 사라진다 — 는 commit hitch에만 해당한다. render hitch는 행 도구로 잡히지 않으므로 남는다. 그래서 "행을 다 고쳤는데 여전히 끊긴다"면 render 쪽을 봐야 한다.
두 번째 원리는 시각 진단의 효율이다. GPU 작업은 프로파일러로 함수 단위 분해가 어렵지만, "화면의 어느 영역이 비싼가"는 색으로 즉시 보인다. 공간적 문제에는 공간적 도구가 맞다.
iOS에서는
시각 진단 도구별로 무엇을 알려주는지 정리하면 이렇다.
| 도구 | 표시 | 의미와 대응 |
|---|---|---|
| Color Offscreen-Rendered | 노란색 | 오프스크린 렌더링 발생. shadowPath 지정, 마스크 제거 (Q4) |
| Color Blended Layers | 빨강 (진할수록 겹침) | 반투명 합성. isOpaque·불투명 배경색으로 감축 |
| Color Copied Images | 청록색 | GPU가 못 쓰는 포맷이라 CPU 변환. 사전 정규화 (Q3) |
| Color Misaligned Images | 노랑/보라 | 픽셀 정렬 어긋남 → 재샘플링. frame을 정수 픽셀에 |
| Color Hits Green and Misses Red | 초록/빨강 | shouldRasterize 캐시 적중률. 빨강이면 끈다 (Q4) |
| Flash Updated Regions | 노란 깜빡임 | 다시 그려지는 영역. 넓으면 갱신 범위를 좁힌다 |
Flash Updated Regions가 의외로 유용하다. 작은 값 하나가 바뀌었는데 화면 전체가 깜빡인다면 갱신 범위가 과도한 것이고, 이건 대개 뷰 계층 구조 문제다. 셀 하나만 바뀌어야 하는데 리스트 전체를 reloadData하는 경우가 전형이다.
그리고 Color Misaligned Images는 원인이 단순하다 — 뷰의 frame이 정수 픽셀 경계에 안 맞으면 GPU가 재샘플링해야 한다. 오토레이아웃 계산 결과가 10.333… 같은 값이 되면 발생한다. 대응은 반올림이다.
extension CGRect {
/// 화면 픽셀 경계에 맞춘다 — 재샘플링을 없앤다
func pixelAligned(scale: CGFloat = UIScreen.main.scale) -> CGRect {
CGRect(x: (origin.x * scale).rounded() / scale,
y: (origin.y * scale).rounded() / scale,
width: (size.width * scale).rounded() / scale,
height: (size.height * scale).rounded() / scale)
}
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = computedFrame.pixelAligned()
}
실험 · 도구
진단 절차를 순서대로 실행하는 체크리스트다.
# ── 1단계: commit인가 render인가 ──
# Instruments › Animation Hitches 템플릿
# - Commit 트랙이 길다 → 2A로
# - Render 트랙이 길다 → 2B로
# - 둘 다 → 큰 쪽부터
# ── 2A: commit hitch ──
# Time Profiler를 함께 켠다 (릴리스 구성, 실기기)
# - layoutSubviews 가 오래? → 오토레이아웃 단순화, 셀 높이 캐시 (Q3)
# - draw(_:) 가 오래? → 레이어 속성으로 대체 (Q3)
# - 이미지 관련 스택? → 백그라운드 디코딩 (Q3)
# ── 2B: render hitch ──
# 시뮬레이터 Debug 메뉴를 순서대로 켜본다
# ① Color Offscreen-Rendered → 노란 영역이 있나? (Q4)
# ② Color Blended Layers → 빨간 영역이 넓나?
# ③ Color Copied Images → 청록 영역이 있나? (Q3)
# ④ Color Misaligned Images → 어긋난 이미지가 있나?
# ⑤ Flash Updated Regions → 갱신 범위가 과도한가?
# 실기기 GPU 시간은 Metal System Trace로 확인
# ── 3단계: 수정 후 재측정 ──
# 같은 시나리오, 같은 기기로 Animation Hitches를 다시 돌려 비교
# ── 4단계: 느린 기기에서 검증 ──
# 지원 목록 중 가장 느린 모델에서 히치율 확인
프로덕션 회귀 감시는 Organizer 히치율이 담당한다(20장 Q4). 현행 기준을 다시 정리하면 ≤10 ms/s 좋음, ≤25 경고, ≤50 심각, >50 즉시 조치다.
아래 CADisplayLink 기반 자체 계측은 메인스레드에서 관찰한 프레임 간격이다. 즉 commit 쪽 지연의 추정치이고, render hitch는 거의 잡지 못한다 — 렌더 서버가 프레임을 늦게 내놓아도 콜백은 VSYNC마다 그대로 불리기 때문이다. 이 챕터의 주제인 render hitch를 프로덕션에서 정확히 보려면 Organizer 히치율이 유일한 수단이다. 그래서 자체 계측 값과 Organizer 값은 다른 것이 정상이며, 자체 계측은 "어느 화면에서 commit이 밀리는가"를 좁히는 용도로만 쓴다.
// 자체 계측 — commit 측 프레임 지연을 화면 정보와 함께 남긴다
final class HitchReporter {
private var link: CADisplayLink?
private var hitchMs: Double = 0
private var windowStart = CACurrentMediaTime()
func start() {
let l = CADisplayLink(target: self, selector: #selector(tick(_:)))
l.add(to: .main, forMode: .common)
link = l
}
private var last: CFTimeInterval = 0
@objc private func tick(_ l: CADisplayLink) {
defer { last = l.timestamp }
guard last != 0 else { return }
// Q2 와 같은 식 — 프레임 간격이 예산을 넘긴 만큼을 누적한다
let budget = l.targetTimestamp - l.timestamp
let gap = l.timestamp - last
if gap > budget { hitchMs += (gap - budget) * 1000 }
// 1초 창마다 히치율(ms/s)을 계산해 보고한다 — Apple 지표와 같은 단위
let elapsed = CACurrentMediaTime() - windowStart
if elapsed >= 1.0 {
let rate = hitchMs / elapsed
if rate > 25 { // Apple 기준 "경고" 이상만 보고
Observability.capture(event: "hitch", fields: [
"rate_ms_per_s": Int(rate),
"screen": CurrentScreen.name, // 어느 화면인지가 핵심 단서
])
}
hitchMs = 0; windowStart = CACurrentMediaTime()
}
}
func stop() { link?.invalidate(); link = nil } // 20장 Q2 — 반드시 무효화
}
화면 이름을 함께 남기는 것이 핵심이다. 히치율 숫자만으로는 어디를 봐야 할지 모르지만, "결제 화면에서만 히치율이 40"이면 조사 범위가 즉시 좁혀진다 — 18장 Q1의 "차원을 살려 보내기"가 여기서 실제 이득을 준다.
프로젝트 적용
운영 규칙 다섯.
- commit/render 구분을 조사 1단계로 못 박는다. 팀 위키에 절차를 적어두면 매번 처음부터 헤매지 않는다.
- 시각 진단을 정기 점검에 넣는다. 주요 화면을 대상으로 분기마다 Color 도구들을 훑는다. 새 기능이 오프스크린을 들여오는 것을 조기에 잡는다.
- 히치율을 화면별로 수집한다. 전체 값만 보면 어느 화면이 문제인지 모른다.
- 느린 기기 기준을 별도 지표로 둔다. 20장 Q4에서 본 대로 90퍼센타일이 나쁘면 특정 기기 문제일 가능성이 높다.
- 디자인 리뷰에 성능 관점을 넣는다. 그림자·블러·마스크가 많은 시안은 만들기 전에 비용을 논의한다. Q5의 복잡도 예산과 같은 발상이다.
"스크롤이 끊기면 메인스레드부터 본다"가 가장 흔한 시간 낭비다. render hitch면 Time Profiler에 아무것도 안 나온다. "행을 다 고쳤으니 히치도 없어졌을 것"도 틀렸다 — render hitch는 행과 무관하다. "시뮬레이터 Color 도구는 참고용"도 과소평가다 — 오프스크린·블렌딩 진단에는 가장 빠른 수단이다(다만 GPU 성능 자체는 실기기에서 봐야 한다). "히치율이 좋으면 렌더링에 문제없다"도 조심할 부분이다 — 최신 기기 기준일 수 있다. 또 CADisplayLink로 자체 계측할 때 무효화를 잊으면 그 자체가 배터리 문제가 된다(20장 Q2).
김밥이 늦게 나온다. 어디를 봐야 할까?
가장 먼저 물을 것 — "준비하는 쪽이 늦나, 마는 쪽이 늦나?" 이 질문 하나로 절반이 걸러진다. 준비가 늦는데 마는 사람만 재촉해봐야 아무 소용이 없다.
마는 쪽(렌더 서버)이 늦다면, 눈으로 보는 게 가장 빠르다. 작업대에 색깔 표시를 켜본다.
- 노란 부분 — 여기는 한 번에 못 만들고 따로 만들었다 붙이는 곳이다.
- 빨간 부분 — 재료가 여러 겹 겹쳐 있어 손이 많이 간다.
- 청록 부분 — 규격이 안 맞아 매번 다시 손질하고 있다.
이렇게 보면 "어느 부분이 비싼지"가 즉시 보인다. 반면 준비하는 사람의 손놀림을 초 단위로 기록하는 장비(프로파일러)는 여기서 아무것도 못 알려준다 — 그 사람은 놀고 있으니까.
마지막으로 — 제일 손이 느린 사람 기준으로 확인해야 한다. 숙련자한테는 괜찮은 작업이 신입한테는 무리일 수 있다.
꼬리 질문
시뮬레이터 Color 도구 결과를 실기기에 어디까지 신뢰할 수 있는가?
"무엇이 일어나는가"는 신뢰할 수 있고, "얼마나 비싼가"는 신뢰할 수 없다가 정확한 구분이다.
신뢰할 수 있는 것 — 오프스크린 렌더링 발생 여부, 블렌딩 겹침 구조, 이미지 포맷 불일치, 픽셀 정렬 어긋남. 이것들은 렌더링 로직의 결과라 시뮬레이터와 실기기가 대체로 같다.
신뢰할 수 없는 것 — 실제 프레임 시간, GPU 부하, 히치 발생 여부. 시뮬레이터는 Mac의 CPU·GPU를 쓰므로 성능 특성이 완전히 다르다. 시뮬레이터에서 매끄러운 것이 실기기에서 끊기고, 그 반대도 있다.
그래서 실무 절차는 2단계가 된다.
- 시뮬레이터에서 구조적 문제를 찾는다. Color 도구로 오프스크린과 블렌딩을 제거한다. 빠르고 반복이 쉽다.
- 실기기에서 성능을 확인한다. Animation Hitches로 실제 히치율을 재고, 가장 느린 지원 기기에서 검증한다.
순서가 중요하다 — 구조적 문제를 먼저 없애면 실기기에서 확인할 것이 줄어든다. 실기기 측정은 준비와 반복이 느리므로, 시뮬레이터에서 걸러낼 수 있는 것은 거기서 거른다.
참고로 일부 진단(GPU 드라이버 특성, Metal 관련)은 시뮬레이터에서 아예 다르게 동작하므로, 애매하면 실기기가 원칙이다.
commit과 render가 둘 다 늦을 때 어디부터 고치는가?
세 가지 기준으로 판단한다.
(1) 절대 시간이 큰 쪽부터. commit이 12ms, render가 3ms라면 commit이 지배적이다. 파레토 원칙이 여기서도 적용된다 — 20장 Q7의 "기여 시간 비중으로 우선순위"와 같다.
(2) 같은 원인일 가능성을 본다. 둘이 함께 나빠졌다면 공통 원인이 있을 수 있다. 예를 들어 뷰 계층이 지나치게 복잡하면 Layout(commit)도 무겁고 레이어 수(render)도 많다. 이 경우 계층을 단순화하는 한 번의 수정으로 둘 다 개선된다.
(3) commit을 먼저 보는 것이 대체로 유리하다. 이유가 셋이다 — 도구가 더 정밀하고(Time Profiler로 함수 단위 분해 가능), 행까지 함께 개선되고(20장 Q1), 수정 결과가 명확히 측정된다. render 쪽은 원인 후보가 구조적이라 조사 비용이 크다.
다만 render가 압도적으로 크면(예: 화면 전체 블러) 그쪽부터가 맞다. 이때는 대개 원인이 뻔하므로 조사 비용도 낮다.
그리고 수정 후 반드시 둘 다 재측정한다. commit을 고쳤더니 render가 나빠지는 경우가 있다 — Q3 꼬리질문에서 본 대로 draw를 레이어로 옮기면 비용이 렌더 서버로 이동하기 때문이다. 한쪽만 보면 "고쳤다"고 착각한다.
프로덕션에서 자체 히치 계측을 켜는 것의 비용과 이득은?
비용은 CADisplayLink가 매 프레임 콜백을 부르는 것이다. 콜백 자체는 매우 가볍지만(시각 비교와 산술 몇 개), 두 가지를 주의해야 한다.
- 런루프를 계속 깨운다. 20장 Q2 꼬리질문에서 본 대로 유휴 상태에서도 프레임마다 깨어나면 배터리를 먹는다. 필요한 화면에서만 켜고 반드시 무효화해야 한다.
- 메인스레드에서 실행된다. 콜백 안에서 무거운 일을 하면 그 자체가 히치를 만든다. 집계만 하고 전송은 창 단위로 묶는다.
이득은 Apple 지표가 못 주는 것을 준다.
- 화면별 분해. Organizer 히치율은 앱 전체나 애니메이션 종류별이고, 우리 화면 이름으로는 안 나눠준다. 자체 계측이면 나눌 수 있다.
- 즉시성. Organizer는 집계·전송 지연이 있지만 자체 계측은 릴리스 직후 볼 수 있다.
- 맥락 결합. 데이터 규모·네트워크 상태·기능 플래그 같은 우리만 아는 차원과 결합할 수 있다(20장 Q7).
권장 구성은 샘플링을 걸고 주요 화면에서만 켜는 것이다(22장 Q4). 모든 사용자에게 상시 켤 이유가 없다 — 히치율은 소수 표본으로도 충분히 안정적인 지표다.
다만 Organizer 값과 자체 계측 값은 애초에 다르다는 점을 알고 봐야 한다. 자체 계측은 메인스레드에서 본 프레임 간격이라 render hitch를 못 잡고, Organizer 히치율은 commit·render를 모두 반영한다. 그래서 "두 값이 같아야 정상"이 아니라, 자체 계측이 Organizer보다 낮게 나오는 것이 정상이고 그 차이가 곧 render 쪽 비중이다. 두 값이 함께 움직이면 commit 문제, 자체 계측은 그대로인데 Organizer만 나쁘면 render 문제다.