iOS Development Guide

UIImage / UIImageView 이미지 다운로드와 디코딩 파이프라인

URL에서 이미지를 받아 `UIImage`를 만들고 `UIImageView`에 표시할 때, 실제로 어떤 단계가 지나가는지와 어디서 성능 비용이 커지는지 정리한 문서다. 핵심은 다운로드 비용, 디코딩 비용, 메인 스레드 렌더링 비용을 분리해서 이해하는 것이다.

Core UIKit / ImageIO concepts Practical implementation notes Knowledge items #51, #52, #54, #60
학습 날짜

2026-04-09

Why This Work Exists

이미지 로딩이 느리다고 할 때 원인이 항상 네트워크는 아니다. 실제로는 다운로드는 빨라도 압축 해제, 픽셀 디코딩, 리사이징, 메인 스레드 렌더링이 병목이 되는 경우가 많다.

Scope / Non-scope

  • URLSession으로 이미지 데이터를 받는 단계
  • Data가 UIImage가 되는 단계
  • UIImage가 실제 픽셀 버퍼로 디코딩되는 시점
  • UIImageView에 표시할 때의 메인 스레드 비용
  • 서드파티 라이브러리 내부 구현 세부사항은 다루지 않는다

As-is

흔한 오해는 `URLSession으로 받았으니 끝났다`, `UIImage(data:) 하면 이미 다 준비됐다`, `UIImageView.image = image는 거의 공짜다`라는 생각이다.

To-be

더 정확한 이해는 이렇다. 네트워크 응답은 압축된 이미지 바이트를 가져오는 단계일 뿐이고, 실제 화면 표시를 위해서는 압축 해제와 픽셀 디코딩이 필요하며, 이 비용이 메인 스레드에 몰리면 스크롤 끊김이 생긴다.

What The Developer Must Understand Next

핵심 요약

`다운로드가 끝났다 = 화면 표시 준비 완료`가 아니다. 화면 표시 직전에 디코딩 비용이 터질 수 있다.

API / Data Contract

  • `URLSession`은 압축된 원본 바이트를 가져온다.
  • `Data`는 아직 그 자체로는 화면에 그릴 수 있는 픽셀 버퍼가 아니다.
  • `UIImage`는 이미지 표현 객체이며, 실제 디코딩은 늦게 일어날 수 있다.
  • `UIImageView`는 최종적으로 Core Animation / 렌더링 파이프라인에서 이미지를 표시한다.

Risks And Decisions Needed

  • 큰 이미지를 메인 스레드에서 처음 그리면 첫 렌더링 순간 프레임 드롭이 날 수 있다.
  • 썸네일이 필요한데 원본 이미지 전체를 유지하면 메모리 낭비가 크다.
  • 리스트 셀 재사용 시 이전 요청 결과가 늦게 도착해 잘못된 셀에 그려질 수 있다.
  • 디스크 캐시, 메모리 캐시, 디코딩 캐시를 구분하지 않으면 병목 원인을 잘못 잡기 쉽다.

Class Diagram

classDiagram class URLSession { +data(for:) } class Data { +compressedBytes } class UIImage { +init(data:) +cgImage } class UIImageView { +image +display() } class Decoder { +decodeToPixels() } URLSession --> Data : downloads Data --> UIImage : creates UIImage --> Decoder : may require UIImageView --> UIImage : renders

Sequence Diagram

sequenceDiagram participant App participant Session as URLSession participant Img as UIImage participant View as UIImageView participant Render as Render Pipeline App->>Session: request image URL Session-->>App: compressed Data App->>Img: UIImage(data:) App->>View: image = UIImage View->>Render: first draw request Render->>Img: decode if needed Render-->>View: display pixels

Flowchart

flowchart LR A["URL request"] --> B["Compressed bytes downloaded"] B --> C["Create UIImage wrapper"] C --> D{"Decoded already?"} D -->|"No"| E["Decode / decompress to pixels"] D -->|"Yes"| F["Reuse decoded pixels"] E --> G["Render in UIImageView"] F --> G

Detailed Reading: What Actually Happens

1단계는 네트워크다. `URLSession`은 JPEG, PNG, WebP 같은 압축된 바이트를 가져온다. 이 단계의 병목은 네트워크 속도, 응답 크기, 캐시 히트 여부다.

2단계는 이미지 객체 생성이다. `UIImage(data:)`를 호출하면 앱은 이미지 표현 객체를 얻는다. 하지만 이 시점에 항상 모든 픽셀을 미리 풀어 놓는다고 보면 안 된다.

