일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 뷰 생명주기
- launchscreen
- 리액트
- 명시적 정체성
- 뷰 정체성
- SwiftUI
- React Native
- 구조적 정체성
- Android
- @sendable
- react
- 360도 이미지
- 360도 이미지 뷰어
- ssot
- native
- 네이티브
- panorama view
- launch screen
- 파노라마 뷰
- completion handler
- react-native-fast-image
- requirenativecomponent
- 스켈레톤 통합
- React-Native
- data driven construct
- 360도 뷰어
- ios
- 라이브러리 없이
- 앱 성능 개선
- 리액트 네이티브
- Today
- Total
Neoself의 기술 블로그
Kingfisher 라이브러리 내부 콜백 구조 분석하기 본문
안녕하세요, 이번 글에서는 Kingfisher 라이브러리 네트워킹 레이어에서 구현된 콜백 구조를 살펴보겠습니다.
우선 Kingfisher에서는 이벤트가 발생했을 때 실행할 콜백을 등록하고, 적절한 시점에 호출될 수 있도록 Delegate 클래스를 제공하고 있습니다. 이 Delegate 클래스로 등록된 콜백들을 체이닝하여, 이벤트 전달을 비롯하여 복잡한 로직들을 처리하고 있습니다.
그럼, Kingfisher는 왜 콜백을 여러 파일에 걸쳐 전달 및 호출하도록 설계하였을까요?
저는 크게 2가지 측면에서 이러한 광범위한 콜백 관리 체계가 필요하다고 판단했습니다.
1. 세션 이벤트 처리
SessionDelegate.swift에서는 다양한 URLSession 이벤트를 처리하기 위해 여러 델리게이트를 사용합니다:
open class SessionDelegate: NSObject, @unchecked Sendable {
let onValidStatusCode = Delegate<Int, Bool>()
let onResponseReceived = Delegate<URLResponse, URLSession.ResponseDisposition>()
let onDownloadingFinished = Delegate<(URL, Result<URLResponse, KingfisherError>), Void>()
...
그리고, 이러한 Delegate들은 상위 클래스인 ImageDownloader의 initializer에서 등록 즉, 호출 시 실행될 클로저를 정의하고 있습니다.
open class ImageDownloader: @unchecked Sendable {
public init(name: String) {
...
setupSessionHandler()
}
private func setupSessionHandler() {
sessionDelegate.onReceiveSessionChallenge.delegate(on: self) { (self, invoke) in
await (self.authenticationChallengeResponder ?? self).downloader(self, didReceive: invoke.1)
}
sessionDelegate.onReceiveSessionTaskChallenge.delegate(on: self) { (self, invoke) in
await (self.authenticationChallengeResponder ?? self).downloader(self, task: invoke.1, didReceive: invoke.2)
}
...
}
여기서 self.delegate와 self.authenticationChallengeResponder는 프로토콜입니다.
...
open weak var delegate: (any ImageDownloaderDelegate)?
open weak var authenticationChallengeResponder: (any AuthenticationChallengeResponsible)?
...
}
public protocol ImageDownloaderDelegate: AnyObject {
func imageDownloader(_ downloader: ImageDownloader, willDownloadImageForURL url: URL, with request: URLRequest?)
func imageDownloader(
_ downloader: ImageDownloader,
didFinishDownloadingImageForURL url: URL,
with response: URLResponse?,
error: (any Error)?)
...
}
public protocol AuthenticationChallengeResponsible: AnyObject {
func downloader(
_ downloader: ImageDownloader,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?)
func downloader(
_ downloader: ImageDownloader,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?)
}
그리고 프로토콜을 준수하는 클래스를 활용하여 등록된 콜백들은 SessionDelegate 클래스 내부 URLSessionDataDelegate 프로토콜 메서드 내부 구현체에서 호출되고 있는 것을 볼 수 있죠.
extension SessionDelegate: URLSessionDataDelegate {
open func urlSession(_ session: URLSession,dataTask: URLSessionDataTask,didReceive response: URLResponse) async -> URLSession.ResponseDisposition {
let httpStatusCode = httpResponse.statusCode
// onValidStatusCode 델리게이트 호출
guard onValidStatusCode.call(httpStatusCode) == true else {
let error = KingfisherError.responseError(reason: .invalidHTTPStatusCode(response: httpResponse))
onCompleted(task: dataTask, result: .failure(error))
return .cancel
}
// onResponseReceived 델리게이트 호출
guard let disposition = await onResponseReceived.callAsync(response) else {
return .cancel
}
return disposition
}
open func urlSession(_ session: URLSession,didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?){
// onReceiveSessionChallenge 호출
await onReceiveSessionChallenge.callAsync((session, challenge)) ?? (.performDefaultHandling, nil)
}
...
}
또한, 앞서 말씀드린 2개 프로토콜의 extension에 기본 구현체를 정의해놓았기 때문에, 프로토콜을 준수하는 클래스를 외부에서 별도로 만들지 않아도, URLSessionDataDelegate 프로토콜 메서드 구현체는 정상적으로 구현되는 것을 확인할 수 있습니다.
이러한 설계에서 저는 아래와 같은 추론을 해볼 수 있었습니다.
1. 사용자가 세부적인 네트워크 통신 제어를 수정하고자 할 경우, URLSessionDataDelegate 프로토콜 메서드 구현체를 접근해 수정해야합니다. 하지만, Kingfisher 라이브러리는 오픈소스 라이브러리로써, 소수를 위한 코드 수정이 불가능합니다.
2. 따라서, Kingfisher는 사용자가 패키지 외부에서 ImageDownloaderDelegate 프로토콜을 준수하는 클래스를 직접 구현한 후, 내부에 네트워크 세부제어를 위한 로직을 구현하여, Delegate 클래스를 통해 URLSessionDataDelegate 프로토콜 메서드에 주입할 수 있도록 설계하였습니다.
여기서 Delegate 클래스는 위와 같은 여러 클래스 간의 복잡한 상호작용을 간결하게 관리해줍니다.
2. 중복 다운로드 방지 매커니즘
이미지 다운로드 라이브러리에서 동일한 이미지에 대해 여러 요청이 동시에 발생하는 상황은 매우 흔합니다. 예를 들어, 같은 이미지를 표시하는 여러 UI요소(UIImageView)가 화면에 존재할 때 각각 동일한 URL에서 이미지를 다운로드하려고 시도할 수 있습니다. 이때, 각 UI 요소가 독립적으로 같은 이미지 URL을 로드한다면 불필요한 네트워크 요청, 메모리 사용량 증가, 그리고 배터리 소모가 발생합니다.
위 상황을 방지하기 위해 Kingfisher 라이브러리 내부에는 중복된 이미지 다운로드를 방지하는 로직을 구현하였습니다. 이 로직에 사용된 콜백 구조를 설명드린 후, 중복 다운로드 방지 매커니즘의 실행 흐름을 설명드리겠습니다.
2.1. 콜백 구조 설명 - 등록과정
1. 다운로드 작업 시작 시 콜백 등록
ImageDownloader에서 이미지 다운로드 요청이 시작될 때, downloadImage 메서드를 통해 onCompleted 콜백이 처음 등록됩니다.
// ImageDownloader.swift
open func downloadImage(
with url: URL,
options: KingfisherParsedOptionsInfo,
completionHandler: (@Sendable (Result<ImageLoadingResult, KingfisherError>) -> Void)? = nil) -> DownloadTask
{
// ...
let callback = createTaskCallback(completionHandler, options: options)
// ...
let actualDownloadTask = startDownloadTask(
context: context,
callback: callback
)
// ...
}
private func createTaskCallback(
_ completionHandler: ((DownloadResult) -> Void)?,
options: KingfisherParsedOptionsInfo
) -> SessionDataTask.TaskCallback
{
SessionDataTask.TaskCallback(
onCompleted: createCompletionCallBack(completionHandler),
options: options
)
}
여기서 completionHandler는 Delegate 객체로 래핑됩니다:
private func createCompletionCallBack(_ completionHandler: ((DownloadResult) -> Void)?) -> Delegate<DownloadResult, Void>? {
completionHandler.map { block -> Delegate<Result<ImageLoadingResult, KingfisherError>, Void> in
let delegate = Delegate<Result<ImageLoadingResult, KingfisherError>, Void>()
delegate.delegate(on: self) { (self, callback) in
block(callback)
}
return delegate
}
}
2. SessionDelegate를 통한 태스크 관리
SessionDelegate는 동일한 URL에 대한 중복 다운로드를 관리합니다:
// SessionDelegate.swift
func add(
_ dataTask: URLSessionDataTask,
url: URL,
callback: SessionDataTask.TaskCallback) -> DownloadTask
{
// 새 태스크 생성
let task = SessionDataTask(task: dataTask)
// 콜백 취소 처리 등록
task.onCallbackCancelled.delegate(on: self) { [weak task] (self, value) in
// 콜백 취소 시 처리 로직
// ...
}
// 콜백 등록 및 토큰 생성
let token = task.addCallback(callback)
tasks[url] = task
return DownloadTask(sessionTask: task, cancelToken: token)
}
func append(
_ task: SessionDataTask,
callback: SessionDataTask.TaskCallback) -> DownloadTask
{
// 기존 태스크에 새 콜백 추가
let token = task.addCallback(callback)
return DownloadTask(sessionTask: task, cancelToken: token)
}
2.2. 콜백 구조 설명 - 콜백 체이닝 구조
1. SessionDataTask의 콜백 처리
SessionDataTask는 여러 콜백을 관리하고 적절한 시점에 호출합니다:
// SessionDataTask.swift
public class SessionDataTask: @unchecked Sendable {
struct TaskCallback {
let onCompleted: Delegate<Result<ImageLoadingResult, KingfisherError>, Void>?
let options: KingfisherParsedOptionsInfo
}
private var callbacksStore = [CancelToken: TaskCallback]()
// 태스크 완료 시 호출될 델리게이트
let onTaskDone = Delegate<(Result<(Data, URLResponse?), KingfisherError>, [TaskCallback]), Void>()
// 콜백 취소 시 호출될 델리게이트
let onCallbackCancelled = Delegate<(CancelToken, TaskCallback), Void>()
func addCallback(_ callback: TaskCallback) -> CancelToken {
lock.lock()
defer { lock.unlock() }
callbacksStore[currentToken] = callback
defer { currentToken += 1 }
return currentToken
}
func removeCallback(_ token: CancelToken) -> TaskCallback? {
lock.lock()
defer { lock.unlock() }
if let callback = callbacksStore[token] {
callbacksStore[token] = nil
return callback
}
return nil
}
}
2. 콜백 체인 실행 흐름
이미지 다운로드 완료 시 콜백 체인의 흐름은 다음과 같습니다:
2.1. URLSessionDataDelegate의 urlSession(_:task:didCompleteWithError:) 메서드가 호출됩니다.
// SessionDelegate.swift
open func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
guard let sessionTask = self.task(for: task) else { return }
// URL 다운로드 완료 처리
if let url = sessionTask.originalURL {
let result: Result<URLResponse, KingfisherError>
// 결과 생성 로직
// ...
onDownloadingFinished.call((url, result))
}
// 최종 결과 생성
let result: Result<(Data, URLResponse?), KingfisherError>
// ...
// 다운로드 완료 핸들러 호출
onCompleted(task: task, result: result)
}
private func onCompleted(task: URLSessionTask, result: Result<(Data, URLResponse?), KingfisherError>) {
guard let sessionTask = self.task(for: task) else {
return
}
// 모든 콜백 가져오기
let callbacks = sessionTask.removeAllCallbacks()
// 태스크 완료 델리게이트 호출하며 다운로드된 데이터 콜백들과 함께 전달
sessionTask.onTaskDone.call((result, callbacks))
remove(sessionTask)
}
2.2. onTaskDone 델리게이트가 호출되면, ImageDownloader에서 등록한 핸들러가 실행됩니다:
// ImageDownloader.swift
private func startDownloadTask(
context: DownloadingContext,
callback: SessionDataTask.TaskCallback
) -> DownloadTask
{
let downloadTask = addDownloadTask(context: context, callback: callback)
guard let sessionTask = downloadTask.sessionTask, !sessionTask.started else {
return downloadTask
}
// 태스크 완료 핸들러 등록
sessionTask.onTaskDone.delegate(on: self) { (self, done) in
// 다운로드 완료 처리
let (result, callbacks) = done
// 다운로드 데이터 처리 전 보고
self.reportDidDownloadImageData(result: result, url: context.url)
switch result {
case .success(let (data, response)):
// 이미지 데이터 프로세싱
let processor = ImageDataProcessor(
data: data, callbacks: callbacks, processingQueue: context.options.processingQueue
)
// 이미지 처리 완료 핸들러 등록
processor.onImageProcessed.delegate(on: self) { (self, done) in
// 이미지 처리 결과 처리
let (result, callback) = done
// 이미지 처리 결과 보고
self.reportDidProcessImage(result: result, url: context.url, response: response)
// 최종 이미지 결과 생성
let imageResult = result.map { ImageLoadingResult(image: $0, url: context.url, originalData: data) }
// 각 콜백의 onCompleted 호출
let queue = callback.options.callbackQueue
queue.execute { callback.onCompleted?.call(imageResult) }
}
processor.process()
case .failure(let error):
// 에러 처리 및 콜백 호출
callbacks.forEach { callback in
let queue = callback.options.callbackQueue
queue.execute { callback.onCompleted?.call(.failure(error)) }
}
}
}
// ...
return downloadTask
}
2.3. 이미지 처리가 완료되면 onImageProcessed 델리게이트를 통해 각 콜백의 onCompleted 핸들러가 호출됩니다:
// ImageDataProcessor.swift
final class ImageDataProcessor: Sendable {
let onImageProcessed = Delegate<(Result<KFCrossPlatformImage, KingfisherError>, SessionDataTask.TaskCallback), Void>()
func process() {
queue.execute {
self.doProcess()
}
}
private func doProcess() {
var processedImages = [String: KFCrossPlatformImage]()
for callback in callbacks {
// 이미지 처리
// ...
// 처리 결과 각 콜백에 전달
onImageProcessed.call((result, callback))
}
}
}
앞서 말씀드렸듯, onCompleted 콜백은 외부에서 주입된 completionHandler를 Delegate로 래핑한 콜백입니다. 즉, Delegate.call()이 호출되면, 내부적으로 저장된 block(원본 콜백)인 completionHandler을 실행하는 것이 됩니다.
// ImageView+Kingfisher.swift
let task = KingfisherManager.shared.retrieveImage(
with: source,
...
completionHandler: { result in
switch result {
case .success(let value):
self.base.image = value.image // 여기서 ImageView에 이미지 설정
completionHandler?(result)
case .failure:
completionHandler?(result)
}
}
)
즉, 위 클로저를 통해 이미지 다운로드 결과를 ImageView에 설정해줍니다.
2.3. 중복 다운로드 방지 매커니즘의 실행흐름
이제 위 콜백구조를 바탕으로 중복 다운로드 방지 매커니즘의 실행흐름을 설명드리도록 하겠습니다
1. 첫 번째 이미지 요청
1. ImageView A가 kf.setImage(with: url) 메서드를 호출합니다.
2. 이 요청은 KingfisherManager를 통해 ImageDownloader로 전달됩니다.
3. ImageDownloader가 SessionDelegate에 새로운 다운로드 작업을 요청합니다.
4. SessionDelegate는 URL을 키로 하여 SessionDataTask를 생성하고 내부 맵에 저장합니다.
2. 두 번째 이미지 요청 (다운로드 진행 중)
1. ImageView B가 동일한 URL에 대해 kf.setImage(with: url)을 호출합니다.
2. ImageDownloader는 SessionDelegate에 동일한 URL에 대한 작업을 요청합니다.
3. SessionDelegate는 URL을 키로 사용하여 기존 SessionDataTask가 있는지 확인합니다.
4. 기존 태스크가 발견되면, 새로운 다운로드를 시작하지 않고 기존 태스크에 콜백만 추가합니다.
3. 다운로드 완료
1. 네트워크 다운로드가 완료되면 URLSessionDataDelegate의 urlSession(_:task:didCompleteWithError:) 메서드가 호출됩니다.
2. SessionDelegate는 태스크를 찾아 모든 등록된 콜백을 수집합니다.
3. SessionDataTask의 onTaskDone 델리게이트를 호출하여 다운로드 결과와 모든 콜백을 전달합니다.
4. 이미지 처리 및 콜백 호출
1. ImageDownloader는 다운로드된 데이터로 이미지를 처리합니다.
2. 각 콜백의 options에 따라 이미지를 개별적으로 처리할 수 있습니다(예: 다른 크기나 필터).
3. 각 콜백의 onCompleted 핸들러가 호출되어 최종 이미지를 전달받습니다.
5. 이미지뷰에 이미지 설정
1. 각 UIImageView는 콜백을 통해 처리된 이미지를 받습니다.
2. UIImageView는 이미지를 설정하고 필요한 경우 전환 효과를 적용합니다.
이러한 콜백 패턴은 사용자가 이미지 로드 완료 시점에 콜백함수를 정의하고자 할 때 사용됩니다. 하지만 그말인 즉, 단순한 이미지 로드만을 목표로 할 경우 이러한 콜백 패턴이 불필요해지게 됩니다.
이러한 실행흐름은 실제 각 클래스(혹은 actor)의 초기화 횟수를 통해서도 파악이 가능했습니다.
// SessionDataTask
init(task: URLSessionDataTask) {
...
NeoLogger.shared.debug("initialized") // OSLog 호출
}
// DownloadTask
init(
sessionTask: SessionDataTask? = nil,
cancelToken: SessionDataTask.CancelToken? = nil
) {
...
NeoLogger.shared.debug("initialized") // OSLog 호출
}
[Delegate.swift:69] init() - initialized (여러 번 반복)
[SessionDataTask.swift:69] init(task:) - initialized
[DownloadTask.swift:69] init(sessionTask:cancelToken:) - initialized
이미지 다운로드 관련 파일들의 초기화 횟수를 로그 기록으로 추적해본 결과, DownloadTask와 SessionDataTask는 모두 렌더 요청한 이미지 개수인 32회 호출되었으며, Delegate.swift는 108번 호출된 것을 확인할 수 있었습니다. 이를 통해 중복된 이미지가 없다면, Task 클래스는 이미지당 1:1 비율로 생성되는 것을 파악할 수 있었으며, Delegate 객체의 경우 다양한 이벤트들을 처리하기 때문에 1개 이미지당 2-4개의 객체가 생성되는 것을 파악할 수 있었습니다.
그에 반해 동일한 36장의 이미지를 렌더하는 상황에서는 SessionDataTask는 1번밖에 호출되지 않는 것을 확인할 수 있었습니다. 이를 통해 SessionDataTask는 이미지 소스와 1대1로 대응되는 클래스이며, DownloadTask는 주로 이미지를 렌더하는 주체(주로 UIImageView)와 대응되는 것을 확인할 수 있었습니다.
따라서, 다음 게시글에서는 이러한 콜백패턴을 간소화한 후, Swift 6 동시성 모델을 도입한 개인 라이브러리를 구현한 과정을 정리해 올리도록 하겠습니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
Swift Closure 정리 (0) | 2025.04.07 |
---|---|
Kingfisher에서 Swift 동시성 모델을 도입한 NeoImage 라이브러리 구현기 (0) | 2025.03.16 |
@Sendable 정리 (0) | 2025.03.06 |
Kingfisher 라이브러리 분석하기 (Networking 레이어) (1) | 2025.03.06 |
기초 CS (0) | 2025.03.06 |