안녕하세요! Android 개발자 한민재입니다. 이번에는 Spoony 프로젝트를 진행하면서 겪었던 이미지 압축 관련 이슈를 다뤄보려고 해요. 게시글 작성 기능을 개발하면서 마주친 문제와 그 해결 과정을 공유하려고 합니다!
해당 이슈가 담긴 PR입니다: https://github.com/SOPT-all/35-APPJAM-ANDROID-SPOONY/pull/200
🤔 어떤 문제가 있었나요?
게시글 작성 화면에서 이미지 업로드 기능을 구현하던 중이었는데요. 기존 BitmapFactory를 사용했을 때 다음과 같은 문제점들이 있었어요
- 용량이 큰 단일 이미지 혹은 여러 이미지 압축 속도가 느렸어요 (평균 1,200ms 이상)
- 용량이 큰 이미지를 여러 장 업로드하면 앱이 중단되는 현상이 발생했어요
- 압축된 이미지 품질이 일관적이지 않았어요
🔍 원인을 파헤쳐봤어요
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
}
이 코드의 문제점들을 하나씩 살펴볼게요
- 메모리 낭비
- BitmapFactory는 큰 이미지를 원본 해상도로 디코딩할 경우 전부 메모리에 올려요
- 예를 들어 4032x3024 이미지를 디코딩하면 46MB의 메모리를 한 번에 사용해요
- 여러 장의 이미지를 처리할 때 OOM이 발생하기 쉬워요
- 부정확한 크기 조정
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
)
}
여기에도 몇 가지 문제가 있었어요
- 비효율적인 압축률 계산
- 단순히 선형적으로 압축률을 계산해요
- 이미지의 특성(복잡도, 색상 분포 등)을 전혀 고려하지 않아요
- 결과적으로 일관성 없는 품질의 이미지가 생성돼요
- 메모리 버퍼 관리 부재
- 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()
}
}
이 방식의 장점은 아래와 같아요
- 최적 품질 탐색
- 선형방법인 이전과 다르게 이진 탐색으로 최적의 압축률을 찾아요
- 로그 시간(O(log n))으로 빠르게 결과를 얻을 수 있어요
- 파일 크기 제한을 지키면서 최상의 품질을 보장해요
- 효율적인 메모리 사용
- 예상 크기로 버퍼를 미리 할당해요
- 버퍼를 재사용해서 메모리 할당을 최소화해요
- 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 향상된 압축률!
📝 정리해 볼까요?
- 무엇이 개선됐나요?
- ImageDecoder로 메모리 효율성 대폭 향상
- 이진 탐색으로 최적의 압축 품질 보장
- EXIF 정보 보존으로 올바른 이미지 방향 유지
- 주의할 점
- ImageDecoder는 API 28 이상 필요해요
- 만약 하위 버전 대응을 하고 있다면 대체 로직 구현 필요해요
- 메모리 캐시 도입을 고려해 볼 수 있어요
📚 참고한 자료들
더 좋은 해결 방법이나 의견이 있으시다면 댓글로 공유해 주시면 감사하겠습니다 :)
'Android > Jetpack' 카테고리의 다른 글
[Android Jetpack] Paging3와 LazyVerticalGrid의 페이지 요청 최적화 방법 (0) | 2025.02.19 |
---|