티스토리 뷰
Coil
개요
이미지 로딩 라이브러리를 사용하는 이유는 무엇일까? 안드로이드에서 이미지를 직접 처리하면 다음과 같은 문제가 발생한다.
네트워크 이미지 로딩
안드로이드의 UI는 단일 스레드이다. 이 스레드에서 네트워크 요청을 처리하면 그 시간만큼 메인스레드는 블로킹 당하기 때문에 ANR이 발생할 수 있다. 따라서 반드시 백그라운드 스레드에서 비동기 처리가 필요하다.
OOM(Out Of Memory Error) 발생
안드로이드의 메모리 용량은 한계가 있다. 이 메모리에 여러 이미지 비트맵이 압축 해제된 상태로 메모리에 적재되면 메모리의 한계를 초과하게 된다.
캐싱 구현
안드로이드는 기본적으로 네트워크, 비트맵, 디스크 등의 캐싱을 제공하지 않는다. 이렇게 되면 이미지가 필요할 때마다 매번 다운로드와 디코딩이 필요하고, 성능 저하와 배터리 소모량을 증가시키는 문제가 발생한다.
생명주기 관리
Activity에서 이미지 로딩을 시작했다고 가정하자. 로딩이 진행되는 동안 화면 회전이나 화면을 나가게 되면 Activity는 파괴된다. 로딩이 완료되고 파괴된 View에 접근하기 때문에 메모리 릭과 앱이 크래시가 나는 문제가 발생한다.
다음과 같은 문제들을 간단하게 해결하기 위해 이미지 라이브러리를 사용한다. 라이브러리의 종류는 여러 개가 있지만 Coil부터 알아보려 한다.
구조
ImageLoader
ImageLoader는 ImageRequest를 실행하는 서비스 객체로 캐싱, 데이터 가져오기, 이미지 디코딩, 네트워크 요청 관리, 메모리 관리 등을 처리한다.
ImageLoader는 생성 비용이 크고, 캐싱된 데이터를 공유하기 위해 앱 전역에서 싱글톤 객체로 사용해야한다.
class CustomApplication : SingletonImageLoader.Factory {
override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(context)
.crossfade(true)
.build()
}
}
사용할 때는 다음과 같이 사용한다.
val imageLoader = context.imageLoader
내부 구현은 아래와 같이 되어 있기 때문에 싱글톤 객체임을 보장할 수 있다.
inline val Context.imageLoader: ImageLoader
get() = SingletonImageLoader.get(this)
object SingletonImageLoader {
private val reference = atomic<Any?>(null)
/**
* Get the singleton [ImageLoader].
*/
@JvmStatic
fun get(context: PlatformContext): ImageLoader {
return (reference.value as? ImageLoader) ?: newImageLoader(context)
}
.
.
}
ImageRequest
ImageRequest는 ImageLoader가 이미지를 로드하는데 필요한 정보를 제공한다.
아래와 같은 방식으로 ImageRequest 객체를 생성하여 ImageLoader에게 전달한다.
val request = ImageRequest.Builder(context)
.data("https://example.com/image.jpg")
.crossfade(true)
.target(imageView)
.build()
imageLoader.enqueue(request)
imageLoader.execute(request)
preload가 필요할 때는 execute() 즉시 UI에 그려야 한다면 비동기 함수 enqueue()를 사용한다.
enqueue()를 즉시 UI를 그릴 때 사용하는 이유는 반환 타입이 Disposable이기 때문이다. 예를 들어 이미지를 요청중인 화면이 사라지거나, RecyclerView에 의해 스크롤되어 이미지가 필요 없게 되는 경우가 발생했을 때 생명주기에 맞춰 Disposable은 이미지 요청을 취소시켜준다.
execute()를 preload에서 사용하는 이유는 ImageResult 타입을 반환하여 preload의 성공 여부에 따라 캐시에 적재할 수 있게 해주기 때문이다.
lifecycleScope에서 execute()를 사용하는 것과, 단순히 enqueue()를 사용하는 것의 차이에 대해 의문이 생겼다.이미지 요청을 생명주기에 맞춰 취소하기 위해 enqueue()를 사용한다고 하는데, lifecycleScope 또한 생명주기에 맞춰 코루틴을 취소하므로 두 방식의 차이가 무엇인지 혼동되었다.
차이는 생명주기의 기준 단위에 있었다. lifecycleScope는 Fragment 또는 Activity 화면 단위의 생명주기를 기준으로 코루틴을 취소한다. 반면, 이미지 로딩에서 실제로 중요한 것은 화면 전체가 아니라 개별 View 단위의 생명주기였다.
예를 들어 RecyclerView에서 스크롤이 발생하면 특정 ViewHolder는 화면에서 사라지며 재사용된다. 이 시점에서 해당 View에 바인딩된 이미지는 더 이상 필요하지 않지만, Fragment나 Activity는 여전히 살아 있기 때문에 lifecycleScope에서 실행된 execute() 기반 이미지 로딩은 계속 진행된다.
이로 인해 이미 필요 없어진 이미지에 대한 네트워크 요청이나 디코딩 작업이 불필요하게 수행될 수 있다. 이러한 비효율을 방지하기 위해 enqueue()는 View와 요청을 직접 연결하고, View가 재사용되거나 분리되는 시점에 자동으로 이미지 요청을 취소하도록 설계되었다.
따라서 UI에 즉시 그려지는 이미지 로딩에서는 View 단위 취소가 가능한 enqueue()가 적합하며, execute()는 preload나 백그라운드 캐시 워밍과 같이 결과를 기다려야 하는 경우에 사용하는 것이 맞다.
https://coil-kt.github.io/coil/api/coil-core/coil3/-image-loader/enqueue.html
https://coil-kt.github.io/coil/api/coil-core/coil3/-image-loader/execute.html
Interceptor
구조적으로는 OkHttp Interceptor 패턴과 동일하며, 이미지 로딩에는 다음과 같은 단계가 필요하다.
- 메모리 캐시에 있는지
- 디스크 캐시에 있는지
- 네트워크에서 가져와야하는지
- 디코딩이 필요한지
- 변환이 필요한지
이 모든 과정을 하나의 함수로 만든다면 확장이 불가하다. 따라서 Coil은 각 단계를 독립적인 Interceptor로 분리했다.
기본 인터페이스는 다음과 같다.
fun interface Interceptor {
suspend fun intercept(chain: Chain): ImageResult
interface Chain {
val request: ImageRequest
val size: Size
/**
* Copy the current [Chain] and replace [request].
*
* @param request The current image request.
*/
fun withRequest(request: ImageRequest): Chain
/**
* Copy the current [Chain] and replace [size].
*
* Use this method to replace the resolved size for this image request.
*
* @param size The requested size for the image.
*/
fun withSize(size: Size): Chain
/**
* Continue executing the chain.
*/
suspend fun proceed(): ImageResult
}
}
여기서 Chain의 역할은 다음 Interceptor로 요청 전달, 요청 수정, 결과 반환의 역할을 한다.
체인 순서
MemoryCacheInterceptor → DiskCacheInterceptor → EngineInterceptor
실제 구현 상 Fetch/Decode는 EngineInterceptor 내부에서 처리된다고 한다.
- MemoryCacheInterceptor
요청 도착 → 메모리 캐시 키 생성 → 캐시 hit?├─ YES → ImageResult.Success 반환 (체인 종료)└─ NO → 다음 Interceptor로 진행
메모리 캐시를 조회하고, Bitmap/Image를 재활용한다.
- DiskCacheInterceptor디스크 캐시를 조회하고, 앱 재시작 후에도 유지한다.
디스크 캐시 hit?├─ YES → 디코딩 후 반환└─ NO → 다음 단계- EngineInterceptor실제 이미지를 로딩하는 엔진이며 Fetch와 Decode 모두를 담당한다.
Decoder:Fetcher가 전달한ImageSource를 기반으로 데이터를 해석하여 화면에 표시가 가능한Image객체로 변환한다. 이 인터페이스를 통해 GIF, SVG, TIFF 등의 디코딩 로직을 확장 시킬 수 있다. - 이 단계에서 네트워크 요청, 파일 IO, 디코딩, OOM 방지 로직이 모두 처리된다.
Fetcher: URL, URI, 파일 등의 데이터를 받아 이후 단계인Decoder가 사용할 수 있는ImageSource또는Image타입으로 변환한다. 이Image타입은 아직 디코딩 전이므로 화면에 그릴 수 없다.Fetcher 선택 → 데이터 스트림 획득 → Decoder 선택 → Bitmap/Image 디코딩 → 변환 적용 → 캐시 저장- Custom Interceptor
다음 예제 코드와 같이 Interceptor를 커스텀해서 사용할 수 있다.val imageLoader = ImageLoader.Builder(context) .components { add(CustomInterceptor()) } .build()
class CustomInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
val newData = when (val data = request.data) { is String -> "$data?width=300" else -> data }
val newRequest = request.newBuilder()
.data(newData)
.build()
return chain.proceed(newRequest)
}
}
'안드로이드' 카테고리의 다른 글
| XML VS Jetpack Compose (0) | 2026.01.01 |
|---|---|
| LifeCycle Of View (0) | 2025.12.09 |
| Hilt, Metro의 DI 그래프 생성, 등록, 주입 차이 (0) | 2025.12.08 |
| Metro로 ViewModel 생성하기 (0) | 2025.11.06 |
| Lottie (0) | 2025.11.05 |