← Mobile Foundation 인터뷰 11 / 13

11 · UITableViewCell 성능 최적화

프레임 예산 → Layout·Display·Commit → 객체 풀링과 데드라인 스케줄링
진행률
0 / 0 완료
면접에서 나오는 형태
  1. TableView 스크롤이 끊길 때 무엇부터 확인하나요?
  2. 셀 재사용은 필요한가요? 그것만으로 충분한가요?
  3. 실제로 어떻게 측정하고 무엇을 고쳤나요?
L1 개념
스크롤은 프레임 예산 싸움 — 60Hz면 16.7ms, 120Hz면 8.3ms
L2 설계
원칙은 하나 — cellForRowAtlayoutSubviews에서 예산을 쓰지 않는다. 미리 하거나, 옮기거나, 없앤다
L3 구현
병목은 Layout · Display · Commit 셋 중 하나. Time Profiler 스택에 이름이 그대로 나온다
L4 CS
객체 풀링 · 소프트 실시간 데드라인 — 프레임을 놓치면 결과가 늦는 게 아니라 틀린다

30초 답변 🔥 먼저 이 문장

스크롤은 프레임 예산 싸움입니다. 60Hz면 16.7ms, 120Hz(ProMotion)면 8.3ms 안에 Layout → Display → Commit을 끝내야 합니다.

그래서 최적화 원칙은 하나예요 — cellForRowAtlayoutSubviews에서 예산을 쓰지 않는다. 비싼 일은 ① 미리 하거나(prefetch·캐시), ② 다른 스레드로 보내거나(디코딩), ③ 아예 하지 않게(계층 축소) 만듭니다.

다만 어느 단계가 병목인지 먼저 확인해야 합니다. Layout이 문제인데 이미지를 최적화하면 아무 소용이 없거든요.

L1개념 — 프레임 예산과 파이프라인

한 프레임 — 이 전체가 16.7ms(60Hz) / 8.3ms(120Hz) 안에 메인 스레드 (앱 프로세스) 렌더 서버 (별도 프로세스) 이벤트 터치 처리 cellForRowAt Layout layoutSubviews Auto Layout Display draw(_:) Core Graphics Commit 레이어 인코딩 IPC 전송 합성 GPU 표시 VSync Time Profiler에서 이 이름을 찾아라 — layoutSubviews / NSISEngine → Layout -[CALayer display] → Display CA::Transaction::commit → Commit 놓치면 어떻게 되나 16.7ms를 넘기면 화면은 이전 프레임을 그대로 한 번 더 보여준다 → 사용자는 "끊김(hitch)"으로 느낀다. 17ms가 걸리면 17ms 늦는 게 아니라 33ms 늦는다 — 다음 VSync까지 기다려야 하므로.
병목이 어느 칸인지 모르면 최적화는 추측이 된다. 먼저 이름을 찾고, 그다음에 그 칸에 맞는 처방을 쓴다.

L2설계 — 항목별 처방과 코드

A. 재사용 제대로 쓰기

prepareForReuse에서 "이전 상태 초기화 + 진행 중 작업 취소"
final class FeedCell: UITableViewCell {
    private let thumbnail = UIImageView()
    private var imageTask: Task<Void, Never>?
    private(set) var representedID: UUID?

    override func prepareForReuse() {
        super.prepareForReuse()
        imageTask?.cancel()            // ✅ 진행 중 다운로드 취소
        imageTask = nil
        thumbnail.image = nil          // ✅ 이전 이미지 제거 (안 하면 깜빡임)
        representedID = nil
        accessoryType = .none          // ✅ 이전 셀의 상태가 남지 않게
        contentView.alpha = 1          // ✅ 애니메이션 중이었을 수 있다
    }

    func configure(_ item: Item, loader: ImageLoader) {
        representedID = item.id
        titleLabel.text = item.title

        imageTask = Task { [weak self] in
            guard let image = try? await loader.image(for: item.imageURL) else { return }
            // ✅ 재사용 검증 — 응답이 늦게 오면 다른 셀이 되어 있을 수 있다
            guard self?.representedID == item.id else { return }
            self?.thumbnail.image = image
        }
    }
}
🚫 "이미지가 엉뚱한 셀에 뜨는" 버그의 정체

