디버깅 소개

1. 시작하기 전에

소프트웨어를 사용한 적이 있는 사용자는 버그를 경험했을 가능성이 높습니다. 버그란 의도치 않은 동작(예: 앱 비정상 종료, 기능이 예상대로 작동하지 않음)을 발생시키는 소프트웨어 오류입니다. 경험이 많든 적든 모든 개발자가 작성하는 코드에는 버그가 있기 마련이며, Android 개발자에게 가장 중요한 기술 중 하나는 바로 버그를 식별하고 수정하는 것입니다. 버그 수정만을 위한 앱 버전이 출시되는 것도 드문 일은 아닙니다. 예를 들어 아래의 Google 지도 버전 세부정보를 참조하세요.

9d5ec1958683e173.png

버그를 수정하는 프로세스를 디버깅이라고 합니다. 저명한 컴퓨터 공학자 브라이언 커니핸은 "가장 효과적인 디버깅 도구는 신중하게 배치된 print 문과 함께 주의 깊게 생각하는 것입니다"라는 말을 남겼습니다. 이전 Codelab를 통해 Kotlin의 println() 문에 이미 익숙해졌을 수 있지만 전문 Android 개발자라면 로깅을 사용하여 프로그램의 출력을 더욱 깔끔하게 정리합니다. 이 Codelab에서는 Android 스튜디오에서 로깅을 사용하는 방법과 이를 디버깅 도구로 사용하는 방법을 알아봅니다. 스택 트레이스라는 오류 메시지 로그를 읽어 버그를 식별하고 해결하는 방법도 알아봅니다. 마지막으로 직접 버그를 조사하는 방법과 실행되는 앱의 스크린샷이나 GIF로 Android Emulator에서 출력을 캡처하는 방법을 알아봅니다.

기본 요건

  • Android 스튜디오에서 프로젝트를 탐색하는 방법을 알고 있습니다.

학습할 내용

이 Codelab을 마치고 나면 다음을 할 수 있습니다.

  • android.util.Logger를 사용하여 로그 작성
  • 다양한 로그 수준을 사용할 시점 파악
  • 로그를 간단하고 강력한 디버깅 도구로 사용
  • 스택 트레이스에서 유의미한 정보를 찾는 방법 파악
  • 오류 메시지를 검색하여 애플리케이션 비정상 종료 해결
  • Android Emulator에서 스크린샷과 애니메이션 GIF 캡처

필요한 항목

  • Android 스튜디오가 설치된 컴퓨터

2. 새 프로젝트 만들기

복잡하고 규모가 큰 앱을 사용하는 대신 로그 구문과 디버깅에 로그 구문을 사용하는 방법을 보여줄 빈 프로젝트로 시작합니다.

다음과 같이 새 Android 스튜디오 프로젝트를 만듭니다.

  1. New Project 화면에서 Empty Activity를 선택합니다.

72a0bbf2012bcb7d.png

  1. 앱 이름을 Debugging으로 지정합니다. 언어를 Kotlin으로 설정하고 다른 모든 부분은 변경하지 않고 그대로 둡니다.

60a1619c07fae8f5.png

프로젝트를 만들면 MainActivity.kt라는 파일이 표시된 새 Android 스튜디오 프로젝트가 표시됩니다.

e3ab4a557c50b9b0.png

3. 로깅 및 디버그 출력

이전 과정에서는 Kotlin의 println() 문을 사용하여 텍스트 출력을 생성했습니다. Android 앱에서 출력 로깅 권장사항은 로그 클래스를 사용하는 것입니다. 출력 로깅 함수에는 여러 가지가 있고 Log.v(), Log.d(), Log.i(), Log.w() 또는 Log.e() 형식을 사용합니다. 이러한 메서드는 두 매개변수를 사용합니다. 첫 번째는 '태그'라는 매개변수로, 로그 메시지의 소스를 식별하는 문자열입니다(예: 텍스트를 로그한 클래스 이름). 두 번째는 실제 로그 메시지입니다.

