Neoself의 기술 블로그

Kingfisher에서 Swift 동시성 모델을 도입한 NeoImage 라이브러리 구현기 본문

개발지식 정리/Swift

Kingfisher에서 Swift 동시성 모델을 도입한 NeoImage 라이브러리 구현기

Neoself 2025. 3. 16. 15:06

Swift의 동시성 모델(Concurrency Model)을 도입하여 Kingfisher의 핵심 로직을 재구현한 NeoImage 라이브러리 개발 과정을 소개합니다. 이 글에서는 특히 동시성 처리와 관련된 변경 사항에 중점을 두고 설명하겠습니다.

 

동시성 모델 도입 배경

Swift 동시성 모델 소개

WWDC 2021에서 Apple은 Swift 5.5와 함께 새로운 동시성 모델을 소개했습니다. 이 모델은 기존의 복잡한 콜백 기반 비동기 프로그래밍을 대체하는 async/await, actor, Task 등의 강력한 기능을 포함하고 있습니다:

 

  • async/await: 비동기 코드를 동기 코드처럼 작성할 수 있게 해주는 문법
  • actor: 공유 가변 상태에 대한 안전한 접근을 보장하는 참조 타입
  • Task: 비동기 작업을 시작하고 관리하는 API

https://developer.apple.com/videos/play/wwdc2021/10132/

 

Meet async/await in Swift - WWDC21 - Videos - Apple Developer

Swift now supports asynchronous functions — a pattern commonly known as async/await. Discover how the new syntax can make your code...

developer.apple.com

 

마이그레이션의 필요성

Apple은 위 WWDC 세션을 통해 새로운 동시성 모델로의 마이그레이션의 필요성을 아래와 같이 정리했습니다.

  • 안전성 개선: 기존 완료 핸들러 기반 코드는 모든 경로에서 완료 핸들러를 호출하는 것을 개발자가 직접 보장해야 합니다. Swift 동시성 모델에서는 컴파일러가 이를 검사하고 보장합니다.
  • 코드 간결성: 코드가 더 직관적이고 개발자의 의도가 명확하게 표현될 수 있도록 합니다.
  • 오류 처리 개선: 비동기 코드에서 오류 처리는 복잡할 수 있지만, async/await를 사용하면 Swift의 일반적인 오류 처리 메커니즘(try-catch)을 사용할 수 있습니다.
  • 스레드 활용 최적화: async/await를 사용하면 함수가 중단될 때 스레드가 차단되지 않고 시스템에 반환되어 다른 작업을 수행할 수 있습니다. 이는 리소스 활용을 개선합니다.
  • Actor 모델 활용: Actor를 사용하면 공유 상태에 대한 안전한 액세스를 보장하고 데이터 경쟁을 방지할 수 있습니다.

Kingfisher는 뛰어난 이미지 캐싱 라이브러리지만, Swift의 최신 동시성 모델(async/await, actor)을 완전히 활용하지 않고 있다고 판단했습니다. (나중에는 이에 대한 이유가 있다는 것을 깨달았습니다..) 이에 Kingfisher의 핵심 로직을 Swift 동시성 모델로 재구현하는 NeoImage 프로젝트를 진행했습니다.

 

핵심 구조의 변경점

1. DiskStorage를 actor로 구현

// Kingfisher 방식: DispatchQueue를 사용한 동기화
private let ioQueue: DispatchQueue
// ...
func store(...) {
    ioQueue.async {
        // 파일 쓰기 작업
    }
}

// NeoImage 방식: actor를 사용한 동기화
public actor DiskStorage<T: DataTransformable> {
    // ...
    func store(value: T, forKey key: String, isPriority: Bool = false, expiration: StorageExpiration? = nil) async throws {
        // 직접 파일 쓰기 작업 (ioQueue 없음)
    }
}

Kingfisher는 ioQueue를 사용하여 디스크 작업의 직렬화를 구현했지만, NeoImage에서는 actor 키워드로 타입을 선언하여 Swift 런타임이 자동으로 동시성 제어를 처리하도록 했습니다.

 

2. MemoryStorage와 Task를 활용한 캐시 정리 로직

메모리 캐시공간인 MemoryStorage의 경우, NSCache 자체로도 스레드-안전하게 설계가 되어있지만, 다른 속성에 대한 접근까지 동기화하기 위해 actor로 타입을 변경했습니다.

 

Kingfisher는 주기적으로 메모리 캐시의 보관기한이 만료된 항목들을 정리하기 위해 Timer를 사용했습니다.