3단계는 디코딩이다. 실제로 이미지를 화면에 그리려면 압축된 데이터를 픽셀 버퍼로 풀어야 한다. 이 작업이 첫 렌더링 시점에 지연되어 일어나면, 사용자는 “이미지는 받았는데 표시가 버벅인다”고 느낄 수 있다.

4단계는 렌더링이다. `UIImageView.image = image` 이후 실제 표시 과정에서 레이아웃, compositing, 스케일링 비용까지 합쳐져 체감 성능이 결정된다.

Performance Viewpoint

  • 다운로드가 느린 경우: 네트워크, 캐시 정책, 파일 크기를 본다.
  • 받은 뒤 첫 표시가 느린 경우: 디코딩과 메인 스레드 렌더링 비용을 의심한다.
  • 스크롤 중 끊기는 경우: 셀 재사용, 대형 이미지 리사이징, 메인 스레드 디코딩 여부를 본다.
  • 메모리 사용량이 큰 경우: 원본 크기 유지, 중복 캐시, 디코딩된 비트맵 크기를 본다.
중요한 감각

JPEG 1MB 파일이 메모리에서도 1MB라고 생각하면 안 된다. 디코딩 후에는 가로 x 세로 x 4 바이트 수준으로 메모리를 먹을 수 있다.

UIImage와 UIImageView 관점의 핵심

`UIImage`는 단순히 “픽셀 덩어리”가 아니라 이미지 데이터의 표현 객체다. 이 객체가 실제로 곧바로 비트맵을 전부 메모리에 펴 두는지, 아니면 나중 렌더링 때 디코딩하는지는 상황에 따라 달라질 수 있다.

`UIImageView`는 이 이미지를 실제 화면에 올리는 마지막 사용자다. 그래서 체감 성능 문제는 종종 `UIImageView`에서 발생한 것처럼 보이지만, 실제 원인은 그 직전 디코딩 비용일 수 있다.

Common Pitfalls

  • 메인 스레드에서 `Data -> UIImage -> 첫 렌더링`까지 몰아서 처리
  • 셀에서 이미지 다운로드 완료 콜백이 늦게 와 잘못된 이미지 표시
  • 썸네일 UI에 원본 해상도 이미지를 그대로 사용
  • 캐시가 있어도 디코딩 캐시가 없어 매번 첫 표시 비용 발생

Practical Strategy

  • 다운로드는 `URLSession` async API로 비동기 처리
  • 결과 저장은 actor나 적절한 캐시 레이어로 보호
  • 필요한 크기에 맞게 다운샘플링
  • 가능하면 메인 직전이 아니라 미리 디코딩 비용을 분산
  • UI 반영은 MainActor에서만 수행
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)
await MainActor.run {
    imageView.image = image
}

위 코드는 기본형으로는 맞다. 하지만 리스트 성능까지 챙기려면 다운샘플링, 캐시, 첫 렌더링 디코딩 비용을 별도로 더 봐야 한다.

Study Conclusion

  • 네트워크 다운로드는 시작일 뿐이다.
  • `UIImage(data:)` 이후에도 실제 표시 비용이 남아 있다.
  • 첫 표시 시 디코딩이 메인에 몰리면 스크롤 성능이 무너질 수 있다.
  • 이미지 최적화는 네트워크, 메모리, 디코딩, 렌더링을 같이 봐야 한다.

Important FAQ

  • `CGImage / ImageSource`: UIImage 아래에서 실제 이미지 파일 해석과 이미지 표현에 가까운 하위 레벨 객체다.
  • `비트맵`: 압축된 파일이 아니라, 픽셀 색 정보가 메모리에 펼쳐진 상태다.
  • `픽셀 버퍼`와 `픽셀 비트맵`: 실무에서는 거의 같은 뜻으로 이해해도 된다. 둘 다 렌더링 가능한 픽셀 데이터 쪽을 가리킨다.
  • `첫 렌더링 시점`: 이미지가 화면에 처음 실제로 그려지는 순간이다. 이때 full decode 비용이 몰리면 버벅일 수 있다.

One-line Summary

`UIImage(data:)`는 이미지 객체를 만들지만, 실제 화면에 그릴 전체 픽셀 데이터가 그 시점에 완전히 준비됐다고 보장할 수는 없다. 그래서 첫 렌더링 시점의 디코딩 비용을 항상 같이 봐야 한다.

