아티클 목록으로 가기

앱을 종료한 것은 OutOfMemoryError가 아니었다: Android 17 Memory Limiter 내부 들여다보기

skydovesJaewoong Eum (skydoves)||19분 소요

앱을 종료한 것은 OutOfMemoryError가 아니었다: Android 17 Memory Limiter 내부 들여다보기

4 GB 휴대폰을 쓰는 사용자가 여러분의 사진 편집 앱을 열고 필터 몇 개를 적용하자, 앱이 사라집니다. 크래시 다이얼로그도 없고, Crashlytics에도 OutOfMemoryError는 찍혀 있지 않습니다. 마지막 프로파일링 세션에서도 Java 힙은 Runtime.maxMemory() 근처에 가지도 않았습니다. 결국 시스템에 무슨 일이 있었는지 물어보시면, ApplicationExitInfo는 REASON_OTHER를 보고하고 그 설명에는 MemoryLimiter:AnonSwap이 들어 있습니다. 앱을 종료한 것은 Java 힙 한도도, 로우 메모리 킬러(low memory killer)도 아니었습니다. Android 17이 Pixel 기기부터 도입한 새로운 프로세스별 상한에 걸려 종료된 것입니다.

이 글에서는 일반적인 앱이 한 번도 들여다보지 않는 메모리 집계 방식에서 출발해, system_server 안에 있는 Memory Limiter의 내부로 따라 들어갑니다. 한도가 어디에서 오는지, 프로세스 상태에 따라 어떤 한도가 선택되는지, 종료에 앞서 cgroup v2가 프로세스를 어떻게 스로틀링하는지, 네이티브 폴러가 선을 넘었다고 판단하는 기준은 무엇인지, REASON_OTHER로 끝나는 정확한 경로는 어떤지, 그리고 Play의 새 메모리 vitals가 같은 숫자와 어떻게 맞물리는지를 차례로 살펴봅니다.

근본적인 문제: 네 가지 상한이 재는 네 가지 다른 숫자

이미지를 많이 다루는 앱이 흔히 처음 만드는 캐시는 다음과 같습니다. 실행 취소와 전후 비교가 즉각적으로 느껴지도록, 가장 최근에 디코딩한 프레임을 보관합니다.

class FrameCache(private val maxFrames: Int = 64) {
  private val frames = object : LinkedHashMap<String, Bitmap>(16, 0.75f, true) {
    override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Bitmap>): Boolean {
      return size > maxFrames
    }
  }

  fun put(key: String, frame: Bitmap) {
    frames[key] = frame
  }

  fun get(key: String): Bitmap? = frames[key]
}

이 캐시는 바이트가 아니라 개수로 크기를 제한합니다. 12MP 카메라로 찍은 원본 해상도 프레임 64장이면, 픽셀당 4바이트 기준으로 픽셀만 약 3 GB입니다. Android 8.0부터 비트맵 픽셀 데이터는 네이티브 힙에 놓이므로, Java 힙에는 작은 Bitmap 객체만 남습니다. Runtime.maxMemory()는 픽셀을 전혀 보지 못하고, 프로세스가 계속 커지는 동안에도 OutOfMemoryError는 발생하지 않습니다.

혼란의 핵심은 여기에 있습니다. 이제 안드로이드 프로세스는 여러 메모리 상한 아래에서 실행되며, 상한마다 재는 대상이 서로 다릅니다.

상한측정 대상결과
Java 힙 한도ART 힙에 있는 객체프로세스 안에서 OutOfMemoryError 발생
로우 메모리 킬러(lmkd)시스템 전체의 메모리 압박oom_adj 순서로 프로세스 종료, REASON_LOW_MEMORY
Memory Limiter (Android 17)해당 프로세스의 cgroupmemory.high를 넘으면 스로틀링, anon + shmem + swap이 memory.high + memory.swap.max를 넘으면 종료
Play 메모리 vitals샘플링한 기기 전반에서 anon RSS + swap의 P90종료하지 않음, 2027년 2월부터 적용되는 품질 기준

앞의 두 가지는 오래전부터 익숙한 상한입니다. Memory Limiter는 새로 생겼고, lmkd와 달리 기기 전체의 메모리가 바닥나기를 기다리지 않습니다. 앱 프로세스를 하나하나 따로 감시합니다. 이 글의 나머지 부분에서는 이 메커니즘을 구성 단계부터 종료 단계까지 따라가 봅니다.

한도는 어디에서 오는가: 설정 파일이 없으면 Limiter도 없다

Memory Limiter는 system_server 안에 있는 Java 클래스 MemoryLimiter이며, 그 아래에서는 리눅스 cgroup v2 파일 시스템과 통신하는 네이티브 계층이 동작합니다. 무언가를 감시하기 전에, 먼저 자신이 실행되어야 하는지부터 결정합니다. 기본 컨트롤러를 고르는 코드를 살펴보면 다음과 같습니다.

private static Controller getDefaultController(Context context) {
    if (!Flags.memoryLimiterEnable()) {
        return new ControllerDisabled();
    } else if (Process.myUid() != Process.SYSTEM_UID) {
        return new ControllerDisabled();
    } else if (!isMemoryLimiterSupported()) {
        return new ControllerDisabled();
    } else {
        return new ControllerEnabled(new Injector(context));
    }
}

