React Native 에서 이미지 최적화 (1. 캐싱이 작동할 좋은 타이밍)

eeennsu·2026년 8월 22일

React Native

목록 보기
85/103

개요

<FastImage />만 잘 써도 대부분의 화면은 동작한다. 하지만 리스트 → 상세 화면 전환에서 이미지가 한 박자 늦게 뜨는 깜빡임, 무거운 갤러리 화면 진입 후 앱이 버벅이거나 백그라운드에서 강제 종료(OOM kill)되는 현상, 사용자 저장 공간을 잡아먹는 누적 디스크 캐시 등은 결국 언제 캐시에 올리고 언제 비울 것인가의 문제다.

RN CLI 환경에서 이미지 캐싱과 관련해 자주 부딪치는 세 가지 작업—프리로드, 메모리 캐시 정리, 디스크 캐시 정리—을 각각 어떤 타이밍에 호출하는 것이 적절한지 정리해보자.

본 글은 @d11/react-native-fast-image를 기준으로 작성했다. 원본 react-native-fast-image(DylanVann)는 유지보수가 멈췄고, @d11 포크가 New Architecture(Fabric)를 지원하므로 New Arch 프로젝트에서는 이쪽을 쓴다. iOS는 SDWebImage, Android는 Glide를 내부 백엔드로 사용한다.

  • 프리로드: FastImage.preload(sources)
  • 메모리 캐시 정리: FastImage.clearMemoryCache()
  • 디스크 캐시 정리: FastImage.clearDiskCache()


1. 메모리 캐시 vs 디스크 캐시

타이밍 얘기 전에 먼저 두 캐시의 차이를 분명히 해두는 게 좋다. 헷갈리면 잘못된 시점에 잘못된 캐시를 비우게 된다.

항목메모리(RAM) 캐시디스크(파일 시스템) 캐시
저장 위치프로세스 메모리앱 샌드박스 내 파일
접근 속도즉시 (디코딩된 비트맵)디스크 I/O + 디코딩 필요
앱 종료 시사라짐유지됨
비우는 이유OOM 방지, 메모리 압박 완화저장 공간 확보, 무결성
비용정리해도 디스크에서 다시 빠르게 복원 가능정리하면 네트워크 재다운로드 필요

핵심은 메모리 캐시 정리는 비교적 안전한 동작이고, 디스크 캐시 정리는 비싸고 되돌릴 수 없는 동작이다. 이 비대칭을 기억하면 호출 타이밍 판단이 훨씬 쉬워진다.



2. 프리로드 (preload)

목적: 사용자가 화면을 보기 직전에 미리 이미지를 다운받아, 진입하자마자 깜빡임 없이 노출되도록 하는 것이 목표이다.

1) 언제 호출하는가

① 목록 → 상세 화면 진입 직전

리스트에서 카드를 누르는 순간(onPress 핸들러 내부)에 다음 화면의 메인 이미지를 프리로드한다. 네비게이션 전환 애니메이션(보통 250~400ms) 동안 이미지가 백그라운드에서 로드되어, 상세 화면이 뜨는 시점에는 이미 메모리에 올라와 있다.

const onPressItem = (item: Item) => {
  FastImage.preload([{ uri: item.heroImageUrl }]);
  navigation.navigate('Detail', { itemId: item.id });
};

onPress가 아니라 onPressIn에 걸면 더 일찍 시작할 수도 있다. 다만 사용자가 실수로 닿기만 해도 호출되므로 트래픽 낭비가 생긴다. onPress가 균형점이다.


② 무한 스크롤 / 페이지네이션 데이터 도착 직후

다음 페이지의 API 응답을 받은 직후, 렌더링 전에 상위 N개 아이템의 이미지를 프리로드한다.

const { data } = await fetchNextPage();
const urlsToPreload = data
  .slice(0, 12)               // 화면에 먼저 보일 만큼만
  .map(item => item.thumbnail);
FastImage.preload(urlsToPreload.map(uri => ({ uri })));

③ 앱 진입 직후 (필수 데이터에 한정)

스플래시 화면이나 초기 부트스트랩 단계에서, 홈 최상단에 반드시 노출되는 배너/히어로 이미지 1~2개. 그 이상은 권장하지 않는다.


