우수사례: OOM 비정상 종료 디버그

메모리 부족(OutOfMemoryError) 비정상 종료는 앱이 할당된 Java 힙을 소진하고 새 메모리 할당을 처리할 수 없을 때 발생합니다. OutOfMemoryError 비정상 종료 보고서의 스택 트레이스는 메모리가 부족할 때 실패한 할당 호출만 표시하고 나머지 힙을 유지하는 객체는 표시하지 않으므로 근본 원인이 드러나는 경우는 거의 없습니다.

ProfilingManager를 사용하면 현장 및 로컬에서 기기의 Java 힙 덤프를 캡처하여 Perfetto UI에서 객체 보관 그래프를 검사하고 메모리를 점유하는 요소를 정확히 파악할 수 있습니다.

시나리오

사용자가 OomSampleActivity와 상호작용할 때 비정상 종료 보고 도구에서 프로덕션의 OutOfMemoryError 비정상 종료 클러스터가 증가하고 있다고 플래그를 지정한다고 가정해 보겠습니다.

조사를 위해 ProfilingManager에 TRIGGER_TYPE_OOM 트리거를 등록하여 OutOfMemoryError가 발생하면 시스템이 Java 힙 덤프를 자동으로 캡처하도록 합니다.

힙 덤프 캡처

Android 17(API 수준 37) 이상에서 OutOfMemoryError 비정상 종료가 발생할 때 힙 덤프를 캡처하려면 ProfilingTrigger.TRIGGER_TYPE_OOM를 사용하여 트리거 기반 프로파일링을 구성합니다. 앱에서 처리되지 않은 OutOfMemoryError가 발생하면 시스템은 자동으로 새 PROFILING_TYPE_JAVA_HEAP_DUMP 캡처를 시작합니다.

비정상 종료가 발생하면 앱 프로세스가 종료되므로 시스템은 백그라운드에서 힙 덤프 저장을 완료하고 결과를 큐에 추가합니다(트리거 수명 주기 참고). 앱 시작이 완료된 후 registerForAllProfilingResults를 사용하여 백그라운드 Executor에 전역 결과 리스너를 등록합니다(앱 시작 속도가 느려지지 않음). 그러면 사용자가 다음에 앱을 실행할 때 앱이 대기열에 추가된 ProfilingResult를 수신합니다. addProfilingTriggers(앱 다시 시작 시에도 유지되며 한 번만 호출하거나 트리거를 업데이트할 때 호출해야 함)와 달리 registerForAllProfilingResults는 앱을 시작할 때마다 호출해야 합니다.

Kotlin

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerOomTrigger(context: Context) {
    val profilingManager =
        context.getSystemService(ProfilingManager::class.java) ?: return
    val callbackExecutor: Executor = Executors.newSingleThreadExecutor()

    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (
            profilingResult.errorCode == ProfilingResult.ERROR_NONE &&
            profilingResult.triggerType == ProfilingTrigger.TRIGGER_TYPE_OOM
        ) {
            val heapDumpPath = profilingResult.resultFilePath
            // Schedule a background job to compress and upload heapDumpPath
        }
    }

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback)

    val oomTrigger = ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
        .build()
    profilingManager.addProfilingTriggers(listOf(oomTrigger))
}

자바

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
public static void registerOomTrigger(Context context) {
    ProfilingManager profilingManager = context.getSystemService(ProfilingManager.class);
    if (profilingManager == null) return;
    Executor callbackExecutor = Executors.newSingleThreadExecutor();

    Consumer<ProfilingResult> resultCallback =
        profilingResult -> {
            if (profilingResult.getErrorCode() == ProfilingResult.ERROR_NONE
                && profilingResult.getTriggerType() == ProfilingTrigger.TRIGGER_TYPE_OOM) {
                String heapDumpPath = profilingResult.getResultFilePath();
                // Schedule a background job to compress and upload heapDumpPath
            }
        };

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback);

    ProfilingTrigger oomTrigger =
        new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
            .build();
    profilingManager.addProfilingTriggers(Collections.singletonList(oomTrigger));
}

