비정상 종료 감지 및 진단

처리되지 않은 예외나 신호로 인해 예상치 못한 종료가 발생할 때마다 Android 앱이 비정상 종료됩니다. 자바 또는 Kotlin을 사용하여 작성된 앱은 Throwable 클래스로 표현되는 처리되지 않은 예외가 발생하면 비정상 종료됩니다. 네이티브 코드 언어를 사용하여 작성된 앱은 실행 중에 SIGSEGV와 같은 처리되지 않은 신호가 있는 경우 비정상 종료됩니다.

앱이 비정상 종료되면 Android는 앱 프로세스를 종료하고 대화상자를 표시하여 그림 1과 같이 앱이 중지되었음을 사용자에게 알립니다.

Android 기기에서 앱 비정상 종료

그림 1. Android 기기에서 앱 비정상 종료

앱이 포그라운드에서 실행되고 있어야만 비정상 종료되는 것은 아닙니다. 백그라운드에서 실행 중인 broadcast receiver나 콘텐츠 제공업체까지 포함하여 모든 앱 구성요소가 앱의 비정상 종료를 유발할 수 있습니다. 이러한 비정상 종료는 사용자에게 혼란스러울 때가 많은데, 사용자가 적극적으로 앱에 참여하지 않은 동안 발생한 것이기 때문입니다.

앱에 비정상 종료가 발생하면 이 페이지의 안내를 사용하여 문제를 진단하고 해결할 수 있습니다.

문제 감지

사용자가 비정상적인 앱 종료 문제를 과도하게 경험하고 있음을 개발자가 항상 알 수 있는 것은 아닙니다. 앱이 이미 게시된 상태라면 Android vitals를 사용하여 그 문제를 인식할 수 있습니다.

Android vitals

Android vitals를 사용하면 앱이 과도한 비정상 종료를 보이는 경우 Play Console을 통해 알림을 보냄으로써 앱 성능을 개선할 수 있습니다. Android vitals는 앱이 다음과 같을 때 비정상 종료가 과도하다고 간주합니다.

  • 일일 세션의 1.09% 이상에서 한 번 이상의 비정상 종료가 발생합니다.
  • 일일 세션의 0.18% 이상에서 두 번 이상의 비정상 종료가 발생합니다.

일일 세션이란 앱이 사용된 1일을 의미합니다. Google Play에서 Android vitals 데이터를 수집하는 방법에 관한 자세한 내용은 Play Console 문서를 참고하세요.

앱에 지나치게 많은 비정상 종료가 발생하는 것을 알았다면 다음 단계는 이를 진단하는 것입니다.

비정상 종료 진단

비정상 종료 문제를 해결하는 일은 어려울 수 있습니다. 그러나 비정상 종료의 근본 원인을 식별할 수 있다면 쉽게 해결책을 찾을 수 있습니다.

앱에 비정상 종료를 일으킬 수 있는 상황은 여러 가지가 있습니다. null 값이나 빈 문자열을 확인하는 작업 등 이유가 분명한 경우도 있지만, API에 무효한 인수를 전달하는 작업이나 복잡한 멀티스레드 상호작용 등 이유가 훨씬 불분명한 경우도 있습니다.

Android에서 비정상 종료가 발생하면 스택 트레이스가 생성됩니다. 스택 트레이스는 비정상 종료가 일어난 시점까지 프로그램에서 호출된 일련의 중첩 함수를 보여 주는 스냅샷입니다. Android vitals에서 비정상 종료 스택 트레이스를 확인할 수 있습니다.

스택 트레이스 읽기

비정상 종료 문제를 해결하는 첫 단계는 발생한 장소를 식별하는 것입니다. Play Console이나 logcat 도구 출력을 사용하고 있다면 보고서 세부정보에 있는 스택 트레이스를 사용할 수 있습니다. 사용할 수 있는 스택 트레이스가 없다면 수동으로 앱을 테스트하거나 영향을 받는 사용자에게 연락하여 비정상 종료를 로컬로 재현하고 logcat을 사용하는 동안 재현해야 합니다.

다음 트레이스는 자바 프로그래밍 언어를 사용하여 작성된 앱이 비정상 종료된 예를 보여줍니다.

