모바일 앱에서 이미지를 로딩하는 것은 사용자 경험에 큰 영향을 미칩니다. 빠르고 효율적인 이미지 로딩은 앱의 성능을 좌우하며, 특히 메모리 관리와 네트워크 사용량이 중요한 요소로 작용합니다. 저는 기존에 Kingfisher라는 외부 라이브러리를 사용해 이미지 로딩을 처리했지만, 몇 가지 한계에 부딪혔습니다. 외부 라이브러리는 편리하지만, 앱의 특정 요구사항을 충족하기에는 유연성이 부족했고, 불필요한 오버헤드가 발생했습니다. 그래서 저는 앱에 최적화된 커스텀 ImageLoader를 설계하기로 했습니다. 이 글에서는 Kingfisher의 단점과 커스텀 ImageLoader의 장점을 비교하며, 어떻게 더 나은 솔루션을 만들었는지 설명하겠습니다.
기존에는 KingFisher 외부 라이브러리를 통해 이미지 최적화를 하였습니다. 하지만 외부 라이브러리 의존성은 몇가지 단점을 가져와서 커스텀 설계를 하였습니다.
1. 빌드 시간 최적화: 외부 라이브러리 통합시 빌드시간이 증가합니다.
2. 라이브러리 크기: KingFisher는 다양한 기능을 제공하지만 실제로 볼레또에서는 일부 기능만 활용했기에 전체 라이브러리 포함하는거는 바이너리 크기 측면에서 비효율적이라고 생각했습니다.
그래서 저는 Custom ImageLoader를 몇가지 중점을 두고 개발을 했습니다:
1. 경량화: 필요한 기능만 구현하여 코드를 최소화
2. Swift Concurrency 활용: Actor 모델을 사용하여 쓰레드 안정성을 확보하고 async/await을 통해 비동기 코드의 가독성과 유지보수성 고려
3. 맞춤형 다운샘플링: 다운샘플링 로직을 특정 사용사례에 맞게 최적화했습니다. 이미지의 크기가 큰 경우 메모리 사용량을 크게 줄일 수 있었습니다.
4. 용도별 캐싱 전략: 스티커와 같은 투명도가 필요한 이미지는 PNG로, 일반 이미지는 압축률 0.8의 JPEG로 저장하여 최적화를 고려
KingFisher
1 . Cache
킹피셔는 2가지 캐시를 사용했다.
1. 메모리 캐시:
- NSCache기반으로 메모리 부족 시 시스템이 자동으로 캐시항목을 제거한다.
- 이미지 크기에 따른 비용 계산을 통해 메모리 사용량을 관리하엿다.
2. 디스크 캐시:
- 파일시스템에 이미지 데이터를 저장한다.
- 캐시된 파일의 메타데이터(생성시간, 마지막 액세스 시간 등)관리하여 만료 및 크기 제한을 구현하였다.
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
)
}
}
}
KingFisher의 경우 기본 압축률은 1.0이다.
디스크 캐시 저장 프로세스
1. 이미지 다운로드 완료
2. 메모리 캐시에 이미지 객체 저장
3. CacheSerializer를 사용하여 이미지를 Data로 변환
4. 디스크 캐시에 Data 저장 . 파일이름은 URL해시값.
캐시 정리 및 관리
- 캐시된 각 이미지에 만료 기간 설정 가능했다. 그래서 주기적으로 만료된 이미지를 자동 제거했다.
- 백그라운드 정리: 앱이 백그라운드로 전환할때 자동으로 만료된 캐시를 정리한다.
- 주기적인 정리: 앱 실행중에는 지정된 시간간격으로 만료된 캐시를 전환
public struct Config {
// ...
/// The time interval between two clean operations when the app is in background.
/// Default is 120 seconds.
public var backgroundCleanInterval: TimeInterval = 120
// ...
}
- 보면 2분간격으로 계속 확인을 하며 만료된 디스크 캐시를 정리한다.
- 흠 그렇다면? 그만큼의 오버헤드가 있는거 아닌가? 왜 2분이지 더길수록 좋은거 아닐까????
- -> 백그라운드 스레드에서 비동기적으로 실행되고 앱이 백그라운드 상태일때만 실행됩니다.
- -> 파일메타데이터만 검사하므로 효율적이다. 그리고 결정적으로 NSFileManager 속성 쿼리는 상대적으로 빠르다.
- 디스크 캐시의 총 크기 제한 설정이 가능했다. 제한 초과시 메타데이터를 보고 가장 오래된 항목부터 제거하였다.
이미지 형식 결정
기본적으로 KingFisher는 원본 이미지 형식을 유지합니다.예를들어 url로부터 다운로드된 이미지가 JPEG 이면 JPEG.
위의 DefaultCacheSerializer를 다시보자. data메서드에서 이 처리를 한다. 그래서 기존에 KFImage를 사용할때
KFImage(imageURL)
.setProcessor(DefaultImageProcessor.default)
.cacheSerializer(FormatIndicatedCacheSerializer.jpeg(compressionQuality: 0.7))
이렇게 할수 있었다. 문제는 PNG가 들어가도 JPEG으로변환되어 투명도가 사라졌다. 물론 그래서 PNGKFImage와 JPEGKFImage를 컴포넌트를 분리해주었다.
2. 비동기 이미지 다운로드
3. 이미지 프로세서
다운로드 후 이미지에 다양한 처리를 적용할 수 있다.
- DefaultImageProcessor: 기본 프로세서
- ResizingImageProcessor: 이미지 크기 조정
- DownsamplingImageProcessor: 메모리 사용을 최소화하며 이미지 크기 조정
- CroppingImageProcessor: 이미지 자르기
- RoundCornerImageProcessor: 모서리 둥글게 처리
- BlurImageProcessor: 블러 효과 적용
- OverlayImageProcessor: 오버레이 색상 적용
- TintImageProcessor: 틴트 색상 적용
- BlackWhiteImageProcessor: 흑백 변환
- CompositionImageProcessor: 여러 프로세서를 조합
4. 이미지 프리패칭
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). 이는 과도한 네트워크 사용을 방지하면서도 효율적인 다운로드를 가능하게한다.
그래서 이는 사용자 경험을 개선하기 위해 이미지를 미리 다운로드 하여 사용자가 실제로 이미지를 요청했을때 즉시 표시될수 있도록 하는 기능이다.
내 Custom Version
//
// ImageLoader.swift
// Boleto
//
// Created by Sunho on 4/13/25.
//
import UIKit
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)
}
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 {
// 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
}
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 {
do {
let image = try await self.loadImage(from: frameUrl,targetSize: CGSize(width: 1024,height: 1024))
return (-1, image)
} catch {
throw error
}
}
// 네컷 이미지 병렬 로드
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)
}
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() // PNG 형식으로 저장 (투명도 유지)
} else {
data = image.jpegData(compressionQuality: 0.8) // JPEG 형식으로 저장 (용량 절약)
}
guard let imageData = data else { return }
try imageData.write(to: fileURL)
}
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)
}
private func loadFromDisk(url: URL) throws -> UIImage? {
let fileURL = cacheDirectory.appendingPathComponent(url.lastPathComponent)
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return UIImage(data: data)
}
func clearCache() {
cache.removeAllObjects()
try? fileManager.removeItem(at: cacheDirectory)
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
}
볼레또는 다른 이미지 프로세서가 필요가 없다. 그래서 모든 URL을 통해 받아오는 이미지에 사이즈에 맞는 다운샘플링을 목표로 하였다.
다운샘플링
1. 메모리 사용량 최소화
- kCGImageSourceShouldCache: false: 소스 이미지 전체를 메모리에 캐싱하지 않도록 설정
- kCGImageSourceShouldCacheImmediately: true: 축소된 이미지만 즉시 캐싱하여 메모리 효율성 극대
2. 디코딩 최적화
- 전체 이미지를 메모리에 로드한 후 크기 조정하는 대신, 필요한 크기만큼만 디코딩하여 CPU 및 메모리 사용량 감소
- Kingfisher도 유사한 접근법을 사용하지만, 여기서는 불필요한 중간 단계와 옵션을 제거하여 더 효율적인 구현
3. 변환 보존
- kCGImageSourceCreateThumbnailWithTransform: true: 이미지 방향(orientation) 정보를 보존하여 회전된 이미지도 정확히 표시
캐시
1. 가장 빠른 메모리 캐시부터 확인 후 디스크 캐시, 거기도 없으면 네트워크 요청은 KingFisher와 동일하게 설계하였다.
구조적 동시성
사실 이게 아주 중요하다.
import SwiftUI
enum AsyncImageType {
case image
case sticker
case fourCut(urls: [String], isLargeMode: Bool)
}
struct AsyncImageView: View {
let urlString: String
let imagetype: AsyncImageType
@State private var image: UIImage?
@State private var fourCutImage: [UIImage] = []
var body: some View {
switch imagetype {
case .fourCut(let urls, let isSmallMode):
ZStack {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: isSmallMode ? 20 : 10))
.clipped()
GeometryReader { geo in
let screenWidth = geo.size.width
let padding = CGFloat(screenWidth / 15)
VStack(spacing: padding) {
HStack(spacing: padding) {
ForEach(0..<2) { index in
if index < fourCutImage.count {
Image(uiImage: fourCutImage[index])
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(RoundedRectangle(cornerRadius: isSmallMode ? 10 : 5))
}
}
}
.frame(maxHeight: .infinity)
HStack(spacing: padding) {
ForEach(2..<4) { index in
if index < fourCutImage.count {
Image(uiImage: fourCutImage[index])
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(RoundedRectangle(cornerRadius: isSmallMode ? 10 : 5))
}
}
}
.frame(maxHeight: .infinity)
}
.padding(.all, padding)
.padding(.bottom, padding * 1.5)
}
}
.aspectRatio(0.87, contentMode: .fit)
}
} else if let error = error {
Text(error)
.foregroundColor(.red)
}
}
.task {
switch imagetype {
case .image:
await loadImage(isSticker: false)
case .sticker:
await loadImage(isSticker: true)
case .fourCut(let urls, let isLargeMode):
let (frameImage, fourimages) = await loadFourCuts(urlString: urlString, urls: urls)
image = frameImage
fourCutImage = fourimages
}
}
}
private func loadFourCuts( urlString: String, urls: [String]) async -> (UIImage?, [UIImage]){
isLoading = true
error = nil
do {
let (frameImage, fourCutImages) = try await ImageLoader.shared.loadFourCutImages(frameUrl: urlString, imageUrls: urls)
isLoading = false
return (frameImage, fourCutImages)
} catch {
print("errorrorororo")
isLoading = false
return (nil, [])
}
}
}
내 경우 네컷이라는 것이 있는데 여기에는 서버로부터 받은 프레임, 그리고 네장의 사진을 배치해준다. 그리고 해당 뷰를 상위뷰에서 요구하는 크기에 맞게 배치해야한다. 뷰에 사진들이 최대 5*6 = 30개가 있을수 있었습니다. 이 복잡한 요구사항은 몇가지 UI문제를 야기했습니다.
- 이미지들이 비동기적으로 순차 로딩되면서 레이아웃이 불안정하게 변화
- 다수의 이미지가 동시 로딩으로 인한 메모리 사용량 급증
모든이미지(프레임+4장의 사진)을 하나의 TaskGroup 내에서 병렬로 다운로드하여 총 로딩시간을 70% 최소화했습니다. 일반적인 비동기 로딩방식과 달리, 이 접근법은 모든 이미지가 준비될때까지 UI업데이트를 지연시켜 불완전한 상태의 네컷을 표시하지않습니다.
각 작업이 반환하는 튜플(Int,UIImage)를 통해 다운로드 완료 순서에 관계없이 올바른 위치에 이미지를 배치할수 있었습니다.
Cache
앱의 특성상 유저는 여행기간인 3박4일?정도는 자주 들어올수있다. 하지만 여행이라는 특성상 그 외는 앱에 자주 들어오지않으므로 디스크 캐시를 타 서비스보다 짧게 유지하려했다.
KingFisher의 경우 백그라운드에서 2분마다 체크하며 지나면 제거한다. 하지만 나는 그 2분마다 체크한다는 오버헤드가 발생한다고 생각하였습니다. 사실 엄청 쪼끄만 수준이지만..
그래서 앱의 마지막 접속시간으로부터 24시간이 지나면 디스크 캐시를 다 날리는 과감한(?) 결정을 내려 구현했습니다.
'SWIFT개발' 카테고리의 다른 글
Metal3편 - 메모리 사용량 급증 버그 수정 (0) | 2025.04.20 |
---|---|
이미지 최적화 적용하기 (1) | 2025.03.06 |
Preview는 어떻게 그림을 그리는 걸까? is that hotreload? (0) | 2025.03.01 |
ScrollView 꾸미기? Deep Dive (0) | 2025.02.22 |
MemoryLeak을 찾아보자(강한참조순환인가?) - 실전편2 (0) | 2025.01.16 |