다음 단계를 따라 빈 프로젝트에서 로깅 사용을 시작합니다.

  1. MainActivity.kt에서 클래스 선언 앞에 TAG라는 상수를 추가하고 값을 클래스 이름인 MainActivity로 설정합니다.
private const val TAG = "MainActivity"
  1. 다음과 같이 MainActivity 클래스에 logging()이라는 새 함수를 추가합니다.
fun logging() {
    Log.v(TAG, "Hello, world!")
}
  1. onCreate()에서 logging()을 호출합니다. 새 onCreate() 메서드는 다음과 같이 표시됩니다.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    logging()
}
  1. 앱을 실행하여 로그가 어떻게 작동하는지 확인합니다. 로그가 화면 하단의 Logcat 창에 표시됩니다. Logcat은 기기(또는 에뮬레이터)에서 진행되는 다른 프로세스의 출력을 표시하므로 드롭다운 메뉴에서 앱(com.example.debugging)을 선택하여 앱과 관련 없는 모든 로그를 필터링할 수 있습니다.

199c65d11ee52b5c.png

출력 창에서 'Hello, world!' 출력을 확인할 수 있습니다. 필요하다면 Logcat 창 상단의 검색창에 'hello'를 입력하여 모든 로그를 검색합니다.

92f258013bc15d12.png

로그 수준

서로 다른 문자로 명명된 다른 로그 함수가 존재하는 이유는 다른 로그 수준에 상응하기 때문입니다. 출력하려는 정보의 유형에 따라 Logcat 출력에서 필터링하는 데 도움이 되는 다른 로그 수준을 사용합니다. 정기적으로 사용하는 5가지 기본 로그 수준이 있습니다.

로그 수준

사용 사례

ERROR

ERROR 로그는 앱이 비정상 종료된 이유 등 심각한 문제가 발생했다고 보고합니다.

Log.e(TAG, "The cake was left in the oven for too long and burned.").

WARN

WARN 로그는 오류보다 덜 심각하지만 더 심각한 오류를 방지하기 위해 수정해야 하는 사항을 보고합니다. 지원 중단된 함수를 호출하는 경우를 예로 들 수 있습니다. 즉, 최신 함수로 대체하여 사용하는 것이 좋습니다.

Log.w(TAG, "This oven does not heat evenly. You may want to turn the cake around halfway through to promote even browning.")

INFO

INFO 로그는 성공적으로 완료되는 작업과 같은 유용한 정보를 제공합니다.

Log.i(TAG, "The cake is ready to be served.").println("The cake has cooled.")

DEBUG

DEBUG 로그에는 문제를 조사할 때 도움이 될 만한 정보가 포함됩니다. 이러한 로그는 Google Play 스토어에 게시하는 것과 같은 출시 빌드에는 포함되지 않습니다.

Log.d(TAG, "Cake was removed from the oven after 55 minutes. Recipe calls for the cake to be removed after 50 - 60 minutes.")

VERBOSE

이름에서 알 수 있듯이 verbose는 가장 포괄적인 로그 수준입니다. 상세 로그와 비교해 디버그 로그는 다소 주관적이나, 일반적으로 상세 로그는 기능이 구현된 후에 삭제될 수 있는 반면 디버그 로그는 디버깅에도 유용할 수 있습니다. 이러한 로그도 출시 빌드에 포함되지 않습니다.

Log.v(TAG, "Put the mixing bowl on the counter.")Log.v(TAG, "Grabbed the eggs from the refrigerator.")Log.v(TAG, "Plugged in the stand mixer.")

각 유형의 로그 수준을 사용할 시점, 특히 DEBUGVERBOSE를 사용할 시점에 관해 정해진 규칙은 없습니다. 소프트웨어 개발팀은 각 로그 수준을 사용해야 하는 경우에 대한 자체 가이드라인을 만들거나 VERBOSE와 같은 특정 로그 수준을 아예 사용하지 않기로 결정할 수 있습니다. 이러한 두 로그 수준은 출시 빌드에 없으므로 로그를 사용하여 디버그해도 게시된 앱의 성능에 영향을 미치지 않지만 println() 문은 출시 빌드에 남아 성능에 부정적인 영향을 미친다는 점에 유의해야 합니다.

