앱이 108MP 사진을 열면 무슨 일이 일어날까? 안드로이드 대용량 이미지 뒤에 숨은 한계
앱이 108MP 사진을 열면 무슨 일이 일어날까? 안드로이드 대용량 이미지 뒤에 숨은 한계
사용자가 "사진 변경"을 누르고 갤러리에서 사진 한 장을 고르면, 앱은 그 사진을 BitmapFactory.decodeStream()으로 디코딩합니다. 테스트한 이미지에서는 모두 잘 동작합니다. 그러다 누군가 108MP 카메라로 찍은 사진을 고르는 순간, 화면이 Canvas: trying to draw too large(432000000bytes) bitmap. 오류를 내며 크래시하거나, 크롭한 결과가 액티비티 결과(activity result)를 통해 끝내 돌아오지 않거나, 셀카가 좌우 반전된 채로 돌아옵니다. 이 중 어느 것도 코드 한 줄짜리 버그가 아닙니다. 모두 플랫폼에 고정된 한계이며, Compose Multiplatform 이미지 크로퍼인 Crayfish는 이 한계를 하나도 빠짐없이 넘어서야 했습니다.
이 글에서는 여러분이 직접 작성할 법한 디코딩 호출에서 출발해, 큰 사진 한 장이 플랫폼 아래로 내려가며 거치는 지점을 차례로 따라갑니다. 캔버스 크기 제한, 디코딩 예산과 샘플 크기, 바이트 예산으로 관리하는 타일 캐시 뒤의 영역 디코딩, 회전한 크롭에 필요한 두 번째 버퍼, 액티비티 결과가 지나가는 Binder 버퍼, 그리고 미러에 해당하는 Exif 방향 네 가지를 다룹니다.
근본적인 문제: 파일 크기로는 메모리 사용량을 알 수 없다
대부분의 앱이 처음에 작성하는 로딩 코드는 다음과 같습니다. 피커가 돌려준 Uri를 열고 이미지 전체를 디코딩합니다.
fun loadPhoto(context: Context, uri: Uri): Bitmap? =
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
디코딩된 비트맵은 파일이 어떻게 압축되어 있었는지 신경 쓰지 않습니다. 기본 설정인 ARGB_8888에서는 픽셀 하나에 4바이트가 들기 때문에, 사진 한 장에 필요한 메모리는 너비 × 높이 × 4입니다.
- 48MP 사진(8000 × 6000): 192,000,000바이트, 약 183 MiB.
- 108MP 사진(12000 × 9000): 432,000,000바이트, 약 412 MiB.
- 200MP 사진: 800,000,000바이트, 약 763 MiB.
디스크에 있는 파일은 아무런 경고도 해 주지 않습니다. 단색 하나로 채운 12000 × 9000 이미지는 무손실 WebP로 4.1 KiB, PNG로 326 KiB, 품질 75의 JPEG로 619 KiB입니다. 그런데 셋 다 디코딩하면 똑같이 412 MiB가 됩니다. 파일이 픽셀을 아무리 적은 바이트로 기술했더라도 디코더는 모든 픽셀을 만들어 내기 때문입니다. "10 MB가 넘는 파일은 거부한다" 같은 규칙은 엉뚱한 것을 재고 있는 셈입니다.
그렇다면 너무 큰 비트맵은 어디에서 실패할까요? Android 8.0 이상에서는 비트맵 픽셀이 Java 힙이 아니라 네이티브 메모리에 놓이기 때문에, 여유 메모리가 충분한 기기라면 디코딩 자체는 성공할 수 있습니다. 실패는 그 뒤, 비트맵을 그릴 때 찾아옵니다. 하드웨어 가속 렌더링을 위해 그리기 명령을 기록하는 캔버스인 RecordingCanvas를 살펴보면 크기 상한이 있습니다.
private static int getPanelFrameSize() {
final int DefaultSize = 150 * 1024 * 1024; // 150 MB;
return Math.max(SystemProperties.getInt("ro.hwui.max_texture_allocation_size", DefaultSize),
DefaultSize);
}
public static final int MAX_BITMAP_SIZE = getPanelFrameSize();
위치를 지정해 그리거나 사각형 안에 그리는 drawBitmap 오버로드는 비트맵을 이 상한과 비교합니다. Compose의 Image도 결국 이 오버로드를 호출합니다.
@Override
protected void throwIfCannotDraw(Bitmap bitmap) {
super.throwIfCannotDraw(bitmap);
int bitmapSize = bitmap.getByteCount();
if (bitmap.getConfig() != Bitmap.Config.HARDWARE && bitmapSize > MAX_BITMAP_SIZE) {
throw new RuntimeException(
"Canvas: trying to draw too large(" + bitmapSize + "bytes) bitmap.");
}
}
이 두 코드 조각에는 실무에서 중요한 세부 사항이 세 가지 있습니다.
- 상한은 안드로이드 버전에 따라 다릅니다. 이 검사는 Android 7.0에서 100 MiB 고정값으로 처음 들어왔습니다. Android 15는 기본값을 여기 보이는 150 MiB로 올렸고, 픽셀이 그래픽 메모리에 있는
HARDWARE비트맵은 검사 대상에서 뺐습니다. - 기기는 상한을 올릴 수는 있어도 낮출 수는 없습니다. Android 12부터는
Math.max가 시스템 프로퍼티와 기본값 중 큰 쪽을 고르므로, 제조사가 더 큰 값을 허용할 수는 있지만 여러분의 코드가 그 값에 기댈 수는 없습니다. - 예외는 디코딩이 아니라 그리기에서 발생합니다. 스택 트레이스는 뷰나 컴포저블의 그리기 단계를 가리키며, 문제를 만든
decodeStream호출과는 한참 떨어져 있습니다.
이제 이 상한을 앞의 숫자와 비교해 보세요. 48MP 사진은 이미 150 MiB를 넘고, 108MP 사진은 거의 세 배, 200MP 사진은 다섯 배를 넘습니다. 원본 해상도의 이 이미지들은 그리기가 느린 것이 아니라 아예 그릴 수가 없습니다. 그러니 해결책은 더 큰 힙이 아닙니다. 그런 비트맵을 애초에 만들지 않는 것이 해결책입니다.
디코딩 예산: 디코더가 할당하기 전에 샘플 크기 고르기
첫 단계는 픽셀 비용을 치르지 않고 이미지 크기를 알아내는 것입니다. BitmapFactory는 이를 위해 inJustDecodeBounds를 제공합니다. 이 옵션은 헤더만 파싱해 크기를 알려 줄 뿐 비트맵을 할당하지 않습니다.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, bounds)
}
val width = bounds.outWidth
val height = bounds.outHeight
크기를 알았으면 inSampleSize를 고릅니다. 각 변은 이 값으로 나눈 크기로 줄어듭니다. 샘플 크기가 2면 픽셀 수가 4분의 1로, 4면 16분의 1로 줄어듭니다. BitmapFactory.Options 문서에는 지금도 "the decoder uses a final value based on powers of 2, any other value will be rounded down to the nearest power of 2"라고, 즉 디코더가 2의 거듭제곱 값을 최종 값으로 쓰고 다른 값은 가장 가까운 2의 거듭제곱으로 내림한다고 적혀 있습니다. 하지만 이는 Android 6.0 이하의 동작입니다. Android 7.0부터 BitmapFactory는 요청한 값에 맞춰 결과를 스케일링하므로, 샘플 크기를 3으로 주면 각 변이 3분의 1이 됩니다. 그래도 Crayfish는 모든 샘플 크기를 2의 거듭제곱으로 유지합니다. 그래야 값을 내림하는 디코더에서도, 그렇지 않은 디코더에서도 Crayfish가 보고하는 배율과 실제로 돌려받는 픽셀이 일치하기 때문입니다.
Crayfish는 스스로 지킬 한계를 타입으로 명시합니다. DecodeBudget은 바이트 상한과 가장 긴 변의 상한, 두 가지를 담습니다.
public data class DecodeBudget(
public val maxByteCount: Long,
public val maxDimension: Int,
) {
public companion object {
public val ForDisplay: DecodeBudget = DecodeBudget(
maxByteCount = 64L * 1024 * 1024,
maxDimension = 4096,
)
public val ForOutput: DecodeBudget = DecodeBudget(
maxByteCount = 192L * 1024 * 1024,
maxDimension = 32_766,
)
}
}
두 예산은 하는 일이 다릅니다. ForDisplay는 화면에 그려지는 모든 것에 쓰이며, 두 숫자가 서로 맞아떨어집니다. 4096 × 4096 ARGB_8888 비트맵은 정확히 64 MiB로, 캔버스 상한보다 넉넉히 작고 앱의 나머지 부분이 쓸 여유도 남습니다. ForOutput은 화면에 그리는 대신 인코딩해서 돌려주는, 완성된 크롭의 픽셀에 쓰이므로 더 큰 일시 버퍼를 허용합니다.