[Android] Kerdy 서비스의 이미지 로딩 성능 개선 (feat. Glide DeepDive)

부나·2023년 10월 18일
10

안드로이드

목록 보기
1/12
post-thumbnail

Kerdy 서비스 이미지 로딩 문제점

Kerdy(커디)는 사용자에게 행사 정보를 제공하는 서비스이다.
행사 정보에는 썸네일 이미지가 포함되어 있다.

기존 방식은 RecyclerView의 onBindViewHolder() 메서드가 호출될 때 (= 필요한 시점에) 제공되는 url을 바탕으로 원격지 어딘가로부터 이미지를 불러온다.

하지만 이러한 방식은 사용자에게 이미지를 빠르게 제공하지 못한다.
이미지를 불러오는 동안 PlaceHolder 화면 (회색 이미지) 이 길어지고, 사용자는 원하는 이미지를 바로 확인할 수 없다.
실제 사용자 피드백 중에, 이미지 로딩 시간 을 개선하면 좋겠다는 피드백을 받기도 하였다.

결국 이미지 로딩 개선의 필요성을 느끼게 되었고, GlidePreload 기능을 이용하여 이미지를 미리 불러오는 작업을 진행하였다.

성능 개선을 위한 Glide 사전 지식

작성하는 코드는 몇 줄 되지 않지만, 이 짧은 코드에 캐싱 이라는 비밀이 숨겨져 있다.

Glide는 WeakReference , MemoryLRUCache , DiskLRUCache 와 같은 다양한 캐시를 제공한다.
뿐만 아니라, Memory , Disk 캐싱 여부를 옵션으로 선택할 수도 있다.
아래에서 위의 개념에 대해 조금 더 자세히 살펴보자.

Preload란?

이미지가 필요할 때 원격지에서 불러오면 네트워크 통신 시간 이 소요되기 때문에, 사용자 입장에서는 불필요한 지연 시간 만 늘어날 뿐이다.
따라서 이미지가 필요한 시점에 불러오는 것이 아니라, Preload(미리 불러오기) 하여 미리 준비시켜 놓을 필요가 있다.

Preload : 이미지를 미리 불러와서 캐싱하는 방식이다.

Glide 이미지 캐싱 방식

Glide는 다양한 이미지 캐싱 방식을 제공한다.
크게 2가지 방식으로 분류할 수 있다.

Disk 캐싱 : 디스크에 이미지를 저장하고 필요한 시점에 재사용한다.
Memory 캐싱 : 메모리에 이미지를 저장하고 필요한 시점에 재사용한다.

Disk 캐싱 방식

원본 이미지 를 캐싱할지 vs 디코딩된 이미지 를 저장할지 결정할 수 있다.
이를 Disk 캐싱 전략 이라고 부르며, Glide에서 제공하는 옵션은 크게 5가지이다.

(Glide 버전에 따라 이름이 상이하며, 해당 포스팅은 Glide 4.x 기준입니다.)

ALL : 원본 이미지, 디코딩된 이미지를 모두 캐싱한다
DATA : 원본 이미지를 캐싱한다.
RESOURCE : 디코딩된 이미지를 캐싱한다.
AUTOMATIC : 상황에 따른 최적의 전략을 사용하여 캐싱한다. (Default)
NONE : 디스크 캐싱을 하지 않는다.


AUTOMATIC에 대한 부가적인 설명

이 중에서, AUTOMATIC 옵션의 최적의 전략 이라는 의미는 매우 추상적이다.
이 문장에서 의미하는 상황 은 이미지의 출처 ( Local vs Remote ) 여부에 따라 결정된다.

Remote (원격지) 로부터 이미지를 가져와야 하는 상황에는 네트워크 비용 이라는 오버헤드가 추가된다.
이러한 상황에는 DATA 전략을 따라, 원본 이미지를 캐싱해두고 필요할 때마다 전처리를 해주는 것이 저렴하다.

반면, Local 로부터 이미지를 가져오는 상황도 존재한다.
예를 들어, Drawable과 같은 이미지들은 RESOURCE 전략을 따라, 원본 이미지를 로컬 저장소에서 매번 가져오는 것보다 한 번 디코딩하여 캐싱해두고 사용하는 것이 저렴하다.

