Image Loading and Caching Library Part 2 — Principle / Memory & Footprint / Compose

이기정
Android Deep-Dive Study
11 min readJun 24, 2021

Image Library의 동작 방식

Android 에서 가장 많이 쓰이는 Glide 를 기준으로 이미지 라이브러리의 동작 방식을 살펴보자.

먼저 Part 1에서 다루었던 기본 예제를 수정하도록 하자.

Glide 의존성 설정

인터넷 사용 권한 부여

레이아웃 파일에서 이미지 소스 제거

코드에 Glide 예제 적용

위와 같이 작업을 하고 나면 아래와 같은 화면을 렌더링할 수 있다.

기능 톺아보기

개발자 입장에서는 매우 간단하게 이미지를 렌더링할 수 있게 해준다.

Glide 문서에서 최소로 요구되고 있는 with()load(), into() 의 내부 구조를 한 번 살펴보자.

1. with 메서드

with()메서드를 호출하면 getRetriever() 메서드를 통해 RequestManagerRetriever 객체를 획득한다.

이후 get() 메서드를 이용해 RequestManager 객체를 생성하는 코드도 연달아 호출하게 된다.

Context의 instance type에 대한 처리후 제일 마지막에 getApplicationManager() 를 호출한다.

with() 메서드는 Global Scope에서 애플리케이션의 생명주기와 연동하여 Glide의 싱글턴 객체를 획득하는 것이 목적이라고 볼 수 있다.

2. load 메서드

두 번째로 load() 메서드를 살펴보자.

단순힌 asDrawable()을 호출해 RequestBuilder 객체를 만들고, load() 를 호출한다고만 명세되어있다.

asDrawable() 구현체를 살펴보자.

android.graphics.drawable.Drawable 타입을 as() 에 파라미터로 넘겨주고 있다.

as() 구현체를 살펴보자.

파라미터로 주어진 객체 타입(여기서는 android.graphics.drawable.Drawable)으로 디코딩하여 반환한다.

여기서 쓰이는 디코더는 com.bumptech.glide.load.ResourceDecoders 이다.

Drawable.class 로 고정되어있지 않은 이유는 리소스 클래스의 서브클래스들도 호환하기 위해서이다.

Glide.load()에서 호출하는 asDrawable()이 디코딩할 리소스 클래스를 생성하였다면 바로 뒤에 붙은 RequestBuilder.load() 메서드에서 데이터를 불러올 것임을 추측할 수 있다.

RequestBuilder.load() 메서드의 호출 구조를 살펴보자.

load()의 파라미터로 주어진 string 값을 key로 사용해 데이터를 캐시하는 영역이다.

동일 이미지의 반복적인 렌더링 작업에 대해 처리한 부분임을 알 수 있다.

이후 loadGeneric() 을 호출한다.

파라미터의 타입이 String에서 Object 로 변경되었다.

modelcom.bumptech.glide.load.model.UriLoader 객체에서 핸들링하게 된다.

이후 RequestBuilder.load()는 최종적으로 RequestBuilder 객체를 반환해주게 된다.

3. into 메서드

현재까지 with()에서 RequestManager객체를 생성한 뒤, 이 객체의 load() 메서드를 호출하여 RequestBuilder 객체를 획득하는 과정까지 진행되었다.

이제 RequestBuilder에서 into() 메서드를 호출해 ImageView 에 리소스를 할당하는 마지막 작업이다.

리소스를 할당할 ImageView를 파라미터로 요구한다.

Util.assertMainThread()에서 메인 쓰레드 여부를 검증한 뒤, Preconditions.checkNotNull(view) 에서 파라미터로 주어진 ImageView에 대한 null check를 수행한다.

이후 ImageView의 scaleType에 대한 처리 후, into(target, targetListener, options, callbackExecutor) 를 호출한다.

이때 target 자리에 GlideContext.buildImageViewTarget()이라는 메서드를 주입하는데, 파라미터로 주어진 ImageView를 이용해 BitmapImageViewTarget이나 DrawableImageViewTarget 으로 변환하는 작업을 수행한다.

GlideContext.buildImageViewTarget()의 두 번째 파라미터인 transcodeClass의 값은 Glide.load()를 호출하였을 때 수행하는 as() 메소드의 파라미터인 Drawable.class 로 이미 주입되어있다.

target으로 주어진 ImageView에 리소스를 세팅하는 곳인데, 특기할만한 부분은 requestManager.clear(target) 이다.

