안드로이드의 메모리 누수 패턴

2017-03-24
 

메모리 누수란?

모든 앱은 작업을 수행하는데 필요한 리소스로 메모리가 필요합니다. Android의 각 앱에 충분한 메모리가 있는지 확인하려면 Android 시스템에서 메모리 할당을 효율적으로 관리해야 합니다. Android 런타임은 메모리가 부족한 경우 가비지 수집(GC)을 트리거합니다. GC의 목적은 더 이상 유용하지 않은 객체를 정리하여 메모리를 회수하는 것입니다.

다음 3단계로 진행됩니다.

  1. GC 루트에서 메모리에 있는 모든 객체 참조를 나열하여 GC 루트의 참조가 있는 활성 객체를 표시합니다.
  2. 나열되지 않아 표시된 모든 객체는 메모리에서 지워집니다.
  3. 살아있는 객체를 다시정렬합니다.

간단히 말해서, 사용자에게 서비스를 제공하는 모든 것을 메모리에 기록해야 하며, 리소스를 확보하기 위해 메모리에서 모든 것을 지워야 합니다.
그러나 사용되지 않는 객체가 사용되는 객체에서 어떻게든 참조되는 좋지 않은 코드로 인해 GC는 사용되지 않은 객체를 유용한 객체로 표시하고 객체를 제거할 수 없게 됩니다. 이를 메모리 누수라고합니다.


왜 메모리 누수는 좋지 않은가?

어떤 객체도 오랫동안 메모리에 기록되어야 합니다. 사용자들에게 실질적인 가치를 제공하기 위해 사용될 수 있는 소중한 자원을 차지합니다. 안드로이드의 경우 다음과 같은 문제가 발생합니다.

1) 메모리 누수가 발생하면 사용 가능한 메모리가 부족하게 됩니다.
결과적으로 안드로이드 시스템은 빈번하게 GC이벤트를 호출합니다. GC이벤트는 모든 이벤트를 멈추게 합니다. GC가 발생하면 UI렌더링과 이벤트 처리가 중단됩니다. 안드로이드는 화면을 16ms로 그립니다. GC가 오래 걸리면 안드로이드는 프레임을 잃어버리기 시작합니다. 일반적으로, 100~200ms이상일 때 사용자가 앱이 느리다는 것을 인지하게됩니다.

안드로이드에서 애플리케이션 응답은 Activity 매니저와 Window 매니저 시스템 서비스로 모니터링됩니다. 안드로이드는 다음 조건중 하나를 감시하면 특정 응용 프래그램에 대한 ANR 다이얼로그를 표시합니다.

  • 5초 내에 입력 이벤트(키 누름 또는 화면 터치 이벤트)에 대한 응답이 없음 경우.
  • BroadcastReceiver가 10초 내에 실행을 완료하지 않을 경우.

어떤 사용자도 앱이 응답지연 다이얼로그를 보고 싶어 하지 않는 것을 확신합니다.

2) 앱에 메모리 누수 있는 경우, 객체는 메모리에서 반환될 수 없습니다.
결과적으로 안드로이드 시스템은 더 많은 메모리를 요청합니다. 그러나 한계가 있습니다. 결국 시스템은 앱에 더 많은 메모리를 할당하는 것을 거부합니다. 이렇게 되면 앱은 메모리 부족으로 인해 강제 종료됩니다. 물론 강제 종료를 아무도 좋아하지 않습니다. 사용자는 당장 앱을 제거하거나 앱 리뷰를 나쁘게 줄 것입니다.

3) 메모리 누수 문제는 QA 테스트로 찾기 어렵습니다.
재현하기도 어렵습니다. 그리고 안드로이드 시스템이 메모리 할당을 거부할 때 언제 어디서나 발생할 수 있기 때문에 크래쉬 리포트로 추론하기가 어렵습니다.


메모리 누수 확인방법?

메모리 누수를 찾기 위해 GC가 어떻게 작동하는지 잘 이해해야 합니다. 코드 작성과 리뷰를 부지런히 노력해야 합니다. 그러나 안드로이드에는 일부 코드가 의심스러울 때 누수를 예측할 수 있는 유용한 도구가 있습니다.

