[안드로이드] 메모리 누수

hee09·2022년 9월 21일
0

메모리 누수란

모든 앱은 작업을 수행하기 위한 리소스로 메모리가 필요합니다. 각 앱에 충분한 메모리가 존재하도록 안드로이드 시스템은 메모리 할당을 효율적으로 관리해야 합니다. 이를 위해서 런타임시(실행시) 메모리가 부족하다면 안드로이드에서는 가비지 컬렉터(Garbage Collection = GC)를 트리거합니다. GC의 목적은 더 이상 유용하지 않은(사용되지 않는) 객체를 정리하여 메모리를 회수하는 것입니다. GC는 다음과 같은 3단계로 과정이 이루어 집니다.

  1. GC 루트에서 메모리의 모든 객체를 탐색하고 현재 참조가 되고 있는 객체는 활성화된 객체로 표시합니다.

  2. 활성화가 표시 되지 않은 객체(참조되지 않는 객체)를 메모리에서 지웁니다.

  3. 남은 객체들은 다시 정렬합니다.

즉, 요약하자면 사용자에게 제공되는 모든 것은 메모리에 보관해야 하고 참조되지 않는 나머지는 리소스를 확보하기 위해서 메모리에서 지워야 합니다. 그러나 잘못된 방식으로 코딩해서 사용되지 않는 객체가 현재 사용중인 객체에서 참조될 경우 GC는 사용되지 않는 객체를 유용한 객체로 표시하여 제거할 수 없게됩니다. 이를 메모리 누수 라고 합니다.


메모리 누수의 영향

모든 객체는 자신이 참조되는 동안만 메모리에 남아있어야 합니다. 그렇지 않으면 메모리 누수가 발생해서 리소스 낭비를 하게 됩니다. 특히 안드로이드에서는 다음과 같은 문제가 발생합니다.

  1. 메모리 누수가 발생하면 사용 가능한 메모리가 줄어듭니다. 결과적으로 안드로이드 시스템은 더 자주 GC 이벤트를 트리거합니다. GC 이벤트는 앱을 중단시키는 이벤트(strop-the-world events)입니다. 즉, GC가 발생하면 UI의 렌더링과 이벤트 처리가 중단됩니다. 안드로이드는 16ms의 속도로 화면을 그립니다. GC가 그것보다 오래 걸리면 안드로이드는 프레임을 잃기 시작합니다. 일반적으로 100 ~ 200ms의 속도가 되면 사용자는 앱이 느리다고 인식합니다.

안드로이드에서 앱의 응답성은 Activity Manager 및 Window Manager 시스템 서비스에 의해 모니터링됩니다. 안드로이드는 다음 조건 중 하나를 감지하면 해당 앱에 대한 ANR 다이얼로그를 표시합니다. 즉, 메모리 누수로 인해 아래의 문제가 발생하게 되어 시스템은 ANR(Android Not Responding) 에러를 던집니다.

  • 5초 이내에 사용자의 입력 이벤트(예: 키 누름 또는 화면 터치 이벤트)에 대한 응답이 없을 경우

  • BroadcastReceiver가 10초 이내에 실행을 완료하지 않을 경우

  1. 앱에 메모리 누수가 있다면 사용하지 않는 객체에 메모리를 요구할 수 없습니다. 결과적으로는 안드로이드 시스템에게 더 많은 메모리를 요청할 것입니다. 하지만 한계가 있기에 시스템은 결국 해당 앱에 더 많은 메모리를 할당하는 것을 거부하게 됩니다. 이렇게 되면 사용자는 메모리 부족 오류가 발생하게 됩니다.

  2. 메모리 누수 문제는 대부분의 QA/테스트에서 찾기 어렵습니다. 그리고 충돌 보고서는 안드로이드 시스템에 의해 메모리 할당이 거부될 때 어디서나 발생할 수 있기 때문에 일반적으로 추론하기 어렵습니다.


메모리 누수를 탐지하는 방법

메모리 누수를 찾으려면 GC의 작동 방식에 대한 깊은 이해가 필요합니다. 이러한 이유로 안드로이드에서 메모리 누수를 식별하거나 누수 여부를 확인하는 데 여러가지 도구가 존재합니다.


Leak canary

Leak canary는 OkHttp, Retrofit 등의 라이브러리를 만든 Square 사에서 앱의 메모리 누수를 찾기 위해 만들어진 라이브러리입니다. 앱에서 액티비티에 대하여 약한 참조를 만듭니다. 그런 다음 GC 후에 참조가 지워졌는지 확인하여 메모리 누수 여부를 판단하는 것입니다. 메모리 누수가 발생한다면 알림이 표시되고 별도로 생성된 앱에서 누수가 어떻게 발생했는지 트리 형태로 표시됩니다.


LeakCanart 동작 방식

  1. retained(보유) 객체 감지