프로필 가독화

OutOfMemoryError가 캡처를 트리거하고 콜백이 다음 앱 실행 시 ProfilingResult를 수신하면 프로필 파일을 서버에 업로드하거나 adb를 사용하여 개발 머신으로 가져옵니다. R8 코드 축소 및 난독화를 사용하는 출시 빌드에서 프로필을 캡처한 경우 분석 전에 워크스테이션 또는 서버에서 오프라인으로 가독화합니다. 가독화가 없으면 R8은 원시 힙 덤프에서 Java 및 Kotlin 클래스, 필드, 메서드 이름을 난독화합니다(예: 원래 클래스 이름을 a.b로 변환).

Perfetto의 trace_processor bundle 명령어를 --proguard-map 플래그와 함께 사용하여 캡처된 프로필을 정확한 출시 빌드에 생성된 R8 mapping.txt 파일과 함께 패키징합니다.

trace_processor bundle \
  --proguard-map /path/to/mapping.txt \
  profile_trigger-type-7_2026-05-06-14-12-40-748_uid-10316.perfetto-java-heap-dump \
  deobfuscated_oom.tar

이 명령어는 원본 프로필과 가독화 매핑을 번들로 묶은 보강된 보관 파일인 deobfuscated_oom.tar를 생성합니다. Perfetto UI에서 이 보관 파일을 열거나 trace_processor로 쿼리하면 Perfetto가 모든 원본 클래스, 필드, 메서드 이름을 자동으로 복원합니다. 자세한 내용은 Perfetto 기호화 및 가독화 문서를 참고하세요.

힙 덤프 분석

다음 단계에 따라 Perfetto UI에서 가독화된 힙 덤프를 수동으로 검사하거나 AI 에이전트를 사용하여 OOM 조사를 자동화할 수 있습니다.

Perfetto UI에서 가독화된 힙 덤프를 열고 왼쪽 탐색 메뉴에서 Heapdump Explorer를 선택합니다. 측정이 객체 크기로 설정된 플레임 그래프 탭에서 그림 1의 유지 계층 구조가 즉시 눈에 띕니다.

StatsReporter가 총 240.28MiB의 OomSampleActivity 인스턴스 8개를 유지하고 있음을 보여주는 Perfetto UI Heapdump Explorer Flame 그래프
그림 1. OomSampleActivity의 유지된 인스턴스 8개를 보여주는 힙 덤프 Flame 그래프

그림 1의 Flame 그래프를 검사하면 몇 가지 주요 결과를 확인할 수 있습니다.

  • 대규모 메모리 보존: 총 힙 254.79MiB(root) 중 com.example.sampleapp.OomSampleActivity는 누적 크기 240.28MiB(전체 앱 힙의 94.31%)를 보존하며, 이 중 대부분은 하위 byte[] 배열에 있습니다.
  • 여러 실시간 활동 인스턴스: OomSampleActivity 프레임을 선택하면 Self Count: 8이 표시됩니다. OomSampleActivity 인스턴스는 한 번에 하나만 화면에 표시되어야 하지만 8개의 개별 인스턴스가 메모리에 동시에 활성 상태로 존재합니다.
  • 유지 객체 경로: Flame 그래프에서 OomSampleActivity의 상위 프레임을 읽으면 이러한 인스턴스를 유지하는 참조 체인이 표시됩니다. com.example.sampleapp.StatsReporter는 java.util.concurrent.CopyOnWriteArrayList(java.lang.Object[]에서 지원)를 보유하며, 이는 8개의 OomSampleActivity 인스턴스를 모두 직접 참조합니다.

코드에서 근본 원인 식별

StatsReporter에서 OomSampleActivity를 가리키는 유지 경로를 사용하여 코드베이스의 두 클래스를 검사합니다.

StatsReporter는 StatsListener 콜백 목록을 유지하고 백그라운드 폴링 스레드에서 주기적으로 업데이트를 게시하는 싱글톤입니다.

Kotlin

