본문 바로가기
Android/Image Loading

[Android] Coil 이미지 로딩 파이프라인 한 번에 이해하기

by 태크민 2025. 11. 14.

Coil 내부 로직을 기반으로 이미지 로딩 과정이 실제로 어떻게 이루어지는지 개발자 관점에서 깊게 정리해보는 글입니다.

Android 개발에서 이미지 로딩은 앱 성능과 사용자 경험(UX)에 직결되는 요소입니다.


Coil은 Kotlin 기반의 가벼운 구조, Coroutines 최적화, Compose 친화성을 강점으로 갖고 있어 최근 Android 프로젝트에서 가장 많이 사용되는 이미지 로딩 라이브러리 중 하나입니다.

 

이번 글에서는 Coil의 로딩 파이프라인 전체 흐름, 메모리 캐시와 디스크 캐시의 동작 원리, 그리고 Fetcher-Decoder 체인까지 한 번에 이해할 수 있도록 구성했습니다.

 

Coil의 전체 이미지 로딩 플로우

Coil은 아래 흐름을 따라 이미지를 로딩합니다.

ImageRequest
   ↓
Memory Cache → Hit ⇒ 즉시 UI 반영
   ↓ Miss
Disk Cache → Hit ⇒ 디코딩 후 메모리 캐시에 저장
   ↓ Miss
Network/File Fetcher → 다운로드
   ↓
Decoder → Bitmap 디코딩
   ↓
Memory Cache 저장 → UI 렌더링

 

사용자가 “이미지 빠르게 뜬다”고 느끼는 대부분의 경우는 메모리 캐시 Hit 덕분입니다.
디스크 캐시 Hit는 “빠른 편”이지만 한 번의 디코딩 과정이 필요하기 때문에 메모리만큼 즉각적이진 않습니다.

 

Compose에서는 실제로 AsyncImage가 아래와 같은 과정을 따라 동작합니다.

onRemembered → State.Loading

일반적으로 AsyncImage는 내부에서 rememberAsyncImagePainter를 사용해 AsyncImagePainter를 기억합니다.
이때, Composable이 Composition에 올라가는 시점에 onRemembered()가 호출됩니다.

다이어그램의 흐름:

  1. AsyncImage가 Composition에 참여
  2. onRemembered() 호출
  3. painter.state = Loading 으로 변경

즉, 화면에 그려지기 시작하는 순간부터 이미지를 가져오는 비동기 작업이 시작되었다는 의미로 State.Loading으로 전환됩니다.

 

onRemembered에서 ImageLoader.execute()까지

다음 단계는 다이어그램 중앙의 ImageLoader입니다.
onRemembered() 안에서는 ImageLoader.execute(request)가 호출됩니다.

override fun onRemembered() {
    // 실제로는 snapshotFlow, mapLatest 등을 통해 request 변경을 관찰하며
    // 변경될 때마다 imageLoader.execute(...)를 다시 실행하도록 구성되어 있습니다.
    scope.launch {
        snapshotFlow { request }
            .mapLatest { imageLoader.execute(updateRequest(it)).toState() }
            .collect(::updateState)
    }
}

 

여기서 중요한 포인트는 두 가지입니다.

  1. 요청은 항상 ImageLoader를 통해 실행된다.
  2. snapshotFlow + mapLatest 구조라 새로운 요청이 들어오면 이전 요청은 취소된다.

다이어그램에서 Asyncimage → onRemembered → ImageLoader.execute 화살표가 바로 이 흐름을 나타냅니다.

 

ImageLoader 내부 – Components & Fetcher 순회

ImageLoader.execute()가 호출되면, Coil은 내부에 등록된 components를 사용하여 실제로 이미지를 가져올 수 있는 Fetcher를 찾습니다.

 

Coil은 주어진 data 타입에 따라 적절한 Fetcher를 자동으로 선택합니다.

Fetcher의 종류는 다음과 같습니다.

Fetcher  종류
HttpUrlFetcher 네트워크 이미지
FileFetcher 파일 이미지
ResourceUriFetcher drawable 등 리소스
ContentUriFetcher content:// URI
VideoFrameFetcher 영상 프레임 로딩

