안드로이드 메모리 누수

송훈기·2022년 10월 3일
0

Android

목록 보기
9/10

스터디에서 발표했던 안드로이드 메모리 누수와 관련된 글을 노션에 작성했는데, 블로그에 옮겨놔야할거 같아서 옮겨 놓는다.

Android Memory Types

Random Access Memory(RAM)

  • RAM은 Application을 실행하는 동안 임시로 저장하는데 사용
  • 여기서 Application은 현재 Foreground에서 돌고 있는 App과 Background에서 돌고 있는 App을 의미한다.
  • 이것은 임시적이여서, App이 죽거나, 기기가 재시작되면 정보를 잃게 된다.

zRAM

  • RAM에 일부로서 사용되지 않는 자원을 압축, 예약된 영역으로 이동해 사용 가능한 메모리량을 늘리는 역할을 한다.
  • 이러한 압축하는 과정에서 CPU의 작업을 더 많이 필요로 하기 때문에 장치 작업을 느리게 할 수 있다.

Storage

  • 기기가 재부팅되어도 유지되어야 하는 데이터, 사진, 비디오, 음악, 문서등을 가지고 있다.
  • 예로는 SharedPreference , SQLite , Realm 등이 존재한다.
  • Android의 스토리지는 다양하지만, 현재의 스토리지는 일반적으로 32GB , 64GB , 128GB , 256GB로 상당히 많은 용량을 할 수 있다. (Galaxy S21 기준 256GB인데 microSD카드는 사용하지 못하도록 한다.)
  • microSD 카드 같은 것을 통해서 더 확장할 수도 있다.(최근 나온 것은 안되게 하는 경우도 존재한다)

메모리 관리 프로세스는 안드로이드 런타임(ART)와 그 이전 버전인 달빅 가상 머신으로 수행이 된다.

What are memory leaks

그럼 이제 메모리 릭이 왜 나는지를 알아야 하는데, 그전에 그렇다면 JVM은 운영체제에게서 받은 메모리를 어떻게 구분해서 사용하는지를 다시 기억을 되새김해봅시다.

JVM Runtime Data Area

기본적으로 큰 영역은 3가지입니다.

Method Area(메서드 영역)

  • 코드에서 사용되는 클래스(~~.class)들을 클래스 로더로 읽어 클래스별로 런타임 상수풀 , 필드 데이터 , 메소드 데이터 , 메서드 코드 생성자 코드 등을 분류해서 저장합니다.
  • 이 영역은 JVM이 시작할 때 생성되고, 모든 스레드가 공유하는 영역이 됩니다.

Heap (힙)

  • 객체와 배열이 생성되는 영역
  • 즉, 인스턴스와 배열이 동적으로(dynamic) 생성되는 공간입니다.
  • 이곳에서 생성된 객체와 배열은 JVM 스택 영역의 변수나 다른 객체의 필드에서 참조를 하게 된다.
  • 참조하는 변수나 필드가 존재하지 않는다면, 의미가 없는 객체로 판단하고 이를 GC가 힙에서 자동 제거 한다.
  • 힙에 대해서 자세히 알고 싶다면 아래 자료를 확인하면 될 듯 싶다.
  • 지금은 힙 얘기를 하려는 것은 아니니 이 정도면 충분하다고 생각한다.

Java Heap (with GC)

Thread

  • 위의 2 영역은 정확히 영역으로 구분 되지만 이 쓰레드는 여러개가 생성될 수 있다.
  • 자바에서 쓰레드를 구성하는 요소는 PC Register , JVM Stack , Native Method Stack이다.

PC Register

  • 현재 수행 중인 JVM Instruction 주소를 가짐

Native Method Stack

  • Java 외의 언어로 작성된 네이티브 코드를 위한 Stack(JNI 같은걸 말하겠죠??)

JVM Stack

  • 각 쓰레드 별로 1개씩 존재하고 스레드가 시작될 떄 할당된다.
  • 이는 메서드를 호출할 때마다 프레임이라는 단위로 추가하고 메서드가 종료되면 해당 프레임을 제거하는 동작을 수행한다.
  • 프레임 내부에는 로컬 변수 스택이 있고, 기본 타입 변수, 참조 타입 변수가 추가 되거나 제거 된다.
  • 변수가 이 영역에 생성되는 시점은 초기화가 될 때, 즉 최초로 변수에 값이 저장될 때임
  • 변수는 선언된 블록 안에서만 스택에 존재하고 블록을 벗어나면 스택에서 제거가 된다.
  • 기본 타입 변수는 스택 영역에 직접 값을 가지고 있다.
  • 참조 타입 변수는 값이 아니라 힙 영역이나 메서드 영역의 객체 주소를 가진다. ( 그림 1 참조 )