--------- beginning of crash
AndroidRuntime: FATAL EXCEPTION: main
Process: com.android.developer.crashsample, PID: 3686
java.lang.NullPointerException: crash sample
at com.android.developer.crashsample.MainActivity$1.onClick(MainActivity.java:27)
at android.view.View.performClick(View.java:6134)
at android.view.View$PerformClick.run(View.java:23965)
at android.os.Handler.handleCallback(Handler.java:751)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:156)
at android.app.ActivityThread.main(ActivityThread.java:6440)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:746)
--------- beginning of system

스택 트레이스는 비정상 종료를 디버깅하는 데 중요한 정보 두 가지를 표시합니다.

  • 발생한 예외 유형
  • 예외가 발생한 코드 섹션

발생한 예외 유형은 일반적으로 무엇이 잘못되었는지에 관한 매우 강력한 힌트입니다. IOException인지 아니면 OutOfMemoryError인지 혹은 그 외의 다른 것에 해당하는지 확인하고 예외 클래스와 관련된 문서를 찾습니다.

예외가 발생한 소스 파일의 행 번호, 클래스, 메서드 및 파일이 스택 트레이스의 두 번째 행에 표시됩니다. 호출된 각 함수의 경우 다른 행에서 이전의 호출 사이트(스택 프레임이라고 함)를 표시합니다. 스택을 올라가며 코드를 검사하면 잘못된 값을 전달하는 장소를 찾을 수 있습니다. 코드가 스택 트레이스에 표시되지 않으면 어느 위치에서 무효한 매개변수를 비동기 작업에 전달했을 가능성이 있습니다. 스택 트레이스의 각 행을 검사하고 사용한 API 클래스를 찾으며 전달한 매개변수가 올바른지, 허용된 장소에서 호출했는지를 확인하여 어떤 일이 발생했는지 종종 파악할 수 있습니다.

C 코드와 C++ 코드를 사용하는 앱의 스택 트레이스는 거의 같은 방식으로 작동합니다.

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/foo/bar:10/123.456/78910:user/release-keys'
ABI: 'arm64'
Timestamp: 2020-02-16 11:16:31+0100
pid: 8288, tid: 8288, name: com.example.testapp  >>> com.example.testapp <<<
uid: 1010332
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    x0  0000007da81396c0  x1  0000007fc91522d4  x2  0000000000000001  x3  000000000000206e
    x4  0000007da8087000  x5  0000007fc9152310  x6  0000007d209c6c68  x7  0000007da8087000
    x8  0000000000000000  x9  0000007cba01b660  x10 0000000000430000  x11 0000007d80000000
    x12 0000000000000060  x13 0000000023fafc10  x14 0000000000000006  x15 ffffffffffffffff
    x16 0000007cba01b618  x17 0000007da44c88c0  x18 0000007da943c000  x19 0000007da8087000
    x20 0000000000000000  x21 0000007da8087000  x22 0000007fc9152540  x23 0000007d17982d6b
    x24 0000000000000004  x25 0000007da823c020  x26 0000007da80870b0  x27 0000000000000001
    x28 0000007fc91522d0  x29 0000007fc91522a0
    sp  0000007fc9152290  lr  0000007d22d4e354  pc  0000007cba01b640

backtrace:
  #00  pc 0000000000042f89  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::Crasher::crash() const)
  #01  pc 0000000000000640  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::runCrashThread())
  #02  pc 0000000000065a3b  /system/lib/libc.so (__pthread_start(void*))
  #03  pc 000000000001e4fd  /system/lib/libc.so (__start_thread)

네이티브 스택 트레이스에서 클래스 정보와 함수 수준 정보가 표시되지 않으면 네이티브 디버그 기호 파일을 생성하여 Google Play Console에 업로드해야 할 수 있습니다. 자세한 내용은 비정상 종료 스택 트레이스 가독화를 참고하세요. 네이티브 충돌에 관한 일반 정보는 네이티브 충돌 진단을 참고하세요.

비정상 종료 재현을 위한 도움말

에뮬레이터를 시작하거나 기기를 컴퓨터에 연결하는 것만으로 문제를 재현할 수는 없습니다. 개발 환경에는 대역폭, 메모리, 저장소와 같은 많은 리소스가 있습니다. 예외 유형을 사용하여 부족한 리소스가 무엇인지 확인하거나 Android 버전, 기기 유형 또는 앱 버전 사이의 상관관계를 찾으세요.

메모리 오류