이 타이머는 생성된 스레드의 RunLoop에서 실행되는데, RunLoop는 각 스레드가 가질 수 있는 반복적인 실행 루프로, 입력 소스(예: 타이머 이벤트)를 지속적으로 감시하고 처리합니다.

메인 스레드의 경우 애플리케이션이 실행되는 동안 RunLoop가 항상 활성화되어 있기 때문에, 메인 스레드에서 Timer를 생성하면 별도의 설정 없이도 예상대로 작동합니다. 하지만, 백그라운드 스레드에서 Timer를 사용하려면 해당 스레드의 RunLoop를 명시적으로 실행해야 하며, 이는 스레드가 계속 활성 상태로 유지되어야 함을 의미합니다.

때문에 저는 여기서 Timer 대신 Task를 사용하게 되었습니다. Task는 RunLoop에 의존하지 않고 Swift 동시성 시스템에 의해 관리되기 때문입니다.

public actor MemoryStorage {
    private let storage = NSCache<NSString, StorageObject>()
    private var cleanTask: Task<Void, Never>? = nil
    
    init(totalCostLimit: Int) {
        // ...
        Task {
            await setupCleanTask()
        }
    }

    private func setupCleanTask() {
        // Timer 대신 Task로 주기적인 정리 작업 수행
        cleanTask = Task {
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: 120 * 1_000_000_000) // 2분마다 실행
                // 취소 확인
                if Task.isCancelled { break }
                // 만료된 항목 제거
                removeExpired()
            }
        }
    }
}

 

 

3. DownloadTask에서 OnCompletedCallback, OnCancelledCallback 제거

Kingfisher의 DownloadTask는 작업 완료 시 호출될 콜백을 저장하는 구조였습니다. 하지만, async/await 패턴을 활용할 경우, 직접 결과를 반환할 수 있게 됨에 따라, 외부에서 각 작업이 완료될때 실행할 콜백(completionHandler)를 전달할 필요가 없어집니다. 따라서, 작업 식별을 위한 인덱스만 저장하도록 변경했습니다.

 

뿐만 아니라 Kingfisher에서는 Download 작업이 취소되었을 때에도 취소 상태를 외부에 알리기 위해, OnCancelledCallback을 저장하고 다른 파일에서 이를 호출 및 등록하는 구조가 구현되어있습니다. 하지만, asnyc/await 패턴을 사용하게 되면서, Swift의 일반적인 오류 처리 시스템을 활용할 수 있게 됨에 따라 오류 처리방식을 단순 throw 및 catch 방식으로 변경하였습니다.

// NeoImage의 DownloadTask 구현
public final actor DownloadTask: Sendable {
    private(set) var sessionTask: SessionDataTask?
    private(set) var index: Int?
    
    init(
        sessionTask: SessionDataTask? = nil,
        index: Int? = nil
    ) {
        self.sessionTask = sessionTask
        self.index = index
    }
    
    public func cancelWithError() async throws {
        guard let sessionTask, let index else { return }
        
        await sessionTask.cancel(index: index)
        // throw를 통해 취소 통지
        throw NeoImageError.responseError(reason: .cancelled)
    }
    
    // ...
}

 

이에 따라 외부에서, do catch 구문으로 취소에 따른 후속 처리작업을 정의할 수 있게 됩니다.

// 사용 예시
do {
    try await imageView.neo.setImage(with: url)
} catch {
    if let neoError = error as? NeoImageError,
       case .responseError(let reason) = neoError,
       case .cancelled = reason {
        // 취소 처리 코드
        print("이미지 로드가 취소됨")
    } else {
        // 다른 에러 처리
        print("에러 발생: \(error)")
    }
}

 

ImageCache 클래스는 actor로 구현하지 않고 Sendable 프로토콜을 채택했습니다. 이는 @objc 메서드와 동시성 모델의 호환성 문제 때문입니다. 대신 내부적으로 Task를 활용하여 actor 메서드 호출 시 비동기 컨텍스트를 생성합니다.

 

4. ImageDownloader를 class로 구현한 이유

NeoImage에서는 ImageDownloader를 actor가 아닌 class로 구현했습니다. 이는 성능 최적화를 위한 선택이었습니다.

public final class ImageDownloader: Sendable {
    public static let `default` = ImageDownloader(name: "default")
    
    private let downloadTimeout: TimeInterval = 15.0
    private let name: String
    private let session: URLSession
    
    private let sessionDelegate: SessionDelegate
    
    // ...
}