Logcat에서 이와 같은 다양한 로그 수준의 모습을 살펴보겠습니다.

  1. MainActivity.kt에서 logging() 메서드의 내용을 다음으로 바꿉니다.
fun logging() {
    Log.e(TAG, "ERROR: a serious error like an app crash")
    Log.w(TAG, "WARN: warns about the potential for serious errors")
    Log.i(TAG, "INFO: reporting technical information, such as an operation succeeding")
    Log.d(TAG, "DEBUG: reporting technical information useful for debugging")
    Log.v(TAG, "VERBOSE: more verbose than DEBUG logs")
}
  1. 앱을 실행하고 Logcat의 출력을 관찰합니다. 필요한 경우 출력을 필터링하여 com.example.debugging 프로세스의 로그만 표시합니다. 출력을 필터링하여 'MainActivity' 태그가 있는 로그만 표시할 수도 있습니다. 이렇게 하려면 Logcat 창 오른쪽 상단에 있는 드롭다운 메뉴에서 Edit Filter Configuration을 선택합니다.

383ec6d746bb72b1.png

  1. 그런 다음 Log Tag에 'MainActivity'를 입력하고 다음과 같이 필터 이름을 만듭니다.

e7ccfbb26795b3fc.png

  1. 이제 'MainActivity' 태그가 있는 로그 메시지만 표시됩니다.

4061ca006b1d278c.png

클래스 이름 앞에 로그 수준에 상응하는 문자(예: W/MainActivity)가 있는지 확인합니다. 또한 WARN 로그는 파란색으로 표시되지만 ERROR 로그는 빨간색으로 표시되며, 이전 예시의 치명적인 오류와 동일합니다.

  1. 프로세스별로 디버그 출력을 필터링하는 것과 마찬가지로 로그 수준별로 출력을 필터링할 수도 있습니다. 기본적으로 Verbose로 설정되어 있으며, VERBOSE 로그 및 상위 로그 수준을 표시합니다. 드롭다운 메뉴에서 Warn을 선택하면 WARNERROR 수준 로그만 표시됩니다.

c4aa479a8dd9d4ca.png

  1. 다시 드롭다운을 Assert로 변경하면 표시되는 로그가 없는 것을 관찰할 수 있습니다. 이렇게 하면 ERROR 수준 이하의 모든 항목이 필터링됩니다.

ee3be7cfaa0d8bd1.png

이렇게 하면 println() 문이 너무 심각한 것처럼 보일 수 있지만 더 규모가 큰 앱을 빌드할수록 더 많은 Logcat 출력이 발생하며, 다양한 로그 수준을 사용하면 가장 유용하고 관련성 높은 정보를 선택할 수 있습니다. 로그 사용이 권장사항으로 여겨지며 Android 개발에서 println()보다 선호됩니다. 디버그 로그와 상세 로그가 출시 빌드의 성능에 영향을 미치지 않기 때문입니다. 다양한 로그 수준에 따라 로그를 필터링할 수도 있습니다. 올바른 로그 수준을 선택하면 코드에 익숙하지 않은 개발팀 내 개발자에게 도움이 되며, 버그를 파악하고 해결하기가 쉬워집니다.

4. 오류 메시지가 있는 로그

버그 삽입

빈 프로젝트에서는 디버깅할 것이 많지 않습니다. Android 개발자에게 발생하는 버그는 대부분 앱의 비정상 종료를 수반하며 이는 분명히 우수한 사용자 환경이 아닙니다. 이 앱을 다운시키는 코드를 추가해 보겠습니다.

수학 수업에서 0으로는 숫자를 나눌 수 없다고 배웠을 것입니다. 코드에서 0으로 나누려고 하면 어떻게 되는지 살펴보겠습니다.

  1. 다음 함수를 logging() 함수 위 MainActivity.kt에 추가합니다. 이 코드는 숫자 두 개로 시작하고 repeat를 사용하여 분자를 분모로 다섯 번 나눈 결과를 기록합니다. repeat 블록의 코드가 실행될 때마다 분모 값이 1씩 감소합니다. 다섯 번째이자 마지막 반복에서 앱은 0으로 나누기를 시도합니다.
