이미지 로딩은 Android 개발에서 가장 뜨거운 지점입니다. 네트워크로 읽어온 여러 이미지를 동시에 보여 주는 화면은 Android의 전형적인 UI입니다.
그런 화면은 SNS의 최신 글 목록처럼 앱의 핵심 UI인 경우가 많고, 이미지 로딩을 어떻게 구현하느냐에 따라서 사용자 경험의 질이 좌우됩니다.
그러나 이미지를 로딩하는 화면을 안정적으로 빠르게 동작하도록 만들기는 어렵습니다.
캐시, 병렬 처리, 실패 처리 등 개발할 요소가 많습니다. 이 글에서는 Android에서는 어떤 점을 고려해서 이미지 로딩을 구현해야하는지 살펴보겠습니다.
이미지 로딩 과정의 숙제들
네트워크를 통한 이미지 로딩을 구현할 때에는 여러 가지 과제를 해결해야 한다.
불안한 HTTP 클라이언트 실행 환경
원본 이미지는 대부분 HTTP 클라이언트 라이브러리를 사용해서 읽어 온다. Android의 HTTP 통신은 라이브러리나 네트워크 환경에서 불안정한 요소가 많기 때문에 이를 충분히 대비해야 한다.
재시도 처리, 실패 처리도 필요하다. 서버에서 서버로 API를 호출할 때보다 불안정한 네트워크 환경을 이용할 때가 많다. 재시도 횟수와 시도 간격을 깊이 고민해야 한다.
불필요해진 호출은 빠른 시점에 취소해야 한다. 화면 회전이나 이동으로 이미 요청된 호출이 의미가 없어졌는데도 이를 끝까지 수행한다면 메모리, 성능, 배터리가 낭비된다. 특히 네트워크 상태가 좋지 않을 때 요청이 오랫동안 대기한다면 더욱 낭비가 크다. 네트워크 호출을 어떻게 취소할지는 병렬 처리 부분에서 추가로 논의하겠다.
메모리가 넘치거나 새기 쉬운 비트맵 디코딩
네트워크로 이미지를 잘 읽어왔어도 메모리를 다루면서 빠지기 쉬운 함정이 많다.
비트맵의 크기가 클 때는 Out of Memory 에러가 발생하지 않도록 유의해야 한다. 이미지 파일은 디코딩을 거쳐야 화면에 출력된다. 비트맵이 차지하는 메모리의 용량은 이미지의 크기에 비례한다. Galaxy Nexus에서 촬영한 이미지의 크기는 2592x1936픽셀인데, 이는 비트맵으로는 약 19MB(2592 x 1936 x 4바이트)이다.[1] 가용 메모리가 16MB인 기기에서 이 비트맵을 메모리에 올리면 Out of Memory 에러가 발생한다. 따라서 작은 크기로 변환하거나 품질을 낮추어서 디코딩해야 한다. Android는 BitmapFactory.Options 클래스로 그런 기능을 제공한다.
충분하지 않은 병렬 처리
네트워크 호출, 디코딩 처리 등 대기 시간이 긴 작업은 백그라운드 쓰레드에서 수행돼야 한다. 그래서 이미지 로딩에서 비동기 처리, 병렬 처리는 필수다. 여러 이미지를 로딩하는 작업이 직렬로 실행되면 비효율적이고 느리다. 이미지 10개를 한 화면에서 보여줘야 하는데 이미지 1개를 불러올 때 1초가 걸린다면 총 10초 기다려야 한다.
Slow Loading
느린 로딩은 Bitmap을 View에 로딩할 때 생기는 문제이다.
주로, View가 Window를 벗어났음에도 불구하고 다운로드나 Bitmap을 디코딩하는 작업들을 취소하지 않기 때문이다.
예를들어, RecyclerView 에서 View를 재활용할 때도 ImageView는 문제의 소지가 있다. 재활용할 때 ImageView에 이전 이미지의 로딩이 끝나지 않은 시점에서 새로운 이미지를 로딩하라고 요청한다면 이전의 이미지 처리가 다 끝나고 나서야 새로운 이미지가 나타난다. 과거 이미지가 보였다가 사라지므로 사용자에게도 이상해 보이고 성능과 자원에도 손해이다. 그래서 ImageView를 재활용하기 위해서는 앞선 요청은 취소해야 한다.
Gglide와 같은 이미지 로딩 라이브러리는 불필요한 동작을 하는 작업들을 취소하고, 오직 사용자에게 보이는 Image만 로딩하도록한다. 또한 Activity와 Fragment의 Lifecycle을 알고 있어 어떤 이미지들이 취소되어야 하는 지 알고 있다.
Unresponsive UI
안드로이드 앱이 지연되거나 성능이 낮은 주된 이유는 GC를 매우 자주 실행하기 때문이다.
한 줄로 간단하게 설명하면, GC가 실행되는 시간은 실제 앱이 실행되지 않은 시간이다.
기본적으로 안드로이드 앱이 실행되면 코드를 기반으로 많은 객체를 할당하고, 객체가 더 이상 참조되지 않으면 시스템은 해당 객체의 할당을 해제하기 위해 메모리 압박이 발생할 때 GC를 호출한다.
여기서 중요한 점은 GC가 실행되는 시간 동안 앱이 그 시간 동안 실행되지 않는다는 것이다. 따라서 앱이 지연되는 것이며, 이는 사용자에게 나쁜 경험이 되게된다.
실제로 안드로이드 앱은 원활한 UI 렌더링을 위해 16ms마다 UI를 업데이트를하게되는데, GC가 오래 실행되면 앱은 UI를 업데이트할 수 없고 몇 프레임을 건너 뛰게 되어 앱이 지연되는 것처럼 보이게 될 것이다. 이것이 앱 지연의 이유 중 하나이다. 또 다른 이유는 메인스레드에서 너무많은 작업을 할 때이다. 예를 들어, 그 시점에 어떤 메서드/작업이 보다 많은 시간을 소요한다면 16ms, 앱은 UI를 업데이트할 수 없게되고, 이는 그 시간 동안 앱이 지연이 발생한다.
이렇게 프레임이 하나라도 떨어지면 애니메이션이 매끄럽지 않게되고, 사용자는 지연을 느낄 것이다.
이미지 캐시와 View 재활용의 어려움
이미지가 들어간 화면을 만들 때 이미지 캐시와 View를 재활용하지 않는다면 앱은 느리게 반응하고 자원을 많이 소모한다. 따라서 이미지 캐시와 View 재활용이 필수적인데 이를 정교하게 구현하려면 많은 코드가 들어가고, 매번 구현하기도 번거롭다.
화면이 회전될 때에는 Activity 객체가 다시 생성되고 onCreate() 메서드 등이 다시 호출된다. 회전 후에는 회전하기 전의 이미지를 다시 화면에 그려줘야 한다. RecyclerView에서 스크롤 중에 이미지가 화면에서 사라졌다가 다시 나타날 때도 마찬가지이다. 이럴 때 처음과 똑같이 네트워크 호출과 디코딩 과정을 반복한다면 큰 낭비이다. 캐싱된 이미지를 메모리나 디스크에서 읽어야 한다. Android SDK에서는 LruCache와 DiskLruCache라는 클래스를 제공한다.
정리
요약하면, 이미지 로딩을 구현할 때는 HTTP 통신을 안정되게 구현하고, 비트맵으로 디코딩하면서 메모리가 넘치거나 새지 않도록 주의해야 한다. 네트워크 호출과 디코딩은 단순히 백그라운드 스레드에서 동작하는 것만으로는 충분하지 않고 더 적극적으로 병렬성을 활용해야 한다. 화면 회전, 전환, 스크롤 때 반복적인 요청이 가지 않도록 이미지를 캐시하고, 불필요해진 요청은 빠른 시점에 취소해서 더 나은 UI 반응을 제공하면서 자원을 절약해야 한다. 이 과제들을 모두 해결하려다 보면 처리 흐름은 복잡해지고, 비슷한 코드가 반복되기 쉽다.
실제로, 여러 오픈 소스 이미지 로딩 라이브러리들은 Out of Memory, 캐시, 병렬처리, 디코딩, 재시도처리, 로딩실패처리 ,취소의 자동화 등을 간편하게 처리할 수 있도록 도와준다.
이미지 로딩 워크플로
이미지 로딩은 <그림 2>의 순서로 작업을 처리한다.
그림 2 이미지 로딩의 실행 단계
각각의 단계를 살펴보면 다음과 같다.
- 이미지 전처리: 이미지를 로딩하기 전에 섬네일이나 진행 상황을 보여 주기 위한 단계
- 이미지 로딩: 캐시나 네트워크에서 이미지를 가져오는 단계
- 디코딩: BitmapFactory를 이용하여 이미지를 비트맵 형식으로 변환하고 크기, 회전, 품질 등을 변환하는 단계
- 이미지 후처리: 보여 줄 이미지에 애니메이션이나 모서리를 둥글게 하는 등의 효과를 적용하는 단계.
- 보여 주기: UI 스레드에서 이미지를 보여 주는 단계
이 단계들 중 가장 중요한 부분은 이미지를 실제로 외부로부터 가져오는 '2. 이미지 로딩'과 '3. 디코딩' 단계이다. 이 단계들의 자세한 과정을 <그림 3>에서 표현했다.
그림 3 이미지 로딩 단계의 흐름
- 메모리 캐시에서 비트맵을 가져온다.
- 메모리 캐시에 비트맵이 있으면(cache hit), 이미지 후처리 단계로 진행한다.
- 메모리 캐시에 비트맵이 없으면(cache miss), 디스크 캐시에서 이미지를 가져온다.
- 디스크 캐시에 이미지가 있으면(cache hit), 비트맵으로 디코딩 후 비트맵을 메모리 캐시에 저장한다. 다음으로 이미지 후처리 단계를 진행한다.
- 디스크 캐시에 이미지가 없으면(cache miss), 이미지를 외부(네트워크, 리소스 등)에서 다운로드 한다.
- 이미지를 다운로드한 후, 디스크 캐시에 이미지를 저장한다.
- 이미지를 비트맵으로 디코딩한 후 비트맵을 메모리 캐시에 저장한다. 그리고 이미지 후처리 단계로 진행한다.
앞에서 말한 것처럼 네트워크나 파일 I/O, 이미지 디코딩 작업은 백그라운드에서 일어난다
메모리 캐시
메모리 캐싱은 데이터를 메모리에 저장하여, 빠르게 접근할 수 있도록 하는 방법이다. 메모리 캐싱은 접근 속도가 빠르다는 장점이 있지만, 메모리 용량이 제한적이기 때문에 많은 데이터를 저장할 수 없다는 단점이 있다.
안드로이드에서 메모리 캐싱을 구현할 때는 LruCache 클래스를 사용할 수 있다. LruCache는 가장 최근에 사용된 데이터를 우선적으로 캐싱하고, 오래된 데이터를 삭제하는 알고리즘을 사용한다. (실제로 Glide의 경우 BitmapPool이라는 LRU Cache를 구현하여 메모리 캐시로 사용하고 있다.)
public class MyCache {
private LruCache memoryCache;
public MyCache() {
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
final int cacheSize = maxMemory / 8;
memoryCache = new LruCache(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getByteCount() / 1024;
}
};
}
public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap);
}
}
public Bitmap getBitmapFromMemCache(String key) {
return memoryCache.get(key);
}
}
위 코드는 LruCache를 사용하여 비트맵 이미지를 메모리에 캐싱하는 예제이다. 비트맵 이미지는 메모리 용량을 많이 차지하기 때문에, 효율적인 캐싱이 필요하다.
메모리 캐싱은 접근 속도가 빠르기 때문에, 자주 사용되는 데이터를 캐싱하는 데 유용하다. 하지만 메모리 용량이 제한적이기 때문에, 많은 데이터를 저장할 수 없다는 점을 고려해야 한다.
결과적으로, 메모리 캐시에서 이미지를 가져오는 속도는 네트워크나 다른 곳에서 가져올 때보다 빨라야 한다. 따라서 I/O나 디코딩 오버헤드를 없애기 위해 모든 이미지 로딩 라이브러리의 메모리 캐시는 비트맵을 캐싱하고 메인 스레드에서 동기적으로 불러온다.
디스크 캐시
디스크 캐시는 이미지 로더의 2차 캐시이다. 메모리보다 빠르지는 않지만, 그래도 충분히 빠르며 더 넓은 공간을 활용한다. 영속성이 있어서 앱이 다시 실행되어도 이미지를 재사용할 수 있다.
안드로이드에서 디스크 캐싱을 구현할 때는 DiskLruCache 라이브러리를 사용할 수 있다. DiskLruCache는 LruCache와 유사한 알고리즘을 사용하여, 오래된 데이터를 삭제하고 새로운 데이터를 저장한다.
다음은 DiskLruCache를 사용하여 디스크 캐싱을 구현하는 예제이다.
public class DiskCache {
private DiskLruCache diskLruCache;
public DiskCache(Context context) throws IOException {
File cacheDir = getDiskCacheDir(context, "bitmap");
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
diskLruCache = DiskLruCache.open(cacheDir, 1, 1, 10 * 1024 * 1024); // 10MB
}
public void addBitmapToDiskCache(String key, Bitmap bitmap) throws IOException {
DiskLruCache.Editor editor = diskLruCache.edit(key);
if (editor != null) {
OutputStream out = editor.newOutputStream(0);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
editor.commit();
out.close();
}
}
public Bitmap getBitmapFromDiskCache(String key) throws IOException {
Bitmap bitmap = null;
DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
if (snapshot != null) {
InputStream in = snapshot.getInputStream(0);
bitmap = BitmapFactory.decodeStream(in);
in.close();
}
return bitmap;
}
private File getDiskCacheDir(Context context, String uniqueName) {
String cachePath = context.getCacheDir().getPath();
return new File(cachePath + File.separator + uniqueName);
}
}
위 코드는 DiskLruCache를 사용하여 비트맵 이미지를 디스크에 캐싱하는 예제이다. 비트맵 이미지는 메모리 용량을 많이 차지하기 때문에, 디스크에 저장하여 효율적으로 관리할 필요가 있다.
디스크 캐싱은 많은 데이터를 저장할 수 있기 때문에, 자주 사용되지 않는 데이터를 캐싱하는 데 유용하다. 하지만 접근 속도가 느리기 때문에, 자주 사용되는 데이터는 메모리 캐싱을 사용하는 것이 좋다.
메모리 캐싱과 다르게 디스크 캐싱에서는 비트맵이 아닌 이미지를 저장한다.
그 이유는 아래와 같다.
첫째, 이미지를 다운로드하면서 HTTP 클라이언트가 Response를 알아서 캐싱하기 때문이다. HTTP 프로토콜의 'expires'나 'max-age' 등 캐시 관련 헤더에 따라서 자연스럽게 원본 이미지를 캐싱할 수 있다.
둘째, 디코딩한 비트맵을 이미지로 저장하려면 Bitmap 클래스의 compress() 메서드를 사용해야 하기 때문이다. compress() 메서드는 비트맵을 이미지로 변환한다.
셋째는 용량 문제이다. 굳이 원본 크기로 비트맵을 저장하지 않는다고 가정하더라도 이미지와 비교하면 비트맵은 많은 용량을 차지한다.
번외. JPEG VS PNG
JPEG (Joint Photograph Expert Group)
- 정지 화상을 위해 만들어진 손실 압축 파일 형식이다. 약간의 손실을 감수하고 작은 크기로 사진을 저장하는데 유용하고 상대적으로 훨씬 작기에 웹에서 많이 사용된다.
PNG (Portable Network Group)
- 무손실 압축 파일 형식이다. 일반적으로 텍스트, 선, 아이콘, 로고 등을 저장하는데 적합하고 투명 배경이 필요한 경우에도 사용된다.
JPGE, PNG 개념을 보다보니 이미지 압축 방식에 앞서서 우린
“손실 vs 무손실”에 대한 개념부터 파악하는게 먼저 일 것 같다.
A가 B에게 이미지를 전송하는데 정말 이미지가 깨지지 않고 완벽한 상태로 갔으면 좋겠다. 이미지를 전달받은 B가 압축을 풀더라도 그대로의 상태를 유지하려면 A는 보낼 이미지를 무손실을 택해야 한다.
물론, 그 반대로 그리 중요하지 않다면 손실을 택하면 된다.
참조문서
https://d2.naver.com/helloworld/429368
https://onlyfor-me-blog.tistory.com/537
[Android] 이미지 캐시
이미지 캐시에 관해선 Glide 등 성능이 검증된 여러 좋은 오픈소스 라이브러리들이 많이 있고 나도 Glide를 위주로 사용하고 있다. 그러나 이미지 캐시라는 근본적인 개념을 모른 채 개발하는 건
onlyfor-me-blog.tistory.com
https://developer.android.com/topic/performance/graphics/cache-bitmap?hl=ko
비트맵 캐싱 | App quality | Android Developers
단일 비트맵을 사용자 인터페이스(UI)에 로드하는 것은 간단하지만 한 번에 더 큰 이미지의 집합을 로드해야 하면 더 복잡해집니다. 많은 경우(ListView, GridView 또는 LruCache 클래스와 같은 구성요소
developer.android.com
https://f-lab.kr/insight/efficient-android-caching-strategies-20240606
효율적인 안드로이드 앱 캐싱 전략
안드로이드 앱에서 효율적인 캐싱 전략을 구현하는 방법에 대해 다룹니다. 메모리 캐싱, 디스크 캐싱, 네트워크 캐싱의 구현 방법과 장단점을 소개합니다.
f-lab.kr
https://velog.io/@jshme/Android-Hello-Out-Of-Memory
[Android] Hello👋, Out Of Memory
많은 이미지를 사용하거나 고해상도 이미지를 이미지뷰에 로드해야하는 경우 발생하게 되는데, 이는 안드로이드 앱에서 사용할 수 있는 힙 메모리는 정해져있는데 반해 그 크기를 넘겨버렸기
velog.io
https://yoon-dailylife.tistory.com/110
Android) 이미지 로딩 라이브러리 자세히 알아보자
안드로이드에서 ImageView에 이미지를 로딩할 때 자주 직면하는 문제 Out of Memory Slow Loading of Image into the View UI becomes unresponsive. Not Smoothing Scrolling Out of Memory 종종 고퀄리티의 이미지 등을 로딩하거
yoon-dailylife.tistory.com
https://outcomeschool.com/blog/android-image-loading-library-use-bitmap-pool-for-responsive-ui
How does the Android Image Loading library use the bitmap pool for responsive UI?
In this blog, we are going to learn how the Android Image Loading library uses the bitmap pool to make the UI responsive.
outcomeschool.com
'Android > Image Loading' 카테고리의 다른 글
[Android] 이미지 로딩 라이브러리 (Glide vs Picasso / Coil) (0) | 2025.01.22 |
---|---|
[Android] 어떻게 이미지 로드를 최적화하면 좋을까?(2) - LRU Cache 내부동작 살펴보자 (0) | 2025.01.21 |
[Android] 어떻게 이미지 로드를 최적화하면 좋을까?(1) - Down Sampling (1) | 2025.01.21 |
[Android] 비트맵(Bitmap)이란? (0) | 2025.01.20 |