1) Square에서 만든 Leak Canary라는 메모리 누수를 감지하는데 유용한 도구가 있습니다. 앱의 액티비티들에 대해 약한 참조를 만듭니다. (다른 객체에 감시 기능을 추가하여 커스컴 할 수도 있습니다.) 그런 다음 GC후에 참조가 지워졌는지 확인합니다. 그렇지 않으면. hprof파일로 힙을 덤프 하고 분석하여 누수가 발생했는지 확인합니다. 있는 경우 알림이 표시되고 별도의 앱을 통해 누수가 발생된 위치를 트리 형태로 표시됩니다.

개발자/테스트 빌드에만 Leak Canary를 설치하는 것이 좋습니다. 사용자 빌드를 만들기 전에 개발자와 QA가 미리 메모리 누수를 찾기 위해서입니다.

2) 안드로이드 스튜디오에는 메모리 누수를 감지하기 위한 좋은 툴이 있습니다. 앱의 일부 Activity가 누수되는 것이 의심된다면 이를 수행하면 됩니다.

  • 1단계: 컴퓨터에 기기 또는 에뮬레이터에서 디버그 모드로 실행합니다.
  • 2단계: 의심스러운 Activity로 이동하고 이전으로 돌아간 뒤 다시 실행합니다.
  • 3단계: 안드로이드 모니터 창의 메모리 섹션에서 GC 시작(Initiate GC) 버튼을 누릅니다. 그 후 Java 힙 덤프(Dump Java Heap) 버튼을 누릅니다.
  • 4단계: Java 덤프 버튼을 누르면 안드로이드 스튜디오에서 덤프 된. hprof 파일을 엽니다. hprof파일 뷰어에는 메모리 누수를 확인할 수 있는 몇 가지 방법이 있습니다. 오른쪽 상단에 있는 Analyzer Tasks 도구를 사용하여 누수되는 Activity를 자동으로 탐지할 수 있습니다. 또는 왼쪽 상단 Class List View를 Package Tree View로 변경하여 Destory 해야 하는 Activity를 찾을 수 있습니다. Activity 객체의 총개수를 확인하세요. 인스턴스가 하나 이상 있으면 누수가 있음을 의미합니다.
  • 5단계: 누수되는 Activity를 찾았다면 하단의 참조 트리(reference tree) 창에서 Activity를 참조하고 있는 객체를 찾으세요.

더 많은 정보는 HPROF Viewer and Analyzer에서 확인하실 수 있습니다.


일반적인 누수의 패턴은?

안드로이드에서 메모리 누수가 발생하는 이유는 여러 가지가 있습니다. 요약하면 3가지 카테고리로 나눌 수 있습니다.

  1. 정적 참조에 대한 Activity 누수
  2. 작업 스레드에 대한 Activity 누수
  3. 스레드 자체 누수

Github의 SinsOfMemoryLeaks에서 다양한 방식으로 메모리 누수를 시키는 간단한 앱을 만들었습니다.

LEAK 브랜치에서는 메모리 누수가 되는 코드를 볼 수 있습니다. 앞에서 언급한 안드로이드 스튜디오에서 모니터링 툴을 통해 누수를 추적할 수도 있습니다. FIXED 브랜치에서는 누수가 어떻게 수정되었는지 확인할 수 있습니다. 확신이 들지 않는다면 앞서 언급한 도구를 사용하여 실제로 수정되었는지 확인할 수 있습니다. 두 가지 브랜치는 서로 다른 앱 ID이기 때문에 동일한 기기에 설치하여 나란히 사용할 수 있습니다.

다양한 원인을 3가지 카테고리로 나누었는데 하나씩 알아보겠습니다.


정적 참조에 대한 Activity 누수

정적 참조는 앱이 메모리에 있는 한 계속 유지됩니다. Activity는 일반적으로 여러 번 파괴되고 다시 생성되는 생명주기를 가지고 있습니다. 정적 참조에서 Activity를 참조하는 경우 Activity는 생명주기에 의해 Destory 된 후에 GC 되지 않습니다. Activity는 콘텐츠에 따라 수 킬로 바이트에서 많은 경우 메가바이트까지 다양합니다. 복잡한 뷰 계층 구조나 고해상도의 이미지의 경우 많은 양의 메모리가 누수될 수 있습니다.

이 카테고리에서는 다음과 같은 항목이 있습니다.

정적 뷰에 Activity를 참조

public class LeakActivityToStaticViewActivity extends AppCompatActivity {
  
