← iOS 개발자 CS 로드맵 25 / 35

25 · 렌더링 심화

렌더 서버가 별도 프로세스인 이유, 렌더 루프 5단계와 파이프라이닝, CA commit의 네 단계, 오프스크린 렌더링 비용, 벡터 애니메이션의 트레이드오프, 렌더 히치 진단.
진행률
0 / 0 완료

P0 08장이 "프레임이 어떤 단계를 거쳐 표시되는가"와 "60/120Hz 프레임 예산"까지였다면, 이 챕터는 그 아래를 판다. 렌더링을 하는 주체가 우리 앱이 아니라는 사실, 다섯 단계가 동시에 서로 다른 프레임을 처리한다는 사실, 그리고 20장에서 미뤄둔 render hitch가 왜 별도 문제인지를 다룬다. 여기서 나오는 수치는 대부분 Apple 문서에 명시된 값이다.

Q1. 렌더 서버는 왜 별도 프로세스인가?

🔑 30초 답변

앱 프로세스는 실제 렌더링을 하지 않는다. 앱은 뷰 계층의 변경을 렌더 서버라는 별도 프로세스에 커밋하고, 렌더 서버가 서버 측 Core Animation으로 그것을 받아 GPU에 그린다. 이 분리가 주는 것이 셋이다. (1) 앱이 멈춰도 애니메이션은 계속 돈다 — 이미 커밋된 애니메이션은 렌더 서버가 자기 시계로 진행시킨다. (2) 파이프라이닝이 가능하다 — 앱이 다음 프레임을 준비하는 동안 렌더 서버가 이전 프레임을 그린다. (3) 시스템이 프레임 예산을 통제한다 — 앱이 아무리 느려도 화면 갱신 자체는 시스템이 관리한다. 대가는 20장에서 본 render hitch다 — 우리 메인스레드가 멀쩡해도 렌더 서버가 늦으면 화면이 끊긴다.

CS 원리

이건 신뢰할 수 없는 클라이언트로부터 공유 자원을 보호하는 고전적 설계다. 디스플레이는 시스템 전체가 공유하는 자원이고, 앱 하나가 그것을 직접 제어하게 두면 그 앱이 멈출 때 화면 전체가 멈춘다.

그래서 대부분의 현대 OS가 컴포지터(compositor)를 별도 프로세스로 둔다 — 각 앱은 "내 화면은 이렇게 생겼다"는 서술만 제출하고, 컴포지터가 그것들을 합성해 최종 화면을 만든다.

iOS에서 그 서술이 레이어 트리다. 앱이 보내는 것은 픽셀이 아니라 "이 위치에 이 크기로 이 콘텐츠를 이 변환으로 놓아라"라는 구조다. 그래서 앱이 커밋을 한 번만 하면, 렌더 서버가 이후 수백 프레임을 앱 없이 진행할 수 있다.

애니메이션이 여기서 특별한 의미를 갖는다. CABasicAnimation을 커밋하는 것은 "0.3초 동안 이 값을 여기서 저기로 바꿔라"라는 명령을 한 번 보내는 것이지, 매 프레임 값을 계산해 보내는 것이 아니다. 그래서 앱이 그 사이에 블로킹돼도 애니메이션은 계속 돈다.

이 구조의 대가는 책임 분산이다. 화면이 끊겼을 때 원인이 두 곳일 수 있고, 어느 쪽인지 구분하지 않으면 엉뚱한 곳을 최적화하게 된다. 20장 Q1에서 본 commit hitch와 render hitch의 구분이 여기서 나온다.

앱 프로세스 이벤트 처리 · 상태 변경 레이어 트리 구성 픽셀은 그리지 않는다 커밋 레이어 트리 (서술) 런루프 틱에 플러시 렌더 서버 별도 프로세스 서버 측 Core Animation 애니메이션 자체 진행 드로우 콜 GPU 최종 이미지 합성 → VSYNC에 표시 여기서 막히면 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가 변환한 이미지

프로젝트 적용

이 구조를 알면 판단이 바뀌는 지점 넷.

⚠️ 흔한 오해

"우리 앱이 화면을 그린다"가 가장 근본적인 오해다. 앱은 레이어 트리라는 서술을 커밋할 뿐이고 실제 렌더링은 별도 프로세스가 한다. "메인스레드가 멀쩡하면 화면도 부드럽다"도 틀렸다 — 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 기반이면서 진행률을 제어할 수 있어, 제스처 연동 애니메이션에 적합하다.