More Clarification

  • `UIImage(data:)`에서 아무 일도 안 일어나는 것은 아니다. 포맷 해석, 크기 확인, 내부 이미지 표현 준비 같은 작업은 있을 수 있다.
  • 하지만 “full decode가 여기서 끝난다”라고 단정하면 안 된다. 실제 비싼 픽셀 디코딩은 첫 렌더링 때까지 미뤄질 수 있다.
  • 즉 `0% decode`도 아니고 `100% full decode 완료`도 아니며, 중요한 것은 full decode가 지연될 수 있다는 점이다.

Data - UIImage - Draw 관계

  • `Data`: 압축된 파일 바이트
  • `UIImage(data:)`: 이미지 객체 생성과 해석 준비
  • `first draw`: 실제 화면 표시를 위해 픽셀 버퍼가 필요해지는 순간

여기서 성능 이슈가 자주 생기는 구간은 세 번째다. 이미지 객체는 있었지만, 화면에 그리기 위한 full decode 비용이 아직 남아 있을 수 있기 때문이다.

Downsampling And Pre-decoding

다운샘플링은 “큰 원본 전체를 다 풀지 않고, 필요한 크기에 맞춰 더 작은 결과를 만드는 것”이다. 썸네일 UI에서는 거의 항상 유리하다.

사전 디코딩은 “처음 화면에 붙기 전에 미리 한 번 그려서 first draw 비용을 앞당기는 것”이다. 첫 표시 버벅임을 줄이는 데 도움이 될 수 있지만, 대신 미리 CPU와 메모리 비용을 쓴다.

Practical Rule

  • 원본이 크고 화면엔 작게 보이면 다운샘플링을 먼저 의심한다.
  • 첫 표시 버벅임이 문제면 pre-decoding을 고려한다.
  • “시간이 지나면 자동으로 full decode 된다”가 아니라 “렌더링이 필요해지면 decode 비용이 터질 수 있다”로 이해한다.

대화 Q&A 1

디코딩은 어느 코드에서 일어나나요?

앱 코드 기준 트리거는 `imageView.image = image`, `kf.setImage(...)`, `sd_setImage(...)` 같은 \"표시 경로\"다. 실제 디코딩/렌더링은 UIKit/Core Animation 및 이미지 라이브러리 내부 경로에서 수행된다.

그래서 다운로드가 끝났어도 첫 표시 시점에서 디코딩 비용이 한 번 더 터질 수 있다.

대화 Q&A 2

`UIImage(data:)`는 디코딩 전인가요?

\"전혀 안 함\"도 아니고 \"항상 full decode\"도 아니다. 포맷/메타 해석 같은 일부 준비는 가능하지만, 전체 픽셀 버퍼 생성은 첫 draw까지 지연될 수 있다.

대화 Q&A 3

메타 확인은 무엇을 아는 건가요?

  • 가로/세로, 포맷, EXIF 등 헤더 정보는 전체 디코딩 전에도 확인 가능
  • 실제 RGBA 픽셀 값(버퍼 내용)은 디코딩 후에 생성

대화 Q&A 4

디스크 캐시 + 메모리 캐시를 같이 쓰는 이유?

  • 디스크(Data) 캐시: 네트워크 재요청 절감
  • 메모리(처리 결과 UIImage) 캐시: 디코딩/다운샘플/가공 재비용 절감
  • 핵심은 둘의 역할이 다르다는 점

Q&A 2026-07-02: Data/압축, 즉시 디코딩, 비트맵 구조

핵심 구분

Data는 "압축 해제된 파일"이 아니다. Data는 그냥 바이트 컨테이너일 뿐, 그 안에 뭐가 들어있는지는 어떻게 만들었느냐에 따라 다르다.

Q: Data와 압축/인코딩의 관계

Q: jpegData()하면 JPEG 인코딩이 적용된 Data가 되는 거지? URL로 이미지 다운받으면 그 데이터는 압축된 상태야?

A: 맞다. Data 자체는 중립적인 바이트 배열이고, 내용물은 생성 방식에 따라 다르다:

  • image.jpegData(compressionQuality:) → JPEG 압축 적용된 바이트
  • image.pngData() → PNG 압축 적용된 바이트
  • URL로 다운로드 → 서버가 보낸 그대로 (JPEG/PNG/WebP 등 압축된 형식)

즉, 네트워크로 받은 Data, jpegData()로 만든 Data 모두 압축된 상태다.

Q: UIImage도 즉시 디코딩 가능?

Q: CGImage만 즉시 디코딩 가능하고 UIImage는 안 되나?