셀 A가 이미지를 요청하고 스크롤로 재사용되어 셀 B가 됐는데, A의 응답이 뒤늦게 도착하면 B에 A의 이미지가 박힙니다. 이건 네트워크 버그가 아니라 race condition입니다 — 요청 시점과 응답 시점의 대상이 달라진 것이죠. 그래서 ①취소 + ②재사용 검증 둘 다 필요합니다. 취소만으로는 이미 날아간 응답을 막지 못합니다.

B. 높이 계산 비용 없애기

셀프사이징은 편하지만 매번 오토레이아웃을 푼다
// ── 최선: 고정 높이 ──
tableView.rowHeight = 88            // 계산 자체가 없다

// ── 차선: 계산해서 캐시 ──
@MainActor
final class HeightCache {
    private var cache: [UUID: CGFloat] = [:]
    private var cachedWidth: CGFloat = 0

    func height(for item: Item, width: CGFloat, font: UIFont) -> CGFloat {
        if width != cachedWidth {           // 회전 등으로 너비가 바뀌면 전부 무효
            cache.removeAll()
            cachedWidth = width
        }
        if let hit = cache[item.id] { return hit }

        let bounding = (item.title as NSString).boundingRect(
            with: CGSize(width: width - 32, height: .greatestFiniteMagnitude),
            options: [.usesLineFragmentOrigin, .usesFontLeading],
            attributes: [.font: font], context: nil)
        let h = ceil(bounding.height) + 24
        cache[item.id] = h
        return h
    }
}

func tableView(_ tv: UITableView, heightForRowAt ip: IndexPath) -> CGFloat {
    heightCache.height(for: items[ip.row], width: tv.bounds.width, font: .preferredFont(forTextStyle: .body))
}

// ── 셀프사이징을 쓸 때는 추정값을 실제와 비슷하게 ──
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 88        // 실제와 멀면 스크롤바가 튄다
// 정확한 높이를 직접 준다면 추정을 끄는 게 낫다
tableView.estimatedRowHeight = 0

C. cellForRowAt에서 하면 안 되는 것

❌ → ✅ 대표적인 네 가지
// ❌ ① DateFormatter를 매번 생성 — 매우 비싸다 (로케일·캘린더 초기화)
func tableView(_ tv: UITableView, cellForRowAt ip: IndexPath) -> UITableViewCell {
    let f = DateFormatter()
    f.dateFormat = "yyyy.MM.dd"
    cell.dateLabel.text = f.string(from: item.date)
    return cell
}
// ✅ 재사용 (또는 iOS 15+ 의 formatted API)
enum Formatters {
    static let date: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "yyyy.MM.dd"
        f.locale = Locale(identifier: "ko_KR")
        return f
    }()
}
cell.dateLabel.text = Formatters.date.string(from: item.date)

// ❌ ② NSAttributedString을 매번 생성
cell.body.attributedText = NSAttributedString(string: item.text, attributes: attrs)
// ✅ 캐시
final class TextCache {
    private var cache: [UUID: NSAttributedString] = [:]
    func attributed(_ item: Item) -> NSAttributedString {
        if let hit = cache[item.id] { return hit }
        let s = NSAttributedString(string: item.text, attributes: attrs)
        cache[item.id] = s
        return s
    }
}

// ❌ ③ 디스크 I/O · JSON 파싱 · 정규식 컴파일
let json = try! Data(contentsOf: fileURL)     // 💥 메인 스레드 블로킹

// ❌ ④ 셀 안에서 매번 뷰 생성
cell.contentView.addSubview(UILabel())        // 💥 재사용될수록 쌓인다
// ✅ 셀 init에서 한 번만 만들고 configure에서 값만 바꾼다

D. 이미지 — 가장 흔한 병목

다운샘플링 + 백그라운드 디코딩
import ImageIO
import UIKit

// ✅ 필요한 크기만 디코딩한다 — 원본을 통째로 메모리에 올리지 않는다
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let srcOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions) else { return nil }

    let maxPixel = max(pointSize.width, pointSize.height) * scale
    let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,      // 지금 디코딩해 둔다
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixel
    ] as CFDictionary

    guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, options) else { return nil }
    return UIImage(cgImage: cg)
}