fun division() {
    val numerator = 60
    var denominator = 4
    repeat(5) {
        Log.v(TAG, "${numerator / denominator}")
        denominator--
    }
}
  1. onCreate()에서 logging()을 호출한 후 division() 함수 호출을 추가합니다.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    logging()
    division()
}
  1. 앱을 다시 실행하면 앱이 다운됩니다. MainActivity.kt 클래스의 로그까지 아래로 스크롤하면 이전에 정의한 logging() 함수의 로그와 division() 함수의 상세 로그가 표시되고 앱이 다운된 이유를 설명하는 빨간색 오류 로그가 표시됩니다.

12d87f287661a66.png

스택 트레이스의 구성

비정상 종료(예외라고도 함)를 설명하는 오류 로그를 스택 트레이스라고 합니다. 스택 트레이스는 가장 최근에 호출된 것부터 예외에 이르기까지 호출된 모든 함수를 표시합니다. 전체 출력은 다음과 같습니다.

Process: com.example.debugging, PID: 14581
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.ArithmeticException: divide by zero
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
     Caused by: java.lang.ArithmeticException: divide by zero
        at com.example.debugging.MainActivity.division(MainActivity.kt:21)
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:14)
        at android.app.Activity.performCreate(Activity.java:8000)
        at android.app.Activity.performCreate(Activity.java:7984)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

텍스트가 너무 많습니다. 다행히 정확한 오류로 범위를 좁히는 데는 일반적으로 몇 가지만 필요합니다. 맨 위부터 시작하겠습니다.

  1. java.lang.RuntimeException:
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.ArithmeticException: divide by zero

첫 번째 줄은 앱이 활동을 시작할 수 없었다고 설명하며, 이는 앱이 다운된 이유입니다. 다음 줄은 정보를 조금 더 제공합니다. 활동을 시작할 수 없었던 구체적인 이유는 ArithmeticException 때문입니다. 자세히 살펴보면 ArithmeticException의 유형은 'divide by zero'였습니다.

  1. Caused by:
Caused by: java.lang.ArithmeticException: divide by zero
        at com.example.debugging.MainActivity.division(MainActivity.kt:21)

'Caused by' 줄까지 아래로 스크롤하면 'divide by zero' 오류가 다시 표시됩니다. 이번에는 오류가 발생한 정확한 함수(division())와 정확한 줄 번호(21)도 표시됩니다. Logcat 창의 파일 이름과 줄 번호는 하이퍼링크로 연결됩니다. 출력에는 오류가 발생한 함수의 이름(division())과 이를 호출한 함수(onCreate())도 표시됩니다.

이 버그가 의도적으로 도입되었기 때문에 당연한 결과입니다. 하지만 알 수 없는 오류의 원인을 파악해야 하는 경우 정확한 예외 유형, 함수 이름, 줄 번호를 알고 있으면 매우 유용한 정보를 얻을 수 있습니다.

'스택 트레이스'가 필요한 이유

'스택 트레이스'라는 용어는 오류로 인한 텍스트 출력에서는 낯선 용어처럼 들릴 수 있습니다. 작동 방식을 더 잘 이해하려면 함수 스택을 더 자세히 알아야 합니다.

한 함수가 다른 함수를 호출하면 기기는 두 번째 함수가 완료될 때까지 첫 번째 함수의 코드를 실행하지 않습니다. 두 번째 함수 실행이 완료되면 첫 번째 함수가 중단된 부분부터 재개됩니다. 두 번째 함수에서 호출한 함수의 경우에도 마찬가지입니다. 두 번째 함수는 세 번째 함수(와 이 함수가 호출하는 다른 함수)가 완료될 때까지 실행을 재개하지 않고 첫 번째 함수는 두 번째 함수의 실행이 완료될 때까지 재개되지 않습니다. 이는 실제로 접시나 카드를 쌓아 놓은 것과 유사합니다. 접시를 사용하려면 맨 위에 있는 접시를 가져와야 합니다. 먼저 위에 있는 접시를 모두 치우지 않으면 아래에 있는 접시를 꺼내기는 불가능합니다.

