Android Bitmap과 메모리 최적화

2026. 2. 22. 12:10·Android

픽셀

픽셀(Pixel)은 Picture Element의 줄임말로, 디지털 이미지를 구성하는 최소 단위다. 각 픽셀은 빨강(R), 초록(G), 파랑(B), 투명도(A, Alpha) 네 가지 채널로 표현되며 각 채널은 0~255 범위의 값을 1byte로 저장한다. 따라서 ARGB_8888 포맷 기준으로 픽셀 하나는 4byte를 차지한다. ARGB_8888이 포맷에 대해서는 추후 자세히 다루겠다.

색상 Red Green Blue Alpha
빨강색 255 0 0 255
파란색 0 0 255 255
흰색 255 255 255 255
투명 0 0 0 0

비트맵

비트맵(Bitmap)은 이 픽셀들이 압축 없이 메모리에 펼쳐진 형태다. JPEG, PNG 같은 이미지 파일은 압축된 상태로 저장되지만 화면에 렌더링 하기 위해서는 반드시 Bitmap으로 디코딩해야 한다. 때문에 3MB짜리 JPEG가 메모리상에서 수십 MB로 불어날 수 있으며, 메모리 사용량은 아래 공식으로 산출된다.

💡 메모리 사용량 공식
Bitmap Memory = 가로(px) × 세로(px) × 픽셀당 바이트수

ContentResolver와 BitmapFactory로 Bitmap 생성하기

Android에서 Uri로부터 이미지를 디코딩하는 가장 기본적인 방법은 ContentResolver로 InputStream을 열고 BitmapFactory.decodeStream()에 넘기는 것이다.

private fun uriToBitmap(imageUri: Uri): Bitmap? {
    return contentResolver.openInputStream(imageUri)?.use { inputStream ->
        BitmapFactory.decodeStream(inputStream)
    }
}

InputStream

InputStream은 데이터를 바이트 스트림 단위로 순차적으로 읽기 위한 파이프라인으로 openInputStream()을 통해 Uri가 가리키는 파일의 원본 바이트에 접근할 수 있다.