ImageDownloader는 내부에 가변 속성이 없습니다. actor 타입 내부에 선언된 메서드는 호출 시, actor의 격리된 실행 큐를 통과해야 합니다. 이는 읽기 전용 또는 내부 속성의 변경이 없는 연산에도 동일합니다. 때문에, 동기화 매커니즘을 적용해야하는 MemoryStorage와 DiskStorage만 actor로 타입을 변경하였고, 이미 그 자체로 Sendable을 준수하는 ImageDownloader는 클래스를 유지하였습니다.

 

5. withCheckedThrowingContinuation 활용

ImageDownloader에서 withCheckedThrowingContinuation 브릿지를 사용하였는데요. 이는 URLSession기반의 콜백 중심 비동기 API를 Swift의 동시성 모델과 연결하기 위해서입니다.

 

withCheckedThrowingContinuation은 다음과 같은 역할을 수행합니다: 

실행 일시 중단: 현재 비동기 함수의 실행을 일시 중단하고, 실행 상태를 continuation 객체에 저장합니다.

컨텍스트 전환 관리: 비동기 컨텍스트에서 일시 중단된 상태를 보존하고, 나중에 실행을 재개할 때 필요한 모든 컨텍스트를 복원합니다.

안전성 검사: "checked" 접두사는 continuation이 정확히 한 번만 재개되는지 확인합니다. continuation이 여러 번 재개되거나 아예 재개되지 않으면 런타임 오류가 발생합니다.

 

이를 활용해 비동기 작업의 결과를 기다리다가 SessionDataTask의 isCompleted 상태가 true가 되는 시점, 즉 URLSessionDelegate 프로토콜 내부 메서드에서 OnTaskDoneDelegate를 호출할 때, 메서드로부터 전달받은 data를 바로 반환할 수 있는 실행흐름을 구현할 수 있었습니다.

return try await withCheckedThrowingContinuation { continuation in
    // 이미 완료된 경우 즉시 결과 반환
    if await sessionTask.isCompleted, let taskResult = await sessionTask.taskResult {
        handleResult(taskResult, continuation)
        return
    }
    
    // 완료되지 않은 경우, 완료 콜백 설정
    await sessionTask.onCallbackTaskDone.delegate(on: self) { (self, value) in
        let (result, _) = value
        handleResult(result, continuation)
    }
}

 

*Result 타입: 성공 또는 실패할 수 있는 연산의 결과를 표현하는 열거형으로, 내부에는 제너링 열거형으로 구현되어있습니다.

enum Result<Success, Failure> where Failure : Error {
    case success(Success)
    case failure(Failure)
}

불필요한 요소 제거

1. 플랫폼 호환성 코드 제거

Kingfisher는 여러 플랫폼(iOS, macOS, watchOS 등)에서 사용할 수 있도록 아래와 같은 코드를 포함하고 있습니다:

// Kingfisher 코드
#if os(macOS)
import AppKit
public typealias KFCrossPlatformImage = NSImage
#else
import UIKit
public typealias KFCrossPlatformImage = UIImage
#endif

NeoImage는 iOS 전용으로 설계되어 이러한 호환성 코드가 불필요하므로 제거했습니다.

 

2. 설정 관련 구조체 간소화

Kingfisher의 DiskConfig 및 관련 구조체를 제거하고, 필요한 설정만 남겼습니다. 예를 들어, diskCachePathClosure는 캐시 디렉토리 위치를 사용자화할 수 있게 하는 기능이지만, NeoImage에서는 이를 고정 구현으로 단순화했습니다.

 

3. 해시 파일명 사용 강제화

Kingfisher는 usesHashedFileName 플래그를 통해 해시 파일명 사용 여부를 선택할 수 있지만, NeoImage에서는 캐시 충돌 방지를 위해 SHA-256 해시를 항상 사용하도록 변경했습니다.

 

동시성 문제 해결과정

1. 비동기 초기화 처리

Actor 내부의 init 메서드는 비동기 컨텍스트가 아니기 때문에, 비동기 작업이 필요한 초기화 코드는 Task를 사용하여 별도의 비동기 컨텍스트에서 실행되도록 구현했습니다.

init (name: String, fileManager: FileManager) {
    // ... 속성 초기화 ...
    
    Task {
        await setupCacheChecking()
        try? await prepareDirectory()
    }
}

 

 

2. Objective-C 호환성 처리

