안드로이드 시스템·Compose 용어 사전
성능LeakCanary · Context 누수

메모리 누수

쓸모없어진 객체를 무언가가 계속 참조해 GC되지 않는 상태. 범인은 대개 Context.

쓸모없어진 객체를 무언가가 계속 참조해 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()

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 메모리·ANR·성능 — 누수·프리징·렉