    static TextView label;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        label = new TextView(this);
        label.setText(getString(R.string.leak_explanation_static_view, getString(R.string.instruction_check_for_leaks)));

        setContentView(label);
    }
}

정적 변수에 Activity를 참조

public class LeakActivityToStaticVariableActivity extends AppCompatActivity {
    static Activity activity = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_context_to_static_variable);
        TextView textView = (TextView) findViewById(R.id.textView);

        if (activity == null) {
            activity = this;
        }
    }
}

싱글톤 객체에 Activity를 참조

public class LeakActivityToSingletonActivity extends AppCompatActivity {

    SomeSingletonManager someSingletonManager = null;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_context_to_singleton);
        
        someSingletonManager = SomeSingletonManager.getInstance(this);
    }
}

Activity의 내부 클래스를 정적으로 참조

public class LeakActivityToStaticInnerClassActivity extends AppCompatActivity {

    private static SomeInnerClass someInnerClass;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_static_reference_to_inner_class);

        if (someInnerClass == null) {
            someInnerClass = new SomeInnerClass();
        }
    }

    class SomeInnerClass {
    }
}


작업 스레드에 대한 Activity 누수

Activity는 작업 스레드보다 오래 지속될 수 있습니다. Activity보다 더 오래 작업하는 스레드에서 Activity를 참조하면 누수가 발생합니다. 이 카테고리에도 다음과 같은 몇 가지 항목이 있습니다.

스레드에서 Activity 참조

public class LeakActivityToThreadActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_to_runnable);

        new MyThread().start();
    }
    
    private class MyThread extends Thread {
        @Override
        public void run() {
            while (true) {
                SystemClock.sleep(1000);
            }
        }
    }
}

Handler에서 Activity 참조

public class LeakActivityToHandlerActivity extends AppCompatActivity {

    private final Handler mLeakyHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
             //work
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_to_handler);

        mLeakyHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                //work
            }
        }, 1000 * 60 * 10);
    }
}

AsyncTask에서 Activity참조

스레드에서 Activity 참조와 동일하게 AsyncTask의 기술인 스레드풀, ExecutorService에도 동일한 원칙이 적용됩니다.

public class LeakActivityToAsyncTaskActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                while (true) {
                    SystemClock.sleep(1000);
                }
            }
        }.execute();
    }
}


스레드 자체 누수

Activity에서 스레드를 시작할 때마다 스레드를 직접 관리해야 합니다. 스레드는 Activity보다 오래 작업할 수 있기 때문에 Activity가 소멸되면 스레드를 중지시켜야 합니다. 이렇게 하지 않으면 스레드가 누수될 위험이 있습니다.

public class LeakThreadsActivity extends AppCompatActivity {
    private LeakedThread mThread;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_thread);

        mThread = new LeakedThread();
        mThread.start();
    }

    private static class LeakedThread extends Thread {
        private boolean mRunning = false;

        @Override
        public void run() {
            mRunning = true;
            while (mRunning) {
                SystemClock.sleep(1000);
            }
        }

        public void close() {
            mRunning = false;
        }
    }
}


까다로운 메모리 누수