함수 스택은 다음 코드로 설명할 수 있습니다.

val TAG = ...

fun first() {
    second()
    Log.v(TAG, "1")
}

fun second() {
    third()
    Log.v(TAG, "2")
    fourth()
}

fun third() {
    Log.v(TAG, "3")
}

fun fourth() {
    Log.v(TAG, "4")
}

first()를 호출하면 다음 순서로 숫자가 기록됩니다.

3
2
4
1

이유가 뭘까요? 첫 번째 함수가 호출되면 second()가 즉시 호출되므로 숫자 1이 바로 기록될 수 없습니다. 함수 스택은 다음과 같습니다.

second()
first()

그런 다음 두 번째 함수는 third()를 호출하고 이는 함수 스택에 추가됩니다.

third()
second()
first()

세 번째 함수는 숫자 3을 출력합니다. 실행이 완료되면 출력된 숫자는 함수 스택에서 삭제됩니다.

second()
first()

이제 second() 함수는 숫자 2를 기록하고 fourth()를 호출합니다. 지금까지 숫자 3을 기록하고 숫자 2를 기록했으며 이제 함수 스택은 다음과 같습니다.

fourth()
second()
first()

fourth() 함수는 숫자 4를 출력하고 함수 스택에서 삭제됩니다. 그런 다음 second() 함수 실행이 완료되고 함수 스택에서 삭제됩니다. second() 및 코드가 호출한 모든 함수가 완료되었으므로 기기는 숫자 1을 출력하는 first()의 나머지 코드를 실행합니다.

따라서 숫자는 4, 2, 3, 1 순서로 기록됩니다.

천천히 코드를 살펴보며 함수 스택의 이미지를 머릿속에 그려보면 어떤 코드가 어떤 순서로 실행되는지 정확히 알 수 있습니다. 이 방법만으로도 위의 0으로 나누기 예와 같은 버그에 사용할 강력한 디버깅 기법이 될 수 있습니다. 코드를 단계별로 실행하면 좀 더 복잡한 문제를 디버그할 수 있도록 로그 구문을 삽입할 위치도 파악할 수 있습니다.

5. 로그를 사용하여 버그 식별 및 해결

이전 섹션에서는 스택 트레이스, 특히 다음 줄을 검토했습니다.

Caused by: java.lang.ArithmeticException: divide by zero
        at com.example.debugging.MainActivity.division(MainActivity.kt:21)

여기서 비정상 종료가 21번 줄에서 발생했고 0으로 나누기와 관련이 있음을 알 수 있습니다. 따라서 이 코드가 실행되기 전 어느 시점에서 분모는 0이었습니다. 이와 같이 양이 적은 예에서는 직접 코드를 단계별로 실행하기가 적합하지만 로그 구문을 사용하여 0으로 나누기가 발생하기 전에 분모 값을 출력해 시간을 절약할 수도 있습니다.

  1. Log.v() 문 앞에 분모를 로그하는 Log.d() 호출을 추가합니다. 디버깅 전용인 Log.d()는 상세 로그를 필터링하기 위해 사용됩니다.
Log.d(TAG, "$denominator")
  1. 앱을 다시 실행합니다. 여전히 다운되지만 분모가 여러 번 기록됩니다. Filter Configuration을 사용하여 "MainActivity" 태그가 있는 로그만 표시할 수 있습니다.

d6ae5224469d3fd4.png

  1. 값이 여러 개 출력되는 것을 확인할 수 있습니다. 분모가 0일 때 다섯 번째 반복에서 다운되기 전에 루프가 몇 번 실행되는 것처럼 보입니다. 이는 분모가 4이고 루프가 5회 반복을 위해 분모를 1씩 감소시키므로 적절합니다. 이 버그를 수정하려면 루프의 반복 횟수를 5에서 4로 변경하면 됩니다. 앱을 다시 실행하면 더 이상 다운되지 않습니다.
