안드로이드 연구소

[A급 개발자 되는 법] 메모리 누수(Memory Leak)를 잡자 본문

안드로이드 연구소

[A급 개발자 되는 법] 메모리 누수(Memory Leak)를 잡자

안드로이드 연구원 2023. 5. 2. 18:05

여러분들은 메모리누수에 대해서 알고 계신가요?

메모리누수를 들어보았다면 왜 잡아야한다고 생각하시는가요?

안드로이드와 메모리 누수의 관계를 알고 계신가요?

 

1. 메모리 누수란

애플리케이션에서 더 이상 사용하지 않는 객체에게도 참조를 유지하여, 해당 객체에 할당된 메모리를 회수할 수 없는 상태.

이에 대한 예시로 락커룸에 사람들이 키를 반납하지 않는 현상이 자꾸 발생하여 다른 사용자들이 락커를 이용할 수 없는 상태에 빗대서 표현합니다.

 

2. 메모리 누수가 자꾸 발생하게 되면?

- 앱 버벅거림

- 앱 중단

 

3. 메모리 누수는 왜 발생하는가?

가비지 콜렉터(GC)가 제대로 작동하지 않기 때문이다.

가비지 콜렉터는 더 이상 사용하지 않는 메모리를 자동으로 해제를 해주는 역할을 하는 아주 고마운 녀석이다.

즉, 가비지 콜렉터란 락커룸을 관리해주는 관리자다. 

 

그렇다면 (더 이상 사용하지 않는 메모리를) 자동으로 해제해주면 내가 신경 쓰고 있지 않아도 되는 것 이지 않은가?

사실 이 문제는 우리 개발자들이 잘못되게 설계를 하였기때문에 발생한다.

 

더보기

우리 개발자들은 목욕탕 주인이다.

빈 화면에 목욕탕 생태계(애플리케이션)를 만들어서 락커(메모리)와 이를 이용하는 사람들(객체)를 만들어 운영을 하고 있다.

목욕탕 카운터에 관리자(가비지 콜렉터)를 배치하여 락커룸을 이용을 다마친 사람에게 열쇠를 받고,

다시 그 열쇠를 새로온 사람들에게 열쇠를 주게 한다.

그런데 주인인 내가 아무것도 모르는 바보 손님들에게 카운터에 열쇠를 반납하는 법을 안내하지 않고 가르켜주지 않아

열쇠는 점점 없어지고 이용할 수 있는 락커룸 크기는 점점 줄어든다.

우리 관리자는 열심히 일하였음에도 불구하고 주인이 잘못하여 목욕탕이 락커룸 대비 적은 손님을 받고, 심지어 문을 닫아야할 경우가 발생한다.

 

그렇다면 우리는 어떻게 목욕탕 손님들을 관리해야할까?

 

4. 메모리 누수를 만드는 원인들과 해결방법

오늘의 핵심은 이곳이다. 우리가 목욕탕 경영을 잘못하고 있는 대표적인 예시들을 보여주고 해결방안을 선보이겠다.

 

[메모리 누수를 만드는 8가지 코드들] http://sjava.net/2016/05/번역-안드로이드-앱이-메모리-누수leak를-만드는-8가지/

위 블로그는 해외 블로그에서 대표적인 안드로이드 메모리 누수가 발생하는 원인에 대해서 서술한 내용은 잘 번역해준 고마운 블로그이다.

하지만 위 내용은 아쉽게도 자바를 기준으로 쓰였고 현재 새로 대체할 수 있는 기술들이 없었던 때이다.

이번 안드로이드 연구소에서 새로 코틀린 기준으로 서술해보겠다!

(참고로 나도 답은 정확하게는 모른다. chatGPT와 대화를 해나가는 과정과 결과를 이곳에 써보겠다!)

 

(1)정적 액티비티(Static Activities)

아래 코드는 가장 대표적인 메모리 누수를 발생시키는 원인이다.

사실 이 말고도 companion object를 사용하여 정적 변수를 만드는 것이 모두 메모리 누수를 발생시키는 원인이지만

Acitivity를 선언해주는것 말고는 크게 사용할 일이 없는듯 하다.

 

(*companion object는 java의 static처럼 Kotlin의 정적 변수를 선언해주는 역할은 한다.

정적 변수는 프로그램 시작 시 메모리에 할당되고, 프로그램 종료 시 메모리가 해제된다.)

