이미지 최적화 3탄(kingFisher를 삭제하고 Custom)

2025. 4. 16. 15:38·SWIFT개발

모바일 앱에서 이미지를 로딩하는 것은 사용자 경험에 큰 영향을 미칩니다. 빠르고 효율적인 이미지 로딩은 앱의 성능을 좌우하며, 특히 메모리 관리와 네트워크 사용량이 중요한 요소로 작용합니다. 저는 기존에 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
'SWIFT개발' 카테고리의 다른 글
  • Metal3편 - 메모리 사용량 급증 버그 수정
  • 이미지 최적화 적용하기
  • Preview는 어떻게 그림을 그리는 걸까? is that hotreload?
  • ScrollView 꾸미기? Deep Dive
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (120)
      • SWIFT개발 (29)
      • 알고리즘 (25)
      • Design (6)
      • ARkit (1)
      • 면접준비 (30)
      • UIkit (2)
      • Vapor-Server with swift (3)
      • 디자인패턴 (5)
      • 반응형프로그래밍 (12)
      • CS (3)
      • 도서관 (1)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
2료일
이미지 최적화 3탄(kingFisher를 삭제하고 Custom)
상단으로

티스토리툴바