④ 다음 캐러셀 슬라이드

스와이프 캐러셀에서 현재 슬라이드의 다음 1~2장. 사용자가 스와이프하는 순간 이미지가 이미 메모리에 있다.


2) 실무 팁

선택과 집중 — 전부 프리로드하지 마라

데이터 배열의 모든 이미지를 프리로드하면 다음이 발생한다.

  • 네트워크 병목
    수십~수백 개 요청이 동시에 발사되어 정작 화면에 보이는 이미지 다운로드가 느려진다.

  • 유저 데이터 요금 낭비
    셀룰러 사용자에겐 보이지도 않을 이미지에 대한 트래픽 청구.

  • 메모리 캐시 오염
    보이지 않을 이미지가 메모리를 차지해, 정작 필요한 이미지가 밀리게 된다.

배치 상한선을 두는 것이 표준이다. 예를 들어 한 번에 최대 40~50개로 제한하는 식이다.

const PRELOAD_BATCH_LIMIT = 48;

function safePreload(urls: string[]) {
  const sources = urls
    .slice(0, PRELOAD_BATCH_LIMIT)
    .map(uri => ({ uri }));
  FastImage.preload(sources);
}

우선순위 — '먼저 보일 것'을 먼저

스크롤 아래 안 보이는 이미지를 프리로드하느니, 상단 viewport에 곧 들어올 이미지에 집중한다. FastImage는 priority: 'high' | 'normal' | 'low'를 지원하므로 활용한다.

FastImage.preload([
  { uri: heroUrl, priority: FastImage.priority.high },
  ...thumbnailUrls.map(uri => ({ uri, priority: FastImage.priority.normal })),
]);

캐시 정책 — source.cache로 갱신 동작을 제어

FastImage는 소스 단위로 캐시 제어 옵션을 제공한다. 프리로드/렌더 시 동일하게 지정한다.

  • FastImage.cacheControl.immutable(기본값): URL이 같으면 갱신하지 않는다. 콘텐츠가 바뀌지 않는 정적 리소스에 적합하다.
  • FastImage.cacheControl.web: HTTP 캐시 헤더(만료/ETag)를 존중해 필요 시 재검증한다.
  • FastImage.cacheControl.cacheOnly: 캐시에서만 로드하고 네트워크 요청은 하지 않는다.
FastImage.preload([
  { uri: heroUrl, cache: FastImage.cacheControl.web }, // 자주 바뀌는 이미지
]);

네트워크 상태 고려

셀룰러나 저속 네트워크에서는 프리로드를 줄이거나 끄는 것이 좋다. @react-native-community/netinfo로 연결 종류를 확인할 수 있다.

const { type } = await NetInfo.fetch();
if (type === 'wifi') {
  FastImage.preload(sources);
}

프리로드는 "결과를 기다리지 않는" 패턴

대부분의 경우 프리로드는 fire-and-forget이다. 완료를 기다리지 않고 즉시 다음 단계로 진행하며, 운 좋게 미리 받아져 있으면 좋고 아니면 평소처럼 로드된다. await FastImage.preload(...)로 묶는 건 의미가 없을 뿐 아니라(promise도 아님) 화면 진입을 지연시키는 안티패턴이다.



3. 메모리 캐시 정리 (clearMemoryCache)

목적: RAM 점유를 낮춰 OOM 종료를 방지하고, 무거운 화면 진입 후의 메모리 압박을 완화한다.

1) 언제 호출하는가

① 이미지 많은 화면을 떠날 때 (unmount)

수십 장의 이미지가 노출되는 갤러리, 탐색, 피드 상세 화면 등이 unmount 될 때 useEffect의 cleanup에서 호출한다.

useEffect(() => {
  return () => {
    FastImage.clearMemoryCache();
  };
}, []);

여기서 디스크 캐시는 그대로 남아 있으므로, 사용자가 다시 그 화면으로 돌아왔을 때 네트워크 재다운로드는 발생하지 않는다(디스크에서 복원). 그래서 비교적 안전한 동작이다.


② 앱이 백그라운드로 전환될 때

