메모리 누수 — 모바일 개발에서 가장 교활한 문제 중 하나입니다. 앱의 메모리 사용량은 OS가 설정한 한계에 도달할 때까지 꾸준히 증가하며, 이후 OutOfMemoryError 또는 강제 종료가 발생합니다. Square Engineering에 따르면 약 40%의 Android 앱에 프로파일링을 통해서만 발견할 수 있는 메모리 누수가 하나 이상 있습니다. 원인과 메모리 증가 방지 방법을 살펴보겠습니다.
핵심 요점
메모리 누수 — 앱에 더 이상 필요하지 않은 객체가 GC 루트 세트의 활성 참조가 여전히 가리키고 있기 때문에 힙에 계속 유지되는 상황입니다. 가비지 컬렉터는 이러한 객체를 살아있는 것으로 간주하고 제거하지 않습니다.
메모리 블로트 — 앱이 현재 작업을 수행하는 데 필요한 것보다 더 많은 메모리를 소비하는 광범위한 문제입니다. 원인: 과도한 캐싱, 객체 중복, 비최적 데이터 구조, 힙 단편화.
Android에서는 각 앱에 제한된 힙이 할당됩니다 (일반적으로 기기 및 OS 버전에 따라 64–512MB). iOS에서는 제한이 덜 엄격하지만, 한계에 가까워지면 시스템이 메모리 경고를 보냅니다.
| 특성 | Android | iOS |
|---|---|---|
| 힙 제한 | 64–512MB (기기에 따라 다름) | 암시적 (시스템) |
| 가비지 컬렉션 | ART (동시, 컴팩트) | ARC (자동 참조 카운팅) |
| 누수 메커니즘 | GC 루트 참조 | 리테인 사이클 (강한 참조 사이클) |
| 결과 | OutOfMemoryError | 메모리 경고 → 종료 |
Facebook Engineering Blog에 따르면, 메모리 누수는 모바일 앱 크래시 보고서의 약 15%를 차지합니다. Android에서는 메모리 부족 시 빈번한 GC 일시 중지로 인한 ANR도 추가됩니다.
Activity에 대한 정적 참조 — 고전적인 Android 누수입니다. 정적 필드나 싱글톤이 Activity에 대한 참조를 보유하면, 싱글톤이 살아있는 한 finish() 후에도 GC가 수집하지 않습니다. Activity는 뷰 계층 구조, 리소스 및 Context를 포함하는 무거운 객체입니다.
object LeakHolder {
var activityRef: Activity ?= null // leak: static reference to Activity
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
LeakHolder.activityRef = this // ❌ MainActivity will never be GC'd
}
}
익명 클래스와 람다 — 외부 클래스에 대한 참조를 암시적으로 보유합니다. Runnable 또는 Callback이 외부 서비스에 전달되고 Activity가 소멸되어도, 익명 클래스 객체는 여전히 큐에 남아 Activity의 가비지 컬렉션을 방해합니다.
iOS에서는 주요 문제가 리테인 사이클입니다: 두 객체가 서로에 대한 강한 참조를 보유하고 ARC가 둘 중 어느 것의 참조 카운트도 0으로 만들 수 없습니다. 일반적인 사례: self를 강하게 캡처하는 클로저와 클로저에 대한 참조를 보유하는 self.
LeakCanary — Android에서 자동 누수 탐지를 위한 Square의 라이브러리입니다. Activity 또는 Fragment가 소멸된 후, 객체가 GC에 의해 수집되었는지 확인합니다. 수집되지 않은 경우 힙 덤프를 생성하고 누수 추적을 표시합니다.
// LeakCanary 2.x — auto-integration via Application
class ExampleApplication : Application() {
override fun onCreate() {
super.onCreate()
// LeakCanary auto-installs in debug build
// via ContentProvider — zero code setup
}
}
// Force check invocation
AppWatcher.objectWatcher.watch(watchedObject, "leak description")
Android Studio Profiler — 실시간 메모리 모니터링을 위한 내장 도구입니다. 힙 덤프 기록, 의심스러운 객체 찾기 (Retained Size > 1MB), 각 객체까지의 GC 루트 경로 추적이 가능합니다.
iOS의 경우 Xcode Memory Graph Debugger를 사용하세요. 메모리의 객체 그래프를 시각화하고, 리테인 사이클을 표시하며, 순환 참조를 즉시 탐지할 수 있습니다. 장기 모니터링을 위해 Instruments > Allocations도 사용 가능합니다.
WeakReference — 가비지 컬렉션을 방해하지 말아야 할 참조를 위한 기본 메커니즘입니다. GC가 객체를 수집하기로 결정하면 WeakReference는 null을 반환합니다. 콜백, 리스너 및 백그라운드 스레드에서 UI 컴포넌트에 대한 참조에 사용됩니다.
Lifecycle-aware 컴포넌트 — Android Jetpack (Lifecycle, LiveData, Flow, coroutines)에서 구현된 아키텍처 접근 방식입니다. onDestroy에서 구독이 자동으로 취소되어 주요 누수 클래스를 제거합니다.
class MyViewModel : ViewModel() {
private val _data = MutableLiveData<List<User>>()
val data: LiveData<List<User>> get() = _data
fun loadData() {
viewModelScope.launch {
val result = repository.fetchData()
_data.postValue(result)
// coroutine auto-cancels on onCleared()
}
}
}
viewModelScope와 lifecycleScope — Android에 내장된 CoroutineScope로, 해당 라이프사이클 이벤트에서 취소됩니다. 이는 현대 Android 개발에서 가장 흔한 시나리오인 코루틴을 통한 누수를 제거합니다.
Android Studio의 Memory Profiler — 힙 모니터링을 위한 주요 도구입니다. 라이브 할당, 힙 스냅샷, 유형별 객체 수를 표시합니다. 덤프를 기록하고 MAT (Memory Analyzer Tool)에서 분석하여 의심스러운 객체를 찾을 수 있습니다.
Eclipse MAT — 데스크톱 힙 덤프 분석기입니다. Android Studio에서 HPROF 파일을 로드한 후, MAT는 지배자 트리를 구축하고 각 객체의 리테인 크기를 표시하며 Leak Suspects Report를 통해 자동 누수 분석을 제공합니다.
Xcode Memory Graph — 시각적 리테인 사이클 디버거입니다. Memory Graph Debugger 버튼을 클릭하면 Xcode가 앱을 중지하고, 메모리의 완전한 객체 그래프를 구축하며, 리테인 사이클을 빨간색으로 강조 표시합니다.
| 도구 | 플랫폼 | 특징 |
|---|---|---|
| LeakCanary | Android | 소멸 후 누수 자동 탐지 |
| Memory Profiler | Android Studio | 힙 덤프 + 라이브 할당 |
| Eclipse MAT | Android | 지배자 트리, Leak Suspects Report |
| Memory Graph | iOS (Xcode) | 리테인 사이클 시각화 도구 |
Google I/O 2023에 따르면, 디버그 빌드에서 LeakCanary를 사용하는 앱은 도입 후 첫 2개월 동안 메모리 관련 크래시가 30–50% 감소합니다. 프로젝트 온보딩 단계에서 LeakCanary를 추가하는 것이 좋습니다.
자주 묻는 질문
누수 — 코드에서 접근할 수 없지만 활성 참조로 인해 GC가 수집하지 않는 객체. 블로트 — 앱이 논리적으로 필요하지만 과도한 양의 객체를 유지하는 상태 (예: 80MB 실행 앱에서 50MB 캐시). 블로트는 아키텍처적으로 해결하고, 누수는 올바른 참조 관리로 해결합니다.
LeakCanary는 ObjectWatcher를 사용합니다 — Activity의 onDestroy() 후 Activity에 대한 WeakReference를 만들고 GC를 실행합니다. 5초 후 WeakReference가 지워지지 않으면 힙 덤프를 생성하고, GC Root에서 객체까지의 최단 참조 체인을 분석하여 파일과 코드 줄이 포함된 정확한 누수 스택을 표시합니다.
Bitmap은 Java 힙 외부의 네이티브 메모리 (네이티브 힙)를 차지합니다. 하나의 Bitmap 크기 = 너비 × 높이 × 4바이트 (ARGB_8888). 12MP 사진 (4000×3000)은 48MB를 차지합니다. Android가 네이티브 메모리를 항상 적시에 해제할 수 있는 것은 아니므로, 여러 Bitmap이 축적되면 충분한 Java 힙이 있어도 OOM이 발생합니다.
리테인 사이클 — ARC에서 두 객체가 서로에 대한 강한 참조를 보유하고 참조 카운트가 절대 0이 되지 않는 상황입니다. 일반적인 예: 클로저에 대한 강한 참조를 가진 ViewController와 self를 강하게 캡처하는 클로저. 해결책: 클로저에서 [weak self] 또는 [unowned self]를 사용하세요.
힙 크기는 기기와 Android 버전에 따라 다릅니다. 구형 기기 (API 15–24)의 경우 64–128MB, 최신 기기 (API 25+)의 경우 256–512MB입니다. 정확한 값은 ActivityManager.getMemoryClass()를 통해 얻을 수 있습니다. 대규모 앱 (게임, 편집기)의 경우 매니페스트에서 largeHeap=true를 설정하면 최대 1GB까지 제공됩니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.