왜 굳이 직접 만들었을까?
솔직히 처음엔 Kingfisher 쓰면서 별 불만이 없었어요. 이미지 캐싱? 그냥 KFImage 하나면 끝이잖아요. 근데 프로젝트를 진행하다 보니 이상한 느낌이 들더라고요.
"나 지금 이 라이브러리의 10%도 안 쓰고 있는데, 왜 전체를 다 가지고 있어야 하지?"
특히 볼레또라는 여행 앱 특성상, 사용자가 여행 기간(보통 3-4일)엔 엄청 자주 들어오지만 그 외엔 거의 안 들어와요. 그런데 Kingfisher는 범용 라이브러리다 보니 모든 상황에 대응하려다 보니 우리 앱엔 오버스펙이었던 거죠.
빌드 시간도 점점 길어지고, 바이너리 크기도 신경 쓰이고... 그래서 결심했습니다. "직접 만들자!"
Kingfisher는 어떻게 동작할까?
비교를 위해 Kingfisher의 내부 구조를 먼저 뜯어봤어요. 생각보다 정교하게 설계되어 있더라고요.
1. 캐싱 전략: 메모리와 디스크의 2단 구조
Kingfisher는 전형적인 2단 캐싱을 사용합니다.
메모리 캐시 (NSCache 기반)
// Kingfisher 내부 구조
public class MemoryStorage {
private let storage = NSCache<NSString, StorageObject>()
init() {
storage.totalCostLimit = 100 * 1024 * 1024 // 100MB
// 메모리 경고 시 자동 정리
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.storage.removeAllObjects()
}
}
}
NSCache는 시스템이 메모리 부족하다고 판단하면 알아서 항목을 제거해줘요. 근데 여기서 포인트는 이미지 크기에 따른 비용 계산이에요. Kingfisher는 이미지를 저장할 때 그냥 넣는 게 아니라 비용(cost)을 계산해서 넣어요.
// 이미지 크기를 cost로 계산
let cost = image.size.width * image.size.height * scale * scale
storage.setObject(image, forKey: key, cost: Int(cost))
이게 왜 중요하냐면, 고해상도 이미지 하나가 저해상도 이미지 여러 개보다 메모리를 더 많이 차지하잖아요? NSCache는 이 cost를 기준으로 "어떤 걸 먼저 버릴까?"를 결정합니다.
디스크 캐시 (파일 시스템 기반)
디스크 캐시는 더 복잡해요. 단순히 파일만 저장하는 게 아니라 메타데이터 관리가 핵심이에요.
// Kingfisher의 디스크 캐시 저장 프로세스
public struct DefaultCacheSerializer: CacheSerializer {
public var compressionQuality: CGFloat = 1.0
public var preferCacheOriginalData: Bool = false
public func data(with image: KFCrossPlatformImage, original: Data?) -> Data? {
if preferCacheOriginalData {
return original ?? image.kf.data(
format: original?.kf.imageFormat ?? .unknown,
compressionQuality: compressionQuality
)
} else {
return image.kf.data(
format: original?.kf.imageFormat ?? .unknown,
compressionQuality: compressionQuality
)
}
}
}
여기서 주목할 점이 기본 압축률이 1.0이라는 거예요. 즉, 원본 품질 그대로 저장한다는 뜻이죠.
범용 라이브러리니까 품질 손실을 최소화하려는 의도는 이해가 가는데, 우리 앱 입장에선 낭비예요.
그리고 파일명은 URL의 해시값을 사용해요:
// URL을 MD5 해싱
let fileName = url.absoluteString.md5
let filePath = cacheDirectory.appendingPathComponent(fileName)
2. 백그라운드 캐시 정리: 2분마다 확인한다고?
Kingfisher는 캐시 정리를 어떻게 하지?
public struct Config {
/// 백그라운드에서 캐시 정리 간격
public var backgroundCleanInterval: TimeInterval = 120 // 2분
}
// 실제 Kingfisher 구현
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask { [weak self] in
self?.endBackgroundTask()
}
// 백그라운드 상태에서만 타이머 시작
cleanTimer = Timer.scheduledTimer(
timeInterval: config.backgroundCleanInterval,
target: self,
selector: #selector(cleanExpiredDiskCache),
userInfo: nil,
repeats: true
)
}
앱이 백그라운드로 가면 2분마다 만료된 캐시를 확인하고 정리해요.
처음엔 "이거 오버헤드 아니야?"라고 생각했는데, 코드를 더 들여다보니 나름의 이유가 있더라고요.
private func cleanExpiredDiskCache() {
ioQueue.async {
guard let diskCacheURL = self.diskCacheURL else { return }
// 파일 메타데이터만 읽음 (빠름)
let resourceKeys: Set<URLResourceKey> = [
.contentModificationDateKey,
.totalFileAllocatedSizeKey
]
let fileManager = FileManager.default
guard let fileEnumerator = fileManager.enumerator(
at: diskCacheURL,
includingPropertiesForKeys: Array(resourceKeys)
) else { return }
let expirationDate = Date(timeIntervalSinceNow: -maxCachePeriodInSecond)
var urlsToDelete: [URL] = []
for case let fileURL as URL in fileEnumerator {
guard let resourceValues = try? fileURL.resourceValues(
forKeys: resourceKeys
),
let modificationDate = resourceValues.contentModificationDate
else { continue }
if modificationDate < expirationDate {
urlsToDelete.append(fileURL)
}
}
// 실제 삭제
urlsToDelete.forEach { try? fileManager.removeItem(at: $0) }
}
}
백그라운드 스레드에서 파일 메타데이터만 읽어서 확인하니까 생각보다 빨라요.
실제 이미지 데이터를 읽는 게 아니라 NSFileManager의 속성 쿼리만 사용하거든요.
그래도 2분은 좀 짧지 않나 싶었어요. 왜 하필 2분일까?
추측해보면, 범용 라이브러리니까 "최대한 자주 정리해서 디스크 공간 확보"에 초점을 맞춘 것 같아요.
인스타, 페이스북 카카오톡 등 이미지가 계속 쌓이는 앱들을 고려한 거죠.
3. 이미지 프리패칭: 미리 땡겨오기
public class ImagePrefetcher: CustomStringConvertible, @unchecked Sendable {
private func startPrefetching(_ source: Source) {
guard !stopped else { return }
// 이미 캐시된 이미지인지 확인
let cacheType = manager.cache.imageCachedType(
forKey: source.cacheKey,
processorIdentifier: optionsInfo.processor.identifier
)
switch cacheType {
case .memory:
append(cached: source) // 메모리 캐시에 있으면 건너뜀
case .disk:
if optionsInfo.alsoPrefetchToMemory {
// 디스크에 있으면서 메모리 캐싱 옵션이 켜져 있다면 메모리로 로드
let context = RetrievingContext(options: optionsInfo, originalSource: source)
_ = manager.retrieveImageFromCache(
source: source,
context: context) { _ in
self.append(cached: source)
}
} else {
append(cached: source) // 디스크에만 있고 메모리 캐싱 옵션이 꺼져 있다면 건너뜀
}
case .none:
downloadAndCache(source) // 캐시에 없다면 다운로드 및 캐싱 시작
}
}
private func downloadAndCache(_ source: Source) {
let downloadTask = manager.loadAndCacheImage(
source: source,
options: optionsInfo,
completionHandler: { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.append(completed: source)
case .failure:
self.append(failed: source)
}
self.reportProgress()
self.reportCompletionOrStartNext()
})
// 다운로드 작업 추적
guard let task = downloadTask else {
append(failed: source)
reportProgress()
reportCompletionOrStartNext()
return
}
tasks[source.cacheKey] = task
}
}
maxConcurrentDownloads가 디폴트 5인 이유가 뭘까요?
네트워크 대역폭은 한정되어 있잖아요. 동시에 너무 많이 다운받으면:
- 각 다운로드가 느려지고
- 네트워크 큐가 막히고
- 실제로 사용자가 보려는 이미지(프리패칭이 아닌)가 늦게 로드돼요
5개가 최적이라는 게 아니라, "과하지 않게"를 목표로 한 거죠. 이것도 범용 라이브러리의 특성이에요.
4. 투명도 문제: PNG vs JPEG의 딜레마
우리 앱에서 Kingfisher 쓸 때 제일 골치 아팠던 부분이에요.
KFImage(imageURL)
.setProcessor(DefaultImageProcessor.default)
.cacheSerializer(FormatIndicatedCacheSerializer.jpeg(compressionQuality: 0.7))
이렇게 설정하면 모든 이미지가 JPEG로 변환돼요. PNG도, GIF도 전부 JPEG가 되는 거죠.
문제는 JPEG는 투명도를 지원 안 하잖아요? 그래서 스티커 같은 건 투명 배경이 검정색으로 바뀌어버렸어요.
해결책은? PNGKFImage와 JPEGKFImage 컴포넌트를 따로 만드는 수밖에 없었어요.
// PNG용 컴포넌트
struct PNGKFImage: View {
let url: URL
var body: some View {
KFImage(url)
.cacheSerializer(FormatIndicatedCacheSerializer.png)
}
}
// JPEG용 컴포넌트
struct JPEGKFImage: View {
let url: URL
var body: some View {
KFImage(url)
.cacheSerializer(FormatIndicatedCacheSerializer.jpeg(compressionQuality: 0.8))
}
}
"이거 좀 번거로운데?" 싶었죠. 이미지 타입에 따라 컴포넌트를 분기해야 하니까요.
그래서 커스텀으로 뚝딱뚞딲🏭
Kingfisher를 분석하고 나니 우리 앱에 맞는 최적화 포인트가 보이더라고요.
핵심 설계 원칙
- 경량화: 필요한 기능만 구현
- Swift Concurrency: Actor 모델로 쓰레드 안정성 확보
- 똑똑한 다운샘플링: 용도별 최적화
- 용도별 캐싱: 스티커는 PNG, 일반 이미지는 JPEG
actor ImageLoader {
static let shared = ImageLoader()
private let cache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let cacheDirectory: URL
private init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
cacheDirectory = paths[0].appendingPathComponent("ImageCache")
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
}
Actor를 쓴 이유는 데이터 레이스를 차단하기 위해서예요.
Kingfisher는 GCD의 DispatchQueue를 사용해요:
// Kingfisher 방식
private let ioQueue = DispatchQueue(label: "com.onevcat.Kingfisher.Cache.ioQueue")
ioQueue.async {
// 캐시 작업
}
여러 큐에서 동시에 캐시에 접근하면 데이터 레이스가 발생할 수 있거든요.
Actor는 컴파일러 레벨에서 동기화를 보장해줘요. 그래서 실수로 잘못된 접근을 하면 컴파일 단계에서 걸러주니까 훨씬 안전해요.
& 다운샘플링 적용
private func downsampleImage(data: Data, targetSize: CGSize?) async throws -> UIImage {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions) else {
throw ImageError.invalidImageData
}
let maxDimension: CGFloat = targetSize?.width ?? 1024
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
] as CFDictionary
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
throw ImageError.downsamplingFailed
}
return UIImage(cgImage: downsampledImage)
}
& 용도별 캐싱
func loadImage(from url: URL, targetSize: CGSize? = nil, isSticker: Bool = false) async throws -> UIImage {
// 메모리 캐시 확인
if let cachedImage = cache.object(forKey: url.absoluteString as NSString) {
return cachedImage
}
// 디스크 캐시 확인
if let diskCachedImage = try? loadFromDisk(url: url) {
cache.setObject(diskCachedImage, forKey: url.absoluteString as NSString)
return diskCachedImage
}
// 네트워크에서 다운로드
let (data, _) = try await URLSession.shared.data(from: url)
let image: UIImage
if let targetSize = targetSize {
image = try await downsampleImage(data: data, targetSize: targetSize)
} else {
guard let originalImage = UIImage(data: data) else {
throw ImageError.invalidImageData
}
image = originalImage
}
cache.setObject(image, forKey: url.absoluteString as NSString)
try? saveToDisk(image: image, url: url, isSticker: isSticker)
return image
}
private func saveToDisk(image: UIImage, url: URL, isSticker: Bool = false) throws {
let fileURL = cacheDirectory.appendingPathComponent(url.lastPathComponent)
let data: Data?
if isSticker {
data = image.pngData() // 투명도 유지
} else {
data = image.jpegData(compressionQuality: 0.8) // 용량 절약
}
guard let imageData = data else { return }
try imageData.write(to: fileURL)
}
이제 투명도도
// 스티커는 PNG로
let sticker = try await ImageLoader.shared.loadImage(from: url, isSticker: true)
// 일반 이미지는 JPEG로
let photo = try await ImageLoader.shared.loadImage(from: url, isSticker: false)
병렬 처리
문제 상황:👿
- 프레임 1장 + 사진 4장 = 총 5장의 이미지
- 화면에 최대 6개의 네컷 (6 × 6 배치)
- 총 36장의 이미지가 동시에 필요
기존 방식대로 순차적으로 로딩하면:
let frame = try await loadImage(from: frameURL)
let image1 = try await loadImage(from: url1)
let image2 = try await loadImage(from: url2)
let image3 = try await loadImage(from: url3)
let image4 = try await loadImage(from: url4)
5개를 순차적으로 로딩하니까 시간이 5배 걸려요. 게다가 이미지가 하나씩 나타나면서 레이아웃이 덜컹덜컹 변해요.
해결책: TaskGroup으로 병렬 처리
func loadFourCutImages(frameUrl: String, imageUrls: [String]) async throws -> (UIImage?, [UIImage]) {
var frameImage: UIImage? = nil
var images: [UIImage] = Array(repeating: UIImage(), count: imageUrls.count)
guard let frameUrl = URL(string: frameUrl) else {
throw ImageError.invalidURL
}
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
// 프레임 로드 (인덱스 -1)
group.addTask {
let image = try await self.loadImage(
from: frameUrl,
targetSize: CGSize(width: 1024, height: 1024)
)
return (-1, image)
}
// 4장 동시 로드
for (index, urlString) in imageUrls.enumerated() {
guard let url = URL(string: urlString) else {
throw ImageError.invalidURL
}
group.addTask {
let image = try await self.loadImage(
from: url,
targetSize: CGSize(width: 640, height: 640)
)
return (index, image)
}
}
// 결과 수집
for try await (index, image) in group {
if index == -1 {
frameImage = image
} else if index >= 0 && index < images.count {
images[index] = image
}
}
}
return (frameImage, images)
}
TaskGroup은 비동기 작업들을 동시에 실행해요. 그럼 완료 순서가 뒤죽박죽이 되잖아요?예를 들어:
- 3번 이미지가 제일 먼저 완료
- 1번 이미지가 두 번째 완료
- 프레임이 세 번째 완료
- ...
튜플 (Int, UIImage)를 사용하면 완료 순서에 관계없이 원래 위치에 이미지를 배치할 수 있어요
for try await (index, image) in group {
if index == -1 {
frameImage = image // 프레임
} else {
images[index] = image // 인덱스에 맞게 배치
}
}
실제 성능 개선
- 순차 로딩: 약 5초
- 병렬 로딩: 약 1.5초
70% 시간 단축이에요!
더 중요한 건 UI 경험이에요. 모든 이미지가 준비될 때까지 기다렸다가 한 번에 표시하니까 레이아웃이 안정적이에요.
struct AsyncImageView: View {
let urlString: String
let imagetype: AsyncImageType
@State private var image: UIImage?
@State private var fourCutImages: [UIImage] = []
var body: some View {
switch imagetype {
case .fourCut(let urls, let isLargeMode):
if image != nil && !fourCutImages.isEmpty {
// 모든 이미지가 준비되면 한 번에 표시
ZStack {
Image(uiImage: image!)
.resizable()
.aspectRatio(contentMode: .fill)
GeometryReader { geo in
VStack(spacing: padding) {
HStack(spacing: padding) {
Image(uiImage: fourCutImages[0])
Image(uiImage: fourCutImages[1])
}
HStack(spacing: padding) {
Image(uiImage: fourCutImages[2])
Image(uiImage: fourCutImages[3])
}
}
}
}
} else {
ProgressView() // 로딩 중
}
}
}
.task {
let (frame, images) = await loadFourCuts(urlString: urlString, urls: urls)
image = frame
fourCutImages = images
}
}
과감한 캐시 정리 전략
Kingfisher는 2분마다 체크하는 반면, 우리는 다르게 접근했어요.
"여행 앱 특성상, 여행 끝나면 한동안 안 들어오는데 캐시를 오래 유지할 필요 있나?"
// AppDelegate 또는 SceneDelegate에서
func applicationDidEnterBackground(_ application: UIApplication) {
let lastAccessTime = UserDefaults.standard.object(forKey: "LastAccessTime") as? Date ?? Date()
let now = Date()
// 24시간 이상 지났으면 디스크 캐시 전체 삭제
if now.timeIntervalSince(lastAccessTime) > 24 * 60 * 60 {
Task {
await ImageLoader.shared.clearCache()
}
}
UserDefaults.standard.set(now, forKey: "LastAccessTime")
}
백그라운드에서 2분마다 체크하는 오버헤드 대신, 앱 실행 시 한 번만 체크해요.
"앱 켤 때 0.1초 걸리는 것" vs "백그라운드에서 수십 번 체크하는 것"
어차피 사용자는 앱 실행 시 약간의 로딩은 기대하잖아요? 그때 한 번 정리하는 게 훨씬 효율적이라고 판단했어요.
결과는?
정량적 개선
- 빌드 시간: 15% 단축 (Kingfisher 의존성 제거)
- 바이너리 크기: 약 2MB 감소
- 네컷 로딩 시간: 70% 단축 (5초 → 1.5초)
- 메모리 사용량: 약 40% 감소 (다운샘플링 효과)
정성적 개선
- 코드베이스 이해도 향상 (외부 라이브러리 블랙박스 → 직접 제어)
- 앱 특성에 맞는 최적화 가능
- Swift Concurrency 학습 기회
'SWIFT개발일지' 카테고리의 다른 글
| 이미지 URL 저장 시 마주하는 함정 문제들 (0) | 2025.09.11 |
|---|---|
| Metal3편 - 메모리 사용량 급증 버그 수정 (0) | 2025.04.20 |
| 이미지 최적화 적용하기 (1) | 2025.03.06 |
| ScrollView 꾸미기? Deep Dive (0) | 2025.02.22 |
| MemoryLeak을 찾아보자(강한참조순환인가?) - 실전편2 (0) | 2025.01.16 |