Flutter 이미지 캐시와 메모리 사용량 관리

Flutter 이미지 캐시와 메모리 사용량 관리

한눈에 보기

이미지 파일이 300KB라고 해서 메모리도 300KB만 사용하는 것은 아니다. 렌더링할 때는 대개 픽셀 버퍼로 디코딩되므로 가로 픽셀 × 세로 픽셀 × 약 4byte에 가까운 메모리가 필요하다. 목록의 작은 카드에 원본 4K 이미지를 반복 decode하면 cache와 live image가 메모리를 오래 점유한다. 서버 thumbnail과 cacheWidth·cacheHeight를 사용해 물리 픽셀 표시 크기에 맞춰 decode하고, cache hit보다 peak memory·frame time·화면 이탈 뒤 잔존량을 함께 측정해야 한다.

목차

파일 용량과 픽셀 메모리는 다른 값이다

서버에서 내려온 JPEG 파일이 450KB라면 앱 memory도 450KB 정도만 늘어날 것이라고 생각하기 쉽다. 하지만 JPEG, WebP, PNG는 전송과 저장을 위한 압축 형식이다. GPU가 화면에 그리려면 픽셀 데이터로 decode해야 한다.

대표적으로 32bit RGBA buffer라면 대략 다음과 같이 계산할 수 있다.

decoded bytes ≈ width × height × 4

4K 이미지 하나:

3840 × 2160 × 4
= 33,177,600 bytes
≈ 31.6 MiB

450KB 파일 하나가 30MB가 넘는 decoded memory를 사용할 수 있다. 목록에 이런 이미지 세 장만 동시에 live 상태여도 pixel buffer만 약 95MB다. decode 중에는 압축 bytes, intermediate buffer, 새 frame이 잠시 함께 존재해 peak는 더 높아질 수 있다.

이미지 압축 파일 예시 decoded RGBA 근사
400×225 thumbnail 30KB 0.34MiB
1080×1920 250KB 7.91MiB
2160×3840 700KB 31.64MiB

압축률이 좋아질수록 network에는 유리하지만 decoded pixel 수가 같으면 rendering memory가 같은 크기일 수 있다.

첫 번째 측정 단위

이미지 최적화에서 파일 KB만 보지 않는다. 원본 픽셀, decode 픽셀, 동시에 live인 개수, frame 수를 함께 본다.

이 글의 URL과 화면 크기는 특정 프로젝트 코드를 사용하지 않은 가상 사진 목록 예제다.

Flutter에서 이미지가 화면에 오기까지

이미지 한 장은 여러 cache와 변환 단계를 지난다.

flowchart LR
    A[CDN compressed bytes] --> B[HTTP and optional disk cache]
    B --> C[ImageProvider]
    C --> D[Codec decode]
    D --> E[Decoded pixel image]
    E --> F[Flutter ImageCache]
    E --> G[Live ImageStream listener]
    G --> H[Layout size로 paint]

각 단계의 “크기”가 다르다.

width: 100을 주는 것은 layout을 100 logical pixel로 만들 뿐 원본 decode 크기를 자동으로 100 pixel로 제한한다는 의미가 아니다.

표시 크기만 줄여서는 메모리가 줄지 않는다

다음 코드는 원본을 작은 card에 맞춰 그린다.

SizedBox(
  width: 120,
  height: 90,
  child: Image.network(
    photo.originalUrl,
    fit: BoxFit.cover,
  ),
)

layout shift를 막고 crop은 잘 되지만 photo.originalUrl이 4000×3000이면 큰 원본을 decode할 수 있다.

4000 × 3000 × 4 ≈ 45.8 MiB

120×90 card 하나에 필요 이상의 픽셀을 보관한다. 같은 URL을 cache가 재사용한다 해도 하나의 큰 decoded image가 memory에 남는다.

Flutter의 cacheWidth·cacheHeight는 engine에 decode target을 알려 ImageCache memory를 줄이는 데 사용한다.

Image.network(
  photo.thumbnailUrl,
  width: 120,
  height: 90,
  cacheWidth: 360,
  cacheHeight: 270,
  fit: BoxFit.cover,
)

