Android 개발/트러블슈팅

안드로이드 메모리 누수, LeakCanary와 프로파일러로 20회 재현 실측 일지

stackD 2026. 10. 5. 18:00
반응형

얼마 전에 QA 담당자가 사내 슬랙으로 '30분쯤 쓰면 스크롤이 뚝뚝 끊기는데, 재부팅하면 사라져요' 하고 캡처를 보냈어요. 크래시 로그는 깨끗했습니다. 딱 누수였습니다.

 

LeakCanary로 누수를 탐지하고, 안드로이드 스튜디오 프로파일러로 인스턴스 수를 교차 검증한 실측 일지입니다. 탐지기와 계측기를 어떻게 이어 붙였는지, 범인은 누구였는지 순서대로 풀어볼게요.

 

크래시 없이 조용히 무거워지다 OOM으로 터지는 이유

안드로이드 메모리 누수의 무서운 점은 앱을 바로 죽이지 않는다는 겁니다. 회수됐어야 할 Activity가 힙에 계속 남으면서, GC(가비지 컬렉션)가 매번 헛도는 구조예요.

 

이게 쌓이면 프레임 하나 그릴 시간을 GC가 갉아먹으면서 스크롤이 끊기고(jank), 결국 메모리 한계를 넘으면 OOM으로 터지죠. "내 폰에선 되는데요"가 나오는 이유가 여기 있더라고요. 잠깐 쓰면 티가 안 나거든요.

 

그래서 재현 시나리오가 절반입니다. 화면을 20번 왕복하거나 회전을 반복해야 비로소 드러나는데요. 시나리오를 안 돌리고 "누수 없음"이라 안심하면, 그게 제일 위험한 거짓 안심이지요.

 

LeakCanary 사용법과 프로파일러를 이어 붙이는 세팅

LeakCanary(Square) 는 의존성 한 줄이면 코드 변경 없이 알아서 켜집니다. 앱이 백그라운드에서 상시 감시하다가, 파괴된 Activity·Fragment가 GC 후에도 살아있으면 힙을 떠서 리포트를 던져줘요.

 

// 디버그 빌드에만 들어갑니다. 최신 버전은 공식 문서 확인 필수
debugImplementation "com.squareup.leakcanary:leakcanary-android:<버전>"

 

여기서 첫 번째 오해를 짚고 갈게요. debugImplementation 이라 디버그 빌드에만 붙습니다. 프로덕션 실사용자 누수는 못 잡아요. 그쪽은 크래시리틱스(Crashlytics)의 OOM 지표나 onTrimMemory 신호를 따로 봐야 합니다.

 

프로파일러는 반대 역할입니다. 안드로이드 스튜디오(Android Studio)에서 "Analyze Memory Usage (Heap Dump)" 작업으로 스냅샷을 뜨는데요(작업 명칭은 버전마다 바뀌니 확인이 필요합니다), 여기선 "얼마나 새는가"를 숫자로 재는 겁니다. LeakCanary가 알람이라면, 프로파일러는 계측기라고 보시면 되겠습니다.

 

leak trace 참조 체인, 빨간 밑줄부터 읽는 법

LeakCanary 리포트를 처음 열면 참조 체인이 주르륵 뜨는데요. 여기서 두 번째 오해가 나옵니다. 최상단이 범인이 아니에요.

 

봐야 할 건 빨간 밑줄(squiggly)이 그어진 구간입니다. 그 자리가 GC를 실제로 막고 있는 지점이거든요. 최상단이나 프레임워크 내부 참조는 대부분 무시해도 되는 대상입니다.

 

용의자를 좁혔으면 프로파일러로 넘어갑니다. 힙 덤프 화면에서 클래스 드롭다운의 "Show activity/fragment leaks" 필터를 걸면 살아남은 인스턴스가 보여요. GC를 강제로 한 번 돌린 뒤 다시 덤프를 떠서 숫자를 비교하면 교차 검증이 됩니다.

 

여기서 대부분의 글이 놓치는 회색지대가 있어요. 완전한 누수는 아닌데 반복할수록 계속 쌓이는(monotonically growing) 객체입니다. 이런 단조 증가 패턴은 LeakCanary 최근 버전이 잡아준다고 알려져 있는데, 이 부분도 버전별로 다르니 공식 문서를 확인하시는 게 정확합니다.

 

static 참조·Handler·리스너, 안드로이드 누수 단골 범인들

제 경우 범인은 화면에 걸린 static 리스너 한 줄이었어요. 자주 나오는 원인을 정리하면 이렇습니다.

 

  1. 비정적 내부/익명 클래스가 바깥 Activity를 암묵적으로 참조합니다. 리스너나 Handler를 이렇게 만들면 Activity가 GC되지 않아요.
  2. static 필드로 Context나 Activity를 들고 있으면 앱이 죽을 때까지 안 풀립니다.
  3. onDestroyView 이후에도 ViewBinding을 null 처리 안 하면 Fragment의 View가 살아남습니다. 이건 프로파일러가 자동 감지를 못 해서 LeakCanary가 상호보완해주는 자리예요.

 

Handler는 이 패턴으로 풀립니다. static 내부 클래스로 빼고 Activity를 약한 참조로 잡는 거예요.

 

private class SafeHandler(activity: MainActivity) : Handler(Looper.getMainLooper()) {
    private val ref = WeakReference(activity)
    override fun handleMessage(msg: Message) {
        val activity = ref.get() ?: return // 회수됐으면 그냥 빠짐
        // activity 로 UI 갱신
    }
}

 

근데 사후에 잡는 것보다 안 만드는 게 훨씬 쌉니다. viewLifecycleOwner로 관찰자를 붙이고, 코루틴은 repeatOnLifecycle로 생명주기에 묶고, onDestroyView에서 바인딩을 null로 돌리는 습관. 이 세 개만 몸에 배도 리포트 자체가 확 줄었어요.

 

안드로이드 메모리 누수를 CI에서 미리 잡아 회귀 막기

한 번 잡고 끝내면 다음 릴리즈에 똑같은 게 또 기어들어옵니다. 그래서 회전·왕복 반복을 계측 테스트로 짜서 CI에 걸어뒀어요.

 

LeakCanary는 테스트에서 누수가 잡히면 빌드를 실패로 떨어뜨리게 연결할 수 있습니다. 프레임워크 쪽 알려진 누수는 suppression 목록으로 걸러야 알람 피로가 줄어들어요. 안 그러면 무시해도 되는 알림에 파묻혀서 진짜를 놓치기 쉬워요.

 

솔직히 프로파일러의 정량 분석(retained size, dominator tree)은 진입장벽이 좀 있습니다. 개인적으로는 초심자라면 LeakCanary만 상시 켜두는 걸로 충분하다고 봅니다. 둘 다 붙이는 건 "숫자로 못 박아야 하는" 이번 같은 상황에서 위력이 나오는 것 같아요.

 

수정을 마치고 화면을 20회 왕복시킨 뒤 다시 덤프를 떴습니다. Activity 인스턴스 1개 유지, retained size 안정. 30분을 켜둬도 스크롤이 안 끊겼어요. 조용히 새던 자리를 닫는 일은, 리포트 한 장이 아니라 반복 20회 뒤에 찍힌 그 숫자 '1'로 끝나는 거였습니다.

 

반응형
LIST