@objc 메서드는 Swift의 비동기 모델(async/await)을 지원하지 않기 때문에, 내부에서 Task를 생성하여 비동기 작업을 수행하도록 구현했습니다. 물론, clearMemoryCache에 async 키워드가 기입되어 있지 않기 때문에, memoryStorage.removeAll()이 완료되기 전에 반환될 수 있지만, 메모리 정리 이후 후속 처리작업이 없었기 때문에 문제가 없을 것이라 판단했습니다.

@objc public func clearMemoryCache() {
    Task {
        await memoryStorage.removeAll()
    }
}

@objc 메서드는 Swift의 비동기 모델(async/await)을 지원하지 않기 때문에, 내부에서 Task를 생성하여 비동기 작업을 수행하도록 구현했습니다. 

 

3. URLSessionDelegate 처리

URLSessionDelegate 메서드와 actor의 호환성 문제를 해결하기 위해 메서드를 nonisolated로 선언하고, 내부에서 Task를 사용하여 actor 메서드를 호출했습니다.

nonisolated public func urlSession(
    _ session: URLSession,
    task: URLSessionTask,
    didCompleteWithError error: Error?
) {
    Task {
        guard let sessionTask = await self.task(for: task) else { return }
        await taskCompleted(task, with: await sessionTask.mutableData, error: error)
    }
}

 

 

성능 평가 및 테스트

이후, NeoImage의 성능을 평가하기 위해 Kingfisher와 비교 테스트를 수행했습니다.

@Test("캐시 성능 비교: NeoImage vs Kingfisher")
func testCompareCachePerformance() async throws {
    let context = await ImageTestingContext()

    // 모든 캐시 비우기
    await context.clearAllCaches()

    // 첫 번째 로드 - 캐시 없음
    try await context.loadImagesWithNeoImage()
    let neoFirstLoadStats = await context.resultsManager.getNeoImageStats()

    await context.resultsManager.resetTimes()

    try await context.loadImagesWithKingfisher()
    let kfFirstLoadStats = await context.resultsManager.getKingfisherStats()

    // 결과 초기화
    await context.resultsManager.resetTimes()

    // 두 번째 로드 - 캐시됨
    try await context.loadImagesWithNeoImage()
    let neoSecondLoadStats = await context.resultsManager.getNeoImageStats()

    await context.resultsManager.resetTimes()
    try await context.loadImagesWithKingfisher()
    let kfSecondLoadStats = await context.resultsManager.getKingfisherStats()

    // 개선율 계산
    let neoImprovementRate = 1 - (neoSecondLoadStats.average / neoFirstLoadStats.average)
    let kfImprovementRate = 1 - (kfSecondLoadStats.average / kfFirstLoadStats.average)

    print("""
    ======== 캐시 성능 비교 ========
    NeoImage 개선율: \(String(format: "%.1f", neoImprovementRate * 100))%
    Kingfisher 개선율: \(String(format: "%.1f", kfImprovementRate * 100))%
    ==============================
    """)

    // 각 라이브러리의 캐시 개선율을 확인
    #expect(neoImprovementRate > 0.5, "NeoImage 캐시 사용 시 최소 50% 이상 속도가 개선되어야 합니다")
    #expect(kfImprovementRate > 0.5, "Kingfisher 캐시 사용 시 최소 50% 이상 속도가 개선되어야 합니다")

    await context.cleanUp()
}
    
======== 캐시 성능 비교 ========
NeoImage 개선율: 91.8%
Kingfisher 개선율: 100.0%
==============================

그 결과, 처음 로드보다 91.8% 더 빠르게 캐싱된 이미지를 불러올 수 있음을 확인해볼 수 있었습니다.

 

여기서 흥미로웠던 것은, 핵심 로직은 동일하게 구현하였음에도 불구하고 캐시된 이미지를 접근하는 소요시간이 0.03초 내외 차이를 보이는 것인데요. 

// NeoImage 라이브러리
@discardableResult
public func setImage(
    with url: URL?,
    placeholder: UIImage? = nil,
    options: NeoImageOptions? = nil
) async throws -> ImageLoadingResult {
    let currentTime = Date()
    let result = try await setImageAsync(
        with: url,
        placeholder: placeholder,
        options: options
    )

    print("**setImageAsync Done: \(String(format: "%.3f", Date().timeIntervalSince(currentTime)))")
    return result
}

// 테스트 케이스
func loadWithNeoImageAsync(imageView: UIImageView, url: URL) async throws -> Double {
    let startTime = Date()
    do {
        try await imageView.neo.setImage(with: url)
        let elapsedTime = Date().timeIntervalSince(startTime)
        print("loaded with NeoImage in \(String(format: "%.3f", elapsedTime)) seconds")

        return elapsedTime
    } catch {
        print("Error loading image with NeoImage: \(error)")
        throw error
    }
}

 