class MyActivity : Activity() {
    companion object {
        val instance = MyActivity()
    }
}

 

그렇다면 어떻게 코드를 변경해주어어야할까?

아래처럼 MyActivity를 객체로 선언한 instance를 액티비티가 onDestory()되는 시점에

null로 초기화 시켜주는것이 포인트다.

class MyActivity : AppCompatActivity() {
    companion object {
        private var instance: MyActivity? = null

        fun getInstance(): MyActivity {
            return instance ?: MyActivity().also { 
            	instance = it 
            }
        }

        fun releaseInstance() {
            instance = null
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 액티비티 초기화
    	getInstance()
    }


    // 액티비티 생명주기 파괴될 시점
    override fun onDestroy() {
        super.onDestroy()
        releaseInstance() // 메모리 해제
    }
}

하지만 위의 코드에도 문제가 있다.

정적 변수에 담은 엑티비티를 잘 해제해주었지만 정적 변수에 선언한 것은 좋지 못한 점이다.

그렇다면 companion object를 쓰지 않고 사용할 수 있는 방법이 있을까?

 

ChatGpt: 어떻게 companion object를 사용하지 않고 엑티비티를 호출할 수 있니? 코틀린에서.

그 답은 액티비티의 Object를 생성해주어서 액티비티와 약한 참조로 연결하는 방법이다.

class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Initialize activity here
        MyObject.setActivity(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        MyObject.releaseActivity()
    }
}

object MyObject {
    private var activityRef: WeakReference<MyActivity>? = null

    fun setActivity(activity: MyActivity) {
        activityRef = WeakReference(activity)
    }

    fun releaseActivity() {
        activityRef?.clear()
        activityRef = null
    }

    fun doSomething() {
        val activity = activityRef?.get()
        if (activity != null) {
            // Use activity here
        }
    }
}

 

(2) 정적 뷰(static view)

정적뷰는 위의 예제와 마찬가지로 textview나 imageview, button같은 뷰들을 정적 변수에 담아주는 것을 말한다.

이럴 경우 "약한 참조" 또는 "변수 null 초기화"로 해결할 수 있다.  

class MyStaticView {
    companion object {
        private var viewRef: WeakReference<View>? = null

        fun setView(view: View) {
            viewRef = WeakReference(view)
        }

        fun getView(): View? {
            return viewRef?.get()
        }
    }
}
class MyActivity : AppCompatActivity() {
    private var myButton: Button? = null

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

        myButton = findViewById(R.id.my_button)
        myButton?.setOnClickListener {
            // Do something when the button is clicked
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        myButton = null
    }
}

하지만 static이나 companion object로 뷰를 생성할 일은 잘 없긴하다.

제일 간단하게 해결하는 방법은 findviewbyId보다는 extension을 사용하면 고려를 안해줘도 될 부분이다.

(*extension으로 서로의 class가 서로를 참조하는 순환 참조만 안되게하면 된다.)

 

(3) 내부클래스 - 클릭 리스너 이벤트

아래 클릭리스너 이벤트를 사용할 때 inner class는 종종 사용하고한다.

그냥 단순히 inner class를 리스너 객체를 생성하여 setOnclickListener에 담아 호출했다면

이제껏 메모리 누수가 발생하는 클릭 이벤트를 만들어주고 있었던 것이다. 

class MainActivity : AppCompatActivity() {
    private lateinit var myButton: Button

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

        myButton = findViewById(R.id.my_button)

        val myClickListener = MyClickListener()
        myButton.setOnClickListener(myClickListener)
    }

    inner class MyClickListener : View.OnClickListener {
        override fun onClick(view: View) {
            // Handle button click
        }
    }
}

아래 코드 처럼 Myclicklister안에서 inner class에서 엑티비티를 약한 참조하여

onclick() 메서드가 실행되기 이전에 엑티비티 인스턴스 활성화 여부를 체크하는 것이 포인트다.

class MainActivity : AppCompatActivity() {
    private var myClickListenerRef: WeakReference<MyClickListener>? = null

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

        val myClickListener = MyClickListener(this)
        myClickListenerRef = WeakReference(myClickListener)
        myButton.setOnClickListener(myClickListener)
    }

    inner class MyClickListener(activity: MainActivity) : View.OnClickListener {
        private val activityRef: WeakReference<MainActivity> = WeakReference(activity)

        override fun onClick(view: View) {
            val activity = activityRef.get()
            if (activity != null) {
                // Handle button click using the activity instance
            }
        }
    }
}

 