OutOfMemoryError가 있는 경우 메모리 용량이 낮은 에뮬레이터를 만들어 테스트할 수 있습니다. 그림 2는 기기의 메모리양을 제어할 수 있는 AVD Manager 설정을 보여줍니다.

AVD Manager에서 메모리 설정

그림 2. AVD Manager에서 메모리 설정

네트워킹 예외

사용자는 모바일 또는 Wi-Fi 네트워크 적용 범위 내외로 자주 이동하기 때문에 애플리케이션 네트워크에서 예외는 일반적으로 오류가 아니라 예기치 않게 발생하는 정상적인 작동 조건으로 처리되어야 합니다.

UnknownHostException과 같은 네트워크 예외를 재현해야 한다면 애플리케이션이 네트워크를 사용하려고 하는 동안 비행기 모드를 켜보세요.

또 다른 옵션은 네트워크 속도 에뮬레이션 또는 네트워크 지연을 선택하여 에뮬레이터의 네트워크 품질을 낮추는 것입니다. AVD Manager에서 SpeedLatency 설정을 사용하거나 다음 명령줄 예에 나와 있는 것처럼 -netdelay-netspeed 플래그를 사용하여 에뮬레이터를 시작할 수 있습니다.

emulator -avd [your-avd-image] -netdelay 20000 -netspeed gsm

이 예에서는 모든 네트워크 요청에서 20초 지연 시간과 업로드 및 다운로드 속도 14.4Kbps를 설정합니다. 에뮬레이터의 명령줄 옵션에 관한 자세한 내용은 명령줄에서 에뮬레이터 시작을 참고하세요.

logcat으로 읽기

비정상 종료를 재현하는 단계를 실행하고 나면 logcat과 같은 도구를 사용하여 자세한 정보를 얻을 수 있습니다.

logcat 출력에서는 인쇄한 다른 로그 메시지가 무엇인지를 시스템의 기타 사항과 함께 표시합니다. 추가한 여분의 Log 구문을 인쇄하면 앱이 실행되는 동안 CPU와 배터리가 낭비되므로 그러한 구문을 끄는 것을 잊지 마세요.

null 포인터 예외로 인한 비정상 종료 방지

null 포인터 예외(런타임 오류 유형 NullPointerException으로 식별됨)는 일반적으로 null 상태 객체의 메서드를 호출하거나 멤버에 액세스하는 방식으로 그러한 객체에 액세스하려고 할 때 발생합니다. null 포인터 예외는 Google Play에서 앱이 비정상 종료되는 가장 큰 원인입니다. null의 목적은 객체가 누락되었음을 나타내는 것입니다(예: 객체가 아직 생성되지 않았거나 할당되지 않았음). null 포인터 예외를 방지하려면 메서드를 호출하거나 멤버에 액세스하려고 시도하기 전에 작업 중인 객체 참조가 null이 아닌지 확인해야 합니다. 객체 참조가 null이면 객체 참조에 관한 작업을 실행하기 전에 메서드를 종료하거나, 디버그 로그에 정보를 작성하는 방법 등으로 이 경우를 올바르게 처리합니다.

호출된 모든 메서드의 매개변수마다 null 검사를 하는 것은 원치 않기 때문에 IDE를 사용하거나 null 허용 여부를 나타내는 객체 유형을 사용할 수 있습니다.

자바 프로그래밍 언어

다음 섹션은 자바 프로그래밍 언어에 적용됩니다.

컴파일 시간 경고

IDE에서 컴파일 시간 경고를 수신하려면 @Nullable@NonNull로 메서드의 매개변수를 주석 처리하여 값을 반환합니다. 이러한 경고는 null 허용 객체가 발생할 수 있다는 메시지를 표시합니다.

null 포인터 예외 경고

이러한 null 검사는 null일 수 있는 객체를 대상으로 합니다. @NonNull 객체에 발생하는 예외는 코드에 처리해야 할 오류가 있음을 나타냅니다.

컴파일 시간 오류

null 허용 여부가 유의미해야 하므로 사용하는 유형에 null 허용 여부를 포함할 수 있습니다. 그러면 컴파일 시간 검사에서 null이 확인됩니다. 객체가 null일 수 있음을 알고 있고 null 허용 여부가 처리되어야 하는 경우 Optional 같은 객체에 null 허용 여부를 래핑할 수 있습니다. 항상 null 허용 여부를 알리는 유형을 사용해야 합니다.

