메모리 누수: 정의, 일반적인 시나리오 및 진단

저자: IT Sectr 게시일: 2026-07-29 읽는 시간: 10 분

메모리 누수 (memory leak) — 애플리케이션이 더 이상 필요하지 않은 객체가 점유한 메모리를 해제하지 않는 상황입니다. 모바일 개발에서는 이것이 특히 중요합니다: 제한된 힙과 스왑 부재로 인해 OutOfMemoryError 및 애플리케이션 충돌이 발생합니다. Purdue University(2022)에 따르면 Google Play의 Android 앱 중 35%가 하나 이상의 메모리 누수를 포함합니다. 일반적인 시나리오, 진단 도구 및 해결 방법을 살펴보겠습니다.

핵심 요점

  • GC Root — 가비지 수집기가 살아있는 객체를 판단하는 진입점
  • Context 누수 — 싱글톤에 Activity Context를 전달하면 전체 View 계층이 유지됨
  • Handler와 postDelayed — Activity가 소멸되어도 Handler가 GC를 방해함
  • Heap dump — MAT 또는 Android Profiler를 통한 누수 분석의 주요 방법
  • SoftReference — 메모리 부족 시 자동 정리되는 캐시를 위한 WeakReference의 대안

모바일 애플리케이션에서 메모리 누수란?

메모리 누수 — 프로그램이 객체를 더 이상 필요로 하지 않은 후에도 할당된 메모리가 시스템에 반환되지 않는 상황입니다. 가비지 수집기는 GC Root의 활성 참조 체인이 이를 가리키고 있기 때문에 해당 객체를 살아있는 것으로 간주합니다.

Java/Kotlin에서 가비지 수집기는 자동으로 작동하지만, 기술적 참조가 존재하는 경우 객체가 논리적으로 불필요하다는 것을 판단할 수 없습니다. 개발자는 불필요한 연결을 명시적으로 끊어야 합니다. Swift/Objective-C에서 ARC는 자동으로 참조를 계산하지만, retain cycle이 카운터가 0이 되는 것을 차단합니다.

누수의 주요 위험은 누적 효과입니다. 각 누수는 소량의 메모리를 소비하지만, 반복적인 화면 전환(화면 회전, Activity 열기/닫기)으로 힙 한계에 도달할 때까지 누수가 축적됩니다.

누수와 블로트의 차이점은?

누수 — 객체는 코드에서 접근 불가능하지만 GC에 의해 제거되지 않았습니다. 블로트 — 객체는 논리적으로 필요하지만 과도한 양으로 저장됩니다. 블로트의 예: 30MB 작업 세트에 100MB 이미지 캐시. 두 문제 모두 OOM으로 이어지지만 원인과 치료 방법이 다릅니다.

가비지 수집기는 어떻게 작동하며 누수는 왜 발생하나?

ART(Android Runtime)는 동시 압축을 통한 세대별 가비지 수집을 사용합니다. 메모리는 Young 세대, Old 세대 및 Large 객체로 나뉩니다. 여러 GC 사이클에서 살아남은 객체는 Old 세대로 이동하며, 여기서 수집이 덜 자주 발생합니다 — 이는 일반 사이클을 가속화합니다.

GC는 이 특정 점유 임계값(보통 75-85%)에 도달하면 시작됩니다. GC 중에는 애플리케이션의 모든 스레드가 중지됩니다(STW — Stop The World). 살아있는 객체가 많을수록 일시 중지 시간이 길어집니다. 누수는 살아있는 객체 수를 증가시켜 GC 일시 중지를 연장합니다.

수집기는 GC Roots에서 그래프를 탐색하여 살아있는 객체를 판단합니다: 정적 필드, 활성 스레드의 스택 변수, JNI 참조. 이러한 루트에서 참조를 통해 도달 가능한 모든 객체는 살아있는 것으로 간주됩니다 — 개발자가 더 이상 필요하지 않다는 것을 알아도 마찬가지입니다.

kotlin
// 예시: GC Root로서의 정적 컬렉션 — 영구적 누수
object GlobalHolder {
    val listeners = mutableListOf<WeakReference<Any>>()
}

class LeakingFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle ?= null) {
        super.onCreate(savedInstanceState)
        GlobalHolder.listeners.add(WeakReference(this))
        // WeakReference는 GC를 방해하지 않음 — 올바른 동작
    }
}

WeakReference가 문제를 해결합니다: GC는 살아있는 객체를 판단할 때 약한 참조를 무시합니다. 객체에 약한 참조만 남아 있으면 가장 가까운 GC 사이클에서 수집됩니다.

