모바일 애플리케이션의 메모리 누수 — 정의, 원인 및 탐지 방법

저자: IT Sectr 게시일: 2026-03-29 읽는 시간: 9 분

메모리 누수(Memory Leak)는 애플리케이션이 더 이상 필요하지 않은 객체에 대한 참조를 유지하여 가비지 컬렉터(GC)가 점유된 메모리를 해제하지 못하는 상황입니다. LeakCanary에 따르면, 잘 작성된 애플리케이션에서도 코드 10,000줄당 3~5개의 누수가 발생합니다. 누수는 사용 가능한 메모리를 점차 줄여 속도 저하와 OutOfMemoryError를 초래합니다.

핵심 요점

  • Memory Leak — 애플리케이션 로직에서 활성 참조가 없음에도 객체가 메모리에 남아 있음
  • Activity 또는 Context에 대한 정적 참조 — Android에서 누수의 가장 일반적인 원인
  • LeakCanary — Android에서 자동 누수 탐지를 위한 표준 도구
  • WeakReference 및 Application Context — 누수 방지의 기본 기술
  • Lifecycle-aware 컴포넌트 — 구독 관련 누수 클래스 전체 제거

메모리 누수란

메모리 누수(Memory Leak)는 객체가 강한 참조(Strong Reference) 체인을 통해 접근 가능한 상태로 남아 있어, 논리적으로 애플리케이션에 더 이상 필요하지 않음에도 가비지 컬렉터(GC)가 해당 객체를 살아있는 것으로 간주하고 점유 메모리를 해제하지 않는 상황입니다. 결과적으로 사용 가능한 힙 메모리가 지속적으로 감소하고 GC 일시 중지 빈도가 증가합니다.

수동 메모리 관리 언어(C, C++)와 달리 Java/Kotlin에서 누수는 잊혀진 free()가 아니라 잊혀진 참조입니다. GC 루트에서 누수된 객체까지 강한 참조가 존재하는 한 GC는 이를 필요하다고 간주합니다. 일반적인 GC 루트: 정적 필드, 활성 스레드, 호출 스택, JNI 전역 참조.

누수의 위험성은 누적 효과에 있습니다. 100KB 누수 하나는 눈에 띄지 않지만, 이런 누수 100개는 10MB를 차지하며 빈번한 GC로 인해 애플리케이션이 느려지기 시작합니다. 누수가 임계 질량에 도달하면 OutOfMemoryError와 애플리케이션 충돌이 발생합니다. 누수 증상: Profiler 그래프에서 메모리 소비의 지속적 증가, STW(Stop The World)를 동반한 빈번한 GC 일시 중지, UI 성능 저하.

모바일 앱에서 흔한 누수 유형

5가지 유형의 누수가 모바일 개발 사례의 95%를 차지합니다. 각각 고유한 원인과 특징적인 코드 패턴이 있습니다.

Activity 또는 Context에 대한 정적 참조

가장 잘 알려진 Android 누수는 Activity 또는 Context에 대한 정적 참조를 저장하는 것입니다. 일반적인 코드: onDestroy()에서 null이 되지 않는 정적 Activity 필드. 정적 필드가 살아있는 동안 1~10MB를 차지할 수 있는 View 트리를 포함한 전체 Activity가 살아있게 됩니다. 이것이 LeakCanary가 가장 먼저 찾는 고전적인 누수입니다.

해결책: Activity 또는 Context를 정적 필드에 절대 저장하지 마십시오. Activity보다 오래 지속되는 싱글톤에는 Application Context를 사용하십시오. Activity에 대한 참조가 필요한 경우 WeakReference<Activity>를 사용하십시오.

kotlin
object MySingleton {
    private var weakActivity: WeakReference<Activity>? = null

    fun attach(activity: Activity) {
        weakActivity = WeakReference(activity)
    }
}

암시적 참조를 가진 내부 클래스

익명 클래스와 비정적 내부 클래스는 암시적으로 외부 클래스에 대한 참조를 유지합니다. onDestroy() 이후에 실행되는 Handler에 전달된 Runnable은 전체 Activity를 유지합니다. Activity를 캡처하는 Retrofit 콜백도 마찬가지입니다. 이것이 가장 교활한 유형의 누수입니다 — 암시적 참조는 코드에서 보이지 않습니다.

Kotlin object 표현식과 람다도 외부 클래스에 대한 참조를 캡처합니다. 내부 클래스를 정적(또는 Kotlin에서는 최상위)으로 만들고 외부 참조를 WeakReference를 통해 전달하십시오. 람다의 경우 viewLifecycleOwner와 함께 Lifecycle-aware 접근 방식을 사용하십시오.

해제되지 않은 리스너 및 구독

시스템 서비스에 구독하고 해제하지 않는 것은 직접적인 누수입니다. onResume()에서 등록되고 onPause()에서 unregister를 호출하지 않는 SensorManager, LocationManager, NotificationListener는 Activity를 유지합니다. 마찬가지로: CompositeDisposable에 추가되지 않은 RxJava Disposable과 GlobalScope를 통해 시작된 코루틴.