A: UIImage도 가능하다. iOS 15+ preparingForDisplay():

// iOS 15+ 동기 버전
if let decoded = originalImage.preparingForDisplay() {
    imageView.image = decoded  // 이미 디코딩됨
}

// iOS 15+ 비동기 버전
originalImage.prepareForDisplay { prepared in
    DispatchQueue.main.async {
        imageView.image = prepared
    }
}

iOS 15 이전에는 CGContext에 draw해서 강제 디코딩 후 UIImage로 감쌌다.

Q: 디코딩된 비트맵이란?

Q: JPEG도 바이트인데, 디코딩된 비트맵도 바이트 아닌가? 정확히 뭐가 다른 거야?

A: 둘 다 바이트지만 구조가 완전히 다르다:

  • JPEG Data: DCT 변환 + 양자화 + 허프만 코딩이 적용된 압축 바이트. 직접 픽셀로 해석 불가.
  • 디코딩된 비트맵: 각 픽셀이 RGBA(또는 BGRA) 채널로 구성, 각 채널 0~255 값. GPU가 직접 읽을 수 있는 형태.

예: 1000×1000 이미지, RGBA 4채널, 8비트/채널 = 4,000,000 바이트 (4MB)

같은 이미지가 JPEG로 압축되면 수십~수백 KB. 디코딩 = 압축 해제해서 원래 픽셀 배열로 복원.

Q: CGImage vs UIImage 차이

Q: 둘 다 이미지인데 뭐가 다른 거야?

  • CGImage: Core Graphics 레벨. 순수 비트맵 데이터 + 픽셀 포맷 정보(width, height, bitsPerComponent, colorSpace). scale/orientation 개념 없음.
  • UIImage: UIKit 레벨 래퍼. CGImage + scale + imageOrientation. 화면 표시용 메타 정보 포함.
let cgImage = image.cgImage!
print(cgImage.width)   // 픽셀 단위 (예: 2000)
print(image.size.width) // 포인트 단위 (예: 1000, scale=2일 때)
// UIImage.size = CGImage 픽셀 ÷ scale

Q: 디코딩은 정확히 언제 일어나?

Q: "렌더링 시점"이 정확히 뭐야?

A: CATransaction commit 전, Render Preparation 단계에서 일어난다:

  1. RunLoop → Event → layoutSubviews → setNeedsDisplay
  2. CATransaction commit (RunLoop 끝)
  3. Render Preparation: 여기서 아직 디코딩 안 된 이미지의 full decode 발생
  4. Display list → Render Server → GPU compositing

그래서 imageView.image = img는 빠르지만, 그 직후 CATransaction commit 때 디코딩 비용이 터질 수 있다.

Q: VSync와 프레임 드랍

Q: 디코딩이 오래 걸리면 왜 버벅여?

A: VSync(60fps = 16.67ms) 내에 렌더링이 끝나야 다음 프레임에 표시된다.

  • RunLoop 작업 + Render Preparation(디코딩 포함)이 16.67ms 초과 → 프레임 드랍
  • 대형 이미지 디코딩은 수십ms 걸릴 수 있음
  • 해결: 미리 백그라운드에서 디코딩, 다운샘플링으로 픽셀 수 줄이기

Q: CGImageSource와 다운샘플링

Q: 다운샘플링하면 왜 메모리가 줄어?

A: ImageIO의 CGImageSourceCreateThumbnailAtIndex는 전체 디코딩 없이 필요한 크기만 추출한다:

import ImageIO

func downsample(data: Data, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let maxDimension = max(pointSize.width, pointSize.height) * scale
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,  // 즉시 디코딩
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimension
    ]
    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        return nil
    }
    return UIImage(cgImage: cgImage)
}
  • kCGImageSourceShouldCacheImmediately: 지연 디코딩 없이 즉시 디코딩
  • 4000×4000 → 200×200 다운샘플: 64MB → 160KB 메모리

Q: bitsPerComponent란?

Q: CGImage의 bitsPerComponent가 8이면 뭘 의미해?

A: 각 색상 채널(R, G, B, A 각각)이 8비트(0~255)로 표현된다는 뜻.

  • bitsPerComponent = 8: 채널당 8비트
  • bitsPerPixel = 32: 픽셀당 32비트 (RGBA 4채널 × 8비트)
  • bytesPerRow: width × 4 (+ 패딩)

16비트/채널 이미지도 있지만 iOS에서는 대부분 8비트를 사용한다.

CGImageSource 심화 Q&A

