[Android] Bitmap을 최적화 시켜야하는 이유

강승구·2023년 10월 25일

안드로이드 앱에서 이미지를 그대로 사용하면..

최근, 이미지를 서버에 POST하는 기능을 구현하며, 예상치 못한 문제를 겪게 되었다.

이미지의 크기가 너무 커서 413(Request Entity Too Large) 에러가 종종 발생했고, 서버에 전송하는 데 과도한 시간이 소요될 뿐만 아니라 서버 자원이 불필요하게 낭비되고 있음을 확인할 수 있었다.
네트워크 통신 시간이 길어진다면 사용자 경험이 저하되고, 데이터 사용량 증가로 인해 사용자 이탈 가능성도 높아진다고 생각했다.

또한, 최근 스마트폰 카메라의 성능이 빠르게 발전하면서, 사용자가 촬영하는 이미지의 해상도와 크기가 계속 커지고 있다. 고해상도 사진의 경우, 사진 한 장의 크기가 10MB에서 20MB에 이를 수 있으며, 이런 대용량 이미지를 서버에 전송할 경우 서버 관리 비용과 리소스 사용량도 크게 증가할 수 있다.

따라서, 이러한 문제를 해결하기 위해 Bitmap의 크기를 최적화할 필요성을 느꼈고, 네트워크 통신 속도 개선과 서버 리소스 절감을 목표로 안드로이드에서 제공하는 공식 문서를 참고해 최적화 작업을 진행했다.


Bitmap 최적화 시키기

Bitmap이란?

우선 안드로이드에서는 Bitmap 객체를 이용해 이미지를 표현한다. Bitmap은 이미지를 픽셀 단위로 관리하며, 각 픽셀은 고유의 색상 값(ARGB)을 가지고 있어, 이들이 모여 전체 이미지를 구성하게 된다.

따라서 갤러리에 저장된 이미지를 불러오거나, 사용자가 카메라로 촬영한 사진은 Bitmap 객체로 변환되어 관리된다.

안드로이드에서는 BitmapFactory 클래스를 사용해 Bitmap 객체를 생성하고 관리할 수 있다. 해당 클래스는 제공하는 여러 메소드를 이용해 이미지를 크기 조정, 메모리 절약, 또는 특정 포맷 변환 등 다양한 작업을 할 수 있다.

최적화 과정

Bitmap을 최적화 시키는 과정은 다음과 같다.

  1. 임시 파일 생성: 캐시 디렉토리 내에 파일을 생성하여, 최종적으로 압축된 이미지를 저장할 공간을 마련한다.
  2. Bitmap 리사이징: 큰 이미지를 필요한 크기로 축소하여 메모리 사용량을 줄인다.
  3. JPEG로 압축: 이미지를 JPEG 포맷으로 압축하여 파일 크기를 최소화한다.
  4. 임시 파일에 저장: 압축된 이미지를 임시 파일에 저장하고, 해당 파일 경로를 반환한다.

우선 앱 내부저장소에 임시파일을 저장할 경로를 만들기 위한 context와 Bitmap을 불러오기 위한 Uri를 인자로 전달받는 함수를 만들었다.
그리고 context.cacheDir로 임시 파일 경로와 고유한 파일 이름을 만들들어주고 임시 파일을 생성해주었다.

