LODY/정리

애플 플랫폼 백과사전 / Kingfisher Under the Hood

Kingfisher Under the Hood

Kingfisher는 URLSession 위에 올라가는 Swift 이미지 로딩 라이브러리다. imageView.kf.setImage(with: url) 한 줄로 동작하지만, 그 한 줄 아래에는 다중 레이어 캐시, 비동기 다운로드 파이프라인, 프로세서 체인, 프리패칭, 재시도 전략이 맞물려 돌아간다.

이 글은 튜토리얼이 아니라 해부서다. 왜 이렇게 설계했는지, 어디서 성능이 결정되는지를 코드와 도표로 따라간다.


전체 흐름

kf.setImage(with:) 호출 한 번이 내부적으로 어떤 경로를 밟는지 먼저 본다.

캐시 히트 계층에 따라 응답 속도가 완전히 달라진다.

경로비용
메모리 히트~0ms, 메인 스레드에서 동기적으로 반환 가능
디스크 히트~수ms, 파일 I/O + 디코딩
네트워크~수백ms, DNS + TCP + TLS + 전송

KingfisherManager: 중앙 조율자

KingfisherManager는 다운로더와 캐시 사이를 중재하는 단일 진입점이다. KingfisherManager.shared가 싱글턴이고, 내부적으로 ImageDownloader.defaultImageCache.default를 들고 있다.

KingfisherManagerprocessingQueue라는 전용 DispatchQueue를 갖는다. 다운로드 완료 후 이미지 디코딩·프로세서 실행이 이 큐에서 이루어지기 때문에 메인 스레드를 막지 않는다.

// KingfisherManager.swift
let processQueueName = "com.onevcat.Kingfisher.KingfisherManager.processQueue.\(UUID().uuidString)"
processingQueue = .dispatch(DispatchQueue(label: processQueueName))

이중 캐시 레이어

Kingfisher의 핵심 최적화는 메모리 + 디스크 이중 레이어다. ImageCache가 두 레이어를 통합 관리한다.

MemoryStorage: NSCache 기반 LRU

메모리 캐시는 NSCache를 래핑한다. NSCache는 시스템 메모리 압박 시 자동으로 항목을 제거하므로 별도 LRU 구현이 불필요하다.

// MemoryStorage.swift
let storage = NSCache<NSString, StorageObject<T>>()
 
// 비용 기반 한도
storage.totalCostLimit = config.totalCostLimit  // 기본: 앱 메모리의 25%
storage.countLimit     = config.countLimit       // 기본: 제한 없음

이미지 비용은 픽셀 수 × 바이트/픽셀로 계산된다.

// ImageCache.swift
extension KFCrossPlatformImage: CacheCostCalculable {
    public var cacheCost: Int { return kf.cost }
    // = cgImage.bytesPerRow * cgImage.height
}

만료 추적 패턴. NSCache가 자체적으로 항목을 제거할 때 keys Set은 갱신되지 않는다. Kingfisher는 의도적으로 이를 허용한다. keys는 느슨한 추적이고, 실제 만료 제거는 cleanTimer가 주기적으로 처리한다. 엄격한 동기화보다 락 비용을 아끼는 쪽을 선택한 것이다.

DiskStorage: 파일 시스템 + maybeCached 단축 경로

디스크 캐시는 FileManager로 파일을 쓰고 읽는다. 파일명은 캐시 키의 MD5 해시다. 여기서 눈에 띄는 최적화가 maybeCached다.

maybeCached는 false-positive(있다고 추측했는데 없는 경우)는 허용하지만 false-negative(없다고 단언했는데 있는 경우)는 허용하지 않는다. 즉, Set에 없으면 파일 시스템을 아예 건드리지 않고 바로 미스 처리한다. 빠른 스크롤 중 대량의 캐시 조회에서 불필요한 I/O를 막는다.


캐시 키 전략

캐시 키는 Resource.cacheKey에서 시작한다. URL을 그대로 키로 쓰지 않고 ImageProcessor.identifier를 접미사로 붙인다.

같은 URL이라도 RoundCornerImageProcessor(radius: 20)RoundCornerImageProcessor(radius: 40)는 서로 다른 캐시 엔트리를 갖는다. 프로세서를 바꿔도 원본 이미지를 다시 다운로드하지 않고, 처리된 결과만 별도로 캐시한다.

중요: 커스텀 프로세서를 만들 때 identifier를 비워두면 DefaultImageProcessor와 같은 키를 공유하게 된다. 처리된 이미지가 다른 곳에서 원본인 것처럼 쓰이는 버그가 생긴다.

// 올바른 커스텀 프로세서
struct BlurProcessor: ImageProcessor {
    let identifier = "com.myapp.BlurProcessor-radius-\(radius)"
    let radius: CGFloat
    // ...
}

다운로드 파이프라인

ImageDownloader는 URLSession 위에 올라가는 얇은 관리 레이어다.

태스크 중복 제거

같은 URL로 동시에 여러 요청이 들어올 때 Kingfisher는 URLSession 태스크를 하나만 만들고 콜백을 공유한다.

