모바일 이미지 캐싱: Kingfisher(iOS)와 Glide(Android) 최적화

모바일 개발

KingfisherGlide이미지 캐싱성능 최적화iOS Android

이 글은 누구를 위한 것인가

  • 이미지가 많은 앱에서 스크롤이 끊기는 문제를 겪는 팀
  • Kingfisher/Glide 기본 사용 이상의 최적화가 필요한 개발자
  • 이미지 로딩으로 인한 메모리 문제를 해결하고 싶은 엔지니어

들어가며

100개 상품 이미지가 있는 그리드 스크롤이 끊긴다. 원인은 이미지 로딩이 매번 네트워크를 치거나, 너무 큰 이미지를 메모리에 올리거나, 올바른 캐싱이 없기 때문이다.

이 글은 bluefoxdev.kr의 모바일 성능 최적화 가이드 를 참고하여 작성했습니다.


1. 이미지 캐싱 전략

[캐시 계층]

메모리 캐시 (L1):
  NSCache / LruCache
  빠름, 앱 종료 시 사라짐
  적합: 자주 접근하는 이미지

디스크 캐시 (L2):
  로컬 파일 시스템
  중간 속도, 앱 재시작 후도 유지
  크기 제한 필요 (보통 100-500MB)

CDN 캐시:
  서버 측, 전 세계 배포
  HTTP 헤더로 TTL 제어

[이미지 크기 최적화]
  서버에서 적절한 크기 제공:
    썸네일: 200x200
    목록: 400x400
    상세: 1200x1200
  
  이름 규칙: image_{width}x{height}.webp
  
  클라이언트에서:
    화면 밀도(ppi) 고려
    @2x: 400px → 실제 200pt
    DownsampleRequired: 큰 이미지는 다운샘플링

[포맷 선택]
  JPEG: 사진, 품질 손실 허용
  PNG: 투명도 필요
  WebP: JPEG 대비 30% 작음, Android 4.0+, iOS 14+
  AVIF: WebP 대비 50% 작음, 최신 기기만

2. Kingfisher (iOS) 고급 설정

import Kingfisher
import SwiftUI

// 글로벌 캐시 설정
class ImageCacheConfig {
    static func configure() {
        let cache = ImageCache.default
        
        // 메모리 캐시: 100MB
        cache.memoryStorage.config.totalCostLimit = 100 * 1024 * 1024
        cache.memoryStorage.config.countLimit = 200
        
        // 디스크 캐시: 300MB, 7일
        cache.diskStorage.config.sizeLimit = 300 * 1024 * 1024
        cache.diskStorage.config.expiration = .days(7)
        
        // 다운로드 설정
        let downloader = ImageDownloader.default
        downloader.downloadTimeout = 15
        downloader.sessionConfiguration.requestCachePolicy = .returnCacheDataElseLoad
    }
}

// SwiftUI에서 Kingfisher 사용
struct ProductImageView: View {
    let url: URL
    let size: CGSize
    
    var body: some View {
        KFImage(url)
            .placeholder {
                // 스켈레톤 로딩
                Rectangle()
                    .fill(Color.gray.opacity(0.3))
                    .shimmer()
            }
            .fade(duration: 0.2)
            // 리사이징: 화면에 맞게 다운샘플링
            .resizable()
            .setProcessor(
                DownsamplingImageProcessor(size: size)
                |> RoundCornerImageProcessor(cornerRadius: 8)
            )
            .cacheOriginalImage()
            .scaledToFill()
            .frame(width: size.width, height: size.height)
            .clipped()
    }
}

// 프리패치: 스크롤 전 미리 로드
class ImagePrefetcher {
    static func prefetch(urls: [URL]) {
        let prefetcher = Kingfisher.ImagePrefetcher(
            urls: urls,
            options: [
                .downloadPriority(URLSessionTask.lowPriority),
                .cacheMemoryOnly,  // 스크롤 빠를 경우 메모리만
            ]
        )
        prefetcher.start()
    }
}

