아티클 목록으로 가기

프로덕션에서 OOM과 Memory Limiter 종료 잡아내기: ProfilingManager 내부 들여다보기

skydovesJaewoong Eum (skydoves)||15분 소요

프로덕션에서 OOM과 Memory Limiter 종료 잡아내기: ProfilingManager 내부 들여다보기

모든 테스트를 통과한 릴리스인데도, 크래시 대시보드에서 OutOfMemoryError가 계속 늘어납니다. 스택 트레이스는 이미지 디코더 안의 byte[] 할당을 가리키지만, 누수가 있는 곳은 거기가 아닙니다. 그 할당은 공간이 바닥난 순간에 마침 실행되었을 뿐입니다. Android 17에서는 이 정도 단서조차 얻지 못하실 수도 있습니다. Memory Limiter를 다룬 이전 글에서 살펴본 것처럼, 프로세스는 anon + swap 사용량 때문에 종료될 수 있는데, 이때 ApplicationExitInfo에 남는 단서는 MemoryLimiter:AnonSwap뿐입니다. 필요한 것은 프로세스가 죽은 그 순간 사용자 기기에서 뜬 힙 덤프(heap dump)이며, 이제는 ProfilingManager로 이를 얻으실 수 있습니다. 그러면 자연스럽게 의문이 생깁니다. 이미 죽어 가는 프로세스에서 힙 덤프는 어떻게 살아남을까요?

이 글에서는 트리거를 등록하는 코드 몇 줄에서 출발해, OutOfMemoryError가 앱의 크래시 핸들러에서 system_server까지 이어지는 경로를 따라갑니다. 액티비티 매니저가 죽어 가는 프로세스를 어떻게 붙잡아 두는지, ART가 포크(fork)한 자식 프로세스에서 힙을 어떻게 덤프하는지, 결과가 왜 때로는 다음 실행 때에야 도착하는지, 애초에 덤프를 받을 수 있을지를 가르는 rate limit(호출 빈도 제한)은 무엇인지, 그리고 Memory Limiter의 이상 징후(anomaly) 트리거는 어떻게 다른 경로를 타는지를 차례로 살펴봅니다. 타이밍 수치는 Android 17 소스와, Android 17 에뮬레이터에서 실행한 OOM 테스트 앱에서 얻었습니다.

근본적인 문제: 스택 트레이스는 마지막 할당을 가리킨다

많은 앱에서 누수를 일으키는 패턴을 하나 살펴보겠습니다. 리스너를 모으기만 하고 절대 놓아주지 않는 싱글톤입니다.

object SessionEvents {
  private val listeners = CopyOnWriteArrayList<(SessionEvent) -> Unit>()

  fun register(listener: (SessionEvent) -> Unit) {
    listeners += listener
  }
}

class EditorActivity : ComponentActivity() {
  private val previewBuffer = ByteArray(30 * 1024 * 1024)

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    SessionEvents.register { event -> render(event, previewBuffer) }
  }
}

모든 EditorActivity는 액티비티 자신을 캡처하는 람다를 등록하지만, 이 등록을 해제하는 곳은 어디에도 없습니다. 화면을 회전하거나 다시 열 때마다 30 MB 버퍼가 하나씩 더 해제되지 못하고 남게 됩니다. 결국 크래시는 공간이 가장 먼저 바닥나는 할당에서 일어나는데, 다른 화면에서 일어난 비트맵 디코딩일 수도 있습니다. 스택 트레이스는 힙이 어디에서 바닥났는지를 알려 줄 뿐, 무엇이 힙을 채웠는지는 알려 주지 않습니다.

그 질문에 답해 주는 것이 힙 덤프입니다. 힙 덤프를 보면 어떤 객체가 붙잡혀 있는지, 그리고 어떤 참조 경로가 그 객체들을 살려 두는지 알 수 있습니다. 다만 힙 덤프는 힙이 아직 존재할 때 떠야 하는데, 메인 스레드에서 OutOfMemoryError가 나는 순간이 바로 프로세스가 정리되기 직전입니다. 이어지는 내용은 모두 플랫폼이 그 틈에 스냅샷을 어떻게 찍는지에 관한 이야기입니다.

힙 덤프 등록하기: 호출 두 번과 리스너 하나