Kotlin

Kotlin에서 null 허용 여부는 유형 시스템의 일부입니다. 예를 들어, 변수는 처음부터 null 허용 또는 null을 허용하지 않음으로 선언되어야 합니다. null 허용 유형은 ?와 함께 표시됩니다.

// non-null
var s: String = "Hello"

// null
var s: String? = "Hello"

null을 허용하지 않는 변수에는 null 값을 할당할 수 없으며 null 허용 변수를 null이 아닌 값으로 사용하려면 null 허용 여부와 관련해 그 변수를 검사해야 합니다.

null에 관한 명시적 검사를 원치 않으면 ?. 안전 호출 연산자를 사용할 수 있습니다.

val length: Int? = string?.length  // length is a nullable int
                                   // if string is null, then length is null

null 허용 객체의 null case를 처리하는 것이 좋습니다. 그러지 않으면 앱이 예기치 않은 상태가 될 수 있습니다. NullPointerException에도 더 이상 애플리케이션이 비정상 종료되지 않으면 이러한 오류가 존재하지 않는지도 알지 못합니다.

다음은 null 검사를 진행하는 몇 가지 방법은 다음과 같습니다.

  • if 검사

    val length = if(string != null) string.length else 0
    

    스마트 변환과 null 검사로 인해 Kotlin 컴파일러는 문자열 값이 null이 아님을 인식합니다. 따라서 안전 호출 연산자 없이 참조를 직접 사용할 수 있습니다.

  • ?: Elvis 연산자

    이 연산자를 사용하여 '객체가 null이 아니면 객체 반환. 그렇지 않으면 그 외 항목 반환'이라고 명시할 수 있습니다.

    val length = string?.length ?: 0
    

여전히 Kotlin에서는 NullPointerException이 발생할 수 있습니다. 다음은 가장 일반적인 상황입니다.

  • NullPointerException을 명시적으로 발생시키는 경우
  • null 어설션 !! 연산자를 사용하는 경우. 이 연산자는 모든 값을 null이 아닌 유형으로 변환하고 값이 null이면 NullPointerException을 발생시킵니다.
  • 플랫폼 유형의 null 참조에 액세스할 경우

플랫폼 유형

플랫폼 유형은 자바에서 비롯된 객체 선언입니다. 이러한 유형은 특별하게 처리됩니다. null 검사가 강제로 진행되지 않기 때문에 null이 아님을 확인하는 작업은 자바에서와 동일한 방식으로 진행됩니다. 플랫폼 유형 참조에 액세스되었을 때 Kotlin은 컴파일 시간 오류를 발생시키지 않지만 그러한 참조가 런타임 오류를 일으킬 수 있습니다. Kotlin 문서의 다음 예를 참고하세요.

val list = ArrayList<String>() // non-null (constructor result) list.add("Item")
val size = list.size // non-null (primitive int) val item = list[0] // platform
type inferred (ordinary Java object) item.substring(1) // allowed, may throw an
                                                       // exception if item == null

플랫폼 값이 Kotlin 변수에 할당된 경우 Kotlin은 유형 추론을 사용합니다. 또는 개발자가 필요한 유형을 정의할 수 있습니다. 자바에서 비롯된 참조에 올바른 null 허용 여부 상태를 보장하는 가장 좋은 방법은 자바 코드에 null 허용 여부 주석(예: @Nullable)을 사용하는 것입니다. Kotlin 컴파일러는 그러한 참조를 플랫폼 유형이 아닌 실제 null 허용 유형 또는 null이 허용되지 않는 유형으로 표시합니다.

자바 Jetpack API는 필요에 따라 @Nullable 또는 @NonNull로 주석 처리됩니다. Android 11 SDK에도 이와 유사한 접근 방식이 사용됩니다. 이 SDK에서 비롯된 유형 중 Kotlin에 사용되는 유형은 null 허용 유형 또는 null을 허용하지 않는 유형으로 올바로 표시됩니다.

Kotlin의 유형 시스템으로 인해 앱에서 NullPointerException 비정상 종료가 크게 감소한 것으로 나타났습니다. 예를 들어 Google Home 앱의 경우 새 기능 개발을 Kotlin으로 이전한 그해에 null 포인터 예외로 인해 비정상 종료가 30% 감소했습니다.