Lifecycle-aware 컴포넌트를 사용하십시오: LifecycleOwner와 함께 observe()는 onDestroy()에서 자동으로 구독을 해제합니다. RxJava의 경우 — DisposableObserver와 함께 viewLifecycleOwner.lifecycle.addObserver를 사용합니다. 코루틴의 경우 — lifecycleScope.launch()가 생명주기에 바인딩됩니다.

kotlin
// Lifecycle을 통한 자동 구독 해제
viewModel.userData.observe(viewLifecycleOwner) { data ->
    updateUI(data)
}

// lifecycleScope를 사용한 코루틴
lifecycleScope.launch {
    viewModel.loadData().collect { render(it) }
}

recycle 없는 Bitmap

Bitmap은 상당한 양의 힙 메모리를 차지합니다: FullHD 비트맵 하나는 1920 × 1080 × 4바이트 = 8.3MB입니다. 목록의 각 항목에 대해 Bitmap이 생성되고 숨길 때 recycle()이 호출되지 않으면 메모리가 빠르게 고갈됩니다. 오래된 Android 버전(3.0 이전)에서는 Bitmap이 네이티브 메모리에 저장되었지만, 현대 버전에서는 Dalvik/ART 힙에 있으며 GC는 강한 참조가 없을 때만 해제할 수 있습니다.

이미지 로딩에는 Glide 또는 Coil을 사용하십시오 — 이 라이브러리들은 캐싱과 재활용을 자동으로 관리합니다. Bitmap을 직접 다루는 경우 더 이상 표시되지 않는 큰 이미지에 대해 bitmap.recycle()을 호출하고 축소된 복사본을 로드하려면 inSampleSize를 사용하십시오.

onDestroyView 후 Fragment 참조

Fragment에는 두 가지 생명주기가 있습니다: Fragment 자체와 그 View의 생명주기입니다. onDestroyView() 후 View 트리는 파괴되지만 외부 참조가 있으면 Fragment 자체가 메모리에 남을 수 있습니다. 일반적인 실수는 ViewPager 어댑터나 네비게이션 그래프에 Fragment 참조를 저장하고 파괴 시 지워지지 않는 것입니다.

장수명 객체의 필드에 Fragment 참조를 절대 저장하지 마십시오. 중첩된 프래그먼트에는 childFragmentManager를, 그들 간의 데이터 전송에는 LifecycleOwner와 함께 observe()를 사용하십시오. ViewPager2는 API 수준에서 이 문제를 해결했습니다: FragmentTransactionAdapter가 생명주기를 올바르게 관리합니다.

메모리 누수 탐지 방법

누수 탐지는 두 가지 사실을 확인해야 합니다: 예상 수명 후 메모리가 돌아오지 않고, 특정 유형의 객체 수가 감소 없이 증가합니다. 진단 프로세스는 세 단계로 구성됩니다.

첫 번째 단계 — Android Studio의 Memory Profiler를 통한 시각적 확인. Memory 탭을 열고 대상 작업(화면 열기 및 닫기)을 수행한 후 GC(Garbage Collection)를 누르고 메모리가 초기 수준으로 돌아오는지 확인합니다. 3~4회의 열기-닫기 사이클 후에도 메모리가 지속적으로 증가한다면 — 누수가 있습니다.

두 번째 단계 — Heap Dump 수집. Memory Profiler에서 Dump Java Heap을 누릅니다. 결과 .hprof 파일을 Android Studio에서 열면 크기와 참조와 함께 힙의 모든 객체를 볼 수 있습니다. 화면을 닫은 후 개수가 0이어야 하는 클래스를 찾으십시오. 예를 들어, 닫은 후 개수가 2인 MainActivity는 명백한 누수입니다.

세 번째 단계 — Retained Size 및 GC Root 분석. Android Studio에서 Retained Size를 분석합니다: 이 객체를 제거하면 얼마나 많은 메모리가 해제될까요? GC Root에서 객체까지의 경로는 무엇이 그것을 잡고 있는지 보여줍니다: Static field → HashMap → Activity — 누수 지점을 알 수 있습니다. Reference 위젯 패널은 객체의 모든 보유자를 표시합니다.

누수 탐지 도구

네 가지 도구가 자동 탐지부터 심층 Heap Dump 분석까지 누수 탐색을 커버합니다.

도구방법결과 형식
LeakCanary자동 모니터링Heap Dump + 누수 stack trace
Android Memory Profiler수동 모니터링메모리 그래프 + Heap Dump
MAT (Eclipse)심층 분석Dominator Tree 보고서 + GC Root 경로
Perfetto시스템 전체 추적타임라인 + 네이티브 메모리

LeakCanary는 모든 Android 프로젝트에 필수입니다. Activity/Fragment 생명주기 종료 시 누수를 자동으로 감지하고 stack trace와 함께 정확한 누수 위치를 표시합니다. 통합: build.gradle에 한 줄. LeakCanary 2.x는 수동 초기화가 필요하지 않습니다 — 자동으로 Application Watcher를 등록합니다.

메모리 누수 방지 방법