private fun uriToBitmap(imageUri: Uri): Bitmap {
    return contentResolver.openInputStream(imageUri)?.use { inputStream ->
        val bytes = inputStream.readBytes()

        Log.d("StreamTest", "총 바이트: ${bytes.size}")
        Log.d("StreamTest", "파일 헤더: ${bytes.joinToString(" ") { "%02X".format(it) }}")

	// ...
}

바이트 스트림을 실제로 로그를 통해 확인해 본 결과 다음과 같이 16진수 값이 출력된다.

이를 간단하게 해석해보면 가장 앞에 있는 3byte(FF D8 FF)는  JPEG 파일의 시작을 알리는 SOI(Start of Image) 마커다. 이를 통해 나의 휴대폰으로 찍은 사진은 JPEG 형태로 저장되는 것을 알 수 있다.

출처 : https://www.file-recovery.com/jpg-signature-format.htm

private fun uriToBitmap(imageUri: Uri): Bitmap? {
    return contentResolver.openInputStream(imageUri)?.use { inputStream ->
        BitmapFactory.decodeStream(inputStream)
    }?.also { bitmap ->
        Log.d("MemoryTest", "allocationByteCount: ${bitmap.allocationByteCount / 1024}KB")
        Log.d("MemoryTest", "size: ${bitmap.width} x ${bitmap.height}")
        Log.d("MemoryTest", "config: ${bitmap.config}")
    }
}

// allocationByteCount: 48768KB
// size: 4080 x 3060
// config : ARGB_8888

또한 갤럭시 S25 기준으로 촬영한 이미지를 allocationByteCount를 사용해 디코딩된 이미지의 크기를 측정해 본 결과 48768KB가 측정되며 크기는 4080 x 3060 가 측정되었다.

 

config로 출력된 ARGB_8888은 Android Bitmap의 기본 픽셀 포맷으로 픽셀 하나의 색(ARGB)을 각각 8bit(1byte)씩 총 4 * 8 = 32bit(4byte)로 표현한다.

 

각 채널이 8bit이므로 2^8에 해당하는 0 ~ 255 범위의 색을 표현할 수 있으며 4개의 채널(ARGB)을 사용하므로 도합256*256*256=4,294,967,296개의 풍부한 색을 표현할 수 있지만 그 만큼 메모리를 많이 사용한다.

 

앞서 살펴본 Bitmap 메모리 사용량 공식을 기반으로 이번 테스트 결과를 실제로 계산해 보면 실제로 로그에서 측정된 값과 거의 근사한 수치가 나온다.

4080 × 3060 × 4byte (ARGB_8888) = 49,939,200 byte

로그로 측정된 값은 48,768KB로 계산값과 1KB 차이에 불과하며 메모리 정렬(alignment) 등 내부 처리 방식에 따라 약간의 편차가 생길 수 있다.

Bitmap.Config

Bitmap.Config는 Android Bitmap 클래스 내부에 정의된 열거형(enum)으로 픽셀 데이터를 메모리에 저장하는 방식을 결정한다. Config에 따라 픽셀당 바이트 수가 달라지므로 메모리 사용량과 색상 표현 범위에 직접적인 영향을 미친다.

Config 픽셀당 크기 특징
ALPHA_8 1byte - Alpha 채널만 저장하여 RGB 색상 정보를 표현 불가
- 마스크 처리나 그림자 효과처럼 투명도만 필요한 경우에 활용
ARGB_4444 2byte - ARGB 각 채널을 4bit로 표현해 픽셀당 총 4 *4 = 16bit(2byte)로 표현
- 색상 표현력이 낮아 다양한 색을 표현할 수 없어 API Level 29부터 Deprecated
ARGB_8888 4byte - ARGB 각 채널을 8bit로 표현해 픽셀당 8 * 4 = 32bit(4byte)로 표현
- 색상 품질과 표현력이 가장 뛰어나 대부분의 이미지 처리에서 사용
- Android Bitmap의 기본 Config
HARDWARE - - 픽셀 데이터를 CPU 메모리가 아닌 GPU 메모리에 저장하는 특수한 Config
- 화면 렌더링 성능이 향상되지만 픽셀 데이터를 CPU에서 직접 읽거나 쓸 수 없어 수정 등의 작업이 불가능
RGBA_1010102 4byte - R, G, B 채널을 10bit, Alpha 채널을 2bit로 (10 * 3) + 2 = 32bit(4byte) 표현
- ARGB_8888보다 색상 표현력이 더 우수
- HDR 콘텐츠 처리에 적합
RGBA_F16 8byte - 각 채널을 16bit 부동소수점으로 저장해  16 * 4 = 64bit(8byte)로 표현 
- 광색역이미지나 HDR 처리처럼 높은 정밀도가 요구되는 작업에 활용
- 메모리 사용량이 ARGB_8888의 두 배
RGB_565 2byte - 알파 채널이 없어 투명도를 표현 불가
- R에 5bit, G에 6bit, B에 5bit로 5 + 6 + 5 = 16byte(2byte)로 표현
- 투명도가 필요 없는 이미지(e.g. 배경 이미지, 썸네일)에 적합

- ARGB_8888 대비 메모리를 절반으로 줄일 수 있지만 그 만큼 색상 표현 범위가 좁음
HDR 콘텐츠
High Dynamic Range의 약자로, 아주 어두운 부분과 아주 밝은 부분을 동시에 더 자세하게 표현하는 기술 
e.g.  햇빛이 강하게 비치는 하늘, 야경 사진, 역광

inPreferredConfig

Bitmap의 픽셀 포맷은 BitmapFactory.Options의 inPreferredConfig를 사용해 직접 설정할 수 있다. 실제로 각 Config별로 메모리 사용량을 측정해본 결과는 다음과 같다.

private fun uriToBitmap(imageUri: Uri): Bitmap? {
    val options = BitmapFactory.Options().apply {
        inPreferredConfig = Bitmap.Config.RGB_565
    }

    return contentResolver.openInputStream(imageUri)?.use { inputStream ->
        BitmapFactory.decodeStream(inputStream, null, options)
    }?.also { bitmap ->
        Log.d("MemoryTest", "[RGB_565]")
        Log.d("MemoryTest", "allocationByteCount: ${bitmap.allocationByteCount / 1024}KB")
    }
}

측정 결과를 살펴보면 ARGB_8888과 RGBA_1010102는 모두 48,768KB로 동일하게 측정되었다. 두 포맷 모두 픽셀당 4byte를 사용하기 때문에 메모리 사용량은 같지만 RGBA_1010102는 RGB 채널에 각각 10bit를 할당해 더 넓은 색상 범위를 표현할 수 있다는 점에서 차이가 있다. 

 

RGBA_F16은 97,537KB로 ARGB_8888의 약 두 배에 해당했으며, RGB_565는 24,384KB로 ARGB_8888의 절반 수준이다.

 

지금까지 사용한 방식은 가장 단순한 디코딩 방법이지만 치명적인 문제가 있다. decodeStream은 이미지를 원본 해상도 그대로 디코딩하기 때문에 여러 장의 사진을 동시에 처리할 경우 OOM(Out Of Memory)나 이미지 로딩 지연 문제가 발생할 수 있다.

 

또한 RGB_565를 사용할 경우 메모리가 줄어드는 이점이 있지만 원본 이미지의 알파를 표현할 수 없기 때문에 원본 이미지를 유지하는 것이 중요할 경우 사용할 수 없다는 제약이 있다. 때문에 RGB_565외에도 비트맵의 메모리를 최적화할 수 있는 방법에 대해서 알아보겠다.

inSampleSize

이미지를 디코딩할 때 원본 해상도보다 축소된 크기로 디코딩 할 수 있도록 샘플링 비율을 지정하는 옵션이다. 예를 들어 inSampleSize = 2로 설정하면 가로, 세로 각각 절반 크기로 디코딩되어 메모리 사용량이 원본의 4분의1 수준으로 줄어든다. 

 

이를 위해 먼저 inJustDecodeBounds = true를 설정해 실제 픽셀 데이터를 메모리에 올리지 않고 이미지의 원본 크기(width, height)만 먼저 읽어온다. 그 후 읽어온 원본 크기와 목표 크기를 바탕으로 적절한 inSampleSize를 계산한 뒤 실제 디코딩을 수행한다.

 private fun uriToBitmap(imageUri: Uri): Bitmap {
    // 1단계: 실제 디코딩 없이 이미지 크기만 먼저 읽기
    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    contentResolver.openInputStream(imageUri)?.use { inputStream ->
        BitmapFactory.decodeStream(inputStream, null, bounds)
    }

    Log.d("MemoryTest", "[After-Sample] 원본 크기: ${bounds.outWidth} x ${bounds.outHeight}")

    // 2단계: sampleSize 계산 후 실제 디코딩
    val options = BitmapFactory.Options().apply {
        inSampleSize = calculateInSampleSize(bounds, 1920, 1080)
    }

    return contentResolver.openInputStream(imageUri)?.use { inputStream ->
        BitmapFactory.decodeStream(inputStream, null, options)
    }?.also { bitmap ->
        Log.d(
            "MemoryTest",
            "[After-Sample] allocationByteCount: ${bitmap.allocationByteCount / 1024}KB"
        )
        Log.d("MemoryTest", "[After-Sample] 실제 디코딩 크기: ${bitmap.width} x ${bitmap.height}")
    }
}

 

calculateInSampleSize는 공식문서에서 가이드하는 함수로 원본 크기와 목표 크기를 입력받아 적절한 inSampleSize를 계산하는 함수다. inSampleSize는 반드시 2의 거듭제곱(1,2,4,8...)으로 지정해야 하며, 이를 위해 sampleSize를 2씩 곱해가며 축소된 크기가 목표 크기 이상을 유지하는 최대값을 구한다. 

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    // Raw height and width of image
    val (height: Int, width: Int) = options.run { outHeight to outWidth }
    var inSampleSize = 1

    if (height > reqHeight || width > reqWidth) {

        val halfHeight: Int = height / 2
        val halfWidth: Int = width / 2

        // Calculate the largest inSampleSize value that is a power of 2 and keeps both
        // height and width larger than the requested height and width.
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }

    return inSampleSize
}

 