// 왜 필요한가 — 메모리 계산
// 4000×3000 원본 = 4000 × 3000 × 4바이트 ≈ 48MB (디코딩된 비트맵)
// 100pt 셀 @3x = 300×300 × 4 ≈ 0.36MB
// → 셀 20개면 960MB vs 7MB. Jetsam이 도는 이유가 이거다. (09번)

// ✅ 백그라운드에서 미리 디코딩해 두면 메인 스레드가 안 멈춘다
actor ImagePipeline {
    private let cache = NSCache<NSURL, UIImage>()

    func image(for url: URL, size: CGSize, scale: CGFloat) async -> UIImage? {
        if let hit = cache.object(forKey: url as NSURL) { return hit }
        let img = await Task.detached(priority: .utility) {
            downsample(imageAt: url, to: size, scale: scale)
        }.value
        if let img { cache.setObject(img, forKey: url as NSURL) }
        return img
    }
}
📝 UIImage(named:)는 왜 첫 표시가 느린가

UIImage지연 디코딩합니다. 생성 시점에는 파일 참조만 들고 있다가, 실제로 그려질 때 압축을 풀어 비트맵을 만듭니다. 그리고 그 시점이 메인 스레드의 Display 단계입니다 — 정확히 프레임 예산 안이죠. 그래서 스크롤 중에 새 이미지가 나타날 때 끊깁니다. 해법은 디코딩을 미리, 백그라운드에서 끝내 두는 것입니다.

E. 렌더링 비용 줄이기

Commit·Display 단계를 가볍게
// ✅ ① 불투명하게 — 블렌딩 계산이 사라진다
cell.contentView.isOpaque = true
cell.contentView.backgroundColor = .systemBackground    // 투명이면 의미 없다

// ✅ ② 그림자에는 반드시 shadowPath — 없으면 매번 알파 채널을 스캔한다
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
layer.shadowOpacity = 0.15
layer.shouldRasterize = true                 // 정적이라면
layer.rasterizationScale = UIScreen.main.scale   // ⚠️ 안 주면 흐려진다

// ✅ ③ 계층을 얕게 — 중첩 스택뷰는 제약 수가 빠르게 늘어난다
// ❌ VStack(HStack(VStack(...)))  같은 4중 중첩
// ✅ 정적인 부분은 한 장의 이미지로 합성하거나, 프레임 기반 배치 고려

// ✅ ④ draw(_:)를 구현하지 않는다 — 구현하면 백킹스토어가 생긴다
// ❌ override func draw(_ rect: CGRect) { /* 원 그리기 */ }
// ✅ layer 속성으로 대체
view.layer.cornerRadius = 8
view.backgroundColor = .systemRed

// ⚠️ 정확히 말하기: "cornerRadius는 무조건 느리다"는 옛말이다.
//    단순한 cornerRadius는 최적화되어 있고,
//    masksToBounds·mask·그룹 불투명과 겹칠 때 오프스크린 렌더가 발생한다.
//    확인: Instruments → Core Animation → Color Offscreen-Rendered Yellow

F. 미리 하기

Prefetching + Diffable Data Source
// ✅ 화면에 나타나기 전에 미리 받아 둔다
extension FeedViewController: UITableViewDataSourcePrefetching {
    func tableView(_ tv: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        for ip in indexPaths {
            pipeline.prefetch(items[ip.row].imageURL)
        }
    }
    func tableView(_ tv: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
        for ip in indexPaths {
            pipeline.cancelPrefetch(items[ip.row].imageURL)   // ✅ 취소도 반드시
        }
    }
}
tableView.prefetchDataSource = self

// ✅ Diffable — 인덱스 불일치 크래시가 구조적으로 사라진다
private lazy var dataSource = UITableViewDiffableDataSource<Section, Item.ID>(
    tableView: tableView
) { [weak self] tv, ip, id in
    let cell = tv.dequeueReusableCell(withIdentifier: "cell", for: ip) as! FeedCell
    if let item = self?.itemsByID[id] { cell.configure(item, loader: self!.pipeline) }
    return cell
}

func apply(_ items: [Item], animating: Bool) {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Item.ID>()
    snapshot.appendSections([.main])
    snapshot.appendItems(items.map(\.id))
    dataSource.apply(snapshot, animatingDifferences: animating)
}
// ⚠️ 항목 ID를 쓰면 "내용만 바뀐 경우" 갱신이 안 될 수 있다
//    → reconfigureItems(_:) (iOS 15+) 로 셀 재구성만 요청한다

