최근들어 개인 앱 하나 만들라고 준비중이었는데, 오랜만에 하는 개인 프로젝트라 좀 더 완벽하게 하고싶어졌다.
여태까지 경험을 통해 쌓은 코드 컨벤션이나 좀 더 수월해진 기술스택을 적재적소에 사용하고 가독성을 훨씬 높히려 했다.
동시에 최적화도 해보고 싶어 가장 효율적인 객체 생성 및 호출, 확실한 옵저빙 해제, 싱글톤 지양 등 메모리 관리에 대해서도 관심이 높았었는데 매번 글로만 "이거 이렇게 하면 메모리 누수나요~"하는 것만 봤지 그게 정말일지 궁금했다.
그러다 Android Studio에서 제공하는 Profiler를 알게 되었다. 특별히 설치하는것이 없어 바로 볼 수 있었는데 CPU, MEMORY, ENERGY 이렇게 3가지 항목 중 MEMORY영역을 보니 앱을 실행할 때 마다 메모리 값이 달라져 기준값을 잡기가 모호했다. 뭐 물론 내가 미숙한 탓이겠지..
다시 구글링을 하다 라이브러리인 leakcanary을 찾게 되었다. 일반적인 라이브러리가 아니라 프로그램의 Low Level에 중점적인 포지션이라 과연 잘 동작하는 모듈인지 의심이 갔다. 개발자를 확인해보니 그 유명한 square사, Android에서 서버연동 앱을 개발해봤다면 한 번쯤은 들어보거나 써봤을 Okhttp, Retrofit와 이미지처리 라이브러리의 양대산맥 중 하나인 Picasso를 제공하는 회사였다. 근데 Glide와 비교글을 봤는데 Glide가 더 좋다고 하더라.
메모리 관련이라 적용하기 빡셀줄 알았는데 의외로 너무 간단했다.
Latest Version 2.9.1
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
이전버전은 Application Class에 추가로 작업할 것들이 있던 거 같은데 최신 버전은 없다. 추가로 구버전으로 적용하면 Android 12에서는 메니페스트 액티비티의 exported를 명시해야 하는데 leakcanary에서 쓰이는 액티비티에서 명시를 안해 빌드 에러가 난다. 최신 버전을 사용하자.
적용하면서 알았는데 implementation의 종류가 많더라. Test용, realease용, debug용 등 선택할 수 있었고 위에 쓰인 debugImplementation는 debug모드에서만 적용된다. 이렇게 하나 또 배워가용~
이제 재밌는 메모리 누수를 유발해보자
Fragment를 사용했다면 아주 많이 보았을 메모리 관련 사항이다. 다들 포스트에서 Fragment와 ViewBinding을 사용하면 onDestroyView에서 binding을 null시킨다. 이유는 두 가지가 있다고 말하는데 하나는 Fragment의 lifecycle이 Fragment의 View보다 오래 살기 때문에 onDestroyView가 실행되어도 onDestroy는 실행이 안 되는 경우가 있다. 예를 들면 A Fragment에서 B Fragment로 이동하면 startActivity를 호출하고 finish를 안하면 전 Activity가 살아있듯이 A Fragment도 살아있다. 이 때 Fragment는 View만 날리게 되고 Fragment자체 인스턴스는 살아있다.
뭔가 이상하다. ViewBinding은 Fragment가 아니라 View니까 Fragment가 혼자 뭘 하던간에 View가 파괴되면 자연스레 ViewBinding도 파괴되는게 정상아닌가..? 이에 대한 결과는 밑에서 나온다.
두번째 이유는 OS에서 Fragment를 관리할 때 재사용을 위해 View가 없어져도 어딘가에 보관하고 있다고 한단다. 왜 접근도 못할 인스턴스를 보관하는지는 만든사람만 알겠지만 아무튼 이 이유라면 View가 살아있는게 납득된다. 일단 구글 공식 문서에도 binding을 null시키는 모습이 보인다만 이유는 안나와있다.
암튼! 나는 메모리 누수를 위해 반대로 해야되니 null을 제거한 상태로 Fragment의 View만 파괴시켜 보겠다
오옷..!!!! 진짜인가 보네?
1 2번째 로그는 무시하고 3번째 로그에서 View만 파괴하고 나니 무언가 튀어나왔다. 읽어보면 "1개의 객체가 남아있어서, 아직 Heap Memory가 덤핑되지 않았다..?" 사실 뒤에는 무슨 말인지 정확이 모르겠다만 대충 어떤 하나의 객체의 강한 참조때문에 메모리에서 제거되지 못했다는 뜻으로 받아진다.
이렇게 메모리 누수가 감지되면 노티로 뭔가 날라온다. 그리고 노티를 클릭하면 알아서 분석해준다.
분석결과다. 지금 BaseFragment에 onDestoryView로 binding을 null을 시켜 경로가 BaseFragment로 잡혀있다. 완벽하게 binding을 null시키지 않아서 발생한 메모리 누수다. 남아있는 객체 수가 696개나 된다 ㄷㄷ 옆에 메모리 크기도 나와있어 신기하다. 추가 설명을 보면 누수를 방지하려면 뷰에 대한 참조를 지워야 한다고 나와있다. 즉 View는 파괴되었지만 ViewBinding은 살아있고 Fragment가 이 ViewBinding을 참고하고 있는 것 같다. 이로써 첫번째 이유가 라이브러리가 정상적으로 동작했다는 가정하에 증명이 된다. 그럼 여기서 다시 A Fragment로 돌아가면?
B Fragment에서 뒤로가기를 눌렀다. 그러면 B Fragment는 View뿐만 아니라 Fragment자체가 사라지게 된다. 다시 A Fragment로 돌아오게 되고 A Fragment는 원래 살아있었기에 onCreate가 아닌 onCreateView가 호출되고 onCreateView에 있는 binding을 초기화하는 코드가 실행된다. 이로써 전 binding이 참조하고 있는 주소값은 UnReachable상태가 되고 JVM의 메모리 회수 조건에 맞아 쓸어가게 되어 위 사진과 같이 garbage처리가 됐다고 나온다.
코드상으로 보게 되면 B Fragment로 이동했을 시점부터 binding은 onCreateView에서 초기화 되기 때문에 UnReachable상태가 된다. 왜 일반적인 Class생성이나 Class안의 method들은 재 초시화 전에 알아서 없어지면서 이건 처리 못하는 건지 모르겠다. 아마 Android가 JVM을 만들었다면 됐었을래나?
아 원래대로 binding에 null로 초기화하는 작업을 해놓으면 메모리 누수가 발생하지 않는다!
정말로 binding을 null시켜주지 않은 경우 view가 파괴되면 Fragment가 binding을 참조하고 있어 메모리 누수가 발생된다.
.
.
재밌는데 하나 더 해볼까?
이 또한 널리 알려진 메모리 누수 예제이다.
애는 컴파일 단계에서부터 하지 말라고 경고를 준다. 결과는 당연히 Memory Leak 발생
package com.example.memory.common
fun isSecondWeek(context: Context): Boolean {
val pref = SleepDiaryPreferenceManager()
return pref.getSecondWeek(context)
}
간혹 이런식으로 Common Class에 함수를 그대로 뚫어논 경우가 있다. 이런식으로 Activity나 Fragment의 생명주기에 벗어나
쓰이는 함수들에서 인자로 lifecycle과 관련된 (ex - context) 값을 넘기면 메모리 누수가 발생한다.
확인 전엔 생각지도 못했다. 왜냐면 해당 함수는 Shared에서 값을 불러오고 컴포넌트가 Destroy되기 전에 종료되니 영향이 없을 줄 알았다. 하지만 이는 메모리 누수를 발생시켰다.
이유를 곰곰히 생각해 보았다. 내가 아는 지식에선 함수가 호출되면 메모리에 올라가고 해당 함수가 가진 기능을 완료하거나 return이 호출되면 메모리에서 해제되는 방식이다.
하지만 곧바로 메모리에서 해제되지 않고 남아있으니 컴포넌트가 destroy되도 context를 붙잡고 있어 메모리 누수가 발생한거니
함수가 종료되더라도 바로 메모리에서 내려가지 않는건가 보다 추측한다. 하긴 함수 하나 종료될 때 마다 GC가 발동되면 그 또한 리소스 낭비가 될 것 같긴 하다.!
이 상황에서는 Activity의 context대신 Application의 context를 넘기면 메모리 누수가 발생하지 않았다.
항상 글로 ~하면 누수난다 라는 글을 복붙형태로 보아서 믿긴 했지만 확실히 신뢰도는 떨어졌다. 이유에 대해서도 앵무새마냥 똑같은 말만하고 deep하게 설명하는 글이 없으니 답답했었는데 이렇게 실제로 로그로 확인하고 분석결과를 보여주는 착한 라이브러리덕에 궁금증이 해결되었다. 알게된 김에 이번 프로젝트에 릴리즈 전까지 사용해서 메모리관련 특이사항을 확인할 예정이다.
가독성이 떨어지는데 글 좀 잘 쓰셔야겠다~