아티클 목록으로 가기

Coil 내부 동작 원리: LRU 캐싱, 성능 트레이드오프, 비트맵 샘플링

skydovesJaewoong Eum (skydoves)||23분 소요

Coil 내부 동작 원리: LRU 캐싱, 성능 트레이드오프, 비트맵 샘플링

이미지 로딩은 안드로이드 개발에서 가장 핵심적이면서도 까다로운 영역 중 하나입니다. 네트워크 통신, 메모리 관리, 스레딩, 캐싱, 비트맵 디코딩 등 여러 복잡한 문제가 한꺼번에 얽혀 있기 때문입니다. Glide나 Picasso 같은 라이브러리가 오랫동안 개발자들에게 사랑받아 왔지만, Coil은 처음부터 코루틴을 기반으로 설계된 모던 코틀린 퍼스트(Kotlin-first) 솔루션으로 등장했습니다. Coil의 진정한 강점은 깔끔한 API 너머에 있습니다. 높은 성능과 메모리 효율성을 동시에 달성하는 견고한 내부 설계가 바로 그 핵심입니다. 실제로 Coil은 Kotlin Coroutines, OkHttp, Okio 등 코틀린 생태계의 핵심 라이브러리를 적극적으로 활용하면서도, 안드로이드 플랫폼의 특성을 깊이 이해한 최적화를 곳곳에 적용하고 있습니다.

이 글에서는 Coil의 내부 메커니즘을 깊이 있게 살펴봅니다. 이미지 요청이 인터셉터 체인(interceptor chain)을 통해 어떻게 흘러가는지, 2단계 메모리 캐시가 어떻게 높은 히트율을 유지하면서 메모리 누수를 방지하는지, 비트맵 샘플링이 비트 연산을 활용하여 메모리 사용량을 어떻게 최적화하는지, 그리고 프로덕션 환경에서 안정적으로 동작하기 위한 다양한 최적화 기법까지 폭넓게 다룹니다.

핵심 추상화 이해하기

Coil은 본질적으로 데이터 소스(URL, 파일, 리소스)를 디코딩된 이미지로 변환하여 뷰에 표시하는 이미지 로딩 라이브러리입니다. Coil이 다른 이미지 로더와 차별화되는 점은 두 가지 핵심 원칙을 철저히 따른다는 것입니다. 바로 코루틴 네이티브 설계(coroutine-native design)조합 가능한 인터셉터 아키텍처(composable interceptor architecture) 입니다.

코루틴 네이티브 설계란 Coil의 모든 구성 요소가 suspend 함수를 중심으로 구축되었다는 의미입니다. 이미지 로딩은 본래 구조화된 동시성(structured concurrency) 모델에 적합합니다. 요청에는 생명주기가 있고, 취소될 수 있으며, 스코프를 존중해야 하기 때문입니다. 가령 RecyclerView에서 빠르게 스크롤하면 화면 밖으로 사라진 아이템의 이미지 요청은 자동으로 취소되어야 합니다. 기존의 이미지 로더는 콜백 체인 방식을 사용했기 때문에 이러한 취소 처리를 수동으로 관리해야 했지만, Coil은 코루틴의 구조화된 동시성을 적극 활용하여 이를 자연스럽게 해결합니다.

// 전통적인 콜백 방식
imageLoader.load(url) { bitmap ->
    imageView.setImageBitmap(bitmap)
}

// Coil의 코루틴 방식
val result = imageLoader.execute(
    ImageRequest.Builder(context)
        .data(url)
        .target(imageView)
        .build()
)

조합 가능한 인터셉터 아키텍처란 전체 요청 파이프라인이 OkHttp와 유사한 인터셉터 체인으로 구성된다는 뜻입니다. 각 인터셉터는 요청을 관찰하거나, 변환하거나, 단락 평가(short-circuit)할 수 있습니다. 덕분에 코어 코드를 수정하지 않고도 라이브러리를 확장할 수 있습니다.

이러한 특성은 단순한 편의 기능이 아니라, 효율적인 리소스 관리, 깔끔한 취소 의미론(cancellation semantics), 강력한 커스터마이징을 가능하게 하는 아키텍처적 결정입니다. 이 원칙들이 실제 구현에서 어떻게 구현되는지 살펴보겠습니다.

ImageLoader 인터페이스와 RealImageLoader 구현