여기서 360×270은 3.0 device pixel ratio를 가정한 예시다. 모든 기기에 고정해 복사하지 않고 실제 표시 크기와 DPR로 계산한다.

Logical Pixel과 Device Pixel Ratio 계산하기

Flutter layout은 logical pixel을 사용하지만 decode target은 pixel 단위다. 120 logical pixel card가 DPR 3인 기기에서는 360 physical pixel 정도가 필요하다.

target pixels = logical size × devicePixelRatio

필요 이상으로 크게 decode하면 memory가 늘고, 너무 작게 decode하면 확대되어 흐릿해진다.

int physicalPixels(
  double logicalPixels,
  double devicePixelRatio,
) {
  return (logicalPixels * devicePixelRatio).ceil();
}

responsive grid에서는 layout constraint가 정해진 뒤 target을 계산한다.

class PhotoCard extends StatelessWidget {
  const PhotoCard({
    required this.photo,
    super.key,
  });

  final PhotoSummary photo;

  @override
  Widget build(BuildContext context) {
    final dpr = MediaQuery.devicePixelRatioOf(context);

    return LayoutBuilder(
      builder: (context, constraints) {
        final logicalWidth = constraints.maxWidth;
        final logicalHeight = logicalWidth * 0.75;
        final targetWidth =
            physicalPixels(logicalWidth, dpr);
        final targetHeight =
            physicalPixels(logicalHeight, dpr);

        return Image.network(
          photo.thumbnailUrl,
          width: logicalWidth,
          height: logicalHeight,
          cacheWidth: targetWidth,
          cacheHeight: targetHeight,
          fit: BoxFit.cover,
          errorBuilder: (_, __, ___) =>
              const PhotoFallback(),
        );
      },
    );
  }
}

constraint가 무한대인 상황을 그대로 int로 바꾸지 않도록 상한과 layout contract를 둔다. target을 매 frame 소수점 차이로 다르게 만들지 않게 size bucket을 사용할 수도 있다.

cacheWidth로 Decode 크기 제한하기

Flutter 공식 API에서 cacheWidthcacheHeight는 이미지가 그려지는 widget 크기를 바꾸는 값이 아니다. decode와 cache target을 제안하는 값이다.

한쪽 비율만 명확하다면 하나만 전달해 aspect ratio를 보존할 수 있다.

Image.network(
  photo.thumbnailUrl,
  width: 160,
  height: 120,
  cacheWidth: 480,
  fit: BoxFit.cover,
)

두 값을 모두 주면 codec이 해당 target에 맞게 resize한다. 원본 비율과 card 비율이 다를 때 실제 decode 결과와 crop quality를 확인한다.

Reusable policy로 감싼다.

final class ImageDecodePolicy {
  const ImageDecodePolicy({
    this.maxDevicePixelRatio = 3,
    this.bucketSize = 64,
  });

  final double maxDevicePixelRatio;
  final int bucketSize;

  int targetPixels({
    required double logicalPixels,
    required double devicePixelRatio,
  }) {
    final effectiveDpr = devicePixelRatio.clamp(
      1.0,
      maxDevicePixelRatio,
    );
    final raw = (logicalPixels * effectiveDpr).ceil();
    return ((raw + bucketSize - 1) ~/ bucketSize) * bucketSize;
  }
}

DPR cap과 bucket 크기는 화질·memory 측정 뒤 정할 제품 정책이다. 무조건 3으로 제한하라는 의미가 아니다.

Flutter Web 차이

Image.network 공식 문서상 Web에서는 browser가 decode를 담당해 cacheWidthcacheHeight가 무시된다. 같은 코드가 mobile과 Web에서 같은 memory 특성을 가진다고 가정하지 않는다.

서버 Thumbnail과 Client Resize의 역할 나누기

cacheWidth는 큰 원본 bytes를 이미 내려받은 뒤 decode memory를 줄이는 데 도움을 준다. network bandwidth와 download latency까지 줄이려면 서버나 CDN이 thumbnail을 제공해야 한다.