L3구현 — 측정 없이 최적화하지 않는다

병목을 특정하는 순서
# ── ① Instruments → Time Profiler ──
#    · 메인 스레드만 필터 (Main Thread)
#    · "Invert Call Tree" 로 실제 시간을 쓰는 리프 함수를 본다
#    · 찾을 이름:
#        layoutSubviews / NSISEngine::...        → Layout 병목
#        -[CALayer display] / CGContext...       → Display 병목
#        CA::Transaction::commit                 → Commit 병목
#        objc_msgSend 가 상위                    → 호출 횟수 자체가 과다
#        swift_retain / swift_release 가 상위    → ARC 과다 (04번)

# ── ② Instruments → Animation Hitches ──
#    hitch 시간과 원인 단계를 직접 보여준다. 가장 빠른 진단.

# ── ③ Core Animation 디버그 옵션 ──
#    Color Blended Layers        → 빨간 영역 = 블렌딩 (isOpaque 확인)
#    Color Offscreen-Rendered    → 노란 영역 = 오프스크린 렌더
#    Color Misaligned Images     → 보라 = 픽셀 정렬 안 됨 (소수점 frame)

# ── ④ os_signpost 로 내 코드 구간 직접 측정 ──
os_signpost — "얼마나 걸렸다"를 숫자로 말할 근거
import OSLog

let signposter = OSSignposter(subsystem: "com.app.feed", category: "cell")

func tableView(_ tv: UITableView, cellForRowAt ip: IndexPath) -> UITableViewCell {
    let state = signposter.beginInterval("configureCell", id: signposter.makeSignpostID())
    defer { signposter.endInterval("configureCell", state) }

    let cell = tv.dequeueReusableCell(withIdentifier: "cell", for: ip) as! FeedCell
    cell.configure(items[ip.row], loader: pipeline)
    return cell
}
// Instruments의 os_signpost 트랙에 구간별 시간이 그려진다.
// "cellForRowAt이 평균 0.8ms → 0.2ms로 줄었습니다" 같은 말을 할 수 있게 된다.
MetricKit — 실사용자 기기의 hitch를 수집한다
import MetricKit

final class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
    override init() {
        super.init()
        MXMetricManager.shared.add(self)
    }
    func didReceive(_ payloads: [MXMetricPayload]) {
        for p in payloads {
            // 스크롤 끊김의 사용자 지표
            if let hitch = p.animationMetrics?.scrollHitchTimeRatio {
                print("scroll hitch ratio: \(hitch)")
            }
        }
    }
}
// 시뮬레이터·개발 기기 성능은 실사용자와 다르다.
// "구형 기기에서만 끊긴다"를 잡으려면 필드 데이터가 필요하다.

L4CS 근본 — 풀링과 데드라인

① 셀 재사용은 "객체 풀링" 패턴이다

왜 재사용이 필수인가 — 두 가지 자원 문제
// 항목이 10,000개인 리스트를 생각해 보자

// ❌ 순진한 구현: 항목마다 뷰 하나
//    메모리:   10,000 × (뷰 객체 + 백킹스토어) → 수백 MB → Jetsam (09번)
//    Commit:   레이어 10,000개를 매 프레임 인코딩 → 예산 초과
//    할당:     스크롤 중 계속 malloc/free → 단편화 + ARC 부하

// ✅ 재사용 풀: 화면에 보이는 수 + α 만 유지
//    메모리:   ~15개 × 뷰 → 수 MB
//    Commit:   레이어 ~15개
//    할당:     초기 15회 후 0회 — 스크롤 중 할당이 아예 없다
//
//    ★ 자원 소비가 "데이터 개수 N"이 아니라 "화면 크기"에 비례한다.
//      O(N) → O(1). 이게 재사용의 본질이다.

이건 객체 풀링(object pooling)이라는 고전 패턴입니다. 게임 엔진의 총알·파티클, 데이터베이스 커넥션 풀, 스레드 풀이 전부 같은 아이디어예요 — "생성·파괴가 비싸면, 만들어 두고 상태만 초기화해서 다시 쓴다."