fun optimizeBitmap(context: Context, uri: Uri): String? {
    try {
        val storage = context.cacheDir // 임시 파일 경로
        val fileName = String.format("%s.%s", "image", "jpg") // 임시 파일 이름
        
        val tempFile = File(storage, fileName)
        tempFile.createNewFile() // 임시 파일 생성
        
        // 지정된 이름을 가진 파일에 쓸 파일 출력 스트림을 만든다.
        val fos = FileOutputStream(tempFile) 

다음으로는, 생성된 임시 파일에 데이터를 쓸 수 있도록 파일 출력 스트림을 만들어주었다.

이때 스트림이란, 데이터를 연속적으로 처리하는 방식으로, 데이터가 전달되는 흐름을 의미한다. 예를 들어, 파일에 데이터를 쓰거나, 파일에서 데이터를 읽어올 때, 데이터를 한 번에 모두 처리하지 않고, 일정한 크기로 나누어 순차적으로 처리하는 방식이 바로 스트림이다. 이를 통해 메모리 효율성을 높이고, 큰 데이터를 처리할 수 있게 된다.
스트림에는 두 가지 유형이 있다.

  • InputStream: 데이터를 읽어오는 스트림.
  • OutputStream: 데이터를 쓰는 스트림.

이제 uri를 이용해 Bitmap 객체를 얻어온뒤 Bitmap을 최적화시켜주어 생성한 임시파일에 써주어야한다.

decodeBitmapFromUri(uri)?.apply {
	compress(Bitmap.CompressFormat.JPEG, 100, fos)
	recycle()
} ?: throw NullPointerException()
        

// 최적화 bitmap 반환
private fun decodeBitmapFromUri(uri: Uri, context: Context): Bitmap? {
    
    // 인자값으로 넘어온 입력 스트림을 나중에 사용하기 위해 저장하는 BufferedInputStream 사용
    val input = BufferedInputStream(context.contentResolver.openInputStream(uri))
    
    input.mark(input.available()) // 입력 스트림의 특정 위치를 기억
    
    var bitmap: Bitmap?
    
    BitmapFactory.Options().run {
    	// inJustDecodeBounds를 true로 설정한 상태에서 디코딩한 다음 옵션을 전달
    	inJustDecodeBounds = true
		bitmap = BitmapFactory.decodeStream(input, null, this)
        
        input.reset() // 입력 스트림의 마지막 mark 된 위치로 재설정
        
        // inSampleSize 값과 false로 설정한 inJustDecodeBounds를 사용하여 다시 디코딩
        inSampleSize = calculateInSampleSize(this)
        inJustDecodeBounds = false
        
        bitmap = BitmapFactory.decodeStream(input, null, this)
    }
    
    input.close()
    
    return bitmap
    
}

bitmap compress
형식을 지정해서 Bitmap을 압축하는 메소드이다.

public boolean compress(Bitmap.CompressFormat format, int quality, OutputStream stream) {...}
  • CompressFormat
    압축할 파일 타입으로 보통 JPEG, PNG를 사용한다.
    투명 값을 가진 이미지를 저장하는 것이 아니므로 압축 속도가 더 빠르고, 압축 시 파일 용량이 더 작은 JPEG 타입을 사용할 것이다.

  • quality
    압축 정도를 의미한다. 0~100의 정수를 넣으면 (quality)%로 압축된다.

  • OutputStream
    Bitmap 이미지를 저장하기 위한 output stream 객체를 받는다.


이미지가 회전되어 전송되는 문제 발생

최적화 이후, 이미지의 사이즈는 줄어들어 전송이 되었지만, 몇몇 이미지가 회전된 채로 서버에 전송되는 문제가 있었다.

해당 문제에 대해 구글링해보니, 스마트폰 카메라로 촬영한 이미지는 회전 정보(EXIF 데이터)를 포함하고 있기 때문이라고 한다. 스마트폰 카메라는 이미지를 촬영할 때 기기의 방향(가로, 세로)을 저장하고, 이 정보를 이미지에 EXIF 데이터로 기록한다. 이 때문에 일부 이미지는 카메라의 각도에 따라 잘못된 방향으로 저장되거나 표시될 수 있다.

따라서 이미지가 가지고 있는 정보의 집합 클래스인 ExifInterface를 사용해 이미지의 회전 정보를 읽고, 필요한 경우 rotateImage 메소드를 이용해 이미지를 재회전시켜 올바른 방향으로 맞춰줘야 한다.
rotateImageIfRequired 메소드는 optimizeBitmap에서 decodeStream을 진행한 후에 호출해주면 된다.

private fun rotateImageIfRequired(context: Context, bitmap: Bitmap, uri: Uri): Bitmap? {
    // 이미지의 InputStream을 열어 EXIF 데이터를 읽음
    val input = context.contentResolver.openInputStream(uri) ?: return null

    // SDK 버전에 따라 ExifInterface 인스턴스 생성
    val exif = if (Build.VERSION.SDK_INT > 23) {
        ExifInterface(input)
    } else {
        ExifInterface(uri.path!!)
    }

    // EXIF에서 회전 정보를 가져옴
    val orientation =
        exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

    // 회전된 각도에 따라 이미지를 재회전
    return when (orientation) {
        ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90)
        ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180)
        ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270)
        else -> bitmap // 회전이 필요 없는 경우 원본 bitmap 반환
    }
}

private fun rotateImage(bitmap: Bitmap, degree: Int): Bitmap? {
    // Matrix 객체를 사용해 이미지를 회전
    val matrix = Matrix()
    matrix.postRotate(degree.toFloat())
    
    // 회전된 Bitmap 생성 및 반환
    return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
profile
강승구

0개의 댓글