NHN Business Platform 웹플랫폼개발랩 김원준, 정상혁

이미지 로딩은 Android 개발에서 가장 뜨거운 지점입니다. 네트워크로 읽어온 여러 이미지를 동시에 보여 주는 화면은 Android의 전형적인 UI입니다. 그런 화면은 SNS의 최신 글 목록처럼 앱의 핵심 UI인 경우가 많고, 이미지 로딩을 어떻게 구현하느냐에 따라서 사용자 경험의 질이 좌우됩니다. 그러나 이미지를 로딩하는 화면을 안정적으로 빠르게 동작하도록 만들기는 어렵습니다. 캐시, 병렬 처리, 실패 처리 등 개발할 요소가 많습니다. 이 글에서는 Android에서는 어떤 점을 고려해서 이미지 로딩을 구현해야 하고 여러 오픈 소스 라이브러리에서는 이를 어떻게 다루는지 살펴보겠습니다.

이미지 로딩 과정의 숙제들

네트워크를 통한 이미지 로딩을 구현할 때에는 여러 가지 과제를 해결해야 한다.

불안한 HTTP 클라이언트 실행 환경

원본 이미지는 대부분 HTTP 클라이언트 라이브러리를 사용해서 읽어 온다. Android의 HTTP 통신은 라이브러리나 네트워크 환경에서 불안정한 요소가 많기 때문에 이를 충분히 대비해야 한다.