prepareForReuse"객체를 파괴하는 대신 상태를 초기화하는" 지점인 것도 이 패턴의 표준 구조입니다. 그리고 풀링의 고전적 위험도 그대로 따라옵니다 — 초기화를 빠뜨리면 이전 사용자의 상태가 새 사용자에게 새어 나갑니다. "이전 셀의 이미지가 잠깐 보이는" 현상이 정확히 그것입니다.

② 프레임 예산은 소프트 실시간 데드라인이다

실시간 시스템의 분류로 보면 성격이 명확해진다
경성 실시간 (hard real-time)
  데드라인을 놓치면 → 시스템 실패 (에어백, 항공 제어)

연성 실시간 (soft real-time)   ← UI 렌더링이 여기
  데드라인을 놓치면 → 결과의 "가치"가 떨어진다 (끊김, 나쁜 경험)

일반 배치 처리
  데드라인 없음 → 늦어도 결과는 유효하다

// ★ 핵심 차이:
//   배치 작업은 17ms 걸리면 "17ms 늦은 정답"이다.
//   프레임은 17ms 걸리면 "그 프레임을 놓친 것"이다 —
//   늦은 정답이 아니라 아예 표시되지 않는다.
//
//   그리고 다음 VSync까지 기다려야 하므로
//   1ms 초과가 16.7ms 지연으로 증폭된다. 벼랑 효과(cliff)다.
1ms 초과가 16.7ms 지연이 되는 이유 0ms16.733.3 50.066.7 VSync 정상 14ms 작업 ✓ 14ms 작업 ✓ 14ms 작업 ✓ 초과 18ms 작업 — 1.3ms 초과 놓친 프레임 (같은 화면 재표시) ↑ 사용자가 "끊겼다"고 느끼는 지점 17ms가 걸리면 17ms 늦는 게 아니라 33ms 늦는다 — 다음 VSync까지 기다려야 하므로 그래서 "평균 프레임 시간"이 아니라 "초과한 프레임의 비율(hitch ratio)"로 재야 한다
평균 12ms라도 가끔 20ms가 나오면 사용자는 끊김을 느낍니다. 성능 지표로 평균이 아니라 최악값과 초과 비율을 봐야 하는 이유가 이 벼랑 효과입니다.

③ "미리 하기"는 지연 시간을 처리량과 맞바꾸는 것이다

최적화 기법들의 정체를 CS 용어로 번역하면
셀 재사용            = 객체 풀링          — 할당 비용을 O(N)→O(1)
높이 캐싱            = 메모이제이션       — 계산을 재사용
prefetching          = 예측적 선반입      — 유휴 시간을 미리 소비
백그라운드 디코딩    = 작업 오프로딩      — 임계 경로에서 빼내기
다운샘플링           = 해상도 축소        — 필요한 만큼만 계산
NSCache              = 캐시 + 방출 정책   — 메모리 압박 시 자동 해제
Diffable snapshot    = 증분 갱신          — 전체 재계산 회피 (12번과 같은 아이디어)
지연 디코딩          = 지연 평가          — 필요할 때까지 미루기 (04번 CoW와 같은 계열)

// ★ 관통하는 원리는 둘뿐이다:
//   ① 임계 경로(critical path)에서 일을 빼낸다 — 미리 / 나중에 / 다른 스레드로
//   ② 같은 일을 두 번 하지 않는다 — 캐시 · 재사용 · 증분
🧠 L4 prefetch의 트레이드오프도 정직하게

미리 받아 두는 것은 공짜가 아닙니다. 쓰지 않을 데이터를 받으면 대역폭·배터리·메모리를 낭비합니다. 그래서 cancelPrefetchingForRowsAt이 API에 있는 겁니다 — 예측이 빗나갔을 때 되돌리라고요. 이건 CPU의 투기적 실행(speculative execution)과 같은 구조입니다 — 예측하고, 빗나가면 버린다. 05번의 분기 예측과 정확히 같은 아이디어가 애플리케이션 층에서 반복되는 셈입니다.

CS 정본으로 더 내려가기

이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.

경험으로 말하기

🎤 측정 → 병목 특정 → 처방 → 재측정 → 안 한 것

"셀을 재사용하고 이미지를 캐싱해서 스크롤을 개선했습니다."