쉽게 말하면 연출가가 1초에 120번 "조명 조금 더 어둡게"라고 외치는 것과, 처음에 한 번 "2초에 걸쳐 어둡게 해주세요"라고 말해두는 것의 차이다. 후자는 연출가가 그동안 다른 일을 할 수 있고, 잠깐 자리를 비워도 조명은 계속 어두워진다.

Q2. 렌더 루프 다섯 단계는 어떻게 겹쳐서 돌아가는가?

🔑 30초 답변

Apple이 정의하는 렌더 루프는 Event → Commit → Render Prepare → Render Execute → Display 다섯 단계다. 핵심은 각 단계가 한 번에 하나의 프레임만 처리하지만, 모든 단계가 서로 다른 프레임을 두고 동시에 일한다는 것이다 — 공장의 조립 라인과 같다. 그래서 총 지연은 여러 프레임에 걸치지만 처리량은 프레임마다 하나가 된다. 예산 계산이 여기서 중요해진다 — 120Hz라면 화면은 8.3ms마다 갱신되고, 각 단계가 각각 8.3ms를 받는다. 총합 8.3ms가 아니다. 그리고 어느 한 단계라도 마감을 놓치면 프레임이 늦고, 그게 히치다.

CS 원리

이건 파이프라이닝이다. CPU 명령어 파이프라인과 원리가 같다 — 한 작업을 여러 단계로 쪼개고, 각 단계가 서로 다른 작업을 동시에 처리하게 한다.

파이프라이닝이 주는 것과 뺏는 것이 명확하다.

파이프라인 없음파이프라인 있음
한 프레임의 총 지연짧음 (단계 합)길어짐 (여러 프레임에 걸침)
처리량낮음 (한 번에 하나)높음 (프레임마다 하나)
각 단계 예산총 예산을 나눠 씀각각 전체 예산
병목의 영향전체가 느려짐한 단계만 늦어도 라인 전체가 밀림

세 번째 행이 실무적으로 가장 중요하다. 단계마다 프레임 하나 분량의 시간을 받는다는 뜻이므로, "메인스레드에서 8.3ms를 쓰면 렌더링할 시간이 없다"가 아니라 "메인스레드가 8.3ms를 쓰는 것 자체는 정상 예산 안"이다. 다만 그 예산을 넘기면 문제가 된다.

네 번째 행이 히치의 구조다. 조립 라인에서 한 공정이 늦으면 그 뒤 공정들이 전부 밀린다. 그래서 한 프레임의 지연이 여러 프레임의 지연으로 번진다 — Apple 문서가 "늦은 프레임 하나가 이후 프레임들을 건너뛰게 만드는 경우가 많다"고 설명하는 이유다.

VSYNC 간격 (120Hz = 8.3ms) Event 프레임 A 프레임 B 프레임 C Commit 프레임 A 프레임 B 프레임 C R.Prepare 프레임 A 프레임 B 프레임 C R.Execute 프레임 A 프레임 B 프레임 C Display 프레임 A 프레임 B 같은 시각에 다섯 단계가 서로 다른 프레임을 처리한다 → 각 단계는 프레임 하나 분량(8.3ms)을 통째로 받는다. 총합이 8.3ms가 아니다. 한 칸이라도 마감을 넘기면 뒤 칸이 전부 밀리고, 그것이 히치다.
조립 라인과 같다. 프레임 하나의 총 지연은 길어지지만, 매 VSYNC마다 완성된 프레임이 하나씩 나온다.

iOS에서는

각 단계에서 무슨 일이 일어나고 누가 책임지는지를 정리하면 이렇다.

단계하는 일주체늦으면
Event터치 이벤트를 처리하고 UI 변경이 필요한지 판단앱 메인스레드행 · commit hitch
CommitUI를 갱신하고 렌더 서버에 제출앱 메인스레드commit hitch
Render Prepare다음 VSYNC에 렌더 서버가 GPU 드로잉을 준비렌더 서버render hitch
Render ExecuteGPU가 최종 이미지를 그림GPUrender hitch
Display다음 VSYNC에 화면에 표시디스플레이

앞의 두 단계가 우리 코드이고, 뒤의 두 단계가 렌더 서버·GPU다. 20장 Q1에서 본 commit/render 구분이 정확히 이 경계다.