AppState를 감지해 'background' 진입 시점에 메모리 캐시를 비우면, OS가 메모리 부족 상태에서 우리 앱을 강제 종료할 확률이 줄어든다. 특히 이미지가 많은 앱에서는 효과가 크다.

useEffect(() => {
  const sub = AppState.addEventListener('change', state => {
    if (state === 'background') {
      FastImage.clearMemoryCache();
    }
  });
  return () => sub.remove();
}, []);

③ 메모리 경고 시 (iOS) / Trim Memory 콜백 (Android)

iOS는 UIApplicationDidReceiveMemoryWarningNotification을, Android는 onTrimMemory 콜백을 발생시킨다. 이 신호를 받는 시점이야말로 메모리 캐시를 비우기 가장 좋은 타이밍이다. 다만 RN JS 측에서 직접 듣기는 까다로워서, 보통은 라이브러리가 내부적으로 처리하거나 별도 네이티브 모듈/브릿지(New Architecture라면 TurboModule)가 필요하다. FastImage는 내부적으로 SDWebImage(iOS) / Glide(Android)를 사용하므로 메모리 경고에 대한 기본 대응이 어느 정도 들어 있다.


2) 주의사항

1. 너무 자주 비우면 깜빡임이 생긴다

화면 전환마다 메모리 캐시를 비우면, 디스크에서 다시 RAM으로 끌어와 디코딩하는 비용이 매번 발생해 미세한 깜빡임이나 지연이 보일 수 있다. 확실한 분기점(무거운 화면 unmount, 백그라운드 전환)에서만 호출한다.


2. 리스트 컴포넌트 unmount에서 무분별하게 호출 금지

탭 전환마다 unmount되는 화면에서 매번 메모리 캐시를 비우면, 탭을 오갈 때마다 모든 이미지가 다시 디코딩된다. 충분히 무거운 화면(50장 이상의 이미지, 큰 해상도)에 한정해서 적용한다.


3. clearMemoryCache()는 Promise를 반환한다

FastImage.clearMemoryCache()는 Promise를 반환한다. 완료를 기다려야 하는 시나리오는 거의 없지만, 로그아웃 등 후속 동작이 있는 경우에는 await로 묶는다.



4. 디스크 캐시 정리 (clearDiskCache)

사용자의 물리적 저장 공간을 확보하고, 캐시 무결성을 보장하는 것을 목적으로 한다. (예: 동일 URL이지만 내용이 바뀐 이미지가 있는 경우)

1) 언제 호출하는가

① 사용자의 명시적 요청

설정 화면의 "캐시 데이터 지우기" 버튼처럼, 사용자가 의도적으로 누른 경우.

const onPressClearCache = async () => {
  await FastImage.clearDiskCache();
  await FastImage.clearMemoryCache(); // 함께 정리하는 게 일반적
  showToast('캐시를 정리했습니다');
};

② 로그아웃 / 회원 탈퇴 시

이전 사용자의 프로필 사진, 비공개 이미지 등이 디스크에 남아 있을 수 있다. 다음 사용자에게 노출되지 않도록 정리한다. auth 도메인의 로그아웃 핸들러 내부에서 호출한다.

async function logout() {
  await apiLogout();
  await FastImage.clearDiskCache();
  await FastImage.clearMemoryCache();
  resetAuthState();
}

③ 앱 버전 업데이트 후 (선택적)

이미지 URL 정책이 바뀌었거나, 캐시 키 구조가 바뀌었거나, CDN을 교체한 경우 한 번에 한해 정리. 사용자 모르게 자동으로 실행되도록 한다.


④ 캐시 무결성 이슈 발생 시

서버에서 동일 URL의 이미지 내용을 바꿔버린 경우(잘못된 운영 관행이지만 현실에선 자주 발생), 기존 디스크 캐시는 잘못된 이미지를 계속 반환한다. 이때 임시 대응으로 사용. 근본 해결은 URL에 해시/버전을 붙이는 것이다.


2) 실무 팁

  • 렌더링 사이클이나 스크롤 중에는 절대 호출하지 않는다