원본:       4032×3024  상세 확대·다운로드
large:      1440×1080  전체 화면
medium:      720×540   큰 card
thumbnail:   360×270   목록

요청 URL이 variant를 명확히 나타내게 한다.

String imageUrl(
  PhotoSummary photo,
  ImageVariant variant,
) {
  return switch (variant) {
    ImageVariant.thumbnail =>
      '${photo.cdnBaseUrl}/thumbnail.webp',
    ImageVariant.medium =>
      '${photo.cdnBaseUrl}/medium.webp',
    ImageVariant.original =>
      '${photo.cdnBaseUrl}/original',
  };
}

서버 variant와 client decode target을 함께 사용한다.

서버 thumbnail → 전송 bytes 감소
cacheWidth      → decoded memory 상한
layout size     → 화면 배치

목록에서 원본 URL 하나를 내려받고 cacheWidth만 주면 network 낭비가 남는다. thumbnail만 내려받고 decode target을 주지 않으면 variant가 여전히 화면보다 클 때 memory 낭비가 남을 수 있다.

이미지 수명주기와 S3 variant 운영은 베베스냅 이미지 원본 리사이즈 S3 생명주기와 이어진다.

ImageCache가 보관하는 것 이해하기

Flutter의 ImageProvider는 framework의 shared ImageCache를 사용한다. 공식 API 설명상 기본 ImageCache는 LRU 정책을 사용하며 entry 수와 bytes 한도를 가진다. 현재 기본값을 앱의 불변 계약으로 생각하지 말고 사용하는 Flutter 버전 문서를 확인한다.

관측 가능한 값:

void logImageCacheSnapshot() {
  final cache = PaintingBinding.instance.imageCache;

  debugPrint(
    'image-cache '
    'count=${cache.currentSize} '
    'bytes=${cache.currentSizeBytes} '
    'pending=${cache.pendingImageCount} '
    'live=${cache.liveImageCount}',
  );
}

cache에는 keep-alive entry뿐 아니라 pending과 live 개념이 있다. clear()가 모든 image memory를 즉시 없앤다고 생각하면 안 된다. 공식 문서에서도 clear()는 pending과 keepAlive entry를 비우지만 listener가 남아 있는 live reference는 별개라고 설명한다.

flowchart TD
    A[ImageProvider resolve] --> B{Cache key 존재?}
    B -- Yes --> C[Completer 재사용]
    B -- No --> D[Load and decode]
    D --> E[Pending]
    E --> F[Completed cache entry]
    E --> G[Live listener]
    G --> H{마지막 listener 제거}
    H -- Yes --> I[더 이상 live 아님]

memory 문제에서 cache bytes와 live count를 함께 보는 이유다.

Cache Key가 달라지면 같은 URL도 다른 이미지다

같은 URL이라도 resize target이 다르면 다른 cache key가 될 수 있다.

Image.network(url, cacheWidth: 320);
Image.network(url, cacheWidth: 384);
Image.network(url, cacheWidth: 448);

responsive card가 작은 width 변화마다 새 target을 만들면 cache variant가 늘어난다. 64 또는 128 pixel bucket으로 target을 정규화하면 재사용 가능성이 높아진다.

URL query도 key에 영향을 준다.

photo.webp?token=abc&ts=100
photo.webp?token=abc&ts=101

매 build마다 timestamp를 붙이는 cache busting은 network와 decoded cache를 모두 무효화한다. 콘텐츠가 바뀔 때만 안정적인 revision URL을 사용한다.

/photos/photo-demo/thumbnail.webp?v=42

인증 URL의 token이 자주 변한다면 URL cache key와 보안 정책을 함께 검토한다. 장기 signed CDN URL이나 header 기반 인증 등 인프라 설계가 필요할 수 있다.

목록에서 Live Image 수를 줄이기

ListView.builder를 사용해도 화면 밖 widget을 keep alive하거나 큰 cacheExtent를 사용하면 많은 이미지가 live 상태가 될 수 있다.

ListView.builder(
  cacheExtent: 2000,
  itemCount: photos.length,
  itemBuilder: (_, index) => PhotoCard(
    photo: photos[index],
  ),
)