Memory 캐싱 방식

Memory 캐싱 방식은 크게 2가지 이다.

WeakReference : 매번 GC 의 대상이 되어 메모리에 오래 유지되지 못한다.
MemoryLRUCache : LRU 방식 으로 메모리에 캐싱되어 있으며, 메모리 부족 상황에만 GC 의 대상이 된다.

LRU(Least Recently Used) : 고정 크기 내에서 용량이 가득차게 되면 가장 최근에 사용되지 않은 데이터 를 선별하여 제거하는 방식이다.
주로 OS에서 페이지를 관리 하기 위한 기법으로 사용된다.

내부적으로 메모리 캐싱은 LRU 기능을 제공하는 LinkedHashMap을 사용하여 구현되어 있다.

당연하게도, Memory에 캐싱이 되어 있다면 Disk에서 이미지를 가져오는 것보다 훨씬 빠르다.

다만, 언제 GC 에 의해 수거될지 알 수 없을 뿐더러 그만큼 메인 메모리를 차지하고 있다는 사실을 알아야 한다.

Glide.with(this)
    ...
    .skipMemoryCache(false) // Not caching in memory.
    ...

or

Glide.with(this)
    ...
    .onlyRetrieveFromCache(true) // Only get image from memory.
    ...

필요하다면 RequestBuilderskipMemoryCache(Boolean) 메서드에 false를 전달하여 메모리 캐싱을 하지 않을 수도 있다.
동남아 기기와 같이 메모리가 극도로 적은 경우 메모리에 캐싱함으로써 성능을 오히려 악화시킬 수 있기 때문이다.

또는 캐시에서만 이미지를 불러오도록 설정할 수도 있다.

캐시 R/W 흐름

위에서 언급한 것처럼, Memory에 접근하는 것이 Disk에 접근하는 것보다 더 빠르다.

따라서 Glide는 가능한 가까운 위치 부터 탐색을 진행한다.

정리하자면 아래와 같은 순서이다.

Read : WeakReference → MemoryLRUCache → DiskLRUCache → Remote
Write : Remote → DiskLRUCache → MemoryLRUCache → WeakReference

Tip. 읽기는 쓰기의 역순이다.

RequestListener

Glide는 이미지를 어딘가로부터 불러오고, 전처리까지 마치면 RequestListener 를 통해 그 사실을 알려준다.

RequestListener는 2가지 메서드를 제공한다.

onLoadFailed : 이미지 로딩에 실패 했음을 알려준다.
onResourceReady : 이미지 로딩에 성공 했음을 알려준다.

특히, onResourceReady 는 인자로 DataSource 를 제공하여 어디에서 이미지를 가져왔는지 알 수 있게 해준다.

DataSource 는 5개의 상수로 이루어진 enum class이다.

LOCAL : 로컬 저장소로부터 불러온 이미지
REMOTE : 원격지로부터 네트워크 통신을 하여 불러온 이미지
DATA_DISK_CACHE : 디스크로부터 불러온 원본 이미지
RESOURCE_DISK_CACHE : 디스크로부터 불러온 전처리된 이미지
MEMORY_CACHE : 메인 메모리로부터 불러온 이미지


Preload를 사용하여 이미지 캐싱

Preload 구현

이제부터는 이미지를 미리 불러와서 디스크와 메모리에 캐싱해두는 기능을 구현해볼 것이다.

class ConferenceRecyclerViewAdapter(
    private val onClickConference: (Event) -> Unit,
) : ListAdapter<Event, ConferenceViewHolder>(EventDiffUtil) {
    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int,
    ): ConferenceViewHolder = ConferenceViewHolder(parent, onClickConference)

    override fun getItemCount(): Int = currentList.size

    override fun onBindViewHolder(holder: ConferenceViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}

해당 코드는 도입부에서 설명한 행사 정보 목록을 제공하기 위한 RecyclerView Adapter이다.
onBindViewHolder() 는 현재 position의 item을 가져와서 바인딩한다.