디스크 I/O는 비용이 큰 작업이다. 스크롤 도중 호출하면 프레임 드랍이 발생한다. UI가 정적인 시점(설정 화면, 로그아웃 직후 등)에서만 호출한다.

  • 비가역적이라는 점을 사용자에게 인지시킨다

디스크 캐시를 비우면 사용자가 자주 보던 이미지도 다시 네트워크에서 받아와야 한다. 셀룰러 환경에선 추가 데이터 요금이 나간다. 명시적 정리 버튼에는 확인 다이얼로그를 띄우는 것이 일반적이다.

  • 자동 정리는 신중하게

"일정 주기마다 자동으로 디스크 캐시 비우기" 같은 정책은 거의 항상 잘못된 결정이다. FastImage는 내부 백엔드(SDWebImage / Glide)의 LRU 기반 캐시 evict 정책을 그대로 사용하므로, 수동 개입 없이도 무한정 늘어나지 않는다. 굳이 자동 정리를 한다면 "디스크 사용량이 X MB를 넘으면" 같은 임계치 기반이 더 적절하다.



5. 자주 만나는 함정

1) 프리로드를 useEffect로 매 렌더마다 호출

// ❌ 의존성 배열 잘못 잡으면 매 렌더 호출
useEffect(() => {
  FastImage.preload(items.map(i => ({ uri: i.url })));
});

items가 새 배열로 만들어질 때마다 동일 URL을 재요청한다. 라이브러리가 중복 제거를 해주긴 하지만, 검사 비용이 발생한다. 의존성을 정확히 지정하거나, 데이터가 처음 도착했을 때 한 번만 호출하는 패턴을 쓴다.


2) 로그아웃 시 메모리 캐시만 비우고 디스크는 안 비움

토큰이 만료된 상태로 디스크 캐시에 남은 인증 이미지 URL이 있다면, 다음 로그인 시점에 잘못된 권한으로 이미지가 표시되거나(또는 안 표시) 다른 사용자의 캐시가 노출될 수 있다. 로그아웃 시에는 메모리 + 디스크 모두 비우는 것이 안전하다.


3) 모든 화면에서 clearMemoryCache

"메모리 절약된다"는 이유로 모든 화면 unmount마다 호출하면, 디코딩 비용이 매번 발생하고 오히려 체감 성능이 나빠진다. 이미지가 많고 무거운 화면에 한정한다.


4) Image.prefetch(React Native 내장)와 FastImage.preload 혼동

React Native 내장 Image.prefetch()는 OS 레벨 HTTP 캐시에만 영향을 주고, FastImage의 내부 캐시(iOS의 SDWebImage, Android의 Glide)와는 별개다. FastImage를 쓴다면 반드시 FastImage.preload()를 써야 캐시가 실제로 채워진다. 둘을 섞어 쓰면 프리로드했다고 생각한 이미지가 정작 FastImage 렌더 시 캐시 미스로 다시 받아진다.




6. 정리

작업타이밍비용비고
프리로드리스트 아이템 onPress 시점낮음다음 화면 메인 이미지만
프리로드다음 페이지 API 응답 직후낮음상한선 두기 (40~50개)
프리로드앱 부트스트랩매우 낮음필수 1~2장만
메모리 캐시 정리무거운 화면 unmount낮음가끔 깜빡임
메모리 캐시 정리백그라운드 전환낮음OOM kill 방지 효과
디스크 캐시 정리사용자 명시적 요청높음 (I/O)확인 다이얼로그 권장
디스크 캐시 정리로그아웃 / 탈퇴높음 (I/O)메모리도 함께
디스크 캐시 정리앱 버전 마이그레이션높음 (I/O)한 번만

원칙은 단순하다.

  • 프리로드는 "곧 보일 것"에만, 상한선을 두고.
  • 메모리 캐시 정리는 "확실한 분기점"에서만.
  • 디스크 캐시 정리는 "사용자가 동의한 명시적 시점"에서만.

이 세 가지만 지켜도 이미지 깜빡임, OOM kill, 저장 공간 잠식이라는 흔한 세 가지 문제를 대부분 막을 수 있다.

profile
이력서 https://resume.eunsu.pro

0개의 댓글