"피드 스크롤이 구형 기기에서 끊겼습니다. Animation Hitches로 보니 hitch가 대부분 Display 단계에 몰려 있었고, Time Profiler에서 -[CALayer display] 아래 이미지 디코딩이 상위였습니다. UIImage(data:)지연 디코딩이라 첫 표시 때 메인 스레드에서 압축을 푸는 게 원인이었습니다.

CGImageSourceCreateThumbnailAtIndex셀 크기만큼만 백그라운드에서 디코딩하도록 바꿨고, os_signpost로 재보니 cellForRowAt 평균이 0.8ms에서 0.2ms로 떨어졌습니다. 부수 효과로 메모리 피크도 크게 줄었습니다 — 원본 비트맵을 안 들고 있게 됐으니까요.

반면 셀 계층 최적화는 안 했습니다. Layout 단계는 애초에 예산의 10% 정도라 병목이 아니었고, 계층을 건드리면 회귀 위험만 컸습니다."

이 답의 강점: 도구 이름, 실제 병목 단계, 원인의 메커니즘, 숫자, 그리고 안 한 것과 그 이유가 전부 있습니다.

꼬리 질문 대비

L2 셀 재사용만 하면 충분한가요?

재사용은 최적화가 아니라 기본값입니다. 그것만으로 부족한 이유가 명확합니다.

재사용이 해결하는 것은 "뷰 객체를 몇 개 만들 것인가"입니다. 즉 메모리와 Commit 단계의 레이어 수를 잡아 주죠.

하지만 해결하지 못하는 것이 더 많습니다.

  • 재사용될 때마다 configure가 불립니다 — 그 안에서 DateFormatter를 만들면, 재사용을 해도 스크롤할 때마다 비싼 일을 반복합니다.
  • Layout 단계 — 셀프사이징은 셀마다 오토레이아웃을 풉니다. 재사용과 무관합니다.
  • Display 단계 — 이미지 디코딩, draw(_:), 오프스크린 렌더. 재사용과 무관합니다.
  • Commit 단계 — 셀 하나 안의 레이어가 20개면, 재사용해도 매 프레임 20개를 인코딩합니다.

그래서 답은 "재사용은 O(N)을 O(1)로 만드는 전제 조건이고, 그 O(1) 안에서 무엇을 하느냐가 실제 최적화"입니다.

쉽게: 컵을 일회용 대신 씻어 쓰기로 한 것과 같습니다. 컵 개수 문제는 해결됐지만, 컵마다 손으로 5분씩 닦고 있으면 여전히 줄이 밀립니다.
L3 어느 단계가 병목인지 어떻게 아나요?

Time Profiler 스택에 이름이 그대로 나옵니다. 이걸 외워 두면 진단이 빨라집니다.

스택에서 찾을 이름과 처방
layoutSubviews / NSISEngine::optimize
  → Layout 병목
  처방: 계층 얕게, 제약 수 줄이기, 높이 캐싱, 고정 높이

-[CALayer display] / CGContextDrawImage / CGBitmapContext
  → Display 병목
  처방: 이미지 다운샘플링·백그라운드 디코딩, draw(_:) 제거,
        오프스크린 렌더 회피

CA::Transaction::commit
  → Commit 병목
  처방: 레이어 개수 줄이기, 백킹스토어 크기 줄이기

swift_retain / swift_release 가 상위
  → ARC 과다 (04번) — 값 타입 검토

objc_msgSend 가 상위
  → 호출 횟수 자체가 과다 — 알고리즘이나 호출 구조를 봐야 한다

더 빠른 방법은 Animation Hitches 템플릿입니다. hitch가 발생한 프레임과 어느 단계에서 초과했는지를 바로 보여줍니다.

그리고 Core Animation 디버그 옵션은 눈으로 즉시 확인할 수 있어서 초기 스크리닝에 좋습니다 — Color Blended Layers(빨강 = 블렌딩), Color Offscreen-Rendered(노랑 = 오프스크린).

쉽게: 배가 아플 때 어디가 아픈지부터 물어보는 것과 같습니다. 위인지 장인지 모르고 아무 약이나 먹으면 안 낫거나 더 나빠집니다.
L4 왜 평균 프레임 시간이 아니라 최악값을 봐야 하나요?

프레임 예산이 데드라인이기 때문입니다. 그리고 데드라인 시스템에서는 평균이 거의 쓸모없는 지표입니다.