누수 예방은 각 단계에서 코드를 검사하는 일련의 규칙과 도구를 통해 개발 프로세스에 내장됩니다.

강한 참조 규칙

Activity, Fragment 또는 View에 대한 참조를 절대 정적 필드, 싱글톤 또는 장수명 객체에 저장하지 마십시오. 참조가 불가피한 경우 WeakReference를 사용하거나 ViewModel을 통해 데이터를 저장하십시오. ViewModel은 필요한 기간만 정확히 지속되며 View를 직접 보유하지 않습니다.

Lifecycle-aware 아키텍처

Android Architecture Components의 ViewModelLiveData는 아키텍처 수준에서 생명주기 문제를 해결합니다. ViewModel은 화면 회전 후에도 유지되며 View 참조를 포함하지 않습니다. LiveData는 onDestroy()에서 자동으로 관찰자의 구독을 해제합니다. 시스템 서비스에 수동으로 구독하는 대신 이것들을 사용하십시오.

GC Root 중심 코드 리뷰

코드 리뷰에서 다음에 주의하십시오: Context/View 유형의 정적 필드, 익명 클래스, Activity를 캡처하는 람다, 수동 구독, composite 없는 RxJava disposable, Bundle을 통한 Fragment 저장. Kotlin에서는 생명주기 바인딩 없는 launch를 사용하는 코루틴을 추가로 확인하십시오.

CI에서 자동 검사

LeakCanary는 테스트 파이프라인의 일부로 작동할 수 있습니다: LeakCanary로 수락 테스트를 실행하고 누수가 발견되면 빌드를 실패 처리하십시오. 이렇게 하면 누수가 프로덕션에 도달하는 것을 방지할 수 있습니다. Android Lint의 StaticFieldLeak 규칙으로 검사를 보완하십시오 — 정적 분석 수준에서 잠재적 누수를 찾습니다.

kotlin
// 테스트에서 LeakCanary
class LeakTest {
    @Test
    fun activityShouldNotLeak() {
        ActivityScenario.launch(MainActivity::class.java)
            .close()
        LeakAssertions.assertNoLeak() // 누수 시 실패
    }
}

자주 묻는 질문

메모리 누수와 OutOfMemoryError의 차이점은?

누수가 원인이고 OutOfMemoryError는 결과입니다. 하나의 누수는 OOM으로 이어지지 않지만, 수십 개의 누수가 쌓이면 힙이 고갈됩니다. OOM은 치명적 예외이고, 누수는 시간이 지남에 따라 그것으로 이어지는 패턴입니다.

LeakCanary 없이 누수를 찾으려면?

Android Memory Profiler를 통해: 화면을 5번 열고 닫은 후 각 닫기 후 GC를 호출하십시오. 메모리가 기준 수준으로 돌아오지 않으면 — 누수가 있습니다. Heap Dump를 수집하고 닫은 후 개수가 0보다 큰 Activity 클래스를 목록에서 찾으십시오.

Kotlin이 언어 수준에서 누수를 방지할 수 있나요?

부분적으로. Kotlin은 null-safety 문제를 해결하지만 강한 참조는 관리하지 않습니다. lifecycleScope와 viewModelScope를 사용한 코루틴은 백그라운드 작업으로 인한 누수를 방지하고, sealed class와 data class는 누수로 이어지는 상태의 수를 줄입니다. 주요 보호는 언어 기능이 아니라 아키텍처 패턴입니다.

존재하지 않는 누수를 LeakCanary가 찾는 이유는?

LeakCanary는 때때로 오탐(false positive)을 발생시킵니다: 객체가 시스템에 의해 일시적으로 유지될 수 있습니다(예: InputMethodManager가 마지막 View를 유지). 수동으로 확인하십시오: Retained Size가 1KB 미만이고 GC Root가 시스템 서비스인 경우 오탐일 가능성이 높습니다.

메모리 누수는 Android에서만 발생하나요?

아닙니다. 누수는 GC가 있는 모든 플랫폼에서 발생할 수 있습니다: iOS(Swift/Objective-C), Flutter(Dart), 웹 브라우저(JavaScript). 메커니즘은 동일합니다 — GC Root로부터의 강한 참조. iOS에서 ARC는 메모리를 자동으로 관리하지만, 객체 간 retain cycle은 동일한 누수를 만듭니다.

요약

  • Memory Leak — 잊혀진 강한 참조로 인해 GC가 해제할 수 없는 객체
  • Activity와 Context에 대한 정적 참조 — 누수의 가장 일반적인 원인
  • 암시적 참조(익명 클래스, 람다, RxJava 구독)는 명시적 참조보다 더 교활함
  • LeakCanary는 자동으로 누수를 찾고 정확한 stack trace 표시
  • Lifecycle-aware 컴포넌트(ViewModel, LiveData, lifecycleScope)가 누수 클래스 제거
  • Heap Dump 및 Retained Size 분석 — 수동 진단의 주요 방법
  • 예방에는 강한 참조에 초점을 맞춘 코드 리뷰와 LeakCanary를 통한 CI 검사 포함

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기