세 가지 조건이 모두 맞아야 합니다. 기능 플래그가 켜져 있고, 코드가 system_server 안에서 실행되며, isMemoryLimiterSupported()가 true를 반환해야 합니다. 마지막 검사는 먼저 설정 파일을 찾습니다. Android 17 소스에서 이 경로는 /vendor/etc/memory-limiter-config.xml입니다. 파일이 없으면 Limiter는 no config file을 로그로 남기고, ControllerDisabled의 모든 메서드는 아무 동작도 하지 않습니다. 활성화된 뒤에 메커니즘이 어디까지 동작할지는 빌드 플래그 두 개가 더 결정하는데, 하나는 모니터링용이고 다른 하나는 스왑 한도용입니다. Android 17 릴리스 구성에서는 두 플래그가 모두 켜져 있으며, 이 글의 수치도 그렇다고 가정합니다.

같은 Android 17 빌드라도 기기에 따라 다르게 동작할 수 있는 것은 이 때문입니다. 이 기능은 옵트인 방식이라, 기기 제조사가 이 파일을 넣어 출시해야만 켜집니다. Android 17 에뮬레이터 이미지에서 이를 직접 확인하실 수 있습니다. RAM을 4 GB로 설정한 에뮬레이터에는 설정 파일이 없고, Limiter도 스스로를 비활성 상태로 보고합니다.

$ adb shell am memory-limiter status
Memory limiter
  disabled

$ adb logcat -d -s MemoryLimiter
11-08 17:43:41.317   637   637 I MemoryLimiter: no config file: /vendor/etc/memory-limiter-config.xml

한도를 짐작하기 전에, 먼저 직접 쓰는 테스트 기기에서 같은 status 명령을 실행해 보세요. 한도를 실제로 적용하는 기기라면, 모든 앱에 적용되는 보이는(visible) 상태와 보이지 않는(not visible) 상태의 한도를 출력합니다.

MemTotal로 티어 고르기

파일이 있으면, 그 안에는 RAM 티어마다 하나씩 한도 세트가 목록으로 들어 있습니다. Limiter는 각 티어의 하한을 /proc/meminfo의 MemTotal과 비교해 그중 하나를 고릅니다.

private static Configuration getConfiguration(List<LimitSet> sets, long memTotal) {
    long minRequiredMem = 0;
    Configuration result = null;
    for (int i = 0; i < sets.size(); i++) {
        LimitSet cfg = sets.get(i);
        long minMemTotal = cfg.getMinimumRequiredMemTotal().longValue() * MB;
        if (minMemTotal > memTotal || minMemTotal < minRequiredMem) {
            continue;
        }
        minRequiredMem = minMemTotal;
        result = new Configuration(
            cfg.getMemVisible().longValue() * MB,
            cfg.getMemNotVisible().longValue() * MB,
            cfg.getSwapVisible().longValue() * MB,
            cfg.getSwapNotVisible().longValue() * MB);
    }
    return result;
}

하한이 MemTotal을 넘지 않는 티어 중에서 하한이 가장 큰 티어가 선택됩니다. 비교 대상이 제품 상자에 적힌 RAM 용량이 아니라 MemTotal이라는 점에 유의하세요. GPU, 모뎀, 카메라 ISP, 보안 환경용으로 하드웨어가 떼어 가는 영역(carve out)을 빼고 나서 커널이 실제로 쓸 수 있는 양이 MemTotal이며, 플랫폼 문서는 이렇게 떼어 가는 양을 500 MiB에서 2 GiB 이상으로 잡고 있습니다. 티어 하한이 마케팅 수치보다 낮은 것도 그래서입니다. 맞는 티어가 하나도 없으면 isMemoryLimiterSupported()가 false를 반환하고 Limiter는 꺼진 채로 남습니다.

다음은 플랫폼 문서가 기기 제조사를 대상으로 공개한 표준 구성 중 4 GB 티어입니다.

<limitSet>
  <minimumRequiredMemTotal>3200</minimumRequiredMemTotal>
  <maximumAllowedMemTotal>4800</maximumAllowedMemTotal>
  <memVisible>2048</memVisible>
  <memNotVisible>1024</memNotVisible>
  <swapVisible>1024</swapVisible>
  <swapNotVisible>1024</swapNotVisible>
</limitSet>

모든 값의 단위는 MiB입니다. 4 GB 기기에서 보이는 프로세스는 2 GiB의 memory.high와 1 GiB의 스왑을 받고, 보이지 않는 프로세스는 각각 1 GiB와 1 GiB를 받습니다. 공개된 티어는 여기서부터 커져서, 보이는 프로세스 기준으로 6 GB 기기는 4 GiB, 8 GB는 5 GiB, 12 GB는 8 GiB, 16 GB는 10 GiB입니다. 이 공개 구성에서 3,200 MiB 미만이거나 18,432 MiB 이상인 기기에는 한도가 전혀 적용되지 않습니다.

공개 구성과 Android 17 소스는 두 가지 세부 사항에서 다릅니다. 문서는 파일 위치를 /system/etc로 두고, 각 티어에 maximumAllowedMemTotal로 상한을 줍니다. 반면 Android 17 소스는 /vendor/etc를 읽으며, 스키마에 상한 요소가 없어서 하한만으로 티어를 고릅니다. 따라서 이 코드에서는 18,432 MiB 이상인 기기라도 파일에 16 GB 티어가 들어 있다면 그 티어에 해당하게 됩니다.

