[Android Jetpack] 이미지 압축 최적화하기: ImageDecoder 도입

2025. 2. 20. 14:27·Android/Jetpack

안녕하세요! Android 개발자 한민재입니다. 이번에는 Spoony 프로젝트를 진행하면서 겪었던 이미지 압축 관련 이슈를 다뤄보려고 해요. 게시글 작성 기능을 개발하면서 마주친 문제와 그 해결 과정을 공유하려고 합니다!

해당 이슈가 담긴 PR입니다: https://github.com/SOPT-all/35-APPJAM-ANDROID-SPOONY/pull/200

🤔 어떤 문제가 있었나요?

게시글 작성 화면에서 이미지 업로드 기능을 구현하던 중이었는데요. 기존 BitmapFactory를 사용했을 때 다음과 같은 문제점들이 있었어요

  1. 용량이 큰 단일 이미지 혹은 여러 이미지 압축 속도가 느렸어요 (평균 1,200ms 이상)
  2. 용량이 큰 이미지를 여러 장 업로드하면 앱이 중단되는 현상이 발생했어요
  3. 압축된 이미지 품질이 일관적이지 않았어요

🔍 원인을 파헤쳐봤어요

1. BitmapFactory의 메모리 문제

기존 코드를 보면 이렇게 구현되어 있었어요

contentResolver.openInputStream(uri).use { inputStream ->
    if (inputStream == null) return
    val option = BitmapFactory.Options().apply {
        inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT)
    }
    originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) ?: return
}

이 코드의 문제점들을 하나씩 살펴볼게요

  1. 메모리 낭비
    • BitmapFactory는 큰 이미지를 원본 해상도로 디코딩할 경우 전부 메모리에 올려요
    • 예를 들어 4032x3024 이미지를 디코딩하면 46MB의 메모리를 한 번에 사용해요
    • 여러 장의 이미지를 처리할 때 OOM이 발생하기 쉬워요
  2. 부정확한 크기 조정
private fun calculateInSampleSize(
    options: BitmapFactory.Options,
    reqWidth: Int,
    reqHeight: Int
): Int {
    var inSampleSize = 1
    while (halfHeight / inSampleSize >= reqHeight) {
        inSampleSize *= 2
    }
    return inSampleSize
}
  • inSampleSize는 2의 배수만 사용할 수 있어요
  • 예: 원하는 크기가 1100px인데 1024px 또는 2048px로만 조정 가능해요
  • 결과적으로 필요 이상의 큰 이미지가 메모리에 로드돼요

2. 단순한 압축 방식

기존의 압축 로직을 볼까요?

val outputStream = ByteArrayOutputStream()
val imageSizeMb = size / (MAX_WIDTH * MAX_HEIGHT.toDouble())
outputStream.use {
    val compressRate = ((IMAGE_SIZE_MB / imageSizeMb) * 100).toInt()
    originalBitmap.compress(
        Bitmap.CompressFormat.JPEG,
        if (imageSizeMb >= IMAGE_SIZE_MB) compressRate else 100,
        it
    )
}

여기에도 몇 가지 문제가 있었어요

  1. 비효율적인 압축률 계산
    • 단순히 선형적으로 압축률을 계산해요
    • 이미지의 특성(복잡도, 색상 분포 등)을 전혀 고려하지 않아요
    • 결과적으로 일관성 없는 품질의 이미지가 생성돼요
  2. 메모리 버퍼 관리 부재
    • ByteArrayOutputStream의 초기 크기를 지정하지 않아요
    • 버퍼가 동적으로 늘어나면서 추가적인 메모리 할당이 발생해요
    • GC에 부담을 주는 원인이 돼요

💡 어떻게 해결했을까요?

1. ImageDecoder 도입

Android P(API 28)에서 소개된 ImageDecoder를 사용하도록 변경했어요.