ImageLoader 인터페이스를 살펴보면, 두 가지 주요 진입점(entry point)을 정의하고 있습니다.

interface ImageLoader {
    fun enqueue(request: ImageRequest): Disposable
    suspend fun execute(request: ImageRequest): ImageResult
}

같은 작업을 위해 왜 두 가지 메서드가 필요할까요? 이는 안드로이드의 이중적인 특성을 반영합니다. ImageView에 이미지를 바인딩하듯 fire-and-forget 방식으로 이미지를 로딩하는 호출자(enqueue)가 있는 반면, 리포지토리나 컴포저블에서 결과를 기다려야 하는 구조화된 동시성이 필요한 호출자(execute)도 있기 때문입니다. enqueue는 내부적으로 Disposable을 반환하여 요청을 취소할 수 있게 하고, executesuspend 함수로서 코루틴 스코프 내에서 자연스럽게 사용할 수 있습니다.

RealImageLoader 구현체는 통합된 내부 파이프라인으로 두 가지 경우를 모두 처리합니다.

internal class RealImageLoader(
    val options: Options,
) : ImageLoader {
    private val scope = CoroutineScope(options.logger)
    private val systemCallbacks = SystemCallbacks(this)
    private val requestService = RequestService(this, systemCallbacks, options.logger)

    override fun enqueue(request: ImageRequest): Disposable {
        // 메인 스레드에서 요청 실행 시작
        val job = scope.async(options.mainCoroutineContextLazy.value) {
            execute(request, REQUEST_TYPE_ENQUEUE)
        }

        // 뷰에 연결된 현재 요청을 업데이트하고 새로운 Disposable 반환
        return getDisposable(request, job)
    }

    override suspend fun execute(request: ImageRequest): ImageResult {
        if (!needsExecuteOnMainDispatcher(request)) {
            // 빠른 경로: 디스패칭 생략
            return execute(request, REQUEST_TYPE_EXECUTE)
        } else {
            // 느린 경로: 메인 스레드로 디스패치
            return coroutineScope {
                val job = async(options.mainCoroutineContextLazy.value) {
                    execute(request, REQUEST_TYPE_EXECUTE)
                }
                getDisposable(request, job).job.await()
            }
        }
    }
}

execute()의 빠른 경로(fast path) 최적화에 주목하시기 바랍니다. 요청에 메인 스레드 디스패치가 필요 없는 경우(대상 뷰가 없는 경우), 코루틴을 새로 실행하는 오버헤드 없이 즉시 실행됩니다. 이는 리포지토리에서 비트맵만 가져오는 백그라운드 이미지 로딩에서 특히 중요합니다.

scopeSupervisorJob 스코프이므로, 하나의 요청이 실패하더라도 진행 중인 다른 요청이 취소되지 않습니다.

private fun CoroutineScope(logger: Logger?): CoroutineScope {
    val context = SupervisorJob() +
        CoroutineExceptionHandler { _, throwable -> logger?.log(TAG, throwable) }
    return CoroutineScope(context)
}

이러한 격리 덕분에 한 이미지의 네트워크 오류가 현재 로딩 중인 다른 이미지에 영향을 미치지 않습니다. CoroutineExceptionHandler는 잡히지 않은 예외를 크래시 대신 로그로 기록하여, 예상치 못한 오류에도 라이브러리가 견고하게 동작하도록 보장합니다.

요청 실행 파이프라인: 끝까지 인터셉터

Coil 아키텍처의 핵심은 인터셉터 체인입니다. 요청을 실행하면, 실제 데이터 페치(fetch)와 디코드를 수행하는 EngineInterceptor에 도달하기 전까지 일련의 인터셉터를 순서대로 통과합니다.

