2026-07-18 학습노트 — 이미지 디코딩·다운샘플·캐싱·네트워크 심화
파일 크기 ≠ 메모리 크기부터 다운샘플, 디코딩 시점·스레드, 2단 캐싱, 그리고 네트워크 응답·취소·캐시 헤더까지 이미지 파이프라인 전체를 정리한다.
§1. 이미지 메모리 기초: 파일 크기 ≠ 메모리 크기
Q. 디스크의 JPEG/PNG 용량과 화면에 뜰 때 메모리가 왜 다른가?
- JPEG/PNG는 압축된 형식. 화면에 그리려면 압축 안 된 픽셀 버퍼(bitmap)로 디코딩해야 함(픽셀당 RGBA 4바이트).
- 4000×3000 사진: 압축 파일 약 2~3MB → 디코딩 후 4000×3000×4 ≈ 48MB.
- 핵심 대비는 "디스크 vs 메모리"가 아니라 "압축 상태(작음) vs 디코딩 상태(큼)". 압축은 디스크든 메모리(Data)든 작다.
Q. 디코딩된 비트맵 메모리 계산식과 point/pixel 구분?
- 디코딩 메모리 = 픽셀W × 픽셀H × 4바이트. (이미 픽셀 단위면 scale 안 곱함)
- iOS UI는 point(pt) 단위, 기기마다 scale(@2x/@3x). 1pt = scale px.
- 원본(픽셀 단위) 디코딩: 4000×3000×4 = 48MB (scale 곱하지 않음).
- 타깃 뷰(pt 단위) 디코딩: (ptW×scale)×(ptH×scale)×4 → 면적 기준 scale² 등장. 예) 200×200 pt를 @3x에서: (200×3)×(200×3)×4 = 600×600×4 ≈ 1.44MB.
- 흔한 실수: 원본 픽셀 크기에 scale을 또 곱하는 것(중복). scale은 "pt→px 변환"에서만.
Q. 뷰 크기를 작게 하면 이미지 메모리가 줄어드나?
- 아니다. UIImage는 내부적으로 CGImage를, 그 CGImage가 디코딩된 픽셀 버퍼를 참조: UIImage → CGImage → 디코딩 버퍼(48MB).
- UIImageView.frame을 200×200으로 줄여도 이 참조 사슬(48MB)은 그대로. 뷰는 표시만 축소, 디코딩 메모리는 안 줄어듦.
- 그래서 애초에 작게 디코딩하는 downsampling이 필요.
§2. Downsampling
Q. downsampling이란? scale(풀디코딩 후 축소)과 뭐가 다른가?
- downsampling: 디코딩 단계에서 애초에 target 크기로만 디코딩 → 메모리 피크 없음.
- full decode 후 scale: 48MB 풀디코딩을 한 번 거친 뒤 축소 → 순간 메모리 피크 발생.
- 큰 UIImage를 만든 뒤 줄이는 게 아니라, ImageIO로 처음부터 작게 디코딩하는 것이 핵심.
Q. ImageIO 다운샘플 코드와 옵션 의미는?
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let sourceOpt = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, sourceOpt) else { return nil }
let maxDimension = max(pointSize.width, pointSize.height) * scale // pt → px
let opt = [
kCGImageSourceCreateThumbnailFromImageAlways: true, // 임베디드 썸네일 무시, 원본에서 생성
kCGImageSourceShouldCacheImmediately: true, // 디코딩을 지금(이 호출) 수행
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF 방향 반영
kCGImageSourceThumbnailMaxPixelSize: maxDimension
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opt) else { return nil }
return UIImage(cgImage: cg)
}
- kCGImageSourceShouldCache(source 옵션): 소스가 원본 풀디코딩 결과를 캐시할지. downsample에선
false → 풀사이즈를 붙들지 않아 메모리 절약. (앱 재사용 캐시인 NSCache와는 별개 층)
- kCGImageSourceShouldCacheImmediately(썸네일 옵션): 디코딩 시점 제어.
true=지금 즉시, false(기본)=렌더 시점 lazy. 스레드는 안 바꿈.
- kCGImageSourceCreateThumbnailFromImageAlways: 일부 JPEG의 EXIF 임베디드 썸네일(저화질)을 쓰지 않고 원본에서 지정한 maxPixelSize로 제대로 생성. (...IfAbsent는 임베디드가 있으면 그걸 씀)
§3. 디코딩 시점과 스레드
Q. 디코딩은 언제 일어나나?
- UIImage(data:)/UIImage(contentsOfFile:)는 만들 때 바로 디코딩 안 함. 처음 화면에 그려지는 순간(렌더, 메인 스레드)에 lazy 디코딩.
- 스크롤 중 이 디코딩이 메인 스레드에서 터지면 hitch(끊김).
Q. 메인 vs 백그라운드 디코딩 — "둘 다 늦게 뜨는 건 같지 않나?"
- 메인 디코딩: 디코딩하는 순간 메인 스레드 통째로 블록 → 스크롤/터치/애니메이션 전부 멈춤(hitch). "이미지만 늦는" 게 아니라 UI 전체가 얼어붙음.
- 백그라운드 디코딩: 메인은 자유 → 스크롤 계속 부드러움. 이미지만 살짝 늦게 툭 나타남.
- 늦는 성격이 다름: 메인=전체 멈춤, 백그라운드=그 이미지만 지연. 그래서 백그라운드 디코딩이 스크롤 성능의 핵심.
- 백그라운드 단점(트레이드오프): 스레드 관리, 셀 재사용 시 이전 로딩 취소 필요(안 하면 엉뚱한 이미지), 완료 후 메인 hop.
Q. kCGImageSourceShouldCacheImmediately: true만 하면 백그라운드에서 디코딩되나?
- 아니다. 이 옵션은 디코딩 "시점"만 정함(지금 vs 렌더 시점). 스레드는 안 바꿈.
- 스레드는 "downsample 함수를 어느 큐에서 호출하느냐"로 결정:
DispatchQueue.global(qos: .userInitiated).async {
let image = downsample(imageAt: url, to: size, scale: scale) // 옵션=즉시 디코딩
DispatchQueue.main.async { cell.imageView.image = image } // 메인에서 set
}
Q. 백그라운드 디코딩용 공식 API는?
- iOS 15+: image.prepareForDisplay { decoded in ... } / await image.preparingForDisplay().
- UIKit이 백그라운드에서 디코딩을 대신 해줌. 단 다운샘플(target 축소)은 안 함 — 기존 UIImage를 미리 디코딩만.
- 정리: 다운샘플은 ImageIO+직접 dispatch, 단순 미리 디코딩은 prepareForDisplay.
Q. 셀 재사용 취소는 메인 디코딩에도 필요한가?
- 취소 문제는 "스레드"가 아니라 비동기 간격(gap)에서 발생. 네트워크 다운로드는 어차피 비동기 → "시작~완료" 사이 셀 재사용 가능 → 취소/검증 필요.
- 순수 메인 동기 디코딩만은 즉시 완료라 재사용 간격이 없지만, 그건 UI 프리징이 더 큰 문제. 실무는 네트워크가 끼므로 비동기 → 취소 필수.
§4. 캐싱 (메모리 + 디스크 2단)
Q. 왜 메모리 캐시와 디스크 캐시를 둘 다 두나?
| 저장 형태 | 아끼는 비용 | 수명 |
| 메모리 캐시 | 디코딩된(+다운샘플된) UIImage | 디코딩 + 디스크읽기 + 네트워크 | 휘발성(메모리 압박·앱 종료 시 사라짐) |
| 디스크 캐시 | 압축된 Data | 네트워크 | 영속(앱 재실행해도 남음) |
- 조회 순서: 1. 메모리 캐시 있으면 즉시 표시(디코딩 X, 가장 빠름). 2. 없으면 디스크 캐시 → Data 읽어 디코딩(다운샘플) → 메모리에 올리고 표시(네트워크 X). 3. 둘 다 없으면 네트워크 다운로드 → 디스크에 Data 저장 + 디코딩 → 메모리에 올리고 표시.
Q. 메모리 캐시로 NSCache가 Dictionary보다 나은 이유?
- 1. 메모리 압박 시 자동 방출(eviction) — Dictionary는 무한 증가 → 크래시.
- 2. 스레드 안전 — Dictionary는 직접 락 필요.
- 3. 용량 제한(countLimit, totalCostLimit) — 이미지 바이트를 cost로 넣어 총량 관리.
Q. 캐시 키를 URL 문자열만 쓰면 생기는 문제?
- 같은 URL을 200×200으로 캐시해두면, 800×800이 필요한 화면이 작은(흐린) 이미지를 잘못 가져옴.
- 키에 크기를 포함(url_width_height)해서 크기별로 구분 저장/조회.
Q. UIImage는 URL을 자동 캐싱하나?
- UIImage(named:)만 캐싱(에셋/번들, 이름 기반). UIImage(data:)/(contentsOfFile:)는 캐싱 안 함.
- 네트워크 이미지(URL)는 자동 캐싱 없음 → 직접 캐시 구현 또는 라이브러리(Kingfisher/SDWebImage/Nuke).
- 내 캐시 없으면 재진입 시 재디코딩(및 URLCache 없으면 재다운로드).
Q. URLCache란?
- URLSession의 HTTP 응답 캐시(네트워크 계층). 저장하는 건 압축된 Data + 응답 메타데이터(디코딩된 이미지 아님).
- HTTP 캐시 헤더(Cache-Control, ETag, Expires)를 따름. 유효하면 네트워크 안 타고 캐시 Data 반환.
- URLCache.shared 기본 제공, 메모리+디스크 가능(용량 설정).
- URLCache = 네트워크 재요청만 막음. 디코딩은 매번 다시 → 디코딩 비용 아끼려면 별도 메모리 캐시(디코딩된 UIImage) 필요. 층이 다름.
Q. Kingfisher는 디코딩/다운샘플/캐싱을 자동으로?
- 디코딩: 자동. 캐싱: 자동(메모리+디스크). 다운샘플: 자동 아님 — 기본은 풀사이즈 디코딩. DownsamplingImageProcessor + .scaleFactor 옵션을 명시해야 함:
let processor = DownsamplingImageProcessor(size: CGSize(width: 200, height: 200))
imageView.kf.setImage(with: url, options: [.processor(processor), .scaleFactor(UIScreen.main.scale)])
- 스크롤 재진입: 메모리 캐시에 있으면 다운로드·디코딩 모두 스킵. 디스크에만 있으면 Data 읽어 재디코딩(네트워크는 스킵).
§5. 네트워크 응답 · 취소 · 캐시 헤더 심화
Q. URLSession의 응답은 항상 Data인가?
- 아니다. task 종류에 따라: dataTask/data(from:) → 본문을 메모리에 Data로(일반 API). downloadTask/download(from:) → 본문을 디스크 임시 파일로 스트리밍, 파일 URL 반환(큰 이미지/영상, 메모리 피크 회피). uploadTask → 업로드.
Q. 응답 메타데이터란?
- URLResponse(보통 HTTPURLResponse)에 담기는 본문 제외 모든 정보: 상태코드, 헤더(Content-Type, Cache-Control, ETag, Last-Modified 등), MIME, 길이, 최종 URL. data(from:)의 반환 (Data, URLResponse) = (본문, 메타데이터).
Q. HTTP 캐시 헤더와 stale(낡은 데이터) 문제?
- Cache-Control: max-age=3600(1h 캐시), no-cache(캐시하되 매번 재검증), no-store(캐시 금지). Expires: 절대 만료 시각(max-age 있으면 그게 우선). ETag: 리소스 버전 식별자(해시). 내용 바뀌면 ETag도 바뀜.
- URLCache는 서버 헤더를 따름(클라가 URLRequest.cachePolicy로 무시/강제 가능).
- stale 딜레마: max-age 동안 리소스가 바뀌어도 낡은 캐시를 보여줌.
- 해결1 ETag 재검증: 만료 시 If-None-Match: <ETag> 조건부 요청 → 안 바뀌었으면 304 Not Modified(본문 없이, 저렴), 바뀌었으면 200+새 본문.
- 해결2 불변 URL: 이미지 CDN은 URL에 버전/해시를 박음(img_v2_abc.jpg). 내용 바뀌면 URL이 바뀌어 stale 원천 차단.
Q. 셀 재사용 시 이미지 로딩 취소 패턴은?
final class FeedCell: UICollectionViewCell {
private var loadTask: Task<Void, Never>?
func configure(url: URL) {
loadTask?.cancel() // 이전 로딩 취소
loadTask = Task {
guard let image = try? await ImageLoader.shared.load(url) else { return }
if Task.isCancelled { return } // 취소됐으면 결과 버림
imageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
loadTask?.cancel(); loadTask = nil; imageView.image = nil
}
}
- 취소는 협조적(cooperative): 부모 Task 취소 → 내부 async(URLSession.data)가 감지해 CancellationError/URLError.cancelled throw.
- 이미 결과가 도착했으면 다운로드는 못 무름 → Task.isCancelled 체크로 결과를 셀에 반영하지 않고 버림.
- 더 견고하게: 완료 시점에 셀의 현재 URL(identity) 비교까지 병행.
Q. ImageIO에 백그라운드 디코딩/다운샘플 옵션이 있나?
- 없다. ImageIO 옵션은 캐시 여부·디코딩 시점·크기만 제어, 스레드는 제어 안 함. 백그라운드는 직접 DispatchQueue.global().async/Task로. (iOS 15+ prepareForDisplay만 백그라운드 디코딩 대행, 단 다운샘플 안 함)
Q. kCGImageSourceShouldCache=false면 "다운샘플 디코딩을 캐시하라"는 뜻?
- 아니다. 이 옵션은 원본 풀사이즈 디코딩을 소스에 캐시할지만 제어.
false=원본 풀디코딩 안 붙듦(절약). "대신 다운샘플 캐시"라는 의미 아님. 다운샘플 결과(썸네일 UIImage)는 내가 받아 드는 별개 산출물 → 재사용 캐싱은 NSCache가 담당(별도 층).
Q. dataTask vs downloadTask — 이미지는 압축 데이터인데 downloadTask가 왜 필요?
- 압축돼도 파일이 클 수 있음(4K 원본·GIF·영상). dataTask는 본문 전체를 메모리 Data로 → 여러 개면 피크. downloadTask는 디스크로 스트리밍(메모리에 통째로 안 올림) + 백그라운드 전송 + 이어받기(resume). 작은 썸네일=dataTask 충분, 큰 파일/영상/백그라운드=downloadTask.
Q. URLRequest.cachePolicy와 "1시간 캐시" 동작?
- 클라가 캐시 취급 방식 지정: .useProtocolCachePolicy(기본, 서버 헤더 따름) / .reloadIgnoringLocalCacheData(캐시 무시, 항상 새로) / .returnCacheDataElseLoad(신선도 무시하고 캐시 우선) / .returnCacheDataDontLoad(오프라인).
- cachePolicy는 주로 "저장된 캐시를 쓸지/무시할지" 제어. 서버가 no-store면 저장 자체가 없어 "강제 사용" 불가. 강제 방향은 "무시하고 새로" 또는 "낡아도 쓰기".
- max-age=3600 신선한 1시간 동안은 서버에 물어보지 않고 캐시 직접 사용(네트워크 0) → 그래서 빠름. 위험 관리는 서버 책임(자주 바뀌면 짧은 max-age나 no-cache).
Q. 캐시 중에도 재검증 통신이 일어나나? (타이밍)
- 신선한 동안: 네트워크 0 (ETag 재검증도 안 함). 만료 후: 조건부 요청(If-None-Match: ETag) → 304면 캐시 유지, 200이면 교체. no-cache: "캐시하되 매번 재검증" → 쓸 때마다 조건부 요청.
Q. CDN URL 버전/해시 예시?
쿼리 버전: https://cdn.example.io/avatar/12345.jpg?v=3
콘텐츠 해시 파일명: https://cdn.example.com/img/a1b2c3d4e5f6.jpg
리사이즈 CDN: https://images.example.io/photo.jpg?w=200&h=200&q=80
- 내용 바뀌면 URL이 바뀜 → 다른 리소스로 취급 → stale 원천 차단. 보통 Cache-Control: max-age=31536000, immutable 동반. 리사이즈 CDN(imgix/Cloudinary)은 쿼리로 크기 지정 → 서버가 리사이즈해 내려줌(클라 다운샘플 부담↓).
Q. load 함수 내부에서도 취소 체크해야 하나?
- 그렇다. URLSession.data는 취소 시 throw하지만 그 뒤 디코딩은 자동으로 안 멈춤 → 디코딩 직전 try Task.checkCancellation()로 낭비 방지.
let (data, _) = try await session.data(from: url) // 취소 시 throw
try Task.checkCancellation() // 디코딩 전 확인
let image = downsample(data, to: size) // 취소됐으면 스킵
Q. identity(URL) 비교는 어떻게?
func configure(url: URL) {
representedURL = url
loadTask = Task {
let image = try? await ImageLoader.shared.load(url)
guard representedURL == url else { return } // 재사용돼 URL 바뀌면 버림
imageView.image = image
}
}
Q. kCGImageSourceShouldCache=false면 스크롤 재진입 시 재디코딩되나?
- 아니다. 이 옵션의 캐시는 CGImageSource 객체 수명 동안만 유효. downsample 함수 끝나면 source 해제 → 그 캐시도 소멸. 스크롤 간 재사용과 무관.
- 재진입 시 재디코딩 여부는 내 NSCache가 결정: 다운샘플 UIImage가 있으면 재디코딩 X, 없으면(밀려남) 디스크 Data 읽어 재디코딩.
Q. 취소되면 session.data가 취소되고 checkCancellation에 잡히나? (타이밍)
let (data, _) = try await session.data(from: url) // ⓐ
try Task.checkCancellation() // ⓑ
let image = downsample(data, to: size) // ⓒ 비싼 디코딩
두 체크는 다른 시간 창을 담당(같은 걸 잡는 게 아님):
| 취소 시점 | 어디서 잡히나 |
| 네트워크 진행 중 | ⓐ session.data가 URLError(.cancelled) throw → ⓑ까지 안 감 |
| 네트워크 완료 후 ~ 디코딩 전 | ⓑ Task.checkCancellation() (ⓐ는 정상 리턴했으므로) |
- 즉 "네트워크 중 취소 → checkCancellation에서 잡힘"은 틀림. 그건 ⓐ가 throw. ⓑ는 "네트워크는 성공했는데 그 직후 취소된 틈"을 메꿔 ⓒ 낭비를 막음. Task가 시작 전부터 취소 상태면 ⓐ가 시작하자마자 throw.
Q. kCGImageSourceShouldCache=true는 언제? (쓸데없이 메모리만 쓰는 것 아닌가)
true = "이 소스의 (풀) 이미지를 여러 번 그리거나 재접근할 거니 디코딩 1회 후 캐시" → 메모리↔CPU 트레이드오프, 재사용 시 이득. 기본값 true.
- 재사용 예시:
// A) draw(_:)에서 같은 이미지를 반복 렌더
private lazy var cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)
override func draw(_ rect: CGRect) {
for i in 0..<20 { ctx.draw(cgImage!, in: ...) } // true면 디코딩 1회 재사용
}
// B) 타일 패턴 배경
view.backgroundColor = UIColor(patternImage: tileImage)
- 다운샘플은 작은 썸네일 한 번 뽑고 원본 버림(반복 없음) →
false(피크 메모리 절약)가 옳음. 기준: 반복 접근 → true, 일회성 → false.