CGImageSource란?

ImageIO 프레임워크의 이미지 파일 파서. UIImage보다 낮은 레벨에서 이미지 데이터에 접근하고, 메타데이터 읽기, 다운샘플링, 점진적 로딩을 지원한다.

Q: 크기 안 줄이고 즉시 디코딩만 하려면?

Q: 다운샘플링 없이 디코딩 옵션만 바꾸려면 createThumbnail을 써야 해?

A: 아니다. CGImageSourceCreateImageAtIndex를 쓰면 된다.

// 원본 크기 + 즉시 디코딩
let options: [CFString: Any] = [
    kCGImageSourceShouldCacheImmediately: true  // 즉시 디코딩
]
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, options as CFDictionary)

// 축소 + 즉시 디코딩 (다운샘플링)
let thumbnailOptions: [CFString: Any] = [
    kCGImageSourceShouldCacheImmediately: true,
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceThumbnailMaxPixelSize: 200
]
let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary)
  • CreateImageAtIndex: 원본 크기 그대로
  • CreateThumbnailAtIndex: 크기 조절 필요할 때

Q: commit 시점 디코딩 → RunLoop 블로킹?

Q: 디코딩이 메인스레드에서 동작하면 RunLoop이 일처리 못하는 거야?

A: 맞다. 디코딩이 끝나야 commit이 완료된다.

RunLoop 한 사이클:
1. 이벤트 처리 (터치, 타이머 등)
2. layoutSubviews, setNeedsDisplay 등
3. CATransaction.commit()
   └─ Render Preparation ← 여기서 디코딩!
      └─ 디코딩 완료까지 블로킹
4. Render Server에 데이터 전달
5. 다음 VSync 대기

디코딩이 오래 걸리면 commit이 늦어지고, 16.67ms 초과 시 프레임 드랍. 이 시간 동안 터치 반응도 지연된다.

Q: 렌더링 전 UIImage.cgImage는 뭐야?

Q: 렌더링 때 디코딩한다면서, 그 전에 cgImage 접근하면 뭐가 나와?

A: CGImage 객체는 있지만, 내부 비트맵이 비어있는 상태.

let image = UIImage(data: jpegData)
let cg = image.cgImage  // ← 접근 가능! nil 아님

// 이 CGImage 내부:
// - width, height: 알고 있음 (메타데이터에서 읽음)
// - colorSpace: 알고 있음
// - 실제 픽셀 비트맵: 아직 안 채워짐 (lazy)

비유: 책 목차는 읽었지만 본문은 안 읽은 상태. 책(CGImage) 객체는 있고 페이지 수(크기)는 알지만, 내용(픽셀)은 나중에 필요할 때 채워진다.

Q: ShouldCacheImmediately 캐시 사용법?

Q: 즉시 디코딩하고 캐시에 저장하면 어떻게 사용해? 알아서 가져다 써?

A: "시스템 캐시"가 아니라 "CGImage 객체 내부에 비트맵을 즉시 채움"이다.

// 즉시 디코딩
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, [
    kCGImageSourceShouldCacheImmediately: true
] as CFDictionary)!

// 이 cgImage는 이미 내부에 디코딩된 비트맵을 가지고 있음
let uiImage = UIImage(cgImage: cgImage)

// 나중에 imageView.image = uiImage 할 때
// → 이미 디코딩되어 있으니 commit 시점에 추가 디코딩 없음

"알아서 가져다 쓰는" 게 아니라, 그 CGImage/UIImage를 계속 들고 있다가 사용하면 된다.

Q: CGImageSourceUpdateData란?

Q: 점진적 로딩에서 UpdateData는 뭐야?

A: "데이터 더 받았어"라고 CGImageSource에 알려주는 함수.

// 1. 빈 Incremental 소스 생성
let source = CGImageSourceCreateIncremental(nil)

// 2. 데이터 청크 받을 때마다 업데이트
var accumulated = Data()

// 청크 1 도착
accumulated.append(chunk1)
CGImageSourceUpdateData(source, accumulated as CFData, false)

// 청크 2 도착
accumulated.append(chunk2)
CGImageSourceUpdateData(source, accumulated as CFData, false)

// 마지막 청크
accumulated.append(lastChunk)
CGImageSourceUpdateData(source, accumulated as CFData, true)  // final = true

// 3. 현재까지 받은 데이터로 이미지 생성 시도
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)

주의: data 파라미터는 "이번 청크"가 아니라 "지금까지 누적된 전체 데이터"다.

