[Android] 안드로이드 메모리 탐험

빙티·2025년 5월 14일
2

안드로이드

목록 보기
2/3

안드로이드 메모리 관리

안드로이드는 가능한 한 많은 메모리를 활용해 성능을 최적화하도록 설계되었다.
예를 들면, 사용자가 앱 사용 중 홈 버튼을 눌러 나가거나 다른 앱으로 전환하더라도 해당 앱의 프로세스를 백그라운드에서 캐시 상태로 유지해 사용자가 앱을 빠르게 재시작할 수 있도록 한다.
이렇게 메모리를 적극적으로 활용하는 덕분에, 안드로이드 기기의 성능을 유지하기 위해선 메모리 관리가 매우 중요하다.
이번 포스팅에서는 안드로이드가 어떤 방식으로 메모리 관리 및 최적화를 하고 있는지 살펴보려 한다.




더 많은 힙 할당하기

안드로이드는 메모리 관리를 위해 애플리케이션마다 할당할 수 있는 힙 크기를 제한하며, 앱이 이 크기를 초과하면 OutOfMemoryError가 발생한다. 힙 영역의 크기는 디바이스, OS, 앱 설정에 따라 정해진다.

📌 힙(heap)이란?
앱이 런타임에서 동적으로 객체(Activity, List, Bitmap 등)를 생성할 때 사용하는 JVM의 메모리 영역

매니페스트의 <application> 태그에서 largeHeap 속성을 true로 설정해, OS에게 "이 앱은 메모리가 많이 필요하니, 가능한 더 큰 힙을 할당해줘" 라고 요청할 수 있다.
다만 어디까지나 '요청'이기 때문에 실제 할당되는 크기는 기기 및 OS 버전에 따라 달라질 수 있다.

<application
	android:name = "Example"
	...
	android:largeHeap = "true"
>

갤럭시S24+, 안드로이드 15 환경에서 largeHeap 설정에 따라 힙 크기가 어떻게 달라지는지 로그로 살펴보았다.

val maxHeap = Runtime.getRuntime().maxMemory() / (1024 * 1024)
Log.d("Memory", "Max heap size = ${maxHeap}MB")
largeHeap = falselargeHeap = true
이미지1이미지2

무려 2배나 커지는 것을 확인할 수 있었다!
그러나 힙 메모리가 커지면 GC에 걸리는 시간이 늘어나 앱이 무거워진다는 단점이 있다.
따라서 섣불리 힙 할당 크기를 늘리기보다는, 메모리 누수 등 근본적인 원인을 해결해 메모리 사용을 최적화하는 것이 중요하다.




리눅스의 메모리 관리

안드로이드는 리눅스 커널 위에서 동작하므로, 리눅스와 마찬가지로 페이징 기반의 가상 메모리 방식을 사용한다. 리눅스의 대표적인 메모리 관리 방식은 다음과 같다.

  • 스왑(Swap)
    메모리가 부족할 때 사용하지 않는 페이지를 디스크의 스왑 공간으로 내보내고(page-out), 필요할 때 다시 메모리로 불러오는(page-in) 방식.

  • OOM(Out Of Memory Killer)
    스왑 공간을 포함해 시스템이 더 이상 메모리를 확보할 수 없을 때, 커널이 개입해 프로세스를 종료시킨다. 이 때, 일반적으로 중요도가 낮고 메모리 사용량이 큰 프로세스를 우선적으로 제거한다.

그러나 리눅스의 스왑과 OOM을 모바일 환경인 안드로이드에 그대로 적용하기에는 문제가 있었으니…




안드로이드 메모리 유형

안드로이드 하드웨어 기기 또한 컴퓨터처럼 주기억장치와 보조기억장치를 갖고 있다.

  • 주기억장치 (RAM)
    • 운영체제와 앱 실행을 위한 고속 작업 공간이다.
    • 전원이 꺼지면 데이터가 사라지는 휘발성 메모리이다.
    • 보조기억장치에 비해 제한된 용량을 갖고있다.
  • 보조기억장치 (플래시 메모리)
    • 앱 설치 파일, 사진/영상 파일 등의 저장 공간으로 RAM에 비해 읽고 쓰는 속도가 느리다.
    • 전원이 꺼져도 데이터가 유지되는 비휘발성 영구 데이터 저장소
    • 주기억장치보다 큰 용량을 갖고있다.

플래시(Flash) 메모리

