앱 메모리 예산을 사용하면 앱이 자체 메모리 예산을 선언할 수 있으며, 이는 앱이 설정된 예산보다 많이 사용할 때 시스템이 메모리 사용량을 줄이도록 합니다. 이는 특히 시스템 및 번들 앱이나 메모리 제한 기기를 타겟팅하는 앱에 유용합니다. 개발자가 예상되는 메모리 작업 세트를 알고 앱이 시스템의 공유 RAM 리소스를 너무 많이 사용하지 않도록 하려는 경우에 유용합니다.
최근에 사용되지 않은 메모리 페이지를 삭제하는 메모리 제거 및 스왑을 사용하여 예산이 균형을 유지하므로 앱의 메모리 사용 공간이 현재 작업 집합에 집중됩니다. 앱이 선언된 예산을 초과하면 운영체제는 해당 앱을 구체적으로 타겟팅하여 회수합니다.
- 파일 지원 페이지 (예: 비활성 코드 및 매핑된 애셋)는 필요한 경우 저장소에서 다시 읽을 수 있으므로 먼저 삭제됩니다.
- 더티 파일 지원 페이지가 스토리지에 다시 작성되고 삭제됩니다.
- 익명 메모리 페이지 (예: 힙 할당)는 압축되어 zRAM으로 스왑됩니다.
예산이 작업 집합을 초과하지 않는 한 앱은 설정된 예산보다 더 많은 메모리를 사용하지 않으면서도 잘 작동합니다. 운영체제는 사용하지 않는 메모리를 제거하고 비활성 힙 페이지를 스와핑하도록 압축하여 프로세스를 종료하지 않고 메모리 할당이 제한되도록 합니다.
Android 매니페스트에서 예산 선언
AndroidManifest.xml에서 메모리 예산을 선언하는 것이 예산을 정의하는 기본이자 권장되는 방법입니다. 런타임 코드가 필요하지 않고 프로세스 시작 시 즉시 적용되며 운영체제에 명확한 계약을 제공합니다.
기준 예산 선언
대부분의 앱에서는 애플리케이션에 단일 예산을 정의하기만 하면 됩니다. <application> 태그 내에 직접 <memory-budget> 요소를 선언합니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.simpleapp">
<application
android:label="@string/app_name">
<!-- Baseline budget for the application -->
<memory-budget android:maxMb="256" />
</application>
</manifest>
이렇게 하면 패키지의 모든 프로세스 및 상태에 걸쳐 256MB 상주 메모리 예산이 설정됩니다. 앱의 메모리 사용 공간이 256MB를 초과하면 운영체제는 제거 및 스왑을 사용하여 비활성 메모리 페이지를 자릅니다.
프로세스 상태별로 예산 변경
앱은 사용자 표시 여부에 따라 다른 양의 메모리가 필요합니다.
- 포그라운드: 프로세스가 사용자와 상호작용하는 표시된 활동을 호스팅합니다. 이 상태는 활성 UI와 그래픽으로 인해 일반적으로 가장 큰 설치 공간을 갖습니다.
- 인식 가능: 프로세스가 사용자에게 인식되지만 표시되는 창을 호스팅하지 않습니다 (예: 미디어 재생 포그라운드 서비스, 세부 경로 안내 내비게이션 또는 활성 입력 방법 호스팅).
- 백그라운드: 프로세스가 백그라운드 작업, 리시버 또는 데이터 동기화를 실행하고 있습니다. 최소한의 설치 공간을 유지해야 합니다.
이러한 상태와 일치하도록 여러 <memory-budget> 절을 선언할 수 있습니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.simpleapp">
<application
android:label="@string/app_name">
<!-- Default budget for visible foreground UI -->
<memory-budget android:maxMb="200" />
<!-- Tighter budget when playing audio in background -->
<memory-budget
android:maxMb="120"
android:state="perceptible" />
<!-- Minimal budget when fully in background -->
<memory-budget
android:maxMb="48"
android:state="background" />
</application>
</manifest>
첫 번째 절에서는 android:state="foreground"를 지정할 필요가 없습니다. android:state가 없는 절은 모든 상태의 기본 대체로 작동합니다. 앱이 perceptible 또는 background 상태로 전환되면 아래의 더 제한적인 절이 예산을 재정의합니다.
멀티 프로세스 앱
애플리케이션이 여러 프로세스에 작업을 분산하는 경우 <processes> 내에서 <process> 태그를 사용하여 전용 프로세스 예산을 구성합니다.
예를 들어 스트리밍 음악 앱 (com.example.radio)을 생각해 보세요.
- 기본 프로세스: 표시되는 UI와 오디오 재생 엔진(
mediaPlayback포그라운드 서비스가 있는MediaSessionService)을 호스팅합니다. 표시되면 프로세스는 180MB 포그라운드 예산으로 작동합니다. 음악이 계속 재생되는 동안 사용자가 앱을 종료하면 프로세스가perceptible상태로 전환되며, 이 상태에서는 재생 엔진과 오디오 버퍼에 64MB 예산이 충분합니다. - 동기화 프로세스 (
:sync): 백그라운드 메타데이터 동기화 및 다운로드 색인을 실행하는 전용 프로세스입니다. 이 프로세스는 항상 백그라운드에서만 활성화되므로state="background"를 명시적으로 선언할 필요가 없습니다. 단일 예산이 적용됩니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.radio">
<application
android:label="@string/app_name">
<!-- Package baseline: main process with UI and audio playback -->
<memory-budget android:maxMb="180" />
<!-- Tighter budget when audio plays in the background -->
<memory-budget
android:maxMb="64"
android:state="perceptible" />
<!-- Dedicated background sync process -->
<processes>
<process android:process=":sync">
<memory-budget android:maxMb="32" />
</process>
</processes>
<service
android:name=".playback.AudioPlayerService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<service
android:name=".sync.PlaylistSyncService"
android:process=":sync"
android:exported="false" />
</application>
</manifest>
모든 하위 프로세스의 메모리 사용량은 프로세스 예산과 둘러싸는 패키지 예산 모두에 포함됩니다. 프로세스가 프로세스 예산이나 패키지 예산을 위반하면 런타임에 메모리 압력을 받습니다.
고밀도 디스플레이의 예산 조정
메모리 사용 공간이 한 번에 디스플레이에 그려야 하는 픽셀 수에 따라 크게 확장되는 애플리케이션(예: 화면 크기에 맞게 비트맵을 캐시하는 사진 갤러리 앱)의 경우 Android에서는 디스플레이 사양에 따라 예산을 동적으로 조정하는 두 가지 대체 메커니즘을 제공합니다.
디스플레이 밀도 버킷 (
android:additionalMbPerDensity)별로 조정:mdpi(1.0x / 160dpi)에 대한 디스플레이의 밀도 비율에 비례하여 메가바이트를 추가합니다. 이는 메모리 사용량이 UI 밀도 버킷에 따라 확장되는 경우에 적합합니다(예: 고해상도 래스터 드로어블 또는 UI 애셋 캐싱).<!-- Baseline 180MB + 16MB per 1.0x density ratio --> <memory-budget android:maxMb="180" android:additionalMbPerDensity="16" />mdpi디스플레이 (1.0x)에서 예산은 180 + 16 × 1 = 196MB입니다.xxhdpi디스플레이 (3.0x)에서는 예산이 180 + 16 × 3 = 228MB로 조정됩니다.물리적 디스플레이 해상도로 조정 (
android:additionalBytesPerDisplayPixel): 물리적 디스플레이 픽셀 (너비 × 높이)당 바이트를 직접 추가합니다. 이는 메모리 소비가 UI 밀도 버킷이 아닌 원시 디스플레이 픽셀 수에 직접 비례하는 전체 화면 그래픽 노출 영역이나 렌더링 버퍼, 전체 해상도 사진 캐시를 할당하는 애플리케이션에 적합합니다.<!-- Baseline 128MB + 16 bytes per physical display pixel --> <!-- For example, a 4-byte RGBA full-screen buffer with double or quadruple buffering --> <memory-budget android:maxMb="128" android:additionalBytesPerDisplayPixel="16" />1080p 디스플레이 (1080x2400, 약 259만 픽셀)에서는 기준 예산에 약 4,140만 바이트가 추가됩니다. 1440p 디스플레이(1440×3120, 약 449만 픽셀)에서는 약 71.8MB가 추가됩니다.
이 두 속성은 대체 속성입니다. 앱의 기본 확장 요소와 일치하는 속성을 선택하고 동일한 절에 두 속성을 모두 결합하지 마세요.
기기 폼 팩터에 맞게 전문화
스마트폰, 태블릿, Wear OS에 APK를 배송할 때는 android:feature 속성을 사용하여 다양한 하드웨어 타겟의 예산을 조정하세요.
Wear OS 시계에서는 RAM이 제한되어 있으며 앱의 UI와 기능 세트가 훨씬 간단합니다. watch 기능을 위해 특화된 더 엄격한 예산을 선언할 수 있습니다.
<!-- General phone and tablet baseline -->
<memory-budget android:maxMb="180" />
<!-- Wear OS override: simpler UI and constrained hardware -->
<memory-budget
android:maxMb="48"
android:feature="watch" />
해결 규칙: 마지막으로 적용되는 조항이 적용됨
애플리케이션 또는 프로세스에 여러 <memory-budget> 요소를 정의할 때 시스템은 매니페스트에 선언된 순서대로 평가합니다. 마지막으로 적용되는 예산 조항이 적용됩니다.
마지막으로 적용되는 예산이 우선하므로 순서가 중요합니다. 가장 일반적인 기준 예산을 먼저 배치하고 그 뒤에 더 구체적인 재정의 (예: 주별 또는 하드웨어별 조항)를 배치합니다.
XML 속성 참조
모든 메모리 크기 속성은 메가바이트 (MB)로 표현되며 Linux cgroup memory.current 요금 (Zygote와 같은 공유 메모리 제외)에 매핑됩니다.
| 속성 | 형식 | 기본값 | 설명 |
|---|---|---|---|
android:maxMb |
정수 (> 0) | 필수 | 기준 상주 메모리 예산 한도(MB)입니다. |
android:state |
Enum | 모두 | 이 예산이 적용되는 프로세스 상태입니다(foreground, perceptible, background). |
android:additionalMbPerDensity |
정수 (0 이상) | 0 |
mdpi (1.0x)에 상대적인 디스플레이 밀도 비율 단위당 추가할 메가바이트입니다. |
android:additionalBytesPerDisplayPixel |
정수 (0 이상) | 0 |
물리적 디스플레이 픽셀 (너비 × 높이)당 할당된 추가 바이트로, 서페이스 버퍼와 비트맵에 유용합니다. |
android:feature |
문자열 | 모두 | watch, automotive 또는 leanback와 같은 특정 하드웨어 기능을 선언하는 기기로 절을 제한합니다. |
런타임 API (보조 동적 옵션)
AndroidManifest.xml에서 예산을 정적으로 선언하는 것이 거의 모든 앱에 권장되는 솔루션입니다. 하지만 동적 워크로드가 있는 애플리케이션이나 런타임 실험의 경우 Android에서는 런타임 SDK 및 NDK API를 보조 옵션으로 제공합니다.
런타임 API를 사용하면 다음 작업을 할 수 있습니다.
- 현재 메모리 사용량과 유효 예산을 쿼리합니다.
- 프로세스 예산을 동적으로 하향 조정합니다.
- 예산 초과 이벤트를 수신하여 운영체제에서 직접 회수를 트리거하기 전에 캐시를 선제적으로 정리합니다.
Kotlin API (MemoryBudgetManager)
MemoryBudgetManager 시스템 서비스는 Android 17 QPR2 (Android 26Q4 SDK 출시, API 수준 37.2/Build.VERSION_CODES_FULL.CINNAMON_BUN_2)부터 사용할 수 있습니다.
서비스 가져오기
val budgetManager = context.getSystemService(MemoryBudgetManager::class.java)
쿼리 사용량 및 예산
// Query current memory charged to this process and the package UID
val processUsageBytes = budgetManager.processCurrentUsageBytes
val packageUsageBytes = budgetManager.packageCurrentUsageBytes
// Query effective budgets (returns LIMIT_IS_DISABLED if unconstrained)
val processBudgetBytes = budgetManager.processBudgetBytes
val packageBudgetBytes = budgetManager.packageBudgetBytes
예산을 동적으로 설정하거나 삭제
런타임에 더 엄격한 예산을 설정하여 경량 작업 중에 메모리를 제한하거나 작업이 완료되면 메모리를 지울 수 있습니다.
// Set a tighter dynamic budget on the current process (e.g., 96 MB)
try {
budgetManager.processBudgetBytes = 96L * 1024L * 1024L
} catch (e: IllegalArgumentException) {
// Thrown if the budget is <= 0 or exceeds the manifest-declared ceiling
Log.e(TAG, "Requested budget exceeds manifest or system ceiling", e)
}
// Clear the dynamic process budget to restore the manifest limit
budgetManager.clearProcessBudget()
예산 초과 압력 콜백 수신 대기
앱은 메모리 사용량이 예산 기준점을 초과할 때 알림을 받도록 리스너를 등록할 수 있습니다. 이를 통해 앱은 운영체제가 직접 회수 지연 시간을 트리거하기 전에 사전 애플리케이션 수준 정리 (예: 메모리 내 비트맵 캐시 삭제)를 실행할 수 있습니다.
val listener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
Log.w(TAG, "Process exceeded memory budget of $budgetBytes bytes")
// Proactively evict caches to release memory
imageTileCache.evictAll()
}
// Register on the main Looper
budgetManager.registerProcessOverBudgetListener(mainLooper, listener)
// When done (e.g., in onStop)
budgetManager.unregisterProcessOverBudgetListener(listener)
예산 초과 콜백 권장사항:
- 신속성: 복구 작업은 즉각적인 해결책을 제공해야 합니다. 압력으로 인한 복잡한 계산으로 인해 성능이 저하됩니다.
- 할당 방지: 콜백 내에서 새 객체를 할당하거나 새 스레드를 시작하지 마세요. 이렇게 하면 즉시 운영체제 직접 회수가 트리거될 수 있습니다.
- 수익률이 높은 타겟에 집중: 큰 비트맵이나 렌더링 버퍼를 삭제하거나 메모리 매핑 파일을 닫는 것이 작은 객체를 많이 해제하는 것보다 훨씬 효과적입니다.
네이티브 NDK API (<android/memory_budget_manager.h>)
네이티브 앱은 libandroid.so에서 노출하는 C NDK API를 사용할 수 있습니다.
CMake 구성
find_library(android-lib android)
target_link_libraries(my_native_engine PRIVATE ${android-lib})
헤더 및 쿼리 사용량 포함
#include <android/memory_budget_manager.h>
// Query current memory usage
int64_t process_usage = AMemoryBudgetManager_getProcessCurrentUsageBytes();
int64_t package_usage = AMemoryBudgetManager_getPackageCurrentUsageBytes();
// Query current budget
int64_t process_budget = 0;
AMemoryBudgetResult result = AMemoryBudgetManager_getProcessBudget(&process_budget);
if (result == AMEMORY_BUDGET_RESULT_SUCCESS) {
// Current budget available in process_budget
} else if (result == AMEMORY_BUDGET_RESULT_LIMIT_IS_DISABLED) {
// No budget is currently active
}
네이티브 예산을 동적으로 구성
// Set a tighter process budget (e.g. 160MB)
AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(160LL * 1024 * 1024);
if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
const char* error_msg = AMemoryBudgetManager_resultToString(result);
// Handle error (e.g. AMEMORY_BUDGET_RESULT_ERROR_EXCEEDS_MANIFEST_LIMIT)
}
// Clear the dynamic budget to resume manifest limits
AMemoryBudgetManager_clearProcessBudget();
메모리 압력 이벤트 모니터링
NDK는 메모리 이벤트를 모니터링하는 두 가지 방법을 제공합니다.
- 상위 수준 감시자 (
AMemoryBudgetManager_Watcher_create): 자동 디바운싱을 사용하여ALooper의 이벤트를 모니터링합니다. - 하위 수준 파일 설명자:
AMemoryBudgetManager_getProcessMemoryPressureFd는 맞춤epoll엔진 루프에 직접 통합할 수 있는 네이티브 파일 설명자를 반환합니다.
void onMemoryPressure(int32_t event_mask, const AMemoryBudgetEvents* events, void* userdata) {
// High-yield eviction of unused native textures or geometry caches
purgeNativeTextureCaches();
}
// Register watcher on an ALooper with a 1000ms debounce interval
AMemoryBudgetManagerWatcher* watcher = AMemoryBudgetManager_Watcher_create(
looper,
AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
1000 /* debounce_ms */,
&onMemoryPressure,
NULL /* userdata */
);
// When done:
AMemoryBudgetManager_Watcher_destroy(watcher);
Runtime API 예시
다음 예는 Kotlin과 C++에서 런타임 API를 구현하는 방법을 보여줍니다.
Kotlin 예: 적응형 이미지 편집기
이 예에서는 사용자가 다중 레이어 편집 캔버스를 열 때 메모리 예산을 동적으로 늘리고 썸네일 갤러리 뷰로 돌아갈 때 동적 예산을 지우는 이미지 편집 앱 (com.example.imageeditor)을 보여줍니다. 또한 압력 하에서 캐시된 미리보기 비트맵을 삭제하는 OnOverBudgetListener를 등록합니다.
package com.example.imageeditor.ui
import android.app.Activity
import android.app.MemoryBudgetManager
import android.graphics.Bitmap
import android.os.Bundle
import android.util.Log
import android.util.LruCache
class ImageEditorActivity : Activity() {
private lateinit var budgetManager: MemoryBudgetManager
// In-memory cache for rendered preview tiles (32MB limit)
private val previewCache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
private val overBudgetListener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
Log.w(TAG, "Process memory pressure detected (budget: ${budgetBytes / 1048576}MB). Evicting preview cache.")
previewCache.evictAll()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
budgetManager = getSystemService(MemoryBudgetManager::class.java)
}
override fun onStart() {
super.onStart()
// Register listener for process-level memory breaches
budgetManager.registerProcessOverBudgetListener(mainLooper, overBudgetListener)
}
override fun onStop() {
super.onStop()
budgetManager.unregisterProcessOverBudgetListener(overBudgetListener)
}
/**
* Called when the user enters the high-resolution editing canvas.
*/
fun enterEditingCanvas() {
try {
// Dynamically set budget to 256MB for the editing canvas
budgetManager.processBudgetBytes = 256L * 1024L * 1024L
Log.i(TAG, "Dynamic budget applied: 256MB")
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Could not apply dynamic budget", e)
}
}
/**
* Called when the user exits the editor back to the thumbnail gallery.
*/
fun exitToGallery() {
previewCache.trimToSize(8 * 1024 * 1024)
// Clear dynamic budget; restores the baseline manifest budget
budgetManager.clearProcessBudget()
}
companion object {
private const val TAG = "ImageEditor"
}
}
NDK C++ 예: 네이티브 3D 엔진
이 예에서는 활성 그래픽 품질 수준에 따라 메모리 예산을 관리하는 네이티브 C++ 게임 엔진을 보여줍니다. 예산이 초과되면 ALooper에서 AMemoryBudgetManager_Watcher_create를 사용하여 텍스처 밉맵을 언로드합니다.
#include <android/memory_budget_manager.h>
#include <android/looper.h>
#include <android/log.h>
#define LOG_TAG "Native3DEngineMemory"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
class MemoryGovernor {
public:
MemoryGovernor() : mWatcher(nullptr) {}
~MemoryGovernor() {
stopMonitoring();
}
// Configures process budget based on user graphics quality settings
bool setQualityBudget(int qualityLevel) {
int64_t targetBytes = 0;
switch (qualityLevel) {
case 0: // Low (budget: 128MB)
targetBytes = 128LL * 1024 * 1024;
break;
case 1: // Medium (budget: 256MB)
targetBytes = 256LL * 1024 * 1024;
break;
case 2: // High (budget: 512MB)
targetBytes = 512LL * 1024 * 1024;
break;
default:
// Clear dynamic override and restore manifest limit
AMemoryBudgetManager_clearProcessBudget();
return true;
}
AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(targetBytes);
if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
LOGW("Could not set quality budget: %s", AMemoryBudgetManager_resultToString(result));
return false;
}
return true;
}
bool startMonitoring(ALooper* looper) {
if (!looper) return false;
// Monitor process budget events, debounced to at most once every 1000ms
mWatcher = AMemoryBudgetManager_Watcher_create(
looper,
AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
1000,
&MemoryGovernor::onPressureEvent,
this
);
return mWatcher != nullptr;
}
void stopMonitoring() {
if (mWatcher) {
AMemoryBudgetManager_Watcher_destroy(mWatcher);
mWatcher = nullptr;
}
}
void unloadUnusedTextures() {
LOGW("Memory pressure callback triggered. Purging cached texture mipmaps...");
// Fast, high-yield eviction without allocating memory
}
private:
static void onPressureEvent(
int32_t event_mask,
const AMemoryBudgetEvents* events,
void* userdata
) {
auto* governor = static_cast<MemoryGovernor*>(userdata);
governor->unloadUnusedTextures();
}
AMemoryBudgetManagerWatcher* mWatcher;
};