어떤 한도를 적용할지는 프로세스 상태가 정한다

티어가 정해지면 Limiter는 숫자 두 쌍을 얻습니다. 한 쌍은 보이는 프로세스용, 다른 한 쌍은 보이지 않는 프로세스용입니다. 프로세스가 어느 쌍을 받을지는 프로세스 상태에 달려 있는데, 여기서 프로세스 상태란 액티비티 매니저가 lmkd용 oom_adj 점수와 함께 계산하는 것과 같은 PROCESS_STATE_* 값입니다. 이 매핑은 initializeMemoryLimits()에서 한 번만 만들어집니다.

Limits ignored = new Limits(LIMIT_IS_IGNORED, LIMIT_IS_IGNORED);
Limits cached = new Limits(LIMIT_IS_IGNORED, LIMIT_IS_DISABLED);
Limits unlimited = new Limits(LIMIT_IS_DISABLED, LIMIT_IS_DISABLED);
Limits notVisible = new Limits(mConfiguration.memNotVisible,
        mConfiguration.swapNotVisible);
Limits visible = new Limits(mConfiguration.memVisible,
        mConfiguration.swapVisible);

각 Limits는 memory.high 값과 memory.swap.max 값을 한 쌍으로 묶습니다. 여기에는 특별한 값이 두 개 나옵니다. LIMIT_IS_DISABLED는 cgroup에 max로 기록되고, LIMIT_IS_IGNORED는 "현재 값을 그대로 둔다"는 뜻입니다. 이어지는 switch 문은 모든 프로세스 상태에 이 중 하나를 할당합니다.

Limits limit = switch (state) {
    case PROCESS_STATE_PERSISTENT, PROCESS_STATE_PERSISTENT_UI -> unlimited;
    case PROCESS_STATE_TOP, PROCESS_STATE_BOUND_TOP,
         PROCESS_STATE_IMPORTANT_FOREGROUND, PROCESS_STATE_TOP_SLEEPING -> visible;
    case PROCESS_STATE_FOREGROUND_SERVICE, PROCESS_STATE_BOUND_FOREGROUND_SERVICE,
         PROCESS_STATE_IMPORTANT_BACKGROUND, PROCESS_STATE_TRANSIENT_BACKGROUND,
         PROCESS_STATE_BACKUP, PROCESS_STATE_SERVICE, PROCESS_STATE_RECEIVER,
         PROCESS_STATE_HEAVY_WEIGHT, PROCESS_STATE_HOME,
         PROCESS_STATE_LAST_ACTIVITY -> notVisible;
    case PROCESS_STATE_CACHED_ACTIVITY, PROCESS_STATE_CACHED_ACTIVITY_CLIENT,
         PROCESS_STATE_CACHED_RECENT, PROCESS_STATE_CACHED_EMPTY -> cached;
    case PROCESS_STATE_UNKNOWN, PROCESS_STATE_NONEXISTENT -> ignored;
};

실제 switch 문은 상태마다 한 줄씩 나열하지만, 묶이는 방식은 위와 같습니다. 실무에서 중요한 관찰 몇 가지는 다음과 같습니다.

  • 포그라운드 서비스는 보이지 않는 상태입니다. 사용자가 화면을 떠난 뒤에도 포그라운드 서비스로 계속 실행되는 음악 플레이어나 내비게이션 앱은 보이지 않는 프로세스용 한도를 받으며, 4 GB 기기에서 이 한도는 보이는 프로세스용 한도의 절반입니다. 포그라운드 서비스가 디코딩한 큰 버퍼를 들고 있다면, 그 서비스는 더 작은 숫자를 기준으로 평가받습니다.
  • 캐시된 프로세스는 이전의 memory.high를 그대로 유지합니다. cached 쌍은 memory.high를 건드리지 않고 스왑 한도는 해제합니다. 캐시된 프로세스는 어차피 동결되고 회수(reclaim)되므로, Limiter가 더 압박할 필요가 없습니다.
  • 영구(persistent) 프로세스에는 한도가 없습니다. 플랫폼이 의존하는 시스템 프로세스이기 때문입니다.

이 중 어느 것이든 적용되려면 먼저 프로세스가 대상 자격을 갖춰야 합니다. 프로세스별 Limiter는 FIRST_APPLICATION_UID(10000) 미만의 UID를 거부하고, 기기에서 샌드박스로 격리되어 실행되는 기본 추론 서비스의 패키지도 제외합니다. 사전 설치 앱을 포함한 나머지 모든 앱 프로세스가 대상입니다.

상태 변경이 Limiter에 전달되는 과정

모든 앱 프로세스는 액티비티 매니저 안에 ProcessRecord를 하나씩 가지며, 모든 ProcessRecord는 MemoryLimiter.Limiter를 하나씩 소유합니다. 액티비티 매니저가 프로세스 상태를 바꾸면 ProcessRecord.onProcStateUpdated()가 이를 해당 프로세스의 Limiter로 넘깁니다. 이 Limiter의 onProcStateUpdated()는 다음과 같습니다.

void onProcStateUpdated(@ProcessState int newState) {
    if (!mController.isEnabled()) return;
    if (!mIsReady) return;

    final Limits newLimit = mController.getStateLimit(newState);
    if (newLimit != null && !Objects.equals(mLimit, newLimit)) {
        mLimit = newLimit;
        mController.setLimit(mPid, mUid, mLimit);
    }
}