그림 1

이렇듯 Heap영역에서 만들어진 객체를 회수하기 위해서 Android에서는 GC(Garbage Collector)를 사용합니다.

Heap영역을 추적해 더이상 사용하지 않는 객체를 힙영역에서부터 회수함으로써 메모리 영역을 남기는 것이다.

그렇다면 생각을 한번 해보면 우리가 사용하지 않는 힙영역의 객체를 계속해서 유지하고 있다면 GC는 이 객체가 계속해서 사용중이라고 생각할 거고 쓰레기라고 생각하지 않을 것이다.

이는 즉 객체를 메모리에서 해제하지 못하게 되는 것이고 이때 메모리 릭이 발생한다.

이러한 데이터들을 하나 , 둘씩 제거하지 못한다면 어드샌가 UI는 굳고 뭐...앱이 망가는 그런 상태가 발생하는거죠 OutOfMemory가 발생하게 되는 것입니다.

자 그럼 지금은 자바의 예로 들었으니까 이를 Android에 대입해서 생각을 한번 해봅시다

MainActivity 1개만 존재하는 App이 있습니다

class MainActivity : AppCompatActivity(){

   override onCreate(savedStateInstance : Bundle?) {
       // 그냥 생각나는 대로 적은 겁니다 일단 있다고 합시다.
   }
} // (1)

이 상황에서 어떠한 다른 Thread를 생성하지 않았기 때문에 Main Thread 즉 UI Thread만 존재합니다.

그럼 MainThread 의 JVMStack 영역에는 MainActivity class에 대한 값이 있어야 하는데

그 값이 실 객체 값을 가지는 Call by Value 형태가 아닌 Heap 영역에 MainActivity에 대한 구현체는 있고 JvmStack에는 MainActivity 클래스의 주소인 주소 값만을 가지고 있는 형태가 될 것입니다.

뭐 기타..여러가지 Context라던가 이러한 Activity를 구성하기위한 여러가지 상위 객체들 또한 Stack에 올라가있는 형태가 되겠지요(MainThread Stack에)

여기서

class SingletonExample(context : Context) {
   private var mContext = context 

   companion object{
       var instance : SingletonExample? = null
       
       fun getSingleton(context : Context) : SingletonExample {
            if(instance == null) {
                instance = SingletonExample(context)
            }
            return instance as SingletonExample
       }
   }
}

이와 같은 SingletonExample이라는 클래스를 만들어 Context를 Singleton 형태로 받을 수 있도록 해봅시다. ( 예시일 뿐입니다 결코 좋은 형태가 아닙니다. )

class MainActivity : AppCompatActivity(){

   override onCreate(savedStateInstance : Bundle?) {
       val singleton = SingletonExample.getSingleton(this)
   }
} // (2)

여기서 singleton이라는 객체는 kotlin이라서 안적혀 있지만 new라는 것을 통해서 동적으로 생성된 객체입니다.

SingletonExample이 MainActivity에서 호출된 시점부터 SingletonExample은 MainActivity의 Context를 가지게 된 것이고 MainActivity가 죽게 되더라도, 즉 JVMStack영역에서 참조되는 것이 사라져 GC에서 제거 대상으로 포함해 제거를 하더라도, SingletonExample에서 계속 참조 중이기에 인스턴스를 아직 사용하고 있다고 간주하고 회수를 할 수 없게 됩니다. 이러한 상황이 메모리 누수를 일으킬 수 있게 됩니다.

일반적인 누수 패턴

1) 정적 참조에 대한 Activity 누수

Activity는 여러번 만들어지고 제거될 수 있는 객체이다.

하지만 정적 참조로 Activity를 참조한다면 앱이 메모리에 있는 동안 계속 유지되기 때문에 생명주기에 의해 제거되어도 GC가 수거할 수 없다.

따라서 Activity에서 정적 변수를 사용할 때 매우 주의 해야한다. 만약 정적 변수가 Activity를 직접 혹은 간접적으로 참조할 가능성이 있는 경우 onDestroy() 에서 참조를 끊어줘야한다.

세부적인 case

  1. static 변수에서 Activity를 참조하는 경우
  2. static View 에서 Activity를 참조하는 경우.
  3. 싱글턴 객체에 Activity 참조를 넘기는 경우.

2) Inner & 익명 클래스 사용으로 인한 Activity 누수