무엇보다 HTTP 클라이언트 라이브러리의 버그를 버전에 따라 대처해야 안정적으로 동작한다. 이에 관해서는 “Android의 HTTP 클라이언트 라이브러리”(http://helloworld.naver.com/helloworld/377316)에서 이미 다루었다.

재시도 처리, 실패 처리도 필요하다. 서버에서 서버로 API를 호출할 때보다 불안정한 네트워크 환경을 이용할 때가 많다. 재시도 횟수와 시도 간격을 깊이 고민해야 한다.

불필요해진 호출은 빠른 시점에 취소해야 한다. 화면 회전이나 이동으로 이미 요청된 호출이 의미가 없어졌는데도 이를 끝까지 수행한다면 메모리, 성능, 배터리가 낭비된다. 특히 네트워크 상태가 좋지 않을 때 요청이 오랫동안 대기한다면 더욱 낭비가 크다. 네트워크 호출을 어떻게 취소할지는 병렬 처리 부분에서 추가로 논의하겠다.

메모리가 넘치거나 새기 쉬운 비트맵 디코딩

네트워크로 이미지를 잘 읽어왔어도 메모리를 다루면서 빠지기 쉬운 함정이 많다.

비트맵의 크기가 클 때는 Out of Memory 에러가 발생하지 않도록 유의해야 한다. 이미지 파일은 디코딩을 거쳐야 화면에 출력된다. 비트맵이 차지하는 메모리의 용량은 이미지의 크기에 비례한다. Galaxy Nexus에서 촬영한 이미지의 크기는 2592×1936픽셀인데, 이는 비트맵으로는 약 19MB(2592 x 1936 x 4바이트)이다.[1] 가용 메모리가 16MB인 기기에서 이 비트맵을 메모리에 올리면 Out of Memory 에러가 발생한다. 따라서 작은 크기로 변환하거나 품질을 낮추어서 디코딩해야 한다. Android는 BitmapFactory.Options 클래스로 그런 기능을 제공한다.

Android 버전에 따라서는 비트맵 자원을 명시적으로 해제해야 한다. Android 3.1(HONEYCOMB_MR1) 이상은 비트맵을 VM의 힙 메모리에 저장하지만, Android 2.3(Gingerbread) 이하 버전은 비트맵을 네이티브 힙에 저장한다. 예전 버전에서 비트맵은 CG(garbage collection)의 대상이 되지 않아 Bitmap 객체의 recycle() 메서드를 호출하여 직접 메모리를 해제해야 한다. 아니면 BitmapFactory.Options의 inPurgeable 플래그를 true로 설정한다.

AsyncTask 클래스만으로는 충분하지 않은 병렬 처리

네트워크 호출과 디코딩 처리 등 대기 시간이 긴 작업은 백그라운드 스레드에서 수행되어야 한다. 그래서 이미지 로딩에서 비동기 처리, 병렬 처리는 필수이다. 이런 작업에는 AsyncTask가 많이 쓰이지만 이 클래스는 버전에 따라 다르게 동작하고 요청 취소는 작업의 특성에 맞게 구현해야 하는 등 고려할 요소가 많다.

여러 AsyncTask가 실행될 때 버전에 따라서 병렬로 실행되기도 하고 직렬로 실행되기도 한다. AsyncTask의 주석에 따르면 Android 1.5(Cupcake)에서는 직렬, Android 1.6(Donut)부터는 병렬로, Android 3.2(HONEYCOMB_MR2) 이상에서는 다시 직렬로 실행된다. AsyncTask의 소스 코드를 보면 버전별로 스레드 풀 관련 상수의 값이 다르다.

표 1 Android 버전별 AsyncTask의 변화

주요 상수

1.5

1.6

2.3

3.2

CORE_POOL_SIZE

1

5

5

AsyncTask.SerialExecutor에 의해 직렬 처리

MAXIMUM_POOL_SIZE

10

128

128

KEEP_ALIVE

10

10

1

동시에 실행되는 스레드의 개수는 상수인 CORE_POOL_SIZE와 MAXIMUM_POOL_SIZE로 정의되는데, 1.6 이상에서는 각각 5와 128로 바뀌었지만, 3.2부터는 이 상수와 상관없이 AsyncTask.SerialExecutor를 통해 하나의 작업이 끝나야 다음 작업이 실행된다.

여러 개의 이미지를 로딩하는 작업이 직렬로 실행되면 비효율적이고 느리다. 예를 들어 10개의 이미지를 한 화면에서 보여 줘야 하는데 만약 한 개의 이미지가 1초가 걸린다면 총 10초를 기다려야 한다. 다양한 성격의 작업이 모두 AsyncTask.execute() 메서드를 호출하고 있을 때에도 서로 영향을 줄 수 있다. 가령 통계용 로그를 기록하는 API 호출과 이미지 로딩을 위한 호출이 둘 다 AsyncTask를 쓴다면 네트워크 사정이 안 좋을 때 상대적으로 사용자에게 중요하지 않은 로그 기록 때문에 이미지 로딩이 대기하는 현상도 발생한다. 굳이 AsyncTask를 쓴다면, 스레드 풀을 직접 지정하는 executeOnExecutor() 메서드를 쓰고 작업의 성격에 따라 스레드 풀을 분리하는 것이 바람직하다. 작업에 따라 적절한 RejctionPolicy까지 지정하려면 ThreadPoolExecutor 클래스 사용에 익숙해져야 한다.[2]다른 스레드의 작업을 정교하게 취소하려면 추가로 할 일이 많다. 진행 중인 작업, 대기 중인 작업, 취소 대상에서 제외할 작업 등을 구분해야 하고, 따로 플래그 변수를 두고 Collection에 저장을 하는 등의 로직이 필요할 때도 있다.

특히, AsyncTask.cancel() 메서드는 작업을 확실히 취소하지 못한다. AsyncTask.cancel(true) 메서드는 현재 작업을 실행하는 스레드와 연결된 Future.cancel(true) 메서드를 호출한다. 이 메서드는 Interrupted Exception을 걸어 수행 중인 doInBackground() 메서드의 작업을 중단시키려고 하지만 소켓 통신과 같은 대개의 블로킹 작업은 Interrupt 시그널을 받지 못한다.[3] 그래서 doInBackground() 메서드의 작업이 중단되지 못하고 끝까지 수행된다. 빠른 시점에 작업을 멈추기 위해서는 doInBackground() 메서드 안에서 isCancelled() 메서드를 반복해서 취소 여부를 확인해야 한다.[4] 소켓 객체를 참조할 수 있다면 소켓을 직접 닫아주는 방법이 더욱 확실하다. 디코딩 취소는 BitmapFactory.Options 클래스의 requestCancelDecode() 메서드를 이용할 수 있다.[5] 이 메서드는 디코딩 중 취소를 완전히 보장해주지는 못하지만 취소가 성공하면 Bitmap 객체 대신 null을 반환한다.

이미지 캐시와 View 재활용의 어려움

이미지가 들어간 화면을 만들 때 이미지 캐시와 View를 재활용하지 않는다면 앱은 느리게 반응하고 자원을 많이 소모한다. 따라서 이미지 캐시와 View 재활용이 필수적인데 이를 정교하게 구현하려면 많은 코드가 들어가고, 매번 구현하기도 번거롭다.

화면이 회전될 때에는 Activity 객체가 다시 생성되고 onCreate() 메서드 등이 다시 호출된다. 회전 후에는 회전하기 전의 이미지를 다시 화면에 그려줘야 한다. ListView에서 스크롤 중에 이미지가 화면에서 사라졌다가 다시 나타날 때도 마찬가지이다. 이럴 때 처음과 똑같이 네트워크 호출과 디코딩 과정을 반복한다면 큰 낭비이다. 캐싱된 이미지를 메모리나 디스크에서 읽어야 한다.

Android SDK에서는 LruCache와 DiskLruCache라는 클래스를 제공한다. Android 개발자 사이트의 “Caching Bitmaps” 페이지(http://developer.android.com/training/displaying-bitmaps/cache-bitmap.html)에 이들의 사용법과 예제가 나와 있다. 그러나 모듈화를 고민하지 않으면 이미지를 재사용해야 하는 부분에 캐시의 동작을 항상 구현하는 등 반복적인 코드가 산재하기 쉽다.

ListView에서 View를 재활용할 때도 ImageView는 문제의 소지가 많다. 재활용할 ImageView에 이전 이미지의 로딩이 끝나지 않은 시점에서 새로운 이미지를 로딩하라고 요청한다면 이전의 이미지 처리가 다 끝나고 나서야 새로운 이미지가 나타난다. 과거 이미지가 보였다가 사라지므로 사용자에게도 이상해 보이고 성능과 자원에도 손해이다. 그래서 ImageView를 재활용하기 위해서는 앞선 요청은 취소해야 한다.

정리

요약하면, 이미지 로딩을 구현할 때는 HTTP 통신을 안정되게 구현하고, 비트맵으로 디코딩하면서 메모리가 넘치거나 새지 않도록 주의해야 한다. 네트워크 호출과 디코딩은 단순히 백그라운드 스레드에서 동작하는 것만으로는 충분하지 않고 더 적극적으로 병렬성을 활용해야 한다. 화면 회전, 전환, 스크롤 때 반복적인 요청이 가지 않도록 이미지를 캐시하고, 불필요해진 요청은 빠른 시점에 취소해서 더 나은 UI 반응을 제공하면서 자원을 절약해야 한다. 이 과제들을 모두 해결하려다 보면 처리 흐름은 복잡해지고, 비슷한 코드가 반복되기 쉽다.

여러 오픈 소스 이미지 로딩 라이브러리들은 캐시, 동시 실행, 취소 등을 간편하게 처리할 수 있도록 도와준다. 다음 단락부터 이를 자세히 살펴보겠다.

조사 대상 라이브러리와 예제

우선 라이브러리별 간단한 특징과 사용 예제를 살펴보겠다.

Android Universal Image Loader

Android Universal Image Loader(이하 AUIL)는 많은 앱에 적용되어 있고, 화면 크기를 기준으로 캐시 용량을 제한하는 등 다양한 캐시 정책을 지원한다. Executor, 스레드 풀 크기, , Bitmap Options 등 변경할 수 있는 옵션이 많다. android.app.Application 클래스를 상속한 클래스에서 ImageLoader 객체를 초기화한 후에,과 같이 각종 옵션을 설정하고 사용할 수 있다.

예제 1 AUIL로 이미지 보여 주기

DisplayImageOptions options = new DisplayImageOptions.Builder()
                              .cacheInMemory()
                              .cacheOnDisc()
                              ...
                              .build();
ImageLoader.getInstance().displayImage(imageUrl, imageView, options);

AQuery

AQuery는 XML 파싱과 권한 관리 등 다양한 기능을 가진 라이브러리이나 이미지 로딩과 캐시 기능도 제공하기 때문에 분석 대상으로 선정했다. 이름처럼 jQuery와 비슷한 메서드 체인 문법을 지원한다. 이미지 로딩 중에 Progress Bar를 보여 주는 API와 이미지 로딩 전에 애니메이션을 보여 주는 API가 제공된다.

예제 2 AQuery로 이미지 보여 주기

AQuery aq = new AQuery(context);
...
String imageUrl ="http://image.naver.com/goldrush.jpg";
aq.id(R.id.imageView).image(imageUrl, true, true, 200,0);

droid4me Bitmap Downloader

droid4me Bitmap Downloader(이하 droid4me)도 Activity 라이프사이클 관리, 예외 처리, 로깅 등의 기능을 제공하는 라이브러리이나 이미지 처리와 캐시도 제공한다. 메모리 캐시에서 옵션 설정이 가능하고, DownloadInstructions.Instructions 라는 인터페이스를 구현하여 디스크 캐시 등의 기능을 확장할 수 있다.

예제 3 droid4me로 이미지 보여 주기

Instructions instructions = new DownloadInstructions.AbstractInstructions();
Object specs = new DownloadSpecs.SizedImageSpecs(R.id.imageView, 30, 30);
Handler handler = new Handler();
BitmapDownloader.getInstance().get(view, imageUrl, specs, handler, instructions);

Libs for Android Image Loader

Libs for Android Image Loader 역시 HTML 위젯(widget)등 다양한 기능을 제공하는 라이브러리이다. 이미지로딩을 담당하는 모듈은 권장하지 않는(deprecated) 모듈이 되었으나 구현 방식을 참고하고자 분석 대상에 포함시켰다.

AUIL과 유사하게 android.app.Application 클래스를 상속한 클래스에서 초기화 구현을 한다. getSystemService() 메서드를 오버라이드하여서 System 서비스로 ImageLoader 객체를 참조할 수 있게 해야 한다. 그런 다음와 같이 ImageLoader.get() 메서드를 호출해서 이미지를 로딩한다.

예제 4 Libs For Android ImageLoader로 이미지 보여 주기

Callback callback = new Callback() {
    public void onImageLoaded(ImageView view, String url) { ... }
    public void onImageError(ImageView view, String url, Throwable error) { ... }
};
ImageLoader.get(this).bind(view, imageUrl, callback);

Volley Image Loader

Volley Image Loader(이하 Volley)는 Google I/O 2013에서 발표된 뜨거운 라이브러리이다. HTTP API 호출에도 초점을 맞추고 있기 때문에 HTTP 클라이언트 라이브러리로 분류할 만도 하다. Google Play 앱에 쓰였으며, Google I/O에서 관련 세션의 발표자는 Volley의 성능이 다른 모든 라이브러리를 압도했다고 자신 있게 말했다. 네트워크 통신과 캐시, 디코딩 등을 알아서 해주는 NetworkImageView라는 ImageView를 상속한 클래스도 제공한다. 일괄 처리(response batching)를 통해 이미지들이 한꺼번에 UI에 나타나는 장점도 있다. 기본 사용법은처럼 requestQueue에 요청을 추가하고, 결과를 처리할 콜백 객체를 넘기는 것이다.

예제 5 Volley로 이미지 보여 주기

imageLoader = new ImageLoader(Volley.newRequestQueue(this),new LruBitmapCache());
imageLoader.get(imageUrl,
                imageLoader.getImageListener(
                    imageView, R.drawable.defaultIcon, R.drawable.errorIcon
                )
);

Google I/O 2013의 발표에서 SDK 매니저를 통한 다운로드를 제공할 계획이 있냐는 질문이 나왔는데, 발표자는 아직 계획이 없다고 답변했다. 아직 릴리스 버전 관리가 되지 않아서 git 저장소를 복사해서 받아야만 한다는 불편함도 있다. 그러나 이전에 Android 소스 저장소에서 Volley가 support 패키지에 들어가 있었던 흔적을 보면 앞으로 SDK에 포함될 가능성도 기대된다.

Novoda’s Android Image Loader

Novoda’s Android Image Loader(이하 Novoda)는 이미지 로딩의 기본적인 기능에 충실한 라이브러리이다. ImageView에 Tag를 설정하여, Tag에서 URL을 불러와서 다운로드하는 방식으로 동작한다. 이미지가 보이기 전에 애니메이션을 보여 주는 기능을 제공한다.

예제 6 Novoda로 이미지 보여 주기

LoaderSettings settings = new LoaderSettings.SettingsBuilder().withDisconnectOnEveryCall(true).build(this);
imageManager = new ImageManager(this, settings);
imageTagFactory = ImageTagFactory.newInstance(this, R.drawable.defaultIcon);
...
ImageTag tag = imageTagFactory.build(imageUrl, this);
imageView.setTag(tag);
imageManager.getLoader().load(imageView);

Picasso

Picasso는 근래에 많은 오픈 소스를 공개하고 있는 Square Inc.가 개발한 라이브러리이다. Square Inc.에서 개발한 HTTP 클라이언트 오픈 소스인 OkHttp를 HTTP 클라이언트로 활용한다. 메서드 체인 방식이라 직관적이고 사용하기 편리하다.

이미지에 대한 모든 정보를 텍스트로 출력하는 Snapshot 기능과 이미지가 네트워크, 디스크 캐시, 메모리 캐시 중 어디에서 왔는지를 리본의 색깔로 나타내는 Debug Indicators 기능도 독특하다. 이런 기능들은 테스트와 디버그에 유용해서 개발 편의성을 많이 배려했다는 느낌이 든다.

과 같이 간단한 코드로 이미지를 불러온다.

예제 7 Picasso로 이미지 보여 주기

Picasso.with(context).load(imageUrl).resize(30, 30).into(imageView);

용량 비교

2fdd2bf79ba0754400ede67a784899ee.png

그림 1 라이브러리별 용량

. jar 파일 용량은 라이브러리 파일의 용량이고, .apk 파일 추가 용량은 이미지를 로딩하는 테스트 앱에서 라이브러리를 사용하였을 때 증가하는 용량을 측정한 것이다. 테스트 앱은 1024 x 1024픽셀 크기의 샘플 이미지를 받아서 ImageView를 통해 적절한 크기로 보여 주는 기능을 하나의 Activity로 구현했다. 소스 코드는 http://dev.naver.com/projects/android-img-lib에 공개했다. APK 추가 용량이 작은 순서대로 나열하면 Libs for Android, Picasso, Volley, AUIL, AQuery, Novoda’s Android Image Loader이다. Libs for Android는 9KB 대의 용량으로 압도적으로 작다. droid4me는 120KB로 가장 용량이 크다. AUIL과 Volley, Picasso는 APK 추가 용량이 40KB 이하로 부담이 없는 수준이다.

이미지 로딩 라이브러리 워크플로

이미지 로딩 라이브러리는의 순서로 작업을 처리한다.

c5515b46b2c5a09c8a18673d1ae5c3bc.png

그림 2 이미지 로딩 라이브러리의 실행 단계

각각의 단계를 살펴보면 다음과 같다.

  1. 이미지 전처리: 이미지를 로딩하기 전에 섬네일이나 진행 상황을 보여 주기 위한 단계
  2. 이미지 로딩: 캐시나 네트워크에서 이미지를 가져오는 단계
  3. 디코딩: BitmapFactory를 이용하여 이미지를 비트맵 형식으로 변환하고 크기, 회전, 품질 등을 변환하는 단계
  4. 이미지 후처리: 보여 줄 이미지에 애니메이션이나 모서리를 둥글게 하는 등의 효과를 적용하는 단계.
  5. 보여 주기: UI 스레드에서 이미지를 보여 주는 단계

이 단계들 중 가장 중요한 부분은 이미지를 실제로 외부로부터 가져오는 ’2. 이미지 로딩’과 ’3. 디코딩’ 단계이다. 이 단계들의 자세한 과정을에서 표현했다.

395ce0678bb9ce58d63916be988db404.png

그림 3 이미지 로딩 단계의 흐름

  1. 메모리 캐시에서 비트맵을 가져온다.
  2. 메모리 캐시에 비트맵이 있으면(cache hit), 이미지 후처리 단계로 진행한다.
  3. 메모리 캐시에 비트맵이 없으면(cache miss), 디스크 캐시에서 이미지를 가져온다.
  4. 디스크 캐시에 이미지가 있으면(cache hit), 비트맵으로 디코딩 후 비트맵을 메모리 캐시에 저장한다. 다음으로 이미지 후처리 단계를 진행한다.
  5. 디스크 캐시에 이미지가 없으면(cache miss), 이미지를 외부(네트워크, 리소스 등)에서 다운로드 한다.
  6. 이미지를 다운로드한 후, 디스크 캐시에 이미지를 저장한다.
  7. 이미지를 비트맵으로 디코딩한 후 비트맵을 메모리 캐시에 저장한다. 그리고 이미지 후처리 단계로 진행한다.

앞에서 말한 것처럼 네트워크나 파일 I/O, 이미지 디코딩 작업은 백그라운드에서 일어난다. 병렬 처리 범위도 라이브러리마다 차이가 있다. 뒤에 나올 “동시 실행(Concurrency) 정책”에서 이를 자세히 설명하겠다.

라이브러리에 따라서 다이어그램에 표현된 일부 단계가 없을 수도 있다. 이미지 로딩 단계 안에서의 각 세부 단계 지원 여부는 다음 표에서 확인할 수 있다.

표 2 이미지 로딩 단계의 기능 비교

 

Memory Cache(Get & Put)

Disc Cache(Get & Put)

이미지 다운로드

디코딩

이미지 후처리

AUIL

O

O

O

O

O

AQuery

O

O

O

O

O

droid4me

O

X

O

O

X

Libs for Android

O

X

O

O

X

Volley

O

O

O

O

X

Novoda

O

O

O

O

X

Picasso

O

O

O

O

O

이미지 다운로드

지원 프로토콜과 확장성

AQuery를 제외한 라이브러리는 이미지를 다운로드하는 부분을 인터페이스나 추상 클래스로 정의하고 있다. 다른 HTTP 클라이언트 라이브러리나 새로운 프로토콜을 사용하고 싶다면 스스로 구현체를 만들어서 바꾸면 된다. 라이브러리별 인터페이스와 디폴트 구현체는 다음 표와 같다.

표 3 이미지 다운로드 인터페이스와 지원 프로토콜

 

인터페이스

디폴트

구현체

구현체 변경 가능

디폴트 구현체 지원 프로토콜

AUIL

Downloader

BaseImageDownloader

O

HTTP , File, ContentProvider Assets, Drawable

AQuery

-

AbstractAjaxCallback

X

HTTP

droid4me

Instructions

AbstractInstructions

O

HTTP

Libs for

Android

URLStreamHandler(Abstract Class)

ContentURLStreamHandler

O

HTTP, File, Content Provider, Android Resource

Volley

HttpStack

BasicNetwork

O

HTTP

Novoda

NetworkManager

UrlNetworkManager

O

HTTP

Picasso

Loader

OkHttp가 있으면 OkHttpLoader

OkHttp가 없으면 URLConnectionLoader

O

HTTP , SPDY, File ContentProvider

Picasso의 프로토콜 처리 방식은 다소 특이하다. Loader에서는 네트워크 관련 프로토콜 관련 작업만 하고, 파일과 Content Provider와 관련된 부분은 Loader가 호출되기 전에 미리 처리한다.

AUIL은 디폴트 구현체가 기본적으로 프로토콜을 많이 지원한다. 디스크에서 이미지를 가져오고 싶다면 “file://path” 식으로 URI을 지정한다. 이 기능은 다양한 출처에서 이미지를 로딩하는 코드에 일관성을 유지하는 데 도움이 된다.

HTTP 클라이언트 라이브러리

Android SDK는 HTTP 클라이언트로 HttpURLConnection과 Apache HttpClient를 제공한다. 각 이미지 로딩 라이브러리별로 사용하는 HTTP 클라이언트 모듈은 다음 표와 같다.

표 4 이미지 로딩에 사용하는 HTTP 클라이언트 라이브러리

라이브러리

디폴트 HTTP 클라이언트

기본 지원 HTTP 클라이언트

AUIL

HttpURLConnection

HttpURLConnection

Apache HttpClient

AQuery

Apache HttpClient

Apache HttpClient

droid4me

HttpURLConnection

HttpURLConnection

Libs for Android

HttpURLConnection

HttpURLConnection

Volley

Android 2.3(Gingerbread) 이상: HttpURLConnection

Android 2.3(Gingerbread) 미만: Apache HttpClient

HttpURLConnection

Apache HttpClient

Novoda

HttpURLConnection

HttpURLConnection

Picasso

OkHttp가 있을 때: OkHttp

HttpURLConnection

HttpURLConnection

OkHttp

Volley만이 버전별로 분기 처리를 제공한다. AUIL은 Apache HttpClient와 HttpURLConnection를 둘 다 지원하기 때문에 쓰는 쪽에서 버전별 분기를 할 수도 있다. Picasso도 OkHttp를 쓰면 Android의 기본 SDK에 의존할 때보다는 버전별 버그 회피에 유리하다. Picasso, Volley는 모두 OkHttp를 통해 SPDY 프로토콜을 사용할 수도 있다.[6]

실패 처리

로딩 실패 처리

이미지 로딩 라이브러리는 로딩이 실패할 때 실패 이벤트를 전달해 주거나 실패를 알리는 이미지를 대신 보여 준다. 라이브러리들의 지원 현황은 다음 표와 같다.

표 5 로딩 실패 처리 기능

 

실패 이벤트 발생

실패 시 대체 이미지 지원

실패 시 로깅

AUIL

O

O

X

AQuery

X

O

O

droid4me

X

X

O

Libs for Android

O

X

X

Volley

O

O

X

Novoda

X

O

X

Picasso

O

O

X

실패 종류를 다양하게 분류하는 라이브러리를 쓴다면 문제의 원인을 쉽게 파악할 수 있어 오류 해결에 도움이 된다. AUIL은 FailReason이라는 enum으로 실패를 5가지로 분류한다. Libs for Android와 Volley는 근본 원인 Exception이 포함된 Error 객체를 리스너에게 넘겨준다. 라이브러리별 예외 분류는 다음과 같다.

  • AUIL: IO Error, Out of Memory, Unsupported URI Scheme, Network Denied, Unknown
  • AQuery: NetworkError, Auth Error
  • droid4me: IO Exception, Out of Memory, Transform(Decoding) Error
  • Libs for Android: ImageError(근본 원인 Exception을 객체 내부에 포함)
  • Volley: VolleyError(근본 원인 Exception을 객체 내부에 포함)
  • Novoda: ImageNotFoundException
  • Picasso: Decode Failed

Out of Memory 처리

AUIL, AQuery, droid4me, Libs for Android는 OOM(Out of Memory Exception)이 발생하면 다음 표와 같이 특별한 처리를 한다.

표 6 OOM의 처리 로직

 

AUIL

AQuery

droid4me

Libs for Android

메모리 캐시 클리어

O

O

O

X

System.gc()

O

X

X

O

특이 사항

활성화 옵션 제공(Configuration.handleOutMemory)

3번까지 로딩 시도

 

OOM 카운트를 증가

gc() 후 이미지 로딩 재시도

가용 메모리를 확보하는 확실한 방법은 메모리 캐시를 비우는 것인데, Libs for Android를 제외한 나머지는 이 방식을 지원한다.

큰 이미지 로딩을 위한 라이브러리의 지원 수준

큰 이미지를 로딩하는 작업은 네트워크 처리와 디코딩에 많은 자원을 소모한다. 각 라이브러리에서는 그런 부담을 줄이고 앱의 반응 속도와 안전성, 사용자 경험을 향상시키기 위해 다음과 같은 기능을 제공한다.

표 7 큰 이미지 처리 지원 기능

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

이미지 로딩 딜레이

O

O

X

X

X

X

X

디폴트 이미지

O

O

X

X

O

O

O

이미지 프리세팅

X

O

X

X

X

X

X

진행률 표시줄

X

O

X

X

X

X

X

이미지 리사이즈(스케일) 기본 지원

O

O

X

X

O

O

O

AQuery가 이 분야에서는 강점을 보인다. 항목을 하나씩 살펴보면,

  • 이미지 로딩 딜레이: 큰 이미지 로딩 작업 시작을 일부러 지연시켜서 그 사이에 다른 UI 작업이 완료될 만한 시간과 자원을 벌어준다. UI만 보는 사용자에게는 반응 속도가 향상되어 보인다.
  • 디폴트 이미지: 로딩이 완료되기 전에 이미지 자리에 들어갈 자원을 지정한다. 기본 이미지는 Android의 정적 자원을 참조한다.
  • 이미지 프리세팅: 최종 이미지의 로딩이 오래 걸리므로, 최종 이미지의 섬네일과 같이 크기가 작은 이미지를 지정한다. 섬네일이 캐시에 있어야 보여 줄 수 있는데, 따로 API로 제공되지 않아도 ImageView.setBitmap(thumbnail) 메서드로 간단하게 구현할 수는 있다. 그래도 개발자 편의성을 높이는 의미가 있는 기능이라서 항목으로 추가했다.
  • 진행률 표시줄: 로딩을 기다리는 동안 진행률 표시줄을 보여 주는 기능이 있는지 여부이다.
  • 이미지 리사이즈(스케일) 기본 지원: 이미지가 큰 만큼 비트맵 사이즈를 줄여야 한다. 디코딩 시 스케일이나 가로세로 길이를 바꿔 주는 기능을 제공하는지의 여부이다

디코딩

다운로드하거나 디스크 캐시에서 가져온 이미지는 비트맵으로 디코딩되어야 한다. 크기를 조정하는 등의 변환 작업도 디코딩 단계에서 수행한다.

디코딩 옵션

디코딩 옵션이 많을수록 성능 최적화에 유리하다. 각 라이브러리가 제공하는 디코딩 옵션은 다음 표와 같다.

표 8 디코딩 옵션

 

지원 스케일

높이와 너비 지정

비율 지정

회전

Default Bitmap.Config

Bitmap.Config

설정 가능 여부

Custom Bitmap.Options

AUIL

2의 제곱

정수배

지정 해상도

O

+업스케일링

X

O

ARGB_8888

O

O

AQuery

2의 제곱

O

(너비만)

O

O

ARGB_8888

X

X

droid4me

지원 안 함

X

X

X

디폴트

X

X

Libs for

Android

지원 안 함

X

X

X

디폴트

X

X

Volley

2의 제곱

O

X

X

RGB_565

X

X

Novoda

2의 제곱

지정 해상도

O

+업스케일링

X

X

디폴트

X

X

Picasso

지정 해상도

O

X

O

디폴트

X

X

디코딩 옵션은 AUIL이 압도적으로 강력하다. Picasso와 AQuery도 옵션을 꽤 제공한다. Volley와 Novoda는 리사이즈만 제공하는 정도이다. droid4me와 Libs for Android는 옵션 지원 수준이 미미하다. 다만 droid4me는 BitmapToolbox라는 유틸리티 클래스가 있어, 이를 사용하여 이미지가 로딩된 후에 옵션과 같은 효과를 처리할 수 있다.

각각의 옵션 항목들을 자세히 살펴보자.

  • 지원 스케일, 높이와 너비 지정: 스케일 타입은 크기 조정 방식을 나타낸다. 모든 라이브러리들이 공통적으로 ’2의 제곱’ 스케일 타입을 지원한다. Android 개발자 문서에 따르면 2의 제곱으로 스케일링하는 것이 성능이 좋다.[7]
    ‘지정 해상도’는 라이브러리가 높이와 너비를 지정한 그대로 디코딩하는 옵션을 제공하는지 여부이다. 너비와 높이를 지정할 수 있지만, ‘지정 해상도’ 방식을 지원하지 않는 라이브러리는 원본 크기의 2의 제곱 스케일 중에서 지정된 너비, 높이와 가장 비슷한 크기로 이미지를 변환한다.
    정수 배는 2의 제곱이 아닌 1, 2, 3, …… 배의 스케일로 변환하는 옵션이다. 2의 제곱 스케일 중 선택할 옵션이 마땅치 않을 때 정수 배를 선택해서 비트맵 용량을 줄일 수 있다. 예를 들어 2, 4, 8 중 4는 작고 8은 너무 크다면 2의 배수는 아니지만 정수인 5를 선택할 수 있다. 디코딩 성능은 2의 제곱 스케일보다는 다소 떨어질 수 있다.
    ‘+업스케일링’은 너비와 높이로 받은 크기가 원래 이미지 크기를 넘어가도 확대하는 옵션이다.
    droid4me는 디코딩 단계에서 이 기능들을 제공하지 않지만, 별도의 유틸리티 클래스를 제공하고 있다. 이 유틸리티 클래스에 로딩된 비트맵과 원하는 사이즈를 넘겨주면 리사이즈된 비트맵을 새로 만들어서 반환한다.
  • 비율 지정: 4:3, 16:9 같이 이미지의 비율을 설정하는 옵션이다. AQuery만 지원한다.
  • 회전: 이미지를 회전시키는 옵션이다.
  • Bitmap.Config: 이미지의 품질을 결정한다. 대부분의 라이브러리는 각 픽셀을 4바이트로 저장하는 ARGB_8888 형식을 사용한다. 명시적으로 이 값을 지정한 라이브러리도 있고, 명시적으로 지정하지 않아도 BitMapFactory.Options.inPrerefferdConfig 값에 의해 ARGB_8888이 지정된다. Volley만이 RGB_565로 품질을 다소 떨어뜨린다.[8]
  • Custom Bitmap.Options: 만약 이미지 로더 모듈에서 제공하는 디코딩 옵션 중에 원하는 것이 없으면 Bitmap.Options를 직접 만들어 로더에 보낼 수 있다. AUIL만 Bitmap.Config 설정을 지원한다.

자동 크기 변환

AUIL 이미지 로더와 Volley의 NetworkImageView라는 UI 컴포넌트는 기본적으로 이미지를 ImageView의 너비와 높이로 변환한다. 따로 리사이즈 크기를 정할 필요가 없어 사용성이 높아지고, 큰 원본 이미지를 자동으로 리사이즈하기 때문에 OOM을 방지할 수 있어 안정성에도 도움이 된다. AUIL은 확실한 안정성을 위해, ImageView의 크기를 가져오지 못하거나 사용자가 설정한 옵션 중에 적당한 이미지 크기를 가져오지 못한다면 기기의 해상도를 기준으로 리사이즈한다. 물론 스케일 타입은 업스케일이 아니고 원본 이미지 크기가 더 작으면 굳이 크기를 조정하지는 않는다.

inPurgeable 옵션

Android 2.3(Gingerbread) 이하에서는 비트맵이 네이티브 메모리에 저장되기 때문에 이미지가 사용되지 않을 때 recycle() 메서드를 호출하고 BitmapFactory.Options의 inPurgeable을 true로 지정해야 한다는 점을 앞에서 언급했다. 이미지가 사용되지 않는 시점은 이미지 로더 입장에서 알 수 없기 때문에 이미지 로더가 직접 recycle() 메서드를 호출할 수는 없다. 따라서 이미지 로더는 inPurgeable을 true로 지정하는 정도로 문제 해결을 지원할 수 있다. 각 라이브러리는 기본적으로 이 옵션을 true로 설정할까?

표 9 inPurgeable= true 설정

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

inPurgeable

= true

O

O

X

X

X

O

X

라이브러리의 반 이상이 inPurgeable을 true로 설정하지 않고 있다. BitmapFactory.Options를 API를 통해 직접 넘겨줄 수 있다면 설정할 수 있겠지만, 그 API를 제공하는 이미지 로더는 AUIL만이 유일하기 때문에 그 방법도 불가능하다.

동시 실행(Concurrency) 정책

각 라이브러리에서 병렬 처리에 쓰이는 클래스와 생성 방식, 풀별 스레드 개수와 확장 가능 여부는 다음 표와 같다.

표 10 병렬 처리 클래스와 개수 등

 

스레드 풀 변수명

풀 구현체 생성 방식

풀 구현 변경

디폴트 스레드 개수

스레드 개수 변경

AUIL

(1) taskDistributor

Executors.newCachedThreadPool

불가능

1 ~ ∞

불가능

(2) taskExecutor

new ThreadPoolExecutor(…)

가능

3

가능

(3) taskExecutor-ForCachedImages

new ThreadPoolExecutor(…)

가능

3

가능

AQuery

sFetch

Executors.newFixedThreadPool

불가능

4

가능

droid4me

(1) PRE_THREAD_POOL

new ThreadPoolExecutor(…)

가능

3

가능

(2) DOWNLOAD_THREAD_POOL

new ThreadPoolExecutor(…)

가능

4

가능

Libs for Android

THREAD_POOL_EXECUTOR

(AsyncTask의 멤버변수)

new ThreadPoolExecutor(…)

불가능

3

불가능

Volley

(1)mCacheDispatcher

(CacheDispatcher 클래스)

new CacheDisPatcher(..)

(Thread 상속 클래스)

불가능

1

불가능

(2)mDispatchers

(NetworkDispatche[] 클래스)

new NetworkDispatche[]()

(Thread 상속 클래스 배열)

불가능

4

가능

Novoda

sExecutor

new ThreadPoolExecutor[]

가능

4

불가능

Picasso

Service

Executors.newFixedThreadPool

가능

3

가능

Libs for Android를 제외한 나머지는 모두 AsyncTask를 사용하지 않고 별도의 스레드 풀에서 백그라운드 작업을 수행한다. Volley는 직접 Thread를 상속한 NetworkDispatcher, CacheDispatcher 클래스를 구현했고, 나머지는 java.util.concurrent 패키지의 ExecutorService를 사용했다.

스레드 풀 정책

병렬 처리 작업을 AsyncTask에 위임하지 않은 라이브러리는 아래와 같은 특징이 있다.

(1) 별도의 스레드 풀을 사용

앞에서 언급했듯이, AsyncTask는 Android 버전에 따라 동작이 다르고, 4.0 이후 execute() 메서드로는 백그라운드 스레드를 동시에 1개만 실행한다. 여러 개의 이미지 작업과 데이터베이스 로딩 같은 작업이 동시에 AsyncTask를 쓴다면 성격이 다른 여러 작업이 서로 방해가 된다. 직접 스레드 풀을 생성하고 정책을 제어하는 방식이 성능 최적화에는 유리하다.

(2) 스레드 풀의 역할을 전략적으로 분업

스레드 풀의 관리 방식에 따라 이미지 모듈을 두 가지로 분류할 수 있다.

표 11 스레드 관리 방식에 따른 분류

 

스레드 풀 종류

해당 라이브러리

Type-1

1개.

모든 로딩 단계의 작업

AQuery, Libs for Android, Novoda’s Image Loader, Picasso

Type-2

2개.

(A) 디스크 캐시 작업

(B) (cache miss이후) 다운로드 작업

AUIL, droid4me, Volley

여기에서는 두 가지 분류를 편의상 “Type-1″, “Type-2″로 명명했다. 이는 동시 처리 범위를 하나의 비동기 작업으로 볼 것이냐(Type-1), 아니면 두 개로 구분할 것이냐(Type-2)에 따라 구분한 것이다.(이에 대한 자세한 내용은 앞의 “이미지 로딩 라이브러리 워크플로”에서 동시 처리 범위를 참고한다.)

Type-2 방식에서는 디스크 캐시를 검사하고 캐시에 이미지가 존재하면(cache hit) 캐시에서 이미지를 가져와 디코딩하는 작업을 하나의 스레드 풀에서 수행하고, 캐시에 이미지가 존재하지 않을 때(cache miss) 이미지를 다운로드하고 디코딩하는 작업을 또 다른 스레드 풀에서 수행한다. 이 방식은 더 빠른 속도를 기대할 수 있는데, 디스크 캐시에 있는 이미지의 로딩도 다른 작업과 병렬로 처리되기 때문이다. 만약 AsyncTask나 Type-1의 이미지 로더를 사용했다면 디스크에 캐싱된 이미지를 보여 주는 작업도 다운로드 작업과 같은 스레드 풀을 쓰기 때문에 대기 시간이 길어질 수 있다.

AUIL은 특별하게 스레드 풀을 3개 관리하는데, 디스크 캐시 검사, cache hit 이후 작업, cache miss 이후 작업을 별도의 스레드 풀에서 수행한다. 넓게 보면 이것도 Type-2로 분류할 수 있다. taskDistributor에 들어온 작업이 (A)이든 (B)이든 둘 중 하나의 Executor로 이동하기 때문에 Type-2에서 (A)의 작업을 다시 두 개의 Executor로 나눈 것이라 보면 된다. 이렇게 스레드 풀을 더 나눈 의도는 cache hit 이후의 작업이 시간이 걸릴 수 있는 작업이라고 판단했기 때문으로 보인다. 이는 AUIL이 사용자가 정의한 Post Processer를 지원하는 것과도 관련이 있는데, Post Processor로 실행되는 코드가 짧은 시간에 실행된다고 보장할 수 없기 때문이다. AUIL은 메모리 캐시에 이미지가 있어도 사용자가 Post Processor를 적용했다면 스레드 풀로 보낸다.

스레드 개수

CPU 작업만 수행하는 경우라면 동시 실행 스레드의 개수는 (CPU의 개수)+1의 공식을 활용할 수 있을 것이다. 그러나 작업에 따라 다른 자원의 성격이나 한계를 고려해야 한다.

이미지를 다운로드하는 스레드의 개수는 네트워크 커넥션과 관련이 깊다. HTTP 클라이언트 라이브러리는 내부적으로 커넥션 풀 방식으로 연결을 관리하기 때문에 커넥션 풀 크기보다 스레드 개수가 더 많으면 커넥션을 두고 경쟁이 일어나서 성능이 저하된다. 그래서 스레드 개수는 커넥션 풀의 크기보다 작거나 같아야 한다. AQuery는 커넥션 풀의 크기를 25로 설정하고, 스레드 개수를 최소 1개에서 최대 25개까지 설정할 수 있다.

디코딩을 처리하는 스레드의 개수도 중요하다. 만약 여러 스레드가 동시에 디코딩 작업을 한다면 급격한 비트맵 생성으로 메모리가 넘칠 수 있다. Volley는 ImageRequest 객체의 parseNetworkResponse() 메서드에서 Global Lock을 이용해서 여러 이미지의 동시 디코딩을 막고 있다. Picasso도 Picasso 클래스의 transformResult() 메서드에서 비슷하게 처리하는데, 재미있게도 이 방식은 Volley에서 베꼈다고 주석에서 설명하고 있다.[9]

조사 대상 라이브러리들은 공통적으로 풀당 1개에서 5개의 스레드를 할당한다. AUIL 사이트의 매뉴얼에서도 OOM을 예방하기 위해 풀 크기로 1 ~ 5개를 추천한다. Volley는 NetworkDispatcher의 디폴트 스레드 개수가 4개인데 이는 Google Play Store에서 실험했을 때 최적의 성능을 발휘한 개수였다고 한다.

앞에서 언급한 Type-2의 [A]에 해당하는 캐시 스레드의 디폴트 개수는 AUIL과 droid4me가 3개, Volley가 1개이다. 디스크 캐시의 저장 포맷이 비트맵이 아닌 이미지라면 그 이미지를 읽어오는 I/O 오버헤드와, 그것을 비트맵으로 변환하는 디코딩 오버헤드가 존재한다. 그러나 이 오버헤드가 있음에도, Volley는 개수를 1개로 지정하였다. 1개여도 (그리고 오버헤드가 있더라도) 충분히 빨랐다고 한다.[10] Volley의 캐시 스레드가 하는 일은 다른 이미지 로더의 캐시 스레드보다 단순하다.

중복 로딩 시의 처리

중복 로딩은 같은 이미지 로딩 요청이 거의 동시에 두 번 발생하는 경우를 뜻한다. 이 때 같은 이미지를 실제로 두 번 다운로드한다면 비효율적이다. 그래서 이미지 로더 모듈들은 이에 대비하는 기능을 제공한다. 짧은 간격으로 같은 URL의 이미지를 여러 크기로 디코딩해야 할 때 중복 처리를 신경 쓰지 않아도 되므로 편리하다. 라이브러리별 처리 방식은 다음과 같다.

  • AUIL: 이미지 로딩 단계에서 메모리 캐시를 반복적으로 검사해서 중복 요청 가능성을 줄인다. 그러나 이미 진행 중인 로딩이 완료되기 전에 새로운 요청이 발생하면 중복 로딩이 발생할 수도 있다.
  • AQuery: 이미 로딩이 진행 중인 URL은 큐에 추가하지 않는다.
  • droid4me: 이전의 요청을 취소하고 새로운 요청을 진행한다.
  • Libs for Android: 특별히 처리하지 않는다.
  • Volley: 이미 로딩이 진행 중인 URL은 큐에 추가하지 않는다.
  • Novoda: 이전의 요청을 취소하고 새로운 요청을 진행한다.
  • Picasso: 특별히 처리하지 않는다.

크게 봤을 때 앞의 요청을 취소하거나, 뒤의 요청을 무시하는 두 방법으로 나뉘는데, 할 일을 최소화한다는 측면에서는 후자가 바람직하다.

취소 처리

취소 기능 지원

AQuery, Libs for Android를 제외한 나머지는 모두 작업 취소를 지원한다.

표 12 취소 처리 기능

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

작업 취소

O

X

X

O

O

O

모든 작업 취소

O

O

X

O

X

X

View 재사용 시

자동 취소

O

X

X

X

O

X

X

에서 droid4me가 △인데, 이는 스레드 풀에 직접 접근하여 remove() 메서드를 호출해야 하기 때문이다.

‘View 재사용 시 자동 취소’는 View에 이미지를 로딩해야 할 때 이미 로딩 중인 이미지를 이미지 로더가 알아서 취소하는 기능이다. AUIL, Volley가 이를 지원하고 다른 라이브러리는 직접 작업 취소 API를 호출해야 한다. Volley는 NetworkImageView라는 ImageView 클래스를 상속한 클래스를 쓰면 앞의 요청이 자동으로 취소된다.

취소 구현 수준

앞에서 언급한 대로 네트워크 호출 작업을 가장 확실히 취소하는 방법은 소켓을 닫는 것이다. 그리고 디코딩 중이라면 디코딩을 취소하는 것이 좋다.

조사 대상 라이브러리 중에서는 소켓을 닫는 코드를 찾을 수 없었다. 또한 네트워크 통신 중 취소가 요청되었는지 주기적으로 확인하여 취소가 요청되었으면 작업을 중단하는 코드도 찾을 수 없었다. 다른 부작용을 우려해서인지도 모르겠지만, 완벽하게 취소를 처리하는 라이브러리는 없다고 봐야 할 것이다. 비교 대상으로 다루지는 않았지만 Ion이라는 라이브러리에서는 Future를 상속받아 cancel() 메서드의 구현부에서 소켓을 닫는 코드를 발견할 수 있었다.

메모리 캐시

메모리 캐시에서 이미지를 가져오는 속도는 네트워크나 다른 곳에서 가져올 때보다 빨라야 한다. 따라서 I/O나 디코딩 오버헤드를 없애기 위해 모든 이미지 로딩 라이브러리의 메모리 캐시는 비트맵을 캐싱하고 메인 스레드에서 동기적으로 불러온다.

메모리 캐시는 모든 이미지 로딩 라이브러리가 지원한다. droid4me와 Libs for Android는 반드시 캐싱해야 하고, 나머지는 선택적이다.

메모리 캐시의 크기

비트맵을 메모리에 저장할 때는 늘 메모리가 넘칠 것을 걱정하게 된다. 그렇다고 비트맵에 메모리의 작은 공간만을 허용한다면 아예 비트맵을 저장할 수 없거나, 너무 자주 캐시의 내용이 바뀌어 비트맵 GC(garbage collection) 비용이 발생한다. 각 라이브러리의 디폴트 캐시 용량은 다음과 같다.

표 13 디폴트 캐시의 크기

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

가용 캐시 용량

-

-

-

~ 16MB

-

-

~ 20MB

디폴트 캐시 크기

1/8 of available memory

1,000,000(BCache) + 250,000(SCache) = 1.2MB

1MB ~ 3MB

25% of available memory

-

LRU: 12MB/4 = 3 MB

SoftMap: 25% of available memory

15% of available memory

절대적인 용량으로는 1MB에서 3MB 정도로 다양하고, 그 외에는 앱 가용 메모리의 1/8이나 1/4 수준으로 설정되어 있다. 이 디폴트 값이 최적인지는 알 수 없다. 기기의 해상도, 앱이 이미지를 다루는 방식과 정책, 테스트 등으로 최적의 값을 찾아야 할 것이다.

간단하게 디바이스의 해상도 크기를 기준으로 계산해 보자. Galaxy Nexus를 예로 들면, 해상도는 480 x 800픽셀이고 앱의 가용 용량은 32MB이다. 만약 ListView로 이미지 갤러리를 화면 가득 보여 준다고 가정할 때 한 화면에 필요한 메모리는 최대 480 x 800 x 4바이트(ARGB_888) = 1.46MB이다. 메모리 캐시의 크기를 가용 메모리의 1/8인 8MB로 설정했다면 기기 해상도의 5배, 즉 다섯 화면 정도를 캐싱할 수 있다. 캐시의 지역성을 생각했을 때 이는 현재 화면 안에 보이는 이미지들 위아래로 네 화면 정도를 캐싱하는 용량이다. 섬네일이나 그 외의 필요한 이미지를 고려하지 않는다면, 적어도 Galaxy Nexus에서는 이 정도로 충분하다.

Google I/O 2013의 Volley 세션에서도 캐시 메모리 크기를 기기 해상도에 따라서 지정하는 기법이 유용했다는 내용이 있었는데, 보통은 3개 화면만큼의 데이터 캐싱을 원하기 때문에 잘 맞아 떨어진다고 했다. 그러나 Volley에서는 메모리 캐시 구현체가 디폴트로는 없어서 스스로 구현해야 하고, 제한 용량도 사용자가 알아서 계산해야 한다.

AUIL에서는 캐싱할 이미지의 최대 크기를 정할 수 있는 API를 제공한다. 이미지가 최대 크기보다 크다면 최대 크기만큼 크기를 조정하는데, 최대 크기의 디폴트 값은 기기 해상도이다.

가용 캐시 용량은 Libs for Android와 Picasso가 구현한 아이디어로서 라이브러리 레벨에서 캐시 용량의 상한을 설정하여 OOM이 발생할 가능성을 줄인다.

메모리 캐시 정책과 확장성

표 14 지원하는 메모리 캐시 정책

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

기본 지원 캐시 구현

LRU

LFU

Unlimited

FuzzyKey

FIFO

LimitedAge

LRU

LFU

LRU

-

LRU

SoftMap

LRU

디폴트 캐시 구현

LRU

LRU

LFU

LRU

X

SoftMap

LRU

캐싱 방식 변경 가능

O

X

X

X

O

O

O

기본 메모리 캐시 타입은 이미지 로더별로 천차만별이지만, LRU(least recently used) 방식이 가장 널리 쓰인다. 앱의 성격에 따라서 어떤 캐시 정책이 효율적인지는 다를 수 있으므로, 캐시 구현체는 다양하게 선택할 수 있거나 확장해서 직접 구현할 수 있는 것이 바람직하다.

AUIL은 다양한 캐시 정책을 기본적으로 제공한다. Volley는 디폴트 캐시 구현 클래스가 없는 대신 확장성이 좋다. AQuery, droid4me, Libs for Android는 캐시 구현체를 변경할 수 없는 문제가 있다.

Cache Key 알고리즘

메모리 캐싱을 할 때 키 값을 어떻게 조합해서 결정하는지는 다음과 같다.

표 15 메모리 캐시의 키 구성 요소

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

메모리 캐시 키 구성 요소

targetWidth, targetHeight, URL

URL

URL

URL

MaxWidth, MaxHeight, URL

URL SoftMap: URL

Path(=URL) or Resource ID, targetWidth, targetHeight, Rotation, Transformations

의 Cache Key 항목은 메모리 캐시 키의 재료가 되는 정보이다. 예를 들어, AUIL은 targetWidth, targetHeight, URL을 조합하여 하나의 키로 만든다. 이렇게 가로세로 크기 정보가 Key의 바탕이 되면, 같은 이미지라도 다른 크기로 디코딩됐을 때 각각 다른 Key로 캐싱된다. 실제로 보여 줄 이미지와 섬네일, 두 가지 크기로 이미지를 관리하고 싶다면 유용하다.

AQuery는 큰 이미지를 캐싱하는 BCache와 작은 이미지를 캐싱하는 SCache 두 개의 캐시를 두어 섬네일 같은 경우를 처리한다.

droid4me와 Libs for Android, Novoda는 가로세로 크기를 기반으로 키를 생성하지 않는다. Novoda는 캐시 객체에서 직접 캐시 키 알고리즘을 생성하기 때문에 사용자가 새로운 캐시 클래스를 만들어 캐시 키 알고리즘을 개선할 수 있다. 이미지의 품질별로 따로 캐싱하는 등의 새로운 유스케이스가 생긴다면 Novoda와 같은 유연성이 필요하다.

디스크 캐시

디스크 캐시는 이미지 로더의 2차 캐시이다. 메모리보다 빠르지는 않지만, 그래도 충분히 빠르며 더 넓은 공간을 활용한다. 영속성이 있어서 앱이 다시 실행되어도 이미지를 재사용할 수 있다.

droid4me와 Libs for Android를 제외한 라이브러리가 디스크 캐시를 기본 지원한다.

표 16 디스크 캐시 지원

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

지원 여부

지원

지원

미지원

미지원

지원

지원

지원

필수 사용 여부

선택

선택

-

-

선택

선택

선택

저장 타입

이미지

이미지

-

-

이미지

이미지

이미지

메모리 캐싱과 다르게 디스크 캐싱에서는 비트맵이 아닌 이미지를 저장한다. 그 이유는 첫째, 이미지를 다운로드하면서 HTTP 클라이언트가 Response를 알아서 캐싱하기 때문이다. HTTP 프로토콜의 ‘expires’나 ‘max-age’ 등 캐시 관련 헤더에 따라서 자연스럽게 원본 이미지를 캐싱할 수 있다. 둘째, 디코딩한 비트맵을 저장하려면 Bitmap 클래스의 compress() 메서드를 사용해야 하기 때문이다. compress() 메서드는 비트맵을 이미지로 변환한다. 셋째는 용량 문제이다. 굳이 원본 크기로 비트맵을 저장하지 않는다고 가정하더라도 이미지와 비교하면 비트맵은 많은 용량을 차지한다.

디스크 캐시의 디폴트 사이즈

디스크 캐시는 메모리에 비해 용량의 제약이 덜하다. Volley의 DiskBasedCache를 제외하고는 모두 디폴트 크기가 없어서 직접 지정해야 한다. AUIL은 디폴트 디스크 캐시로 크기의 제한이 없는 UnlimitedDiscCache를 사용한다.

표 17 디스크 캐시 디폴트 크기

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda’s Image Loader

Picasso

디스크 캐시 디폴트 크기

-

-

-

-

DiskBasedCache ( LRU ): 5 MB

-

2 % of total space

캐시 크기 변경 가능 여부

O

-

-

-

O

-

X

디스크 캐시 구현 클래스

디스크 캐시도 앱에 따라 다양한 정책이 적용되어야 한다. AQuery는 캐시 방식을 변경할 수 없다. droid4me는 디스크 캐시를 지원하지 않으나 Local Bitmap이라는 개념으로, 앱의 리소스에서 이미지를 가져오는 정도를 지원한다. Libs for Android 역시 디스크 캐시를 지원하지 않는다. 단, droid4me와 Libs for Android에서는 이미지를 다운로드하는 클래스에서 디스크를 같이 활용하도록 구현한다면 디스크 캐시가 불가능하지는 않다.

AUIL과 Volley, Novoda는 디스크 캐시 구현체를 변경할 수 있다. 여기에서도 AUIL이 기본적인 디스크 캐시 구현체를 가장 많이 제공한다. 특히 UnlimitedDiscCache는 AUIL에서만 있는 구현체인데, 성능이 좋기 때문에 디폴트로 선정되었다. UnlimitedDiscCache는 다른 디스크 캐시에 비해 성능이 30% 좋다고 한다.[11]

Picasso는 특이한 방식인데, 이미지를 다운로드하는 구현체에서 캐시를 내장하고 있다. 그리고 HttpResponseCache라는 android.net.http.HttpResponseCache를 사용한다. 그래서 만약 캐시 타입을 변경하고 싶다면, Loader를 상속받아 새로 구현하여 새로운 캐시를 내장해야 한다.[12]

표 18 디스크 캐시 구현 클래스

 

기본 지원

캐시 클래스

디폴트

캐시 클래스

캐시 클래스

변경 가능

AUIL

UnlimitedDiscCache ( Unlimited )

TotalSizeLimitedDiscCache ( LRU )

 

FileCountLimitedDiscCache ( LRU)

LimitedAgeDiscCache ( Limited Age )

UnlimitedDiscCache ( Unlimited )

O

AQuery

독립적인 클래스 없음

( LimitedAge )

독립적인 클래스 없음

(Limited Age)

X

droid4me

-

-

-

Libs for Android

-

-

-

Volley

DiskBasedCache ( LRU )

DiskBasedCache ( LRU )

O

Novoda

BasicFileManager

( Limited Age )

BasicFileManager

( Limited Age )

O

Picasso

HttpResponseCache

 

X

External Storage 지원 여부

디스크 캐싱을 지원하는 모든 이미지 로더 모듈들은 SD 카드를 저장소로 지원해서 내부 저장소의 용량 한계를 극복할 수 있다. 단 SD 카드에서의 I/O 오버헤드는 내부 저장소보다 크다는 점을 감안해야 한다.

표 19 외부 저장 공간 지원

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

SD 카드 지원

O
(자동)

O
(수동)

-

-

O
(수동)

O
(자동)

X

AUIL과 Novoda는 캐싱할 경로를 설정하지 않으면 디폴트로 외부 저장 공간을 사용할 수 있는 권한이 있는지 확인하여, 권한이 있다면 SD 카드를 저장 공간으로 사용한다.

캐시 키 생성 알고리즘

표 20 디스크 캐시의 키 구성 요소

 

AUIL

AQuery

droid4me

Libs for Android

Volley

Novoda

Picasso

디스크 캐시 키

구성 요소

URL

URL

-

-

URL

URL, Width, Height

URL

Novoda를 제외한 나머지는 디스크 캐시에서 URL만을 키로 사용한다. 대개의 이미지 로딩 라이브러리는 원본 이미지를 저장하기 때문에 메모리 캐시처럼 너비와 높이까지 구분할 필요가 없다. 앱에서 어떤 크기의 이미지를 원하든 디스크 캐시에서 원본 이미지를 가져와서 원하는 크기로 디코딩한다. 섬네일이든 디바이스 해상도 크기에 맞는 적당한 크기의 이미지든 상관없이 여러 가지 크기의 디코딩에 하나의 이미지를 재사용한다는 이점이 있다.

ListView를 위한 라이브러리의 지원 수준

ListView 안의 아이템들이 ImageView를 포함하는 경우에 스크롤할 때 지나가는 모든 이미지를 불필요하게 로딩한다면 스크롤 속도가 느려진다. 각 라이브러리는 이와 같은 스크롤 랙 문제 해결에 도움이 되는 기능들을 제공한다.

표 21 ListView 지원 기능

 

AUIL

AQuery

droid4me

Libs for

Android

Volley

Novoda

Picasso

큐 프로세스 LIFO 지원

O

X

X

O

Priority로 대신함.

X

X

캐시만 수행하는 API

O

O

O

O

O

X

O

이미지 로딩 딜레이

O

O

X

X

X

X

X

이미지 로딩 일시 중지

O

X

X

X

X

X

X

이미지 로딩 취소

O

O

X

O

O

O

  • LIFO 큐 지원 여부: ListView가 스크롤된 후 마지막에 보이는 아이템의 이미지가 마지막으로 큐에 들어가므로, LIFO 방식으로 처리하면 마지막 항목이 먼저 디큐되어 더욱 빠르게 ListView 안에서 이미지를 볼 수 있다.
  • 캐시만 수행하는 API: ImageView에 이미지를 보여 주는 단계를 제외하고 대신 메모리, 디스크에 캐시만 하는 API이다. 이미지가 로딩되었지만 ListView가 스크롤되어 해당 이미지를 담은 ImageView가 보이지 않는 영역에 있는 경우에 사용을 고려할 만하다. 이때 비트맵을 캐시에만 넣고 있으면 불필요한 보여 주기 단계를 생략할 수 있다. Libs for Android는 이런 처리를 하는 API를 따로 제공한다.
  • 이미지 로딩 딜레이: ListView가 스크롤되고 있을 때 이미지 로딩을 일부러 지연시키면 스크롤 시 랙이 발생할 가능성이 낮아진다.
  • 이미지 로딩 일지 중지(pause): 로딩을 일시 중지하면 오버헤드가 아예 없으므로 부드러운 스크롤이 가능하다.
  • 이미지 로딩 취소: 큐에서 대기하고 있는 이미지 로딩 작업들을 취소하는 것도 UI 성능 향상에 도움이 된다.
  • 특이 사항:
  • AUIL은 PauseOnScrollListener라는 특수한 리스너를 제공한다. 이를 ListView에 등록하면, 스크롤 시 또는 스크롤이 일어나고 있는 중(fling)에 모든 이미지 로딩을 멈추고, 스크롤이 끝나면 다시 이미지 로딩을 진행한다. [13]Libs for
  • Android는 이미지 로드 API를 호출할 때 ImageView 대신 Adapter를 넘겨줄 수 있다. Adapter를 넘겨주면 ImageView와는 다르게 이미지 로딩이 끝난 후 adapter.notifyDataSetChanged()를 호출한다.

총평

AUIL은 다른 라이브러리에 비해서 거의 모든 면에서 압도적이다. 기존 이미지 로딩 문제의 대부분을 잘 해결하고 있을 뿐 아니라 여러 종류의 캐시나 다운로더, 디코더 등이 이미 구현되어 있어 사용자가 채워줘야 할 부분이 별로 없다. 많은 기능들을 제공하지만 직관적인 API 덕분에 로더의 원하는 기능을 사용할 때 혼란스럽지 않다. 이미지 로더의 요소 대부분을 설정할 수 있다는 점은 대단한 장점이다. 그 덕분에 OOM 같은 문제에 대비하거나 성능을 끌어올릴 때 도움이 된다. 용량도 APK에 포함될 때 40KB 초반으로 부담이 없다. 그 동안 수많은 곳에서 사용된 만큼 노하우의 집약되어 있고 지금도 계속 활발하게 개선되고 있는 만큼 이를 따라잡을 수 있는 라이브러리는 쉽게 나타나지 않을 것이다.

Volley ImageLoader는 메모리 캐시는 구현체를 직접 만들어줘야 하고 디코딩 기능은 리사이즈 밖에 지원하지 않는 등 사용성 측면에서 다소 부족하다. 그러나 실무에서 많은 테스트를 거친 라이브러리이기 때문에 안정성과 성능에서는 신뢰가 간다. Google이 만들었기에 Android SDK에 포함될 가능성도 배제할 수 없다. 구조가 직관적이고 확장하기가 편하기에 발전 가능성은 크다.

Picasso는 다른 라이브러리보다 간단하고 직관적인 메서드 체인 문법을 갖고 있다는 것이 차별화된 강점이다. 이미지 로딩 문제 해결에 필요한 기능들도 충분하고 디버깅을 도와주는 기능도 독창적이다. 쉽고 깔끔한 개발에 도움이 된다.

AQuery는 Picasso와 같이 메서드 체인 문법을 갖고 있지만 Picasso와 다르게 직관적인 사용이 어렵다. 예를 들어, 메모리 캐시 사용 여부는 image() 메서드의 인자로 설정하지만, 메모리 캐시의 크기 제한을 설정하기 위해서는 BitmapAjaxCallback 객체의 setCacheLimit() 메서드를 호출해야 하는 등 아리송한 점이 존재한다. 확장성도 부족하여 원하는 기능을 적용하거나 개선하기 어렵다. 그래도 AQuery가 가지고 있는 막강한 유틸리티 기능들 덕분에 편하게 쓸 수 있다.

Novoda’s Android Image Loader는 기본적인 기능이나 구조는 다 갖추고 있지만 아주 특별한 강점은 없다. 용량도 특별히 작거나 크지 않다. 쓸 만하지만 다른 라이브러리에 비해 아주 큰 매력은 없다.

droid4me는 다른 라이브러리보다 기본 구현 수준이 부족하여 디스크 캐시나 디코딩 부분을 사용자가 직접 구현해야 한다. 문서화가 잘 되어 있지 않아 확장 지점을 파악하기도 어렵다. droid4me에서 OOM 카운트 등의 아이디어는 참고할 만하다. 앱을 테스트할 때 OOM의 빈도를 측정한다면 메모리 캐시의 크기를 조정하는 데 도움이 될 것이다.

Libs for Android는 압도적으로 용량이 작지만, 기본 기능이 부족하다. deprecate된 프로젝트인 만큼 사용하지 않는 편이 좋다. 대신 이 프로젝트를 fork한 로딩 라이브러리가 존재한다.[14]

이미지 로딩 라이브러리들은 공통적으로 요청 취소 기능에 대해서는 고민이 필요해 보인다. 확실한 취소를 위해 소켓을 닫고, 디코딩을 중단시키는 방법이 어느 라이브러리에도 적용돼 있지 않았다. Ion이라는 라이브러리는 로딩 취소 시 소켓을 닫아주는 처리와 interruptible한 NIO를 사용하는 등 가장 강력한 취소 처리를 제공하는 라이브러리라고 평가된다.

정리하면 기능 측면에서는 Android Universal Image Loader, 사용성에서는 Picasso, 향후 발전 가능성에서는 Volley를 가장 주목할 만하고, 다른 라이브러리들에서도 몇 가지 아이디어를 참조할만하다.

마치며

Android의 이미지 로딩은 험난한 길이었고, 많은 개발자들이 그 길을 반복해서 헤쳐 나왔다. 조사 대상 오픈 소스들이 제공하는 기능의 수준은 각기 달랐지만, 전체 구조는 유사한 면이 많았다. 오픈소스 라이브러리를 사용하지 않는 앱에서도 비슷한 기능을 직접 구현한 사례도 쉽게 발견할 수 있다. 애플리케이션의 소스를 직접 공개할 수는 없으므로 이 글에서는 오픈소스 라이브러리의 분석에 중점을 두었다.

웹플랫폼개발랩에서는 그런 라이브러리들의 문제점을 개선하고 기능을 확장하는 시도도 하고 있다. 테스트 도중 Android Universal Imager Loader의 LimitedDiscCache 클래스에서 버그를 발견했고, Github를 통해 버그를 수정하는 Pull Request를 올렸다.[15] 6월28일에 해당 커밋이 머지되었고 6월 30일에 릴리스된 1.8.5 버전에 포함되었다.

Universal Image Loader같은 성숙한 라이브러리도 있고, Volley, Picasso, Ion등 좋은 라이브러리가 최근 몇 달 사이에 계속 공개되고 있어서 이미지 로딩의 구현이 전보다는 쉬워졌다. 그래도 결국 이미지 로딩의 어려움은 Android SDK에서 채워줘야 할 빈틈이 아닐까 한다. ImageView에 이미지를 보여 주는 작업이 알아서 기기에 적합한 만큼의 캐시와 스레드를 사용해서 실행되고, WebView처럼 주소만 있으면 되는 날이 오기를 기대한다.

참고 자료

[1] http://developer.android.com/training/displaying-bitmaps/index.html

[2] AsyncTask.executeOnExecutor() 메서드는 API Level 11 이상부터 지원되고, AsyncTask.execute() 메서드는 API Level 13 이상이 지원되는 기기에서 타켓 SDK가 13 이상일 때 직렬로 실행된다. 자세한 내용은 https://gist.github.com/benelog/5954649를 참조한다.

[3] Brian Goetz 등, ‘Java Concurrency In Practice’, Addison Wesley Professional, 2006, 7.1.6 Dealing with Non-interruptible Blocking http://logc.at/2011/11/08/the-hidden-pitfalls-of-asynctask/

[4] https://groups.google.com/forum/?fromgroups#!topic/android-developers/3K2xvHcF8bs

[5] http://developer.android.com/reference/android/graphics/BitmapFactory.Options.html#requestCancelDecode()

[6] https://gist.github.com/JakeWharton/5616899

[7] http://developer.android.com/training/displaying-bitmaps/load-bitmap.html

[8] Bitmap.Config 옵션은 http://developer.android.com/reference/android/graphics/Bitmap.Config.html를 참조한다.

[9]

[10] https://gist.github.com/benelog/5981448https://developers.google.com/events/io/sessions/325304728를 참조한다.

[11] https://github.com/nostra13/Android-Universal-Image-Loader

[12] 참고로 HttpResponseCache는 Android 4.0(Ice Cream Sandwich) 이후로 생긴 클래스이다. OkHttp는 이를 라이브러리 내에서 직접 구현하고 있으므로 버전과 무관하게 지원된다.

[13] https://github.com/nostra13/Android-Universal-Image-Loader#useful-info의 8번째 항목을 참조한다.

[14] https://github.com/wuman/AndroidImageLoader

[15] https://github.com/nostra13/Android-Universal-Image-Loader/pull/316

 
NBP 웹플랫폼개발랩 김원준
개발자라 불리어도 부끄럽지 않을 사람이 되기 위해 노력하는 자칭 개발 꿈나무입니다. 카페에서 독서와 모바일게임을 즐기는 소박한 된장남이기도 합니다. 요새는 모바일, 분산이라는 키워드에 관심이 많으며, 그 외에 현대음악, 재즈, 인식론 등에 관심이 많다고 거짓말하고 다닙니다.
 
NBP 웹플랫폼개발랩 정상혁
Java와 Linux 를 주로 쓰는 흔한 주류 개발자이다. 서버,클라이언트, UI 등 다양한 분야를 소화하는 프로그래머가 되려고 노력 중이다. NHN계열사의 여러 조직에서 신규 프로젝트 개발, 기술지원, 교육 업무를 수행해 왔다.