Android 및 iOS의 일반적인 누수 시나리오

Activity Context — Android에서 가장 광범위한 누수 시나리오입니다. 싱글톤, 정적 필드 또는 장수명 서비스가 Activity Context에 대한 참조를 보유하면 모든 View를 포함한 전체 Activity를 GC가 수집할 수 없습니다. 해결책: 장수명 객체에는 Application Context를 사용하세요.

Handler와 전송된 메시지 — Handler.postDelayed(runnable, delay)는 Main Looper 큐에 메시지를 넣습니다. 지연이 만료되기 전에 Activity가 소멸되면 메시지가 여전히 큐에 있고 Runnable → 익명 클래스 → 외부 클래스(Activity)를 통해 참조를 유지합니다.

kotlin
class SafeActivity : AppCompatActivity() {
    private val mainHandler = Handler(Looper.getMainLooper())
    private val callback = Runnable { /* update UI */ }

    override fun onResume() {
        super.onResume()
        mainHandler.postDelayed(callback, 5000)
    }

    override fun onPause() {
        mainHandler.removeCallbacks(callback) // 필수: 큐 지우기
        super.onPause()
    }
}

내부 클래스 — 비정적 내부 클래스는 외부 클래스 인스턴스에 대한 암시적 참조를 가집니다. 외부 클래스가 Activity이고 내부 클래스가 외부로 전달되면(예: RecyclerView.Adapter), Activity를 수집할 수 없습니다.

  • TimerTask 및 ScheduledExecutorService — Activity 소멸 전에 예약된 작업
  • BroadcastReceiver — onPause/onDestroy에서 등록 취소되지 않으면 Context를 계속 유지
  • View 참조가 있는 ViewModel — ViewModel이 Activity보다 오래 살며 View 참조가 누수로 이어짐
  • Retrofit Call — Call이 취소되지 않으면 응답이 소멸된 Fragment에 도착

메모리 누수 진단 도구

Android Studio Memory Profiler — 실시간 힙 모니터링을 위한 내장 도구입니다. 사용된 메모리 그래프, 할당 수 및 유형별 객체를 표시합니다. 힙 덤프를 기록하고 MAT에서 분석하기 위해 HPROF 형식으로 내보낼 수 있습니다.

Eclipse MAT(Memory Analyzer Tool) — 데스크톱 힙 덤프 분석기입니다. 자동으로 Leak Suspects 보고서를 작성하여 가장 큰 retained size를 가진 객체를 강조 표시하고 각 의심 객체에 대한 추정 GC root chain을 제안합니다.

Xcode Memory Graph Debugger — iOS용. 애플리케이션을 일시 중지하고 객체 그래프를 시각화합니다. Retain cycle은 빨간색으로 강조 표시되며, 객체를 클릭하여 retain count와 참조를 볼 수 있습니다.

도구기능복잡성
Memory Profiler실시간 그래프, 힙 덤프, Object Allocation Tracking낮음
Eclipse MATDominator tree, Leak Suspects, OQL 쿼리중간
LeakCanary자동 탐지, 알림의 누수 추적최소
Xcode Memory Graphretain cycle 시각적 그래프, 살아있는 객체 목록낮음

Uber Engineering Blog에 따르면 CI/CD 파이프라인에 자동 메모리 프로파일링(LeakCanary + 힙 덤프 분석)을 통합하면 3개월 이내에 프로덕션의 메모리 관련 인시던트가 60% 감소합니다.

누수 제거 방법

Context 교체 — 객체가 Activity보다 오래 사는 경우 applicationContext를 사용하세요. 모든 장수명 객체(싱글톤, 리포지토리, 데이터베이스 헬퍼)는 Activity Context가 아닌 Application Context를 받아야 합니다. 예외: Activity별 테마나 리소스에 접근해야 하는 UI 구성 요소.

Lifecycle-aware 구성 요소 — LifecycleObserver, DefaultLifecycleObserver 또는 반응형 확장을 사용하면 onDestroy에서 자동으로 구독이 취소됩니다. Android Jetpack은 lifecycleScope와 viewModelScope를 제공하며, 해당 라이프사이클 이벤트에 의해 정리됩니다.

정적 내부 클래스 — 내부 클래스가 외부 클래스의 필드에 접근할 필요가 없으면 static으로 만드세요. 정적 내부 클래스는 외부 클래스에 대한 암시적 참조가 없습니다. 접근이 필요하면 명시적 참조에 WeakReference를 사용하세요.