(4) 익명 클래스(Anonymous Classes)

사실 코틀린에서 익명 클래스는 없다.

인터페이스를 구현하거나 클래스를 확장할 때 즉석으로 정의되는 클래스입니다.

무슨 소리인가 싶겠지만 아래 코드를 보면 우리가 너무나도 많이 써온 코드일 것이다.

myButton.setOnClickListener(object : View.OnClickListener {
    override fun onClick(view: View) {

    }
})

여기서도 메모리 누수가 발생하고 있다니...

어떻게 해결할 수 있는지 chatGPT에게 물어보겠다.

또 정답은 엑티비티 약한 참조. 이정도면 "만능 약한 참조약"이다.

myButton.setOnClickListener(object : View.OnClickListener {

    private val activityRef: WeakReference<MyActivity> = WeakReference(this@MyActivity)

    override fun onClick(view: View) {
        val activity = activityRef.get()

        activity?.let {
            // Do something with the activity...
        }
    }
})

(5) 핸들러(Handlers)

아래 코드는 handler.postDelayed(this, 1000)를 통해 1초 뒤에 작업이 이루어지게 하는 handler를 사용하고 있다.

가장 좋은 해결책은 코루틴을 사용하는 것이다. 코루틴에 대한 연구는 다음에.

그래도 만약 핸들러를 사용한다면 해결책은 아래처럼.

더보기

Runnable은 handler인스턴스를 참조하는 클래스인데 

Runnable이 실행을 완료하기 전에 활동이 소멸되면 Handler와 Runnable이 계속 살아있어 메모리 누수가 발생한다.

class MyActivity : AppCompatActivity() {

    private val handler = Handler()

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

        handler.postDelayed(object : Runnable {
            override fun run() {
                // Do something...
                handler.postDelayed(this, 1000)
            }
        }, 1000)
    }

    override fun onDestroy() {
        super.onDestroy()

        // Cleanup resources...
    }
}

해결하기 위한 방안으로는 runnable 클래스를 만들어 약한 참조로 엑티비티를 체크한다. 

class MyActivity : AppCompatActivity() {

    private val handler = Handler()
    private val runnable = MyRunnable(this)

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

        handler.postDelayed(runnable, 1000)
    }

    override fun onDestroy() {
        super.onDestroy()
        
        handler.removeCallbacks(runnable)
    }

    private class MyRunnable(activity: MyActivity) : Runnable {

        private val activityRef: WeakReference<MyActivity> = WeakReference(activity)

        override fun run() {
            val activity = activityRef.get()

            activity?.let {
                // Do something...
                activity.handler.postDelayed(this, 1000)
            }
        }
    }
}

(6) 스레드(Thread)

핸들러와 마찬가지로 비동기에서 처리 중에 null처리를 제대로 해주지 못하면 메모리 누수가 발생한다.

하지만 쓰레드 또한 코루틴의 시대에서는 크게 사용할 이유가 없다. 가장 좋은 해결 방법은 코루틴을 사용하기.

그래도 쓰레드를 사용할 경우 해결책은 아래에

더보기

엑티비티가 onDestory될 때 thread또한 잘 null 처리 해주면 된다.

class MyActivity : AppCompatActivity() {

    private var thread: Thread? = null

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

        thread = Thread {
            while (!Thread.currentThread().isInterrupted) {
                // Do something...
            }
        }

        thread?.start()
    }

    override fun onDestroy() {
        super.onDestroy()

        thread?.interrupt()
        thread = null

        // Cleanup resources...
    }
}

 

(7) 타이머 작업

timer() 또한 쓰레드와 동작 원리가 똑같다.

종료를 제대로 해주지않는다면 백그라운드에서 계속 사용되어 메모리 누수가 발생한다.

아래 코드 처럼 onDestory()애서 timer를 null 처리 잘 해주기.

class MyActivity : AppCompatActivity() {

    private var timer: Timer? = null

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

        timer = Timer()
        timer?.scheduleAtFixedRate(object : TimerTask() {
            override fun run() {
                // Do something...
            }
        }, 0, 1000)
    }

    override fun onDestroy() {
        super.onDestroy()

        timer?.cancel()
        timer = null

        // Cleanup resources...
    }
}

 