이 방식을 적용하면 4080 x 3060의 원본 이미지가 목표 해상도 1920 x 1080에 맞게 축소되어 디코딩되므로 앞서 측정한 48,768KB에 비해 메모리 사용량을 크게 줄일 수 있다.

실제로 측정해본 결과 원본 크기는 4080 × 3060에서 inSampleSize = 2가 적용되어 실제 디코딩 크기는 2040 × 1530으로 가로, 세로 각각 절반으로 축소되었다.

 

그 결과 메모리 사용량은 12,192KB로 에서 절반으로 줄어들었으며, 아무 옵션도 적용하지 않은 상태인 48,768KB 대비 약 1/4 수준으로 감소했다. 이는 앞서 설명한 메모리 공식으로도 확인할 수 있다.

2040 × 1530 × 4byte(ARGB_8888) = 12,192KB

마무리

지금까지 픽셀과 비트맵의 개념부터 시작해 Android에서 Bitmap이 메모리에 올라가는 과정과이를 최적화하는 방법까지 살펴봤다. 핵심을 정리하면 이렇다. 우리가 다루는 이미지 파일은 압축된 형태로 저장되어 있지만 화면에 렌더링하기 위해선 반드시 Bitmap으로 디코딩해야 한다.

 

이 과정에서 메모리 사용량은 파일 크기가 아닌 가로 × 세로 × 픽셀당 바이트 수로 결정되며, 아무런 최적화 없이 디코딩할 경우 고해상도 이미지 하나가 수십 MB의 메모리를 점유할 수 있다. 이를 해결하기 위해 inJustDecodeBounds로 실제 디코딩 없이 원본 크기를 먼저 읽어온 뒤, inSampleSize를 활용해 목표 해상도에 맞게 축소 디코딩하는 방식을 적용했다.

 