fun division() {
    val numerator = 60
    var denominator = 4
    repeat(4) {
        Log.v(TAG, "${numerator / denominator}")
        denominator--
    }
}

6. 디버깅 예: 존재하지 않는 값에 액세스

기본적으로 프로젝트를 만드는 데 사용한 Blank Activity 템플릿은 화면 중앙에 TextView가 있는 단일 활동을 추가합니다. 앞서 학습한 것과 같이 Layout Editor에서 ID를 설정하고 findViewById()로 뷰에 액세스하여 코드에서 뷰를 참조할 수 있습니다. 활동 클래스에서 onCreate()를 호출할 때 먼저 setContentView()를 호출하여 레이아웃 파일(예: activity_main.xml)을 로드해야 합니다. setContentView()를 호출하기 전에 findViewById()를 호출하려고 하면 뷰가 없으므로 앱이 다운됩니다. 다른 버그를 설명하기 위해 뷰에 액세스해 보겠습니다.

  1. activity_main.xml을 열고 Hello, world! TextView를 선택하고 idhello_world로 설정합니다.

c94be640d0e03e1d.png

  1. 다시 onCreate()ActivityMain.kt에서 코드를 추가하여 TextView를 가져오고 텍스트를 "Hello, debugging!"으로 변경한 다음 setContentView()를 호출합니다.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val helloTextView: TextView = findViewById(R.id.hello_world)
    helloTextView.text = "Hello, debugging!"
    setContentView(R.layout.activity_main)
    division()
}
  1. 앱을 다시 실행하고 실행 즉시 다시 다운되는지 확인합니다. "MainActivity" 태그가 없는 로그를 확인하려면 이전 예의 필터를 삭제해야 할 수도 있습니다. 840ddd002e92ee46.png

예외는 Logcat에서 마지막에 표시됩니다(표시되지 않는 경우 RuntimeException 검색). 출력은 다음과 같습니다.

Process: com.example.debugging, PID: 14896
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.NullPointerException: findViewById(R.id.hello_world) must not be null
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
     Caused by: java.lang.NullPointerException: findViewById(R.id.hello_world) must not be null
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:14)
        at android.app.Activity.performCreate(Activity.java:8000)
        at android.app.Activity.performCreate(Activity.java:7984)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

이전과 마찬가지로 상단에 "Unable to start activity"가 표시됩니다. 이는 MainActivity를 실행하기도 전에 앱이 비정상 종료되었기 때문입니다. 다음 줄은 오류에 대해 조금 더 자세히 설명합니다.

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.NullPointerException: findViewById(R.id.hello_world) must not be null

스택 트레이스를 살펴보면 정확한 함수 호출 및 줄 번호가 표시되는 줄도 표시됩니다.

Caused by: java.lang.NullPointerException: findViewById(R.id.hello_world) must not be null

이 오류의 정확한 의미는 무엇이며 "null" 값은 정확히 무엇인가요? 이는 잘못된 예시이고 앱이 비정상 종료된 이유를 이미 알고 있을 수도 있겠지만 이전에는 본 적이 없는 오류 메시지를 분명히 마주할 것입니다. 이 경우 오류를 처음 경험한 사용자가 아닐 수 있으며, 가장 숙련된 개발자조차도 다른 사용자가 문제를 어떻게 해결했는지 확인하기 위해 Google에 오류 메시지를 검색합니다. 이 오류를 검색하면 개발자가 질문을 하고 버그가 있는 코드 또는 보다 일반적인 프로그래밍 주제에 대한 답변을 제공할 수 있는 사이트인 StackOverflow에서 여러 결과를 확인할 수 있습니다.