큰 cache extent는 scroll 직전 이미지를 준비하지만 memory와 decode burst를 늘린다. 기본값을 바꾸기 전에 실제 scroll trace로 필요성을 확인한다.

목록 최적화 순서:

  1. 원본 대신 thumbnail URL 사용
  2. card의 physical size에 맞춰 decode
  3. viewport 근처만 build
  4. 불필요한 keep-alive 제거
  5. 동시에 시작하는 image request 제한
  6. placeholder로 layout 크기 고정

dispose를 호출한다고 global image cache까지 자동 제거할 필요는 없다. widget listener가 사라지면 live 상태가 줄고 cache entry는 재사용을 위해 남을 수 있다. 화면마다 전역 cache를 비우면 뒤로 돌아올 때 다시 download·decode하는 thrashing이 생긴다.

Precache는 가까운 미래만 대상으로 하기

precacheImage는 곧 보여 줄 image를 미리 resolve해 첫 표시 지연을 줄인다.

Future<void> precacheNextPhoto(
  BuildContext context,
  PhotoSummary next,
) {
  final dpr = MediaQuery.devicePixelRatioOf(context);
  final targetWidth = policy.targetPixels(
    logicalPixels: 320,
    devicePixelRatio: dpr,
  );

  return precacheImage(
    ResizeImage(
      NetworkImage(next.mediumUrl),
      width: targetWidth,
    ),
    context,
  );
}

목록 500장을 모두 precache하지 않는다. carousel의 다음 한두 장처럼 사용 확률이 높은 범위만 대상으로 한다. network가 constrained·expensive한지도 고려한다.

precache에 사용한 ImageProvider key와 실제 화면 provider key가 같아야 재사용된다. precache는 width 720인데 화면은 704로 요청하면 다른 variant가 될 수 있다.

Memory Cache와 Disk Cache를 구분하기

Flutter framework ImageCache는 decoded image 재사용과 관련된 memory cache다. 별도 package가 관리하는 disk cache는 압축 bytes를 파일에 저장한다.

Cache 보관 값 줄이는 비용 주요 한도
HTTP/CDN 원격 compressed bytes origin 처리·전송 server policy
앱 disk cache compressed file 재다운로드 disk quota·TTL
Flutter ImageCache decoded image completer decode·paint 준비 RAM
live image 현재 listener가 참조 현재 rendering widget lifetime

disk cache hit가 나도 decode memory는 다시 필요하다. 반대로 memory cache hit는 network 없이 바로 그릴 수 있지만 RAM을 점유한다.

logout 때 private disk image를 삭제해야 할 수 있지만 decoded cache key도 남아 있는지 확인한다. 공개 이미지와 계정 전용 이미지를 같은 정책으로 지우지 않는다.

Animated Image와 큰 원본에 별도 정책 두기

GIF나 animated WebP는 한 장의 정적 image와 다른 비용을 가진다. frame decode와 animation controller가 화면 밖에서도 살아 있지 않은지 확인한다.

목록에서는 정적 poster thumbnail을 보여 주고 상세 화면에서만 animation을 재생하는 방법이 있다.

Widget buildPreview(MediaSummary media) {
  return Image.network(
    media.posterThumbnailUrl,
    cacheWidth: media.previewDecodeWidth,
    fit: BoxFit.cover,
  );
}

초고해상도 원본 편집은 일반 Image widget 하나로 전체 decode하기보다 tiled rendering, platform image API, downsampled preview를 검토한다.

EXIF orientation이 있는 사진은 resize 단계에서 방향을 올바르게 적용하는지도 확인한다. metadata를 제거할 때 color profile과 회전 정보가 사라져 결과가 달라질 수 있다.

전체 Cache 삭제보다 원인을 먼저 찾기

memory가 높다고 다음 코드를 화면 전환마다 호출하면 문제를 숨길 수 있다.

PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();

전역 삭제의 부작용:

특정 이미지 내용이 갱신됐거나 깨졌을 때는 해당 provider를 evict한다.

await NetworkImage(photo.thumbnailUrl).evict();