리프레시 레이트가 가변이라는 점도 중요하다. Apple 문서가 명시하듯 사용자 상호작용 중에는 기기가 지원하는 최대 리프레시 레이트로 갱신하고, 표시할 새 내용이 없으면 일부 디스플레이는 레이트를 낮춘다. 그래서 다음이 따라온다.

그리고 렌더 서버도 앱을 대신해 일한다는 점을 다시 짚어야 한다 — 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()
    }
}

CADisplayLinktargetTimestamp - 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 등으로 간접 확인

프로젝트 적용

파이프라인 구조를 알면 달라지는 판단 넷.

⚠️ 흔한 오해

"120Hz면 모든 작업을 8.3ms 안에 끝내야 한다"는 틀렸다. 각 단계가 각각 8.3ms를 받는다 — 파이프라인이라 동시에 진행되기 때문이다. "프레임 하나가 늦어도 하나만 늦다"도 틀렸다 — 뒤 단계들이 밀려 여러 프레임에 영향을 준다. "프레임 예산은 항상 16.67ms 또는 8.3ms"도 부정확하다 — 리프레시 레이트가 가변이라 상황마다 다르고, CADisplayLinktargetTimestamp로 실제 값을 알 수 있다. 또 "렌더 서버가 늦는 건 우리가 어쩔 수 없다"도 틀렸다 — 렌더 서버의 작업량을 정하는 것은 앱이다.

🧒 쉽게 이해하기

김밥 공장을 떠올려보자. 공정이 다섯 개다. 재료 준비 → 밥 펴기 → 재료 올리기 → 말기 → 썰기.

여기서 중요한 건 다섯 사람이 동시에 일한다는 것이다. 1번 김밥을 썰고 있을 때, 2번 김밥은 말고 있고, 3번 김밥은 재료를 올리는 중이다.

그래서 김밥 하나가 완성되기까지는 다섯 공정 시간이 다 걸리지만, 완성품은 한 공정 시간마다 하나씩 나온다. 이게 파이프라인이다.

여기서 자주 하는 착각 — "김밥 하나에 10초니까 각 공정은 2초 안에 끝내야겠네". 아니다. 각 공정이 10초씩 써도 된다. 동시에 돌아가니까 완성품은 여전히 10초마다 하나씩 나온다.

대신 무서운 게 있다. 한 사람이 12초를 쓰면 어떻게 될까? 그 사람 뒤의 공정들이 전부 2초씩 기다린다. 그리고 앞사람이 만든 김밥이 쌓이기 시작한다. 한 명이 늦은 것이 라인 전체를 흔든다.

마지막으로 — 주문이 몰릴 때는 라인 속도를 올린다. 그러면 각 공정에 주어진 시간이 짧아진다. 그래서 가장 바쁠 때를 기준으로 준비해야 한다.

꼬리 질문

CADisplayLinktargetTimestamp는 정확히 무엇을 뜻하는가?

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까지 기다려야 하고, 그 사이 앞 단계에서 만든 프레임은 갈 곳이 없어 대기한다.

구체적 연쇄는 이렇다.

  1. 커밋이 마감을 2ms 넘겼다.
  2. 렌더 서버는 이번 VSYNC에 받을 것이 없어 이전 프레임을 다시 표시한다(프레임 반복).
  3. 늦은 커밋은 다음 VSYNC에 처리되고, 그 뒤 단계들이 한 칸씩 밀린다.
  4. 그 사이 앱은 다음 프레임을 이미 준비했는데 라인이 밀려 있어 추가 지연이 생긴다.

Apple 문서도 이를 명시한다 — 늦은 프레임으로 인한 지연은 종종 시스템이 이후 하나 이상의 프레임을 건너뛰게 만들고, 그래서 이 현상을 프레임 드롭이라고도 부른다. 다만 프레임 드롭은 시스템의 대응 중 하나일 뿐이고, 모든 히치가 프레임 드롭을 유발하지는 않는다는 단서도 함께 붙는다.

실무적 함의는 "가끔 한 번 늦는 것"의 비용이 생각보다 크다는 것이다. 평균 프레임 시간이 좋아도 가끔의 큰 지연이 연쇄를 만들어 여러 프레임을 망친다 — 20장 Q1 꼬리질문에서 본 "평균은 히치를 숨긴다"의 메커니즘이 이것이다.