class StatsReporter private constructor() {
    fun interface StatsListener {
        fun onReportStats(stats: String)
    }

    private val listeners: MutableList<StatsListener> = CopyOnWriteArrayList()
    private var isReporting = false

    init {
        startReporting()
    }

    fun registerListener(listener: StatsListener) {
        if (!listeners.contains(listener)) {
            listeners.add(listener)
        }
    }

    fun unregisterListener(listener: StatsListener) {
        listeners.remove(listener)
    }

    private fun startReporting() {
        if (isReporting) return
        isReporting = true

        Thread({
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000)
                } catch (e: InterruptedException) {
                    Thread.currentThread().interrupt()
                    break
                }

                for (listener in listeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread")
                }
            }
        }, "StatsReporterPollingThread").start()
    }

    companion object {
        private var instance: StatsReporter? = null

        @JvmStatic
        fun getInstance(): StatsReporter {
            return instance ?: StatsReporter().also { instance = it }
        }
    }
}

자바

public class StatsReporter {
    public interface StatsListener {
        void onReportStats(String stats);
    }

    private static StatsReporter sInstance;

    private final List<StatsListener> mListeners = new CopyOnWriteArrayList<>();
    private boolean mIsReporting = false;

    private StatsReporter() {
        startReporting();
    }

    public static StatsReporter getInstance() {
        if (sInstance == null) {
            sInstance = new StatsReporter();
        }
        return sInstance;
    }

    public void registerListener(StatsListener listener) {
        if (!mListeners.contains(listener)) {
            mListeners.add(listener);
        }
    }

    public void unregisterListener(StatsListener listener) {
        mListeners.remove(listener);
    }

    private void startReporting() {
        if (mIsReporting) return;
        mIsReporting = true;

        new Thread(() -> {
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }

                for (StatsListener listener : mListeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread");
                }
            }
        }, "StatsReporterPollingThread").start();
    }
}

다음으로 OomSampleActivity를 검사합니다.

Kotlin

class OomSampleActivity : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        StatsReporter.getInstance().registerListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleActivity"
    }
}

자바

public class OomSampleActivity extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleActivity";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

이 코드는 StatsReporter가 OomSampleActivity의 인스턴스 8개를 유지하는 이유를 확인합니다.

  1. 각 OomSampleActivity 인스턴스는 30MiB byte[] 버퍼(Kotlin의 heavyData, Java의 mHeavyData)를 할당하고 onCreate의 싱글톤 StatsReporter에 리스너로 등록합니다.
  2. OomSampleActivity는 시스템에서 소멸될 때 unregisterListener를 호출하지 않습니다. StatsReporter는 활동보다 오래 지속되고 등록된 모든 리스너에 대한 강력한 참조를 보유하므로 가비지 컬렉터는 소멸된 인스턴스나 30MiB byte[] 배열을 회수할 수 없습니다.
  3. Android 활동 수명 주기에서 화면 회전 중에 시스템이 OomSampleActivity를 소멸시키고 다시 만들 때마다 StatsReporter에서 또 다른 30MiB 인스턴스가 누출됩니다. 7번 회전하면(8개 인스턴스 × 30MiB ≈ 240MiB) 힙이 가득 차고 앱이 OutOfMemoryError로 인해 비정상 종료됩니다.

누수 수정

메모리 누수를 수정하려면 활동에서 onDestroy를 재정의하고 StatsReporter.getInstance().unregisterListener(this)를 호출하여 시스템이 활동을 소멸시킬 때 싱글톤이 참조를 해제하도록 합니다.

Kotlin

class OomSampleActivityFixed : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        // FIX: Unregister the listener to prevent the memory leak
        // when the Activity is destroyed (for example, during screen rotation).
        StatsReporter.getInstance().unregisterListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleFixed"
    }
}

자바

public class OomSampleActivityFixed extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleFixed";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // FIX: Properly unregister the listener to prevent the memory leak
        // when the Activity is destroyed (e.g., during screen rotation).
        StatsReporter.getInstance().unregisterListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

AI 에이전트로 OOM 조사 자동화