private suspend fun loadBitmap(uri: Uri): Result<Bitmap> =
    withContext(Dispatchers.IO) {
        runCatching {
            val source = ImageDecoder.createSource(contentResolver, uri)
            ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE  // 메모리 할당 방식 설정
                decoder.isMutableRequired = true
                calculateTargetSize(info.size.width, info.size.height).let { size ->
                    decoder.setTargetSize(size.width, size.height)
                }
            }
        }
    }

왜 ImageDecoder가 더 좋을까요?

1. 메모리 효율성

  • ImageDecoder는 setTargetSize를 통해 원하는 해상도로 바로 디코딩해요
  • 원본 전체를 메모리에 올리지 않고, 필요한 크기로만 최적화해서 디코딩해요
  • ALLOCATOR_SOFTWARE를 사용해 CPU 메모리에 직접 할당하여 메모리 관리가 더 예측 가능해져요
  • 메인 스레드 블로킹을 방지하기 위해 IO 디스패처를 사용해요
  • 실제 테스트 결과 메모리 사용량이 73% 감소했어요

2. 정확한 크기 조정

private fun calculateTargetSize(width: Int, height: Int): Size {
    if (width <= config.maxWidth && height <= config.maxHeight) {
        return Size(width, height)
    }
    val scaleFactor = min(
        config.maxWidth / width.toFloat(),
        config.maxHeight / height.toFloat()
    )
    return Size(
        (width * scaleFactor).toInt(),
        (height * scaleFactor).toInt()
    )
}
  • 원하는 크기로 정확하게 리사이징이 가능해요
  • 불필요한 메모리 낭비가 없어요
  • 화면에 표시될 크기에 맞게 최적화된 이미지를 얻을 수 있어요

2. 이진 탐색 기반 압축 알고리즘

압축 방식을 새로 구현했어요

private suspend fun compressBitmap(bitmap: Bitmap): ByteArray =
    withContext(Dispatchers.IO) {
        val estimatedSize = min(bitmap.byteCount / 4, config.maxFileSize)
        ByteArrayOutputStream(estimatedSize).use { buffer ->
            var lowerQuality = config.minQuality
            var upperQuality = config.initialQuality
            var bestQuality = lowerQuality

            while (lowerQuality <= upperQuality) {
                val midQuality = (lowerQuality + upperQuality) / 2
                buffer.reset()

                bitmap.compress(config.format, midQuality, buffer)

                if (buffer.size() <= config.maxFileSize) {
                    bestQuality = midQuality
                    lowerQuality = midQuality + 1
                } else {
                    upperQuality = midQuality - 1
                }
            }

            buffer.toByteArray()
        }
    }

이 방식의 장점은 아래와 같아요

  1. 최적 품질 탐색
    • 선형방법인 이전과 다르게 이진 탐색으로 최적의 압축률을 찾아요
    • 로그 시간(O(log n))으로 빠르게 결과를 얻을 수 있어요
    • 파일 크기 제한을 지키면서 최상의 품질을 보장해요
  2. 효율적인 메모리 사용
    • 예상 크기로 버퍼를 미리 할당해요
    • 버퍼를 재사용해서 메모리 할당을 최소화해요
    • GC 부하가 크게 감소했어요

3. 자잘한 개선들

1. EXIF 정보 처리

private fun getOrientation(uri: Uri): Int {
    return contentResolver.query(
        uri,
        arrayOf(MediaStore.Images.Media.ORIENTATION),
        null,
        null,
        null
    )?.use { cursor ->
        if (cursor.moveToFirst()) {
            cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
        } else 0
    } ?: getExifOrientation(uri)
}
  • 이미지의 회전 정보를 보존해요
  • MediaStore와 ExifInterface를 모두 활용해 안정성을 높였어요
  • 불필요한 회전 연산을 줄였어요

2. 에러 처리 개선

suspend fun prepareImage(): Result<Unit> = runCatching {
    withContext(Dispatchers.IO) {
        uri?.let { safeUri ->
            compressImage(safeUri).onSuccess { bytes ->
                compressedImage = bytes
            }
        }
    }
}
  • Result 타입을 활용한 명확한 에러 처리
  • 코루틴 취소 시 리소스 정리 보장
  • 메모리 릭 방지