빠르게 스크롤할 때 셀이 재사용되면서 같은 URL 요청이 연달아 발생한다. 이 중복 제거가 없으면 동일한 이미지를 여러 번 다운로드한다.

RequestModifier: 헤더 주입

Authorization이나 커스텀 헤더가 필요할 때 AnyModifier로 요청을 변조한다.

let modifier = AnyModifier { request in
    var req = request
    req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    return req
}
 
imageView.kf.setImage(with: url, options: [.requestModifier(modifier)])

ImageProcessor 체인

ImageProcessor는 다운로드된 데이터나 이미지를 변환하는 프로토콜이다.

프로세서를 append(another:)로 연결하면 합성 식별자가 자동으로 만들어진다.

let processor = RoundCornerImageProcessor(cornerRadius: 20)
    .append(another: BlurImageProcessor(blurRadius: 5))
// identifier = "round-20|>blur-5"
// 캐시 키도 이 식별자를 포함 → 독립된 캐시 슬롯

Progressive 로딩

Progressive JPEG는 전체 데이터가 오기 전에 저화질 이미지를 먼저 표시한다.

ImageProgressive는 세 가지를 제어한다.

imageView.kf.setImage(with: url, options: [
    .progressiveJPEG(
        ImageProgressive(
            isBlur: true,          // 스캔마다 블러 처리
            isFastestScan: true,   // 첫 번째 스캔만 빠르게
            scanInterval: 0.1      // 스캔 간 최소 간격(초)
        )
    )
])

scanInterval이 0이면 매 스캔마다 뷰를 업데이트하므로 CPU를 많이 쓴다. 실제 사용 시 0.1~0.2초가 균형점이다.


ImagePrefetcher: 스크롤 전 선점

ImagePrefetcher는 보여주기 전에 이미지를 미리 다운로드해 캐시에 넣는다.

UICollectionViewDataSourcePrefetching과 연계하면 스크롤 방향으로 이미지를 선점할 수 있다.

// ViewController
var prefetchTask: ImagePrefetchingTask?
 
func collectionView(_ cv: UICollectionView,
                    prefetchItemsAt indexPaths: [IndexPath]) {
    let urls = indexPaths.map { feedItems[$0.item].imageURL }
    prefetchTask = ImagePrefetcher(urls: urls).start()
}
 
func collectionView(_ cv: UICollectionView,
                    cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
    prefetchTask?.stop()
}

재시도 전략

네트워크 오류 시 자동 재시도를 RetryStrategy로 구성한다.

내장 DelayRetryStrategy는 지수 백오프를 지원한다.

imageView.kf.setImage(with: url, options: [
    .retryStrategy(
        DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(2))
        // 2s → 4s → 8s 지수 백오프
    )
])

커스텀 재시도 조건도 쉽게 만들 수 있다.

struct NetworkRetryStrategy: RetryStrategy {
    func retry(context: RetryContext, retryHandler: @escaping (RetryDecision) -> Void) {
        guard context.retriedCount < 3 else {
            retryHandler(.stop)
            return
        }
        // 서버 오류(5xx)만 재시도
        if case .responseError(let reason) = context.error,
           case .invalidHTTPStatusCode(let response) = reason,
           response.statusCode >= 500 {
            retryHandler(.retry(userInfo: nil))
        } else {
            retryHandler(.stop)
        }
    }
}

KF 빌더 패턴

KF.url(...)은 옵션을 메서드 체이닝으로 조립하는 빌더다.

KF.url(imageURL)
    .placeholder(UIImage(named: "placeholder"))
    .transition(.fade(0.3))
    .processor(RoundCornerImageProcessor(cornerRadius: 12))
    .cacheOriginalImage()
    .onSuccess { result in
        print("from: \(result.cacheType)")
    }
    .set(to: imageView)

.cacheOriginalImage()는 처리된 이미지뿐만 아니라 원본 이미지도 캐시한다. 같은 이미지를 다른 프로세서로 표시하는 곳이 여러 군데라면 원본을 디스크에 보존해 두면 재다운로드 없이 각 프로세서를 적용할 수 있다.


메모리 경고 대응

UIApplication.didReceiveMemoryWarning 알림을 받으면 Kingfisher는 메모리 캐시를 전부 비운다. 이 동작은 ImageCache가 초기화 시 자동으로 등록한다.

앱이 백그라운드로 진입할 때는 디스크 캐시의 만료 파일을 정리한다. 이 두 가지 정책이 맞물려 메모리는 빠르게 회수하되 디스크 데이터는 보존하는 계층적 관리가 이루어진다.


정리: 최적화 포인트 지도

Kingfisher의 설계 철학은 "자주 쓰는 경로를 최대한 빠르게" 다. 메모리 히트는 락 없이 반환하고, 디스크 히트는 메인 스레드를 건드리지 않는다. 네트워크가 불가피한 경우에도 태스크 중복 제거, 프리패칭, 재시도가 사용자 경험을 보호한다.

kf.setImage(with:) 한 줄이 간단해 보이는 이유는 이 모든 것이 내부에서 조율되기 때문이다.