Neoself의 기술 블로그

Kingfisher 라이브러리 분석하기 (Cache 레이어) 본문

개발지식 정리/Swift

Kingfisher 라이브러리 분석하기 (Cache 레이어)

Neoself 2025. 2. 25. 09:36

이번 포스트에서는 이미지 캐싱 라이브러리의 캐시 히트율을 높이기 위한 프로젝트의 첫 단계로, Kingfisher 라이브러리의 핵심 구조를 분석하고 필수 모듈을 추출한 과정을 공유하려 합니다.

프로젝트 배경 및 목표

최근 모바일 애플리케이션에서 이미지 로딩은 사용자 경험에 직접적인 영향을 미치는 중요한 요소입니다. 이에 저는 다음과 같은 가설을 세웠습니다:

이미지에 대한 카테고리 정보를 이미지 데이터와 함께 제공받을 수 있다면, 사용자의 이미지 사용 패턴을 학습하여 캐시 히트율을 향상시킬 수 있다.

 

이 가설을 검증하기 위해 우선 기본적인 이미지 캐싱 라이브러리를 구축할 필요가 있었고, 널리 사용되는 오픈소스 라이브러리인 Kingfisher를 참고하여 핵심 기능만을 추출하는 작업을 진행했습니다.

0. Kingfisher 라이브러리 구성 요소 나열

Kingfisher는 iOS 애플리케이션에서 이미지 다운로드 및 캐싱을 위한 강력한 라이브러리입니다. 전체 코드베이스를 분석한 결과, UIImageView.setImage 메서드 구현에 필요한 핵심 구성 요소들을 파악할 수 있었습니다.

 

 

Cache 레이어

1. DiskStorage

2. ImageCache

3. MemoryStorage

 

Facade 레이어

1. KingfisherManager

 

Networking 레이어

1. ImageDownloader

2. SessionDelegate

3. SessionDataTask

4. SessionDownloaderDelegate

 

Image 레이어

1. ImageProcessor

 

Utility 레이어

1. Delegate

 

Extensions

1. ImageView+Kingfisher

 

SwiftUI

1. KFImage

2. ImageContext

3. ImageBinder

 

 

본 게시글에서는 Kingfisher 라이브러리에 가장 핵심이 되는 Cache 레이어에 대한 분석내용을 정리해보도록 하겠습니다.

 

1. DiskStorage

파일 시스템 기반 디스크 캐싱에 필요한 CRUD를 구현합니다.

class DiskStorage<T: DataTransformable> {
    struct Config {
        let name: String
        let sizeLimit: UInt
        let fileManager: FileManager
        // ...
    }
    private let propertyQueue = DispatchQueue(label: "com.onevcat.kingfisher.DiskStorage.Backend.propertyQueue")
    
    private let config: Config
    private let directoryURL: URL
    
    var maybeCached: Set<String>?
    let maybeCachedCheckingQueue = DispatchQueue(label: "com.onevcat.kingfisher.DiskStorage.Backend.maybeCachedCheckingQueue")
    
    public var config: Config {
        get { propertyQueue.sync { _config } }
        set { propertyQueue.sync { _config = newValue } }
    }
        
    func store(value: T, forKey key: String, expiration: StorageExpiration? = nil) throws {
            let expiration = expiration ?? config.expiration
            guard !expiration.isExpired else { return } 
            let data: Data
            ...
            data = try value.toData()
            ...
            let fileURL = cacheFileURL(forKey: key, forcedExtension: forcedExtension)
            try data.write(to: fileURL, options: writeOptions)
            ...
    }
    
    func value(forKey key: String) throws -> T?
    func remove(forKey key: String) throws
    // ...
}

- 파일 시스템을 추상화하여 디스크에 데이터를 저장하고 관리합니다.

- 앞서 언급하였던 DataTransformable 프로토콜과 제너릭 타입을 조합해, DiskStorage의 구현이 특정 타입에 의존하지 않게 설계한 것을 확인할 수 있습니다.

 

DiskStorage에서 저는 아래와 같은 흥미로운 인사이트를 파악할 수 있었습니다.

1. maybeCached Set 자료구조

2. 공유속성인 config 구조체에 대한 접근방식

3. 디스크 저장소 수정 작업의 직렬화

3. 잠재적인 참조순환 차단을 위한 접근방식

 

1. maybeCached Set를 중심으로 하는 성능 최적화 메커니즘

