이 글은 누구를 위한 것인가
- 이미지가 많은 앱에서 스크롤이 끊기는 문제를 겪는 팀
- 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()에서 이미지 로딩을 취소하지 않으면 스크롤 시 이전 이미지가 잠깐 보이는 문제가 생긴다.