안드로이드 UI 대세인 컴포즈를 사용할 때에는 좀 더 스테이블한 Coil 라이브러리를 사용한다.
하지만 xml을 통해 뷰 시스템을 사용하는 경우에는 캐시 성능이 뛰어난 Glide를 사용하는데, 오늘은 Glide의 디스크/메모리 캐시가 내부적으로 어떻게 구현되어 있는지에 대해 살펴보고자 한다.
우선, Glide의 캐시는 크게 메모리 캐시와 디스크 캐시로 구성되어 있다.
우리는 diskCacheStrategy() 메서드를 통해 디스크 캐시 전략을, isMemoryCacheable/skipMemoryCache 메서드 등을 통해 메모리 캐시 사용 여부를 설정할 수 있다.
0. 전체 이미지 로딩 과정
- ActiveResources 조회
- MemoryCache 조회
- DishCache 조회
- 네트워크/파일 로딩
Glide를 통해 이미지를 로딩할 때, 캐싱이 적용되어 있다면 위와 같은 과정을 거쳐 리소스를 재사용하게 된다.
위 조회 순서는, 빠르고 가벼운 경로부터 점진적으로 무거운 경로로 넘어가도록 설계되었다.
현재 화면에서 사용중인 요소들부터 시작해서 무거운 경로 순으로 탐색함으로써 지연 시간을 최소화한다.
1. 메모리 캐시
LRU 알고리즘 기반 리소스 캐시
Glide는 LruResourceCache를 기본 메모리 캐시로 사용하며, ActiveResources에서 useCount가 0이 된 리소스들을 보관한다.
LruResourceCache는 내부적으로 LruCache를 사용해서 최근에 참조된 요소들을 우선적으로 보존하고, 메모리 최대 크기를 넘어가면 가장 오랫동안 참조되지 않은 항목부터 제거(evict)한다.(클래스 내부에 onItemEvicted 메서드가 존재)
LruCache<Key, Resource<?>>
-> 여기서 Key는 이미지 url, size 등의 여러 정보를 포함하여 해시로 생성된다
캐시에 저장되는 정보는 Bitmap이나 Drawable을 래핑한 Resource<?> 객체이다.
ActiveResources
만약 메모리 캐시에 저장된 리소스가 현재 화면에서 사용중이라면, 이를 빠르게 재사용하기 위해 ActiveResources라는 객체에서 참조하게 된다.
final class ActiveResources {
private final boolean isActiveResourceRetentionAllowed;
private final Executor monitorClearedResourcesExecutor;
@VisibleForTesting final Map<Key, ResourceWeakReference> activeEngineResources = new HashMap<>();
private final ReferenceQueue<EngineResource<?>> resourceReferenceQueue = new ReferenceQueue<>();
. . .
synchronized void activate(Key key, EngineResource<?> resource) {
ResourceWeakReference toPut =
new ResourceWeakReference(
key, resource, resourceReferenceQueue, isActiveResourceRetentionAllowed);
ResourceWeakReference removed = activeEngineResources.put(key, toPut);
if (removed != null) {
removed.reset();
}
}
synchronized void deactivate(Key key) {
ResourceWeakReference removed = activeEngineResources.remove(key);
if (removed != null) {
removed.reset();
}
}
}
ActiveResources는 내부적으로 Map 객체를 가지며, 현재 화면에서 사용중인지 여부에 따라 activate/deactivate 메서드를 통해 내부 Map에 추가하거나 제거하는 과정을 거친다.
Map과 약한 참조를 사용함으로써 얻는 이점
현재 화면에서 사용중인 이미지를 사용해야 할 때, Map을 통해서 빠르게 그에 맞는 EngineResource를 찾을 수 있고
ResourceWeakReference 객체로 EngineResource를 감싸서 뷰가 해당 리소스를 사용중이라면 GC되지 않도록 보장한다.
이후 deactivate() 메서드가 호출되어 강한 참조가 제거되면, 해당 리소스를 weakly reachable 상태로 만들어 다음 GC 사이클에서 메모리가 회수되도록 한다.
@Synthetic
void cleanupActiveReference(@NonNull ResourceWeakReference ref) {
synchronized (this) {
activeEngineResources.remove(ref.key);
if (!ref.isCacheable || ref.resource == null) {
return;
}
}
EngineResource<?> newResource =
new EngineResource<>(
ref.resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ false, ref.key, listener);
listener.onResourceReleased(ref.key, newResource);
}
메모리 관리 메커니즘
(1) 백그라운드 정리 스레드
Glide는 ActiveResources가 관리하던 EngineResource가 현재 화면에서 더 이상 사용되지 않음을 JVM 레벨에서 감지하고 메모리를 회수하는 작업을 보장하기 위해서 이를 전담하는 백그라운드 스레드를 사용하는데, 구현은 아래와 같다.
단일 스레드 풀을 만들고 백그라운드 우선순위를 지정하고, 스레드 명을 "glide-active-resources"로 설정한 것을 볼 수 있다.
ActiveResources(boolean isActiveResourceRetentionAllowed) {
this(
isActiveResourceRetentionAllowed,
java.util.concurrent.Executors.newSingleThreadExecutor(
new ThreadFactory() {
@Override
public Thread newThread(@NonNull final Runnable r) {
return new Thread(
new Runnable() {
@Override
public void run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
r.run();
}
},
"glide-active-resources");
}
}));
}
@VisibleForTesting
ActiveResources(
boolean isActiveResourceRetentionAllowed, Executor monitorClearedResourcesExecutor) {
this.isActiveResourceRetentionAllowed = isActiveResourceRetentionAllowed;
this.monitorClearedResourcesExecutor = monitorClearedResourcesExecutor;
monitorClearedResourcesExecutor.execute(
new Runnable() {
@Override
public void run() {
cleanReferenceQueue();
}
});
}
(2) cleanReferenceQueue 메서드
GC가 발생해서 강한 참조가 해제된 EngineResource 객체가 수거되면 그걸 래핑하는 ResourceWeakReference 객체가 enqueue되는데,
이후 remove한 객체를 cleanupActiveReference 메서드에 아규먼트로 전달하여 ActiveResources 내부 Map과 메모리 캐시를 최신화하게 된다.
@Synthetic
void cleanReferenceQueue() {
while (!isShutdown) {
try {
ResourceWeakReference ref = (ResourceWeakReference) resourceReferenceQueue.remove();
cleanupActiveReference(ref); << 내부 Map과 메모리 상태 최신화
// This section for testing only.
DequeuedResourceCallback current = cb;
if (current != null) {
current.onResourceDequeued();
}
// End for testing only.
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
(3) cleanupActiveReference 메서드
remove된 ResourceWeakReference 객체를 아규먼트로 받는 메서드이다.
이 메서드의 목적은 GC되어 수거된 리소스가 ActiveResources 내부 Map 객체에 남아있을 때 이를 제거하는 것이다.
@Synthetic
void cleanupActiveReference(@NonNull ResourceWeakReference ref) {
synchronized (this) {
activeEngineResources.remove(ref.key);
if (!ref.isCacheable || ref.resource == null) {
return;
}
}
EngineResource<?> newResource =
new EngineResource<>(
ref.resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ false, ref.key, listener);
listener.onResourceReleased(ref.key, newResource);
}
아규먼트의 isCacheable이 true이고, resource가 null이 아닐 때, 해당 객체를 새로이 EngineResource 객체로 래핑해서 MemoryCache에 삽입하게 된다.
이로 인해 "GC가 발생해서 수거된 리소스"라고 해도, 아직 사용 가능하다면 메모리 캐시에 보존하여 이후 요청이 발생할 때 다시 디코딩할 필요 없이 바로 재사용이 가능하다.(EngineResource 객체가 GC 되어도 내부 Resource<?> 객체는 메모리 캐싱하여 재사용)
2. 디스크 캐시
Glide의 디스크 캐시는 DiskLruCacheWrapper를 통해 구현된다.
파라미터인 Key를 바이트 해시로 변환해서 파일명으로 사용하며, 디스크에 저장할 때 최대 크기까지 저장할 수 있으며,
최대 크기를 초과할 경우 메모리 캐싱과 동일하게 LRU 알고리즘으로 오래전에 참조된 파일을 삭제하게 된다.
SafeKey 생성
이미지 url, 크기, 서명 등을 조합해서 만든 Key 객체를 파일명으로 사용할 수 없기 때문에, SHA-256 기반 해시를 이용해 디스크에 안전한 문자열로 변환한다.
DiskLruCacheWrapper
어플리케이션이 처음으로 Glide 디스크 캐시를 사용할 때, 디렉토리와 최대 크기를 기반으로 DiskLruCache 인스턴스를 lazy하게 생성한다.
그리고 이 DiskLruCache 인스턴스로부터 get/edit 메서드를 호출해서 디스크 캐시에서 데이터를 읽어오거나 쓰게 된다.
DiskCacheStrategy 비교
디스크 캐시를 설정할 때, 4가지 전략 중 한 가지를 선택할 수 있다. 디폴트는 Automatic으로, 상황에 따라 원본 이미지와 변환된 이미지 중 하나를 자동으로 선택해서 캐싱하는 전략이다.
변환이나 크기 조정이 있다면 Resource를 캐싱, 그렇지 않다면 Data를 캐싱하는 식이다.
(1) DiskCacheStrategy.ALL
원본 데이터와 변환된 이미지를 모두 디스크에 캐시하는 옵션이다. 다음 로딩 시에 네트워크/로컬 읽기 과정 없이 바로 사용할 수 있지만, 원본과 변환본을 모두 저장하니만큼 디스크 사용량이 증가한다는 단점이 있다.
(2) DiskCacheStrategy.RESOURCE
변환된 이미지만 디스크에 캐시하는 옵션이다. (원본 이미지에 옵션/크기 변경 적용된 Bitmap을 저장)
실제 화면에 보여질 결과물만 저장하므로 효율적이고, 동일한 옵션으로 여러 번 요청이 들어와도 변환 작업을 생략할 수 있다는 장점이 있다.
하지만 옵션이 달라지면 캐시 미스가 발생하므로, 디코딩 과정을 다시 거쳐야 한다는 단점이 있다. 또한 원본 데이터가 필요할 경우에 다시 읽어와야 한다.
(3) DiskCacheStrategy.DATA
원본 이미지만 디스크에 캐싱하는 옵션이다. (옵션/크기 변경 적용 전인 바이트 스트림을 저장)
옵션이나 크기 변경이 발생해도 바로 적용이 가능하며, 디스크 사용량이 적다는 장점이 있다.
하지만 매 로딩 시마다 원본 이미지를 디코딩/변환하는 과정을 거쳐야 하므로 CPU 부담이 증가한다는 단점이 있다.
(4) DiskCacheStrategy.NONE
디스크에 캐시하지 않도록 설정하는 옵션이다. 일회성이거나 보안이 중요한 이미지에 적합한 옵션이다.
하지만 매번 네트워크/로컬 로딩이 발생하므로 로딩 속도가 느리고, 네트워크 트래픽이 증가하게 되므로 필요한 경우가 아니라면 사용할 일이 없는 옵션이다.
3. 다른 이미지 로딩 라이브러리의 캐싱
'개발 > Android' 카테고리의 다른 글
| [KMP] kotlinx-datetime 라이브러리 활용하기 (1) | 2025.11.04 |
|---|---|
| [안드로이드] Bundle의 장점과 내부 동작 방식을 알아보자 (1) | 2025.08.19 |
| [안드로이드] Parcelable을 사용하는 이유(feat.직렬화) (1) | 2025.04.15 |
| [안드로이드] Context와 메모리 누수 (0) | 2025.04.07 |
| [안드로이드] Hilt 어노테이션 프로세싱 - Dagger를 휘두르는 방법 (0) | 2025.03.25 |