다들 아시다시피, 파일 존재 확인을 수행하는 fileManager.fileExists(at:Path:) 메서드는 디스크에 직접 접근하는 I/O 작업으로 메모리 접근보다 많은 시간을 필요로 하는 작업입니다.

여기서 Kingfisher는 maybeCached Set 자료구조에 디스크에 저장한 데이터의 키값을 삽입하는 작업을 병행하여, maybeCached 조회만으로 디스크 내부 파일 존재 유무를 빠르게 확인할 수 있도록 설계하였습니다.

 

해당 Set가 디스크 캐시 키를 제어하는 과정은 아래와 같습니다.

 

1.1 Set 초기화 과정

func setupCacheChecking(){
    maybeCachedCheckingQueue.async {
        do {
            self.maybeCached = Set()
            try self.config.fileManager.contentsOfDirectory(atPath: self.directoryURL.path).forEach { fileName in
                self.maybeCached?.insert(fileName)
        } catch {
            self.maybeCached = nil
        }
    }
}

우선 DiskStorage 인스턴스 초기화 시점에 config 구조체를 통해 생성된 디렉토리 URL의 경로 내부에 있는 모든 파일 이름을 maybeCached에 저장합니다. 여기서 디스크 I/O 작업에 실패하게 되더라도 디스크 I/O에 대한 오버헤드만 발생할 뿐, 기능상 문제를 발생하지 않기 때문에 단순히 maybeCached를 nil로 설정하는 것을 확인할 수 있습니다.

 

1.2 Set내부 파일 이름 삽입 과정

func store(...){
    ...
    maybeCachedCheckingQueue.async {
        self.maybeCached?.insert(fileURL.lastPathComponent)
    }
}

이후, store 메서드 호출을 통해 새 파일이 디스크에 저장되면 해당 파일 이름이 key값으로써 maybeCached 세트에 추가됩니다.

 

1.3 Disk I/O 작업 오버헤드 없이 디스크 내 파일 존재유무 파악

 func value(
    forKey key: String,
    referenceDate: Date,
    actuallyLoad: Bool,
    extendingExpiration: ExpirationExtending,
    forcedExtension: String?
) throws -> T? {
    let fileManager = config.fileManager
    let fileURL = cacheFileURL(forKey: key, forcedExtension: forcedExtension)
    let filePath = fileURL.path

    let fileMaybeCached = maybeCachedCheckingQueue.sync {
        return maybeCached?.contains(fileURL.lastPathComponent) ?? true
    }
    
    // 처음 접근하는 파일일 경우 여기에서 바로 nil 반환되며 메서드 종료
    guard fileMaybeCached else { 
        return nil
    }
    
    // maybeCached가 없을 경우, Disk에 접근하기 전까지 파일 존재여부 파악불가능. 추가 오버헤드 발생
    guard fileManager.fileExists(atPath: filePath) else {
        return nil 
    }
    ...
}

이 maybeCached Set 자료구조가 빛을 발하는 시점은 바로 내부 value 메서드, 정확히는 디스크 저장소 내부에 파일이 존재하는지 유무를 파악하는 로직에서 빛을 발합니다.

maybeCached 자료구조(조회 테이블)가 없는 상태에서 처음 접근한 파일에 대해 value 메서드를 호출하게 되면, 디스크 접근이 필수적으로 동반되어야 합니다.

하지만, maybeCached로 키값을 클래스 내에서 별개 관리하면서, 디스크 I/O작업으로 인한 오버헤드 없이 파일 존재여부를 파악할 수 있게 됩니다.

 

2. 공유속성인 config 구조체에 대한 접근방식

다음 인사이트는 클래스의 공유 속성인 config에 대한 접근을 제어하고 있는 방식에 있습니다.

이 config 속성은 만료기한, 키 해시화 여부, 크기 제한과 같은 설정값뿐만 아니라, 원본 데이터에 대한 이름, 크기 제한, 디렉토리 URL과 같은 핵심정보들이 정의된 구조체입니다.

Kingfisher 라이브러리의 핵심 모듈인 ImageCache 클래스는 싱글톤 패턴을 사용하고 있기에, 앱 전체에서 동일한 ImageCache 인스턴스를 사용하게 되며, 하위 속성으로 정의된 memoryStorage와 diskStorage 또한 앱 전체에서 공유되고 있습니다.

 

이미지 그리드나 테이블 뷰를 빠르게 스크롤하는 상황을 생각해볼까요?

각 UIImageView 마다 setImage(일반적으로 didAppear) 메서드가 호출되면서 이미지 요청이 발생, 즉 짧은 시간에 다수 스레드로부터 같은 DiskStorage 인스턴스에 접근할 수 있음을 예상해볼 수 있습니다.

 

만일 이 상황에서 config 속성을 다수 스레드에서 수정하면 어떻게 될까요?

// 스레드 1
var config = diskStorage.config  // 읽기
config.sizeLimit = 50_000_000   // 수정
diskStorage.config = config     // 쓰기

// 스레드 2 (동시에)
var config = diskStorage.config  // 읽기
config.expiration = .days(7)     // 수정
diskStorage.config = config      // 쓰기

config속성이 스레드 안전하게 보호되지 않는다면, 다른 스레드로부터 변경으로 인해 덮어씌워진 config 구조체로 인해 의도와는 다른 Disk 저장소에 대한 CRUD가 수행될 수 있습니다.

private let propertyQueue = DispatchQueue(label: "com....")

private var _config: Config

public var config: Config {
    get { propertyQueue.sync { _config } }
    set { propertyQueue.sync { _config = newValue } }
}

이러한 data race condition을 방지하기 위해 Kingfisher는 propertyQueue라는 직렬화 큐 인스턴스를 생성하여, 한번에 하나의 스레드만 config에 접근할 수 있도록 보장하였습니다. DispatchQueue.sync 메서드는 직렬 큐로서, 큐에 제출한 작업이 완료될때까지 호출 스레드를 Blocking하는 성질을 지니고 있으며, 제출된 순서가 처리되는 순서와 일치되는 것을 보장시켜줍니다.

때문에, 여러 스레드에서 동시에 접근될 때 config 값이 의도치 않게 덮어씌워지는 상황을 방지할 수 있습니다.

 

3. 파일시스템 접근 및 수정 작업 직렬화

private let metaChangingQueue = DispatchQueue(label: "com.onevcat.kingfisher.DiskStorage.ioQueue")

func value(
    forKey key: String,
    referenceDate: Date,
    actuallyLoad: Bool,
    extendingExpiration: ExpirationExtending,
    forcedExtension: String?
) throws -> T? {
    do {
        let data = try Data(contentsOf: fileURL)
        let obj = try T.fromData(data)
        // 직렬 큐를 통한 파일 시스템 작업
        metaChangingQueue.async {
            meta.extendExpiration(with: self.config.fileManager, extendingExpiration: extendingExpiration)
        }
        return obj
    } catch {
        throw KingfisherError.cacheError(reason: .cannotLoadDataFromDisk(url: fileURL, error: error))
    }
}

value 메서드 내부, 디스크로부터 접근한 파일에 대한 파일만료 예상날짜를 연장시키는 메서드에서도 직렬화 큐 인스턴스를 사용한 것을 확인할 수 있습니다.

여기서 FileMeta 구조체는 파일 만료 예상 날짜뿐만 아니라 파일 크기, 파일 URL 등의 메타데이터를 관리하는 구조체에 해당합니다. 즉 FileMeta는 Config 구조체와 달리 여러 스레드가 동시에 접근할 수 있는 구조체가 아닙니다. 

 

그럼 직렬화 큐를 왜 사용하고자 했을까요?

이는 FileMeta 자체의 값을 보호하는 것이 아니고, 구조체가 영향을 미치는 파일 시스템작업을 동기화하기 위한 것입니다.

func extendExpiration(with fileManager: FileManager, extendingExpiration: ExpirationExtending) {
    let attributes: [FileAttributeKey : Any]
    ...
    try? fileManager.setAttributes(attributes, ofItemAtPath: url.path)
}

meta.extendExpiration 메서드는 파일 시스템 수정 작업을 동반합니다. 여기서 파일 시스템 작업은 여러 스레드에서 동시에 접근될 수 있는 작업 즉 thread-safe하지 않습니다. 때문에 Config 속성 때와 마찬가지로 다른 스레드에 의해 덮어씌워지는 상황을 방지하기 위해 직렬화 큐를 사용하는 것입니다.

 

4. 잠재적인 참조순환 차단

init(noThrowConfig config: Config, creatingDirectory: Bool) {
    var config = config

    let creation = Creation(config)
    self.directoryURL = creation.directoryURL

    // Break any possible retain cycle set by outside.
    config.cachePathBlock = nil
}

마지막으로, DiskStorage의 initializer 로직에서 config 속성 내부 cachePathBlock을 nil로 변경하는 로직에 대한 이유를 파악해보았습니다.

struct Creation {
    let directoryURL: URL
    let cacheName: String

    init(_ config: Config) {
        let url: URL
        if let directory = config.directory {
            url = directory
        } else {
            url = config.fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
        }

        cacheName = "com.onevcat.Kingfisher.ImageCache.\(config.name)"
        directoryURL = config.cachePathBlock(url, cacheName)
    }
}

여기서 cachePathBlock은 Creation 인스턴스 생성 시 사용되어, 딧크 저장소의 디렉토리 url 생성에 사용됩니다.

public var cachePathBlock: (@Sendable (_ directory: URL, _ cacheName: String) -> URL)! = {
    (directory, cacheName) in
    return directory.appendingPathComponent(cacheName, isDirectory: true)
}

그리고 cachePathBlock의 내부구현 코드를 보면 참조 타입인 클로저로 구현이 되어있는 것을 확인할 수 있는데요.

Config 구조체는 DiskStorage 더 나아가 ImageCache 인스턴스의 initializer를 통해 외부로부터 주입할 수 있게 설계가 되어있는 만큼, 사용자가 커스터마이즈할 수 있습니다.

현재 기본구현되어있는 클로저에서는 순환참조가 발생할 가능성이 낮으나, 개발자의 휴먼 미스테이크로 인해 순환참조가 발생할 수 있습니다. 때문에, Kingfisher는 커스텀화된 cachePathBlock의 잘못된 설계를 염두해 두고, cache 경로 설정이 완료된 직후 nil로 설정해 순환참조를 원천 차단하고 있습니다.

 

그럼 어떤 상황에서 이런 순환참조가 발생할 수 있을까요?

class ImageCacheManager {
    var diskCache: DiskStorage.Backend<Data>?
    
    func setupCache() {
        var config = DiskStorage.Config(name: "images", sizeLimit: 1024 * 1024 * 100)
        
        // 클로저 내에서 self를 강하게 참조
        config.cachePathBlock = { directory, cacheName in
            // self 강한 참조
            let customPath = self.generateCustomPath(cacheName)
            return directory.appendingPathComponent(customPath, isDirectory: true)
        }
        
        do {
            self.diskCache = try DiskStorage.Backend<Data>(config: config) // DiskStorage 인스턴스 생성
        } catch {
            print("Failed to initialize disk cache: \(error)")
        }
    }
    
    func generateCustomPath(_ cacheName: String) -> String {
        return "custom-\(cacheName)"
    }
}

위 코드는 사용자가 직접 구현한 ImageCacheManager 클래스 내부에 DiskStorage 인스턴스를 생성하고 있습니다. 이 과정에서 ImageCacheManager는 DiskStorage.Backend를 참조하게 되며, DiskStorage.Backend는 인자로 전달받은 config를 통해 Config 구조체를 참조하게 됩니다. 하지만 여기서 config.cachePathBlock 클로저가 self인 ImageCacheManager를 참조하게 되면서 순환 참조가 발생하게 되는 것입니다.

 

2. MemoryStorage

NSCache 기반 메모리 캐싱에 필요한 CRUD를 구현합니다.

class MemoryStorage<T: DataTransformable> {
    private let storage = NSCache<NSString, StorageObject<T>>()
    private let lock = NSLock()
    // 메모리 캐시 관리 메서드들
    func store(value: T, forKey key: String, expiration: StorageExpiration? = nil){
        lock.lock()
        defer { lock.unlock() }
        ...
    }
        
    func value(forKey key: String) -> T? {}
    func remove(forKey key: String) {}
    func removeAll() {}
    
    // ...
}

- NSCache를 내부적으로 사용하여 메모리 관리를 iOS 시스템에 맡기는 것을 확인할 수 있었습니다.

- DiskStorage와 마찬가지로 제네릭 타입을 사용해 구현체에 대한 타입 독립성을 보장하고 있습니다.

- 다수 DataTransformable을 준수하는 컴포넌트로부터 동시에 MemoryStorage에 대한 쓰기를 수행하는 data race condition을 방지하고자 NSLock() 객체를 통해 store 메서드와 같은 중요한 지점(Critical Section)의 접근을 통제하고 있습니다.

 

DiskStorage에서 maybeCached Set가 존재하는 것과 같이 MemoryStorage 클래스에서도 keys Set가 존재합니다.

var keys = Set<String>()

이 Set는 메모리 캐시에 저장된 객체를 추적한다는 점에서 maybeCached와 동일한 기능을 수행하는 자료구조입니다. 하지만, DiskStorage와 달리 큰 차이점이 하나 존재하는데요.

NSCache를 사용하게 되면서 내부 객체들이 개발자의 개입없이도, 시스템의 캐시 정책/규칙에 의해 삭제될 수 있다는 것입니다.

Kingfisher는 이러한 NSCache의 속성에 대한 접근방식을 아래 주석을 통해 기입해놓았습니다.

// Keys track the objects once inside the storage.
//
// For object removing triggered by user, the corresponding key would be also removed. However, for the object
// removing triggered by cache rule/policy of system, the key will be remained there until next `removeExpired`
// happens.
//
// Breaking the strict tracking could save additional locking behaviors and improve the cache performance.
// See https://github.com/onevcat/Kingfisher/issues/1233

이를 요약하자면 keys Set와 NSCache 간의 동기화를 일부로 불완전하게 하는 것입니다...(?!)

NSCache는 시스템 메모리 압박으로 인해 객체를 제거할 경우 별도로 이를 클래스에 알리지 않습니다. 

그리고 NSCache와 Keys Set간 완벽한 동기화를 위해선 모든 메서드에 락을 사용해야합니다.

 

때문에 Kingfisher는 주기적으로 호출되는 removeExpired 메서드 내부에서 keys Set를 통해 모든 알려진 키를 순회하며 NSCache 내부에서 제거된 객체들을 확인 및 제거하도록 설계했습니다.

 

상태 일관성보다 락킹 감소를 통해 성능 최적화를 우선시하는 트레이드 오프를 보여줬기에 개인적으로 굉장히 인상깊었던 코드였습니다.

 

3. ImageCache

마지막으로, 앞서 다뤘던 메모리 및 디스크 캐시를 통합 관리하는 통합 및 인터페이스 모듈입니다.

public class ImageCache {
    public static let default = ImageCache(name: "default")
    
    private let memoryStorage: MemoryStorage<Image>
    private let diskStorage: DiskStorage<Data>
    
    // 메서드들
    public func store(_ image: Image, forKey key: String, ...)
    public func retrieveImage(forKey key: String, ...) -> RetrieveImageResult
    public func removeImage(forKey key: String, ...)
    // ...
}

 

1. UI 관련 Notification 구독

ImageCache는 메모리 캐시 및 디스크 캐시에 대한 제어를 담당하고 있는 클래스인 만큼, 메모리 공간 및 디스크 공간의 최적화 로직도 구현되어있습니다. 그리고 이러한 저장공간 최적화 로직 호출 시점은 하드웨어 상태와 밀접하게 연결되어있습니다. 설령, Kingfisher는 앱이 백그라운드 상태로 전환되거나 앱이 종료될때 디스크 공간을 정리하고, 잔여 메모리 부족 알림 발생 시 메모리 공간을 정리하고 있습니다.

 

이러한 하드웨어 상태를 실시간으로 파악하고 대응할 수 있도록, ImageCache 내부에서는 옵저버 패턴을 사용해 UI 관련 Notification을 구독하고 있습니다. 

public init(
        memoryStorage: MemoryStorage.Backend<KFCrossPlatformImage>,
        diskStorage: DiskStorage.Backend<Data>)
    {
        self.memoryStorage = memoryStorage
        self.diskStorage = diskStorage
        let ioQueueName = "com.onevcat.Kingfisher.ImageCache.ioQueue.\(UUID().uuidString)"
        ioQueue = DispatchQueue(label: ioQueueName)
        
        Task { @MainActor in
            let notifications = [
                (UIApplication.didReceiveMemoryWarningNotification, #selector(clearMemoryCache)),
                (UIApplication.willTerminateNotification, #selector(cleanExpiredDiskCache)),
                (UIApplication.didEnterBackgroundNotification, #selector(backgroundCleanExpiredDiskCache))
            ]

            // 각 알림에 대해 옵저버 등록
            notifications.forEach {
                NotificationCenter.default.addObserver(self, selector: $0.1, name: $0.0, object: nil)
            }
        }
    }

UI 관련 Notification은 메인 스레드에서만 발산됩니다. 때문에, initialize 단계에서 Notification 구독 작업에 대해서만 부분적으로 메인 스레드에서 실행되도록 Task {  } 블록으로 작업을 캡슐화 한 후 @MainActor 키워드를 기입해 메인스레드에서 구독이 메인 스레드에서 실행되도록 보장하고 있습니다.

 

 

2. 디스크 저장 로직 비동기 처리 및 직렬화

ImageCache 내부에서는 diskStorage 클래스에 구현되어있는 store 메서드를 호출하고 있으며, 이는 보조기억장치에 대한 I/O 작업을 동반합니다.

private func syncStoreToDisk(
    _ data: Data,
    ...
{
    let result: CacheStoreResult
    do {
        // 디스크 스토리지에 데이터 저장 시도
        try self.diskStorage.store(
            value: data,
            ...
        )
}

 

때문에, DiskStorage 내부 metaChangingQueue와 같이 직렬화 큐에 작업을 전송하여 다수 스레드에서 동시에 접근하여도 덮어씌워지지 않게끔 작업을 직렬화하고 있습니다.

private let ioQueue: DispatchQueue

public init(
    memoryStorage: MemoryStorage.Backend<KFCrossPlatformImage>,
    diskStorage: DiskStorage.Backend<Data>)
{
    ...
    ioQueue = DispatchQueue(label: ioQueueName) // I/O 작업의 직렬화를 위한 큐
    ...
}

open func storeToDisk(
    _ data: Data,
    ...
{
    // 디스크 I/O 작업이므로 백그라운드 큐에서 실행
    ioQueue.async {
        self.syncStoreToDisk(
            data,
            ...
        )
    }
}

 

3. @Sendable func callHandler()

 func removeImage(
    forKey key: String,
    processorIdentifier identifier: String = "",
    forcedExtension: String?,
    fromMemory: Bool = true,
    fromDisk: Bool = true,
    callbackQueue: CallbackQueue = .untouch,
    completionHandler: (@Sendable ((any Error)?) -> Void)? = nil)
{
    // 프로세서 식별자와 키를 결합하여 최종 캐시 키 생성
    let computedKey = key.computedKey(with: identifier)

    // 메모리에서 제거해야 하는 경우
    if fromMemory {
        memoryStorage.remove(forKey: computedKey)
    }

    // 핸들러 호출을 위한 내부 함수
    @Sendable func callHandler(_ error: (any Error)?) {
        if let completionHandler = completionHandler {
            callbackQueue.execute { completionHandler(error) }
        }
    }

    if fromDisk {
        ioQueue.async{
            do {
                try self.diskStorage.remove(forKey: computedKey, forcedExtension: forcedExtension)
                callHandler(nil)
            } catch {
                callHandler(error)
            }
        }
    } else {
        callHandler(nil)
    }
}

여기서 callHandler 함수는 completionHandler를 호출합니다. 문제는 이 메서드가 2개 이상의 스레드에서 각기 호출될 수 있다는 점에 있습니다.

callHandler 메서드 내부에는 callbackQueue.execute를 통해 핸들러를 실행합니다. 

 

여기서 CallbackQueue는 비동기 작업(네트워크 다운로드, 이미지 처리, 캐시 작업)의 결과를 어떤 스레드에서 처리할지 제어하는 데에 사용됩니다. 

이 CallbackQueue에는 네가지 유형이 존재합니다.

case mainAsync // 항상 메인 큐에서 비동기적으로 콜백을 실행
case mainCurrentOrAsync // 현재 메인 스레드면 즉시 실행하고, 아니면 메인 큐에 비동기적으로 전달
case untouch // 현재 큐를 변경하지 않고 콜백 실행
case dispatch(DispatchQueue) // 지정된 DispatchQueue에서 콜백 실행

 

여기서 CallbackQueue는 외부에서부터 주입되기 때문에, 메서드 단에서는 핸들러가 호출되는 스레드를 확정지을  수 없습니다.

뿐만 아니라, 디스크 영역 내부에 있는 데이터 또한 삭제하도록 분기처리될 경우, 비동기적으로 직렬화 큐에 전송되어 실행됩니다.

 

따라서, callHandler가 스레드 간에 안전하게 전달될 수 있음을 보장하기 위해 @Sendable을 사용하고 있습니다. 즉, 핸들러가 다른 스레드에서 실행될 때, error와 같이 캡처된 값이 스레드 안정성을 보장한다는 것을 명시하고 있습니다.

 

다음에는 네트워킹 레이어에 대한 상세분석 내용을 다루도록 하겠습니다.

 

감사합니다.


Reference

https://github.com/onevcat/Kingfisher

 

GitHub - onevcat/Kingfisher: A lightweight, pure-Swift library for downloading and caching images from the web.

A lightweight, pure-Swift library for downloading and caching images from the web. - onevcat/Kingfisher

github.com