하이브리드 모바일 앱에서 이미지 캐싱 및 Lazy Loading 마스터하기

발행: (2026년 2월 16일 오후 03:19 GMT+9)
9 분 소요
원문: Dev.to

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에서는 이를 원활하게 사용할 방법이 필요합니다. 독립형 LazyLoadDirectiveappLazyLoad 속성을 가진 모든 <img> 태그를 대상으로 합니다.

1. 구조와 플레이스홀더

디렉티브가 초기화될 때 DOM 구조를 최적화하고 로딩 상태를 설정합니다:

  • Picture Wrapper – 이미지가 이미 <picture> 태그 안에 있지 않다면, 디렉티브가 프로그래밍적으로 <img><picture> 안으로 감싸고, CSS 타깃팅을 쉽게 하기 위해 lazy-picture-wrapper 클래스를 추가합니다.
  • Transparent GIF – 실제 이미지가 로드되기 전에, 디렉티브는 가벼운 투명 base64 GIF(data:image/gif;base64,...)를 src 속성에 삽입합니다.
  • Loading Stateimg-loading CSS 클래스가 적용되어 스켈레톤 애니메이션이나 스피너에 활용될 수 있습니다.
@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 Cleanupsrc 입력이 변경되거나 컴포넌트가 파괴될 때, 컨트롤러의 abort() 메서드가 호출됩니다.
  • Safety Check – 전역 캐시가 Blob URL을 반환한 뒤, 디렉티브는 signal.aborted를 확인합니다. 이미 취소된 경우 DOM 업데이트를 중단해, 오래된 이미지가 새로운 이미지 위에 렌더링되는 레이스 컨디션을 방지합니다.

3. 엣지 케이스

견고한 디렉티브는 가져올 필요가 없는 데이터를 처리해야 합니다:

  • Data URIs – 제공된 이미지 소스가 이미 base64 문자열(startsWith('data:'))인 경우, 디렉티브는 캐시를 건너뛰고 바로 소스를 적용하며 즉시 로딩 클래스를 제거합니다.
  • Event Listeners – 네이티브 loaderror 이벤트를 청취해 성공 여부와 관계없이 img-loading 클래스가 확실히 제거되도록 합니다. 이러한 리스너는 unlisteners 배열에 저장되고, 파괴 시 메모리 누수를 방지하기 위해 정리됩니다.

캐싱 로직(“두뇌”)을 DOM 조작(UI 레이어)과 분리함으로써, 이 아키텍처는 매우 높은 성능과 네이티브 감각을 갖춘 이미지 경험을 제공합니다.

하이브리드 앱.

0 조회
Back to Blog

관련 글

더 보기 »