7 minute read

본 글은 이전 글과 이어지는 내용으로 이전 글을 먼저 읽고 오시는 것을 추천드립니다.

Bitmap 메모리 구조

디지털 이미지는 가로와 세로로 이루어진 2차원적인 형태 내에서 픽셀들이 각자의 영역을 차지하고 있습니다. 이러한 이미지는 우리가 보기에는 2차원 구조이지만, 실제로 컴퓨터 내부에서 저장되는 방식은 조금 다릅니다.

Android에서 사용하는 Bitmap 객체는 시각적으로는 2차원 픽셀 배열처럼 보이지만, 메모리상에서는 실제로 1차원으로 이어진 연속적인 바이트 배열로 구성됩니다. 해당 배열은 row-major order 방식으로 저장되는데, 이 방식은 첫 번째 행의 모든 픽셀을 순서대로 저장한 뒤, 곧이어 다음 행으로 넘어가는 순서로 구성됩니다. 다른 말로 표현하자면 위에서 아래로 한 줄씩 차곡차곡 저장되는 방식입니다.

이미지 데이터는 위 방식에 따라 순차적으로 저장되었기 때문에 이미지의 가로 총 바이트 수와 찾고자 하는 위치(x,y)를 알고 있다면 특정 위치의 픽셀 데이터가 메모리 주소 내 어느 위치에 있는지 알 수 있습니다. 이렇게 모든 픽셀 데이터가이어져서 하나의 배열로 저장되는 구조는 하드웨어적으로 효율적인 처리와 주소 계산을 가능하게 합니다.

메모리는 본질적으로 1차원 구조이기 때문에 2차원 배열보다 단순하게 계산할 수 있으며, 캐시 효율을 최대화할 수 있습니다. 이에 대한 자세한 내용은 아래의 링크를 참고하시면 좋을 것 같습니다. [Wikipedia CPU_cache]

Bitmap에는 이러한 구조를 처리할 수 있도록 도와주는 rowBytes 라는 필드가 존재합니다. rowBytes는 이미지의 한 줄을 메모리상에 저장하는 데 필요한 총 바이트 수를 의미하며 이 값은 일반적으로 아래와 같이 계산됩니다.

rowBytes = (가로 픽셀 수) × (픽셀 하나당 바이트 수)

예를 들어, 1,000픽셀 너비의 이미지를 ARGB_8888 포맷으로 저장한다고 가정하였을 때, 한 줄을 저장하는 데 필요한 바이트 수인 rowBytes는 4,000이 됩니다. (ARGB_8888 포맷은 한 픽셀당 4바이트를 사용합니다.) 이에 따라 이미지의 첫 번째 행은 4,000바이트 동안 이어지고 그다음 4,000바이트는 두번 째 행의 이미지 데이터가 저장됩니다.

추가로 rowBytes는 성능 향상과 하드웨어 특성을 고려하여 별도 여분의 바이트가 추가되기도 합니다. 이 여분의 바이트는 padding이라고 불리며, 일반적으로 메모리 정렬을 맞추기 위해 사용됩니다.

CPU와 GPU는 메모리를 빠르게 읽기 위해 4, 8, 16, 32바이트와 같은 특정 크기로 정렬된 경계를 선호합니다. 예를 들어, 만약 한 줄의 크기가 1,020 바이트라면, 성능을 위해 이를 1,024 바이트처럼 가장 가까운 정렬 경계로 맞춰 padding을 추가하게 됩니다. [Wikipedia Data structure alignment]

이러한 padding의 특성으로 인해 저수준에서 직접 픽셀 배열에 접근하거나 외부 라이브러리와 연동해 Bitmap을 조작할 때는 반드시 rowBytes 값을 정확히 고려해야 합니다. 그렇지 않을 경우 줄 바꿈 위치 계산에 오류가 생겨 이미지가 변질되거나 메모리 접근 오류가 발생할 수 있습니다.

따라서 rowBytes는 단순히 픽셀과 넓이를 바탕으로 계산하여 추정해서는 안 되며, 항상 실제 값을 확인하고 사용하는 것이 안전합니다. Android에서는 Bitmap.getRowBytes() 메서드를 통해 이 값을 얻을 수 있습니다.