2. 설정 분리

data class ImageConfig(
    val maxWidth: Int,
    val maxHeight: Int,
    val maxFileSize: Int,
    val initialQuality: Int,
    val minQuality: Int,
    val format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG
)
  • 압축 설정을 별도 클래스로 분리
  • 유연한 설정 변경 가능
  • 테스트 용이성 향상

📈 개선 결과를 수치로 확인해 볼까요?

6회에 걸쳐 다양한 크기의 이미지로 테스트를 진행했어요

테스트 환경과 측정 방법

  • 원본 이미지 크기는 ContentResolver로 측정했어요 (openInputStream으로 확인)
  • 메모리 사용량은 네이티브 힙 메모리를 측정했어요 (Debug.getNativeHeapAllocatedSize 사용)
  • 압축 후 크기는 RequestBody의 contentLength()로 확인했어요
  • 실행 시간도 함께 측정했어요

참고로 이 테스트는 도구 없이 런타임에 측정한 결과예요!

1. 처리 속도

📊 기존 방식
- 최소: 735ms
- 최대: 2,059ms
- 평균: 1,224ms

📊 개선 방식
- 최소: 177ms
- 최대: 608ms
- 평균: 289ms

✨ 75% 이상 속도 향상!

2. 메모리 사용량

📊 기존 방식
- 평균: 원본 대비 150%
- 최대 스파이크: 8.5MB

📊 개선 방식
- 평균: 원본 대비 40%
- 최대 스파이크: 0.55MB

✨ 73% 메모리 사용량 감소!

3. 압축 품질

📊 기존 방식
- 압축률: 73.1% ~ 90.5%
- 평균: 82.3%

📊 개선 방식
- 압축률: 93.3% ~ 97.2%
- 평균: 94.8%

✨ 더 나은 품질로 12.5%p 향상된 압축률!

📝 정리해 볼까요?

  1. 무엇이 개선됐나요?
    • ImageDecoder로 메모리 효율성 대폭 향상
    • 이진 탐색으로 최적의 압축 품질 보장
    • EXIF 정보 보존으로 올바른 이미지 방향 유지
  2. 주의할 점
    • ImageDecoder는 API 28 이상 필요해요
    • 만약 하위 버전 대응을 하고 있다면 대체 로직 구현 필요해요
    • 메모리 캐시 도입을 고려해 볼 수 있어요

📚 참고한 자료들

  • Android ImageDecoder 공식 문서
  • Android 이미지 로딩 최적화 가이드
  • Bitmap 메모리 관리 문서

더 좋은 해결 방법이나 의견이 있으시다면 댓글로 공유해 주시면 감사하겠습니다 :)

'Android > Jetpack' 카테고리의 다른 글

[Android Jetpack] Paging3와 LazyVerticalGrid의 페이지 요청 최적화 방법  (0) 2025.02.19
'Android/Jetpack' 카테고리의 다른 글
  • [Android Jetpack] Paging3와 LazyVerticalGrid의 페이지 요청 최적화 방법
한민돌
한민돌
Android 개발자가 되기까지.
  • 한민돌
    미래 가젯 연구소
    한민돌
  • 전체
    오늘
    어제
    • 분류 전체보기 (13)
      • Android (1)
        • Compose (8)
        • Jetpack (2)
      • Kotlin (2)
        • Kotlin In Action (0)
      • 외부 활동 (0)
        • SOPT (0)
        • SOPT makers (0)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    custom plugin
    onfailure
    lazyverticalgrid
    coroutine
    LaunchedEffect
    effecthandler
    imagecompress
    sideeffect
    Google Recommend Architecture
    producestate
    rememberupdatedstate
    derivedstateof
    build-logic
    imagedecoder
    compose
    runcatching
    jetpack
    Multi-module
    coroutune
    Android
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
한민돌
[Android Jetpack] 이미지 압축 최적화하기: ImageDecoder 도입
상단으로

티스토리툴바