답변이 유사하지만 정확히 같지는 않은 질문이 많을 수 있으므로 답변을 직접 검색할 때 다음 팁을 염두에 두세요.

  1. 답변이 얼마나 오래되었나요? 몇 년 전 답변은 더 이상 관련이 없거나 오래된 버전의 언어나 프레임워크를 사용하고 있을 수 있습니다.
  2. 답변에서 자바나 Kotlin을 사용하나요? 문제가 특정 언어나 특정 프레임워크에만 관련이 있나요?
  3. 'accepted(수락됨)'로 표시되거나 찬성 투표가 많은 답변이 더 유용할 수 있지만 다른 답변도 여전히 중요한 정보를 제공할 수 있다는 점에 유의합니다.

1636a21ff125a74c.png

숫자는 찬성 투표(또는 반대 투표)수를 나타내고 녹색 체크표시는 수락된 답변임을 나타냅니다.

기존 질문을 찾을 수 없으면 언제든지 새로 질문할 수 있습니다. StackOverflow(또는 다른 사이트)에서 질문할 때는 이 가이드라인을 염두에 두는 것이 좋습니다.

이제 오류를 검색합니다.

a60ba40e5247455e.png

답변을 읽어보면 오류의 원인이 여러 가지로 다양할 수 있음을 알게 됩니다. 그러나 setContentView() 전에 의도적으로 findViewById()를 호출한 점을 고려하면 이 질문에 대한 답변 중 일부는 유익하다고 생각됩니다. 예를 들어 두 번째로 투표가 많은 답변은 다음과 같습니다.

'setContentView를 호출하기 전에 findViewById를 호출하고 있을지도 모릅니다. 그렇다면 setContentView를 호출한 findViewById를 호출해 보세요.'

이 답변을 확인한 후 실제로 findViewById() 호출이 setContentView() 전에 너무 일찍 실행되며 대신 setContentView() 다음에 호출되어야 함을 코드에서 확인할 수 있습니다.

코드를 업데이트하여 오류를 해결합니다.

  1. findViewById() 호출과 helloTextView 텍스트를 설정하는 줄을 setContentView() 호출 아래로 이동합니다. 새 onCreate() 메서드는 아래와 같습니다. 다음과 같이 로그를 추가하여 버그가 수정되었는지 확인할 수도 있습니다.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    Log.d(TAG, "this is where the app crashed before")
    val helloTextView: TextView = findViewById(R.id.hello_world)
    Log.d(TAG, "this should be logged if the bug is fixed")
    helloTextView.text = "Hello, debugging!"
    logging()
    division()
}
  1. 앱을 다시 실행합니다. 앱이 더 이상 다운되지 않고 텍스트가 올바르게 업데이트됩니다.

9ff26c7deaa4a7cc.png

스크린샷 찍기

지금까지 이 과정에서 여러 Android Emulator 스크린샷을 확인했을 것입니다. 스크린샷 찍기는 비교적 간단하며 버그 재현 단계와 같은 정보를 다른 팀원과 공유하는 데 도움이 될 수 있습니다. Android Emulator에서 오른쪽 툴바의 카메라 아이콘을 눌러 스크린샷을 찍을 수 있습니다.

455336f50c5c3c7f.png

Command+S 단축키를 사용하여 스크린샷을 찍을 수도 있습니다. 스크린샷은 자동으로 데스크톱 폴더에 저장됩니다.

실행 중인 앱 녹화

스크린샷이 여러 정보를 전달하지만 실행 중인 앱을 녹화하여 공유하면 버그를 일으키는 문제를 재현하는 데 유용할 수 있습니다. Android Emulator에서는 실행 중인 앱의 GIF(애니메이션 이미지)를 쉽게 캡처할 수 있는 내장 도구를 제공합니다.

  1. 오른쪽에 있는 에뮬레이터 도구에서 More 558dbea4f70514a8.png 버튼(마지막 옵션)을 클릭하면 추가적인 에뮬레이터 디버깅 옵션이 표시됩니다. 테스트 목적으로 실제 기기의 기능을 시뮬레이션하는 추가 도구를 제공하는 창이 표시됩니다.