(8) 센서 관리자(getSystemService())

 

윈도우, 푸시, 알람, 센서 디바이스 기능을 이용할 때 getSystemService()를 사용한다면

아래 코드 처럼 onDestory()되었을 때 해당 인스턴스를 null로 초기화 잘해주자.

class MyActivity : AppCompatActivity() {

    private var mWindowManager: WindowManager? = null

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

        mWindowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
    }

    override fun onDestroy() {
        super.onDestroy()

        mWindowManager = null

        // Cleanup resources...
    }
}

 

5. 요즘 메모리 누수를 만드는 원인들과 해결방법

1) 프레그먼트에서 view extension 혹은 viewbinding을 사용할 경우

 

프레그먼트내의 뷰들의 생명주기가 더 오래 유지가 되기 때문에 초기화를 해주지 못하면 메모리 누수는 필수 불가결하다.

프레그먼트에서 extension을 사용할 경우에는 초기화가 힘들어 viewbiding 사용을 권장한다.

 

viewbinding을 사용한다면 프래그먼트 파괴될 때인 onDestoryview()에서 바인딩한 인스턴스를 초기화한다.  

override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
}

나중에 배우게 될 AAC LIfecycle을 적용한다면 효과적으로 프레그먼트나 엑티비티에서

onDestory 가 될 경우 인스턴스 초기화를 잘 관리 해줄 수 있다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    viewLifecycleOwner.lifecycle.addObserver(object : LifecycleObserver {
        @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
        fun onDestroyView() {
            _binding = null
        }
    })
    // Use the view here
}

 

2) ViewModel에서 activity 참조할 경우

ViewModel은 내부의 액티비티의 인스턴스보다 더 오랜 수명을 가질 수 있기 때문에 메모리 누수가 일어날 수 있습니다.

그래서 viewModel에서는 파라미터로 불러온 activity를 약한 참조로 호출하고

viewModel이 oncleared() 될 때 인스턴스를 clear해준다.

class MyViewModel(activity: Activity) : ViewModel() {

    private val activityRef = WeakReference(activity)

    // Use activityRef.get() to access the activity

    override fun onCleared() {
        super.onCleared()
        activityRef.clear()
    }
}

3) ViewModel의 livedata 

 

엑티비티가 소멸되면 메모리 누수를 방지하기 위해 viewModel.data.removeObservers()를 사용하여 관찰자가 제거됩니다.

class MyActivity : AppCompatActivity() {

    private lateinit var viewModel: MyViewModel

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

        // Initialize ViewModel
        viewModel = ViewModelProvider(this).get(MyViewModel::class.java)

        // Observe LiveData
        viewModel.data.observe(this, Observer { data ->
            // Update UI with new data
            textView.text = data
        })

        // Fetch data
        viewModel.fetchData()
    }

    override fun onDestroy() {
        super.onDestroy()

        // Remove observer to prevent memory leaks
        viewModel.data.removeObservers(this)
    }
}

ViewModel의 onCleared() 메서드는 ViewModel이 더 이상 필요하지 않을 때 네트워크 연결이나 데이터베이스 참조와 같은 리소스를 정리하는 데 사용할 수 있습니다.

class MyViewModel : ViewModel() {

    private val _data = MutableLiveData<String>()
    val data: LiveData<String>
        get() = _data

    fun fetchData() {
        // Fetch data from repository or service and update _data
        _data.value = "New data"
    }

    override fun onCleared() {
        super.onCleared()
        // Clean up any resources here
    }
}

4) webview

웹뷰를 사용할 때도 onDestory()가 되면은 webview 인스턴스를 초기화 해주어야한다.

class MyActivity : AppCompatActivity() {

    private var webView: WebView? = null

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

        webView = findViewById(R.id.web_view)
        webView?.loadUrl("https://www.example.com")
    }

    override fun onDestroy() {
        super.onDestroy()
        webView?.apply {
            stopLoading()
            destroy()
        }
        webView = null
    }
}

 

오늘은 대표적인 메모리 누수들에 대해서 알아보았다.

하지만 이 외에도 메모리 누수가 발생할 수 있는 원인은 너무나도 많아서 이 예제들 이외의 내용들이 있을 수 도 있다.

그렇다면 나의 프로젝트에서 어디서 메모리 누수가 발생하고 있다는 것을 알고 고칠 수 있을까?

그 내용은 다음 내용에 계속!

Comments