커뮤니티 매칭 서비스 '동행'의 Flutter 앱을 운영하던 중, CS 채널에 "앱이 갑자기 꺼져요"라는 문의가 늘어나기 시작했어요. 처음에는 간헐적인 이슈로 봤지만, Crashlytics를 열어보니 이야기가 달랐어요.
저사양 기기(Android 8~10, RAM 3GB 이하)에서 OOM(Out of Memory) 크래시가 급증하고 있었어요. 전체 비정상 종료 중 상당 부분이 메모리 부족으로 인한 강제 종료였고, 특정 화면에서 집중적으로 발생하고 있었어요.
크래시 리포트를 기기별, 화면별로 분류해 보니 명확한 패턴이 보였어요:
고사양 기기에서 크래시가 안 난다고 문제가 없는 게 아니에요. 증상이 숨어 있을 뿐이에요.
감이 아니라 숫자로 확인하기 위해 Flutter DevTools의 Memory 탭으로 프로파일링을 진행했어요.
메모리가 계단식이 아니라 선형으로 증가한다는 것은, 할당된 메모리가 해제되지 않고 계속 쌓이고 있다는 뜻이었어요.
Heap Snapshot을 분석한 결과, 범인은 이미지 캐싱 라이브러리(cached_network_image)의 두 가지 기본 동작이었어요.
서버에서 받아온 이미지를 원본 해상도 그대로 메모리에 적재하고 있었어요.
// 문제: 64x64 위젯에 1080x1080 이미지를 원본 그대로 디코딩
CachedNetworkImage(
imageUrl: profileImageUrl,
width: 64,
height: 64,
fit: BoxFit.cover,
// memCacheWidth/memCacheHeight 미설정 → 원본 해상도로 디코딩
)위젯 크기는 64×64인데, 메모리에는 1080×1080 원본이 올라가요:
64×64로 디코딩했다면? 64 × 64 × 4 = 약 16KB / 장 — 280배 차이
ListView에서 스크롤할 때 화면 밖으로 나간 이미지도 캐시에서 해제되지 않았어요. cached_network_image의 기본 캐시 정책이 사실상 무제한 보관이었기 때문이에요.
프로필 목록 화면에서 50명만 스크롤해도:
이 두 가지가 조합되면서, 스크롤할수록 메모리가 끝없이 쌓이는 구조였어요.
원인을 알았으니 해결책을 순차적으로 검증했어요.
가장 간단한 수정부터 시도했어요. memCacheWidth를 설정해서 디코딩 해상도를 제한하는 거예요.
CachedNetworkImage(
imageUrl: profileImageUrl,
memCacheWidth: 200, // 디코딩 시 최대 너비를 200px로 제한
width: 64,
height: 64,
fit: BoxFit.cover,
)결과: 메모리 피크가 1.5GB → 약 1.1GB로 부분 개선. 하지만 여전히 스크롤이 길어지면 메모리가 선형으로 증가하는 문제가 남아 있었어요.
한 장당 메모리는 줄었지만, 무한히 쌓이는 구조 자체는 바뀌지 않았어요.
단일 해결책으로는 부족했어요. 세 가지 전략을 조합해서 적용했어요.
① 동적 다운샘플링
위젯 크기와 기기의 Device Pixel Ratio를 반영해 필요한 만큼만 디코딩하도록 했어요.
// 위젯 크기 × DPR로 최적 해상도 계산
Widget optimizedImage(String url, double width, double height) {
final dpr = MediaQuery.of(context).devicePixelRatio;
return CachedNetworkImage(
imageUrl: url,
memCacheWidth: (width * dpr).toInt(),
memCacheHeight: (height * dpr).toInt(),
width: width,
height: height,
fit: BoxFit.cover,
);
}② LRU 캐시 정책
메모리에 무한히 쌓이는 것을 막기 위해, 캐시에 상한선을 설정했어요.
// 앱 초기화 시 캐시 정책 설정
PaintingBinding.instance.imageCache.maximumCount = 100; // 최대 100장
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 << 20; // 최대 200MB가장 오래 접근하지 않은 이미지부터 자동으로 해제되는 LRU(Least Recently Used) 방식이에요.
③ 임계치 모니터링 + 선제 정리
방어선을 한 겹 더 추가했어요. 메모리 사용량이 임계치에 도달하면 캐시를 선제적으로 정리하도록 했어요.
// 화면 전환 시 메모리 체크 및 선제 정리
void onRouteChanged() {
final currentBytes = PaintingBinding.instance.imageCache.currentSizeBytes;
if (currentBytes > 500 * 1024 * 1024) { // 500MB 임계치
PaintingBinding.instance.imageCache.clear();
}
}| 지표 | Before | After | 개선폭 |
|---|---|---|---|
| 메모리 피크 | 1.5GB | 700MB | 53%↓ |
| 저사양 기기 OOM 크래시 | 빈번 발생 | 0건 | 해결 |
| 이미지당 메모리 | ~4.5MB | ~16KB | 280배↓ |
| 이미지 로딩 체감 속도 | 기준 | 동일 | UX 유지 |
"이미지가 많으니까 메모리 문제일 거야"라는 직감은 맞았지만, 정확한 원인은 달랐어요. 이미지의 "수"가 아니라 이미지의 "해상도"와 "캐시 정책"이 핵심이었어요. DevTools Heap Snapshot을 안 봤다면 리사이징만 적용하고 "좀 나아졌네" 하고 넘어갔을 거예요.
리사이징만으로는 1.1GB까지밖에 줄지 않았어요. 캐시 정책 변경만으로도 부족했을 거예요. 세 가지 레이어(다운샘플링 + LRU + 임계치 모니터링)를 조합해야 근본적으로 해결됐어요.
고사양 기기에서는 RAM이 넉넉해서 OOM이 발생하지 않아요. 하지만 문제가 없는 게 아니라, 증상이 숨어 있을 뿐이에요. 저사양 기기를 기준으로 테스트해야 실제 사용자가 겪는 문제를 잡을 수 있어요.
memCacheWidth/memCacheHeight를 설정했는가?imageCache.maximumCount와 maximumSizeBytes에 상한이 있는가?