Landscapist Core의 내부 동작 원리 심층 분석
Landscapist Core의 내부 동작 원리 심층 분석
Landscapist Core는 코틀린 멀티플랫폼(Kotlin Multiplatform)을 위해 처음부터 새로 설계된 독립형 이미지 로딩 엔진입니다. Landscapist가 제공하는 Coil, Glide, Fresco 래퍼(wrapper)와 달리,
Landscapist Core는 이미지 가져오기(fetching), 캐싱(caching), 디코딩(decoding), 변환(transformation)을 모두 자체적으로 처리합니다. 플랫폼별 의존성을 제거하고, 이미지 로딩의 모든 과정을 세밀하게 제어할 수 있다는 점이 가장 큰 특징입니다.
이 글에서는 Landscapist Core의 내부 아키텍처를 집중적으로 살펴봅니다. Landscapist 클래스가 로딩 파이프라인을 어떻게 조율하는지, TwoTierMemoryCache가 약한 참조(weak reference)를 활용하여 제거된 항목에 두 번째 기회를 부여하는 방식, DecodeScheduler가 백그라운드 로딩보다 화면에 보이는 이미지를 우선 처리하는 원리, 프로그레시브 디코딩(progressive decoding)이 체감 로딩 속도를 어떻게 높이는지, 그리고 메모리 압력(memory pressure) 상황에서 앱 반응성을 유지하는 메커니즘까지 단계별로 상세히 분석합니다.
Landscapist 오케스트레이터
Landscapist 클래스는 이미지 로딩의 핵심 진입점(entry point)입니다. 이미지 가져오기, 캐싱, 디코딩, 변환 작업을 하나의 통합된 파이프라인으로 조율하는 역할을 합니다.
public class Landscapist private constructor(
public val config: LandscapistConfig,
private val memoryCache: MemoryCache,
private val diskCache: DiskCache?,
private val fetcher: ImageFetcher,
private val decoder: ImageDecoder,
private val dispatcher: CoroutineDispatcher,
public val requestManager: RequestManager = RequestManager(),
public val memoryPressureManager: MemoryPressureManager = MemoryPressureManager(),
)
각 컴포넌트는 단일 책임 원칙(Single Responsibility Principle)에 따라 명확한 역할을 담당합니다. memoryCache는 디코딩된 이미지를 메모리에 저장하고, diskCache는 원본 이미지 데이터를 디스크에 영속화합니다. fetcher는 네트워크 또는 로컬 소스에서 이미지를 가져오며, decoder는 원시 바이트(raw bytes)를 화면에 표시 가능한 이미지로 변환합니다. requestManager는 활성 요청(active request)을 추적하여 필요에 따라 취소할 수 있게 해 주고, memoryPressureManager는 시스템 메모리 부족 경고에 반응하여 적절한 조치를 취합니다.
로딩 파이프라인
load 함수는 3단계 조회(lookup)와 프로그레시브 확장(progressive enhancement)을 조합한 구조로 구현되어 있습니다.
public fun load(request: ImageRequest): Flow<ImageResult> = flow {
emit(ImageResult.Loading)
val cacheKey = CacheKey.create(
model = request.model,
transformationKeys = request.transformations.map { it.key },
width = request.targetWidth,
height = request.targetHeight,
)
// 1. 메모리 캐시 확인 (즉시 응답)
if (request.memoryCachePolicy.readEnabled) {
memoryCache[cacheKey]?.let { cached ->
emit(ImageResult.Success(data = cached.data, dataSource = DataSource.MEMORY))
return@flow
}
}
// 2. 디스크 캐시 확인
if (request.diskCachePolicy.readEnabled && diskCache != null) {
diskCache.get(cacheKey)?.use { snapshot ->
val bytes = snapshot.data().buffer().readByteArray()
// 디코딩 후 방출(emit)...
}
}
// 3. 네트워크에서 가져오기
val fetchResult = fetcher.fetch(request)
// 결과 처리...
}.flowOn(dispatcher)
파이프라인은 항상 동일한 순서를 따릅니다. 메모리 캐시를 가장 먼저 확인하고(즉시 응답), 디스크 캐시를 그다음으로 확인하며(빠른 I/O), 마지막으로 네트워크에서 가져옵니다(가장 느림). 각 단계는 CachePolicy를 통해 개별적으로 활성화 또는 비활성화할 수 있어, 강제 새로고침이나 캐싱을 완전히 건너뛰어야 하는 특수한 사용 사례(use case)에도 유연하게 대응할 수 있습니다.
캐시 키 생성
CacheKey는 이미지의 최종 외형에 영향을 미치는 모든 요소를 기반으로 캐시된 이미지를 고유하게 식별합니다.
val cacheKey = CacheKey.create(
model = request.model,
transformationKeys = request.transformations.map { it.key },
width = request.targetWidth,
height = request.targetHeight,
)
동일한 URL이라도 요청 크기가 다르면 서로 다른 캐시 키가 생성됩니다. 같은 URL과 크기라 하더라도 적용하는 변환(transformation)이 다르면 역시 서로 다른 키를 갖습니다. 덕분에 캐시는 항상 정확히 요청한 이미지 변형(variant)을 반환하며, 변환이나 크기가 다를 때 잘못된 결과가 반환되는 문제를 방지합니다.
2단계 메모리 캐시(Two-Tier Memory Cache)
TwoTierMemoryCache는 캐싱에서 흔히 발생하는 문제를 해결합니다. 일반적인 캐시는 용량이 가득 차면 가장 오래된 항목을 제거하는데, 제거된 항목이 앱의 다른 곳에서 여전히 참조되고 있더라도 소실됩니다. 2단계 방식은 약한 참조(weak reference)를 통해 "두 번째 기회"를 제공하여 이 문제를 효과적으로 완화합니다.
public class TwoTierMemoryCache(
private var _maxSize: Long,
private val weakReferencesEnabled: Boolean = true,
) : MemoryCache {
private val strongCache = linkedMapOf<String, CachedImage>()
private val weakCache = mutableMapOf<String, WeakRef<CachedImage>>()
private val currentSize = atomic(0L)
강한 캐시(strong cache)는 메모리 예산 내에서 LRU(Least Recently Used) 방식으로 이미지를 보유합니다. 약한 캐시(weak cache)는 최근 제거된 이미지에 대한 참조를 메모리 예산에 포함시키지 않으면서 유지합니다. 이 두 계층의 조합이 바로 핵심적인 설계 포인트입니다.
캐시 제거(eviction) 동작 원리
강한 캐시에서 항목이 제거되면 약한 캐시로 이동합니다.
private fun evictOldest() {
val iterator = strongCache.entries.iterator()
if (iterator.hasNext()) {
val eldest = iterator.next()
iterator.remove()
currentSize.addAndGet(-eldest.value.sizeBytes)
// 약한 참조가 활성화되어 있으면 약한 캐시로 이동
if (weakReferencesEnabled) {
weakCache[eldest.key] = WeakRef(eldest.value)
}
}
}
약한 참조는 가비지 컬렉션(garbage collection)을 방해하지 않습니다. 시스템이 메모리를 회수해야 한다면 GC가 해당 이미지를 자유롭게 수거합니다. 하지만 이미지가 아직 메모리에 존재한다면(예를 들어, UI 컴포넌트가 해당 이미지를 보유하고 있는 경우), 약한 참조를 통해 디스크나 네트워크 접근 없이 즉시 복원할 수 있습니다.
캐시 히트(cache hit) 경로
키를 조회할 때 캐시는 두 계층을 순서대로 확인합니다.
override fun get(key: CacheKey): CachedImage? = synchronized(lock) {
val memoryKey = key.memoryKey
// 먼저 강한 캐시 확인
strongCache.remove(memoryKey)?.let { image ->
strongCache[memoryKey] = image // 접근 순서 갱신을 위해 재삽입
return@synchronized image
}
// 약한 캐시 확인 (활성화된 경우)
if (weakReferencesEnabled) {
weakCache[memoryKey]?.get()?.let { image ->
// 강한 캐시로 승격(promote)
weakCache.remove(memoryKey)
evictIfNeeded(image.sizeBytes)
strongCache[memoryKey] = image
currentSize.addAndGet(image.sizeBytes)
return@synchronized image
}
// GC로 수거된 약한 참조 정리
weakCache.remove(memoryKey)
}
null
}