다이어그램에 있는 “iterate and fetch components”는 다음 로직을 의미합니다.

  1. ImageRequest의 data 타입을 기준으로
  2. 등록된 Fetcher들을 순서대로(iterate) 검사하면서
  3. “내가 처리할 수 있는 타입인가?”를 확인
  4. 처리 가능한 Fetcher가 발견되면 그 Fetcher를 사용해 실제 로딩을 시도

EngineInterceptor – FetchResult → ExecuteResult

Fetcher에서 이미지를 가져오면, 이제 오른쪽의 노란 박스, EngineInterceptor 단계로 넘어갑니다.

 

HttpUrlFetcher와 같은 Fetcher는 FetchResult를 반환합니다.
대부분의 경우 SourceResult 형태이며, 여기에 포함되는 것은:

  • 원본 데이터(소스 스트림, Source)
  • MIME 타입 등 메타 정보
  • 캐시 정책 옵션 등

EngineInterceptor는 이 FetchResult를 기반으로 필요하다면 Decoder를 통해 Bitmap 또는 Drawable로 디코딩하고 캐시 정책에 따라 메모리 캐시 저장 최종적으로 ExecuteResult를 생성합니다.

  • 성공 시 → SuccessResult
  • 실패 시 → ErrorResult (다이어그램에서는 FailureResult로 표현)

즉, 다음과 같이 정리할 수 있습니다.

  • 예외가 발생하지 않고 정상적으로 디코딩까지 완료되면 → SuccessResult
  • 네트워크 오류, 디코딩 실패, 취소 등 문제가 발생하면 → FailureResult

그리고 이 두 결과가 다시 왼쪽 State.Success / State.Failure 상태로 매핑되어
Composable에 반영됩니다.

 

자, 위에서 언급한 내용을 요약해보면 다음과 같습니다.

 

1. AsyncImage가 Compose에 올라가면 onRemembered()가 호출되고 State.Loading으로 전환됩니다.

2. 이 시점부터 ImageLoader.execute(request)가 실행됩니다.

3. ImageLoader는 등록된 components(Fetcher 목록)를 순회하면서 현재 data를 처리할 수 있는 Fetcher를 찾습니다.
(예: URL이면 HttpUrlFetcher)

4. 선택된 Fetcher가 실제로 네트워크/파일/리소스에서 데이터를 읽어 FetchResult(SourceResult)를 반환합니다.

5. 이 결과는 EngineInterceptor로 전달되어 다음 과정을 거칩니다.

  • 디코딩
  • 캐싱 처리
  • ExecuteResult 생성 

6.  최종적으로:

  • 성공 → SuccessResult → State.Success로 매핑 → 이미지가 UI에 표시
  • 실패 → FailureResult → State.Failure로 매핑 → 에러 상태 UI에 반영

 

그럼 이제 무수한 Fetcher중에 최우선으로 불리는 HttpUriFetcher를 중심으로 상세한 flow를 살펴보겠습니다.

 

AsyncImage가 composition된 이후부터 최종적으로 이미지가 나오기까지의 과정을 6단계로 정리했습니다. 젅체적인 이미지 로딩 프로세스와 동일하므로 위에서 설명한 내용과 동일한 내용이 반복될 수 있습니다.

 

1. AsyncImage → onRemembered() 

Compose에서 AsyncImage가 화면에 그려지기 시작하면, 내부적으로 AsyncImagePainter가 생성됩니다.
이때 Composable이 Composition에 올라가는 순간 onRemembered()가 호출됩니다.

이 단계에서:

  • 이미지 요청 준비
  • 내부 상태(state)를 State.Loading으로 설정
  • ImageLoader에 요청을 전달

다이어그램 왼쪽의 파란 박스 State.Loading이 바로 이 상태를 나타냅니다.

 

2. ImageLoader.execute() 

onRemembered()가 끝나면 다음 단계로 ImageLoader.execute(request)가 호출됩니다.

ImageLoader는 현재 data가 URL이므로 적절한 Fetcher, 즉 HttpUrlFetcher를 선택합니다.

AsyncImage → execute → HttpUriFetcher

 

이제 HttpUriFetcher가 실제로 캐시를 확인하고, 필요하면 네트워크 요청을 수행하는 본격적인 로딩 과정이 시작됩니다.

 

3. readFromDiskCache() – 디스크 캐시 확인 

HttpUriFetcher가 가장 먼저 수행하는 작업은 디스크 캐시 조회입니다.

snapshot = readFromDiskCache(diskCacheKey)

 