비용 측면에서는 Objects.equals 검사가 중요합니다. 액티비티가 시작되고 서비스가 바인딩될 때마다 프로세스 상태는 끊임없이 바뀌지만, 대부분의 전환은 TOP에서 BOUND_TOP으로 가는 것처럼 같은 그룹 안에 머뭅니다. 보이는 상태에서 보이지 않는 상태로 넘어가는 것처럼 그룹이 바뀔 때만 메시지가 만들어집니다. setLimit() 역시 cgroup을 직접 건드리지 않습니다. 시스템 백그라운드 스레드의 핸들러로 메시지를 보낼 뿐이므로, 네이티브 계층이 파일을 쓰는 동안 액티비티 매니저 락을 잡고 있는 일은 없습니다.

종료 전에 스로틀링부터: memory.high와 첫 번째 이벤트

네이티브 계층은 각 Limits 값을 해당 프로세스의 cgroup 파일에 써 넣습니다. 앱 프로세스마다 자기만의 cgroup이 있으며, 그 경로는 /proc/<pid>/cgroup의 0:: 줄에 나와 있습니다. 레이아웃은 기기마다 다를 수 있으니, 경로를 직접 조립하지 말고 이 파일에서 읽으세요. Limiter가 비활성화된 Android 17 에뮬레이터에서 런처의 cgroup은 다음과 같습니다.

$ adb shell cat /proc/1023/cgroup
3:cpuset:/top-app
2:cpu:/top-app
1:blkio:/
0::/apps/uid_10195/pid_1023

$ adb shell cat /sys/fs/cgroup/apps/uid_10195/pid_1023/memory.high
max
$ adb shell cat /sys/fs/cgroup/apps/uid_10195/pid_1023/memory.events
low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0

한도를 담는 파일은 두 개입니다. memory.high는 memory.current에 대한 소프트 상한이며, memory.current는 파일 기반 페이지(file backed page)를 포함해 cgroup에 집계되는 모든 메모리입니다. 사용량이 이 상한을 넘어도 커널은 아무것도 종료하지 않고, 해당 cgroup에서만 메모리를 회수합니다. 코드나 매핑된 애셋 같은 깨끗한(clean) 파일 기반 페이지는 저장소에서 다시 읽을 수 있으므로 버려집니다. 힙 할당 같은 익명 페이지(anonymous page)는 압축되어 zRAM 스왑으로 들어갑니다. memory.swap.max는 cgroup이 이 스왑을 얼마나 쓸 수 있는지 제한합니다. 세 번째 파일인 memory.events에는 high 카운터가 있는데, cgroup의 프로세스가 memory.high를 넘어 스로틀링될 때마다 커널이 이 값을 올립니다. Limiter가 감시하는 것이 바로 이 카운터입니다.

구성 메시지가 도착하면 네이티브 Process는 자신이 추적하는 세 가지 한도를 설정합니다.

void setLimits(int64_t memHigh, int64_t swapMax) {
    setLimit(MonitoredLimit::kMemoryHigh, memHigh);
    setLimit(MonitoredLimit::kSwapMax, swapMax);
    setLimit(MonitoredLimit::kAnonSwap, memHigh + swapMax);
}

앞의 두 값은 그대로 memory.high와 memory.swap.max에 기록됩니다. 세 번째인 kAnonSwap은 어떤 파일에도 기록되지 않습니다. 이 두 값을 더한 합이 Limiter 고유의 종료 임계값입니다. 4 GB 기기라면 보이는 프로세스는 3 GiB, 보이지 않는 프로세스는 2 GiB가 됩니다.

처음으로 상한을 넘는 순간을 알아차리려고, 네이티브 계층은 memory.events에 inotify 감시를 등록하고 현재 high 카운트를 기준값으로 기록해 둡니다. inotify는 파일이 바뀌면 스레드를 깨우는 리눅스 기능이라서, Limiter가 루프를 돌며 파일을 읽을 필요가 없습니다. 커널이 카운터를 올리면 감시가 작동합니다.

type = p.getLimitType(wd);
if (p.getEventCount(type) != p.getEventBaseline(type)) {
    found = p;
    p.unwatch(mInotifyFd, mWdMap, type);
    p.setExceeded(type, mLimitMode);
    anyRed = p.isRed();
    p.resetLimits();
}

작업 순서에 주목하세요.

  1. 감시를 제거합니다. 그래야 memory.high 위에 머무는 프로세스가 Limiter에 이벤트를 쏟아붓지 않습니다.
  2. setExceeded()가 memory.high 감시자를 트리거된 상태로 표시합니다.
  3. resetLimits()가 한도를 다시 기록하는데, 이번에는 setLimit()이 memory.high에 여유분(margin)을 더합니다.

여유분은 100 MB인 상수 mMemHighMargin에서 옵니다. setLimit()에서 이 값을 쓰는 분기는 다음과 같습니다.

case MonitoredLimit::kMemoryHigh:
    mMemoryHighLimit = limit;
    if (mMemWatcher.mTriggered) {
        limit = incrLimit(limit, mMemWatcher.mMargin);
    }
    writeLimit(cgroupPath(CgroupFile::kMemoryHigh), limit);
    break;

소스 주석은 그 의도를 이렇게 설명합니다. 익명 메모리가 memory.high 한도에 걸려 있어도 프로세스가 계속 실행될 수 있게 하고, 한도에 다시 닿기 전에 CPU가 그 메모리 일부를 덜어 낼 기회를 주려는 것입니다.

