[Android] Glide, Picasso 와 같은 라이브러리는 어떻게 이미지 로드를 최적화할까? — 1편 : Down Sampling

DongYeon-Lee
9 min readJan 27, 2024

--

Photo by Patrick on Unsplash

애플리케이션을 사용하다 보면 수많은 이미지를 접하게 됩니다. 쇼핑몰의 상품 이미지일 수도 있고, SNS의 여러 이미지일 수도 있습니다. 이렇듯 이미지는 현대사회에서 볼 수 있는 가장 보편적인 시각자료중 하나입니다.

그러나 이러한 이미지들을 앱으로 그려주는 작업은 너무나도 무겁습니다. URL 통신으로 ByteArray 형태의 이미지를 불러와서, 디코딩 과정을 거쳐, Canvas에 픽셀 하나하나 찍어 주어야 하니말이죠.

이러한 이미지 최적화에 대한 방안으로 Glide, Picasso와 같은 이미지 로드 라이브러리에 대해서 떠올리셨을 거라 생각합니다. 해당 라이브러리들은 여러 타입의 이미지를 간편하게 로드할 수 있다는 점 뿐만 아니라, 이미지 로드 속도, 메모리 사용량 까지 최적화하여 원활하고 쾌적한 앱 사용을 가능하게 해줍니다.

이러한 라이브러리들의 이미지 최적화에 대한 비밀은 크게 두가지로 나눌 수 있습니다.

  1. 이미지 다운샘플링
  2. 이미지 캐시

오늘은 이중에서 이미지 다운샘플링에 대해서 알아볼까 합니다.

Down Sampling

앞서 말했듯, 이미지의 디코딩작업은 매우 무겁고, 막대한 컴퓨팅 자원을 요하는 작업입니다. 이러한 고비용의 작업을 최적화하고자, 디코딩 하기 전, 사전에 미리 이미지 사이즈를 작게 세팅하는 작업이 다운샘플링입니다.

Prevent OOM(Out-of-memory)

다운샘플링을 통해 막대한 메모리 사용으로 인한 OOM(Out-Of-Memory) 에러를 방지할 수 있습니다. 작은 사이즈의 이미지뷰에 사용될 이미지에 대해서 원본 이미지크기를 메모리에 담는다면 막대한 메모리 사용량으로 인해 OOM을 발생시키는 것이죠.

한가지의 예로 만약 포토갤러리 앱을 만든다고 가정해봅시다. 포토그래퍼에게 1920*1080 의 이미지들을 받아서 320*240사이즈의 이미지뷰에 넣어야하는 상황입니다.

이 때, 1920*1080 사진을 다운샘플링 없이 넣는다고 할 때 메모리 사용량을 계산해보면 다음과 같이 7.91MiB의 메모리를 사용하게 됩니다.

1920 * 1080 * 4 = 8,294,400byte(7.91MiB)

* 32비트 ARGB(ARGB_8888) 기준으로 1픽셀당 4byte(32bit)의 메모리가 사용됨

여기서 이미지뷰 사이즈(320*240)에 맞게 다운샘플링 과정을 수행했을 경우의 용량을 구해보면 다음과 같습니다.

320 * 340 * 4 = 307,200byte(300KiB)

다운샘플링 전후를 비교해 보면 7.6MiB 만큼 줄어든 것을 볼 수 있습니다.작은 사이즈의 이미지뷰에 매핑할 것이라 큰 이미지는 필요가 없습니다. 만약 포토그래퍼로 부터 더 큰 사이즈의 이미지를 받았다면 OOM 위험성은 더욱 커지겠죠.

다운샘플링을 직접 해보자

다운샘플링을 하기 위해선 안드로이드에서 제공해주는 BitmapFactory API 를 이용하면 됩니다. BitmapFactory는 비트맵을 생성, 가공하기 위한 여러 기능을 제공해줍니다.

우선 외부 URL 이미지를 ByteArray로 불러오기위해 다음 코드를 작성했습니다.

suspend fun getByteArray(url: String): ByteArray{
val byteArrayDeferred = CoroutineScope(Dispatchers.IO).async {
URL(url).readBytes()
}
return byteArrayDeferred.await()
}

앞에서 구한 ByteArray 를 비트맵으로 변환하려면 BitmapFactory.decodeByteArray(…) 를 이용하면 됩니다.

/*
* param 1 : 파싱하고자 하는 byteArray
* param 2 : 파싱을 시작하고자 하는 지점 (offset)
* param 3 : 파싱하고자 하는 byteArray의 사이즈(byte 용량)
*/
val bitmap: Bitmap = BitmapFactory
.decodeByteArray(byteArray, 0, byteArray.size)