갤럭시 S25 기준 실측 결과 48,768KB였던 메모리 사용량이 12,192KB로 약 75% 감소했다. 다만 inSampleSize는 2의 거듭제곱 단위로만 축소되는 특성상 목표 해상도에 정확히 맞출 수 없다는 제약이 있다. 환경에 따라 이미지의 특성과 요구사항에 맞게 조합하는 것이 중요하다.

참조

  • Android Bitmap Source Code

  • Android BitmapFactory Source Code

 

  • JPG Signature Format

 

 

'Android' 카테고리의 다른 글

Android ExifInterface를 활용해 촬영한 사진이 회전하는 문제 해결하기  (0) 2026.02.14
Compose 디자인 시스템 설계하기  (0) 2026.01.22
HiltViewModel 의존성 주입 원리  (0) 2025.11.06
아주 쉽게 알아보는 뷰가 그려지기까지의 여정  (0) 2025.10.29
운영체제 메모리  (0) 2025.10.14
'Android' 카테고리의 다른 글
  • Android ExifInterface를 활용해 촬영한 사진이 회전하는 문제 해결하기
  • Compose 디자인 시스템 설계하기
  • HiltViewModel 의존성 주입 원리
  • 아주 쉽게 알아보는 뷰가 그려지기까지의 여정
빨주노초잠만보
빨주노초잠만보
  • 빨주노초잠만보
    과거의 나를 통해 미래의 나를 성장시키자
    빨주노초잠만보
  • 전체
    오늘
    어제
    • 분류 전체보기 (110) N
      • 우아한테크코스 (6)
      • TEKHIT ANDROID SCHOOL (4)
      • Android Architecture (8)
      • Android (40) N
      • PROJECT (11)
      • KOTLIN (10)
        • 코루틴의 정석 (3)
      • BACK END (12)
      • CS (4)
      • 컨퍼런스 (4)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    안드로이드 디자인 시스템
    DI
    repository
    process Context Switching
    view 생명주기
    의존성 주입
    컴포즈 디자인 시스템
    android Room
    ThrottleFirst
    android clean architecture
    orbit
    MVI
    android view lifecylce
    thread Context Switching
    Throttle
    DataSource
    코틀린 코루틴의 정석
    retrofit call
    callbackflow
    coroutine Context Switching
    sealed class
    2025 우아콘 후기
    STATEFLOW
    Compose Typography
    Two pass process
    Room
    value class
    Repository Pattern
    Clean Architecture
    flow
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
빨주노초잠만보
Android Bitmap과 메모리 최적화
상단으로

티스토리툴바