해당 메서드를 호출하면 현재 target으로 설정된 뷰에 대한 모든 로딩을 취소하고 모든 리소스를 해제한다.

Memory & Footprint

Bitmap를 다루다보면 필연적으로 OOM 관련하여 메모리 이슈를 피할 수 없다.

Image Library를 사용하지 않은 상태에서 많은 이미지를 사용하거나 고해상도 이미지를 이미지뷰에 로드해야하는 경우 메모리 부족으로 OOM이 발생하게 된다.

요즘 나오는 스마트폰은 굉장히 많은 메모리를 가지고 태어나는데 왜 이정도도 못 버티지(?) 라는 생각을 한다면, 안드로이드는 앱 내에서 사용할 수 있는 힙 메모리가 정해져있기 때문이다.

Android의 메모리 모델은 운영체제 버전에 따라 두가지로 나뉘게 된다.

  • Dalvik heap 영역 : java 객체를 저장하는 메모리
  • External 영역 : native heap의 일종으로 네이티브의 비트맵 객체를 저장하는 메모리 Dalvik heap 영역와 External 영역의 Dalvik heap footprint + External Limit을 합쳐 프로세스당 메모리 한계를 초과하면 OOM이 발생하게 된다.

참고 java의 footprint는 한 번 증가하면 크기가 다시 감소되지 않기 때문에, footprint가 과도하게 커지지 않게끔 잘 관리해야한다.

Dalvik VM은 처음에 동작에 필요한 만큼만 프로세스에 Heap을 할당하게 되고, 프로세스에 할당된 메모리보다 많은 메모리를 필요하게될 때마다 Dalvit Footfrint도 증가하게된다. 하지만 증가된 Footfrint는 결코 감소하지 않기 때문에 java 객체가 사용가능한 메모리 공간의 여유가 있어도, External heap의 크기가 증가되면 OOM이 발생할 수 있다. 하지만 이런 문제도 Honeycomb 이후부터는 Dalvik heap 과 External 영역이 합쳐졌기 때문에, 고려할 필요가 없어졌다.

External 영역을 사용하는 Honeycomb 미만 버전에서는 이미지를 많이 사용하고있는 화면에서 화면을 전환하는 행동이 발생했을 때도 OOM이 발생하면서 앱이 중지될 것이다. 화면을 전환하면 이전 액티비티 인스턴스에 있던 이미지뷰나 할당되었던 비트맵이 함께 소멸되어 메모리가 회수되고 새로운 액티비티 인스턴스를 생성할텐데, 이 과정에서 이전 액티비티 인스턴스의 비트맵 객체가 회수되지 않아 메모리 누수가 발생했기 때문이다.

비트맵 객체에 대한 참조가 없는데도 왜 회수가 될 수 없을까? Honeycomb 미만 버전에서는 Java 비트맵 객체는 실제 비트맵 데이터를 가지고 있는 곳을 가리키는 포인터일 뿐이고 실제 데이터는 External 영역인 Native Heap 영역에 저장되기 때문이다. Java 비트맵 객체는 참조가 없을 때 GC에 의해 회수되지만 Native Heap 영역은 GC 수행영역 밖이기 때문에 메모리 소멸 시점이 다르다.

이러한 문제점 때문에, Honeycomb 이후 버전에서는 External 영역이 없어지면서 Dalvik heap 영역에 비트맵 메모리를 올릴 수 있게 되었고 GC도 접근할 수 있게 되었다.

다시 돌아와, 만약 고해상도 이미지를 로드할 때 OOM이 발생하는 경우 BitmapFactory 객체를 이용해 다운샘플링, 디코딩 방식을 선택해 적절하게 뷰에 로드하면 된다. 하지만 많은 이미지를 사용하게 되면서 OOM이 발생한다면, 이미지 캐싱 을 이용해보는 것이 어떨까?

Bitmap Caching

이미지가 화면에서 사라지고 다시 구성할 때 이미지를 매번 로드하는 것은 성능상으로나 사용자 경험에 좋지 않다. 이럴 때 메모리와 디스크 캐시를 이용하여 어디에선가 저장되어있던 비트맵을 다시 가져온다면 다시 로드하는 시간도 단축시킬 수 있으며 성능 개선도 가능할 것이다. 이 때 캐싱을 위해 Memory CacheDisk Cache 사용을 추천하는데 두가지가 어떤 차이점이 있는지 알아보자.

  1. Memory Cache

