연속 스크롤을 위한 멀티 레벨 이미지 캐싱
연속 스크롤을 위한 멀티 레벨 이미지 캐싱
웹툰 리더 애플리케이션은 모바일 플랫폼에서 가장 까다로운 이미지 로딩 과제 중 하나입니다. 사용자는 고해상도 아트워크로 이루어진 세로 연속 스트립을 수직으로 스크롤하며, 한 화(chapter)당 수십 개의 개별 비트맵 타일을 넘나듭니다. 이때 실제 종이를 넘기듯 매끄러운 경험을 기대하기 마련입니다. 로딩 지연이 눈에 보이거나, 프레임이 드롭되거나, 빈 플레이스홀더가 노출되면 하나의 연속된 캔버스라는 몰입감이 깨져 버립니다. 따라서 웹툰 리더의 캐싱 아키텍처는 두 가지 문제를 동시에 해결해야 합니다. 첫째, 디코딩된 비트맵을 UI 스레드에 충분히 빠르게 전달하여 프레임 드롭(jank)을 방지하는 것이고, 둘째, 이미 읽었던 콘텐츠를 다시 방문할 때 불필요한 네트워크 다운로드를 피하는 것입니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
- 2단계(two-tier) 캐싱 시스템의 조회 우선순위와 각 계층이 존재하는 이유를 설명할 수 있습니다.
- LRU 메모리 캐시가 디코딩된 비트맵을 저장하는 방식과, 퇴거 정책(eviction policy)이 스크롤 위치와 어떻게 상호작용하는지 기술할 수 있습니다.
- 디스크 캐시가 네트워크 소비를 줄이고 오프라인 접근을 가능하게 하는 역할을 파악할 수 있습니다.
- 이미지 타일이 네트워크 요청에서 디스크 저장, 메모리 승격(promotion), 최종 퇴거에 이르기까지의 전체 생명주기를 추적할 수 있습니다.
BitmapPool과 타일 기반 디코딩 기법을 활용하여 빠른 스크롤 시 메모리 할당 부하를 줄이는 방법을 적용할 수 있습니다.
캐싱 계층 구조와 조회 순서
프로덕션 수준의 웹툰 리더는 모든 이미지 타일을 엄격한 3단계 조회를 통해 처리합니다. 렌더링 파이프라인이 특정 타일이 화면에 진입하려는 시점을 감지하면, 각 계층을 순서대로 조회하고 첫 번째 히트가 발생한 시점에서 멈춥니다.
- 메모리 캐시: 타일 식별자를 키로 하여 완전히 디코딩된
Bitmap객체를 저장하는 인프로세스(in-process) LRU 맵입니다. 히트 시 해시 조회와 참조 복사만 발생하며, 비트맵이 이미 캔버스에 바로 그릴 수 있는 형태이므로 추가 작업이 필요 없습니다. - 디스크 캐시: 기기의 내부 또는 외부 캐시 파티션에 저장된 압축 이미지 파일(JPEG 또는 WebP)의 크기 제한 디렉터리입니다. 히트 시 네트워크 왕복은 피할 수 있지만, 비트맵을 그리기 전 백그라운드 디코딩 단계가 필요합니다.
- 네트워크: 오리진 서버 또는 CDN입니다. 가장 느린 경로이며 사용자의 데이터 사용량을 소모합니다. 성공적으로 가져온 뒤에는 원본 바이트를 디스크 캐시에 기록하고, 비트맵으로 디코딩하여 메모리 캐시에 삽입합니다. 이렇게 하면 이후 동일 타일 요청이 더 빠른 계층에서 처리됩니다.
이 계층 구조 덕분에 처음 한 화를 읽을 때는 모든 타일이 네트워크에서 메모리까지의 전체 경로를 거치지만, 같은 세션 내에서 동일 타일을 다시 스크롤하면 마이크로초 단위로 메모리에서 바로 처리됩니다. 메모리 캐시가 비워진 이후 다른 세션에서 해당 화를 다시 열면, 타일이 다시 다운로드되는 대신 디스크에서 밀리초 단위로 처리되어 체감 속도 차이가 큽니다.
면접에서 캐싱 전략을 설명할 때는, 단순히 "메모리 캐시와 디스크 캐시를 사용합니다"라고 답하기보다 각 계층의 존재 이유와 조회 순서까지 함께 설명하면 훨씬 깊이 있는 답변이 됩니다.
메모리 캐시: UI 프레임 드롭 방지
메모리 캐시는 스크롤 성능을 위한 가장 핵심적인 계층입니다. 화면에 보이는 뷰포트와 그 바로 인접한 영역에 있는 모든 타일이 이미 디코딩되어 그리기(draw) 단계에서 즉시 사용 가능하도록 보장하는 역할을 합니다. 백그라운드 스레드 개입이 전혀 없어야 한다는 점이 핵심입니다.
표준 구현 방식은 애플리케이션 힙의 일정 비율로 크기를 설정한 LruCache입니다. 비트맵 타일은 해상도에 따라 크기가 달라지므로, 캐시의 용량은 항목 개수가 아닌 바이트 단위로 측정해야 합니다. sizeOf를 오버라이드하여 bitmap.byteCount를 반환하면, 고해상도 패널 하나가 전체 예산을 조용히 소진하는 상황을 방지할 수 있습니다.
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSize = maxMemory / 8 // 힙의 1/8을 캐시로 할당
val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024 // KB 단위로 반환
}
}
LRU 퇴거 정책은 수직 스크롤 패턴과 자연스럽게 맞아떨어집니다. 사용자가 아래로 스크롤하면 뷰포트 상단 근처의 타일이 최근 사용 목록에서 밀려나 먼저 퇴거되고, 뷰포트 바로 아래의 타일이 새로 가져와져 삽입됩니다. 결과적으로 캐시는 현재 스크롤 위치를 중심으로 디코딩된 비트맵의 슬라이딩 윈도우를 유지하게 됩니다. 대략 2~3 화면 분량의 타일을 담을 수 있는 크기로 설정하면, 예측 프리페치(predictive prefetch)에 충분한 여유 공간을 확보하면서도 힙을 독점하지 않을 수 있습니다.