즉, 필요한 시점마다 그때 그때 이미지를 불러오는 구조이다.


override fun onBindViewHolder(holder: ConferenceViewHolder, position: Int) {
    holder.bind(getItem(position))
    preload(holder.itemView.context, position)
}

private fun preload(context: Context, currentPosition: Int) {
    val endPosition = (currentPosition + PRELOAD_SIZE).coerceAtMost(currentList.size - 1)

    currentList
        .subList(currentPosition, endPosition)
        .forEach { event -> preload(context, event.posterUrl) }
}

private fun preload(context: Context, url: String?) {
    Glide.with(context)
        .load(url)
        .preload()
}

companion object {
    private const val PRELOAD_SIZE = 8
}

만약 Glide의 Preload 기능을 사용한다면, 현재 item보다 앞선 데이터들의 이미지를 미리 불러올 수 있다.

위 코드에서는 PRELOAD_SIZE 를 8로 지정하여, 8개의 이미지를 미리 불러와 Disk에 이미지 원본을 캐싱하고 있다.

단순해보이는 코드이지만, 주의해야 하는 부분이 있다.
DiskCacheStrategyRESOURCE 로 지정하면 캐싱된 데이터를 전혀 활용하지 못한다.

원격지로부터 불러온 데이터를 전처리하여 저장해두면, 전처리된 이미지와 ImageView의 크기가 다르기 때문에 캐싱 Key가 다르고, 결국 필요한 시점에 또 다시 원격지에서 이미지를 불러오게 된다.

Tip. Glide는 width, height, Transformation, Signature 등 다양한 Key를 통해 이미지를 캐싱한다.


@BindingAdapter("app:imageUrl")
fun ImageView.setImage(imageUrl: String?) {
    Glide.with(this)
        .load(imageUrl)
        .placeholder(R.drawable.img_all_loading)
        .error(R.mipmap.ic_launcher)
        .fallback(R.mipmap.ic_launcher)
        .listener(object : RequestListener<Drawable> {
            override fun onLoadFailed(
                e: GlideException?,
                model: Any?,
                target: Target<Drawable>?,
                isFirstResource: Boolean,
            ): Boolean {
                return false
            }

            override fun onResourceReady(
                resource: Drawable?,
                model: Any?,
                target: Target<Drawable>?,
                dataSource: DataSource?,
                isFirstResource: Boolean,
            ): Boolean {
                Log.d("buna", "from : $dataSource")
                return false
            }
        })
        .into(this)
}

실험을 위해 RequestListener를 통해 어디에서 이미지를 가져오는지 확인해보자.
위 코드는 Kerdy에서 이미지를 불러오는 기능을 BindingAdapter와 함께 작성한 코드다.
onResourceReady() 메서드에서 데이터의 출처를 로깅 하도록 하였다.


// ConferenceRecyclerViewAdapter.kt

private fun preload(context: Context, url: String?) {
    Glide.with(context)
        .load(url)
        .diskCacheStrategy(DiskCacheStrategy.RESOURCE) // Cache decoded image
        .preload()
}

이제 Preload 할 때 디스크 캐싱 전략을 RESOURCE 로 지정하고 실행해보면 아래와 같은 결과를 확인할 수 있다.

분명 Preload를 하였다면 이미지가 디스크에 캐싱되어 있어야 하지만, DATA_DISK_CACHE 가 아닌 REMOTE 가 출력되고 있다.

그 이유는, Preload 시점에 디코딩된 이미지는 당시 옵션들을 바탕으로 캐싱 Key를 가지기 때문에 원하는 조건의 이미지는 디스크 또는 메모리에서 찾을 수 없는 것이다.

‼️ 결론은 원본 이미지를 디스크/메모리 캐싱 해두어야 한다는 의미이다.

Tip. 원격 이미지를 원본으로 캐싱하는 전략은 ALL , DATA , AUTOMATIC 3가지이다.

원하는 결과를 얻기 위해, 원래대로 코드를 롤백하고 데이터 삭제를 한 뒤에 다시 앱을 실행시켜 보자.