이상적으로 메모리 누수를 일으키는 코드를 작성하지 말아야 하며 존재하는 메모리 누수 문제를 수정해야 합니다. 그러나 실제로 다른 작업으로 인해 메모리 누수 수정의 우선순위를 판단하기 힘든데, 다음 3가지 목록을 통해 심각도를 평가할 수 있습니다.

  1. 누수된 메모리는 얼마나 큰가?
    모든 메모리 누수는 동일하지 않습니다. 일부는 몇 킬로바이트만 누설합니다. 일부는 많은 양의 메가바이트까지 유출할 수 있습니다. 앞서 언급한 도구를 사용하여 누설되는 메모리 크기를 측정하여 사용자의 기기에서 얼마나 중요한 용량인지 여부를 통해 결정할 수 있습니다.

  2. 누수된 객체는 얼마나 오랫동안 메모리에 상주하는가?
    스레드를 통한 누수는 스레드의 작업이 완료될 때까지 지속됩니다. 스레드가 최악의 시나리오에서 얼마나 오랫동안 지속되는지 검사를 해야 합니다. 예제에서 스레드는 무한루프이기 때문에 누수되는 객체는 영원히 메모리에 상주합니다. 실제로 대부분의 스레드가 파일 시스템에 액세스 하거나 네트워크 호출을 하는 등의 작업을 수행하기 때문에 일반적으로 시간이 제한되어 있는 짧은 시간일 것입니다. 발생할 수 있는 최대 시간은 메모리 누수를 수정의 우선순위를 결정할 때 고려해야 할 사항입니다.

  3. 얼마나 많은 객체가 누수되는가?
    예제의 정적 참조와 같이 메모리 누수는 하나의 객체에서만 나타납니다. 새로운 Activity가 생성되자 말자, 레퍼런스는 새로운 Activity를 참조되기 시작됩니다. 이전 Activity는 GC수집 대상이 됩니다. 따라서 최대 누출은 개수는 Activity 인스턴스 한 개입니다. 그러나 누수가 있는 경우 새 객체가 생성될 때마다 Activity 인스턴스 개수가 늘어납니다. 예제에서도 Activity가 생성될 때마다 스레드에 Activity에 참조가 걸려 누수가 발생됩니다. 따라서 기기를 20번 회전시키는 동안 20개의 스레드가 누수됩니다. 새로운 인스턴스가 GC에 의해 정리되지 않는 다면 사용 가능한 모든 메모리가 점점 줄어들기 때문에 정말로 나쁜 상황이 생길 수 있습니다.


고치거나 피하는 방법은?

Activity 클래스에서 정적 변수를 사용할 때 매우 주의해야 합니다. 정적 변수가 Activity를 직접 또는 간접적으로 참조할 가능성이 있는 경우 onDestory에서 참조를 끊어야 합니다. Manager 인스턴스 또는 싱글톤 객체에 리스너로 Activity를 전달할 때, 전달한 Activity 인스턴스로 다른 객체가 무엇을 하는지 알고 있어야 합니다. 필요한 경우 onDestory에서 리스너를 null로 설정하세요.

public class LeakActivityToSingletonActivity extends AppCompatActivity {

    SomeSingletonManager someSingletonManager = null;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_context_to_singleton);
        
        someSingletonManager = SomeSingletonManager.getInstance(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        someSingletonManager.unregister(this);
    }
}

Activity 클래스에 내부 클래스를 만들 때 가능한 정적으로 만듭니다. 내부 클래스와 익명 클래스에는 암시적 참조가 있습니다. 따라서 내부/익명 클래스의 인스턴스가 포함된 클래스보다 오랫동안 유지되면 문제가 발생합니다. 누수의 위험을 피하기 위해 내부/익명 클래스가 아닌 정적 클래스를 사용하면 됩니다.

public class LeakThreadsActivity extends AppCompatActivity {
    private LeakedThread mThread;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak_thread);

        mThread = new LeakedThread();
        mThread.start();
    }

    private static class LeakedThread extends Thread {

        @Override
        public void run() {
            while (!isInterrupted()) {
                SystemClock.sleep(1000);
            }
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();

        mThread.interrupt();
    }
}

싱글톤 또는 Manager클래스를 만들 경우 Listener 인스턴스의 참조를 저장하여 관리할 수 있게 해야 하며, 사용자에 의해 참조를 관리할 수 없는 경우 Listener를 WeakReference로 관리하게 합니다. WeakReference는 GC에서 해당 대상을 지우지 않고 다시 회수하지 못하게 합니다. 이 기능은 메모리 누수를 막는데 큰 도움이 되지만 참조된 객체가 필요할 때 사용하지 못하는 부작용도 있을 수 있습니다. 따라서 메모리 누수를 수정하기 위해 가장 마지막 수단으로 사용해야 합니다. Activity에서 시작한 스레드 작업은 onDestory에서 항상 종료하세요.


마치며

우리는 메모리 누수가 무엇인지, 어떻게 발생하는지, 안드로이드 시스템에서 어떤 결과가 발생하는지에 대해 알아보았습니다. 메모리 누수를 탐지하고 식별하는 도구와 안드로이드의 일반적인 메모리 누수 패턴을 검사하는 방법, 심각도를 평가하는 방법과 피하거나 수정하는 방법을 소개하였습니다. Github저장소에서 일반적인 메모리 누수 패턴 및 수정에 대한 코드 예제를 한 번씩 확인해보세요. 모두 행복한 안드로이드 앱을 만드세요:)


참고