Android 앱을 개발하다 보면 메모리 부족 문제에 시달리기 쉽습니다. 특히, 비트맵 이미지를 로딩하다가 다음과 같이 OOM(out of memory)이 발생하는 경우가 흔합니다.
java.lang.OutOfMemoryError: bitmap size exceeds VM budget
비트맵 이미지의 크기가 VM(virtual machine) 메모리의 한계를 초과했다는 것인데, 인터넷에서 검색해 보면 recycle() 메서드로 비트맵 이미지를 해제하라는 얘기가 나옵니다. recycle() 메서드를 사용하는 것이 해결책이긴 하지만, 왜 그래야 하는지에 대한 상세한 설명을 찾아보기도 힘듭니다.
이로 인해 ‘Android 프레임워크의 비트맵 자체에 누수(leak)가 있다’는 얘기부터 ‘Android의 메모리 관리는 정말 꽝이다’라는 얘기까지, 밑도 끝도 없는 오해가 생겼습니다. 그렇다고 Android 측에서 적극적인 해명이나 해결책을 제시하는 것도 아니라서 수많은 프로그래머들이 아직도 혼란스러워 하고 있습니다. 그도 그럴 것이 Android 측에서 그 수많은 질문에 어떻게 일일이 답을 해 주겠습니까.
이 글에서는 대체 비트맵에 대해 어떤 오해가 있으며 이를 어떻게 해결할 수 있을지 한번 풀어보면서 Android의 메모리 부족 문제와 해결 방법를 살펴볼 것입니다.
Android 비트맵
큰 비트맵 이미지를 로딩하고 나서 화면을 가로로 전환하면 실행이 중단되는 앱을 한번 만들어 보자. 테스트로 사용할 Android 단말기의 메모리 규격은 다음과 같다.
- 갤럭시 S3: 1GB RAM
- Xperia arc SO-01C: 512MB RAM
참고로 프로그램이 로딩되는 공간인 RAM도 메모리라고 부르고, 내장/외장 메모리도 메모리라고 부르기 때문에 헷갈리기 쉽다. 이 글에서 메모리는 RAM을 의미하며 내장/외장 메모리는 스토리지(저장 공간)라고 부르겠다.
갤럭시 S3와 Xperia arc SO-01C처럼 메모리 용량이 512MB 이상으로 상당히 큰 단말기에서도 큰 비트맵 이미지를 로딩하다가 실행이 중단되는 것은 왜일까? 메모리 용량이 아무리 커도 Android 앱에서 그 용량을 전부 다 쓸 수는 없기 때문이다. 앱은 프로세스당 메모리 용량에 한계가 있다. Android 앱은 각 dalvik VM 위에서 실행되며, dalvik VM은 각 프로세스로 할당된다.
Android 메모리 모델
Android의 메모리 모델은 운영체제의 버전에 따라 Honeycomb(Android 3.0) 미만의 메모리 모델과 Honeycomb 이상의 메모리 모델, 두 가지로 나뉜다. 먼저 Honeycomb 미만의 메모리 모델을 살펴보자.
그림 1 Honeycomb(Android 3.0) 미만의 메모리 모델
- dalvik heap 영역은 Java 객체를 저장하는 메모리이다.
- external 영역은 native heap의 일종으로서 네이티브 비트맵 객체를 저장하는 메모리이다.
- dalvik heap 영역과 external 영역은 각각 프로세스당 메모리 한계까지 확장될 수 있다.
- dalvik heap 영역과 external 영역의 dalvik heap footprint와 external limit를 합쳐서 프로세스당 메모리 한계를 초과하면 OOM이 발생한다.
- external limit는 external allocated와 일정한 간격을 유지하면서 줄어들거나 늘어나지만, dalvik heap footprint는 증가하기만 할 뿐 절대 감소하지 않는다는 특성이 있다.
참고
비트맵은 Java 비트맵 객체와 네이티브 비트맵 객체로 나뉜다. Java 비트맵 객체는 실제 비트맵 픽셀 데이터를 담고 있는 네이티브 비트맵 객체를 가리키는 껍데기일 뿐이다. 자세한 내용은 “비트맵
설계와 recycle() 메서드, finalizer 스레드“에서 다룬다.
external 영역이라는 말이 생소할 수도 있는데, 그냥 native heap이라고 생각하면 된다. native heap은 Linux 커널(kernel)에서 관리하는 메모리로서 단말기에 장착된 물리적인 메모리 용량 전체까지 할당받을 수 있다. C/C++과 같은 언어를 이용해 네이티브 프로그래밍을 할 때 사용한다. dalvik heap 영역은 Android용 Java 가상 머신(dalvik VM)에 의해 할당되는 Java용 메모리이다.
Honeycomb부터는 네이티브 비트맵 객체를 저장하기 위한 external 영역을 없애고 dalvik heap만 남겼다. 비트맵 픽셀 데이터 자체도 dalvik heap 영역에 저장하게 된다. 이렇게 바뀐 이유는 “external 메모리를 쓴 이유와 Honeycomb에서의 비트맵“에서 더 자세히 다룰 예정이다.
이와 같은 프로세스당 메모리를 애플리케이션 메모리 또는 앱 메모리라고 한다. 이 메모리의 크기는 단말기별로 다양한데, 보통 단말기의 화면 크기와 밀도가 커질수록 더 커진다. 화면이 커질수록 화면을 꽉 채우려면 비트맵 이미지의 크기가 더 커져야 하기 때문이다. 최초의 Android 폰인 HTC G1이 앱 메모리는 16MB였다. 현재 단말기의 앱 메모리는 24MB, 32MB, 48MB, 64MB, 96MB, 128MB 등 다양하다.
메모리 용량 확인 방법
프로세스당 메모리 크기의 한계를 확인하려면 다음 API를 사용한다.
- ((ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass();
Dalvik heap 영역의 메모리 정보를 알고 싶으면 다음 API를 사용한다.
- Dalvik heap 영역 최대 크기(프로세스당 메모리 한계): Runtime.getRuntime().maxMemory()
- Dalvik heap 영역 크기(footprint): Runtime.getRuntime().totalMemory()
- Dalvik heap free 크기: Runtime.getRuntime().freeMemory()
- Dalvik heap allocated 크기: totalMemory() 값에서 freeMemory() 값을 빼면 된다.
다음 API는 프로세스 메모리가 아닌 시스템 전체의 메모리 정보를 구할 때 쓴다. 즉, native heap 정보에 대한 것이다.
- ActivityManager.getMemoryInfo(ActivityManager.MemoryInfo outInfo)[1]
앱 입장에서는 프로세스 메모리가 중요하지 시스템 전체 메모리 정보는 별로 중요하지 않다. 하지만 Java 영역을 벗어나 네이티브 프로그래밍을 할 때는 프로세스당 메모리에 상관 없이 전체 메모리를 다 쓸 수 있다. 이에 관해서는 “Native heap 사용하기“에서 알아보도록 하자.
프로세스 메모리 정보를 알 수 있는 API가 하나 더 있는데, 전부 페이지(page) 단위라서 별 의미가 없을 수도 있다.
- ActivityManager.getProcessMemoryInfo (int[] pids)[2]
희한하게도 external 정보를 알 수 있는 API는 없다. 다만, 특정 단말기는 GC(Garbage Collection) 로그로 그 정보를 알 수 있다. 다음은 Xperia 단말기의 로그 예다.
08-18 12:29:24.294: D/dalvikvm(1336): GC_EXPLICIT freed 808K, 47% free 5145K/9543K, external 18051K/20067K, paused 112ms
그럼 Android 메모리와 비트맵에 관한 오해로 돌아가 보자.
비트맵 설계와 recycle() 메서드, finalizer 스레드
프로세스당 메모리가 32MB인 단말기가 있다고 가정한다. 가로 3000px, 세로 2000px인 이미지 파일을 메모리로 로딩하면 약 22.8MB를 소모하게 된다(3000px x 2000px x 4color). 즉, 32MB 단말기에서는 이 이미지 파일을 하나만 로딩할 수 있지 두 개 이상은 로딩할 수 없다. 액티비티의 onCreate 시점에 이미지뷰를 생성하고, 그 이미지뷰에 위의 이미지 파일을 로딩해서 할당한다. 이미지가 단말기에서 잘 나오는지 확인한 후 화면을 가로로 전환한다. 그러면 앱이 예상치 않게 중지되었다며 종료되어 버린다. 종료 원인은 위에서 언급한 비트맵 관련 OOM이다.
화면을 전환하면 이전 액티비티 인스턴스는 소멸되고, 새로운 액티비티 인스턴스가 생성될 것이다. 그러면 이전 액티비티 인스턴스에 있었던 이미지뷰나 이미지뷰에 할당됐던 비트맵도 함께 소멸돼서 메모리가 회수될 텐데, 왜 OOM이 발생할까? 원인은 이전 액티비티 인스턴스에서 생성됐던 비트맵 객체가 여전히 회수되지 않아서이다. 그 비트맵 객체에 대한 참조가 없는데도 왜 회수될 수 없는가? 그 원인을 이해하려면 비트맵 객체가 어떻게 설계되어 있는지부터 알아야 한다.[3]
다음 그림과 같이 Honeycomb 미만 버전에서 비트맵 객체는 Java 비트맵 객체와 네이티브 비트맵 객체로 이루어져 있다. Java 비트맵 객체는 dalvik heap 영역에 저장되고 네이티브 비트맵 객체는 native heap(external 메모리 영역)에 저장된다. Java 비트맵 객체는 실제 비트맵 픽셀 데이터를 담고 있는 네이티브 비트맵 객체를 가리키는 포인터일 뿐이다.
그림 2 Honeycomb(Android 3.0) 미만 버전의 비트맵 객체(원본 출처: http://aroundck.tistory.com/378)
Java 비트맵 객체는 참조가 없을 때 가비지 컬렉터에 의해 메모리가 회수되지만, 네이티브 비트맵 객체는 언제 회수될까? GC가 수행될 때 함께 되면 좋겠지만, 가비지 컬렉터는 Java 객체의 메모리만 회수할 뿐이다. 네이티브 쪽이 언제 회수되는지 알려면 finalize() 메서드를 이해해야 한다.
finalize() 메서드는 객체 인스턴스가 가비지 컬렉터에 의해 소멸되는 시점에 특정한 동작을 수행하기 위해 사용한다. 비트맵 객체에서는 네이티브 비트맵 객체를 해제하는 용도로 사용하고 있다.
protected void finalize() throws Throwable {
try {
nativeDestructor(mNativeBitmap); // -> 네이티브 비트맵 객체 해제
} finally {
super.finalize();
}
}
문제는 특정 클래스에 finalize() 메서드가 재정의되어 있으면 가비지 컬렉션을 수행할 때 finalize() 메서드가 즉각 호출되지 않는다는 점이다. 대신 finalization 큐(queue)에 들어간 후 별도의 스레드에서 수행되는 finalizer에 의해 finalize() 메서드가 호출된다. Java 언어 명세는 finalize() 메서드가 언제 호출될지 보장하지 않으므로, 최악의 경우엔 finalize() 메서드가 호출되지 않는 불상사(실제로 이렇게 구현하지는 않겠지만)가 발생할 수도 있다.
앞서 언급한 예제에 적용해 보면 다음과 같이 진행된다.
- 처음 앱을 실행하면 22.8MB의 비트맵 픽셀 데이터가 native heap(external) 영역에 할당된다.
- 화면을 가로로 회전하면 새로운 액티비티 인스턴스가 만들어지고 새로운 비트맵을 로딩하려고 시도한다.
- 이때 메모리가 부족하다는 것을 알고 이전 액티비티 인스턴스에서 만들어진, 더 이상 참조가 없는 비트맵 객체를 메모리에서 회수하려고 시도한다.
- 비트맵 클래스가 finalize() 메서드를 재정의한 사실을 알고 finalize() 호출 작업은 큐에 넣은 후 계속 진행한다. 이것으로 Java의 기본 GC 작업은 끝난다.
- 새로운 비트맵을 로딩하려고 하나 4 단계에서 기존 비트맵 객체의 네이티브 쪽이 여전히 존재하고 메모리를 차지하고 있으므로 OOM이 발생한다. 네이티브 비트맵 객체는 나중에 별도의 finalizer 스레드에서 finalize() 메서드를 호출하고, finalize() 메서드에서 비로소 네이티브 비트맵 객체를 회수한다.
즉, 기본 GC 과정과 finalizer에 의한 finalize() 메서드 호출(네이티브 비트맵 객체의 해제)이 동기화되지 않았기 때문에 문제가 발생한다.
보통 Java에서는 finalize() 메서드에서 자원을 해제하는 것을 권장하지 않거나 불필요하게 여긴다. 권장하지 않는 이유는 finalizer의 실행 시간을 예측할 수 없기 때문이다. VM마다 스케줄링이 다를 것이고, 시스템의 상태에 따라 finalizer 스레드가 수행될 여력이 생기지 않을 수도 있다. 또 불필요한 이유는 사용이 끝난 Java 객체는 가비지 컬렉터에 의해 자동으로 회수되기 때문이다. 다만, Java 객체와 연결되는 네이티브 피어 객체는 가비지 컬렉터에서 직접 회수하지 못하므로 finalizer 기능을 사용할 수 있다. 비트맵이 그런 경우에 속하기는 하지만 문제는 네이티브 비트맵 객체(네이티브 피어 객체)가 매우 귀중한 메모리를 소모하는 객체라는 것이다. 메모리 소모가 별로 없거나 중요하지 않은 자원이었다면 큰 문제가 없겠지만, 빠른 시간 내에 자원이 해제돼야 하는 작업을 finalizer에게 맡길 수는 없다. 그래서 비트맵 클래스에서 명시적으로 네이티브 쪽 객체를 제거하는 메서드인 recycle() 메서드를 별도로 제공하는 것이다.
어쨌든 확실한 건 finalizer 스레드 스케줄링에 의존하도록 프로그래밍하면 안 된다는 사실이다. 네트워크 자원이나 파일 자원은 명시적으로 사용자가 close() 메서드를 이용해 해제하도록 되어 있다. finalize() 메서드는 사용자가 실수로 close() 메서드를 사용하지 않고 사용을 마쳤을 때(참조가 없어진 경우) 기본 GC가 끝나고 finalizer 스레드에 의해 자원이 해제되도록 하는 안전 장치 정도로 쓰는 것이 좋다.[4]
따라서 위 예제의 경우, 네이티브 비트맵 객체를 명시적으로 해제하는 recycle() 메서드는 onDestroy를 이용해 액티비티를 소멸시키는 시점에 호출해야 한다. 그럼 recycle() 메서드에 대해 좀 더 자세히 알아보자.
비트맵 클래스의 recycle() 메서드에 대한 설명을 API에서 찾아보면 다음과 같다.
public void recycle ()
Free the native object associated with this bitmap, and clear the reference to the pixel data. This will not free the pixel data synchronously; it simply allows it to be garbage collected if there are no other references. The bitmap is marked as “dead”, meaning it will throw an exception if getPixels() or setPixels() is called, and will draw nothing. This operation cannot be reversed, so it should only be called if you are sure there are no further uses for the bitmap. This is an advanced call, and normally need not be called, since the normal GC process will free up this memory when there are no more references to this bitmap.
여기서 recycle() 메서드는 일반적인 상황에서는 호출할 필요가 없다고 되어 있지만, 일반적인 상황이라는 것이 꽤 애매하다. 예제에서는 큰 이미지(메모리 사용량 22.8MB)를 로딩해서 화면 전환을 했는데, 이런 경우는 실제 앱에서는 있을 수 없는 일이므로 일반적인 상황이 아니라고 볼 수 있다.
recycle() 메서드 실험하기
크기가 작은 이미지를 지속적으로 로딩했다가 해제하는 앱의 경우는 어떨까? 테스트 앱을 만들어서 시험해 보았다. 조건은 다음과 같았다.
- 기본적으로 앱은 1.7MB를 차지한다.
- 특정 이미지를 메모리로 로딩하면 700KB를 소모하게 되어서 2.4MB가 된다.
- 이미지의 참조를 끊고 강제 GC 명령을 내린다.
이렇게 이미지 로딩과 해제를 반복한다. 이때 생기는 GC 로그로 그래프를 그릴 수 있다.
그림 4 recycle() 메서드를 수행한 경우
그림 5 recycle() 메서드를 수행하지 않은 경우
<그림 4>는 recycle() 메서드를 수행한 경우인데 정확하게 개발자가 계산한 만큼의 메모리(1.7MB에서 2.4MB)를 사용했다. <그림 5>는 그림 1보다 메모리 사용량이 더 크다. 2.4MB가 유지되다가 3.2MB까지 올라가는 경우도 있다. recycle() 메서드를 수행하지 않은 경우는 이미지 1 ~ 2개 정도의 용량을 더 쓰는 꼴이었지만 OOM은 발생하지 않았다.
이 말은 일반적인 GC 과정에서 한두 개 정도의 네이티브 비트맵 객체가 좀 더 긴 시간 동안 생성돼 있겠지만 finalizer 스레드가 적절한 스케줄링으로 동작하므로 굳이 recycle() 메서드를 호출하지 않아도 문제가 없다는 뜻이다.
이런 이유로 recycle() 메서드의 API 설명에 ‘This is an advanced call, and normally need not be called’라고 되어 있는지도 모른다. 얼핏 보면 이 말이 맞는 것 같다. 하지만 테스트 단말기에서만 그렇게 동작했다 뿐이지, 다른 단말기나 VM에서는 그렇지 않을 수 있다. 게다가 같은 단말기라도 테스트 조건이 달라지면 어떻게 될지 모른다. 이 테스트에서는 이미지 1개를 로딩했지만 이미지 수십 개를 로딩하면 OOM이 발생할 수도 있다.
따라서 지금이라도 API 설명을 다음과 같이 바꾸는 게 어떨까 생각한다.
finalizer에 의해 네이티브 객체가 회수되겠지만, 그 동작을 예측하기 어려우므로 가급적 명시적으로 recycle() 메서드를 사용하기를 권장한다.
Android 팀을 옹호하려는 것은 아니지만 그들도 고민이 많았을 것이다. 그냥 겉으로 보기에는 비트맵은 일반 Java 객체인데, ‘객체를 해제하기 위해서 참조만 끊으면 되지 recycle() 메서드를 왜 사용해야 하지? 이건 Java답지 못하잖아?’라고 말하는 개발자의 잔소리가 들리는 듯하다. 그래서 그들도 ‘네이티브 비트맵 객체는 finalizer에게 맡겨도 보통의 경우엔 문제가 없을 테니 이렇게 써 놓자’라고 생각했을 것 같다. 지금이야 비트맵 이미지의 홍수 시대가 되어 버렸지만 초기에는 비트맵 이미지를 많이 안 썼으니 말이다.
최근에 발행된 Android 문서를 보면 이제 recycle() 메서드를 사용하는 것을 권장한다고 되어 있다.
On Android 2.3.3 (API level 10) and lower, using recycle() is recommended. If you’re displaying large amounts of bitmap data in your app, you’re likely to run into OutOfMemoryError errors. The recycle() method allows an app to reclaim memory as soon as possible.
(원본 출처: http://developer.android.com/training/displaying-bitmaps/manage-memory.html)
결론적으로 비트맵은 일반 Java 객체처럼 쓰고 버리면 기본 GC에 의해서 자동으로 메모리가 회수될 수 없는 구조이므로 파일이나 네트워크 자원처럼 명시적으로 recycle() 메서드를 사용해야 하는 자원이다.
recycle() 메서드를 사용하는 것이 권장되기는 하나 굳이 사용할 필요가 없는 경우도 있다. 단말기의 메모리 용량이 극도로 크거나 앱이 메모리를 매우 적게 쓰는 경우, 샘플 앱인 경우가 그렇다. 하지만 이런 경우는 거의 없다고 봐도 무방하다. 실제 앱은 안정적인 동작이 무엇보다 중요하기 때문이다.
external 메모리를 쓴 이유와 Honeycomb에서의 비트맵
이쯤에서 ‘왜 Android는 비트맵이 external 메모리를 쓰게끔 설계했을까’ 하는 의문이 들 것이다. Honeycomb 이전에 Android의 그래픽 라이브러리는 skia라 불리는, C++로 된 네이티브 라이브러리를 사용했다. 비트맵도 native 메모리에 로딩될 수 밖에 없었고, 이 비트맵을 과도하게 사용하면 다른 앱이 사용할 native 메모리가 부족해지기 때문에 한계를 정해 둘 필요가 있었다. 따라서 native 메모리의 일부분을 external 메모리라는 이름으로 할당받고 external 메모리가 프로세스당 메모리 한계를 초과하지 못하도록 만든 것이다.
Android에서도 이 부분의 문제점을 인지하고 있었고, 이를 해결하려고 노력한 끝에 Honeycomb부터 다음과 같이 비트맵 구조가 바뀌었다.
그림 6 Honeycomb(Android 3.0) 이후 버전의 비트맵 객체(원본 출처: http://aroundck.tistory.com/378)
즉, native heap 영역에 할당하던 픽셀 데이터를 dalvik heap 영역으로 옮겼다. 이로써 GC에 의해 완벽히 동기화되므로 recycle() 메서드를 호출할 필요가 없다. 그냥 일반 Java 객체와 같다.
Honeycomb부터는 비트맵 구조가 이렇게 바뀌어서 recycle() 메서드 사용에 대한 부담이 없지만, 아직도 Android 버전 중에 Gingerbread(Adroid 2.3)가 점유율이 가장 높다. Honeycomb 미만 버전을 지원하려면 여전히 recycle() 메서드를 사용해야 한다. 메서드 하나만 호출하면 되므로 별로 어려운 일은 아니다.
공유하는 비트맵 해제하기
하지만 비트맵 객체를 공유한다면 상황이 달라진다. 만일 다른 곳에서 쓰고 있는 비트맵을 recycle() 메서드로 해제하면 다음과 같은 오류가 생긴다.
java.lang.RuntimeException: Canvas: trying to use a recycled bitmap
공유된 비트맵 객체는 함부로 recycle() 메서드로 해제하면 안 된다. 다른 곳에서 쓰고 있지 않은지 확인한 후에 recycle() 메서드를 호출해야 한다. 예를 들면, 공유된 비트맵을 다른 곳에서 쓸 때마다 이 비트맵에 대한 참조 개수를 1씩 증가시키고 사용이 끝날 때마다 참조 개수를 1씩 감소시키다가 0이 되면 그때서야 recycle() 메서드를 호출한다.
이런 방식으로 비트맵 참조 개수를 관리하는 방법은 네이버의 여러 프로젝트에서 이미 사용하고 있다. 하지만 참조 개수를 증가, 감소시키는 작업은 사용하는 측에서 해야 하므로 실수할 가능성이나 부담이 생기게 된다. 이에 대한 해결책 또한 최근 발행된 Android 문서인 “Managing Bitmap Memory“에 나타나 있다.
문서에 있는 샘플 소스를 열어보면 핵심은 다음과 같다.
- RecyclingBitmapDrawable 클래스는 BitmapDrawble 클래스를 상속하고, 이미지 참조 개수를 가진다.
- 이미지 다운로더는 비트맵 대신 RecyclingBitmapDrawable 객체를 생성해서 반환한다.
-
RecyclingImageView 클래스는 ImageView 클래스를 상속하고, 다음 두 개 메서드를 재정의한다.
- setImageDrawable() 메서드: ImageView에 drawable 객체를 주입할 때 RecyclingBitmapDrawable 였다면 이미지 참조 개수를 증가시킨다.
- onDetachFromWindow() 메서드: ImageView가 화면에서 안 보이게 될 때 RecyclingBitmapDrawable의 이미지 참조 개수를 감소시킨다. 0이 되면 recycle() 메서드를 호출한다.
네이버에서 사용해 오던 방식이나 일반적인 참조 개수 세기 방식에서는 ImageView에 비트맵을 확장한 특정 하위 클래스를 할당하고, 사용자가 특정 하위 클래스의 increase() 메서드와 release() 메서드를 직접 호출해야 했다. 하지만 Android 문서에 나온 방식에서는 ImageView 대신 RecyclingImageView에, 비트맵 대신 RecyclingBitmapDrawable을 할당한다. 그러면 참조 개수 증가와 감소는 사용하는 측에서 직접 할 필요 없이 두 클래스에 의해 자동으로 행해진다. 참조 개수를 센다는 기본 원리는 같지만, 개수 세는 것을 자동화해서 실수할 가능성이 없어진다.
그렇다고 이 방식이 항상 잘 맞는 것은 아니다. 비트맵이 위와 같이 ImageView에 주입되는 경우에는 딱 맞는 방식이지만, OpenGL이나 기타 다른 방식으로 사용할 경우에는 또 다른 고민을 해야 한다. 하지만 대부분의 경우는 이 방식만으로도 충분히 편하게 쓸 수 있다.
만일 TextView의 백그라운드로 비트맵을 넣고 싶다면 동일하게 ImageView에서 재정의한 관련 메서드들을 같은 방식으로 재정의하면 된다.
참고로 다음 문서는 효율적으로 비트맵을 표시하는 데 대한 정말 중요한 정보를 담고 있기 때문 반드시 정독하기를 권한다.
- “Displaying Bitmaps Efficiently” http://developer.android.com/training/displaying-bitmaps/index.html
갤럭시 넥서스의 IME 문제
finalizer에 관련해서 생긴 오해가 또 하나 있다. ‘왜 갤럭시 넥서스, 갤럭시 S3는 EditText 위젯이 있는 액티비티의 인스턴스가 액티비티를 종료했는데도 해제되지 않는가?’하는 것이다. 게다가 이 액티비티를 생성하고 종료하기를 반복하다보면 결국 OOM이 발생하기도 한다.
또 다시 시험용 앱을 만들어 보자. 액티비티를 두 개 만드는데, 하나는 메인 액티비티이고 다른 하나는 서브 액티비티이다. 메인 액티비티에는 버튼을 하나 만들어서 버튼을 클릭하면 서브 액티비티가 실행되도록 한다. 서브 액티비티는 EditText 위젯을 배치해 놓고, 자신이 생성될 때와 소멸될 때 자신의 인스턴스 개수를 로그로 출력하는 기능도 넣는다.
액티비티 인스턴스 개수 세기
다음은 액티비티 인스턴스 개수를 세는 코드이다.
public class TestInstanceActivity extends Activity {
public static int instanceCount = 0;
public TestInstanceActivity () {
super();
instanceCount++;
Log.d("test", "TestInstanceActivity() instanceCount: " + instanceCount);
}
@Override
protected void finalize() throws Throwable {
super.finalize();
instanceCount--;
Log.d("test", "finalize() instanceCount: " + instanceCount);
}
}
앱을 실행해 메인 액티비티에서 버튼을 눌러 서브 액티비티를 실행하고, EditText를 클릭해서 IME(소프트 키보드)를 실행한다. 뒤로 버튼을 눌러 서브 액티비티를 닫는다. 이 동작을 반복한다. 이때 로그를 보면서 서브 액티비티의 인스턴스가 계속 증가하지 않는지 확인해야 한다. 메모리가 충분하다면 GC가 수행되지 않고 계속 인스턴스가 증가할 수 있다. 하지만 강제 GC를 수행하면 반드시 서브 액티비티 인스턴스는 0 ~ 1개를 유지해야 한다. 2개 이상이 된다면 어디선가 인스턴스가 해제되지 않고 있다는 뜻이다.
실험 결과로 에뮬레이터, LG-F160S, Xperia에서는 인스턴스 개수를 0 ~ 1개로 유지한다. 갤럭시 넥서스와 갤럭시 S3에서는 인스턴스 개수가 2개 이상으로 증가한다. 무한정 쌓이지는 않지만 늦게 반환되는 모습이다. 또한 꽤 오랜 시간이 지나면 자동으로 개수가 정상적으로 돌아온다. 레퍼런스 폰이라 불리는 갤럭시 넥서스를 포함해서 삼성 휴대폰 중 일부에서 이러한 문제가 발생하는데, 문제의 원인은 EditText와 IME의 연결 구조에 있다.
액티비티 생명주기의 콜백 메서드인 onCreate/onDestroy(Android 액티비티 생명주기)와 액티비티 인스턴스의 생성과 소멸(Java 객체 생명주기)을 혼동하지 않도록 한다. 문제의 증상은 액티비티에 EditText가 있는 경우 EditText에 IME 윈도가 연결되면(즉, 소프트 키보드를 실행하면) 갤럭시 넥서스와 갤럭시 S3에서 액티비티를 종료(onDestroy)하더라도 액티비티 인스턴스가 해제되지 않는 것이다. 이는 삼성 휴대폰의 버그(leak)인 듯 하다.[5]
인스턴스 해제를 위해 우회하는 방법
당장 문제의 근본 원인을 해결하기 전에 우회책을 한번 써 보도록 하자. 액티비티 인스턴스가 해제되지 않는 것은 EditText가 참조하고 있기 때문이고(EditText 생성 시 액티비티 컨텍스트를 주입하므로), EditText가 해제되지 않는 것은 IME 등이 참조하고 있기 때문이다. 따라서 액티비티가 해제될 수 있도록 EditText가 액티비티 컨텍스트가 아닌 애플리케이션 컨텍스트를 참조하게 해 보자. EditText를 생성할 때 액티비티 컨텍스트 대신 애플리케이션 컨텍스트를 주입하니 액티비티는 바로 반환되었다. 그런데 이 방식은 또 다른 역효과를 불러 일으킨다.
원래 EditText는 액티비티 컨텍스트를 써야지 애플리케이션 컨텍스트를 쓰면 안 된다. 지금까지 KYL21과 201K, 두 개의 단말기에서 역효과를 일으키는 것을 발견했다.
EditText에서 화면을 길게 누르면 컨텍스트 메뉴(복사, 잘라내기, 붙여넣기 메뉴)가 나타난다. 대부분의 단말기에서는 내부적으로 알고 있는 액티비티 컨텍스트를 이용해 메뉴를 실행하는 반면, KYL21과 201K 단말기에서는 EditText로 주입된 컨텍스트를 이용해 메뉴를 실행하는 듯하다. EditText에 애플리케이션 컨텍스트가 들어가 있으면 메뉴를 실행할 때 다음과 같은 충돌이 발생한다.
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application
메뉴(다이얼로그)는 반드시 액티비티 인스턴스를 이용해야 하기 때문이다. 그렇다면 어떻게 해야 하는가?
IME와 Finalizer와의 관계
이 현상은 엄밀히 따지자면 메모리 누수(leak)가 아니다. 이것 또한 finalizer와 관련되어 있다. 먼저, 해제되지 않는 당시의 힙 덤프(heap dump)를 분석해 보자.
com.example.test.EditActivity @ 0x41a09338
'- mContext android.widget.EditText @ 0x41a11f10
|- mTextView, mTargetView com.android.internal.widget.EditableInputConnection @ 0x41a065f0
| '- referent java.lang.ref.SoftReference @ 0x41a06668
| '- mInputConnection android.view.inputmethod.InputMethodManager$ControlledInputConnectionWrapper @ 0x41a06618 Native Stack
|- this$0 android.widget.TextView$IClipboardDataPasteEventImpl @ 0x41a0aae8
| '- this$1 android.widget.TextView$IClipboardDataPasteEventImpl$1 @ 0x41a0ab00
| '- referent java.lang.ref.FinalizerReference @ 0x41a0ab20
| '- next java.lang.ref.FinalizerReference @ 0x41a0b770
| '- ...
| '- head class java.lang.ref.FinalizerReference @ 0x40c58ec8 System Class
참조 관계는 다음과 같다.
Activity <- EditText <- IClipboardDataPasteEventImp <- FinalizerReference
위에서 보면 IClipboardDataPasteEventImp라는 클래스가 있는데, 이 클래스는 에뮬레이터에서 힙 덤프를 실행해서 확인하면 보이지 않는다. 아마도 IClipboardDataPasteEventImp 클래스는 삼성전자에서 자체적으로 만든 것 같다. 액티비티를 닫았지만(onDestroy) IClipboardDataPasteEventImp 객체가 EditText를 참조하고 있고 EditText는 액티비티를 참조하고 있으므로 액티비티 인스턴스는 소멸(finalize)될 수 없는 상태이다. IClipboardDataPasteEventImp 객체는 별도의 finalizer에 의해 finalize() 메서드가 호출되기를 기다리고 있다. 참고로, FinalizerReference는 finalizer 스레드에 의해 사용되고, 객체의 static 멤버변수인 ReferenceQueue가 IClipboardDataPasteEventImp 객체를 가리키는 형태다. 그리고 finalizer가 동작하면 그 큐에서 IClipboardDataPasteEventImp 객체로의 참조가 끊어진다. finalize() 메서드가 호출되면 FinalizerReference에서 IClipboardDataPasteEventImp 객체를 참조하던 것이 끊어지고, 그제서야 IClipboardDataPasteEventImp 객체를 포함해서 EditText와 액티비티까지 함께 해제될 수 있다.
결국 이 문제도 finalizer에 관련된 것인데, 앞서 설명한 비트맵처럼 심각한 문제는 아니라고 생각한다. 비트맵은 finalize되는 시점까지 앱의 메모리를 계속 차지하고 있어서 큰 문제가 되었지만, IClipboardDataPasteEventImp은 finalize되는 시점까지 메모리를 차지하고 있는 양은 지극히 작다(힙 덤프상 1KB도 안 됨). 예측할 수 없는 시점에 메모리가 해제되기는 하지만, 액티비티를 반복해서 실행하다가 OOM이 발생하는 것은 IClipboardDataPasteEventImp 객체의 문제라기 보다는 액티비티가 사용하는 다른 객체 때문이다. 예를 들어서 고객 정보를 저장하기 위해 5MB를 소모하는 A 객체, 비트맵을 로딩하는 데 10MB 소모하는 B 객체, 1KB 미만인 IClipboardDataPasteEventImp 객체 중 대체 누가 메모리를 많이 소모하는가? A 객체와 B 객체이다. 따라서 onDestroy 시 이 객체들만 해제해도 OOM은 발생하지 않는다.
물론 ‘finalize 관련 부분만 끼어들지 않았어도 위의 A, B 객체에 대한 참조를 없애는 코드를 넣지 않아도 될 텐데…’라는 생각은 든다. 하지만 일반 Java 프로그래밍에서도 사용이 끝난 객체는 빠르게 GC 대상이 될 수 있게끔 참조를 끊는 코드를 종종 사용한다. 비슷한 경우라고 생각할 수 있다.
참고로 에뮬레이터와 다른 단말기들은 IME와 연결되는 구조가 SoftReference로 되어 있다. OOM이 발생하려는 순간 먼저 SoftReference가 끊겨서 메모리가 회수되므로 OOM은 발생하지 않는다.[6]
필자의 경험상 Android에서 메모리 누수인 것 같다고 여기는 경우는 대부분 finalize와 관련이 있다. 한번은 갤럭시 넥서스의 IME 문제에 대해 삼성전자 측에 문의해 본 적이 있다. 그들의 대답은 메모리 상황에 따라서 해제된다는 것이었다(메모리 상황이라기보다는 시스템의 상황일 것 같지만). 그들 조차도 finalize 문제에 대해서 명쾌하게 대답을 못 해 주는 것 같다. 실제 책임자가 아니어서 그럴 지도 모르지만.
WebView 인스턴스가 해제되지 않는 경우
finalize() 메서드와 관련된 문제는 이외에도 몇 가지가 더 있는데 그 중 하나만 더 알아보자. 간혹 WebView 객체가 액티비티를 닫았는데도 해제되지 않아서 액티비티가 살아 있다며 메모리 누수인 것 같다고 얘기하는 경우가 있다. 그래서 해결책으로 WebView 클래스를 확장하고 특정 메서드를 오버라이드해서 강제로 WebView 객체를 해제하는 코드를 제시한다. 하지만 이 해결책 또한 다른 역효과를 불러 일으킨다. 최근 수행한 프로젝트에서 이로 인해 웹 페이지가 submit이 안 되는 현상이 발견되었다.
WebView의 소스 코드를 보면 결국 이 문제도 finalize와 관련돼 있다. finalize() 메서드를 재정의했고, finalize() 메서드 내에서 close() 메서드를 호출하고, close() 메서드에서 네이티브 쪽 객체를 닫는 nativeClose() 메서드를 호출하게끔 되어 있다. WebView가 원치 않는 시간 동안 유지된다 하더라도 앱의 메모리를 안 쓰기 때문에 문제는 없다. WebView의 렌더링 엔진은 Webkit이고 웹 페이지는 전부 네이티브 메모리를 사용하기 때문이다. WebView가 해제되지 않아서 생기는 액티비티 인스턴스가 살아 있는 문제는 IME 문제와 같이 액티비티가 과도하게 사용하는 다른 객체를 해제해 주면 될 것이다. 그리고 WebView 인스턴스는 일정한 시간이 지나면 자동으로 해제된다. 다만 해제되는 시점을 정확히 알 수 없을 뿐이다.
비트맵 최적화 작업
앱에서 가장 많은 메모리를 소모하는 곳이 비트맵일 것이다. 지금까지는 비트맵과 recycle() 메서드를 이용한 비트맵 해제에 관해서 알아보았다면 이제부터는 이 비트맵 사용을 최적화하는 방법을 알아보자.
배경/전경 비트맵을 액티비티 생명주기에 맞춰서 로딩하고 해제하기
액티비티마다 배경 비트맵이나 전경 비트맵이 사용되고, 액티비티 스택이 길어질수록 메모리는 훨씬 빨리 고갈될 것이다. 어느 순간에는 더 이상 액티비티를 쌓을 수 없게 된다. 그렇다고 다음 액티비티가 실행되기 전에 이전 액티비티를 종료하고 다음 액티비티에서 뒤로 버튼을 누르면 이전 액티비티를 다시 생성하는 방법을 쓴다면 푸시 알림으로 인해 다른 액티비티를 실행하는 등의 상황에서 액티비티 흐름이 꼬일 수 있다.
이 방법보다는 액티비티 생명주기(onStart, onStop, onResume, onPause)에 맞춰 비트맵을 로딩하고 해제하는 방법을 쓰는 것이 낫다. 물론 비트맵을 액티비티 생명주기에 맞춰 매번 로딩하고 해제하는 것도 시스템에 부담이 될 수 있으므로 상황에 맞게 써야 할 것이다.
이미지 품질 줄이기
이미지 파일 중 PNG 파일을 최적화하는 방법이 있다. 다음 표는 똑같은 PNG 이미지를 8비트와 24비트로 각각 저장하고 비트맵 디코딩 옵션을 달리해서 로딩했을 때 메모리 사용량을 나타낸 것이다.
이미지 파일 비트 수 | 비트맵 디코딩 옵션 | 메모리 사용량 | |
res 폴더 | 8bit | RGB_565 | 47,752 |
24bit | ARGB_8888 | 47,752 | |
asset 폴더, 스토리지에 있는 파일 | 8bit | RGB_565 | 26,790 |
24bit | ARGB_8888 | 107,160 |
Android에서는 res 폴더에 이미지 파일을 넣으면 빌드 과정에서 aapt(Android Asset Packaging Tool)가 자동으로 품질 손실 없이 이미지를 최적화한다. 예를 들어 24비트 이미지 파일을 넣더라도 256색 이상을 사용하지 않았다면 8비트 이미지 파일로 변환된다. 품질은 똑같지만 메모리는 훨씬 덜 차지하게 된다.[7]
하지만 asset 폴더에 들어가거나 스토리지에 존재하는 이미지는 당연히 그런 최적화를 하지 않는다. 8비트 이미지 파일은 24비트 이미지 파일에 비해서 메모리 사용량이 1/4 수준이다. 이미지 품질이 매우 중요한 경우가 아니라면 8비트 이미지를 사용하고 RGB_565 옵션으로 디코딩하는 것을 권장한다.
참고로, 위 표가 res쪽과 asset쪽의 메모리 사용량 비교를 위한 것은 아니다. asset쪽의 8비트 이미지가 res쪽보다 메모리 사용량이 더 적다고 asset 쪽을 쓰는 것은 Android의 비트맵 리소스 관리의 장점을 버리는 꼴이 된다. 여기서의 핵심은 res쪽은 Android가 최적화를 하니 신경 쓸 필요가 없다는 것이고, asset 폴더나 스토리지에 있는 파일은 그런 최적화를 Android가 할 수도 없고 하지도 않으니 프로그래머가 알아서 해야 한다는 의미이다.
새로운 비트맵 생성 없이 이미지 라운딩 처리하기
일반적으로 이미지 라운딩을 처리할 때는 빈 비트맵을 새로 생성하고 그 비트맵에 기존 이미지를 라운딩 방식으로 그려서 만들어진 비트맵을 ImageView에 주입하곤 한다. 그런데 새로운 비트맵을 생성하는 것이 부담스럽다면 RoundedImageView라는 클래스를 사용해 보는 것도 좋은 방법이다. 이 클래스를 사용하면 새로운 비트맵을 만들지 않고 그릴 때(onDraw) 계산해서 라운딩 방식으로 그리게 된다.[8]
그 외의 방법
앞에서 설명한 비트맵 최적화 방법 외에 다음과 같은 방법도 고려해 볼 수도 있다.
- 배경 비트맵 대신 컬러나 반복 패턴 사용
- 앱 메모리 한계 기준에 따라 품질이 한 단계 더 낮은 이미지 사용
비트맵 최적화와는 상관 없지만, 동시에 많은 수의 비트맵 디코딩이 일어나면 그 순간 메모리가 부족해질 수 있다. 그래서 비트맵 디코딩(다운로드 포함) 작업을 큐에 담아 놓고 복수 개의 작업 스레드에서 순차적으로 디코딩할 수 있도록 해야 한다. 작업 스레드는 풀을 구성해서 동시에 작업하는 스레드 개수를 제한한다. 풀의 적정한 개수는 앱에서 동시에 사용하는 이미지 크기나 개수에 따라서 달라질 수 있다. 이는 실제 테스트를 통해 결정해야 한다.[9]
Dalvik heap 최적화
지금까지 비트맵과 recycle() 메서드, 최적화를 통해 native heap을 절약했다면 이번 장에서는 dalvik heap을 줄이는 방법을 살펴보자.
dalvik heap을 적게 쓰는 것은 일반적인 Java 메모리를 적게 사용하는 것과 별반 차이가 없으므로, 여기서는 Android에만 해당하는 내용을 살펴보겠다.
Dalvik heap 늘리기
dalvik heap 사용량을 줄이는 방법을 보기 전에, dalvik heap의 최대 크기를 늘리는 방법부터 살펴보자. 사실 이 방법이 가장 빠르고 간편하다. largeHeap 옵션을 사용하는 것인데, 보통 2배에서 4배까지 dalvik heap의 최대 크기가 늘어난다. 이 옵션은 AndroidManifest.xml 파일에 설정하며 Honeycomb부터 동작한다.
- largeHeap 크기를 얻는 API: ((ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE)).getLargeMemoryClass();
apk 파일로 압축된 파일 목록 줄이기
Android는 .apk 파일을 패키징하면 앱을 실행할 때 .apk 파일에 든 모든 파일의 엔트리 정보를 전부 메모리로 로딩한다. 힙 덤프를 실행해서 살펴보면 다음과 같은 클래스 이름으로 나타난다.
org.apache.harmony.luni.internal.net.www.protocol.jar.JarURLConnectionImpl
만일에 asset 폴더나 res 폴더에 수천 장의 이미지 파일이 있다면(또는 클래스가 너무 많아도 마찬가지) 이미지 파일 크기와는 상관 없이 파일 목록에 대한 엔트리 정보만으로 JarURLConnection 객체의 메모리 사용량이 10MB를 넘어가 버린다. 전체 메모리 용량이 32MB라면 이건 엄청난 부담이다.
res 폴더에 넣는 것은 어쩔 수 없더라도 asset 폴더에 들어가는 파일들은 차라리 로컬 DB로 구성하는 게 낫다. 테이블의 한 칼럼을 BLOC 칼럼으로 정의하고 이미지 바이너리를 이 칼럼에 저장하는 것이다. DB를 사용하면 파일을 사용하는 것보다 속도 면에서 더 나을 수도 있다. 특히, 이미지 파일의 크기가 작거나 많을수록, 동시에 여러 개의 이미지를 가져올수록 빨라진다. 네트워크에서 다운로드한 이미지 파일도 크기가 작고 개수가 상당하다면 개별 파일로 저장하는 것보다 DB로 저장하는 것이 스토리지 용량 측면에서도 더 유리하다.
이때 파일 크기의 기준은 정하기 애매하지만, 다음의 경우는 DB를 사용하는 것이 확실히 나았다. 최근 한 프로젝트에서 아바타를 그리기 위해서 사용하는 이미지 크기가 보통 1KB 정도였고 512바이트가 안 되는 경우도 상당히 많았는데, 이것을 파일에서 DB 방식으로 바꾸었더니 눈에 띄게 속도가 빨라졌다. 512바이트도 안 되는 이미지를 개별 파일로 저장하면 파일당 기본 디스크 할당 크기가 512바이트인 파일 시스템에서는 공간 낭비도 심하다.
그렇다면 파일 크기가 어느 정도일 때 DB 방식이 유리할까? 실제 테스트해 보지 않고서는 결론을 내리기가 어렵지만 다음과 같은 기준을 제시할 수 있다. DB 커서(Cursor)를 통해 이미지 바이너리를 읽을 때 커서 윈도 크기가 1 ~ 2MB 사이라는 것이다. 이 커서 윈도 크기를 초과해서는 안 되겠다.[10]
꼭 DB가 아니더라도 LRU(least recently used) 파일 캐시 구현이나 별도의 파일 형식(예컨대 게임에서 많이 쓰는 이미지 스프라이트 파일처럼 여러 개의 이미지를 가지고 있는 파일)을 사용하는 것도 고려해 볼 수 있다. LRU 파일 캐시 구현은 “Caching Bitmaps” 페이지의 예제 파일(BitmapFun.zip) 내에 DiskLruCache라는 이름으로 제공된다.
자료 구조 최적화
자료 구조를 최적화하는 것도 dalvik heap을 절감하는 데 도움이 된다. 최근 프로젝트에서 맵(map)을 써서 키와 값을 저장하는 구조가 있었다. 맵에 들어가는 키는 연속적인 숫자지만, 중간중간에 한두 개 정도 인덱스가 빠진다. 그런데 이 숫자 인덱스의 개수가 많아지면 많아질수록 메모리 사용량이 증가한다. 즉, 키를 저장하는 것 자체가 부담이 되는 것이다. 그래서 이 구조를 배열로 바꾸었더니 상당량의 메모리를 절감할 수 있었다. 중간에 빠진 인덱스는 그냥 무시해 버리면 된다.
프로세스 분리하기
앞서 설명한 최적화 방법을 사용한 후에도 절대적인 메모리 사용량이 많다면 프로세스 분리 방안을 생각해 본다. 단, 프로세스 분리보다 메모리 최적화가 우선이라는 것을 잊어서는 안 된다. 프로세스를 분리한다고 해도 JarURLConnection 같이 기본적으로 차지하는 사용량은 분리된 두 개의 프로세스 모두에서 소모될 것이다. Android 앱을 만들 때는 가능한 한 컴포넌트별로 개발하는 것이 나중에 프로세스를 분리할 때를 대비해 좋다.
프로세스를 분리할 때는 몇 가지 고려해야 할 사항이 있다. 첫 번째 화면에서 두 번째 화면으로 넘어갈 때 검은 화면이 나오게 되는데, 이는 두 번째 프로세스를 구동하는 데 시간이 걸리기 때문이다. 따라서 첫 번째 화면이 나오면 바로 두 번째 프로세스를 미리 실행해야 한다. 방송과 방송수신 방식을 통해서(BroadcastReciever) 두 번째 프로세스를 미리 실행할 수 있다. 그리고 프로세스 간 자원을 공유할 때는 DB, 파일 등 공유할 수 있는 자원을 이용하거나 각 프로세스 간 메모리 사이에서 동기화할 수 있는 토대를 마련해야 한다.
Footprint 증가에 유의하기
앞서 언급했듯이 Java footprint는 한번 증가하면 다시 크기가 감소되지 않는다. footprint가 과도하게 커지지 않게끔 잘 관리해야 한다는 의미이다. 예를 들어 프로세스당 메모리가 24MB인 단말기가 있고, DB에 고객 정보가 1,000건이 있다고 가정해 보자. 이 고객 정보 전부를 메모리로 올리면 한 건당 10KB(실제로는 이 정도의 용량을 사용하지 않겠지만)라고 했을 때 10,000KB(10MB)를 차지하게 된다. 그러면 java heap allocated는 10MB를 차지할 테고, footprint도 10MB(실제로는 10MB보다 조금 더 큼)일 것이다. 이 고객 정보를 메모리에서 모두 삭제하면 allocated는 0KB가 되겠지만, footprint는 10MB인 상태 그대로 남아있게 된다. 문제는, 아무리 java heap allocated가 0KB라고 해도 external 영역은 14MB(프로세스당 메모리 24MB – Java footprint 10MB = 14MB) 밖에 쓸 수 없다는 것이다. 그래서 비트맵을 로딩할 영역이 줄어들게 된다.
이번에는 고객 정보를 전부 다 메모리로 올리지 않고 100건씩 나누어서 먼저 100건을 올려 무엇인가를 처리하고 삭제한 다음 다시 다음 100건을 올려 처리하고 삭제하는 과정을 1,000건을 모두 올려서 처리할 때까지 반복한다고 해 보자. 그러면 java allocated 영역은 1MB(10KB * 100건)를 차지하게 될 테고 Java footprint도 1MB일 것이다. 이 경우 external 영역은 23MB를 쓸 수 있다.
즉, java heap을 한꺼번에 많이 쓰면 java heap allocated 사용량이 다시 줄어도 Java footprint는 높은 상태 그대로 유지되므로 비트맵이 쓸 external 공간이 부족해진다. 따라서 footprint를 과도하게 올리는 일을 피해야 한다. 위의 예처럼 고객 정보를 1,000건 모두 메모리에 올리기 보다(모두 올려야만 뭔가를 할 수 있는 서비스라면 방법이 없겠지만), 100건씩 올려서 처리하고 삭제하는 것이 external 영역을 확보하는 데 좋다. 주소록 같이 무한정으로 고객 정보를 저장하는 앱이라면 DB 데이터를 전부 메모리로 올리지 않고, DB 커서를 이용해서 화면에 보이는 정보만 가져와서 보여주고 화면에서 보이지 않게 된 정보는 메모리에서 삭제하는 게 낫다(즉, 현재 화면에 보이는 정보 분량만큼만 메모리를 차지한다). DB 커서를 사용하면 메모리가 절감될 뿐만 아니라 초기 속도도 빨라진다. 데이터를 전부 로딩해서 보여주는 것보다 한 화면에 해당하는 분량만 로딩해서 보여주는 것이 빠르기 때문이다.
반대로 비트맵 객체의 경우는 문제가 없다. external 영역은 limit이 줄어들기 때문이다. 이렇게 Java footprint가 줄어들지 않는 특성으로 인해 external 영역이 부족해지는 문제 때문에 Honeycomb부터는 dalvik heap과 external 영역이 합쳐져서 이런 고민을 할 필요가 없어졌다. 하지만 개발 기준은 언제나 2.3 이하 버전이라는 사실을 명심한다.
Native heap 사용하기
여러 가지 방식의 최적화 노력에도 불구하고 메모리 부족이 심하다면 마지막으로 native heap을 사용해 볼 수 있다. native heap은 프로세스당 메모리 한계를 벗어나서 시스템의 물리적인 메모리 한계까지 다 쓸 수 있다. 하지만 native heap을 사용하려면 C/C++로 프로그래밍한 후 Java에서 C/C++ 코드를 JNI를 통해 호출해야 하기 때문에 프로그램 구조가 더 복잡해지고 JNI 호출에 따른 성능 저하 문제도 고려해야 한다. 보통 메모리를 많이 쓰는 게임 앱을 만들 때 Java보다는 C/C++를 사용한 네이티브 방식을 많이 사용한다.
마치며
지금껏 기술한 내용은 사실 Android에서 메모리 문제로 고생한 적이 있다면 부분적으로 한두 번은 시도해 봤을 법한 것들이다. 개인적인 생각이지만, Android에서는 Honeycomb이 되어서야 비로소 메모리를 Java답게 쓰게 되었다는 느낌이다. 그 전에는 모바일 기기라는 특수한 상황 때문에 어쩔 수 없었을 것이라고 위안하고 있다.
최근에 프로젝트를 수행하면서 다음과 같은 몇 가지 교훈을 깨달았다.
- 예측에 따라 프로그래밍해서는 안 된다.
- Android 가이드 문서를 반드시 읽어야 한다(예전과 달리 Android 가이드 문서가 상당히 풍족해졌다).
- Android 샘플 코드를 참조하는 것은 많은 도움이 된다.
- 가능한 한 앱을 컴포넌트(액티비티, 서비스, 콘텐트 프로바이더, 브로드캐스트 리시버)별로 개발한다.
앞으로 Android 기반 앱을 개발할 때 Android 메모리를 충분히 이해하고 임한다면 필자와 같은 실수를 범하지 않을 것이다. 이 글에서 다룬 내용이 많은 도움이 되기를 바란다.
참고 자료
[1] http://developer.android.com/reference/android/app/ActivityManager.MemoryInfo.html
[2] http://developer.android.com/reference/android/app/ActivityManager.html. ActivityManager.getProcessMemoryInfo() 메서드가 반환하는 MemoryInfo 클래스에 관한 내용은 http://developer.android.com/reference/android/os/Debug.MemoryInfo.html를 참조한다.
[3] http://aroundck.tistory.com/378
[4] http://www.yunsobi.com/blog/429
[5] http://stackoverflow.com/questions/8497965/why-does-edittext-retain-its-activitys-context-in-ice-cream-sandwich
[6] http://stackoverflow.com/questions/14135931/memory-leak-through-iclipboarddatapasteeventimpl
[7] http://developer.android.com/guide/topics/resources/drawable-resource.html#Bitmap
[8] https://gist.github.com/Pretz/1586565
[9] http://developer.android.com/training/multiple-threads/index.html
[10] https://plus.google.com/111462487833256325744/posts/AEU34Wimi2b