쓸모없어진 객체를 무언가가 계속 참조해 GC되지 않는 상태.
ART의 GC는 무엇을 기준으로 하나
GC 루트(정적 필드 · 살아 있는 스레드 · 스택 지역변수) 에서 참조를 따라가 '도달 가능한' 객체는 살린다
- 도달 가능하기만 하면 아무리 쓸모없어도 안 지운다
- 누수 = '논리적으로는 죽었는데 참조 사슬이 남은' 상태
가장 흔한 범인은 Context
// ❌ 정적 필드가 Activity 를 붙든다 — 회전할 때마다 하나씩 샌다
companion object { var instance: MyActivity? = null }
// ❌ 싱글턴에 Activity Context 를 넘긴다
object Analytics { lateinit var ctx: Context }
Analytics.ctx = this // Activity 를 영원히 붙든다
// ❌ 비정적 내부 클래스 / 익명 객체는 바깥 인스턴스를 암묵 참조한다
Handler(Looper.getMainLooper()).postDelayed({ updateUi() }, 60_000)
// 이 람다가 Activity 를 붙들어 1분간 GC 되지 않는다
판별 기준
- "이 객체는 Activity 보다 오래 사는가?"
- 그렇다 → applicationContext 를 쓰거나 아예 참조하지 않는다
다른 흔한 원천
- 해제하지 않은 리스너 (registerReceiver · addObserver · setOnClickListener 를 정적 객체에)
- Fragment 의 _binding 을 onDestroyView 에서 null 로 안 만든 경우
- 취소하지 않은 코루틴/스레드 (GlobalScope.launch)
- 닫지 않은 Cursor · InputStream
- 무한히 자라는 캐시 (LruCache 대신 HashMap)
LeakCanary로 잡는다
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
의존성만 추가하면 자동으로 동작한다
- Activity/Fragment 파괴 후 GC 를 유도하고, 살아남으면 힙을 덤프해
- '누가 붙들고 있는지' 참조 사슬을 그려 준다
디버그 빌드에만 넣는다 (릴리스에 들어가면 안 된다)
누수가 왜 크래시로 이어지나
회전 20번 → Activity 20개 + 그 뷰 트리 전체가 힙에 남는다
- 힙이 한계에 닿으면 OutOfMemoryError
- 그 전에도 GC 가 잦아져 화면이 끊긴다
크래시 리포트에는 OOM 만 찍히고 진짜 원인(누수) 은 안 보인다
면접 함정
- ❌ "코틀린은 GC가 있으니 누수가 없다" → GC는 도달 가능성만 본다. 논리적 쓸모는 모른다.
- ❌ "WeakReference를 쓰면 해결된다" → 근본 설계를 가리는 반창고인 경우가 많다. 참조 사슬 자체를 끊는 편이 낫다.
힙을 직접 들여다본다
-
Android Studio → Profiler → Memory → Capture heap dump
-
① 의심되는 화면을 열었다 닫는다 (5번 반복)
-
② GC 버튼을 누른다
-
③ 힙 덤프를 뜬다
-
④ Activity 인스턴스 수를 센다 — 1개를 넘으면 누수다
-
⑤ 'References' 탭에서 GC 루트까지의 경로를 따라간다
코루틴 누수
// ❌ 앱이 끝날 때까지 산다. 화면을 참조하면 함께 남는다
GlobalScope.launch { repeat(Int.MAX_VALUE) { delay(1000); update() } }
// ❌ 직접 만든 스코프를 취소하지 않는다
private val scope = CoroutineScope(Dispatchers.Main) // cancel() 이 없다
// ✅ 수명이 있는 스코프를 쓴다
viewModelScope · lifecycleScope · rememberCoroutineScope
캐시가 무한히 자라는 경우
// ❌ 지워지지 않는다
private val cache = HashMap<String, Bitmap>()
// ✅ 크기를 정하고 오래된 것을 버린다
private val cache = object : LruCache<String, Bitmap>(maxSizeBytes) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}
한도는 보통 가용 힙의 1/8 정도로 잡는다
- val max = (Runtime.getRuntime().maxMemory() / 8).toInt()