다시 Java 쪽으로 돌아오면, 이 첫 이벤트를 받은 콜백은 기록만 남깁니다. onLimitExceeded()는 이벤트를 로그로 남기고 statsd 아톰을 씁니다. 아톰은 모든 프로세스가 함께 쓰는 토큰 버킷 하나를 거치는데, 처음에는 최대 4개까지 한꺼번에 나갈 수 있고 그 뒤로는 1시간에 1개씩 더 허용됩니다. 이 단계에서는 아무것도 종료되지 않으며, 앱에 알려 주는 것도 없습니다. 앱 입장에서는 회수와 zRAM 압축에 CPU 시간이 쓰이면서 이때부터 프레임이 떨어지기 시작하지만, 프로세스는 계속 실행됩니다. memory.high는 memory.current와 비교되므로, 파일 페이지만으로도 이 단계에 들어설 수 있습니다. 반면 뒤이어 오는 종료 검사는 익명 메모리만 봅니다.

레드 존: anon + swap 폴링

memory.high 이벤트가 발생해 감시가 사라지면, 프로세스는 소스에서 레드 존(red zone)이라고 부르는 상태에 들어갑니다.

bool isRed() const {
    return mMemWatcher.mTriggered && !mAnonSwapTriggered;
}

anon + swap 합계를 추적하는 커널 파일은 없습니다. cgroup 이벤트로는 이 합계가 종료 임계값을 넘는 시점을 알려 줄 수 없으므로, Limiter는 폴링으로 전환합니다. 모니터 스레드는 epoll_wait\(\)에서 잠들며, 타임아웃은 레드 존에 있는 프로세스가 있는지에 따라 달라집니다. 하나라도 있으면 30초(RED_POLL_PERIOD_MS), 없으면 5분인데, 5분짜리 타임아웃은 이미 종료된 프로세스를 잊는 용도로만 쓰입니다. 타임아웃이 올 때마다 레드 존에 있는 모든 프로세스를 검사합니다.

AnonSwapState testAnonSwap() const {
    if (mAnonSwapTriggered) return AnonSwapState::kTriggered;
    int64_t metric = getMetric(MonitoredLimit::kAnonSwap);
    if (mAnonSwapLimit < 0) {
        return AnonSwapState::kOkay;
    } else if (metric > mAnonSwapLimit) {
        return AnonSwapState::kHot;
    } else if (metric < (mMemoryHighLimit - mMemHighHysteresis)) {
        return AnonSwapState::kCold;
    } else {
        return AnonSwapState::kOkay;
    }
}

아직 트리거되지 않은 프로세스라면 검사 결과는 세 가지 중 하나입니다.

  1. Hot: 지표가 memHigh + swapMax보다 큽니다. 프로세스가 종료 임계값을 넘어섰다는 뜻입니다.
  2. Cold: 지표가 설정된 memory.high보다 10 MB(mMemHighHysteresis) 넘게 아래로 내려갔습니다. Limiter는 inotify 감시를 다시 등록하고, 이 프로세스의 폴링을 멈춥니다.
  3. Okay: 그 사이에 있는 모든 경우입니다. 프로세스는 레드 존에 머물고, 다음 폴링에서 다시 검사를 받습니다.

앱이 직접 통제할 수 있는 탈출구는 스왑까지 포함한 익명 메모리를 충분히 해제해 memory.high보다 10 MB 아래로 내려가는 것입니다. 새 Limits 메시지가 올 때마다 두 임계값을 다시 기록하기 때문에, 프로세스 상태가 바뀔 때도 두 임계값이 함께 움직입니다. 보이는 상태가 되면 임계값이 올라가서, 아무것도 해제하지 않았는데도 다음 폴링에서 Cold가 나올 수 있습니다. 반대로 보이지 않는 상태로 내려가면 임계값이 낮아지므로, 사용자가 앱을 떠나자마자 포그라운드에서는 Okay였던 프로세스가 Hot으로 바뀌기도 합니다.

어떤 메모리가 한도에 잡히는지는 지표 자체를 보면 알 수 있습니다. 이 지표는 cgroup 파일 두 개를 읽습니다.

int64_t anonMetric() const {
    return anon + shmem;
}

case MonitoredLimit::kAnonSwap:
    return readMemoryStat().anonMetric() + readMetric(CgroupFile::kSwapCurrent);

anon과 shmem은 memory.stat에서, 스왑은 memory.swap.current에서 가져옵니다. 익명 메모리는 프로세스가 할당한 메모리 가운데 파일과 연결되지 않은 모든 메모리입니다. ART 힙, 네이티브 malloc 할당, 그리고 Android 8.0부터는 소프트웨어 비트맵의 픽셀도 여기에 속합니다. shmem은 memfd 버퍼 같은 공유 메모리를 다룹니다. memory.swap.current를 더하기 때문에, 스왑으로 밀려난 페이지도 여전히 집계됩니다. DEX 코드나 메모리 매핑된 애셋 같은 파일 기반 페이지는 지표에 전혀 들어가지 않습니다. memory.high를 넘었을 때 커널이 가장 먼저 버리는 대상도 이 페이지들입니다.