46b1743301a2d12.png

  1. 왼쪽 사이드 메뉴에서 Record and Playback을 클릭하면 녹화를 시작하는 버튼이 있는 화면이 표시됩니다.

dd8b5019702ead03.png

  1. 현재 프로젝트에는 정적 TextView 외에 녹화할 흥미로운 점이 없습니다. 몇 초마다 라벨을 업데이트하여 나누기 결과를 표시하도록 코드를 수정해 보겠습니다. MainActivitydivision() 메서드에서 Log() 호출 전에 Thread.sleep(3000) 호출을 추가합니다. 이제 메서드가 다음과 같이 표시됩니다(루프는 비정상 종료를 방지하려고 4번만 반복되어야 함).
fun division() {
   val numerator = 60
   var denominator = 4
   repeat(4) {
       Thread.sleep(3000)
       Log.v(TAG, "${numerator / denominator}")
       denominator--
   }
}
  1. activity_main.xml에서 TextViewiddivision_textview로 설정합니다.

db3c1ef675872faf.png

  1. 다시 MainActivity.kt에서 Log.v() 호출을 다음 findViewById()setText() 호출로 대체하여 텍스트를 몫으로 설정합니다.
findViewById<TextView>(R.id.division_textview).setText("${numerator / denominator}")
  1. 이제 앱 UI에서 나누기의 결과를 렌더링하므로 UI 업데이트가 실행되는 방법에 관한 몇 가지 세부정보를 처리해야 합니다. 먼저 repeat 루프를 실행할 수 있는 새 스레드를 만들어야 합니다. 그러지 않으면 Thread.sleep(3000)이 기본 스레드를 차단하여 onCreate()가 완료될 때까지 앱 뷰가 렌더링되지 않습니다(repeat 루프가 있는 division() 포함).
fun division() {
   val numerator = 60
   var denominator = 4

   thread(start = true) {
      repeat(4) {
         Thread.sleep(3000)
         findViewById<TextView>(R.id.division_textview).setText("${numerator / denominator}")
         denominator--
      }
   }
}
  1. 지금 앱을 실행하려고 하면 FATAL EXCEPTION이 표시됩니다. 이 예외가 발생하는 이유는 뷰를 만든 스레드만 뷰를 변경할 수 있기 때문입니다. 다행히 runOnUiThread()를 사용하여 UI 스레드를 참조할 수 있습니다. UI 스레드 내의 TextView를 업데이트하도록 division()을 변경합니다.
private fun division() {
   val numerator = 60
   var denominator = 4
   thread(start = true) {
      repeat(4) {
         Thread.sleep(3000)
         runOnUiThread {
            findViewById<TextView>(R.id.division_textview).setText("${numerator / denominator}")
            denominator--
         }
      }
   }
}
  1. 앱을 실행하고 즉시 에뮬레이터로 전환합니다. 앱이 실행되면 Extended Controls 창에서 Start Recording 버튼을 클릭합니다. 몫이 3초마다 업데이트됩니다. 몫이 여러 번 업데이트된 후 Stop Recording을 클릭합니다.

55121bab5b5afaa6.png

  1. 기본적으로 출력은 .webm 형식으로 저장됩니다. 드롭다운을 사용하여 출력을 GIF 파일로 내보냅니다.

850713aa27145908.png

7. 축하합니다

축하합니다. 이 과정에서 알아본 내용은 다음과 같습니다.

  • 디버깅은 코드의 버그 문제를 해결하는 프로세스입니다.
  • 로그를 사용하면 다양한 로그 수준과 태그로 텍스트를 출력할 수 있습니다.
  • 스택 트레이스는 예외를 발생시킨 정확한 함수와 예외가 발생한 줄 번호와 같이 예외에 관한 정보를 제공합니다.
  • 디버깅할 때는 여러 개발자에게 같거나 유사한 문제가 발생할 가능성이 높고 StackOverflow와 같은 사이트를 사용하여 버그를 조사할 수 있습니다.
  • Android Emulator를 사용하여 스크린샷과 애니메이션 GIF를 모두 간단히 내보낼 수 있습니다.

자세히 알아보기