LeakCanary는 Android 생명 주기를 알고 Activity, Fragment, View, ViewModel이 파괴됐을 때 자동으로 감지하여 이를 ObjectWatcher에 전달합니다. 그리고 5초 동안 대기하다가 가비지 컬렉터가 실행된 후에도 해당 참조가 ObjectWatcher에서 지워지지 않는다면 메모리 누수로 간주하고 이를 Logcat에 기록합니다.

D LeakCanary: Watching instance of com.example.leakcanary.MainActivity
  (Activity received Activity#onDestroy() callback) 

... 5 seconds later ...

D LeakCanary: Scheduling check for retained objects because found new object
  retained

이후 힙을 덤프하기 전에 retained 객체의 수를 알림과 함께 표시합니다.

  1. 힙 덤프

retained 객체의 개수가 임계값에 도달하면 LeakCanary는 Java 힙 메모리 영역을 안드로이드 파일 시스템에 저장되어 있는 .hprof 파일에 덤프합니다. 힙을 덤프하면 알림을 표시해주며 짧은 시간 동안 앱이 정지됩니다.

  1. 힙 분석

LeakCanary는 Shar라는 힙 분석 툴을 사용해서 .hprof 파일을 분서갛고, 덤프된 힙에서 retained 객체를 찾습니다.

분석이 완료되면 알림과 로그캣을 통해 결과를 표시합니다.

====================================
HEAP ANALYSIS RESULT
====================================
0 APPLICATION LEAKS

====================================
1 LIBRARY LEAK

...
┬───
│ GC Root: Local variable in native code
│
...
  1. 누수 분류

LeakCanary는 앱에서 발견된 누수를 어플리케이션 누수와 라이브러리 누수 두 가지 범주로 구분합니다. 라이브러리 누수는 제어할 수 없는 타사 코드의 버그로 인해 발생하는 누수입니다. 이 누수는 앱에 영향을 미치고 있지만 타사의 코드를 수정하는 것은 개발자가 제어할 수 없으므로 LeakCanary에서 분리합니다.


LeakCanary 사용 예제

우선 Leak canary를 사용하기 위해서는 module 레벨의 build.gradle 파일에 아래와 같이 설정합니다. 아래와 같이 설정하고 앱을 실행하면 Leaks라는 앱이 생성됩니다.

dependencies {
	debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1"
}

누수의 원인은 여러가지 있지만, 대표적인 예제 중 하나인 정적 뷰를 선언하고 해당 뷰를 생성할 때 액티비티를 인자로 받는 코드입니다. 이와 같이 코드를 작성하면 액티비티는 생명주기에 따라서 destroy될 때 GC에서 이를 제거해야 하지만, 정적 변수인 textView에 의해서 참조되고 있기에 GC에서 이를 제거하지 못합니다. 결과적으로 메모리 누수가 발생하는 것입니다.

MainActivity
class MainActivity : AppCompatActivity() {
    companion object {
    	var textView: TextView? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        textView = TextView(this)
    }
}

이와 같이 코드를 작성하고 액티비티를 계속해서 종료하다 보면 위에서 나온 과정이 이루어지며 LeakCanary에서 메모리 누수를 분석하고 누수가 일어난 곳을 보여줍니다.

요약 화면분석 화면

로그캣을 확인하면 textView에서 문제가 발생하였다는 것을 확인할 수 있습니다.


일반적인 메모리 누수 패턴

안드로이드에서 메모리 누수를 일으킬 수 있는 방법에는 여러 가지가 있는데, 이를 요약하면 크게 네 가지 범주가 있습니다.

  1. 정적 참조에 액티비티 누출

  2. 작업자 스레드에 액티비티 누출

  3. 스레드 자체 누출

  4. ViewModel에 액티비티 또는 뷰 누출


정적 참조에 액티비티 누출

정적 참조는 앱이 메모리에 있는 한 지속됩니다. 액티비티는 일반적으로 앱의 수명 주기 동안 여러 번 파괴되고 다시 생성되는 생명 주기를 가집니다. 정적 참조에서 직접 또는 간접적으로 액티비티를 참조하는 경우 액티비티가 소멸된 후 GC가 이루어지지 않습니다. 액티비티는 코드에 따라서 몇 킬로바이트에서 수 메가바이트까지 다양한데, 이러한 액티비티가 계속해서 메모리에 남게 되는 것입니다. 만약 뷰 계층이 많거나 고해상도 이미지가 있는 경우 많은 양의 메모리가 누수될 수 있습니다.

정적 view에 의한 액티비티 누출

class LeakActivityToStaticViewActivity : AppCompatActivity() {

    // static view will leak the activity. To fix it, make it non-static
    companion object {
        var label: TextView? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        label = TextView(this)
        label?.text = getString(R.string.leak_explanation_static_view, getString(R.string.instruction_check_for_leaks)))

        setContentView(label)
    }
}

정적 변수에 의한 액티비티 누출

