하이브리드 모바일 앱에서 이미지 캐싱 및 Lazy Loading 마스터하기
I’m ready to translate the article for you, but I don’t see the text you’d like translated—only the source line is provided. Could you please paste the content you want translated into Korean? Once I have the text, I’ll keep the source line unchanged and preserve all formatting, markdown, and technical terms as requested.
기본 WebView 캐싱의 문제점
표준 <img> 태그에만 의존하면 리소스 관리를 기본 모바일 브라우저 엔진(iOS의 WKWebView, Android의 Chromium)에 맡기게 됩니다. 이로 인해 여러 문제가 발생합니다:
- 과도한 캐시 제거 – WebView는 시스템 메모리를 확보하기 위해 캐시를 정기적으로 비우며, 이로 인해 사용자는 방금 본 아바타와 썸네일을 다시 다운로드해야 합니다.
- 중복 요청 – 같은 이미지가 화면에 여러 번 나타나면 브라우저가 동시에 여러 네트워크 요청을 시작할 수 있습니다.
- 메모리 관리 미비 – 대용량 이미지를 정리 없이 가져오면 메모리 제한으로 앱이 충돌할 수 있습니다.
Source:
솔루션: 커스텀 LRU Blob 캐시
WebView 제한을 우회하는 가장 효과적인 방법은 이미지 fetch 과정을 가로채서 이미지를 Blob 데이터로 다운로드하고, 로컬 메모리에 Object URL 형태로 저장하는 것입니다. 제공된 ImageCacheService는 Least Recently Used (LRU) 캐싱 전략을 사용해 이를 우아하게 처리합니다.
1. 전역 캐시 서비스
이 아키텍처의 핵심은 루트 레벨에서 제공되는 Angular 서비스입니다.
- Map 기반 저장 – 표준 JavaScript
Map을 사용해 원본 URL과 생성된 로컬 Object URL 간의 매핑을 저장합니다. - LRU Bump – 이미지가 요청되면 서비스는 캐시에서 존재 여부를 확인합니다. 존재한다면 해당 엔트리를 삭제하고 다시 삽입해
Map의 “뒤쪽”으로 이동시켜 가장 최근에 사용된 것으로 표시합니다. - 메모리 관리 – 캐시에는 고정 한도(
MAX_CACHE_SIZE = 100)가 있습니다. 한도에 도달하면 가장 오래된 엔트리(Map의 첫 번째 키)를 찾아URL.revokeObjectURL()을 호출해 브라우저 메모리를 해제하고 해당 엔트리를 삭제합니다.
@Injectable({ providedIn: 'root' })
export class ImageCacheService {
private readonly MAX_CACHE_SIZE = 100;
private cache = new Map(); // original URL → object URL
private pending = new Map>(); // ongoing fetches
async get(url: string): Promise {
if (this.cache.has(url)) {
const objUrl = this.cache.get(url)!;
// LRU bump
this.cache.delete(url);
this.cache.set(url, objUrl);
return objUrl;
}
if (this.pending.has(url)) {
return this.pending.get(url)!;
}
const fetchPromise = this.fetchAndCache(url);
this.pending.set(url, fetchPromise);
const result = await fetchPromise;
this.pending.delete(url);
return result;
}
private async fetchAndCache(url: string): Promise {
const response = await fetch(url);
const blob = await response.blob();
const objUrl = URL.createObjectURL(blob);
this.addToCache(url, objUrl);
return objUrl;
}
private addToCache(url: string, objUrl: string) {
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
URL.revokeObjectURL(this.cache.get(oldestKey)!);
this.cache.delete(oldestKey);
}
this.cache.set(url, objUrl);
}
}
2. 중복 fetch 방지
리스트나 피드에서는 같은 사용자 아바타가 여러 번 나타날 수 있습니다. 서비스는 진행 중인 Promise 다운로드를 추적하기 위해 pending Map을 유지합니다:
- 특정 URL에 대해 이미 HTTP 요청이 진행 중이라면, 이후 요청은 새로운 네트워크 요청을 발생시키는 대신 기존
Promise에 연결됩니다. - fetch가 완료(또는 실패)되면 해당 URL은
pending맵에서 제거됩니다.
Source: …
UI 레이어: 스마트 레이지 로딩 디렉티브
캐싱 로직을 서비스에 분리했으니, UI에서는 이를 원활하게 사용할 방법이 필요합니다. 독립형 LazyLoadDirective는 appLazyLoad 속성을 가진 모든 <img> 태그를 대상으로 합니다.
1. 구조와 플레이스홀더
디렉티브가 초기화될 때 DOM 구조를 최적화하고 로딩 상태를 설정합니다:
- Picture Wrapper – 이미지가 이미
<picture>태그 안에 있지 않다면, 디렉티브가 프로그래밍적으로<img>를<picture>안으로 감싸고, CSS 타깃팅을 쉽게 하기 위해lazy-picture-wrapper클래스를 추가합니다. - Transparent GIF – 실제 이미지가 로드되기 전에, 디렉티브는 가벼운 투명 base64 GIF(
data:image/gif;base64,...)를src속성에 삽입합니다. - Loading State –
img-loadingCSS 클래스가 적용되어 스켈레톤 애니메이션이나 스피너에 활용될 수 있습니다.
@Directive({
selector: '[appLazyLoad]',
standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
@Input('appLazyLoad') src!: string;
private abortController?: AbortController;
private unlisteners: (() => void)[] = [];
constructor(
private el: ElementRef,
private cache: ImageCacheService,
private renderer: Renderer2
) {}
ngOnInit() {
this.setupWrapper();
this.loadImage();
}
ngOnChanges() {
this.loadImage();
}
ngOnDestroy() {
this.abortController?.abort();
this.unlisteners.forEach((un) => un());
}
private setupWrapper() {
const img = this.el.nativeElement;
if (!img.parentElement?.matches('picture')) {
const picture = this.renderer.createElement('picture');
this.renderer.addClass(picture, 'lazy-picture-wrapper');
const parent = img.parentNode;
this.renderer.insertBefore(parent, picture, img);
this.renderer.removeChild(parent, img);
this.renderer.appendChild(picture, img);
}
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
this.renderer.addClass(img, 'img-loading');
}
private async loadImage() {
this.abortController?.abort();
this.abortController = new AbortController();
const signal = this.abortController.signal;
// Skip cache for data URIs
if (this.src.startsWith('data:')) {
this.applySrc(this.src);
return;
}
try {
const objectUrl = await this.cache.get(this.src);
if (signal.aborted) return;
this.applySrc(objectUrl);
} catch (e) {
// handle error (optional)
}
}
private applySrc(url: string) {
const img = this.el.nativeElement;
img.src = url;
this.renderer.removeClass(img, 'img-loading');
}
}
2. 우아한 취소 처리
모바일 사용자는 빠르게 스크롤합니다. 사용자가 이미지가 다운로드되기 전에 스크롤을 지나치면, 그 이미지를 렌더링하는 데 불필요한 처리량을 낭비하면 안 됩니다.
- AbortControllers – 디렉티브는 로드 시도마다 새로운
AbortController를 생성합니다. - Lifecycle Cleanup –
src입력이 변경되거나 컴포넌트가 파괴될 때, 컨트롤러의abort()메서드가 호출됩니다. - Safety Check – 전역 캐시가 Blob URL을 반환한 뒤, 디렉티브는
signal.aborted를 확인합니다. 이미 취소된 경우 DOM 업데이트를 중단해, 오래된 이미지가 새로운 이미지 위에 렌더링되는 레이스 컨디션을 방지합니다.
3. 엣지 케이스
견고한 디렉티브는 가져올 필요가 없는 데이터를 처리해야 합니다:
- Data URIs – 제공된 이미지 소스가 이미 base64 문자열(
startsWith('data:'))인 경우, 디렉티브는 캐시를 건너뛰고 바로 소스를 적용하며 즉시 로딩 클래스를 제거합니다. - Event Listeners – 네이티브
load와error이벤트를 청취해 성공 여부와 관계없이img-loading클래스가 확실히 제거되도록 합니다. 이러한 리스너는unlisteners배열에 저장되고, 파괴 시 메모리 누수를 방지하기 위해 정리됩니다.
캐싱 로직(“두뇌”)을 DOM 조작(UI 레이어)과 분리함으로써, 이 아키텍처는 매우 높은 성능과 네이티브 감각을 갖춘 이미지 경험을 제공합니다.
하이브리드 앱.