Q: 인덱스 0은 뭐야? 여러 이미지?

Q: CreateImageAtIndex(source, 0, ...)에서 0이 뭐야?

A: 파일 내 몇 번째 이미지인지.

  • 일반 JPEG/PNG: 항상 0 (이미지 1개)
  • GIF: 프레임 번호 (0, 1, 2, ... N-1)
  • APNG: 프레임 번호
  • HEIC (Live Photo): 여러 장 가능
let count = CGImageSourceGetCount(source)
// JPEG: 1
// GIF (30프레임): 30

for i in 0..<count {
    let frame = CGImageSourceCreateImageAtIndex(source, i, nil)
}

Q: 썸네일이란?

Q: 썸네일이라는 표현이 낯선데?

A: Thumbnail = "엄지손톱 크기 이미지" = 미리보기용 작은 이미지. 파일 탐색기에서 이미지 미리보기가 썸네일이다.

CGImageSource의 CreateThumbnail"원본보다 작은 크기로 디코딩"이라고 이해하면 된다.

Q: UIImage 내부에서 ImageIO 사용?

Q: 이미지 파일 디코딩할 때 무조건 ImageIO가 쓰여?

A: 대부분 그렇다.

UIImage(data:) 내부 흐름:
Data → ImageIO (CGImageSource) → CGImage → UIImage

UIImage(data:), UIImage(contentsOfFile:) 등은 내부적으로 ImageIO를 사용해서 JPEG/PNG/HEIC 등을 파싱한다.

예외: UIImage(cgImage:)처럼 이미 만들어진 CGImage를 감싸는 경우.

Q: 다운샘플링 = 필요 부분만 디코딩?

Q: 160KB만 생성한다는 건 필요 픽셀만 파싱/디코딩한다는 거야?

A: 완전히 "필요 픽셀만"은 아니지만, 전체 64MB를 거치지 않고 작은 결과를 직접 생성한다.

[일반 방식: UIImage → resize]
4000×4000 JPEG → 64MB 비트맵 생성 → 200×200으로 축소 → 64MB 해제
                 ↑ 여기서 64MB 메모리 피크

[다운샘플링 방식: CGImageSource]
4000×4000 JPEG → 200×200 비트맵 직접 생성 (160KB만 사용)
                 ↑ 중간에 64MB 메모리 피크 없음

JPEG는 DCT 블록 단위로 저장되어 있어서, 디코딩 알고리즘 레벨에서 축소를 최적화할 수 있다.

Q: 메타데이터 크기 = 픽셀? pt?

Q: kCGImagePropertyPixelWidth는 픽셀 크기지? pt 아니고?

A: 맞다. 순수 픽셀 크기다. CGImageSource 레벨에는 scale 개념이 없다.

// CGImage: 픽셀만 안다
cgImage.width  // 2000 (픽셀)

// UIImage: scale을 적용해서 포인트로 변환
let image = UIImage(cgImage: cgImage, scale: 2.0, orientation: .up)
image.size.width  // 1000 (포인트) = 2000 / 2.0

포인트 크기가 필요하면 UIImage를 만들거나, 직접 scale로 나눠야 한다.

Q: ThumbnailFromImageIfAbsent?

Q: kCGImageSourceCreateThumbnailFromImageIfAbsent가 뭐야?

A: 일부 JPEG/HEIC 파일은 임베디드 썸네일이 이미 들어있다.

JPEG 파일 구조:
┌─────────────────────────────┐
│ EXIF 메타데이터             │
│ ├─ 임베디드 썸네일 (160x120)│ ← 이미 만들어진 작은 이미지
│ └─ 촬영 정보 등             │
│ 본문 이미지 데이터          │
└─────────────────────────────┘
  • IfAbsent: 임베디드 썸네일 있으면 그거 사용, 없으면 새로 생성
  • Always: 임베디드 썸네일 무시하고 항상 새로 생성

실무에서는 Always를 더 많이 쓴다 — 임베디드 썸네일 크기가 원하는 크기와 다를 수 있어서.

UIImage(data:) 내부 상태와 draw 시점 디코딩

핵심

UIImage(data:)는 디코딩하지 않는다. 압축된 Data를 참조하고, 실제 비트맵은 픽셀에 접근할 때(draw 등) 생성된다.

UIImage(data:) 직후 상태

UIImage
├── CGImage (객체 존재)
│   ├── 메타데이터: width=4000, height=4000, colorSpace, ...
│   ├── 압축된 Data 참조 (JPEG 바이트)
│   └── 디코딩된 비트맵: ❌ 아직 없음
└── scale, orientation