앞서 구한 Bitmap은 사진의 원본 사이즈입니다. 사이즈에 대한 로그를 찍어보면 다음과 같이 출력됩니다. 파일의 원본 사이즈 계산방식은 앞서 계산한 계산식과 일치하는 것을 볼 수 있습니다. (Android에서 기본적으로 ARGB_8888 포맷을 사용하여 디코딩하기 때문입니다.)

val originalWidth = bitmap.width
val originalHeight = bitmap.height
val size = bitmap.byteCount

// originalWidth: 1920, originalHeight: 1280, size: 9830400
// size = 1920 * 1280 * 4

이제 본격적으로 파일을 용량을 줄여보겠습니다. BitmapFactory.Options()inSampleSize 값으로 다운샘플링할 해상도 배수를 지정할 수 있습니다.

fun downSample(
byteArray: ByteArray,
requestWidth: Int,
requestHeight: Int
): Bitmap = BitmapFactory.Options().run {
// inJustDecodeBounds가 true일 때, decodeByteArray(...)는 null을 리턴합니다.
// 대신 options의 outHeight, outWidth를 사진의 원본 해상도값으로 set해줍니다.
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, this)

// inSampleSize: 다운샘플링 배율입니다. 1이면 원본, 2면 1/2 같은 방식으로 처리됩니다.
inSampleSize = calcInSampleSize(this, requestWidth, requestHeight)

// 최종적으로 bitmap을 얻기위해 inJustDecodeBounds를 false로 바꿔줍니다.
inJustDecodeBounds = false
BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, this)
}

위 과정에서 적절한 inSampleSize를 구하는 것이 가장 중요합니다. 너무 큰값이면 이미지 품질이 나빠질 것이고, 너무 작은 값이면 디코드 처리가 그만큼 무거워지게 되는 것이죠. 따라서 이미지 품질을 해치지 않도록, 최적의 다운샘플링 배수를 구해야합니다.

다음 코드는 요청 사이즈에 가장 가까운 inSampleSize를 구하는 코드입니다.

fun calcInSampleSize(
options: BitmapFactory.Options,
requestWidth: Int,
requestHeight: Int
): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1

// 요청 해상도가 더 작다면, 가로 배율과 새로 배율중 더 성능적으로 좋은 값을 채택합니다.
if (requestHeight < height || requestWidth < width) {
inSampleSize = max(
ceil(height.toFloat() / requestHeight).toInt(),
ceil(width.toFloat() / requestWidth).toInt()
)
}

return inSampleSize
}

이제 원하는 액티비티에 다음과 같이 적용하면 됩니다.

lifecycleScope.launch {
val bitmap = getByteArray(/* Set Image URL you want */)
var bm: Bitmap = downSample(bitmap, /* Set width */, /* Set height */)

binding.imageView.setImageBitmap(bm)
}

다운샘플링 결과 전후 비교

이제 어느정도 이미지 로드가 최적화되었는지 확인해보겠습니다. 다음 코드와 같이 다운샘플링 과정 유무에 따른 소요시간(ms), 사이즈(byte) 차이를 비교하였습니다. 원본 이미지는 1920*1280 사이즈의 이미지이고, 320*240 으로 다운샘플링 한 결과에 대한 비교입니다.

lifecycleScope.launch {
val bitmap = getByteArray("...")

val originalTime = measureTimeMillis {
BitmapFactory.decodeByteArray(bitmap, 0, bitmap.size)
.also { println("original size = ${it.byteCount}byte") }
}
println("original time = ${originalTime}ms")

var bm: Bitmap

val downSampledTime = measureTimeMillis {
bm = downSample(bitmap, 240, 320)
.also { println("downSampled size = ${it.byteCount}byte") }
}
println("downSampled time = ${downSampledTime}ms")

binding.imageView.setImageBitmap(bm)
}

결과는 다음과 같습니다. 높은 효율을 위해 inSampleSize 계산로직을 다소 타이트하게 작성했네요. calcInSampleSize(…) 로직은 입맛에 맞게 보정하시면 될 것 같습니다.

original size = 9830400byte
original time = 49ms

calulated inSampleSize = 8
downSampled dimensions = 240 * 160

downSampled size = 153600byte
downSampled time = 8ms

마치며

이미지 다운샘플링은 메모리 최적화를 위한 선택이 아닌 필수작업입니다. 그렇지만 다운샘플링만 해서는 앱이 완벽하게 최적화가 되지 않습니다. 중복적인 디코드 작업으로 인한 컴퓨팅 자원 소비와, 여러 이미지 뷰에 set 해주는 작업으로 인한 메모리 점유 관련 비효율성이 아직 존재하긴합니다. 이를 해결하기 위해서는 인메모리 캐시를 사용하면 됩니다. 다음 포스팅에서는 이미지를 효율적으로 캐시하는 방법에 관해 이야기해볼까 합니다.

--

--