Memory Cache는 어플리케이션 내에 존재하는 메모리에 비트맵을 캐싱하고, 필요할 때 빠르게 접근가능하다. 하지만 Memory Cache도 곧 어플리케이션 용량을 키우는 주범이 될 수 있기 때문에 많은 캐싱을 요구하는 비트맵의 경우에는 Disk Cache에 넣는 것이 더 좋을 수 있다.

2. Disk Cache

Memory Cache에 넣기엔 많은 캐시를 요구하는 경우, 혹은 앱이 백그라운드로 전환되어도 적재한 캐시가 삭제되지 않기를 바란다면 Disk Cache를 이용하는 것이 좋다. 하지만 Disk로부터 캐싱된 비트맵을 가져올 때는 Memory에서 로드하는 것보다 오랜시간이 걸린다.

BitmapPool

Memory Cache의 예시를 위해 소개할 것은 BitmapPool이다. BitmapPool 의 원리는 사용하지 않는 Bitmap을 리스트에 넣어놓고, 추후에 동일한 이미지를 로드할 때 다시 메모리에 적재하지 않고 pool에 있는 이미지를 가져와 재사용하는 것이다.

보통 BitmapPool을 이용해 재사용 Pool을 만들게 될 때, LRU 캐싱 알고리즘으로 구현된 LinkedList (lruBItmapList)와 Byte Size 순으로 정렬된 LinkedList(bitmapList) 를 사용하여 구현하게 된다. 이 둘은 들어있는 비트맵의 순서만 다를 뿐, 같은 비트맵이 담기게된다.

LRU 알고리즘을 이용해 오랫동안 참조되지않은 비트맵 객체는 맨 뒤로 밀리게되고, 맨 뒤에있는 객체를 회수하면서 BitmapPool을 유지시키는 것이다. LRU 알고리즘을 이용하지 않는다면 처음 BitmapPool이 가득 찰 때까지는 문제없이 동작하지만, 비트맵을 재사용하는 시점부터는 특정 비트맵만 재사용될 수 있으며, 앱이 끝날 때까지 메모리가 줄어들지 않게된다. 자세한 내용은 이 블로그 를 참고하길 바란다. :)

대표적인 이미지 로더 라이브러리인 Glide 에서 구현한 LruBitmapPool Class 내부를 보며, LruBitmapList와 bitmapList가 어떻게 쓰이고있는지 살펴보자.

Glide내 LruBitmapPool Class 에서는 strategy(LruPoolStrategy)가 곧 LRU 기반으로 구현된 리스트이며, tracker(BitmapTracker)가 Bitmap size 순으로 정렬된 리스트이다.

Bitmap을 Pool에 넣을 수 있는 조건을 충족시킨다면, strategy & tracker에 bitmap이 들어가게된다.

참고 Lru 기반인 strategy 는 최근에 들어온 bitmap일수록 리스트의 맨 앞으로 배치시켜야하는데, 그 로직이 LruPoolStrategy 구현체 내부에 존재하게 된다.

trimToSize()를 이용하면 pool이 최대사용량을 넘어선 경우 참조를 가장 적게하고 있는 lastIndex 부터 객체를 지워가며 크기를 줄여줄 수 있다.

이미지를 로드하기 위해 pool에서 필요한 비트맵을 가져온다.

Jetpack Compose에서 이미지 라이브러리 사용하기

Google 에서는 Jetpack Compose를 보다 편하게 사용하기 위한 라이브러리를 묶어서 제공한다.

Accompanist 라고 하는 GroupId를 가진 라이브러리 모음이다.

참고 Google#Accompanist Repository

Accompanist에서는 Glide와 Coil을 지원하고 있다.

의존성 설정은 아래와 같다.

Glide는 아래와 같이 적용한다.

Coil은 아래와 같이 적용한다.

Coil에서 gif를 렌더링하기 위해선 coil-gif 의존성을 추가한다.

아래와 같이 별도의 ImageLoader를 설정한다.

Image의 적용은 아래와 같이 적용한다.

해당 포스트는 아래 팀원들과 함께 작성되었습니다.

  • 곽욱현 @Knowre
  • 김남훈 @Naver
  • 배희성 @Rocketpunch
  • 송시영 @Smartstudy
  • 옥수환 @Naver
  • 이기정 @Banksalad
  • 정세희 @Banksalad

함께 공부하고 싶으신 분들은 여기 에 이슈를 등록해주세요!

--

--

이기정
Android Deep-Dive Study

사회공헌을 위한 개발을 좋아합니다. 최근엔 안드로이드 플랫폼을 기반으로하는 Reactive Programing에 관심이 많습니다.