컴퓨터가 HDD나 SSD를 사용하는 것과 달리, 대부분의 스마트폰은 보조기억장치로 반도체 기반의 플래시 메모리를 사용한다. (정확히는 NAND 플래시 메모리)
플래시 메모리는 전원이 꺼져도 데이터를 유지할 수 있어 SSD, USB 드라이브 등에서 사용되며, 빠른 읽기/쓰기 속도와 작은 크기라는 장점이 있지만 아래와 같은 한계점도 있다.

  • 수명 단축
    쓰기 작업 횟수가 많아지면 플래시 메모리의 수명이 단축될 수 있다.
  • 배터리 소모
    디스크 I/O 작업은 배터리 소모를 증가시킨다.

이러한 플래시 메모리 기반 모바일 기기의 한계로 인해, 안드로이드에서는 보조기억장치의 스왑 공간을 제공하지 않는다.

그러나 스왑 공간 없이도 안드로이드는 메모리를 효과적으로 관리하도록 설계되었다.




zRam

바로, RAM 일부를 압축해서 스왑 공간처럼 사용하는 기법 덕분이다.
Android 9(Pie) 버전 이후 대부분의 기기에서 기본으로 zRAM을 활성화한다.
zRam은 압축률이 높아, 1GB zRAM에 실제로 2GB 이상의 데이터를 저장할 수 있다.

  1. 시스템 부팅 시, 커널이 zRAM 블록 장치를 생성 (/dev/zram0)
  2. RAM 일부를 zRAM에 할당
  3. 메모리 부족 시, 사용하지 않는 페이지를 압축하여 zRAM에 저장(page-out)
    이 때 압축된 파일은 .zip과 같아서 사용할 수 없다.
  4. 다시 필요하면 압축 해제 후 메모리에 복원(page-in)



로우 메모리 킬러(LMK)

기존의 리눅스 메모리 정책인 (OOM killer)는 임베디드 환경에 적용하기에 아래와 같은 한계점이 있었다.

  • 사용자 중심의 앱 우선순위, 애플리케이션 수명 주기를 고려하지 못함 (포그라운드, 백그라운드, 캐시)
  • 메모리 할당이 실패한 후 작동하는 사후 처리 방식이어서 개입 타이밍이 느림

이를 개선한 방식이 바로 LMK이다.

  • Android 커널에서 시스템의 메모리 상태를 모니터링하며 메모리 압박 상황을 감지
  • Android 프레임워크가 앱 프로세스별로 우선순위(OOM adj score)를 계산
  • 필요성이 가장 낮은 프로세스를 종료해 메모리 확보
    • cached, background, service, foreground 순으로 제거 우선순위가 높음
  • RAM을 확보하고 시스템 안정성을 유지

Android9부터는, 아래의 이유들로 LMK가 커널 영역에서 유저 영역으로 이동했다.
그에 따라 명칭도 LMKD (low memory killer daemon)로 변경되었다.

  • 성능 저하
  • 여유 메모리 제한에 의존적
  • 확장성이 떨어짐
  • vmscan 프로세스 속도가 느려짐



가비지 컬렉션

안드로이드 앱이 실행되면서 다양한 객체들이 메모리(heap)에 생성된다.
이 중 더 이상 사용되지 않는 객체들은 자동으로 제거되어야 메모리 누수를 막을 수 있다.
이 역할을 수행하는 것이 바로 가비지 컬렉터(Garbage Collector, GC)이다.

안드로이드 런타임 환경 ART(Android Runtime)의 GC는 Mark and Sweep 방식을 사용한다.

  • Mark 단계 : GC Root에서 시작해 참조를 따라가며 사용 중인 객체들을Mark하는 단계
  • Sweep 단계 : 힙 메모리 내부를 돌면서 Mark 되지 않은 메모리들을 해제하는 단계


STW(stop-the-world)

GC가 실행되면 일시적으로 애플리케이션의 모든 스레드가 멈추는 현상이 발생한다.
이를 STW(Stop-The-World)라고 하며, GC를 수행하는 스레드를 제외한 모든 작업이 중단된다.
STW 시간이 길어지면 UI가 멈추거나 애니메이션과 스크롤이 버벅이며 UX가 저하될 수 있다.
따라서 안드로이드에서는 이 STW 시간을 최대한 줄이기 위해 아래와 같은 최적화를 한다.
기회가 된다면 안드로이드 버전 별 GC의 발전 역사도 다뤄보면 좋을 것 같다.

  • Generational GC
    새로 생성된 객체는 짧게 사용되는 경우가 많다는 점을 이용해, 젊은 객체와 오래된 객체를 구분하여 관리
  • Concurrent Mark & Sweep (동시 수행)
    Mark & Sweep의 일부 단계를 앱 실행과 동시에 수행하여 STW 시간을 줄인다.

0개의 댓글