랜덤 액세스 메모리(RAM)는 모든 소프트웨어 개발 환경에서 중요한 리소스이지만 실제 메모리의 제약이 많은 모바일 운영체제에서는 더욱 중요합니다.
Android 런타임(ART)과 Dalvik 가상 머신에서 일상적인 가비지 컬렉션을 실행하지만, 그래도 앱에서 메모리를 할당하고 해제하는 시점과 위치를 무시할 수는 없습니다.
일반적으로 정적 멤버 변수에서 객체 참조를 유지하여 발생하는 메모리 누수를 방지하고 수명 주기 콜백에서 정의한 대로 적절한 시점에 모든 Reference
객체를 해제하는 작업은 계속해야 합니다.
이 페이지에서는 사전에 앱 내의 메모리 사용량을 줄일 수 있는 방법을 설명합니다. Android 운영체제의 메모리 관리 방법에 관한 자세한 내용은 Android 메모리 관리 개요를 참고하세요.
사용 가능한 메모리 및 메모리 사용량 모니터링
앱에서 메모리 사용량 문제를 해결하려면 먼저 문제를 찾아야 합니다. Android 스튜디오의 메모리 프로파일러를 사용하면 다음과 같은 방법으로 메모리 문제를 찾고 진단할 수 있습니다.
- 앱에서 시간 경과에 따라 메모리를 할당하는 방법을 확인합니다. 메모리 프로파일러에서는 앱에서 사용 중인 메모리의 양, 할당된 Java 객체의 수, 가비지 컬렉션이 발생하는 시점을 실시간 그래프로 보여줍니다.
- 가비지 컬렉션 이벤트를 시작하고 앱이 실행되는 동안 Java 힙 스냅샷을 찍습니다.
- 앱의 메모리 할당을 기록한 다음 할당된 객체를 모두 검사하고, 각 할당의 스택 트레이스를 확인하며, Android 스튜디오 편집기에서 해당 코드로 이동합니다.
이벤트에 대한 응답으로 메모리 해제
Android 메모리 관리 개요에 설명된 대로 Android에서는 여러 가지 방법으로 앱에서 메모리를 회수하거나 필요한 경우 앱을 완전히 종료시켜 중요한 작업을 위한 메모리를 확보합니다. 시스템 메모리의 균형을 유지하고 시스템에서 앱 프로세스를 종료하지 않도록 하려면 Activity
클래스에 ComponentCallbacks2
인터페이스를 구현하면 됩니다. 제공된 onTrimMemory()
콜백 메서드를 사용하면 앱이 포그라운드 또는 백그라운드에 있을 때 앱에서 메모리 관련 이벤트를 수신 대기하고, 앱 수명 주기 또는 시스템의 메모리 회수 요구를 나타내는 시스템 이벤트에 대한 응답으로 객체를 해제할 수 있습니다.
예를 들어, 다음과 같이 onTrimMemory()
콜백을 구현하여 다양한 메모리 관련 이벤트에 응답할 수 있습니다.
Kotlin
import android.content.ComponentCallbacks2 // Other import statements ... class MainActivity : AppCompatActivity(), ComponentCallbacks2 { // Other activity code ... /** * Release memory when the UI becomes hidden or when system resources become low. * @param level the memory-related event that was raised. */ override fun onTrimMemory(level: Int) { // Determine which lifecycle or system event was raised. when (level) { ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { /* Release any UI objects that currently hold memory. The user interface has moved to the background. */ } ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { /* Release any memory that your app doesn't need to run. The device is running low on memory while the app is running. The event raised indicates the severity of the memory-related event. If the event is TRIM_MEMORY_RUNNING_CRITICAL, then the system will begin killing background processes. */ } ComponentCallbacks2.TRIM_MEMORY_BACKGROUND, ComponentCallbacks2.TRIM_MEMORY_MODERATE, ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { /* Release as much memory as the process can. The app is on the LRU list and the system is running low on memory. The event raised indicates where the app sits within the LRU list. If the event is TRIM_MEMORY_COMPLETE, the process will be one of the first to be terminated. */ } else -> { /* Release any non-critical data structures. The app received an unrecognized memory level value from the system. Treat this as a generic low-memory message. */ } } } }
Java
import android.content.ComponentCallbacks2; // Other import statements ... public class MainActivity extends AppCompatActivity implements ComponentCallbacks2 { // Other activity code ... /** * Release memory when the UI becomes hidden or when system resources become low. * @param level the memory-related event that was raised. */ public void onTrimMemory(int level) { // Determine which lifecycle or system event was raised. switch (level) { case ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN: /* Release any UI objects that currently hold memory. The user interface has moved to the background. */ break; case ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE: case ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW: case ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL: /* Release any memory that your app doesn't need to run. The device is running low on memory while the app is running. The event raised indicates the severity of the memory-related event. If the event is TRIM_MEMORY_RUNNING_CRITICAL, then the system will begin killing background processes. */ break; case ComponentCallbacks2.TRIM_MEMORY_BACKGROUND: case ComponentCallbacks2.TRIM_MEMORY_MODERATE: case ComponentCallbacks2.TRIM_MEMORY_COMPLETE: /* Release as much memory as the process can. The app is on the LRU list and the system is running low on memory. The event raised indicates where the app sits within the LRU list. If the event is TRIM_MEMORY_COMPLETE, the process will be one of the first to be terminated. */ break; default: /* Release any non-critical data structures. The app received an unrecognized memory level value from the system. Treat this as a generic low-memory message. */ break; } } }
onTrimMemory()
콜백은 Android 4.0(API 수준 14)에 추가되었습니다. 이전 버전에서는 TRIM_MEMORY_COMPLETE
이벤트와 거의 동일한 onLowMemory()
를 사용할 수 있습니다.
사용해야 하는 메모리 양 확인
Android에서는 여러 개의 프로세스 실행을 허용하기 위해 각 앱에 할당된 힙 크기를 엄격하게 제한합니다. 정확한 힙 크기 제한은 기기에서 전체적으로 사용할 수 있는 RAM 크기를 기준으로 기기에 따라 다릅니다. 앱이 힙 용량에 도달한 후 메모리를 더 할당하려고 하면 시스템에서 OutOfMemoryError
가 발생합니다.
메모리 부족을 방지하려면 시스템에 쿼리하여 현재 기기에서 사용할 수 있는 힙 공간의 양을 확인하세요.
getMemoryInfo()
를 호출하여 시스템에 이 값을 쿼리할 수 있습니다. 이 메서드는 사용 가능한 메모리, 총 메모리 및 메모리 기준점(시스템에서 프로세스를 종료하기 시작하는 메모리 수준)과 같은 기기의 현재 메모리 상태 정보를 제공하는 ActivityManager.MemoryInfo
객체를 반환합니다. 또한 ActivityManager.MemoryInfo
객체는 기기의 메모리가 부족한지 알려주는 간단한 불리언인 lowMemory
를 노출합니다.
다음 코드 스니펫은 애플리케이션에서 getMemoryInfo()
메서드를 사용하는 방법을 보여주는 예입니다.
Kotlin
fun doSomethingMemoryIntensive() { // Before doing something that requires a lot of memory, // check to see whether the device is in a low memory state. if (!getAvailableMemory().lowMemory) { // Do memory intensive work ... } } // Get a MemoryInfo object for the device's current memory status. private fun getAvailableMemory(): ActivityManager.MemoryInfo { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager return ActivityManager.MemoryInfo().also { memoryInfo -> activityManager.getMemoryInfo(memoryInfo) } }
Java
public void doSomethingMemoryIntensive() { // Before doing something that requires a lot of memory, // check to see whether the device is in a low memory state. ActivityManager.MemoryInfo memoryInfo = getAvailableMemory(); if (!memoryInfo.lowMemory) { // Do memory intensive work ... } } // Get a MemoryInfo object for the device's current memory status. private ActivityManager.MemoryInfo getAvailableMemory() { ActivityManager activityManager = (ActivityManager) this.getSystemService(ACTIVITY_SERVICE); ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); activityManager.getMemoryInfo(memoryInfo); return memoryInfo; }
메모리 효율성이 높은 코드 구성 사용
일부 Android 기능, Java 클래스, 코드 구성은 다른 구성에 비해 메모리를 더 많이 사용합니다. 코드에서 효율성이 높은 대안을 선택하면 앱에서 사용하는 메모리의 양을 최소화할 수 있습니다.
서비스를 드물게 사용
Android 앱에서 일으킬 수 있는 최악의 메모리 관리 실수 중 하나는 서비스가 필요 없을 때도 실행되도록 두는 것입니다. 앱에서 백그라운드로 작업을 실행하기 위해 서비스가 필요한 경우 작업을 실행해야 할 때가 아니면 서비스를 실행 상태로 유지하면 안 됩니다. 작업을 완료한 서비스는 반드시 중지해야 합니다. 그러지 않으면 의도치 않게 메모리 누수가 발생할 수 있습니다.
서비스를 시작하면 시스템에서는 항상 그 서비스의 프로세스를 실행 상태로 유지하려고 합니다. 그러면 서비스에서 사용하는 RAM을 다른 프로세스에서 사용할 수 없기 때문에 서비스 프로세스의 비용이 매우 높아집니다. 이로 인해 시스템에서 LRU 캐시에 유지할 수 있는 캐시된 프로세스의 수가 줄어들어 앱 전환의 효율성이 저하됩니다. 심지어 메모리가 부족하고 시스템에서 현재 실행 중인 모든 서비스를 호스팅하기에 충분한 프로세스를 유지할 수 없게 되면 시스템에서 스래싱이 발생할 수 있습니다.
일반적으로 지속적인 서비스 사용은 사용 가능한 메모리를 끊임없이 요구하므로 사용하지 않아야 합니다. 대신 JobScheduler
와 같은 대체 구현을 사용하는 것이 좋습니다. JobScheduler
를 사용하여 백그라운드 프로세스를 예약하는 방법에 관한 자세한 내용은 백그라운드 최적화를 참고하세요.
서비스를 사용해야 하는 경우 서비스 수명을 제한하는 가장 좋은 방법은 서비스를 시작한 인텐트의 처리가 끝나는 즉시 서비스가 자체 종료되는 IntentService
를 사용하는 것입니다.
자세한 내용은 백그라운드 서비스에서 실행을 참고하세요.
최적화된 데이터 컨테이너 사용
프로그래밍 언어에서 제공하는 일부 클래스는 휴대기기에서 사용하도록 최적화되지 않았습니다. 예를 들어, 일반 HashMap
구현은 모든 매핑에 별도의 항목 객체가 필요하므로 메모리 사용이 상당히 비효율적일 수 있습니다.
Android 프레임워크에는 SparseArray
, SparseBooleanArray
, LongSparseArray
를 비롯하여 여러 최적화된 데이터 컨테이너가 포함되어 있습니다.
예를 들어, SparseArray
클래스는 키와 때로는 값(항목당 한두 개의 객체 생성)을 시스템에서 오토박싱하지 않으므로 더 효율적입니다.
필요한 경우 언제든지 매우 가벼운 데이터 구조를 위해 원시 배열로 전환할 수 있습니다.
코드 추상화 관련 주의사항
추상화를 사용하면 코드 유연성과 유지 관리의 용이성을 높일 수 있어 개발자들이 프로그래밍 관행으로 추상화를 사용하는 경우가 많습니다. 그러나 추상화를 위해서는 실행해야 할 코드가 꽤 많이 필요하여 코드를 메모리에 매핑하는 데 더 많은 시간과 RAM이 필요하므로 상당한 비용이 듭니다. 따라서 추상화를 통한 이득이 크지 않다면 사용하지 않는 것이 좋습니다.
직렬화된 데이터에 라이트 protobuf 사용
프로토콜 버퍼는 Google에서 구조화된 데이터 직렬화를 위해 설계한 언어 및 플랫폼 중립적인 확장 가능한 메커니즘으로, XML과 비슷하지만 더 작고 빠르며 단순합니다. 데이터에 protobuf를 사용하기로 한 경우 항상 클라이언트 측 코드에서 라이트 protobuf를 사용해야 합니다. 일반 protobuf는 지나치게 상세한 코드를 생성하므로 RAM 사용 증가, APK 크기의 현저한 증가, 실행 속도 저하 등 앱에 다양한 문제를 일으킬 수 있습니다.
자세한 내용은 protobuf 리드미에서 '라이트 버전' 섹션을 참고하세요.
메모리 급변 방지
앞서 언급한 바와 같이 가비지 컬렉션 이벤트는 앱 성능에 영향을 주지 않습니다. 하지만 단기간에 많은 가비지 컬렉션 이벤트가 발생하면 가비지 컬렉터와 애플리케이션 스레드 사이에 필요한 상호작용으로 인해 배터리가 빠르게 소모되고 프레임을 설정하는 데 걸리는 시간이 미미하게나마 늘어날 수 있습니다. 시스템에서 가비지 컬렉션에 소비하는 시간이 길어질수록 배터리가 빠르게 소모됩니다.
메모리 급변으로 인해 수많은 가비지 컬렉션 이벤트가 발생하는 경우가 많습니다. 실제로 메모리 급변은 특정 기간 내에 발생하는 할당된 임시 객체의 수를 나타냅니다.
예를 들어, for
루프 내에 여러 임시 객체를 할당할 수 있습니다. 또는 뷰의 onDraw()
함수 내에 새 Paint
또는 Bitmap
객체를 생성할 수도 있습니다.
두 경우 모두 앱에서 많은 객체를 빠르게 대량으로 만듭니다.
이로 인해 새로운 객체 영역에서 사용 가능한 모든 메모리가 빠르게 소모되어 가비지 컬렉션 이벤트를 발생시킬 수 있습니다.
물론 문제를 해결하기 전에 코드에서 메모리 급변이 높은 위치를 찾아야 합니다. 이를 위해 Android 스튜디오의 메모리 프로파일러를 사용해야 합니다.
코드에서 문제 영역을 확인한 후에는 성능이 중요한 영역 내에 할당 수를 줄여 보세요. 또한 내부 루프에서 객체를 이동하거나 Factory 기반 할당 구조로 객체를 이동하는 것이 좋습니다.
사용 사례에 객체 풀이 도움이 되는지 살펴볼 수도 있습니다. 객체 풀을 사용하면 더 이상 필요하지 않은 객체 인스턴스가 삭제되는 대신 풀로 해제됩니다. 다음번에 이 유형의 객체 인스턴스가 필요해지면 인스턴스를 할당하는 대신 풀에서 획득할 수 있습니다.
주어진 상황에서 객체 풀을 사용하는 것이 적합한지 확인하려면 철저한 성능 평가를 진행해야 합니다. 객체 풀을 사용함으로써 성능이 더 악화되는 경우도 있습니다. 풀을 사용하면 할당이 방지되지만 그 밖의 오버헤드가 발생합니다. 예를 들어, 풀을 유지관리하려면 보통 동기화가 진행되는데, 이 과정에서 무시할 수 없는 오버헤드가 발생합니다. 또한 해제 중에 메모리 누수를 방지하기 위해 풀링된 객체 인스턴스를 지운 다음 획득 중에 인스턴스를 초기화하는 과정에서도 0이 아닌 오버헤드가 발생할 수 있습니다. 마지막으로, 풀에 필요한 것보다 많은 객체 인스턴스를 보유하고 있으면 GC에 부담이 가중됩니다. 객체 풀은 GC 호출 수를 줄이는 반면, 라이브 상태인(도달 가능한) 바이트의 수에 비례하기 때문에 호출마다 실행해야 하는 작업량을 늘립니다.
메모리를 많이 사용하는 리소스와 라이브러리 삭제
코드에 있는 일부 리소스와 라이브러리가 모르는 사이에 메모리를 빠르게 소모할 수 있습니다. 타사 라이브러리 또는 삽입된 리소스를 비롯하여 APK의 전체 크기는 앱에서 사용하는 메모리 양에 영향을 줄 수 있습니다. 코드에서 중복되거나 불필요하거나 지나치게 무거운 구성요소, 리소스 또는 라이브러리를 삭제하면 앱의 메모리 소비를 향상할 수 있습니다.
전체 APK 크기 줄이기
앱의 전체 크기를 줄여 앱의 메모리 사용량을 상당히 줄일 수 있습니다. 비트맵 크기, 리소스, 애니메이션 프레임, 타사 라이브러리는 모두 앱 크기에 영향을 미칠 수 있습니다. Android 스튜디오와 Android SDK는 리소스 및 외부 종속 항목의 크기를 줄이는 데 도움이 되는 여러 도구를 제공합니다. 이러한 도구는 R8 컴파일과 같은 최신 코드 축소 메서드를 지원합니다. Android 스튜디오 3.3 이하에서는 R8 컴파일 대신 ProGuard를 사용합니다.
앱의 전체 크기를 줄이는 방법에 관한 자세한 내용은 앱 크기를 줄이는 방법에 관한 가이드를 참고하세요.
종속성 주입에 Dagger 2 사용
종속성 주입 프레임워크는 작성하는 코드를 단순화하고 테스트 및 기타 구성 변경에 유용한 적응형 환경을 제공합니다.
앱에 종속성 주입 프레임워크를 사용하려는 경우 Dagger 2를 사용해 보세요. Dagger에서는 앱 코드 검색에 리플렉션을 사용하지 않습니다. Dagger의 정적 컴파일 시간 구현은 불필요한 런타임 비용이나 메모리를 사용하지 않아도 Android 앱에서 사용될 수 있습니다.
리플렉션을 사용하는 기타 종속성 주입 프레임워크는 코드에서 주석을 검색하여 프로세스를 초기화하는 경향이 있습니다. 이 프로세스에 CPU 주기와 RAM이 훨씬 더 많이 필요할 수 있으며 앱이 시작될 때 현저한 지연이 발생할 수 있습니다.
외부 라이브러리 사용 관련 주의사항
외부 라이브러리 코드는 모바일 환경을 위해 작성되지 않는 경우가 많으며, 모바일 클라이언트에서 작업할 때 사용하면 비효율적일 수 있습니다. 외부 라이브러리를 사용하기로 결정했다면 그 라이브러리를 휴대기기에 최적화해야 할 수 있습니다. 이러한 작업은 미리 계획하고 코드 크기와 RAM이 차지하는 공간 측면에서 라이브러리를 분석한 후 사용을 결정하세요.
일부 모바일에 최적화된 라이브러리도 다른 구현으로 인해 문제를 일으키는 경우가 있습니다. 예를 들어, 한 라이브러리는 마이크로 protobuf를 사용하고 다른 라이브러리는 라이트 protobuf를 사용하면 결과적으로 앱에 두 가지 다른 protobuf 구현이 존재합니다. 이러한 문제는 로깅, 분석, 이미지 로드 프레임워크, 캐싱, 그 밖에 예상하지 못한 여러 곳의 다양한 구현에서 발생할 수 있습니다.
ProGuard를 사용하면 적절한 플래그로 API와 리소스를 삭제할 수 있지만 라이브러리의 큰 내부 종속성은 삭제할 수 없습니다. 이러한 라이브러리에서 원하는 기능이 낮은 수준의 종속성을 요구할 수도 있습니다. 이는 라이브러리에서 Activity
서브클래스를 사용하거나(보통 종속 항목이 많음) 라이브러리에서 리플렉션을 사용할 때(흔히 발생하는 경우이며 ProGuard를 수동으로 조절하여 작동하는 데 많은 시간이 소요됨)와 같은 경우 특히 문제가 됩니다.
또한 수십 가지 기능 중 한두 가지만 사용하기 위해 공유 라이브러리를 사용하지 마세요. 사용하지 않을 수많은 코드와 오버헤드가 발생할 수 있습니다. 라이브러리 사용 여부를 고려할 때 필요한 내용과 확실히 일치하는 구현을 찾으세요. 또는 구현을 직접 만드는 방법도 있습니다.