Width Height

Android Bitmap의 필드에는 이미지의 가로세로 픽셀 수를 의미하는 widthheight 필드가 존재합니다. 512 x 512 해상도를 가진 이미지의 경우 width 및 height는 각각 512를 가지게 됩니다.

한편으로 메모리의 총량을 구할 때 width × 픽셀 당 바이트 수 x height 공식을 생각할 수 있겠지만, 앞서 말했듯 실제 메모리에서는 정렬을 위해 padding을 붙여 계산하기 때문에 rowbytes를 통하여 메모리를 계산하여야 정확한 이미지 접근과 처리가 가능해집니다.

allocationByteCount

앞서 설명했듯이, Bitmap 의 메모리 구조는 단순히 width × 픽셀당 바이트 수 × height로 계산된 크기와 일치하지 않을 수 있습니다. 그 이유는 성능 최적화 및 메모리 정렬을 위해 각 행마다 padding 바이트가 추가되는 경우가 존재하기 때문입니다.

Bitmap에서는 allocationByteCount라는 필드를 통해 실제 메모리에 할당된 전체 크기를 알 수 있습니다. 이 값은 rowBytes × height로 계산됩니다.

한편으로 이와 비슷한 byteCount라는 필드도 존재하지만, 이는 KitKat(API 19) 이후부터는 내부적으로 사용되지 않으며, allocationByteCount를 사용하는 것이 더 정확하고 안전합니다.

메모리 할당

과거의 Bitamp 객체 자체는 Java Heap에 존재하였지만, 실제 픽셀 데이터는 Java Heap이 아닌 Native Heap에 저장되었습니다. 이렇게 설정된 이유는 Garbage Collector(GC)로 인한 지연을 최소화하기 위함입니다. 이미지 데이터는 일반적으로 수백 KB ~ 수 MB로 크기가 컸으며, 그때 당시에는 이런 데이터가 Java Heap에 올려 GC 대상이 될 때 앱 실행이 일시적으로 멈추는 등의 성능에 악영향을 주었습니다. 따라서 Native Heap 영역에서 픽셀 데이터를 관리하도록 하여 GC 대상에서 제외할 수 있습니다.

허니콤(API 11, Android 3.0)이 도입된 이후에는 메모리 위치의 변화가 생겼습니다. Bitmap의 실제 픽셀 데이터가 Java Heap에 저장되도록 변경된 것이였습니다. 해당 시기에는 GC 기술이 발전하며, 성능이 많이 개선되었기 때문입니다.

오레오(API26, Android 8.0) 부터는 Bitmap.Config.HARDWARE가 등장하였습니다. 해당 설정으로 생성된 Bitmap의 경우에는 픽셀 데이터가 GPU 메모리에 할당되고 관리됩니다. 물론 ARGB_8888과 같은 일반적인 비트맵의 경우에는 동일하게 JavaHeap에서 관리됩니다.

내부 구조

현재 Android 그래픽 시스템은 Skia 엔진을 사용하고 있습니다. 이는 C++인 네이티브 기반에서 사용되기 때문에 네이티브 영역에서 Bitmap을 관리한다면, Bitmap 객체에 즉시 접근하여 처리하는 효율적인 구조를 만들어 낼 수 있습니다. 즉, Android의 Bitmap은 C++ 기반의 SkBitmap(Skia) 객체와 연결되어 있으며, Java에서 getPixels(), setPixel() 등을 호출하면 해당 명령이 내부적으로 JNI를 통해 네이티브 레벨로 전달합니다.

실제로 Bitmap에서 사용되는 메서드들의 내부 구조를 보면 JNI와 연결하기 위한 진입점 역할만 하고 있다는 것을 알 수 있습니다.

 private static native Bitmap nativeCreate(
        int[] colors, int offset,
        int stride, int width, int height,
        int nativeConfig, boolean mutable,
        long nativeColorSpace
        );
private static native Bitmap nativeCopy(
        long nativeSrcBitmap, int nativeConfig,
        boolean isMutable
        );
