0Pricing
Android Academy · 강의

메모리 누수와 해결 방법

누수를 찾아 멈춥니다.

메모리 누수와 해결 방법은(는) CoddyKit의 무료 Android Academy 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Android Academy 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Android Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

메모리 누수란 무엇인가요?

메모리 누수는 더 이상 필요하지 않은 객체를 다른 무언가가 여전히 참조하고 있어 가비지 컬렉션할 수 없을 때 발생합니다. Android에서 대표적인 피해자는 화면이 제거된 후에도 남아 있는 Activity 또는 Context입니다.

누수가 시간이 지날수록 힙을 키우고, 가비지 컬렉션(이로 인해 UI가 일시 중지되고 끊김이 발생합니다)을 더 자주 실행하게 하며, 결국 OutOfMemoryError로 앱이 비정상 종료될 수 있습니다. 이 수업에서는 누수를 찾고 수정하는 방법을 보여 줍니다.

GC는 무엇을 유지할지 어떻게 결정할까요?

Android의 가비지 컬렉터는 GC 루트(실행 중인 스레드, 정적 필드 등)에서 도달 가능한 모든 객체를 유지합니다. 도달할 수 없는 객체는 해제됩니다.

누수는 간단히 말해 원치 않는 도달 경로입니다. 수명이 긴 객체가 수명이 짧은 객체를 참조하는 경우입니다. 아래의 순수 Kotlin 데모는 도달 가능성 때문에 객체가 살아 있는 모습을 보여 줍니다.

object GlobalCache {
    val items = mutableListOf<ByteArray>()
}

fun cacheSomething() {
    // This 1MB array stays alive forever because GlobalCache
    // (a static singleton, a GC root) keeps referencing it.
    GlobalCache.items.add(ByteArray(1_000_000))
}

fun main() {
    repeat(3) { cacheSomething() }
    println("Held arrays: ${GlobalCache.items.size}") // never freed
}

고전적인 사례: Activity 누수

가장 흔한 Android 누수는 Context를 보유한 정적 객체 또는 수명이 긴 객체입니다. Activity가 소멸되면(화면 회전, 탐색) 시스템은 이를 해제하려고 하지만, 정적 참조가 Activity를 영원히 살아 있게 만듭니다.

아래 코드는 누수를 일으킵니다. 싱글톤이 Activity Context를 캐시하기 때문입니다.

// LEAK: static field holds an Activity Context
object Analytics {
    var context: Context? = null   // <- keeps Activity alive
    fun init(ctx: Context) { context = ctx }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Analytics.init(this) // passing the Activity -> leak on rotation
    }
}

해결 방법: Application Context 사용

수명이 긴 객체에 Context를 저장해야 한다면, 전체 프로세스와 수명이 같아 안전하게 유지할 수 있는 애플리케이션 Context를 저장해야 합니다.

Activity/View Context는 해당 화면이 존재하는 동안에만 유지하십시오. 이 한 가지 규칙으로 대부분의 Android 누수를 방지할 수 있습니다.

object Analytics {
    private var appContext: Context? = null
    fun init(ctx: Context) {
        // applicationContext is process-scoped and safe to keep
        appContext = ctx.applicationContext
    }
}

// Call site is now leak-free:
Analytics.init(this) // stores applicationContext, not the Activity

내부 클래스와 핸들러

정적이 아닌 내부 클래스(대부분의 리스너, Runnable, Handler 콜백 포함)는 바깥 클래스에 대한 암시적 참조를 보유합니다. 해당 작업이 화면보다 오래 지속되면 화면이 누수됩니다.

지연된 Handler.postDelayed는 자주 문제를 일으킵니다. 대기 중인 메시지가 실행될 때까지 Activity를 살아 있게 만들기 때문입니다.

// LEAK: the posted Runnable holds the Activity for 60 seconds
handler.postDelayed({ updateUi() }, 60_000)

// FIX: cancel pending work when the screen goes away
override fun onDestroy() {
    super.onDestroy()
    handler.removeCallbacksAndMessages(null)
}

코루틴: 작업의 범위 지정

화면보다 오래 지속되는 코루틴은 자신이 캡처한 모든 것을 누수시킵니다. 해결 방법은 구조화된 동시성입니다. 수명 주기에 연결된 범위에서 작업을 시작하면 작업이 자동으로 취소됩니다.

ViewModel에서는 viewModelScope를 사용하고, UI 컨트롤러에서는 lifecycleScope를 사용하십시오. 소유자가 소멸되면 범위가 취소되고 참조가 해제됩니다.

class FeedViewModel : ViewModel() {
    fun load() {
        // Cancelled automatically when the ViewModel is cleared
        viewModelScope.launch {
            val feed = repository.fetchFeed()
            _state.value = feed
        }
    }
}

// In Compose, collect tied to the lifecycle:
val state by viewModel.state.collectAsStateWithLifecycle()

Compose의 DisposableEffect

Compose에서는 등록하는 모든 항목을 컴포저블이 화면에서 사라질 때 등록 해제해야 합니다. DisposableEffect는 바로 이를 위한 onDispose 블록을 제공합니다. 리스너, 옵저버, 센서, 브로드캐스트 수신기 등에 사용할 수 있습니다.