Flame 그래프를 수동으로 검사하고 파일 간 참조를 추적하는 대신 Perfetto AI 기술로 구성된 AI 코딩 에이전트를 사용하여 힙 덤프를 분석하고 코드베이스와 연결할 수 있습니다.

Analyze the Perfetto OOM heap dump at </path/to/profile>, which corresponds to
the codebase located at </path/to/codebase> using the Perfetto AI skill and find
the root cause of the OutOfMemoryError.

에이전트는 trace_processor를 사용하여 힙 덤프를 쿼리하고, StatsReporter에서 유출된 OomSampleActivity 인스턴스로의 도미네이터 경로를 추적하며, 코드베이스에서 두 클래스를 모두 검사합니다. 수동 개입 없이 OomSampleActivity가 등록을 취소하지 않고 onCreate에 리스너를 등록한다는 동일한 결론에 도달하고 onDestroy에 unregisterListener(this)를 추가하는 패치를 생성합니다(다음 보고서는 비정상 종료 시점에 유지된 인스턴스가 7개 있는 별도의 캡처에서 생성됨).

# Perfetto OOM Heap Dump Analysis Report

## 1. Orienting Context & First-Pass Triage

| Property | Value |
| :--- | :--- |
| **Process Name** | `com.example.sampleapp` |
| **Sample Timestamp (`graph_sample_ts`)** | `3433221241585` |
| **Leaf Retained Class** | `byte[]` (7 instances) |
| **Leaf Self Size** | `220,201,044` bytes (~210 MiB) |

### Dominator Tree Retention Path

Querying the `android.memory.heap_graph.dominator_tree` standard library module identifies the following largest dominated class path from GC root to leaf allocation:

    [ROOT_JAVA_FRAME] com.example.sampleapp.StatsReporter [1]
      -> java.util.concurrent.CopyOnWriteArrayList [1]
      -> java.lang.Object[] [1]
      -> com.example.sampleapp.OomSampleActivity [7]
      -> byte[] [7] (220,201,044 bytes)
## 2. Root Cause Analysis

> **Important:** The `OutOfMemoryError` is caused by an **Activity listener leak**: `OomSampleActivity` (`app/src/main/java/com/example/sampleapp/OomSampleActivity.java#L8-L27`) registers itself as a listener on the long-lived `StatsReporter` (`app/src/main/java/com/example/sampleapp/StatsReporter.java#L6-L61`) singleton in `onCreate()`, but never unregisters itself in `onDestroy()`.

### Step-by-Step Retention Mechanism

1. **Per-Activity 30 MiB Allocation:**
   Each instance of `OomSampleActivity` (`OomSampleActivity.java#L11-L12`) allocates a 30 MiB byte array (`30 * 1024 * 1024 = 31,457,280` bytes):
   ```java
   // OomSampleActivity.java:12
   private final byte[] mHeavyData = new byte[30 * 1024 * 1024];
   ```

2. **Strong Reference Registration in `onCreate()`:**
   When created, `OomSampleActivity.onCreate()` (`OomSampleActivity.java#L15-L20`) registers `this` with `StatsReporter` (`StatsReporter.java#L29-L33`):
   ```java
   // OomSampleActivity.java:19
   StatsReporter.getInstance().registerListener(this);
   ```

3. **GC Root Pinning via Background Thread & Singleton:**
   `StatsReporter` (`StatsReporter.java#L11-L60`) is both a static singleton (`sInstance`) and the owner of an active background thread (`StatsReporterPollingThread`, which holds a `[ROOT_JAVA_FRAME]` GC root reference while sleeping/looping in `startReporting()`). It stores listeners in `mListeners` (`CopyOnWriteArrayList<StatsListener>`).

4. **Leak Accumulation Across Activity Recreations:**
   Because `OomSampleActivity` does not override `onDestroy()` to call `StatsReporter.unregisterListener(this)`, every destroyed instance of `OomSampleActivity` remains strongly reachable in `mListeners`. After 7 Activity creations (7 * 30 MiB = 220,200,960 bytes plus array headers = `220,201,044` bytes), the Java heap is exhausted.

