[iOS Programming Big Nerd Ranch] 8. Debugging
디버깅 하는 방법에 대해 보자.
Xcode의 디버거(LLDB)는 버그를 찾고 해결하는데 도움을 주는 필수 도구다. 여기서는 Xcode의 debugger를 보고 간단한 함수를 보도록 하겠다.
Debugging Basics
가장 간단한 디버깅은 콘솔을 이용하는 것이다. 콘솔에서 확인할 수 있는 정보를 해석해서 코드의 실패를 관찰하고 이를 해결할 수 있게 해준다.
Interpreting console messages
가령 아래와 같은 콘솔 메세지가 떴다고 해보자.
2021-05-25 18:50:05.420618+0900 Buggy[59926:14814079] -[Buggy.ViewController buttonTapped:]: unrecognized selector sent to instance 0x7fc3bb8124a0
2021-05-25 18:50:05.432550+0900 Buggy[59926:14814079] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[Buggy.ViewController buttonTapped:]: unrecognized selector sent to instance 0x7fc3bb8124a0'
*** First throw call stack:
(
0 CoreFoundation 0x00007fff20421af6 __exceptionPreprocess + 242
1 libobjc.A.dylib 0x00007fff20177e78 objc_exception_throw + 48
2 CoreFoundation 0x00007fff204306f7 +[NSObject(NSObject) instanceMethodSignatureForSelector:] + 0
3 UIKitCore 0x00007fff246cba43 -[UIResponder doesNotRecognizeSelector:] + 292
4 CoreFoundation 0x00007fff20426036 ___forwarding___ + 1489
5 CoreFoundation 0x00007fff20428068 _CF_forwarding_prep_0 + 120
6 UIKitCore 0x00007fff2469d19e -[UIApplication sendAction:to:from:forEvent:] + 83
7 UIKitCore 0x00007fff23fc6684 -[UIControl sendAction:to:forEvent:] + 223
8 UIKitCore 0x00007fff23fc69a7 -[UIControl _sendActionsForEvents:withEvent:] + 332
9 UIKitCore 0x00007fff23fc5290 -[UIControl touchesEnded:withEvent:] + 500
10 UIKitCore 0x00007fff246d984e -[UIWindow _sendTouchesForEvent:] + 1287
11 UIKitCore 0x00007fff246db6c7 -[UIWindow sendEvent:] + 4774
12 UIKitCore 0x00007fff246b5466 -[UIApplication sendEvent:] + 633
13 UIKit 0x000000010f120382 -[UIApplicationAccessibility sendEvent:] + 85
14 UIKitCore 0x00007fff24745f04 __processEventQueue + 13895
15 UIKitCore 0x00007fff2473c877 __eventFetcherSourceCallback + 104
16 CoreFoundation 0x00007fff2039038a __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
17 CoreFoundation 0x00007fff20390282 __CFRunLoopDoSource0 + 180
18 CoreFoundation 0x00007fff2038f764 __CFRunLoopDoSources0 + 248
19 CoreFoundation 0x00007fff20389f2f __CFRunLoopRun + 878
20 CoreFoundation 0x00007fff203896d6 CFRunLoopRunSpecific + 567
21 GraphicsServices 0x00007fff2c257db3 GSEventRunModal + 139
22 UIKitCore 0x00007fff24696cf7 -[UIApplication _run] + 912
23 UIKitCore 0x00007fff2469bba8 UIApplicationMain + 101
24 libswiftUIKit.dylib 0x00007fff551885f2 $s5UIKit17UIApplicationMainys5Int32VAD_SpySpys4Int8VGGSgSSSgAJtF + 98
25 Buggy 0x000000010ec32a7a $sSo21UIApplicationDelegateP5UIKitE4mainyyFZ + 122
26 Buggy 0x000000010ec329ee $s5Buggy11AppDelegateC5$mainyyFZ + 46
27 Buggy 0x000000010ec32ac9 main + 41
28 libdyld.dylib 0x00007fff2025a3e9 start + 1
29 ??? 0x0000000000000001 0x0 + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException
가장 위부터 보자.
2021-05-25 18:50:05.420618+0900 Buggy[59926:14814079] -[Buggy.ViewController buttonTapped:]: unrecognized selector sent to instance 0x7fc3bb8124a0
발생한 시간, 애플리케이션의 이름, 그리고 뒤에 문장이 나온다. 먼저 이 정보에 대한 감을 잡기 위해, iOS 앱은 Swift로도 작성되었을 수 있지만 Objective-C로 작성된 프레임워크의 콜렉션인 Cocoa Touch에서 돌아가고 있음을 기억해야 한다. Objective-C는 동적 언어이고, 인스턴스에 메세지가 전달되면 Obejctive-C는 런타임에 즉각 ID라고 생각할 수 있는 selector를 가지고 실제 호출된 메서드를 찾는다.
그래서 위의 문장은 앱이 인스턴스가 가지고 있지 않은 메서드를 호출하려 했다는 것을 의미한다.
그렇다면 어떤 인스턴스인가? 여기서는 두 개의 정보를 얻을 수 있다. 먼저, Buggy.ViewController
이다. (단지 ViewController
가 아닌 이유는 Swift의 namespace는 모듈의 이름을 포함하고, 여기서는 이게 앱의 이름이다.) 두번째로, 이거는 메모리 주소 0x7fc3bb8124a0
에 있다.
-[Buggy.ViewController buttonTapped:]
는 Objective-C 표현이다. Objective-C의 메세지는 항상 [receiver selector]
의 형태의 대괄호 안에 감싸져 있다. receiver
는 메세지가 전해진 클래스나 인스턴스다. 대괄호 전의 -
는 receiver가 ViewController
의 인스턴스임을 알려준다. +
는 receiver가 class임을 나타낸다. 즉 인스턴스 메서드인지 클래스 메서드인지를 말하는 거다.
요약하자면 Buggy.ViewController
의 인스턴스에 selector buttonTapped:
가 전해졌지만 인식되지 않았다는 것을 의미한다.
다음 줄은 앱이 “uncaught exception”에 의해 중단되었으며 이 예외의 타입을 NSInvalidArgumentException
이라고 정의한다.
그 밑에 있는 메세지들은 stack trace이며, 앱 크래스에서 호출된 함수들과 메서드의 리스트를 의미한다. 앱이 크래시 나기 전에 어떤 논리적인 경로를 따라 실행되고 있었는지 아는 것은 버그를 수정하기 쉽게 해준다. Stack trace에 있는 호출들은 return되지 않았고, 위에서부터 가장 최근에 호출된 것을 보여준다. Stack trace를 다시 보자.
*** First throw call stack:
(
0 CoreFoundation 0x00007fff20421af6 __exceptionPreprocess + 242
1 libobjc.A.dylib 0x00007fff20177e78 objc_exception_throw + 48
2 CoreFoundation 0x00007fff204306f7 +[NSObject(NSObject) instanceMethodSignatureForSelector:] + 0
3 UIKitCore 0x00007fff246cba43 -[UIResponder doesNotRecognizeSelector:] + 292
4 CoreFoundation 0x00007fff20426036 ___forwarding___ + 1489
5 CoreFoundation 0x00007fff20428068 _CF_forwarding_prep_0 + 120
6 UIKitCore 0x00007fff2469d19e -[UIApplication sendAction:to:from:forEvent:] + 83
7 UIKitCore 0x00007fff23fc6684 -[UIControl sendAction:to:forEvent:] + 223
8 UIKitCore 0x00007fff23fc69a7 -[UIControl _sendActionsForEvents:withEvent:] + 332
9 UIKitCore 0x00007fff23fc5290 -[UIControl touchesEnded:withEvent:] + 500
10 UIKitCore 0x00007fff246d984e -[UIWindow _sendTouchesForEvent:] + 1287
11 UIKitCore 0x00007fff246db6c7 -[UIWindow sendEvent:] + 4774
12 UIKitCore 0x00007fff246b5466 -[UIApplication sendEvent:] + 633
13 UIKit 0x000000010f120382 -[UIApplicationAccessibility sendEvent:] + 85
14 UIKitCore 0x00007fff24745f04 __processEventQueue + 13895
15 UIKitCore 0x00007fff2473c877 __eventFetcherSourceCallback + 104
16 CoreFoundation 0x00007fff2039038a __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
17 CoreFoundation 0x00007fff20390282 __CFRunLoopDoSource0 + 180
18 CoreFoundation 0x00007fff2038f764 __CFRunLoopDoSources0 + 248
19 CoreFoundation 0x00007fff20389f2f __CFRunLoopRun + 878
20 CoreFoundation 0x00007fff203896d6 CFRunLoopRunSpecific + 567
21 GraphicsServices 0x00007fff2c257db3 GSEventRunModal + 139
22 UIKitCore 0x00007fff24696cf7 -[UIApplication _run] + 912
23 UIKitCore 0x00007fff2469bba8 UIApplicationMain + 101
24 libswiftUIKit.dylib 0x00007fff551885f2 $s5UIKit17UIApplicationMainys5Int32VAD_SpySpys4Int8VGGSgSSSgAJtF + 98
25 Buggy 0x000000010ec32a7a $sSo21UIApplicationDelegateP5UIKitE4mainyyFZ + 122
26 Buggy 0x000000010ec329ee $s5Buggy11AppDelegateC5$mainyyFZ + 46
27 Buggy 0x000000010ec32ac9 main + 41
28 libdyld.dylib 0x00007fff2025a3e9 start + 1
29 ??? 0x0000000000000001 0x0 + 1
)
각 줄은 호출 번호, 모듈 이름, 메모리 주소, 그리고 함수나 메서드를 나타내는 symbol이다. 스택을 아래서부터 위로 훝ㄹ어보면, 27번째 줄에 Buggy의 main 함수가 실행되었다는 것을 확인할 수 있다. 그리고 10번째 줄에 touch 이벤트가 발생했고, 8번째 줄에 버튼의 타겟에 일치하는 action을 전달하려고 하는 것을 볼 수 있다. 그리고 3번째 줄에 action의 selector를 찾을 수 없다는 말이 나온다.
Caveman debugging
ViewController
의 buttonTapped(_:)
는 지금 단지 콘솔에 로그를 찍고 있다. 이렇게 디버깅하는 기술을 caveman debugging이라고 한다. 단디 코드 안에서 print()
를 통해 메서드를 호출해서 중요한 데이터를 지속적으로 감시하려는 것이다.
@IBAction func buttonTapped(_ sender: UISwitch) {
print("Called buttonTapped(_:)")
// Log the control state:
print("Is control on? \(sender.isOn)")
}
위 메서드는 sender
라는 인자를 전달받고 있다. 이 인자는 메세지를 전달하는 control에 대한 참조다. control은 UIControl
의 하위 클래스이고, UIButton
, UITextField
, UISegmentedControl
같은 애들도 UIControl
의 하위 클래스다. 위 메서드에서 sender는 UISwitch
의 인스턴스다. isOn
프로퍼티는 switch 인스턴스가 on인지 아닌지를 나타내는 bool 값이다. UIButton은 UISwitch 프로퍼티에 응답할 수 없으니, 로그한 부분을 지운다.
Swift는 콘솔에 정보를 로깅하는 것을 지원해주는 4개의 literal 표현을 가지고 있다.
Literal | Type | Value |
---|---|---|
#file | String | 표현이 뜨는 곳의 파일 이름 |
#line | Int | 표현이 뜨는 곳의 줄 번호 |
#column | Int | 표현이 시작되는 열 번호 |
#function | String | 표현이 뜨는 곳의 정의의 이름 |
print("Method: \(#function) in file: \(#file) line: \(#line) called.")
로 로그를 찍으면 Method: buttonTapped in file: /Users/juampa/Desktop/Buggy/Buggy/ViewController.swift at line: 13 was called.
와 같이 나온다.
Caveman debugging은 유용하긴 하지만 프로젝트를 릴리즈 하기 위해 빌드했을 때 print문들이 자동으로 없어지는 게 아니다.
Xcode Debugger: LLDB
NSMutableArray를 이용해서 일부러 에러가 나는 상황을 만들어서 실행하면 NSRangeException
예외가 뜨면서 앱이 크래시가 난다. 만약 이 대신에 Array
타입을 썼다면, Xcode는 해당 에외를 띄운 코드에서 하이라이트를 표현했을 것이다. NSMutableArray를 썼기 때문에, 이 예외를 발생시킨 코드는 Cocoa Touch 프레임워크와 깊게 관련이 있게 된다. 이런 상황은 디버깅할 때 많이 발생한다. 문제가 확실하지 않아서 추가의 탐색 작업을 해야 한다.
브레이크 포인트 생성하기
만약 앱이 크래시가 나는 이유를 정확하게 모른다고 생각해보자. 나는 이 문제가 특정 행동 이후에 발생한 것만 안다고 가정한다. 따라서 해당 부분에 브레이크 포인트를 생성하면 실행 도중에 해당 부분의 코드를 거칠 때 앱이 멈춘다.
설정하는 방법은 해당 줄의 라인을 클릭하는 것이다.
해제하려면 해당 줄의 라인을 다시 클릭하면 해제가 된다.
이 브레이크 포인트 마커를 컨트롤을 누른 상태에서 클릭해서 활성화하거나, 삭제하거나, 비활성화하거나, 수정할 수 있다.
해당 메뉴에서 Reveal in Breakpoint Navigator
를 누르거나 Xcode의 왼쪽 판넬에서 Breakpoint Navigator
를 선택하면 앱에서 설정한 브레이크 포인트의 리스트가 나온다.
Stepping through code
브레이크 포인트를 설정하고 앱을 실행했을 때 앱이 멈추면 Xcode는 초록색으로 다음에 실행될 코드 줄을 하이라이트로 보여주고, 추가의 정보를 보여준다.
Variable view는 해당 브레이크 포인트의 scope에서 변수들과 상수들의 값을 찾을 수 있게 도와준다. 하지만, 특정 값을 찾기 위해서 조금 헤맬수도 있다.
처음에 여기에서 확인할 수 있는 것들은 해당 브레이크 포인트가 속한 곳의 메서드에 전해진 변수와 self
arugment다. 특정 component를 누르고 스페이스 바를 누르면 아래와 같이 Quick Look 위도우 창이 뜬다. 여기서는 이 변수의 preview를 보여준다.
SEL은 이게 selector임을 말해주는 것이다. 이렇게 간단하게 값을 찾을 수 있을 수도 있지만, 그렇지 않은 경우도 많다.
디버그 바의 버튼을 사용할 수도 있다.
여기서 중요한 버튼들은 아래와 같다.
- Continue program execution : 프로그램의 실행을 재개한다.
- Step Over: 함수나 메서드 호출을 하지 않고 한 줄의 코드를 실행한다.
- Step Into : 함수나 메서드 호출을 포함해서 다음 줄의 코드를 실행한다.
- Step out : 현재 함수나 메서드에서 나올 때까지 실행을 재개한다.
코드를 한 줄 한 줄 실행시킬 때, 특정 변수에 마우스를 올려서 값이 업데이트 되는 것도 확인할 수 있다.
가끔 어떤 줄이 실행되었지만 그 부분을 실행했을 때 멈춰서 추가적인 정보를 확인할 필요가 없을 때도 있다. 이를 위해서는 브레이크 포인트에 소리를 추가하고 자동으로 이어서 실행하도록 할 수 있다.
브레이크 포인트에 컨트롤 클릭 후 브레이크 포인트를 수정하고 Action을 sound로 설정하고 options 부분을 체크한다. 이렇게 하면 브레이크 포인트 부분이 실행될 때 소리는 나지만 멈추지는 않는다. 팝업에서 Log Message를 추가할 수도 있다.
위의 텍스트 필드에서 %H
는 breakpoint hit count로, 이 브레이크 포인트가 얼마나 실행되었는지 횟수를 참조하는 것이다. 이렇게 설정하고 코드를 실행시키면 해당 브레이크 포인트 부분을 실행할 때 로그가 찍힌다.
지금은 의심이 가는 부분에 브레이크 포인트를 찍긴 했지만, 실제로 개발할 때는 어느 부분에서 버그가 발생하는지 모를때가 많다. 이럴 때는 어떤 줄이 exception을 던지는지 알면 좋을 것이다. 이를 exception breakpoint로 알 수 있다. 브레이크포인트 네비게이터를 열고 왼쪽 하단의 + 버튼을 누르고 exception breakpoint를 선택한다. 새로운 exception breakpoint가 생성될 것이고 팝업이 뜬다. 모든 예외를 감지할 수 있도록 아래와 같이 설정한다.
다시 앱을 실행시키면 exception이 발생한 라인을 알려준다. 하지만 여기서는 console log가 없다. 이는 앱이 아직 crash가 나지 않았기 때문이다. crash를 보고 이유를 확인하라면 계속 실행 버튼을 누른다.
이제 symbolic 브레이크 포인트를 볼 것이다. 이 브레이크 포인트는 몇 번째 줄인지 명시되어있지 않지만, symbol로 알려진 함수나 메서드의 이름으로 명시된다. Symbolic 브레이크 포인트는 symbol이 호출되면 탐지된다.
이 상태로 앱을 실행하면 해당 메서드가 호출된 이후에 멈춘다.
실제로는 내가 생성한 메서드에 symbolic 브레이크 포인트를 설정하는 경우는 드물다. Symbolic 브레이크 포인트는 애플의 프레임워크의 메서드같이 내가 작성하지 않은 메서드가 호출되었을 때 멈추는 것에 유용하다.
LLDB console
Xcode의 LLDB 디버거의 강력한 기능은 command-line 인터페이스를 가지고 있다는 것이다. Console 영역은 메세지를 읽는데 사용될 뿐만 아니라, LLDB 커맨드를 타이핑 하는데 사용될 수 있다. 콘솔에 파란색으로 lldb라고 표시되어있을 때 활성화가 되어있다는 뜻이다.
LLDB 커맨드에서 가장 유용한 것들 중 하나는 print-object
로, po
라고 한다. 이 커맨드는 인스턴스의 description을 출력한다.
po self
라고 치면 커맨드는 self
를 뷰 컨트롤러의 인스턴스라고 한다. 이제 step
명령어를 쳐서 arrary
상수 참조를 활성화시킬 것이다. step
, po array
를 친다. 그러면 0 elements
라는 말이 나오는데 정보를 많이 주지 않기 때문에 도움이 되는 말은 아니다. p
로 요약할 수 있는 print
는 더 자세하게 알려준다.
print
나 print-object
를 사용해서 변수들을 확인하는 것이 Xcode의 variables view pane을 통해 보는 것보다 더 편하다.
다른 LLDB 커맨드는 expression
으로, expr
로 작성하면된다. 이 커맨드는 variable을 수정할 수 있게 Swfit로 작성하는 것을 가능하게 해준다. 예를 들어 특정 배열에 데이터를 추가하거나, 컨텐츠를 보거나, 실행을 재객할 수 있다.
또한 UI를 LLDB expression으로 바꿀 수 있다.
더 많은 기능을 보고 싶으면 help
라고 치면 된다.