실제로 작성하실 코드는 다음과 같습니다. 전역 결과 리스너 하나와 트리거 두 개를 등록하는데, 하나는 OutOfMemoryError용이고 다른 하나는 Memory Limiter용입니다.

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerMemoryTriggers(context: Context, uploader: HeapDumpUploader) {
  val profilingManager = context.getSystemService(ProfilingManager::class.java) ?: return
  profilingManager.registerForAllProfilingResults(Executors.newSingleThreadExecutor()) { result ->
    val path = result.resultFilePath ?: return@registerForAllProfilingResults
    if (result.errorCode == ProfilingResult.ERROR_NONE) {
      uploader.enqueue(path, result.triggerType, result.tag)
    }
  }
  val triggers = listOf(ProfilingTrigger.TRIGGER_TYPE_OOM, ProfilingTrigger.TRIGGER_TYPE_ANOMALY)
    .mapNotNull { type -> runCatching { ProfilingTrigger.Builder(type).build() }.getOrNull() }
  profilingManager.addProfilingTriggers(triggers)
}

TRIGGER_TYPE_OOM(값 7)과 TRIGGER_TYPE_ANOMALY(값 8)는 API 37에서 새로 추가되었고, 트리거 API 자체는 API 36에서, registerForAllProfilingResults는 API 35에서 도입되었습니다. 위의 짧은 코드에는 나중에 중요해지는 세부 사항이 세 가지 있습니다.

  • 결과는 전역 리스너로만 전달됩니다. 트리거 결과는 요청별 콜백으로는 절대 전달되지 않으므로, 전역 리스너를 먼저 등록하세요. 프로세스 안의 ProfilingManager가 전역 리스너 없이 이미 서비스와 통신했다면, 결과가 앱 저장소로 복사되었는데도 이를 받을 리스너가 없을 수 있습니다.
  • 타입마다 트리거는 하나입니다. Builder 문서에 따르면, 같은 타입의 트리거를 추가하면 이전 트리거를 대체합니다. 또한 기기에서 해당 타입을 제어하는 기능 플래그가 꺼져 있으면 Builder 생성자가 IllegalArgumentException("Invalid trigger type.")을 던집니다. 이 플래그는 원격으로 바뀔 수 있으므로, 위 예제는 트리거를 하나씩 따로 만들고 예외를 던지는 타입은 앱 시작을 실패시키는 대신 건너뜁니다.
  • 트리거를 대체하면 쿨다운도 초기화됩니다. setRateLimitingPeriodHours로 앱이 정한 쿨다운을 추가할 수 있지만, addProfilingTriggers를 호출할 때마다 다음과 같이 새 트리거 기록이 저장됩니다.
public ProfilingTriggerData(
        int uid, @NonNull String packageName, int triggerType, int rateLimitingPeriodHours) {
    mUid = uid;
    mPackageName = packageName;
    mTriggerType = triggerType;
    mRateLimitingPeriodHours = rateLimitingPeriodHours;
}

새 기록은 mLastTriggeredTimeMs = 0에서 시작합니다. 트리거는 시스템 쪽에 유지되므로, 앱이 실행될 때마다 addProfilingTriggers를 호출하면 그때마다 자기 쿨다운 이력을 스스로 지우게 됩니다. setRateLimitingPeriodHours에 의존하신다면 트리거는 한 번만 등록하고, 등록했다는 사실을 기억해 두세요.

1단계: 크래시 핸들러는 손을 놓지 않는다

자체 핸들러가 없는 스레드에서 예외가 빠져나가면, 런타임은 프로세스의 기본 UncaughtExceptionHandler를 호출합니다. 안드로이드는 RuntimeInit에서 KillApplicationHandler라는 핸들러를 설치하며, 주요 부분은 다음과 같습니다.

@Override
public void uncaughtException(Thread t, Throwable e) {
    try {
        ensureLogging(t, e);
        if (mCrashing) return;
        mCrashing = true;
        // ...
        // 크래시 다이얼로그를 띄우고, 닫힐 때까지 기다립니다.
        ActivityManager.getService().handleApplicationCrash(
                mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e));
    } catch (Throwable t2) {
        // ...
    } finally {
        // 이 프로세스가 확실히 사라지도록 할 수 있는 모든 방법을 시도합니다.
        Process.killProcess(Process.myPid());
        System.exit(10);
    }
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기