Integrated Practice
실전 통합 ImageLoader
이미지 디코딩·2단 캐시·다운샘플·백그라운드·취소·in-flight dedup·셀 재사용을 하나의 파이프라인으로 조립.
전체 흐름
load(url, size)
① 메모리 캐시(NSCache) 히트? → 즉시 반환 (디코딩·네트워크 X)
② in-flight 진행 중? → 그 Task 공유 (중복 다운로드 방지)
③ 새 Task 시작:
디스크 캐시 히트? → Data 읽어 백그라운드 디코딩+다운샘플
없으면 → 네트워크 다운로드 → 디스크 저장 → 백그라운드 디코딩+다운샘플
→ 메모리 캐시에 올리고 반환 (성공 시 in-flight 정리, 실패 시 캐시 제거)
1. 다운샘플
func downsample(_ data: Data, to size: CGSize, scale: CGFloat) -> UIImage? {
let src = CGImageSourceCreateWithData(data as CFData,
[kCGImageSourceShouldCache: false] as CFDictionary) // 원본 풀디코딩 안 붙듦(피크 절약)
guard let src else { return nil }
let maxPixel = max(size.width, size.height) * scale // pt→px
let opt = [
kCGImageSourceCreateThumbnailFromImageAlways: true, // 원본에서 제대로 생성
kCGImageSourceShouldCacheImmediately: true, // 지금 즉시 디코딩(호출 스레드에서)
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF 방향
kCGImageSourceThumbnailMaxPixelSize: maxPixel
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opt) else { return nil }
return UIImage(cgImage: cg)
}
2. 2단 캐시 (메모리=디코딩 UIImage / 디스크=압축 Data)
final class ImageCacheStore {
private let memory = NSCache<NSString, UIImage>()
private let dir = FileManager.default.temporaryDirectory.appendingPathComponent("img")
init() {
memory.totalCostLimit = 100 * 1024 * 1024
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
private func key(_ url: String, _ size: CGSize) -> String { "\(url)_\(Int(size.width))x\(Int(size.height))" } // 크기 포함
private func diskURL(_ url: String) -> URL { dir.appendingPathComponent(String(url.hashValue)) }
func memoryImage(_ url: String, _ size: CGSize) -> UIImage? { memory.object(forKey: key(url,size) as NSString) }
func setMemory(_ img: UIImage, _ url: String, _ size: CGSize) {
memory.setObject(img, forKey: key(url,size) as NSString, cost: Int(img.size.width*img.size.height*4))
}
func diskData(_ url: String) -> Data? { try? Data(contentsOf: diskURL(url)) }
func setDisk(_ data: Data, _ url: String) { try? data.write(to: diskURL(url)) }
}
3. ⭐ actor로 in-flight dedup + 취소
actor ImageLoader {
static let shared = ImageLoader()
private let cache = ImageCacheStore()
private let scale = UIScreen.main.scale
private var inFlight: [String: Task<UIImage, Error>] = [:] // 진행 중 Task 공유
func load(_ url: String, size: CGSize) async throws -> UIImage {
let k = "\(url)_\(Int(size.width))x\(Int(size.height))"
if let img = cache.memoryImage(url, size) { return img } // ① 메모리 캐시
if let task = inFlight[k] { return try await task.value } // ② 진행 중이면 공유
let task = Task<UIImage, Error> { // ③ 다운로드+디코딩을 한 Task에
let data: Data
if let disk = cache.diskData(url) { data = disk } // 디스크 캐시
else {
let (net, _) = try await URLSession.shared.data(from: URL(string: url)!) // 취소 시 throw
cache.setDisk(net, url); data = net // 네트워크→디스크 저장
}
try Task.checkCancellation() // 디코딩 전 취소 확인
guard let img = self.downsample(data, to: size, scale: self.scale) else {
throw URLError(.cannotDecodeContentData)
}
return img
}
inFlight[k] = task // ★ await 전에 박아둠 → dedup
do {
let img = try await task.value
cache.setMemory(img, url, size); inFlight[k] = nil // 메모리 승격
return img
} catch {
inFlight[k] = nil // 실패는 캐시 안 함(재시도 가능)
throw error
}
}
nonisolated private func downsample(_ d: Data, to s: CGSize, scale: CGFloat) -> UIImage? { /* 1번 */ nil }
}
4. 셀에서 사용 — 취소 + identity
final class FeedCell: UICollectionViewCell {
private var loadTask: Task<Void, Never>?
private var representedURL: String?
func configure(_ url: String, size: CGSize) {
representedURL = url; imageView.image = nil
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let img = try? await ImageLoader.shared.load(url, size: size) else { return }
if Task.isCancelled { return }
guard self?.representedURL == url else { return } // 재사용돼 URL 바뀌면 버림
self?.imageView.image = img
}
}
override func prepareForReuse() {
super.prepareForReuse()
loadTask?.cancel(); loadTask = nil; imageView.image = nil; representedURL = nil
}
}
Q. representedURL은 isCancelled 있으면 불필요? 메인 액터로 직렬화되면 redundant(생략 가능)하다. 불변식: 타깃이 바뀔 때마다(configure·prepareForReuse) 이전 task를 cancel →
!isCancelled ⟹ 시작 이후 타깃 안 바뀜 ⟹ representedURL==url. 즉 isCancelled 통과 = 셀이 여전히 이 URL을 원함 = 올바른 이미지.진짜 관건 = 그 Task가 메인 액터로 격리됐나. Task{}는 감싼 함수가 actor 격리일 때만 상속. Swift 6/@MainActor UIKit이면 cell·Task가 메인 액터라 tail이 configure/prepareForReuse와 직렬화 → representedURL 없이도 정확. 구형(비-@MainActor)이면 Task{}가 글로벌 executor에서 돎 → 직렬화 깨짐 + imageView 접근 오프메인(버그) →
Task { @MainActor in }/main hop 필요(그럼 다시 직렬화 케이스).그럼 왜 흔히 두나(값싼 보험): 필수는 아니고 방어적 — ① cancel을 리팩터링에서 빠뜨려도 identity는 정확 ② 격리 가정 흔들려도 안전 ③ 의미 명확성. (이전 판의 "캐시 히트 레이스로 isCancelled=false 통과" 설명은 오류 — 직렬화 시 !isCancelled면 타깃 안 바뀐 것이라 올바름.)
dedup / identity 개념
- dedup = deduplication(중복 제거). in-flight dedup = 같은 URL 요청이 진행 중이면 새로 시작 말고 그 Task를 공유 → 중복 다운로드·디코딩 제거(5개 셀이 같은 이미지 요청해도 다운로드 1번).
- identity 검증(representedURL) = "셀이 지금 원하는 URL일 때만 반영"이라는 정확성 불변식. Kingfisher 등도 셀 identity 기준으로 최종 반영 판단.
각 부분 ↔ 학습 연결
| 코드 | 학습 |
|---|---|
| downsample + ShouldCache:false | 피크 없는 디코딩(대형 이미지 문서) |
| NSCache(디코딩)/디스크(압축 Data) | 2단 캐싱 |
| actor + inFlight를 await 전 박기 | actor reentrancy·in-flight dedup(토큰 리프레시와 동일 패턴) |
| 다운로드+디코딩 한 Task | dedup 함정(별도 Task면 미디코딩/중복) |
| Task.checkCancellation() | 취소 타이밍(네트워크 후~디코딩 전) |
| 셀 cancel + representedURL==url | 셀 재사용 레이스 |
주의점: ① 다운로드+디코딩을 한 Task에(공유 호출도 완전 디코딩 이미지 받음) ② 실패 시 inFlight 제거(재시도 가능) ③ inFlight를 await 전에 박아야 dedup 성립 ④ 디스크 캐시 무한 증가 방지(LRU·용량 제한은 실서비스 추가).
한 줄: 메모리(디코딩)·디스크(압축) 2단 캐시 위에 actor로 in-flight dedup, 다운로드+디코딩을 한 Task에 묶어 중복 차단. 다운샘플로 피크 제거·백그라운드 디코딩, 셀은 취소+URL identity로 레이스 방지.
관련 문서
대형 이미지 메모리·디코딩·다운샘플·캐싱
스크롤 성능·hitch·셀 재사용·in-flight dedup
네트워크 레이어·토큰 리프레시(같은 dedup 패턴)