class LeakActivityToStaticVariableActivity : AppCompatActivity() {
    // static reference to the activity will leak the activity.
    // To fix it, set it null onDestroy or use weak reference
    companion object {
        var activity: AppCompatActivity? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_leak_context_to_static_variable)
        val textView: TextView = findViewById<TextView>(R.id.textView)

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

싱글톤 객체에 의한 액티비티 누출

class LeakActivityToSingletonActivity : AppCompatActivity() {
    companion object {
        var someSingletonManager: SomeSingletonManager = null;
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_leak_context_to_singleton);
        // fix option 1: instead of passing `this` to getInstance(), pass getApplicationContext()
        someSingletonManager = SomeSingletonManager.getInstance(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.e("FRANK", "onDestroy: called")
        // fix option 2: uncomment the following line to fix the leak
        //someSingletonManager.unregister(this)
    }
}

액티비티 inner 클래스의 정적 인스턴스에 의한 액티비티 누출

  • Kotlin의 내부 클래스는 외부 클래스에 대한 참조를 가지고 있습니다
class LeakActivityToStaticInnerClassActivity : AppCompatActivity() {
    companion object {
        private var someInnerClass: SomeInnerClass? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_leak_static_reference_to_inner_class)

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

    inner class SomeInnerClass {

    }
}

작업자 스레드에 액티비티 누출

작업자 스레드는 액티비티보다 오래 지속될 수도 있습니다. 액티비티보다 오래 지속되는 작업자 스레드에서 직접 또는 간접적으로 액티비티를 참조하는 경우 액티비티 객체도 누출됩니다.

스레드에 액티비티 누출

class LeakActivityToThreadActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_leak_to_runnable)

        MyThread().start()
    }

    // non-static anonymous classes hold an implicit reference to their enclosing class.
    // Fix is to make it static. Also, close thread in activity onDestroy() to avoid thread leak. See `LeakThreadsActivity`
    private inner class MyThread : Thread() {
        override fun run() {
            while (true) {
                SystemClock.sleep(1000)
            }
        }
    }
}

핸들러에 액티비티 누출

class LeakActivityToHandlerActivity : AppCompatActivity() {

    private val mLeakyHandler = object: Handler() {
        override fun handleMessage(msg: Message) {
            Log.d("Test", "handle message")
        }
    }

    override void onCreate(savedInstanceState: Bundle) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_leak_to_handler)

        // Post a message and delay its execution for 10 minutes.
        mLeakyHandler.postDelayed(object: Runnable {
            override fun run() {
                Log.e("FRANK", "in run()")
            }
        }, 1000 * 60 * 10)
    }
}

이와 같은 원리는 스레드를 사용하는 thread pool 또는 ExecutorService 에도 적용됩니다.


스레드 스스로 누출

액티비티에서 작업자 스레드를 시작할 때마다 해당 스레드를 직접 관리해야 합니다. 작업자 스레드는 액티비티보다 오래 지속될 수 있으므로, 액티비티가 destroy될 때 스레드를 적절하게 중지해야 합니다. 그렇지 않으면 작업자 스레드 자체가 누출될 위험이 있습니다.

class MainActivity : AppCompatActivity() {
    companion object {
        class LeakedThread : Thread() {
            var mRunning = false

            override fun run() {
                mRunning = true
                while(mRunning) {
                    sleep(1000)
                }
            }

            fun close() {
                mRunning = false
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val mThread = LeakedThread()
        mThread.start()
    }

    override fun onDestroy() {
        super.onDestroy()

        // uncomment the line to fix the thread leak
        //mThread.close();
    }
}

ViewModel에서 액티비티 또는 뷰 누출

ViewModel은 뷰 또는 LifecycleOwners의 특정 인스턴스화(Activity, Fragment 등)보다 오래 지속되도록 설계되어 있습니다. 따라서 ViewModel에서 해당 객체에 대한 참조를 가지면 메모리 누수가 발생할 수 있습니다.

ViewModel

class MainViewModel: ViewModel() {
    var activity: MainActivity? = null
}

Activity

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel = ViewModelProvider(this)[MainViewModel::class.java]
        viewModel.activity = this
    }
}

결론

자바와 코틀린에서 GC를 통해 객체를 관리하더라도 위와 같은 이유로 메모리 누수가 발생할 수 있습니다. 이는 앱의 성능을 낮추고 심각하면 ANR 오류까지 발생시킬 수 있습니다. 따라서 가끔 LeakCanary 또는 안드로이드에서 지원하는 방법을 통해서 메모리 누수를 파악하면 좋을 것 같습니다.


참고 및 참조
틀린 부분은 댓글로 남겨주시면 바로 수정하겠습니다..!!

Android의 메모리 누수 패턴
안드로이드의 메모리 누수 패턴
LeakCanary로 메모리릭 잡기

profile
되새기기 위해 기록

0개의 댓글