여기서 정리 작업을 잊으면 콜백과 콜백이 캡처한 모든 항목이 누수됩니다.

@Composable
fun LocationDisplay(manager: LocationManager) {
    DisposableEffect(manager) {
        val listener = LocationListener { /* update */ }
        manager.register(listener)
        onDispose { manager.unregister(listener) } // prevents the leak
    }
}

LeakCanary로 누수 찾기

LeakCanary는 디버그 빌드에서 누수를 자동으로 감지하는 표준 도구입니다. 종속 항목을 추가하면 소멸된 객체를 감시하며, 객체가 가비지 컬렉션되지 않으면 힙을 덤프하고 객체를 계속 살아 있게 만드는 정확한 참조 체인을 보여 줍니다.

이 체인이 바로 해결의 실마리입니다. 문제를 일으킨 필드나 리스너를 직접 가리키기 때문입니다.

// build.gradle.kts (app module)
dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}
// No code needed: on a debug build, navigate away from a screen and
// LeakCanary posts a notification with the leak trace, e.g.:
//   Analytics.context -> MainActivity (leaked)

프로파일러에서 힙 덤프 확인

LeakCanary가 감지하지 못하는 누수(느린 증가, 네이티브 메모리, 큰 캐시)에는 메모리 프로파일러를 사용하십시오. 힙 덤프를 캡처하고 GC를 강제로 실행한 다음, 인스턴스 수가 예상보다 많은 클래스를 확인하십시오.

화면을 떠난 뒤 Activity, Fragment 또는 ViewModel의 인스턴스 수가 계속 증가한다면 명백한 문제 신호입니다. 프로파일러는 GC 루트까지의 경로도 보여 주므로 참조를 추적할 수 있습니다.

비트맵과 대규모 할당

모든 메모리 문제가 참조 누수인 것은 아닙니다. 큰 비트맵과 제한 없는 캐시는 그 자체로 힙을 가득 채울 수 있습니다. 전체 해상도 사진 하나가 메모리에서 수십 메가바이트를 차지할 수 있습니다.

이미지 라이브러리(Coil/Glide)가 표시 크기에 맞게 다운샘플링하도록 하고, 직접 만드는 캐시에는 명시적인 최대 크기를 설정하십시오. 아래 코드는 크기가 제한된 LRU 캐시를 보여 줍니다.

// Bound memory: keep at most ~1/8 of available app memory
val maxKb = (Runtime.getRuntime().maxMemory() / 1024 / 8).toInt()
val bitmapCache = object : LruCache<String, Bitmap>(maxKb) {
    override fun sizeOf(key: String, value: Bitmap) = value.byteCount / 1024
}
// An unbounded HashMap<String, Bitmap> would grow until OutOfMemoryError.

누수 방지 점검 목록

다음 습관을 들이면 대부분의 누수는 애초에 발생하지 않습니다.

  • 수명이 긴 객체에는 Activity/View가 아니라 applicationContext를 저장합니다.
  • 수명 주기 범위가 지정된 코루틴(viewModelScope, lifecycleScope)에서 비동기 작업을 실행합니다.
  • 리스너와 수신기는 항상 등록 해제합니다(onDispose, onDestroy).
  • 지연된 Handler와 타이머를 취소합니다.
  • 캐시의 크기를 제한하고 비트맵을 다운샘플링합니다.
  • 디버그 빌드에 LeakCanary를 유지하고 추적 결과에 따라 조치합니다.

빠른 확인

전체 수명 동안 Context를 유지해야 하는 싱글톤 객체가 있습니다. 화면 누수를 방지하려면 어떤 Context를 보유해야 할까요?

복습: 메모리를 깨끗하게 유지하기

누수가 무엇이며 어떻게 막는지 배웠습니다.

  • 누수는 이미 사용이 끝난 객체를 계속 살아 있게 만드는 원치 않는 도달 가능성 경로입니다.
  • 전형적인 누수는 Activity/Context에 대한 정적 또는 수명이 긴 참조입니다. 대신 applicationContext를 저장하십시오.
  • 내부 클래스 리스너, 지연된 Handler, 범위가 지정되지 않은 코루틴은 소유자를 누수시킵니다. 범위를 지정하고 취소하십시오.
  • Compose에서 DisposableEffect를 사용해 콜백을 등록 해제하십시오.
  • LeakCanary와 메모리 프로파일러로 참조 체인을 확인할 수 있습니다.
  • 캐시의 크기를 제한하고 비트맵을 다운샘플링하십시오.

다음 학습 내용에서는 시작 최적화와 기준 프로필을 사용해 앱을 빠르게 시작하는 방법을 알아봅니다.

자주 묻는 질문

“메모리 누수와 해결 방법” 강의는 무료인가요?

네 — “메모리 누수와 해결 방법” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Android Academy 강의 전체를 잠금 해제할 수 있습니다. Android Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

“메모리 누수와 해결 방법”에서 뭘 배우나요?

누수를 찾아 멈춥니다. 브라우저에서 직접 실행하는 실습 코드로 Android Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Android Academy을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Android Academy은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“메모리 누수와 해결 방법” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Android Academy 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Android Academy 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 성능 측정
  2. 재구성 길들이기
  3. 메모리 누수와 해결 방법
  4. 시작 시간과 기준선 프로필
← Android Academy(으)로 돌아가기