Why This Work Exists
이미지 로딩이 느리다고 할 때 원인이 항상 네트워크는 아니다. 실제로는 다운로드는 빨라도 압축 해제, 픽셀 디코딩, 리사이징, 메인 스레드 렌더링이 병목이 되는 경우가 많다.
URL에서 이미지를 받아 `UIImage`를 만들고 `UIImageView`에 표시할 때, 실제로 어떤 단계가 지나가는지와 어디서 성능 비용이 커지는지 정리한 문서다. 핵심은 다운로드 비용, 디코딩 비용, 메인 스레드 렌더링 비용을 분리해서 이해하는 것이다.
2026-04-09
이미지 로딩이 느리다고 할 때 원인이 항상 네트워크는 아니다. 실제로는 다운로드는 빨라도 압축 해제, 픽셀 디코딩, 리사이징, 메인 스레드 렌더링이 병목이 되는 경우가 많다.
흔한 오해는 `URLSession으로 받았으니 끝났다`, `UIImage(data:) 하면 이미 다 준비됐다`, `UIImageView.image = image는 거의 공짜다`라는 생각이다.
더 정확한 이해는 이렇다. 네트워크 응답은 압축된 이미지 바이트를 가져오는 단계일 뿐이고, 실제 화면 표시를 위해서는 압축 해제와 픽셀 디코딩이 필요하며, 이 비용이 메인 스레드에 몰리면 스크롤 끊김이 생긴다.
`다운로드가 끝났다 = 화면 표시 준비 완료`가 아니다. 화면 표시 직전에 디코딩 비용이 터질 수 있다.
1단계는 네트워크다. `URLSession`은 JPEG, PNG, WebP 같은 압축된 바이트를 가져온다. 이 단계의 병목은 네트워크 속도, 응답 크기, 캐시 히트 여부다.
2단계는 이미지 객체 생성이다. `UIImage(data:)`를 호출하면 앱은 이미지 표현 객체를 얻는다. 하지만 이 시점에 항상 모든 픽셀을 미리 풀어 놓는다고 보면 안 된다.
3단계는 디코딩이다. 실제로 이미지를 화면에 그리려면 압축된 데이터를 픽셀 버퍼로 풀어야 한다. 이 작업이 첫 렌더링 시점에 지연되어 일어나면, 사용자는 “이미지는 받았는데 표시가 버벅인다”고 느낄 수 있다.
4단계는 렌더링이다. `UIImageView.image = image` 이후 실제 표시 과정에서 레이아웃, compositing, 스케일링 비용까지 합쳐져 체감 성능이 결정된다.
JPEG 1MB 파일이 메모리에서도 1MB라고 생각하면 안 된다. 디코딩 후에는 가로 x 세로 x 4 바이트 수준으로 메모리를 먹을 수 있다.
`UIImage`는 단순히 “픽셀 덩어리”가 아니라 이미지 데이터의 표현 객체다. 이 객체가 실제로 곧바로 비트맵을 전부 메모리에 펴 두는지, 아니면 나중 렌더링 때 디코딩하는지는 상황에 따라 달라질 수 있다.
`UIImageView`는 이 이미지를 실제 화면에 올리는 마지막 사용자다. 그래서 체감 성능 문제는 종종 `UIImageView`에서 발생한 것처럼 보이지만, 실제 원인은 그 직전 디코딩 비용일 수 있다.
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)
await MainActor.run {
imageView.image = image
}위 코드는 기본형으로는 맞다. 하지만 리스트 성능까지 챙기려면 다운샘플링, 캐시, 첫 렌더링 디코딩 비용을 별도로 더 봐야 한다.
`UIImage(data:)`는 이미지 객체를 만들지만, 실제 화면에 그릴 전체 픽셀 데이터가 그 시점에 완전히 준비됐다고 보장할 수는 없다. 그래서 첫 렌더링 시점의 디코딩 비용을 항상 같이 봐야 한다.
여기서 성능 이슈가 자주 생기는 구간은 세 번째다. 이미지 객체는 있었지만, 화면에 그리기 위한 full decode 비용이 아직 남아 있을 수 있기 때문이다.
다운샘플링은 “큰 원본 전체를 다 풀지 않고, 필요한 크기에 맞춰 더 작은 결과를 만드는 것”이다. 썸네일 UI에서는 거의 항상 유리하다.
사전 디코딩은 “처음 화면에 붙기 전에 미리 한 번 그려서 first draw 비용을 앞당기는 것”이다. 첫 표시 버벅임을 줄이는 데 도움이 될 수 있지만, 대신 미리 CPU와 메모리 비용을 쓴다.
디코딩은 어느 코드에서 일어나나요?
앱 코드 기준 트리거는 `imageView.image = image`, `kf.setImage(...)`, `sd_setImage(...)` 같은 \"표시 경로\"다. 실제 디코딩/렌더링은 UIKit/Core Animation 및 이미지 라이브러리 내부 경로에서 수행된다.
그래서 다운로드가 끝났어도 첫 표시 시점에서 디코딩 비용이 한 번 더 터질 수 있다.
`UIImage(data:)`는 디코딩 전인가요?
\"전혀 안 함\"도 아니고 \"항상 full decode\"도 아니다. 포맷/메타 해석 같은 일부 준비는 가능하지만, 전체 픽셀 버퍼 생성은 첫 draw까지 지연될 수 있다.
메타 확인은 무엇을 아는 건가요?
디스크 캐시 + 메모리 캐시를 같이 쓰는 이유?
Data는 "압축 해제된 파일"이 아니다. Data는 그냥 바이트 컨테이너일 뿐, 그 안에 뭐가 들어있는지는 어떻게 만들었느냐에 따라 다르다.
Q: jpegData()하면 JPEG 인코딩이 적용된 Data가 되는 거지? URL로 이미지 다운받으면 그 데이터는 압축된 상태야?
A: 맞다. Data 자체는 중립적인 바이트 배열이고, 내용물은 생성 방식에 따라 다르다:
image.jpegData(compressionQuality:) → JPEG 압축 적용된 바이트image.pngData() → PNG 압축 적용된 바이트즉, 네트워크로 받은 Data, jpegData()로 만든 Data 모두 압축된 상태다.
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: JPEG도 바이트인데, 디코딩된 비트맵도 바이트 아닌가? 정확히 뭐가 다른 거야?
A: 둘 다 바이트지만 구조가 완전히 다르다:
예: 1000×1000 이미지, RGBA 4채널, 8비트/채널 = 4,000,000 바이트 (4MB)
같은 이미지가 JPEG로 압축되면 수십~수백 KB. 디코딩 = 압축 해제해서 원래 픽셀 배열로 복원.
Q: 둘 다 이미지인데 뭐가 다른 거야?
scale + imageOrientation. 화면 표시용 메타 정보 포함.let cgImage = image.cgImage! print(cgImage.width) // 픽셀 단위 (예: 2000) print(image.size.width) // 포인트 단위 (예: 1000, scale=2일 때) // UIImage.size = CGImage 픽셀 ÷ scale
Q: "렌더링 시점"이 정확히 뭐야?
A: CATransaction commit 전, Render Preparation 단계에서 일어난다:
그래서 imageView.image = img는 빠르지만, 그 직후 CATransaction commit 때 디코딩 비용이 터질 수 있다.
Q: 디코딩이 오래 걸리면 왜 버벅여?
A: VSync(60fps = 16.67ms) 내에 렌더링이 끝나야 다음 프레임에 표시된다.
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)
}Q: CGImage의 bitsPerComponent가 8이면 뭘 의미해?
A: 각 색상 채널(R, G, B, A 각각)이 8비트(0~255)로 표현된다는 뜻.
16비트/채널 이미지도 있지만 iOS에서는 대부분 8비트를 사용한다.
ImageIO 프레임워크의 이미지 파일 파서. UIImage보다 낮은 레벨에서 이미지 데이터에 접근하고, 메타데이터 읽기, 다운샘플링, 점진적 로딩을 지원한다.
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)Q: 디코딩이 메인스레드에서 동작하면 RunLoop이 일처리 못하는 거야?
A: 맞다. 디코딩이 끝나야 commit이 완료된다.
RunLoop 한 사이클:
1. 이벤트 처리 (터치, 타이머 등)
2. layoutSubviews, setNeedsDisplay 등
3. CATransaction.commit()
└─ Render Preparation ← 여기서 디코딩!
└─ 디코딩 완료까지 블로킹
4. Render Server에 데이터 전달
5. 다음 VSync 대기디코딩이 오래 걸리면 commit이 늦어지고, 16.67ms 초과 시 프레임 드랍. 이 시간 동안 터치 반응도 지연된다.
Q: 렌더링 때 디코딩한다면서, 그 전에 cgImage 접근하면 뭐가 나와?
A: CGImage 객체는 있지만, 내부 비트맵이 비어있는 상태.
let image = UIImage(data: jpegData) let cg = image.cgImage // ← 접근 가능! nil 아님 // 이 CGImage 내부: // - width, height: 알고 있음 (메타데이터에서 읽음) // - colorSpace: 알고 있음 // - 실제 픽셀 비트맵: 아직 안 채워짐 (lazy)
비유: 책 목차는 읽었지만 본문은 안 읽은 상태. 책(CGImage) 객체는 있고 페이지 수(크기)는 알지만, 내용(픽셀)은 나중에 필요할 때 채워진다.
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: 점진적 로딩에서 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: CreateImageAtIndex(source, 0, ...)에서 0이 뭐야?
A: 파일 내 몇 번째 이미지인지.
let count = CGImageSourceGetCount(source)
// JPEG: 1
// GIF (30프레임): 30
for i in 0..<count {
let frame = CGImageSourceCreateImageAtIndex(source, i, nil)
}Q: 썸네일이라는 표현이 낯선데?
A: Thumbnail = "엄지손톱 크기 이미지" = 미리보기용 작은 이미지. 파일 탐색기에서 이미지 미리보기가 썸네일이다.
CGImageSource의 CreateThumbnail은 "원본보다 작은 크기로 디코딩"이라고 이해하면 된다.
Q: 이미지 파일 디코딩할 때 무조건 ImageIO가 쓰여?
A: 대부분 그렇다.
UIImage(data:) 내부 흐름: Data → ImageIO (CGImageSource) → CGImage → UIImage
UIImage(data:), UIImage(contentsOfFile:) 등은 내부적으로 ImageIO를 사용해서 JPEG/PNG/HEIC 등을 파싱한다.
예외: UIImage(cgImage:)처럼 이미 만들어진 CGImage를 감싸는 경우.
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: 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: kCGImageSourceCreateThumbnailFromImageIfAbsent가 뭐야?
A: 일부 JPEG/HEIC 파일은 임베디드 썸네일이 이미 들어있다.
JPEG 파일 구조: ┌─────────────────────────────┐ │ EXIF 메타데이터 │ │ ├─ 임베디드 썸네일 (160x120)│ ← 이미 만들어진 작은 이미지 │ └─ 촬영 정보 등 │ │ 본문 이미지 데이터 │ └─────────────────────────────┘
실무에서는 Always를 더 많이 쓴다 — 임베디드 썸네일 크기가 원하는 크기와 다를 수 있어서.
UIImage(data:)는 디코딩하지 않는다. 압축된 Data를 참조하고, 실제 비트맵은 픽셀에 접근할 때(draw 등) 생성된다.
UIImage ├── CGImage (객체 존재) │ ├── 메타데이터: width=4000, height=4000, colorSpace, ... │ ├── 압축된 Data 참조 (JPEG 바이트) │ └── 디코딩된 비트맵: ❌ 아직 없음 └── scale, orientation
"비어있는 픽셀 버퍼"가 아니라, 압축된 Data만 참조하고 있고 디코딩된 비트맵 자체가 생성 안 된 상태.
비유: ZIP 파일 + 목차만 읽은 상태. 압축 해제 안 함.
UIImage ├── CGImage │ ├── 메타데이터: width=4000, height=4000, ... │ ├── 압축된 Data 참조 │ └── 디코딩된 비트맵: ✅ 64MB RGBA 배열 생성됨 └── scale, orientation
draw(in:), cgImage.dataProvider 등 원본 픽셀에 접근하는 순간 디코딩이 강제 수행된다.
지정한 영역에 이미지를 그리는 메서드. 그리려면 원본 픽셀을 읽어야 하므로 디코딩 발생.
// 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 방식 — 비효율]
Data → UIImage(껍데기)
→ resize 내부 draw
→ 64MB 원본 디코딩
→ 작은 비트맵 생성
↑ 메모리 피크
[DownsamplingImageProcessor — 효율]
Data → CGImageSource
→ 작은 비트맵 직접 생성
↑ 64MB 안 거침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
Q: RunLoop이 16.67ms 초과하면 프레임 드랍이라고 했는데, Render Server 합성 시간도 있잖아?
A: 맞다. 프레임 드랍은 두 곳에서 발생할 수 있다.
App (메인 스레드) Render Server Display
──────────────────── ────────────────── ─────────
RunLoop → commit ──→ compositing (합성) ──→ VSync에 표시
└─ Render Preparation └─ GPU 렌더링
(디코딩 포함)정확한 표현: "App 작업 + Render Server 합성"이 VSync 전에 완료되어야 프레임 드랍 없음.
A: 당연하다. RunLoop은 속도 제한이 없다.
// 이벤트 없으면 RunLoop: 대기(sleep) → 이벤트 → 처리(1ms) → 대기 → ... // 한 사이클이 0.1ms, 1ms, 5ms 등 얼마든지 빠를 수 있음
"RunLoop이 60fps로 돈다"는 완전히 잘못된 표현.
VSync가 60fps인 것이고, RunLoop은 할 일 있으면 처리하고 없으면 쉰다.
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하면 화면이 먹통이 되는 이유는?
A: 데드락으로 RunLoop이 영원히 끝나지 않음.
// 메인 스레드에서 실행 중
DispatchQueue.main.sync { // ← 메인 스레드 블로킹
// 이 블록은 메인 스레드에서 실행되어야 함
// 하지만 메인 스레드는 위의 sync를 기다리는 중
// → 서로 기다림 → 데드락
}RunLoop이 무한 대기 → commit 없음 → 화면 갱신 영원히 안 됨 = 먹통
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 내부 버퍼가 디코딩됐는지 확인할 수 있어?
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)
}
}