private static native Bitmap nativeCopyAshmem(long nativeSrcBitmap);
private static native Bitmap nativeCopyAshmemConfig(long nativeSrcBitmap, int nativeConfig);
private static native int nativeGetAshmemFD(long nativeBitmap);
private static native long nativeGetNativeFinalizer();
private static native void nativeRecycle(long nativeBitmap);
@UnsupportedAppUsage
private static native void nativeReconfigure(
        long nativeBitmap, int width, int height,
        int config, boolean isPremultiplied
);

private static native boolean nativeCompress(
        long nativeBitmap, int format,
        int quality, OutputStream stream,
        byte[] tempStorage
);
private static native void nativeErase(long nativeBitmap, int color);
private static native void nativeErase(long nativeBitmap, long colorSpacePtr, long color);
private static native int nativeRowBytes(long nativeBitmap);
private static native int nativeConfig(long nativeBitmap);

private static native int nativeGetPixel(long nativeBitmap, int x, int y);
private static native long nativeGetColor(long nativeBitmap, int x, int y);
private static native void nativeGetPixels(
        long nativeBitmap, int[] pixels,
        int offset, int stride, int x, int y,
        int width, int height
);

...

추가로 앞서 최신의 방식에서는 픽셀 데이터를 Java Heap에서 관리한다고 언급하였습니다. 네이티브 기능을 사용하기 위해서는 Java Heap에 저장된 픽셀 데이터를 알 필요가 있으며, 실제로 해당 픽셀 데이터를 접근할 수 있도록 포인터를 반환하는 기능을 JNI를 통해 제공하고 있습니다. 이를 통해 네이티브 기능에서 Java Heap의 데이터를 안전하게 접근할 수 있습니다.

메모리 해제

앞서 과거 Android에서는 Bitmap 객체의 실제 픽셀 데이터는 Java Heap이 아닌 Native Heap에 저장된다는 사실을 언급하였습니다. 이에 따라 이미지가 생성될 때는 malloc() 같은 함수를 사용하여 Native Heap 내에 직접 할당되었습니다. 일반적인 Java객 체들은 null 처리를 통해 GC 메모리 해제를 수행할 수 있지만, Bitmap 객체 내 데이터들은 단순히 null처리만으로는 메모리 해제가 되지 않습니다.

이를 지원해 주기 위해, Android에서는 Bitmap을 더 이상 사용하지 않을 때, 메모리를 해제할 수 있도록 recycle() 메서드를 제공하였습니다. 하지만 recycle() 사용 시에 주의할 점이 있었는데, recycle() 사용 이후 메모리가 해제되었기 때문에 이후 해당 Bitmap 객체를 다시 사용하려 하면 예외가 발생할 수 있었다는 것입니다. 또한 recycle() 의 작업은 되돌릴 수 없었기 때문에, 확실하게 판단되는 경우에만 사용해야 했다는 점입니다.

public void recycle() {
    if (!mRecycled) {
        nativeRecycle(mNativePtr);
        mNinePatchChunk = null;
        mRecycled = true;
        mHardwareBuffer = null;
    }
}

앞서 recycle()를 사용하여 명시적으로 메모리를 해제하는 방식은 개발자의 실수에서 벗어나지 못하기 때문에, 많은 문제의 소지가 존재하였습니다. 하지만 허니콤(API 11, Android 3.0)이후에서 부터는 Bitmap 메모리 관리 방식이 변경되어 이제는 GC의 도움을 받아 메모리를 해제할 수 있게 되었습니다. 즉, Android 개발자는 메모리 해제를 크게 신경 쓸 필요가 없게 되었습니다.

Bitmap.Config.HARDWARE 를 통해 생성된 하드웨어 Bitmap 또한 마찬가지로 메모리 해제에 신경을 쓸 필요가 없습니다. 안드로이드 시스템과 GPU가 자동으로 Bitmap 메모리 해제를 도와주기 때문입니다.

메모리 관리

현재의 Android Bitmap의 메모리 관리는 GC의 큰 도움을 받아 해결하고 있지만, 본질적으로 Bitmap 데이터는 매우 무겁습니다. 이러한 Bitmap을 효율적으로 사용하기 위한 옵션들이 존재하며, 아래는 메모리를 관리할 수 있는 방법에 대한 일부를 다루어보겠습니다.