① 벼랑 효과. 16.7ms를 1ms 초과하면 1ms 늦는 게 아니라 한 프레임을 통째로 놓칩니다. 다음 VSync까지 기다려야 하니까요. 즉 비용이 연속이 아니라 계단식입니다.

② 평균은 초과를 숨깁니다. 평균 10ms인데 100프레임 중 5프레임이 25ms라면, 평균은 훌륭하지만 사용자는 1초에 세 번 끊김을 봅니다. 사람은 부드러움이 아니라 끊김을 인지합니다.

③ 그래서 지표가 따로 있습니다. Apple의 hitch time ratio는 "총 스크롤 시간 중 프레임이 늦어진 시간의 비율"입니다 — 평균이 아니라 초과의 누적을 봅니다. MetricKit의 scrollHitchTimeRatio가 그것입니다.

이건 성능 공학의 일반 원칙이기도 합니다. 서버에서도 평균 응답 시간 대신 p95·p99를 보죠 — 사용자 경험은 최악값이 지배하기 때문입니다. 프레임 렌더링은 그 원칙이 가장 극단적으로 나타나는 경우입니다. 초당 60번의 데드라인이 있으니, 1%만 놓쳐도 1초에 한 번 가까이 끊깁니다.

쉽게: 버스가 평균 5분 간격이라도, 가끔 30분씩 안 오면 사람들은 "이 노선 엉망"이라고 합니다. 평균은 좋아도 기다린 사람의 기억은 최악값이 만듭니다.

쉽게 이해하기

스크롤을 초당 60번 사진을 찍어 넘겨 보여주는 것이라고 생각해 봅시다. 사진 한 장을 만들 시간은 16.7밀리초뿐입니다.

여기서 가장 중요한 사실이 있습니다. 17밀리초가 걸리면 "0.3밀리초 늦는" 게 아닙니다. 그 사진은 아예 못 나가고, 앞 사진을 한 번 더 보여줍니다. 그리고 다음 기회는 16.7밀리초 뒤죠. 0.3 늦으면 16.7 늦는 겁니다. 이래서 "평균은 괜찮은데 가끔 끊긴다"가 생깁니다.

그럼 셀 재사용은 뭘까요? 뷔페에서 접시를 손님 수만큼 준비하지 않는 것과 같습니다. 테이블에 앉을 수 있는 만큼만 두고, 다 먹으면 씻어서 다시 씁니다. 손님이 만 명이든 십만 명이든 접시는 스무 개면 됩니다.

그런데 여기서 함정이 있어요. 접시를 씻을 때 앞사람 음식이 남아 있으면 다음 손님이 그걸 봅니다. 그래서 prepareForReuse에서 깨끗이 지우는 겁니다. "이전 셀 이미지가 잠깐 보이는" 현상이 바로 덜 씻긴 접시예요.

그리고 진짜 문제는 접시 개수가 아니라 요리 시간인 경우가 많습니다. 접시를 스무 개로 줄여도, 주문마다 재료를 처음부터 손질하면 줄이 밀립니다. 그래서 미리 손질해 두거나(prefetch·캐시), 주방 보조에게 시키거나(백그라운드 디코딩), 애초에 손질할 게 적게(다운샘플링) 만듭니다.

마지막으로 미리 준비하는 것도 공짜가 아닙니다. 손님이 안 오면 준비한 재료를 버려야 하죠. 그래서 cancelPrefetching이 있는 겁니다 — "예측했는데 빗나갔으니 취소해". 재미있게도 CPU도 똑같은 일을 합니다(분기 예측). 미리 해 놓고, 틀리면 버립니다. 같은 아이디어가 층만 바꿔서 반복되는 거예요.

🔑 한 문장

셀 최적화는 기법 목록이 아니라 데드라인 관리다. 임계 경로에서 일을 빼내고, 같은 일을 두 번 하지 않는다. 그리고 어느 단계가 병목인지 먼저 재고, 평균이 아니라 최악값으로 판정한다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.

📌 각 자료가 뒷받침하는 것
  • Apple — 재사용 큐를 API로 노출한 계약
  • WWDC20 — hitch를 "평균"이 아니라 "초과"로 재야 하는 이유
  • Apple — 실사용자 scroll hitch ratio 지표의 정의