memory pressure 대응으로 cache 상한을 조정할 수 있지만 먼저 decode target과 live count를 줄인다. cache limit을 너무 낮추면 정상 scroll에서도 thrashing이 생긴다.

void configureImageCache({
  required int maxEntries,
  required int maxBytes,
}) {
  final cache = PaintingBinding.instance.imageCache;
  cache.maximumSize = maxEntries;
  cache.maximumSizeBytes = maxBytes;
}

숫자는 기기 memory class와 실제 workload를 측정해 정한다. 인터넷의 한 값을 그대로 복사하지 않는다.

DevTools로 Peak와 Frame Time 측정하기

이미지 문제는 최종 steady memory만 보면 놓치기 쉽다. 화면 진입과 빠른 scroll 중 peak를 본다.

측정 시나리오를 고정한다.

1. 앱 cold start
2. 사진 목록 100개 로드
3. 10초 동안 일정 속도로 아래로 scroll
4. 상세 화면 세 번 진입·복귀
5. 목록 route 제거
6. 일정 시간 뒤 memory와 cache snapshot 기록

비교할 지표:

debug build 성능만으로 결론내리지 않고 profile mode와 실제 저사양 기기에서 측정한다. image loading 직후 GC 전후 값도 구분한다.

Flutter painting library에는 oversized image를 시각적으로 찾는 debug 도구와 paint image 정보를 관찰하는 hook이 있다. 개발 환경에서 이를 사용해 실제 출력 크기보다 과도하게 decode된 이미지를 찾을 수 있다.

테스트 가능한 이미지 정책 만들기

target 계산을 widget 안에 흩뜨리지 않고 순수 policy로 테스트한다.

test('120 logical px를 DPR 3에서 384 bucket으로 만든다', () {
  const policy = ImageDecodePolicy(
    maxDevicePixelRatio: 3,
    bucketSize: 64,
  );

  final result = policy.targetPixels(
    logicalPixels: 120,
    devicePixelRatio: 3,
  );

  expect(result, 384);
});

test('과도한 DPR은 설정한 상한을 적용한다', () {
  const policy = ImageDecodePolicy(
    maxDevicePixelRatio: 3,
    bucketSize: 64,
  );

  final result = policy.targetPixels(
    logicalPixels: 100,
    devicePixelRatio: 4,
  );

  expect(result, 320);
});

Golden test는 crop과 fallback UI를 확인하고 performance test는 memory budget을 본다.

테스트 검증
Policy unit test DPR·bucket·상한
Widget test constraint와 provider 인자
Golden test BoxFit·placeholder·error
Integration test scroll 중 request·cache 재사용
Profile test peak memory·jank
실제 기기 저메모리·background 복귀

서버 thumbnail이 사라졌을 때 원본으로 조용히 fallback하면 memory budget이 깨질 수 있다. fallback도 최대 decode target을 유지하거나 placeholder를 선택한다.

구현 체크리스트

이미지 크기

Cache와 Lifecycle

특수 이미지와 보안

검증

마무리

Flutter 이미지 memory 문제는 cache를 켤지 끌지의 문제가 아니다. 어느 크기의 compressed resource를 내려받고, 몇 pixel로 decode하며, 몇 장을 동시에 live·cache 상태로 유지하는지의 문제다.

작은 card에는 서버 thumbnail을 사용해 network bytes를 줄이고, logical size와 device pixel ratio에 맞는 cacheWidth·cacheHeight로 decoded memory를 제한한다. responsive 크기는 bucket으로 정규화해 같은 URL의 cache variant가 무한히 늘지 않게 한다.

ImageCache는 재사용을 위해 유용하지만 live listener가 남은 image와 disk cache까지 대신 관리하지 않는다. 전체 cache를 반복해서 비우기 전에 oversized decode, 긴 목록의 live count, 과도한 prefetch를 먼저 측정한다.

좋은 이미지 정책은 가장 작은 이미지만 보여 주는 정책이 아니다. 사용자가 구분할 수 있는 화질을 유지하면서 peak memory와 frame time을 예측 가능한 budget 안에 두는 정책이다.

관련 노트

참고 자료