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 개념

각 부분 ↔ 학습 연결

코드학습
downsample + ShouldCache:false피크 없는 디코딩(대형 이미지 문서)
NSCache(디코딩)/디스크(압축 Data)2단 캐싱
actor + inFlight를 await 전 박기actor reentrancy·in-flight dedup(토큰 리프레시와 동일 패턴)
다운로드+디코딩 한 Taskdedup 함정(별도 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 패턴)