최초 이미지는 Preload 하지 못하기 때문에 REMOTE에서 가져오는 것을 제외하면, 나머지 이미지는 모두 디스크에서 원본 데이터를 가져오고 있다.

로컬 저장소를 확인해보면, 원본 이미지와 디코딩된 이미지 캐시 키가 SHA-256 방식으로 암호화되어 저장되는 것까지 확인할 수 있다.

Decoding 문제점

그러나 이 방법에는 큰 문제점이 하나 있다.
위 사진에서는 DATA_DISK_CACHE 에서 이미지를 불러와 이미지뷰의 크기에 맞추어 디코딩을 하기 시작한다.
얼핏 보면 디스크에 이미지를 캐싱하기 때문에 빠르다고 생각할 수 있지만 실상 그렇지 않다.

Profiler로 확인해본 결과 다음과 같은 결과를 띈다.

<Preload 적용 전>

<Preload 적용 후>

오히려 Glide의 디코딩 쓰레드 작업량이 비정상적으로 높아져 스크롤할 때 엄청난 버벅임이 발생한다.
원인은 Preload 할 때와 실제로 ImageView 보여줄 때의 캐시 키 값이 다르기 때문이었다.

위에서 설명했듯 Glide는 Width, Height까지도 캐시 요소로 반영되기 때문이다.
따라서 preload에 ImageView와 동일한 형식을 맞추어주지 않으면 불필요한 오버헤드가 발생한다.

  1. Preload할 때 디코딩 수행
  2. 실제 ImageView에 불러올 때 키가 일치하지 않아 또 다시 원본 이미지 디코딩 수행

이를 해결하기 위해, Glide.preload()를 호출할 때 다음과 같이 이미지뷰 형식에 맞추어 너비, 높이, RoundedCorners 등 캐시 키와 일치하게 로드하면 화면이 버벅이거나 하는 문제를 해결하면서 미리 불러올 수 있다.

// ConferenceRecyclerViewAdapter.kt

private fun preload(context: Context, url: String?) {
    Glide.with(context)
        .load(url)
        .override(imageView.width, imageView.height)
	    .transform(CenterCrop(), RoundedCorners(15.dp))
        .preload()
}

이후에는 RequestListener를 설정했을 때 Disk가 아니라 Memory에서 사전 디코딩된 이미지를 바로 불러와 로딩 속도를 개선할 수 있게 된다.

Preload 처리를 한 결과, 이제는 처음에 보이는 PlaceHolder 가 아예 보이지 않을 정도로 지연이 줄어들었다.
로그로만 확인하면 Preload 전/후를 정확히 파악하기 어렵기 때문에 영상을 찍어 GIF 로 나타내어보았다.
GIF에는 담기지 않았지만 아래로 스크롤 할 수록 Preload 차이가 극명하다.

조금 더 개선된 Preload 구현

조금 더 자세히 알아보니 Glide는 RecyclerViewPreloader를 제공한다.

이름 그대로 미리 불러오는 기능 뿐만 아니라, 스크롤을 감지하여 위로 스크롤하는 경우에도 아래 배치된 이미지들을 불러왔던 문제점을 해결할 수 있으며, Paging과 유용하게 상호 작용할 수 있다.

// build.gradle.kts

implementation ("com.github.bumptech.glide:recyclerview-integration:4.14.2") {
  // Excludes the support library because it's already included by Glide.
  transitive = false
}

Glide 외에도 recyclerview-intergartion 의존성을 추가로 작성해야 한다.

// MyRecyclerViewAdapter.kt