소요시간의 차이가 발생하는 지점을 파악해본 결과, NeoImage 최상위의 래퍼 메서드의 경우 캐시된 이미지 접근에 0.0003초 소요된 반면, 테스트에서는 이를 호출하는 과정에서 0.034초가 소요된 것입니다.

로그 내용
/// **setImageAsync Done: 0.000
/// loaded with NeoImage in 0.031 seconds

 

저는 이러한 현상의 원인을 컨텍스트 전환에 의한 오버헤드라고 판단했습니다.

 

Swift 동시성 모델을 도입한 NeoImage 라이브러리 구현 결론

Swift의 동시성 모델(Concurrency Model)을 도입하여 Kingfisher의 핵심 로직을 재구현한 NeoImage 라이브러리 개발 과정을 통해 많은 것을 배울 수 있었습니다.

 

동시성 모델 도입의 도전과 교훈

처음에는 actor와 async/await 기반의 새로운 동시성 모델이 기존의 GCD(Grand Central Dispatch) 기반 코드보다 훨씬 안전하고 간결할 것이라고 기대했습니다. 하지만 실제 구현 과정에서 다양한 동시성 관련 버그들을 마주하게 되었습니다.

 

특히 actor 간의 상호작용, URLSessionDelegate 프로토콜의 비동기 이벤트 대응, 그리고 Objective-C 런타임과의 호환성 문제 등에서 많은 도전을 겪었습니다. 이런 문제들을 해결하면서 Swift 동시성 모델의 내부 동작 방식에 대해 더 깊이 이해할 수 있었습니다. 

 

Actor와 작업 순서 보장의 한계

가장 중요한 깨달음 중 하나는 WWDC 세션을 통해 알게 된 actor의 작업 순서 보장 방식이었습니다. actor는 상호 배제(mutual exclusion)를 보장하지만, 항상 FIFO(First-In-First-Out) 방식으로 작업을 처리하지는 않는다는 사실을 발견했습니다. 특히 우선순위와 재진입성(reentrancy)으로 인해 추가된 순서와 다르게 작업이 실행될 수 있다는 점이 중요했습니다.

// DiskStorage에서 FIFO 순서가 보장되어야 하는 작업:
// 1. 이미지 A 저장
// 2. 이미지 A 업데이트
// 3. 이미지 A 읽기

이런 작업들이 정확히 이 순서대로 실행되지 않으면 데이터 일관성에 문제가 생길 수 있습니다. 그런데 actor는 이런 작업 순서를 엄격히 유지한다고 보장하지 않습니다.

 

이 사실을 깨닫고 나서야 Kingfisher가 @unchecked Sendable 어노테이션을 사용하면서도 내부적으로 DispatchQueue를 사용한 이유를 이해하게 되었습니다. 시리얼 큐는 작업이 추가된 정확한 순서대로 처리되는 것을 보장하기 때문에, 데이터 일관성이 중요한 파일 시스템 작업에는 여전히 필요한 접근 방식이었던 것입니다.

 

또한, Async/Await 패턴을 도입할 경우, 컨텍스트를 전환하는 오버헤드가 발생하기 때문에 메모리에 캐싱된 이미지를 접근하는 것과 같이 동기 작업만으로 처리가 가능한 메서드의 경우, Completion Handler로 비동기작업을 처리하는 것이 오히려 소요시간을 단축시킬 수 있음을 알았습니다.

 

모든 설계 결정에는 이유가 있다

이 프로젝트를 통해 가장 중요하게 배운 점은 모든 코드 설계 결정에는 깊은 이유가 있다는 것입니다. 처음에는 '낡은' GCD 패턴을 '현대적인' actor로 대체하는 것이 무조건 좋은 접근법이라고 생각했습니다. 하지만 실제로는 각 패턴이 다른 강점과 트레이드오프를 가지고 있었고, 특정 상황에 맞는 적절한 도구를 선택하는 것이 중요하다는 것을 배웠습니다.

 

감사합니다.

'개발지식 정리 > Swift' 카테고리의 다른 글

Swift Closure 정리  (0) 2025.04.07
Kingfisher 라이브러리 내부 콜백 구조 분석하기  (0) 2025.03.13
@Sendable 정리  (0) 2025.03.06
Kingfisher 라이브러리 분석하기 (Networking 레이어)  (1) 2025.03.06
기초 CS  (0) 2025.03.06