"비어있는 픽셀 버퍼"가 아니라, 압축된 Data만 참조하고 있고 디코딩된 비트맵 자체가 생성 안 된 상태.

비유: ZIP 파일 + 목차만 읽은 상태. 압축 해제 안 함.

draw 호출 시 (픽셀 접근)

UIImage
├── CGImage
│   ├── 메타데이터: width=4000, height=4000, ...
│   ├── 압축된 Data 참조
│   └── 디코딩된 비트맵: ✅ 64MB RGBA 배열 생성됨
└── scale, orientation

draw(in:), cgImage.dataProvider원본 픽셀에 접근하는 순간 디코딩이 강제 수행된다.

UIImage.draw(in:) 동작

지정한 영역에 이미지를 그리는 메서드. 그리려면 원본 픽셀을 읽어야 하므로 디코딩 발생.

// resize 내부 흐름 (대략적)
func resize(to size: CGSize) -> UIImage {
    // 1. 새 캔버스 생성 (200×200)
    UIGraphicsBeginImageContext(size)

    // 2. 캔버스에 원본 이미지를 200×200로 그림
    //    → 원본 픽셀 읽기 → 64MB 디코딩 발생!
    self.draw(in: CGRect(origin: .zero, size: size))

    // 3. 캔버스에서 결과 추출
    let resized = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return resized
}

resize vs DownsamplingImageProcessor

[resize 방식 — 비효율]
Data → UIImage(껍데기)
     → resize 내부 draw
     → 64MB 원본 디코딩
     → 작은 비트맵 생성
       ↑ 메모리 피크

[DownsamplingImageProcessor — 효율]
Data → CGImageSource
     → 작은 비트맵 직접 생성
       ↑ 64MB 안 거침
  • resize: 64MB 원본 디코딩 → 그걸 보고 작은 그림 그림
  • DownsamplingImageProcessor: 처음부터 작은 크기로만 디코딩

Q: bounds.size는 픽셀? 포인트?

Q: 서버 이미지는 px 단위인데, UIScreen.main.bounds.size도 px 단위야?

A: 아니다. bounds.size포인트(pt) 단위다.

// iPhone 14 Pro 예시
UIScreen.main.bounds.size  // 393×852 pt (포인트)
UIScreen.main.scale        // 3.0
// 실제 픽셀 = 393×3 × 852×3 = 1179×2556 px

// Kingfisher 내부에서 scale 곱함
let maxPixelSize = max(pointSize.width, pointSize.height) * scale
// 852 × 3.0 = 2556 px
  • 서버 이미지: 픽셀 단위 (JPEG/PNG 파일은 픽셀로 저장)
  • bounds.size: 포인트 단위 (UIKit 좌표계)
  • 다운샘플링: 내부에서 scale 곱해서 픽셀로 변환

Q: 프레임 드랍 = RunLoop만의 문제?

Q: RunLoop이 16.67ms 초과하면 프레임 드랍이라고 했는데, Render Server 합성 시간도 있잖아?

A: 맞다. 프레임 드랍은 두 곳에서 발생할 수 있다.

App (메인 스레드)              Render Server              Display
────────────────────        ──────────────────        ─────────
RunLoop → commit        ──→  compositing (합성)  ──→  VSync에 표시
  └─ Render Preparation       └─ GPU 렌더링
     (디코딩 포함)
  • App 측 원인: 복잡한 레이아웃, 이미지 디코딩, 무거운 연산
  • Render Server 측 원인: 복잡한 레이어, 블러, 그림자, 투명도 합성

정확한 표현: "App 작업 + Render Server 합성"이 VSync 전에 완료되어야 프레임 드랍 없음.

Q: RunLoop이 60fps보다 빨리 끝날 수 있어?

A: 당연하다. RunLoop은 속도 제한이 없다.

// 이벤트 없으면
RunLoop: 대기(sleep) → 이벤트 → 처리(1ms) → 대기 → ...

// 한 사이클이 0.1ms, 1ms, 5ms 등 얼마든지 빠를 수 있음

"RunLoop이 60fps로 돈다"는 완전히 잘못된 표현.

VSync가 60fps인 것이고, RunLoop은 할 일 있으면 처리하고 없으면 쉰다.

Q: 화면 크기보다 더 작게 다운샘플링?

Q: 실제 ImageView가 100×100인데 화면 전체 크기로 다운샘플링하면 낭비 아냐?