처음에 본 FrameCache가 4 GB 기기에서 위험한 이유도 여기서 나옵니다. 이 캐시의 픽셀은 익명 메모리입니다. 회수 과정에서 zRAM으로 옮겨질 수는 있지만, zRAM 페이지도 여전히 집계됩니다. 포그라운드에서 프레임과 나머지 메모리를 합친 양이 3 GiB를 넘으면, 다음 폴링에서 Hot으로 판정됩니다.

종료 경로: 해제하고, 프로파일링하고, 30초 기다리기

Hot 결과가 나오면 프로세스는 트리거된 상태로 표시되고, LIMIT_TYPE_ANON_SWAP과 함께 Java 쪽 콜백이 호출됩니다. 이벤트와 statsd 아톰을 기록한 뒤, Java 콜백은 뜻밖의 일을 합니다.

if (type == LIMIT_TYPE_ANON_SWAP) {
    // 모든 한도를 해제합니다.
    configureLimit(mNative.get(), pid, uid, LIMIT_IS_DISABLED, LIMIT_IS_DISABLED);
}
onLimitExceeded(pid, uid, type, memHigh, swapHigh, pkg);

한도를 없애 버립니다. memory.high와 memory.swap.max가 모두 max로 설정되므로, 이 프로세스는 다음번에 한도 그룹이 바뀔 때까지 더 이상 스로틀링되지 않습니다. 뒤이은 대기 시간 동안 사용자가 앱을 떠나는 식으로 그룹이 바뀌면, 그 그룹의 한도가 다시 기록됩니다. 소스 주석은 네이티브 계층이 락을 잡고 있지 않아 이 호출이 안전하다는 점만 언급하지만, 그 효과는 프로세스가 정상적으로 실행되기를 바라는 다음 단계와 잘 맞아떨어집니다.

if (type == LIMIT_TYPE_ANON_SWAP) {
    ProfilingServiceHelper helper = ProfilingServiceHelper.getInstance();
    helper.onProfilingTriggerOccurred(uid, pkg,
            ProfilingTrigger.TRIGGER_TYPE_ANOMALY);

    Message msg = mQueue.obtainMessage(MESSAGE_KILL, pid, uid,
            "MemoryLimiter:AnonSwap");
    mQueue.sendMessageDelayed(msg, KILL_DELAY_MS);
}

실제 코드에서는 프로파일링 호출 앞에 프로파일링 기능 플래그 두 개를 검사하는 조건과 패키지 이름이 null이 아닌지 확인하는 조건이 붙고, 호출 자체도 try 블록으로 감싸여 있지만, 순서는 같습니다. 먼저 Limiter가 TRIGGER_TYPE_ANOMALY를 발생시킵니다. 앱이 이 트리거에 등록해 두었다면, 프로파일링 서비스가 힙 덤프를 캡처할 수 있습니다. 그다음 Limiter는 KILL_DELAY_MS(30초) 뒤로 종료를 예약합니다. 이렇게 늦추는 이유는 소스 주석에 그대로 적혀 있습니다. 프로파일러가 구성되어 있다면 작업을 마칠 수 있도록 시간을 주려는 것입니다.

지연된 메시지가 실행되면, Limiter는 스위치 하나를 더 확인한 뒤 액티비티 매니저에 해당 PID의 종료를 요청합니다.

case MESSAGE_KILL -> {
    if (mDisableKill) {
        Slog.i(TAG, "not killing process " + pid);
    } else {
        mInjector.killProcess(pid, uid, (String) msg.obj);
    }
}

killProcess()는 사유 문자열과 함께 ActivityManagerService.killPids()를 호출하고, killPids()는 결국 killLocked(reason, ApplicationExitInfo.REASON_OTHER, ApplicationExitInfo.SUBREASON_KILL_PID, true\)에 도달합니다. Android 17(API 37.0)에서 실제 사용 환경에 나타나는 결과는 모두 여기에서 비롯됩니다. 사유는 REASON_OTHER이고, 설명에는 MemoryLimiter:AnonSwap이 담깁니다. API 37.2는 전용 종료 사유인 REASON_MEMORY_LIMITER를 추가합니다. 다만 이후 Android 17 릴리스의 소스는 아직 공개되지 않았으므로, Limiter가 종료한 프로세스가 언제부터 이 사유로 보고되는지는 소스로 확인할 수 없습니다.

이 경로 위에 놓인 런타임 스위치는 두 개입니다. mDisableKill과 그 짝인 mDisableLimits는 activity_manager 네임스페이스에 있는 DeviceConfig 키 memory_limiter_disable_kill과 memory_limiter_disable_limits에서 값을 읽습니다. 둘 다 OTA 업데이트 없이 DeviceConfig로 원격에서 바꿀 수 있지만, 하는 일은 서로 다릅니다. memory_limiter_disable_limits는 각 프로세스의 다음 상태 변경 시점에 두 cgroup 파일에 max를 기록하지만, 네이티브 쪽 종료 임계값은 지우지 않으므로 이미 레드 존에 있는 프로세스는 여전히 종료될 수 있습니다. 종료 자체를 막는 것은 memory_limiter_disable_kill뿐입니다.