inner class는 outer class 변수에 접근 가능하다는 특징이 있다. 이 경우 inner class는 outer class의 참조를 유지해야한다는 특성이 있기 때문에, 내부/익명 클래스가 outer class 보다 오랫동안 유지 될 경우 누수가 발생한다.

따라서 누수 위험을 피하기 위해서는 내부/익명 클래스는 정적 클래스를 사용한다.

주로 중요하게 작용하는 지점은 다음과 같다
Activity 내부에서 Thread, handler, AsyncTask 를 사용하는경우

3) 스레드 사용으로 인한 Activity 누수

스레드의 경우 Activity보다 오래 작업할 수 있다. 따라서 Thread의 작업을 onDestroy() 에서 종료해야한다.

메모리 누수가 일어나면 왜 좋지 않은가?

1. 메모리 부족

메모리 누수가 발생하면, 기존에 차지한 메모리가 반환되지 않는다. 이 상황에서 다시 메모리를 요청하며 결과적으로 더 많은 메모리를 차지하게 된다.

하지만 메모리에는 한계가 있기 때문에, 더 이상 메모리에 할당할 수 없다면 앱은 메모리 부족으로 인해 강제 종료 된다.

⇒ 메모리 부족으로 앱이 종료되는 경우 사용자 입장에서 강제 종료의 경험을 겪는다.

2. GC 발생시 UI렌더링과 이벤트 처리가 중단된다.

메모리 누수가 발생하면 결과적으로 Android 시스템은 GC를 빈번하게 일으키게 된다. GC가 발생하면 UI 랜더링 및 이벤트 처리가 중단된다.

안드로이드는 대략 화면을 16ms로 그린다고 한다.
만약 GC가 오래걸리면 안드로이드는 프레임이 떨어진다.
⇒ 결과적으로 사용자 입장에서 앱이 느리다는 것을 인지하게 된다. 또한 심각한 경우 ANR 조건으로 ANR이 발생할 수 있다.

사용자는 일반적으로 100ms~200ms 이상 걸리는 작업에서 앱이 느리다고 인지하게 된다.

3. 재현이 어려움, QA 테스트로 찾기 어려움.

안드로이드 시스템이 메모리 할당을 거부할 때를 재현하기에는 어려움이 있다.

더불어 크래시 리포트로 추론하기도 어려움이 있다.

그럼 어떻게 해야할까?

결국 프로그래머가 GC가 어떻게 작동하는지 잘 이해해야 한다.

그리고 메모리 누수를 찾기 위해서 코드 작성과 리뷰에 노력을 해야한다.

안드로이드에서 일부 코드가 의심스러울 경우 누수를 예측할 수 있는 방법을 소개하면 다음과 같다.

  1. 안드로이드 스튜디오 메모리 프로파일러

    이 경우, 개발자가 결국 GC를 잘 이해하고 어디에서 누수가 날지 인지해야한다. 누수를 찾는 방법은 아래와 같은 순서를 거친다.

    1. 디버그 모드 실행
    2. 의심스러운 Activity로 이동하고 이전으로 돌아간뒤 다시 실행한다.(예측된 누수 상황 재현)
    3. 메모리 프로파일러 메모리 섹션 → GC 시작 버튼
    4. Dump java heap 버튼
    5. d 과정에서 .hprof파일이 열리는데 해당 뷰어에서 다음과 같은 작업으로 누수를 탐지할 수 있다.
      Activity 객체가 하나 이상인경우 누수가 있다는 것이다.
      - Analyzer Tasks 도구를 사용하여 누수되는 Activity 자동 탐지
      - Class List View 를 Package Tree View로 변경하여 Destory해야하는 Activity를 찾는다.
    6. 누수되는 Activity를 찾았다면 하단 참조트리 창에서 Activity를 참조하는 객체를 찾으면 된다.
  2. Square - Leak Canary

    앱 액티비티들에 약한 참조를 만들어 GC 이후에 참조가 지워지는지 확인한다.

    그렇지 않은 경우 .hprof 를 이용하여(힙 덤프) 분석하고 누수 발생을 확인한다.

    만약 누수가 있는경우 알림이 표시되고 별도 앱을 통해 누수가 발생된 위치를 트리형태로 표시한다.

    주의) 개발/테스트 빌드시에만 Leak Canary를 설치하는 것이 좋다. 사용자 빌드 전에 개발자와 QA가 미리 메모리 누수를 찾기 위함.

결과적으로 프로그래머 자신이 GC의 작동과 어떤 경우에 누수의 위험이 있는지 잘 인지하고 이를 신경쓰고, 주의하여 코드를 작성해야한다.

profile
안녕하세요 송훈기입니다.

0개의 댓글