A: 맞다. 실제 ImageView 크기에 맞추는 게 더 효율적.

// 현재: 화면 전체 크기 (393×852 pt)
DownsamplingImageProcessor(size: UIScreen.main.bounds.size)

// 더 최적화: 실제 ImageView 크기 (100×100 pt)
DownsamplingImageProcessor(size: imageView.bounds.size)

화면 크기를 기본값으로 쓰는 이유:

  • imageView.bounds.size가 0일 수 있음 (레이아웃 전)
  • 셀 재사용 시 크기가 변할 수 있음
  • "안전한 기본값"으로 화면 크기 사용

Q: main.sync 먹통 이유?

Q: 메인스레드에서 main.sync하면 화면이 먹통이 되는 이유는?

A: 데드락으로 RunLoop이 영원히 끝나지 않음.

// 메인 스레드에서 실행 중
DispatchQueue.main.sync {  // ← 메인 스레드 블로킹
    // 이 블록은 메인 스레드에서 실행되어야 함
    // 하지만 메인 스레드는 위의 sync를 기다리는 중
    // → 서로 기다림 → 데드락
}

RunLoop이 무한 대기 → commit 없음 → 화면 갱신 영원히 안 됨 = 먹통

Q: 16.67ms 안에 뭐가 다 끝나야 해?

Q: Render Server 시간도 있으니까 RunLoop은 16.67ms보다 더 짧아야 하는 거 아냐?

A: 맞다. 더 정확하게는:

16.67ms ≥ App 작업 시간 + Render Server 작업 시간

App (메인 스레드): 이벤트 처리, 레이아웃, commit, 디코딩
Render Server: layer compositing, GPU 렌더링

복잡한 UI(블러, 그림자 많음)면 Render Server 시간이 길어져서 App 쪽 시간을 더 줄여야 함.

Apple의 Triple Buffering으로 약간의 여유가 있지만, 기본 원칙은 그렇다.

Q: CGImage 디코딩 여부 확인?

Q: CGImage 내부 버퍼가 디코딩됐는지 확인할 수 있어?

A: "디코딩 여부" 확인하는 공개 API는 없다. 간접적으로만 가능.

// 1. 데이터 프로바이더로 시도
if let dataProvider = cgImage.dataProvider,
   let data = dataProvider.data {
    print("Data length: \(CFDataGetLength(data))")
}

// 2. 강제 디코딩으로 확인
let context = CGContext(...)
context.draw(cgImage, in: rect)  // 이 시점에 디코딩 강제

실무에서는: Instruments로 메모리 확인하거나, kCGImageSourceShouldCacheImmediately로 디코딩 시점을 제어.

점진적 로딩 전체 예제

class ProgressiveLoader: NSObject, URLSessionDataDelegate {
    private var source: CGImageSource?
    private var data = Data()
    weak var imageView: UIImageView?

    func load(url: URL) {
        let session = URLSession(configuration: .default,
                                 delegate: self, delegateQueue: nil)
        session.dataTask(with: url).resume()
    }

    // 응답 시작
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
                    didReceive response: URLResponse,
                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        source = CGImageSourceCreateIncremental(nil)
        completionHandler(.allow)
    }

    // 청크 도착
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
                    didReceive chunk: Data) {
        data.append(chunk)
        CGImageSourceUpdateData(source!, data as CFData, false)

        if let cg = CGImageSourceCreateImageAtIndex(source!, 0, nil) {
            DispatchQueue.main.async {
                self.imageView?.image = UIImage(cgImage: cg)
            }
        }
    }

    // 완료
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        CGImageSourceUpdateData(source!, data as CFData, true)
    }
}

QA Checklist

  • `다운로드 = 렌더 준비 완료`로 단순화하지 않았는지 확인한다.
  • 디코딩 비용과 네트워크 비용을 구분해 설명했는지 확인한다.
  • 메인 스레드에서 이미지 첫 렌더링 비용이 터질 수 있다는 점을 적었는지 확인한다.
  • 대형 이미지의 메모리 비용을 빠뜨리지 않았는지 확인한다.

Operations / Rollout Checklist

  • 후속 문서에서 downsampling, pre-decoding, cache layer를 분리해 더 깊게 다룬다.
  • 실제 앱 코드에서는 셀 재사용과 취소(cancellation) 전략까지 같이 본다.
  • 필요하면 다음 문서에서 `CGImageSourceCreateThumbnailAtIndex` 기반 다운샘플링 예제를 추가한다.