소스를 바탕으로 타임라인을 정리해 보겠습니다. memory.high를 처음 넘으면 스로틀링이 시작되고 프로세스는 레드 존에 들어갑니다. 레드 존 검사는 다른 inotify 이벤트 없이 30초가 지나야 실행되며, 이 검사에서 Hot으로 판정되면 종료는 30초 뒤로 예약됩니다. 조용한 기기라면 anon + swap이 임계값을 넘은 뒤 대략 30초에서 60초, memory.high를 처음 넘은 뒤로는 적어도 60초 정도가 지나 종료됩니다. 감시 중인 다른 프로세스에서 활동이 있으면 이 시점은 더 늦어질 수 있습니다. 이 타임라인은 기기에서 측정한 값이 아니라 코드에서 도출한 것입니다.

실제 사용 환경에서 잡아내기

종료 과정에서 예외가 발생하지 않으므로, 크래시 리포터가 종료 사유를 읽지 않는 한 아무것도 보이지 않습니다. 다음 실행 때 직접 종료 사유를 읽으실 수 있습니다.

fun findMemoryLimiterKills(context: Context): List<ApplicationExitInfo> {
  val am = context.getSystemService(ActivityManager::class.java)
  return am.getHistoricalProcessExitReasons(context.packageName, 0, 10)
    .filter { info -> info.description?.contains("MemoryLimiter:AnonSwap") == true }
}

Android 17에서는 REASON_OTHER가 서로 관련 없는 여러 종료를 포괄하므로, 믿을 수 있는 표식은 설명 쪽입니다. API 37.2 이상에서는 해당 빌드가 설명에 무엇을 담는지 아직 공개되지 않았으므로, REASON_MEMORY_LIMITER도 함께 확인하세요. 조건에 맞는 각 기록에는 타임스탬프와 프로세스가 종료될 때의 중요도(importance)가 담겨 있어, 포그라운드에서 일어난 종료와 포그라운드 서비스 상태에서 일어난 종료를 구분하는 데 도움이 됩니다. 종료는 감지된 뒤 30초 이상 지나서야 일어나므로, 이 중요도가 프로세스가 임계값을 넘던 순간의 상태와 다를 수 있다는 점도 염두에 두세요. 기록의 pss와 rss는 마지막 샘플링에서 가져온 값이라 오래되었거나 0일 수 있습니다. Crashlytics 20.1.0도 Memory Limiter 종료에 대한 디버그 데이터를 추가합니다.

무엇이 메모리를 붙잡고 있었는지 알아내려면, Limiter가 종료 전에 발생시키는 이상 징후(anomaly) 트리거에 등록하세요. OOM 트리거와 같은 ProfilingManager API를 쓰는 패턴이며, API 37부터 사용할 수 있습니다.

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerAnomalyTrigger(context: Context) {
  val profilingManager = context.getSystemService(ProfilingManager::class.java) ?: return
  profilingManager.registerForAllProfilingResults(Executors.newSingleThreadExecutor()) { result ->
    if (result.errorCode == ProfilingResult.ERROR_NONE &&
      result.triggerType == ProfilingTrigger.TRIGGER_TYPE_ANOMALY
    ) {
      // result.resultFilePath 업로드를 예약합니다.
    }
  }
  val anomaly = ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANOMALY).build()
  profilingManager.addProfilingTriggers(listOf(anomaly))
}

문서의 OOM 사례 연구가 권장하는 대로, 결과 콜백은 앱이 시작될 때마다 등록하세요. 그래야 프로세스가 없던 동안 대기열에 쌓인 결과를 다시 시작할 때 전달받을 수 있습니다. 두 트리거는 분명히 구분해 두셔야 합니다. TRIGGER_TYPE_OOM은 Java 힙이 OutOfMemoryError를 던질 때 발생하고, TRIGGER_TYPE_ANOMALY는 Memory Limiter가 발생시키는 트리거입니다. 네이티브 메모리나 비트맵 메모리에서 생긴 누수는 대개 두 번째 트리거로만 드러납니다. 이상 징후 결과가 메모리에만 국한되지는 않으므로, 프로파일을 Memory Limiter 캡처로 다루기 전에 이상 징후에 대한 추가 정보를 담은 result.tag를 확인하세요.

이 동작을 로컬에서 재현하려면 am memory-limiter 셸 명령의 status, ignore, manual 하위 명령을 쓰시면 됩니다. manual은 문서마다 설명이 엇갈리므로 주의해서 다루셔야 합니다. Android 17 도움말 텍스트와 소스 트리의 설계 문서는 백분율로, 동작 변경 사항 페이지는 메가바이트로, 현재 플랫폼 문서는 MB와 GB 접미사를 붙인 바이트로 설명합니다. Android 17 코드는 이 값에 1 MiB를 곱해 memory.high와 스왑 양쪽에 적용하므로 종료 임계값은 넘긴 숫자의 두 배가 되며, 이 코드에서는 none으로도 재정의가 해제되지 않습니다. 이 명령들은 Limiter가 활성화된 기기에서만 효과가 있습니다.

Play 메모리 vitals: 같은 티어, 더 낮은 기준

이제 Google Play는 Android vitals에서 메모리 지표 두 가지를 보고합니다. 익명 RSS와 스왑을 더해 측정하는 메모리 사용량, 그리고 비트맵 메모리 사용량입니다. Play는 최근 28일 동안의 90번째 백분위수를 평가하며, 2027년 2월부터 앱은 부적절한 동작 임계값(bad behavior threshold) 아래에 머물러야 기준을 충족합니다. Play의 티어와 Limiter 구성을 비교해 보시면 경계가 3,200, 4,800, 6,800, 9,216, 14,336, 18,432로 같은 숫자를 씁니다. 단위는 플랫폼 문서에서 MiB, Play에서는 MB로 표기됩니다. 다만 지표 자체는 Limiter가 폴링하는 합계와 정의가 비슷할 뿐, 똑같은 숫자는 아닙니다. Play는 프로세스별 익명 RSS와 스왑을 재고, Limiter는 cgroup의 anon, shmem, swap을 읽습니다. 차이가 훨씬 큰 쪽은 임계값입니다. 다음은 앱 카테고리의 기준입니다.