// UIKit에서 사용 (CollectionView Cell)
class ProductCell: UICollectionViewCell {
    @IBOutlet weak var imageView: UIImageView!
    
    func configure(with product: Product) {
        let processor = DownsamplingImageProcessor(size: imageView.bounds.size)
            |> RoundCornerImageProcessor(cornerRadius: 8)
        
        imageView.kf.indicatorType = .activity
        imageView.kf.setImage(
            with: product.imageURL,
            placeholder: UIImage(systemName: "photo"),
            options: [
                .processor(processor),
                .scaleFactor(UIScreen.main.scale),
                .transition(.fade(0.2)),
                .cacheOriginalImage,
            ]
        )
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.kf.cancelDownloadTask()
    }
}

extension View {
    func shimmer() -> some View { self }  // 실제 구현은 별도
}

struct Product { let imageURL: URL }
// Android Glide 최적화
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions

// Glide 모듈 커스터마이징
@GlideModule
class AppGlideModule : AppGlideModule() {
    override fun applyOptions(context: Context, builder: GlideBuilder) {
        builder
            .setMemoryCache(LruResourceCache(50L * 1024 * 1024))  // 50MB
            .setDiskCache(DiskLruCacheFactory(context.cacheDir.path, 300L * 1024 * 1024))
            .setDefaultRequestOptions(
                RequestOptions()
                    .format(DecodeFormat.PREFER_RGB_565)  // ARGB_8888 대비 메모리 절반
                    .diskCacheStrategy(DiskCacheStrategy.ALL)
            )
    }
}

// RecyclerView에서 최적화 사용
class ProductAdapter : RecyclerView.Adapter<ProductAdapter.ViewHolder>() {
    
    private val requestManager = Glide.with(/* fragment or activity */)
    
    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val imageView: ImageView = view.findViewById(R.id.productImage)
        
        fun bind(product: Product) {
            requestManager
                .load(product.imageUrl)
                .placeholder(R.drawable.placeholder_image)
                .error(R.drawable.error_image)
                .centerCrop()
                .override(300, 300)  // 명시적 크기로 다운샘플링
                .transition(DrawableTransitionOptions.withCrossFade(200))
                .diskCacheStrategy(DiskCacheStrategy.DATA)  // 변환 전 원본만 캐시
                .into(imageView)
        }
    }
    
    override fun onViewRecycled(holder: ViewHolder) {
        super.onViewRecycled(holder)
        // RecyclerView 재사용 시 로딩 취소
        requestManager.clear(holder.imageView)
    }
    
    // RecyclerView 프리패치
    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        super.onAttachedToRecyclerView(recyclerView)
        val preloadSizeProvider = ViewPreloadSizeProvider<Product>()
        val modelPreloader = ProductPreloader(requestManager, preloadSizeProvider)
        RecyclerViewPreloader(requestManager, modelPreloader, preloadSizeProvider, 10)
            .let { recyclerView.addOnScrollListener(it) }
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent)
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {}
    override fun getItemCount() = 0
}

class ProductPreloader(
    private val glide: RequestManager,
    private val preloadSizeProvider: ViewPreloadSizeProvider<Product>,
) : ListPreloader.PreloadModelProvider<Product> {
    override fun getPreloadItems(position: Int) = listOf<Product>()
    override fun getPreloadRequestBuilder(item: Product) =
        glide.load(item.imageUrl).override(300, 300)
}

data class Product(val imageUrl: String)

마무리

이미지 최적화의 80%는 서버에서 올바른 크기와 포맷을 제공하는 것이다. 썸네일은 200x200, 목록은 400x400을 WebP로 제공하면 클라이언트 부담이 크게 줄어든다. prepareForReuse()/onViewRecycled()에서 이미지 로딩을 취소하지 않으면 스크롤 시 이전 이미지가 잠깐 보이는 문제가 생긴다.