inSampleSize

BitmapFactory.Option 에는 inSampleSize라는 설정 필드 값이 존재합니다. 해당 필드는 다운샘플링을 적용해 주는 필드로 BitmapFactory로 생성하는 리소스의 크기를 줄여 메모리 사용량의 부담을 덜어내어줄 수 있습니다. inSampleSize의 값을 1보다 크게 설정할 경우, 원본 이미지를 샘플링하여 더 작은 이미지로 반환해 줍니다. 예를 들어, inSampleSize 의 값이 4일 경우 가로와 세로는 각각 1/4크기가 되며 최종적인 픽셀 수는 1/16로 줄어들게 됩니다. 흔히, 작은 프로필 썸네일의 경우 이미지의 크기가 작아도 사용자가 보기에는 큰 영향을 주지 않기 때문에 이러한 케이스에서 다운 샘플링을 통해 메모리를 절약할 수 있게 됩니다.

val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.sample)
val bitmap2 = BitmapFactory.decodeResource(resources, R.drawable.sample, BitmapFactory.Options().apply {
    inSampleSize = 4
})

println(bitmap1.allocationByteCount) //438939648
println(bitmap2.allocationByteCount) //27433728

inBitmap

inBitmap 또한 BitmapFactory.Option의 필드 중 하나로, 새로운 Bitmap 을 생성할 때 기존에 만들어둔 Bitamp 객체를 재활용하도록 하는 설정입니다. Bitmap 은 앞서 언급하였듯, 많은 양의 메모리가 요구되고 이를 생성하고 해제하는 과정은 비싼 비용을 소모합니다. 이때 inBitmap 옵션을 사용할 경우 이미 할당된 공간을 재활용하기 때문에 앱의 성능이 향상됩니다. 이 과정에서 또한 메모리를 재활용하기 때문에 OOM(OutOfMemory)의 위험에서 벗어날 가능성을 높여줍니다.

주의할 점으로는 새롭게 디코딩하여 사용할 Bitmap의 크기는 기존 Bitmap보다 작거나 같아야만 합니다. 또한 기존에 만들어둔 BitmapisMutable 속성이 true 여야하고, 다른 스레드에서 동시에 접근하여서는 안 된다는 여러 가지 제약이 존재합니다. 해당 필드에 대한 자세한 내용은 별도의 포스트에서 다룰 예정입니다.

isMutable 에 대한 내용은 [해당 포스팅]에서 확인하실 수 있습니다.

이미지 라이브러리 사용하기

사실 앞서 언급되었던 메모리 관리 전략 외에도 여러 가지 방법들이 존재하지만, 이러한 방법들을 종합적으로 고안하고 구현하는 것은 소프트웨어 개발에 있어서 많은 시간과 비용 지불이 따르게 됩니다. 다행히도 이러한 메모리 관리 전략들을 구현해 둔 여러 가지의 라이브러리가 존재합니다. 대표적으로 Glide, Coil 과 같은 라이브러리들이 존재하며, 해당 라이브러리들은 큰 기여와 커뮤니티가 오픈 되어있고, 이에 따라 사용성과 안정성 면에서 많은 검증이 되어있기 때문에 라이브러리 도입에 대한 큰 문제는 없을 것입니다. 물론, 각 라이브러리들마다 목적이 다를 수 있기 때문에, 프로젝트의 상황에 맞춰 적절한 라이브러리를 채택하는 것이 중요합니다. 현대에 가장 많이 사용되는 2개의 라이브러리를 간단하게 소개하고 글을 마칩니다.

Glide

가장 규모가 큰 이미지 라이브러리입니다. 이미지 로딩, 캐싱, 이미지 변환과 같은 최적화 측면에서 우수하며, 확장성이 뛰어나다는 특징이 있습니다.

참고링크

Coil

Kotlin 친화적인 이미지 라이브러리입니다. 코틀린 코루틴을 활용한 비동기 이미지 로딩을 사용할 수 있으며, Jetpack Compose와 같이 사용할 수 있는 특징이 존재합니다.

참고링크