Kingfisher는 URLSession 위에 올라가는 Swift 이미지 로딩 라이브러리다. imageView.kf.setImage(with: url) 한 줄로 동작하지만, 그 한 줄 아래에는 다중 레이어 캐시, 비동기 다운로드 파이프라인, 프로세서 체인, 프리패칭, 재시도 전략이 맞물려 돌아간다.
이 글은 튜토리얼이 아니라 해부서다. 왜 이렇게 설계했는지, 어디서 성능이 결정되는지를 코드와 도표로 따라간다.
전체 흐름
kf.setImage(with:) 호출 한 번이 내부적으로 어떤 경로를 밟는지 먼저 본다.
캐시 히트 계층에 따라 응답 속도가 완전히 달라진다.
| 경로 | 비용 |
|---|---|
| 메모리 히트 | ~0ms, 메인 스레드에서 동기적으로 반환 가능 |
| 디스크 히트 | ~수ms, 파일 I/O + 디코딩 |
| 네트워크 | ~수백ms, DNS + TCP + TLS + 전송 |
KingfisherManager: 중앙 조율자
KingfisherManager는 다운로더와 캐시 사이를 중재하는 단일 진입점이다. KingfisherManager.shared가 싱글턴이고, 내부적으로 ImageDownloader.default와 ImageCache.default를 들고 있다.
KingfisherManager는 processingQueue라는 전용 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:) 한 줄이 간단해 보이는 이유는 이 모든 것이 내부에서 조율되기 때문이다.