
이 이슈를 주제로한 글을 써야지 써야지, 하다가 이제서야 쓰게 되었습니다. 많은 내용을 담아야하기 때문에 미루다가 그만😅... 시간은 항상 부족한 것 같습니다.
간략한 설명을 위해 OutOfMemory를 줄여서 OOM 이라고 부르도록 하겠습니다. OOM 는 많은 분들이 알고 계시는 대로 Heap 메모리가 한계에 도달하여 더 이상 새로운 객체를 할당할 수 없을 때 발생하는 에러입니다.
최근 우아한 테크 - 폴라의 Out of Memory Error 영상을 보고 다른 문제로 발생하기도 한다는 것을 알게되었습니다. JVM은 Garbage Collector 가 있고 메모리를 알아서 관리해주기 때문에 C 언어와 같이 수동으로 메모리를 할당 및 해제 해야하는 언어에 비해 편리합니다. 하지만 자동으로 사용하지 않는 메모리를 수집(해제)하지 못하는 경우도 있습니다.
GC(Garbage collector)는 메모리를 관리하는 메커니즘이나 알고리즘이 존재합니다. 꽤 심오한 내용이 있는것으로 알고 있지만 글의 주제와 다소 거리가 있으므로 생략하겠습니다... 간략하게 참조 되고 있는 인스턴스는 사용중인 메모리라고 판단하여 GC의 대상이 되지 않습니다. static 과 같은 곳에서 heap 메모리에 할당된 인스턴스를 참조하면 프로그램이 종료될 때 까지 메모리를 수집하지 않습니다. 이런 경우에도 Memory leak 이 발생하고 static 사용을 지양하는 이유입니다.
이렇게 수집(해제)되지 못한 메모리가 발생하는 것을 Memory leak(메모리 누수) 가 발생한다고 합니다. 이와 관련해 좋은 내용이 담겨 있는 블로그 와 영상 Demystify the data in Android Studio Profilers (Android Dev Summit '19) 소개 드립니다.
할당 해제 되지 못한 메모리가 많아지게 되면 Heap 메모리가 한계에 도달하게 됩니다. Android 환경에서 OOM 이 발생하는 경우는 다양하지만 일반적으로 Context 를 잘못 참조할 때 입니다.
Android 환경은 시스템에 종속성이 강합니다. 개인적인 생각으로는 기기를 휴대하며 사용하기 위해 배터리를 사용하고 안정적인 전력 공급이 어렵다는 점과 관련이 있는것으로 보입니다. 따라서 Desktop 같은 장치보다 비교적 낮은 사양으로, 자원을 효율적으로 사용하기 위해 특화된 환경이라고 유추하고 있습니다. 따라서 Android 환경에서 구동되는 Application 을 강하게 제어하고 있기 때문에 Lifecycle, Main Thread(UI thread) 등을 사용하는데 주의가 필요하고 이런 특징을 잘 이해하고 있어야 합니다. 쾌적한 사용자 경험을 위해 적절한 메모리 할당/해제 가 되어야 하지요.
본론으로 돌아가 이 Lifecycle 을 고려하지 못했을 때 OOM이 발생합니다. 구체적으로 Activity 와 같은 안드로이드 컴포넌트의 Context 를 잘못 참조하는 경우가 있습니다. 가장 흔하게 메모리 누수가 발생하는 원인이기도 합니다. 또 OOM이 발생하지 않더라도 Android 환경의 특징을 고려하지 못하면 다양한 문제들이 발생할 수 있습니다. 할당 해제된 메모리에 접근하게 되어 NPE(null point exception) 가 발생하거나, 적절한 메모리 수집이 수행 되지 못하면 UI처리가 지연되어 버벅거리는 Jank 현상이 발생합니다.
Jank 현상은 Main Thread 가 짧게나마 Blocking 될 때 발생하기도 합니다. 한 가지 더 메모리 누수로 인해 작업 처리가 지연되면, 사용자의 이벤트나 시스템 이벤트를 일정 시간동안 처리하지 못하여 ANR이 발생할 수 있습니다. 따라서 Android 환경의 특징을 잘 이해하는것은 매우 중요합니다.
회사에서 제게 할당 된 이슈를 테스트 하다가 특정 경로에서 Jank 현상이 발생하였습니다. 이전에도 동일한 경로에서 메모리 누수를 발견한 경험이 있어 Android Studio 의 profiler 를 사용해, 다른 동료가 담당하는 모듈에서 메모리 누수가 발생하고 있는 것을 찾았습니다. 지나치게 많이 쌓였을 때 ANR 시스템 팝업도 없이 앱이 죽어버려 빠르게 수정이 필요한 상황이었고 바로 동료와 함께 원인을 분석했습니다.😢
회사 프로젝트로 상세한 내용을 말씀드리긴 어렵고 여러 애로사항이 있지만 내용이 너무 길어져 최대한 간소하게 설명하려고합니다. 함수명이나 변수명 또한 실제와 다르니 이점 양해 부탁드립니다.
코드를 분석하는 중 조금 당황스러운 코드가 보였습니다. 어떤 이벤트 콜백을 받았을 때 Thread 를 직접 생성하여 Bitmap을 조작하는 작업을 처리하고 있었습니다. 또 동일한 함수를 중복 호출하거나 아무 처리도 하지 않는 메서드에 Bitmap 을 전달하고 있어 오버헤드도 많아 보였습니다. 글로 표현하니 더 복잡해 보이네요. 왜 이렇게 처리하고 있는지 알아볼 수 있는 commit 이나 문서는 없었습니다. 네...그런 환경이지요...
코드를 분석해 보니 Bitmap에 대한 처리가 무거운 작업이라 Main Thread Blocking 방지를 위해 Worker Thread 에서 이를 처리하도록 작성한 것 같았습니다. Bitmap 에 Blur 처리를 하는 등의 작업 후 ImageView 의 post 메서드에 이를 또 예약하고 있었습니다. 이 때 post() 에 전달된 Runnable 에서 Bitmap에 대한 참조를 가지고 있었기 때문에 Bitmap 이 계속 쌓이면서 메모리 누수가 발생되고 있었습니다.
또 Bitmap 굉장히 많은 메모리를 사용합니다. Android 8.0 부터는 JVM에 메모리 사용에 대한 부담을 덜기 위해 Native Memory 를 사용한다고 합니다. 관련된 내용은 Demystify the data in Android Studio Profilers (Android Dev Summit '19) 영상의 4분 25초에 나옵니다.
객체 자체는 Heap 에 존재하지만 Bitmap 에 사용되는 픽셀 데이터는 Native Memory 를 사용한다고 하는데 자세한 내용은 잘 몰라서 여기서 다루지 않겠습니다.

Native Memory 는 시스템에서 공유하는 메모리 영역으로 한계에 도달하게 됫을 때 다른 Process 에 영향을 줄수도 있다곡 생각하니, 상상만해도 끔찍합니다... 왜 쌓이는지 다음 섹션인 해결하는 내용에서 다루겠습니다.
public void updateImage(final Bitmap bitmap) {
//...생략
new Thread(new Runnable() {
public void run() {
final Bitmap bitmaps = CommonUtil.blur(CommonUtil.createRoundedRectBitmap(bitmap, 15f, 0, 0, 15f, 100, 100));
final Drawable mBackgroundDrawable = replaceBackgroundInLayerList(mContext.getApplicationContext(), bitmaps, R.drawable.something, R.id.something);
mBackGroundImageView.post(new Runnable() {
public void run() {
mBackGroundImageView.setImageDrawable(mBackgroundDrawable);
mAimageView.setImageBitmap(bitmap);
mBimageView.invalidate();
}
});
}
}).start();
}
위 메서드는 Streaming 중인 음악이 다른 음악으로 전환될 때 마다 호출되며, 사용자가 직접 음악을 전환할 수 있습니다. 호출되는 횟수가 많은데 Thread 를 계속 새로 생성하고 있습니다. Thread 를 생성하는 것은 비용이 굉장히 많이 드는 작업입니다. 새로운 Thread 를 계속 생성할 이유가 없는 것 같아 이를 첫번째로 수정했습니다.
직접 Thread를 생성해서 사용하면 관리도 어려울 뿐더러 위 코드처럼 잘못 사용할 문제가 있어 AsyncTask 나 HandlerThread 와 같은 Api를 제공합니다. 이 또한 deprecated 되며 최근 안드로이드에서는 코루틴을 권장하고 있습니다.
하지만 제가 담당한 프로젝트는 Automotive 환경의 시스템 앱이고 Jetpack의 ViewModel, LiveData 정도만 사용하고 있습니다. 문제를 해결할 당시에 코루틴을 학습하고 있었고 ThreadPoolExecutorService 가 생각나 Thread 의 개수가 1개인 ThreadPool 을 이용하는 방법을 채택했습니다. 나중에 다시 생각해보니 HandlerThread 로도 충분했을 것 같아 동료에게 말씀드렸습니다.😂
mBitmapControlThread = Executors.newFixedThreadPool(1);
두번째로 Lifecycle 을 고려하지 않고 ImageView 의 post 메서드를 사용하고 있는 것을 수정했습니다. Worker Thread 에서 직접 View 에 대한 처리를 할 경우 CalledFromWrongThreadException 예외가 발생하기 때문에 post 메서드와 같은 방법을 사용해야 합니다. 비슷한 API 로 runOnUiThread 가 있습니다. 다른 쓰레드에서 UI 와 관련된 처리를 할 경우 어떤 결과가 나올지 예상할 수 없기 때문에 반드시 Main Thread 에서 처리해야 합니다. 그래서 안드로이드 환경의 Main Thread 는 UI Thread 라고 불리기도 합니다.
mBackGroundImageView.post(bitmapRunnable);
아래는 API 29 의 View.post() 에 대한 코드와 JavaDoc 입니다.
@UnsupportedAppUsage
public ThreadedRenderer getThreadedRenderer() {
return mAttachInfo != null ? mAttachInfo.mThreadedRenderer : null;
}
/**
* <p>Causes the Runnable to be added to the message queue.
* The runnable will be run on the user interface thread.</p>
*
* @param action The Runnable that will be executed.
*
* @return Returns true if the Runnable was successfully placed in to the
* message queue. Returns false on failure, usually because the
* looper processing the message queue is exiting.
*
* @see #postDelayed
* @see #removeCallbacks
*/
public boolean post(Runnable action) {
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
return attachInfo.mHandler.post(action);
}
// Postpone the runnable until we know on which thread it needs to run.
// Assume that the runnable will be successfully placed after attach.
getRunQueue().post(action);
return true;
}
View.post() 메서드는 MainThread 에서 처리할 Runnable 을 Queue 에 적재하고 해당 View 를 가지고 있는 컴포넌트(Activity 또는 Fragment) 가 사용자와 상호작용할 수 있는 onResume 상태에 Queue 에 있는 Runnable 을 가져와 실행 합니다. post에 대한 좀 더 자세한 내용을 담은 블로그가 있어 소개 드립니다.
Activity/Fragment 가 onResume 상태가 아니면 Runnable 은 계속 적재됩니다. 또 Activity/Fragment 가 Destroy 되더라도 View.post() 로 등록된 Runnable 은 자동으로 할당한 메모리를 수집(해제) 하지 않기 때문에 반드시 수동으로 처리해야합니다.
본론으로 돌아가, 등록된 Runnable 을 수동으로 처리하는 곳은 있었습니다. 하지만 특정 경로(상황)에서 처리되지 못하고 무한히 쌓이고 있었죠.
이전, Streaming 중인 음악이 다른 음악으로 전환될 때 마다 호출 된다고 말씀드렸습니다. 다른 음악으로 전환될 때 이전 Bitmap 에대한 처리나 데이터가 필요하지 않기 때문에 이전에 등록된 Runnable 을 제거하는 것으로 쉽게 수정할 수 있었습니다. 또 동일한 Bitmap 을 전달 받았을 때 이전 Bitmap 을 사용하여 더 성능을 높이도록 개선했습니다.
public void updateImage(Bitmap bitmap) {
Log.d(sTAG,"updateAlbumArt");
mNewBitmap = bitmap;
if(!CommonUtil.getImageViewBitmap(mArtImageView).sameAs(bitmap)){
mBackGroundImageView.setVisibility(View.VISIBLE);
mBackGroundImageView.removeCallbacks(bitmapRunnable);
AndroidAutoOsdController.getAndroidAutoOsdController().updateAlbumArt(mUpdateAlbumArtRunnable);
} else {
Log.d(sTAG, "same AlbumArt bitmap");
}
}
해결하기 어려운 주제는 아닙니다. 또 어려운 기술로 문제를 해결 하지 않았고, 불필요하게 ThreadPool 을 사용하기도 했습니다. 아직 JVM GC 알고리즘이나 Bitmap 에 대한 자세한 내용은 모르지만 Android 환경에 대한 특징과 메모리와 같은, 중요 하지만 기본적인 지식을 많이 사용하여 해결한 것 같아 보람이 있던 문제입니다.