이때 Coil은 아래 조건을 자동으로 확인합니다.

  • 디스크 캐시 정책이 readEnabled인가?
  • diskCacheKey(URL 기반)의 캐시 파일이 존재하는가?

다이어그램에서 ③번 단계는 다음과 같이 분기됩니다.

  • Cache Exists? → YES → 디스크 캐시에서 로드
  • Cache Exists? → NO → 네트워크 요청으로 전환

4. 디스크 캐시 없음 → 네트워크 요청 → writeToDiskCache() 

디스크 캐시에 파일이 없는 경우(N):

  1. HttpUriFetcher는 URL로 네트워크 요청을 보냅니다.
    (다이어그램 오른쪽의 지구본 아이콘)
  2. 네트워크로 받은 Response Body는 이미지 바이너리 데이터입니다.
  3. 이 데이터를 디스크 캐시에 저장하기 위해writeToDiskCache()가 실행됩니다.
writeToDiskCache(snapshot, request, response)

 

다이어그램의 ④번은 바로 이 저장 과정입니다:

  • 네트워크에서 받은 이미지 → 디스크 캐시에 SAVE

이 작업 덕분에 앱을 다시 켜거나 화면을 다시 방문할 때, 네트워크를 거치지 않고 디스크 캐시에서 빠르게 로딩할 수 있습니다.

 

5. 디스크 캐시 / 네트워크 → SourceResult 생성 

디스크 캐시에서 읽었든, 네트워크에서 다운로드했든, 최종적으로 이미지 데이터는 SourceResult라는 형태로 변환됩니다.

SourceResult는 다음 정보를 담고 있습니다:

  • 이미지 데이터 Source(Okio)
  • MIME 타입
  • 캐시 정보
  • 디코더가 사용할 다양한 옵션들

다이어그램에서는 ⑤번 단계를 통해: 다음과 같이 분기가 됩니다.

DiskCache LOAD → SourceResult
Network LOAD → SourceResult

 

즉, 결과적으로 캐시 Hit이든 Miss든 SourceResult 형태로 통합됩니다.

 

(6) SourceResult → SuccessResult → State.Success

ImageLoader는 SourceResult를 받아 다음 단계에서 디코더(예: BitmapFactory, ImageDecoder)를 사용해
실제 Android Drawable/Bitmap으로 변환합니다.

변환이 성공하면:

  • SuccessResult 생성
  • AsyncImagePainter가 State.Success로 업데이트
  • Composable에 이미지 표시

다이어그램 왼쪽의 초록색 State.Success 박스가 바로 이 시점입니다.

 

메모리 캐시 부터 읽지 않고 왜 디스크 캐시 부터 읽지?

Coil의 전체 로딩 파이프라인은 항상 메모리 캐시를 가장 먼저 확인합니다. 이는 EngineInterceptor 단계에서 이루어지며, 이 시점에 메모리 캐시에서 Bitmap 또는 Drawable이 존재하면 바로 SuccessResult로 반환되기 때문에 Fetcher 단계까지 진행할 필요가 없습니다.

즉, HttpUriFetcher가 호출되었다는 것 자체가 이미 “메모리 캐시에는 해당 이미지가 존재하지 않았다(Memory Cache Miss)”는 뜻입니다.

 

그렇기 때문에 HttpUriFetcher 내부에서는 굳이 메모리 캐시를 다시 확인하지 않습니다. Fetcher는 본래 “raw source를 가져오는 역할”만을 담당하기 때문입니다. 메모리 캐시는 디코딩된 Bitmap/Drawable을 LRU 방식으로 관리하지만, Fetcher 단계에서는 디코딩 이전 단계인 원본 이미지 소스만을 처리합니다.

 

또한 메모리 캐시와 디스크 캐시는 저장하는 데이터의 형태도 다릅니다. 메모리 캐시는 디코딩된 Bitmap/Drawable을 보관하지만, 디스크 캐시는 압축된 이미지 파일(JPG/PNG/WebP 등)의 원본 바이너리 데이터를 저장합니다. 따라서 Fetcher는 네트워크나 파일 시스템에서 이 원본 데이터를 읽어들이는 역할을 하고, 디코딩 이후의 결과물은 EngineInterceptor가 메모리 캐시에 넣어 관리하도록 설계되어 있습니다.

 