쉽게 말하면 김밥 라인에서 한 사람이 2초 늦었다. 그런데 라인은 정해진 신호에 맞춰 다음으로 넘긴다. 신호를 놓치면 다음 신호까지 기다려야 한다. 그동안 앞사람이 만든 것이 쌓이고, 뒷사람은 놀고, 결국 여러 개가 늦게 나간다.
리프레시 레이트가 가변이라는 사실이 애니메이션 구현에 주는 영향은?

세 가지를 조심해야 한다.

(1) 프레임 수로 시간을 세면 안 된다. "60프레임 동안 이동" 같은 코드는 120Hz에서 절반 시간에 끝난다. 반드시 시각 기반으로 계산해야 한다 — targetTimestampCACurrentMediaTime()을 쓴다.

(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에서 본 대로 유휴 구간의 낮은 레이트가 섞이면 평균이 왜곡되므로, 성능 지표는 상호작용 구간으로 한정해 보는 것이 정확하다.

쉽게 말하면 공장 라인 속도가 바쁠 땐 빠르고 한가할 땐 느리다. 그래서 "김밥 60개 만들면 한 시간"이라고 세면 틀린다. 시계를 봐야 한다. 그리고 급하지 않은 작업은 "천천히 해도 된다"고 알려주면 라인이 무리하지 않아 전기를 아낀다.

Q3. CA commit의 네 단계에서 무엇이 일어나는가?

🔑 30초 답변

커밋 트랜잭션은 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 원리

커밋이 여러 단계로 나뉘는 이유는 의존 관계 때문이다. 각 단계의 결과가 다음 단계의 입력이라 순서를 지켜야 한다.

  1. 크기와 위치가 정해져야 무엇을 그릴지 안다 → Layout이 먼저.
  2. 그려질 내용이 정해져야 GPU에 올릴 것을 준비할 수 있다 → Display가 그다음.
  3. 모든 준비가 끝나야 일관된 상태로 전송할 수 있다 → Commit이 마지막.

여기서 트랜잭션이라는 이름의 의미가 나온다 — 여러 변경을 모아 원자적으로 적용한다. 뷰 열 개의 위치를 바꾸면 열 번 전송되는 것이 아니라 한 번에 간다. 그래서 화면에 중간 상태가 보이지 않는다.

P0 09장에서 본 데이터베이스 트랜잭션과 성질이 비슷하다 — 일관성을 위해 변경을 묶고, 커밋 시점에 한꺼번에 반영한다.

커밋 트랜잭션 — 앞 단계 결과가 뒤 단계 입력이라 순서를 못 바꾼다 ① Layout 오토레이아웃 해 · layoutSubviews ② Display draw(_:) — CPU 래스터라이즈 ③ Prepare 이미지 디코딩 · 업로드 준비 ④ Commit 레이어 트리 직렬화·전송 ① ~ ④ 네 단계 전부 앱 프로세스 · 메인스레드 — 우리 예산을 직접 먹는다 커밋이 끝난 뒤에야 렌더 서버 · GPU가 이어받는다 — 그쪽은 우리 예산 밖 그래서 최적화는 "이 단계에서 빼는 것"이 아니라 "옮기는 것"이다 Prepare 에서 디코딩 메인스레드가 프레임 중에 지불 백그라운드에서 미리 디코딩 kCGImageSourceShouldCacheImmediately 같은 총 작업량이어도 지불하는 스레드가 바뀌면 히치가 사라진다
Layout · Display · Prepare · Commit 까지 네 단계 모두 앱 메인스레드에서 일어난다. 렌더 서버의 실제 렌더링만 예산 밖이다.

비용 구조에서 중요한 비대칭이 하나 있다. Layout과 Display는 CPU 작업이고 앱 프로세스에서 일어난다.메인스레드 예산을 직접 먹는다. 반면 실제 렌더링은 렌더 서버와 GPU가 하므로 우리 예산 밖이다. 그래서 커밋 비용을 줄이는 것과 렌더 비용을 줄이는 것은 다른 작업이다.

iOS에서는

단계별로 우리 코드가 개입하는 지점과 비용 요인을 정리하면 이렇다.

단계불리는 것비용 요인줄이는 법
LayoutlayoutSubviews, 오토레이아웃 해 찾기제약 개수, 중첩 깊이제약 단순화, 셀 높이 캐시
Displaydraw(_:), 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 단계에 그대로 얹힌다. 사전 디코딩 시 표준 포맷으로 정규화하면 사라진다.

프로젝트 적용

커밋 비용을 줄이는 실무 규칙 다섯.

⚠️ 흔한 오해

"UIImage(named:)로 미리 로드하면 디코딩도 끝난다"가 대표적 오해다. 디코딩은 실제로 그려질 때(Prepare 단계) 일어난다. "draw(_:)로 직접 그리는 게 레이어보다 빠르다"도 대체로 틀렸다 — drawCPU가 그리고 백킹 스토어 메모리도 쓴다. "커밋은 그냥 전송이라 비용이 없다"도 틀렸다 — 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 계측으로 실제로 어느 뷰가 오래 걸리는지 확인한 뒤에 손대야 한다.

쉽게 말하면 상자 크기를 정하는데 "A는 B보다 커야 하고, B는 C의 절반이고, C는 전체의 30%…" 같은 조건이 수십 개 얽혀 있으면 계산이 오래 걸린다. 조건이 적고 명확하면 금방 나온다. 그리고 매번 같은 계산을 반복하지 말고 결과를 적어두면 훨씬 빠르다.
이미지 포맷이 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. 오프스크린 렌더링은 왜 비싼가?

🔑 30초 답변

GPU가 한 번에 그리지 못하고 별도 버퍼에 먼저 그린 뒤 다시 합성해야 하기 때문이다. 비용은 두 갈래다 — 버퍼 할당·전환(GPU 파이프라인이 렌더 타깃을 바꾸는 것 자체가 비쌈)과 추가 메모리. 유발 요인은 마스크, 둥근 모서리와 clipsToBounds의 특정 조합, shadowPath 없는 그림자, 그룹 투명도, shouldRasterize 등이다. 결정적인 점은 이게 render hitch로 나타난다는 것이다(20장 Q1) — 메인스레드는 완전히 멀쩡한데 화면이 끊긴다. 그래서 Time Profiler로 아무리 봐도 안 보이고, 시뮬레이터의 Color Offscreen-Rendered나 Animation Hitches의 render 트랙으로 찾아야 한다.

CS 원리

GPU는 렌더 타깃(그릴 대상 버퍼)을 정해두고 그린다. 정상 경로에서는 화면 버퍼 하나에 레이어들을 순서대로 합성하며 그린다 — 한 번의 패스로 끝난다.

그런데 어떤 효과는 "먼저 완성된 결과가 있어야 적용할 수 있다". 마스크가 대표적이다 — 마스크를 적용하려면 마스크 대상 전체가 그려져 있어야 하고, 그러려면 별도 버퍼에 먼저 그려야 한다.

그래서 흐름이 이렇게 된다.

  1. GPU가 렌더 타깃을 임시 버퍼로 전환한다.
  2. 대상 레이어들을 그 버퍼에 그린다.
  3. 효과(마스크·블러 등)를 적용한다.
  4. 렌더 타깃을 화면 버퍼로 되돌린다.
  5. 결과를 화면에 합성한다.

비싼 이유는 1번과 4번의 전환이다. GPU는 파이프라인 구조라 렌더 타깃 전환이 파이프라인 플러시를 유발할 수 있다 — 진행 중인 작업을 마무리하고 상태를 바꿔야 한다. 이건 CPU의 분기 예측 실패나 캐시 미스와 비슷한 성격의 비용이다(P0 12장).

여기에 메모리 대역폭 비용이 더해진다. 임시 버퍼를 쓰고 다시 읽는 것은 메모리를 왕복하는 것이고, 모바일 GPU는 대역폭이 성능을 좌우하는 경우가 많다.

정상 경로 레이어들을 화면 버퍼에 순서대로 합성 표시 1 패스 오프스크린 (마스크·그림자) 레이어들을 전환 ① 임시 버퍼에 그림 효과 적용 전환 ② 화면에 합성 비싼 것은 그리기가 아니라 렌더 타깃 전환이다 GPU 파이프라인 플러시 + 메모리 왕복 — CPU 의 캐시 미스와 같은 성격 그래서 개수가 곧 비용이다 — 셀 20개면 프레임마다 40번 전환 shadowPath 없는 그림자 GPU 가 알파 채널을 분석해 모양을 찾음 → 오프스크린 ❌ shadowPath 지정 모양을 미리 알려주니 분석이 사라짐 → 오프스크린 없음 ✅ 부작용도 없음 단, bounds 가 바뀌면 layoutSubviews 에서 shadowPath 도 갱신해야 한다.
정상 경로는 한 번에 그리지만, 마스크·그림자는 "완성된 결과가 먼저 있어야" 하므로 버퍼를 오간다.

그래서 오프스크린 렌더링은 개수보다 전환 횟수가 문제다. 한 화면에 오프스크린 유발 요소가 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 RedshouldRasterize 판단의 결정적 근거다. 빨간색이 보이면 캐시가 매번 무효화되고 있다는 뜻이므로 즉시 꺼야 한다.

비용 차이를 직접 재보는 것도 유용하다.

/// 그림자 방식별 비용을 비교하는 실험 뷰
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 에서만 노란 영역이 뜬다.

프로젝트 적용

실무 규칙 다섯.

⚠️ 흔한 오해

"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이 된다.

쉽게 말하면 사진 100장을 하트 모양으로 오려야 한다면, 손님 앞에서 한 장씩 오리는 것보다 미리 오려서 서랍에 넣어두는 것이 훨씬 빠르다. 손님이 오면 꺼내기만 하면 된다.
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. 벡터 애니메이션은 무엇을 맞바꾸는가?

🔑 30초 답변

벡터 애니메이션은 미리 렌더된 픽셀 프레임(GIF·APNG·비디오)을 재생하는 대신, 도형·경로·키프레임 같은 벡터 데이터를 런타임에 실시간으로 그려낸다. 얻는 것은 해상도 독립성(어떤 크기에서도 선명), 작은 파일 크기, 런타임 변경 가능성(색·속도·구간을 코드로 조작)이다. 대신 CPU·GPU 시간을 매 프레임 지불한다 — 프리렌더는 디코딩만 하면 되지만 벡터는 계산해서 그려야 한다. 그래서 트레이드오프가 명확하다 — 파일 크기·유연성 ↔ 런타임 비용. 복잡한 애니메이션을 작은 화면에 여러 개 띄우면 프리렌더가 유리하고, 크기가 크거나 런타임 조작이 필요하면 벡터가 유리하다.

CS 원리

이건 시간-공간 트레이드오프의 전형이다. 미리 계산해두면(프리렌더) 공간을 쓰고 시간을 아끼고, 런타임에 계산하면(벡터) 시간을 쓰고 공간을 아낀다.

프리렌더 (GIF·APNG·비디오)벡터 (런타임 렌더)
파일 크기해상도 × 프레임 수에 비례도형 복잡도에만 비례
해상도고정 — 확대하면 깨진다독립 — 어떤 크기든 선명
런타임 CPU디코딩만경로 계산 + 래스터라이즈
메모리디코딩된 프레임 버퍼벡터 데이터 (작음)
런타임 변경불가색·속도·구간 조작 가능
비용 예측쉬움 (크기로 결정)어려움 (내용에 따라 급변)

마지막 행이 실무에서 자주 사고를 만든다. 프리렌더는 비용이 예측 가능하다 — 해상도와 프레임 수를 알면 대략 안다. 반면 벡터는 디자이너가 만든 내용에 따라 비용이 수십 배 달라진다. 도형 500개에 마스크가 겹친 애니메이션은 겉보기에 단순해도 렌더 비용이 폭발한다.

그리고 비용이 지불되는 위치도 다르다. 프리렌더 디코딩은 Q3에서 본 Prepare 단계 비용이고 백그라운드로 옮길 수 있다. 벡터 렌더링은 매 프레임 일어나므로 옮길 수 없다 — 프레임 예산 안에서 끝나야 한다.

iOS에서는

iOS에서 벡터 애니메이션을 다루는 방식은 크게 셋이다.

세 번째 계열에서 중요한 사실이 하나 있다 — 렌더 엔진이 둘로 나뉘는 구현이 흔하다. 대표적으로 Lottie는 Core Animation 엔진메인스레드 엔진 두 가지를 갖는다.

Core Animation 엔진메인스레드 엔진
그리는 주체렌더 서버앱 메인스레드
메인스레드 비용커밋 한 번매 프레임
지원 기능제한적 — 일부 효과 미지원더 넓음
결과엔진에 따라 렌더 결과가 다를 수 있다

마지막 행이 실무의 함정이다. 같은 애니메이션 파일인데 엔진에 따라 다르게 보일 수 있다. 성능을 위해 Core Animation 엔진으로 바꿨더니 일부 효과가 사라지거나 다르게 렌더되는 상황이 생긴다.

시간–공간 트레이드오프 + 같은 파일에 두 개의 구현 프리렌더 — GIF · APNG · 비디오 공간을 쓰고 시간을 아낀다 비용이 예측 가능 — 해상도 × 프레임 수 디코딩은 Prepare 단계 → 백그라운드로 옮길 수 있다 벡터 — 런타임 렌더 시간을 쓰고 공간을 아낀다 비용이 내용에 따라 수십 배 — 도형 수·마스크 매 프레임 발생 → 옮길 수 없다 게다가 벡터 라이브러리는 렌더 엔진이 둘로 갈리는 구현이 흔하다 animation.json (같은 파일 하나) Core Animation 엔진 렌더 서버가 그린다 · 메인 비용 = 커밋 1회 · 일부 효과 미지원 메인스레드 엔진 앱이 그린다 · 매 프레임 비용 · 기능은 더 넓다
"파일을 넣으면 돌아간다"는 인터페이스 뒤에 성능도 렌더 결과도 다른 두 구현이 숨어 있다.

이건 추상화 뒤에 성능 특성과 동작이 함께 숨는 전형적 사례다 — 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

프로젝트 적용

선택 기준과 운영 규칙 다섯.

⚠️ 흔한 오해

"벡터니까 가볍다"가 가장 흔한 오해다. 파일은 작지만 런타임 비용은 클 수 있다 — 파일 크기와 렌더 비용은 별개 축이다. "애니메이션 파일을 넣으면 어디서든 똑같이 보인다"도 틀렸다 — 렌더 엔진에 따라 결과가 다를 수 있다. "성능이 나쁘면 엔진만 바꾸면 된다"도 위험하다 — 지원 기능이 달라 렌더 결과가 바뀔 수 있으므로 시각 검증이 필요하다. "애니메이션 하나는 괜찮으니 열 개도 괜찮다"도 틀렸다 — 개수에 비선형으로 악화되는 경우가 많다. 또 디자인 단계에서 복잡도를 통제하지 않으면 완성된 뒤에는 고치기가 훨씬 비싸다.

🧒 쉽게 이해하기

애니메이션을 만드는 두 가지 방법이 있다.

방법 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에서 레이어 수를 세어 상한을 넘으면 경고하면, 디자인 단계에서 조정된다. 완성 후에 "성능 때문에 못 쓴다"고 하면 재작업 비용이 크고 관계도 나빠진다.

가장 효과적인 것은 디자이너가 실기기에서 직접 확인하게 하는 것이다. 구형 기기 한 대를 두고 프리뷰 앱으로 바로 확인할 수 있게 해두면, 기준을 문서로 설명하는 것보다 훨씬 빠르게 감이 생긴다.

쉽게 말하면 "가볍게 만들어주세요"는 아무 기준도 안 준다. 대신 "부품 100개 이하, 겹치는 층 2개까지, 크기는 손바닥만 하게"라고 정하면 만드는 사람이 판단할 수 있다. 그리고 완성 전에 실제로 돌려보게 하면 말로 설명할 필요가 없어진다.

Q6. 렌더 히치는 어떻게 진단하는가?

🔑 30초 답변

순서가 정해져 있다. (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 hitchrender hitch
병목 위치앱 메인스레드렌더 서버 · GPU
주 원인Layout, Display(draw), 이미지 디코딩오프스크린, 블렌딩, 레이어 수, 블러
Time Profiler유효거의 안 보인다
주 도구Time Profiler, HangsColor 시각 진단, Metal Trace
행과의 관계같은 원인 — 행을 고치면 함께 줄어든다무관 — 별도로 잡아야 한다

마지막 행이 중요하다. 20장 Q1에서 본 Apple의 지침 — 행을 다 고치면 히치도 상당수 사라진다 — 는 commit hitch에만 해당한다. render hitch는 행 도구로 잡히지 않으므로 남는다. 그래서 "행을 다 고쳤는데 여전히 끊긴다"면 render 쪽을 봐야 한다.

가장 강력한 첫 질문 — "commit 인가 render 인가" Animation Hitches 계측기 commit hitch 병목: 앱 메인스레드 원인: Layout · draw · 이미지 디코딩 도구: Time Profiler · Hangs 행을 고치면 함께 줄어든다 render hitch 병목: 렌더 서버 · GPU 원인: 오프스크린 · 블렌딩 · 레이어 수 도구: Color 시각 진단 · Metal Trace 행 도구로는 안 잡힌다 — 그대로 남는다 Time Profiler 유효 Time Profiler 에 거의 안 보인다 "행을 다 고쳤는데 여전히 끊긴다" — 그건 오른쪽이다
이 한 질문으로 후보가 절반으로 줄고, 이후 쓸 도구와 최적화 방향이 완전히 갈린다.

두 번째 원리는 시각 진단의 효율이다. 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의 "차원을 살려 보내기"가 여기서 실제 이득을 준다.

프로젝트 적용

운영 규칙 다섯.

⚠️ 흔한 오해

"스크롤이 끊기면 메인스레드부터 본다"가 가장 흔한 시간 낭비다. render hitch면 Time Profiler에 아무것도 안 나온다. "행을 다 고쳤으니 히치도 없어졌을 것"도 틀렸다 — render hitch는 행과 무관하다. "시뮬레이터 Color 도구는 참고용"도 과소평가다 — 오프스크린·블렌딩 진단에는 가장 빠른 수단이다(다만 GPU 성능 자체는 실기기에서 봐야 한다). "히치율이 좋으면 렌더링에 문제없다"도 조심할 부분이다 — 최신 기기 기준일 수 있다. 또 CADisplayLink로 자체 계측할 때 무효화를 잊으면 그 자체가 배터리 문제가 된다(20장 Q2).

🧒 쉽게 이해하기

김밥이 늦게 나온다. 어디를 봐야 할까?

가장 먼저 물을 것"준비하는 쪽이 늦나, 마는 쪽이 늦나?" 이 질문 하나로 절반이 걸러진다. 준비가 늦는데 마는 사람만 재촉해봐야 아무 소용이 없다.

마는 쪽(렌더 서버)이 늦다면, 눈으로 보는 게 가장 빠르다. 작업대에 색깔 표시를 켜본다.

  • 노란 부분 — 여기는 한 번에 못 만들고 따로 만들었다 붙이는 곳이다.
  • 빨간 부분 — 재료가 여러 겹 겹쳐 있어 손이 많이 간다.
  • 청록 부분 — 규격이 안 맞아 매번 다시 손질하고 있다.

이렇게 보면 "어느 부분이 비싼지"가 즉시 보인다. 반면 준비하는 사람의 손놀림을 초 단위로 기록하는 장비(프로파일러)는 여기서 아무것도 못 알려준다 — 그 사람은 놀고 있으니까.

마지막으로 — 제일 손이 느린 사람 기준으로 확인해야 한다. 숙련자한테는 괜찮은 작업이 신입한테는 무리일 수 있다.

꼬리 질문

시뮬레이터 Color 도구 결과를 실기기에 어디까지 신뢰할 수 있는가?

"무엇이 일어나는가"는 신뢰할 수 있고, "얼마나 비싼가"는 신뢰할 수 없다가 정확한 구분이다.

신뢰할 수 있는 것 — 오프스크린 렌더링 발생 여부, 블렌딩 겹침 구조, 이미지 포맷 불일치, 픽셀 정렬 어긋남. 이것들은 렌더링 로직의 결과라 시뮬레이터와 실기기가 대체로 같다.

신뢰할 수 없는 것 — 실제 프레임 시간, GPU 부하, 히치 발생 여부. 시뮬레이터는 Mac의 CPU·GPU를 쓰므로 성능 특성이 완전히 다르다. 시뮬레이터에서 매끄러운 것이 실기기에서 끊기고, 그 반대도 있다.

그래서 실무 절차는 2단계가 된다.

  1. 시뮬레이터에서 구조적 문제를 찾는다. Color 도구로 오프스크린과 블렌딩을 제거한다. 빠르고 반복이 쉽다.
  2. 실기기에서 성능을 확인한다. 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 문제다.

쉽게 말하면 공장에 자체 스톱워치를 두는 것이다. 본사에서 주는 월간 보고서보다 빠르고 자세하다 — "어느 라인에서 늦었는지"까지 안다. 대신 재는 사람이 계속 붙어 있어야 하니, 필요한 라인에서만 켜고 본사 숫자와 가끔 맞춰본다.

출처 · 참고자료