class MyRecyclerViewAdapter(
    private val fragment: Fragment,
    private val onPreloaderReady: (preloader: RecyclerViewPreloader<Event>) -> Unit,
) : ListAdapter<Event, EventViewHolder>(EventDiffUtil), PreloadModelProvider<Event> { 
	private var isFirstPreloader: Boolean = true
    private val requestManager: RequestManager = Glide.with(fragment)
    
    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int,
    ): EventViewHolder = EventViewHolder(
        parent = parent,
        onClickConference = onClickConference,
        onEventPosterPreDraw = { view ->
            if (isFirstPreloader) {
                isFirstPreloader = false
                val preloader = RecyclerViewPreloader(
                    fragment,
                    this,
                    ViewPreloadSizeProvider(view),
                    MAX_PRELOAD_SIZE,
                )
                onPreloaderReady(preloader)
            }
        },
    )
    
    override fun getPreloadItems(
    	position: Int
    ): MutableList<Event> = mutableListOf(getItem(position))

    override fun getPreloadRequestBuilder(
    	event: Event
    ): RequestBuilder<Drawable> = requestManager
        .load(event.posterImageUrl)
        .transform(CenterCrop(), RoundedCorners(15.dp))
        
    ...
    
    companion object {
        private const val MAX_PRELOAD_SIZE = 8
    }
}

이전 코드와 달라진 점을 비교해보자면, PreloadModelProvider를 구현하고 있다.
이 때, 다음 두 가지를 구현해주어야 한다.

  1. 미리 불러오기 위한 Item을 제공하는 getPreloadItems()
  2. Preload하기 위한 Glide의 RequestBuilder를 제공하는 getPreloadRequestBuilder()

getPreloadItems()가 사용되는 코드를 확인해보면 다음과 같다.

  private void preload(int from, int to) {
    int start;
    int end;
    if (from < to) {
      start = Math.max(lastEnd, from);
      end = to;
    } else {
      start = to;
      end = Math.min(lastStart, from);
    }
    end = Math.min(totalItemCount, end);
    start = Math.min(totalItemCount, Math.max(0, start));

    if (from < to) {
      // Increasing
      for (int i = start; i < end; i++) {
        preloadAdapterPosition(
            preloadModelProvider.getPreloadItems(i), /* position= */ i, /* isIncreasing= */ true);
      }
    } else {
      // Decreasing
      for (int i = end - 1; i >= start; i--) {
        preloadAdapterPosition(
            preloadModelProvider.getPreloadItems(i), /* position= */ i, /* isIncreasing= */ false);
      }
    }

    lastStart = start;
    lastEnd = end;
  }

내부적으로 preload() 메서드에서 사용되고 있으며, Increasing 또는 Decreasing에 따라 preloadModelProvider.getPreloadItems()를 호출하고 있다.
이러한 원리로 사용자의 스크롤 방향에 따라서 미리 불러오는 것이 가능한 것이다.

override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int,
): EventViewHolder = EventViewHolder(
    parent = parent,
    onClickConference = onClickConference,
    onEventPosterPreDraw = { view ->
        if (isFirstPreloader) {
            isFirstPreloader = false
            val preloader = RecyclerViewPreloader(
                fragment,
                this,
                ViewPreloadSizeProvider(view),
                MAX_PRELOAD_SIZE,
            )
            onPreloaderReady(preloader)
        }
    },
)

그리고 RecyclerView의 ItemView 하나가 생성될 때 ImageView의 width와 height으로 RecyclerViewPreloader 객체를 생성해주어야 한다.
생성된 preloader는 RecyclerView.addScrollListener(preloader)를 호출하여 추가한다.
ImageView의 너비와 높이가 ViewType에 따라 달라지는 것이 아닌 이상, 한 번만 등록해주면 된다.

RecyclerViewPreloaderRecyclerView.OnScrollListener의 서브 클래스이기 때문에 리스너에 추가가 가능하다.

맨 처음 소개했던 preload() 메서드를 직접 호출하는 방식을 사용해도 되지만 RecyclerViewPreloader에는 다음과 같은 이점이 있다.

  1. 스크롤 방향에 따라 데이터를 미리 불러오기 때문에, 위로 스크롤 하는 경우에는 아래에 있는 이미지를 미리 불러오지 않아 효율적이다.
  2. Paging이 추가될 가능성을 고려하여 PreloadModelProvider를 구현하여 사용하는 방식도 좋다.
  3. preload에 필요한 메서드를 필수로 구현해야 하기 때문에 개발자의 실수 또한 덜어준다는 장점이 있다.

Ref. Glide RecyclerView 공식문서

profile
망각을 두려워하는 안드로이드 개발자입니다 🧤

0개의 댓글