kotlin
class MyActivity : AppCompatActivity() {

    // ❌ 비정적 내부 클래스 — MyActivity에 대한 암시적 참조
    inner class BadListener : SomeListener {
        override fun onEvent() { /*...*/ }
    }

    // ✅ 정적 내부 클래스 — 암시적 참조 없음
    class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
        override fun onEvent() { /*...*/ }
    }
}

iOS에서는 캡처 목록을 사용하세요: 생성자보다 오래 살 수 있는 클로저에서는 [weak self]를 사용합니다. 델리게이트에는 약한 참조를 사용하세요(weak var delegate). self의 수명 동안만 호출이 보장되는 클로저의 경우 [unowned self]를 사용할 수 있지만 주의해야 합니다 — 할당 해제된 객체에 접근하면 충돌이 발생합니다.

자주 묻는 질문

특별한 도구 없이 누수를 찾는 방법은?

Android에서 여러 화면 전환(Activity A → B → A → B)을 수행하고 adb shell dumpsys meminfo package_name을 확인하세요. Total PSS가 꾸준히 증가하고 원래 값으로 돌아오지 않으면 — 누수가 있습니다. iOS에서도 유사하게: Xcode의 Debug Memory Graph를 사용하여 시각적으로 검사합니다.

Kotlin 코루틴이 누수를 일으킬 수 있나요?

네, 구성 요소가 소멸될 때 CoroutineScope가 취소되지 않은 경우입니다. GlobalScope에서 시작된 코루틴은 Activity의 finish() 후에도 계속 실행됩니다. 해결책: viewModelScope(onCleared에서 취소) 또는 lifecycleScope(onDestroy에서 취소)를 사용하세요. 사용자 정의 범위의 경우 LifecycleOwner를 통해 lifecycle-aware 범위를 만드세요.

Bitmap은 누수에 어떻게 영향을 미치나요?

Bitmap은 Java 힙이 아닌 네이티브 힙에 픽셀 데이터를 저장합니다. 이는 Java GC가 Bitmap의 실제 크기를 인식하지 못한다는 것을 의미합니다. Bitmap에서 recycle()을 호출하지 않거나 참조를 null로 설정하지 않으면 네이티브 메모리가 해제되지 않습니다. 축소된 복사본을 로드하려면 BitmapFactory를 inSampleSize와 함께 사용하고 자동 캐시 관리를 위해 Glide/Coil을 사용하세요.

정적 필드를 통한 누수란?

정적 필드는 GC Root입니다. 클래스가 로드되는 한(Android에서는 Process가 살아있는 한) 유지됩니다. 정적 필드가 Activity, Bitmap, View 또는 기타 무거운 객체를 참조하면 해당 객체는 GC에 의해 절대 수집되지 않습니다. 정적 필드는 영원한 참조입니다. 해결책: WeakReference만 저장하거나 onDestroy에서 정적 필드를 null로 설정하세요.

ARC를 사용하는 iOS에서 누수를 방지하는 방법은?

ARC는 강한 참조 카운트가 0이 되면 자동으로 객체를 해제합니다. ARC에서 누수의 유일한 방법은 retain cycle입니다. 자식이 부모보다 오래 살 수 있는 부모→자식 참조(델리게이트, data source)에는 항상 weak을 사용하세요. 클로저에서는 캡처 목록 [weak self]를 사용하고 클로저 내에서 self가 nil인지 확인하세요.

요약

  • 메모리 누수 — 객체는 코드에서 접근 불가능하지만 GC Root의 활성 참조로 인해 GC에 의해 제거되지 않음
  • GC Roots에는 정적 필드, 스택 변수, JNI 참조가 포함되며, 이들에서 도달 가능한 모든 객체는 살아있음
  • Context 누수 — Android에서 가장 널리 퍼진 문제: 싱글톤 또는 정적 필드에 Activity Context 전달
  • Handler와 내부 클래스 — 두 번째로 흔한 원인: Looper 큐의 취소되지 않은 메시지가 Activity 참조를 유지
  • LeakCanary — 표준 자동 탐지 도구, 힙 덤프를 가져와 정확한 GC root chain을 표시
  • lifecycleScope 및 viewModelScope가 코루틴을 통한 누수 문제 해결 — 소멸 시 자동 취소
  • CI/CD에서 메모리 프로파일링: 디버그 모드의 LeakCanary + 테스트 실행의 힙 덤프 분석이 새 누수 시 병합 차단

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

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

프로젝트 논의

더 읽어보기