기기 티어Play 앱 포그라운드 기준 (P90)Limiter 보이는 프로세스 memory.highLimiter 보이는 프로세스 종료 임계값
4 GB2 GB2 GiB3 GiB
8 GB2.25 GB5 GiB8 GiB
16 GB4.25 GB10 GiB15 GiB

두 값이 재는 양은 서로 다르지만, 4 GB 기기에서는 Play의 앱 포그라운드 임계값과 Limiter의 보이는 프로세스용 memory.high가 같은 숫자입니다. 더 큰 기기에서는 Play의 기준이 Limiter의 상한보다 훨씬 낮습니다. 백그라운드 임계값도 같은 패턴을 따르며, 앱 기준으로 4 GB 기기는 1 GB, 8 GB는 1.5 GB, 16 GB는 2 GB입니다. Play의 임계값은 총 메모리가 3,200 MB에서 18,432 MB 사이인 휴대폰과 태블릿에만 적용되고, 게임에는 별도의 더 높은 기준표가 있습니다. 이 범위 안이라면 4 GB보다 큰 모든 티어에서 앱에 더 엄격한 쪽은 Play입니다.

직접 상한 선언하기: 메모리 예산

Android 17 QPR2(API 레벨 37.2)는 반대 방향의 기능을 추가합니다. 앱이 플랫폼 한도보다 낮은 예산을 직접 선언할 수 있습니다. 선언은 매니페스트에 둡니다.

<application android:label="@string/app_name">
    <memory-budget android:maxMb="200" />
    <memory-budget android:maxMb="120" android:state="perceptible" />
    <memory-budget android:maxMb="48" android:state="background" />
</application>

android:state가 없는 항목은 선언하지 않은 모든 상태에 대한 폴백이며, 이 예제에서는 포그라운드에 해당합니다. 프로세스가 perceptible이나 background 상태로 옮겨 가면 해당 항목이 대신 적용됩니다. 플랫폼 문서에 따르면 예산은 프로세스의 memory.high를 설정하며, 같은 상태의 플랫폼 한도를 결코 넘을 수 없습니다. 스스로 정한 예산을 넘어도 프로세스는 종료되지 않습니다. 대신 앞에서 본 것과 같은, 해당 프로세스만 겨냥한 회수가 일어납니다. 깨끗한 파일 페이지는 버려지고, 최근에 쓰이지 않은(cold) 익명 페이지는 zRAM으로 옮겨집니다. ApplicationExitInfo를 보고 나서야 플랫폼 한도를 알게 되는 대신, 직접 고른 크기에서 이 회수를 미리 요청하는 수단이 바로 예산입니다. 이전 안드로이드 버전은 알 수 없는 매니페스트 요소를 무시하므로, 예산을 선언해도 하위 호환성에는 영향이 없습니다.

예산 크기를 정할 때 도움이 되는 집계 규칙은 두 가지입니다. Zygote에서 물려받은 공유 메모리는 제외되며, 하드웨어 비트맵과 DMA-BUF 할당은 힙과 예산 바깥에서 집계됩니다. 다만 예산이 무엇을 재는지는 기억해 두세요. 예산이 재는 값은 cgroup에 집계되는 상주 메모리인 memory.current이며, 여기에는 DEX 코드 같은 파일 페이지가 들어가고 스왑은 빠집니다. Limiter의 종료 검사가 쓰는 anon + swap 합계와는 다른 숫자입니다.

결론

실무에서 쓸모 있는 변화는 Runtime.maxMemory()를 메모리 한도로 여기지 않는 것입니다. 앱이 스로틀링되고 있다는 사실은 어디에서도 알려 주지 않으며, Limiter가 일단 종료를 예약하면 앱이 무엇을 하든 이를 취소할 수 없습니다. 캐시 크기는 항목 개수가 아니라 바이트로 정하고, 그 크기에 네이티브 메모리와 비트맵 메모리까지 포함하세요. 포그라운드 서비스를 포함해 프로세스가 실행되는 모든 상태에서 anon + swap을 memory.high + memory.swap.max보다 넉넉히 낮게 유지하셔야 합니다. 실제 사용 환경에서는 ApplicationExitInfo에서 MemoryLimiter:AnonSwap을 찾고, TRIGGER_TYPE_ANOMALY에 등록하고, Play vitals의 anon + swap 지표를 지켜보세요.

Memory Limiter가 새로운 종류의 메모리를 만들어 내는 것은 아닙니다. 앱이 이미 할당하고 있는 익명 페이지 위에 프로세스별로 선을 하나 긋고, 그 선 앞에는 스로틀링 구간을, 선 너머에는 되돌릴 수 없는 종료를 둘 뿐입니다. Limiter와 같은 방식으로 앱을 측정하기 시작하시면, 사라지는 프로세스는 더 이상 미스터리가 아니라 대시보드 위의 숫자 하나가 됩니다.

아티클 목록으로 가기