private suspend fun execute(initialRequest: ImageRequest, type: Int): ImageResult {
    val requestDelegate = requestService.requestDelegate(
        request = initialRequest,
        job = coroutineContext.job,
        findLifecycle = type == REQUEST_TYPE_ENQUEUE,
    ).apply { assertActive() }

    val request = requestService.updateRequest(initialRequest)
    val eventListener = options.eventListenerFactory.create(request)

    try {
        if (request.data == NullRequestData) {
            throw NullRequestDataException()
        }

        requestDelegate.start()

        if (type == REQUEST_TYPE_ENQUEUE) {
            requestDelegate.awaitStarted()
        }

        // 타겟에 플레이스홀더 설정
        val cachedPlaceholder = request.placeholderMemoryCacheKey?.let {
            memoryCache?.get(it)?.image
        }
        request.target?.onStart(placeholder = cachedPlaceholder ?: request.placeholder())
        eventListener.onStart(request)

        // 사이즈 결정
        val sizeResolver = request.sizeResolver
        eventListener.resolveSizeStart(request, sizeResolver)
        val size = sizeResolver.size()
        eventListener.resolveSizeEnd(request, size)

        // 인터셉터 체인 실행
        val result = withContext(request.interceptorCoroutineContext) {
            RealInterceptorChain(
                initialRequest = request,
                interceptors = components.interceptors,
                index = 0,
                request = request,
                size = size,
                eventListener = eventListener,
                isPlaceholderCached = cachedPlaceholder != null,
            ).proceed()
        }

        when (result) {
            is SuccessResult -> onSuccess(result, request.target, eventListener)
            is ErrorResult -> onError(result, request.target, eventListener)
        }
        return result
    } catch (throwable: Throwable) {
        if (throwable is CancellationException) {
            onCancel(request, eventListener)
            throw throwable
        } else {
            val result = ErrorResult(request, throwable)
            onError(result, request.target, eventListener)
            return result
        }
    } finally {
        requestDelegate.complete()
    }
}

이 실행 흐름에는 주목할 만한 여러 측면이 있습니다. 각 단계가 어떤 역할을 하는지 하나씩 살펴보겠습니다.

생명주기 통합: requestDelegate는 요청을 안드로이드의 생명주기에 연결합니다. enqueue 요청의 경우 뷰의 생명주기를 찾아 생명주기가 시작되지 않았다면 일시 중단됩니다. 이를 통해 화면에 보이지 않는 뷰에 대한 불필요한 이미지 로딩을 방지할 수 있습니다.

사이즈 결정: 데이터를 가져오기 전에 Coil은 타겟 사이즈를 먼저 결정합니다. 뷰의 경우 뷰가 측정될 때까지 대기합니다. 결정된 사이즈는 샘플링에서 매우 중요한데, 이미지를 얼마나 다운샘플링할지 결정하기 때문입니다.

이벤트 리스너: eventListener는 요청의 모든 단계를 관찰할 수 있는 훅(hook)을 제공합니다. Coil의 디버깅 도구가 바로 이 메커니즘을 활용하여 로깅 이벤트 리스너를 설치하는 방식으로 동작합니다.

예외 처리: 취소 예외(CancellationException)는 구조화된 동시성을 존중하여 그대로 전파되며, 다른 예외는 catch하여 ErrorResult로 변환됩니다. 네트워크 오류나 디코딩 실패가 앱 크래시로 이어지지 않도록 보장하는 것입니다. 이러한 방어적 예외 처리는 프로덕션 환경에서 안정성을 확보하는 데 필수적입니다. 특히 CancellationException을 별도로 분리 처리하는 패턴은 코루틴 기반 라이브러리 설계에서 모범 사례로 꼽힙니다.

RealInterceptorChain 구현

인터셉터 체인의 구현은 우아하리만치 간결합니다.

internal class RealInterceptorChain(
    val initialRequest: ImageRequest,
    val interceptors: List<Interceptor>,
    val index: Int,
    override val request: ImageRequest,
    override val size: Size,
    val eventListener: EventListener,
    val isPlaceholderCached: Boolean,
) : Interceptor.Chain {

    override suspend fun proceed(): ImageResult {
        val interceptor = interceptors[index]
        val next = copy(index = index + 1)
        val result = interceptor.intercept(next)
        checkRequest(result.request, interceptor)
        return result
    }

    private fun checkRequest(request: ImageRequest, interceptor: Interceptor) {
        check(request.context === initialRequest.context) {
            "Interceptor '$interceptor' cannot modify the request's context."
        }
        check(request.data !== NullRequestData) {
            "Interceptor '$interceptor' cannot set the request's data to null."
        }
        check(request.target === initialRequest.target) {
            "Interceptor '$interceptor' cannot modify the request's target."
        }
        check(request.sizeResolver === initialRequest.sizeResolver) {
            "Interceptor '$interceptor' cannot modify the request's size resolver. " +
                "Use `Interceptor.Chain.withSize` instead."
        }
    }
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기