전체 로딩 파이프라인 흐름은 아래와 같습니다.

AsyncImage
   ↓
onRemembered
   ↓
ImageLoader.execute()
   ↓
[EngineInterceptor]
   ├─ MemoryCache.load()   ←★★ 여기가 1번
   │      ├ Hit → Success
   │      └ Miss
   ↓
[Fetcher 단계]
   ├─ DiskCache.read()     ←★★ 여기가 2번
   └─ Network.fetch()

 


Coil 캐싱 디버깅 해보기

Coil은 기본적으로 메모리 캐시와 디스크 캐시가 모두 활성화된 상태로 동작하기 때문에, 별도 설정 없이도 이미지가 자동으로 캐싱됩니다.
하지만 실제로 이미지가 어떤 방식으로 로딩되고 있는지—예를 들어 “메모리 캐시 Hit인지”, “디스크 캐시에서 불러온 것인지”, “네트워크 요청이 발생한 것인지”를 명확하게 확인하고 싶다면 Coil의 DebugLogger 기능을 활용할 수 있습니다.

Coil은 DebugLogger를 ImageLoader에 설정해두면 모든 이미지 요청에 대해 상세한 로그를 출력해줍니다.
이 로그에는 요청이 어떤 경로를 통해 처리되었는지에 따라 이모지와 함께 구분된 태그가 찍히기 때문에, 캐싱이 의도대로 동작하는지를 확인하는 데 매우 유용합니다.

override fun newImageLoader(): ImageLoader {
    return ImageLoader.Builder(this)
        .diskCache {
            DiskCache.Builder()
                .directory(cacheDir.resolve("image_cache"))
                .maxSizeBytes(10 * 1024 * 1024)
                .build()
        }
        .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
        .respectCacheHeaders(false)
        .build()
}

 

단, 주의해야 할 점이 하나 있습니다.
DebugLogger를 활성화하면 이미지 로딩 시마다 상세 로그를 찍기 때문에 성능이 저하될 수 있습니다.
따라서 DebugLogger는 반드시 개발용·디버그 빌드에서만 사용하고, 실제 서비스(릴리즈 빌드)에서는 비활성화하는 것이 좋습니다.

 


마무리

Coil은 단순한 이미지 로딩 라이브러리처럼 보이지만, 내부적으로는 메모리 캐시·디스크 캐시·Fetcher·Decoder·Interceptor가 유기적으로 연결된 꽤 정교한 구조를 가지고 있습니다. 특히 DebugLogger를 활용하면 이미지가 어떤 경로를 통해 로딩되고 있는지, 캐시가 의도대로 동작하고 있는지를 눈으로 직접 확인할 수 있어 실제 개발 과정에서 큰 도움이 됩니다.

이미지 로딩은 사용자 경험과 성능에 직결되는 만큼, 캐싱 동작을 정확히 이해하고 상황에 맞게 활용하는 것이 중요합니다.
앞으로 프로젝트에서 이미지 로딩 최적화가 필요할 때, 이번 글에서 다뤘던 플로우와 디버깅 방법이 좋은 기준점이 되었으면 합니다.

 

 

끝.


참고자료

https://medium.com/@csh153/jetpack-compose-coil-%EC%BA%90%EC%8B%B1-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%98%EA%B3%A0-%EC%9E%88%EC%9D%84%EA%B9%8C%EC%9A%94-%EB%94%94%EC%8A%A4%ED%81%AC-%EC%BA%90%EC%8B%B1-b6595c05bd5a

 

Jetpack Compose Coil 캐싱, 어떻게 하고 있을까요?- 디스크 캐싱

저번에는 Coil의 캐싱 사용법에 대해서 정리를 먼저 했었는데, 사용하는 입장에서 Coil이 이미지 캐싱을 잘 하는지, 그리고 어떻게 하는지가 궁금했습니다.

medium.com

https://medium.com/@csh153/jetpack-compose-coil-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%BA%90%EC%8B%B1-%EC%9E%98-%ED%95%98%EA%B3%A0-%EA%B3%84%EC%8B%A0%EA%B0%80%EC%9A%94-806252d9c73a

 

Jetpack Compose Coil 이미지 캐싱, 잘 하고 계신가요?

Android View에 이미지 라이브러리 Glide가 있다면 Jetpack Compose에는 Coil이 있습니다.

medium.com