---

## 3. Actionable Implementation Plan

### 1. Unregister the Listener in `OomSampleActivity.onDestroy()`

Update `OomSampleActivity.java` to unregister the listener when the `Activity` is destroyed (matching the fix in `OomSampleActivityFixed.java`):

```diff
--- a/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
+++ b/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
@@ -19,4 +19,10 @@
         StatsReporter.getInstance().registerListener(this);
     }

+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        StatsReporter.getInstance().unregisterListener(this);
+    }
+
     @Override
```

### 2. Optional Architectural Hardening in `StatsReporter`

* **Lifecycle-Aware Registration:** Have `StatsReporter.registerListener()` accept a `LifecycleOwner` and attach a `DefaultLifecycleObserver` that automatically invokes `unregisterListener()` on `ON_DESTROY`.
* **Weak References:** Store `WeakReference<StatsListener>` entries in `StatsReporter` and evict cleared references during iteration so forgotten `unregisterListener()` calls cannot retain entire `Activity` hierarchies.

수정사항 확인

테스트 기기에서 로컬로 또는 프로덕션에서 누수가 해결되었는지 확인합니다.

문제를 로컬에서 재현할 수 있는 경우

TRIGGER_TYPE_OOM는 OutOfMemoryError가 발생할 때만 힙 덤프를 캡처하므로 성공적으로 수정하면 트리거가 실행되지 않습니다. 힙을 검사하고 누수가 사라졌는지 확인하려면 화면을 회전한 후 명령줄에서 주문형 자바 힙 덤프를 캡처합니다.

  1. 비율 제한기를 사용 중지(rate_limiter.disabled true)하고 임시 결과를 유지(delete_temporary_results.disabled true)하여 시스템이 명령줄 캡처를 /data/misc/perfetto-traces/profiling/에 보관하도록 합니다.
  2. 테스트 기기에서 OomSampleActivityFixed를 열고 화면을 8회 이상 회전하여 앱이 더 이상 비정상 종료되지 않는지 확인하고 앱을 포그라운드에 유지합니다.
  3. cmd profiling_service request-profiling 셸 명령어를 사용하여 주문형 Java 힙 덤프(profiling_type 1)를 트리거합니다.

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. /data/misc/perfetto-traces/profiling/에서 유지된 프로필을 가져와 Perfetto UI에서 엽니다.

수정 후 Perfetto UI Heapdump Explorer Flame 그래프. 30.01MiB를 유지하는 OomSampleActivityFixed의 단일 인스턴스를 보여줍니다.
그림 2. 수정 후 OomSampleActivityFixed의 단일 인스턴스를 보여주는 힙 덤프 Flame 그래프

그림 2와 같이 여러 번 화면을 회전한 후에는 다음이 적용됩니다.

  • com.example.sampleapp.OomSampleActivityFixed는 회전할 때마다 인스턴스를 누적하는 대신 자체 개수가 1입니다.
  • 누적 Retained Size는 30.01MiB(총 힙 44.27MiB 중)로 일정하게 유지되며, 이는 가비지 컬렉터가 이전 Activity 인스턴스를 회수함을 확인해 줍니다.

로컬에서 재현하기 어려운 문제인 경우

복잡한 멀티스크린 탐색, 장기 지속 세션 또는 특정 사용자 데이터로 인해 테스트 기기에서 안정적으로 재현할 수 없는 OutOfMemoryError이 발생하는 경우 출시 후 프로덕션에서 수정사항을 검증합니다.

  • 비정상 종료 보고 도구에서 OutOfMemoryError 비정상 종료 클러스터를 출시 전반의 프록시 측정항목으로 모니터링하여 OomSampleActivity와 관련된 비정상 종료가 감소하는지 확인합니다.
  • 업데이트된 출시 버전에서 수집된 새로운 TRIGGER_TYPE_OOM 힙 덤프를 검사하여 StatsReporter가 더 이상 폐기된 OomSampleActivity 인스턴스를 유지하지 않는지 확인합니다.