스택 트레이스 읽는 법 share
이번 포스트는 프로그램 실행 중에 만나는 스택 트레이스, 즉 예외 또는 에러 메시지를 읽는 법을 다루려고 합니다. 메모리의 스택 영역에 기록된 프로그램의 실행 이력을 역 추적하기때문에 스택 트레이스, 스택 백트레이스, 스택 트랙백이라 부른다는군요(위키피디아).
그림출처: https://i.stack.imgur.com/HOY4C.png
1 실행 흐름
사전에서 프로그램을 찾아 보면, “진행 계획이나 순서, 또는 그 목록”이라 나옵니다. 연주회, 연극등의 편성표를 프로그램이라 하지요. 즉 순서대로 실행되는 일련의 작업 목록을 프로그램이라고 부릅니다.
그럼, 일반적으로 컴퓨터 프로그램은 어떻게 실행되지는지 살펴볼까요?
<?php
foo();
function foo() {
echo '1', PHP_EOL;
bar();
echo '5', PHP_EOL;
}
function bar() {
echo '2', PHP_EOL;
baz();
echo '4', PHP_EOL;
}
function baz() {
echo '3', PHP_EOL;
}
실행 결과는 이렇습니다.
$ php main.php
1
2
3
4
5
- 3번 줄에서
foo()
함수를 호출합니다. - 6번 줄에서 “1”을 출력합니다.
- 7번 줄에서
bar()
함수를 호출합니다. - 12번 줄에서 “2”를 출력합니다.
- 13번 줄에서
baz()
함수를 호출합니다. - 18번 줄에서 “3”을 출력합니다.
- 14번 줄에서 “4”를 출력합니다.
- 8번 줄에서 “5”를 출력합니다.
어렵지 않죠?
2 예외 상황
baz()
함수에서 예외를 만들고 결과를 살펴보겠습니다.
<?php
foo();
function foo() {
bar();
}
function bar() {
baz();
}
function baz() {
throw new RuntimeException('런타임 예외가 발생했습니다');
echo '이 라인은 출력되지 않습니다';
}
$ php exception.php
PHP Fatal error: Uncaught RuntimeException: 런타임 예외가 발생했습니다 in /Users/appkr/workspace/stack-trace/php/exception.php:14
Stack trace:
#0 /Users/appkr/workspace/stack-trace/php/exception.php(10): baz()
#1 /Users/appkr/workspace/stack-trace/php/exception.php(6): bar()
#2 /Users/appkr/workspace/stack-trace/php/exception.php(3): foo()
#3 {main}
thrown in /Users/appkr/workspace/stack-trace/php/exception.php on line 14
스택 트레이스를 하나씩 뜯어 보겠습니다.
- 첫 줄을 통해
/Users/appkr/workspace/stack-trace/php/exception.php
파일의 14번 줄에서RuntimeException
이 발생했고, 메시지는런타임 예외가 발생했습니다
라는 사실을 알 수 있습니다. - 첫 줄에서 문제의 원인을 알 수 있다면 아래 내용은 읽을 필요가 없습니다.
- 총 4 개의 스택 프레임이 잡혔는데, 시간 역순으로 기록되어 있습니다.
#2 exception.php
3번 줄에서foo()
함수를 호출했네요.#1 exception.php
6번 줄에서bar()
함수를 호출했네요.#0 exception.php
10번 줄에서baz()
함수를 호출했네요.
어떤가요, 1절에서 본 내용과 전혀 다르지 않죠?
3 사용자 정의 예외 및 처리
시스템에서 제공하는 예외를 그대로 사용하기 보다는, 비즈니스에서 사용하는 보편 언어를 사용하여 의미를 담은 예외를 정의하여 던지는 것이 더 좋은 구현이라고 알려져있습니다. 아래 예제처럼요.
<?php
try {
foo();
} catch (RuntimeException $e) {
throw new CustomException('사용자 정의 예외가 발생했습니다');
}
class CustomException extends RuntimeException {}
function foo() {
bar();
}
function bar() {
baz();
}
function baz() {
throw new RuntimeException('런타임 예외가 발생했습니다');
echo '이 라인은 출력되지 않습니다';
}
$ php custom_exception.php
PHP Fatal error: Uncaught CustomException: 사용자 정의 예외가 발생했습니다 in /Users/appkr/workspace/stack-trace/php/custom_exception.php:6
Stack trace:
#0 {main}
thrown in /Users/appkr/workspace/stack-trace/php/custom_exception.php on line 6
그런데 위 스택 트레이스는 문제를 일으킨 근본 원인(Root Cause)이 무엇인지 보이지 않는다는 문제가 있습니다.
catch
블록에서 CustomException
을 던질 때, 직전 예외를 생성자에 넣어 주는 것 만으로 쉽게 해결할 수 있습니다.
<?php
try {
foo();
} catch (RuntimeException $e) {
throw new CustomException('사용자 정의 예외가 발생했습니다', null, $e);
}
// 생략
$ php handle_exception.php
PHP Fatal error: Uncaught RuntimeException: 런타임 예외가 발생했습니다 in /Users/appkr/workspace/stack-trace/php/handle_exception.php:20
Stack trace:
#0 /Users/appkr/workspace/stack-trace/php/handle_exception.php(16): baz()
#1 /Users/appkr/workspace/stack-trace/php/handle_exception.php(12): bar()
#2 /Users/appkr/workspace/stack-trace/php/handle_exception.php(4): foo()
#3 {main}
Next CustomException: 사용자 정의 예외가 발생했습니다 in /Users/appkr/workspace/stack-trace/php/handle_exception.php:6
Stack trace:
#0 {main}
thrown in /Users/appkr/workspace/stack-trace/php/handle_exception.php on line 6
4 현실 세계
현실 세계의 코드는 예제처럼 간단하지는 않습니다. 그럼에도 불구하고 다음 두 가지만 기억하면 디버깅할 때 도움이 됩니다.
- 첫 줄을 잘 보라.
- 첫 줄에서 힌트를 찾을 수 없을 때는, 전체 스택 트레이스를 따라가면서 내가 짠 코드에서 원인을 찾아라.
현실 세계의 예제를 한번 볼까요? 라라벨 프레임워크를 사용하는 프로젝트에서 사용자 인증을 위해 JWT 발급했는데, 사용자가 블랙리스트에 등록된 JWT를 제출한 케이스입니다.
| ---------------------------------------> |
| GET /protected_resource |
| Authorization: Bearer ey..OE |
| |
| <--------------------------------------- |
| 401 Unauthorized |
| { |
| "error": "UnauthorizedTokenException", |
| "message": "Invalid token, please .." |
| } |
# /var/www/html/storage/logs/laravel.log
[2019-03-09 23:10:02] local.NOTICE: Tymon\JWTAuth\Exceptions\TokenBlacklistedException: The token has been blacklisted in /var/www/html/vendor/tymon/jwt-auth/src/Manager.php:109
Stack trace:
#0 /var/www/html/vendor/tymon/jwt-auth/src/JWT.php(200): Tymon\JWTAuth\Manager->decode(Object(Tymon\JWTAuth\Token))
#1 /var/www/html/vendor/tymon/jwt-auth/src/JWTAuth.php(64): Tymon\JWTAuth\JWT->getPayload()
#2 /var/www/html/app/Http/Middleware/JWTAuthenticate.php(61): Tymon\JWTAuth\JWTAuth->authenticate()
#3 /var/www/html/app/Http/Middleware/JWTAuthenticate.php(26): App\Http\Middleware\JWTAuthenticate->authenticate(Object(Illuminate\Http\Request))
#4 [internal function]: App\Http\Middleware\JWTAuthenticate->handle(Object(Illuminate\Http\Request), Object(Closure))
#...
#51 {main}
Next My\Domain\Auth\Exceptions\UnauthorizedTokenException: Invalid token, please sign in again in /var/www/html/app/Http/Middleware/JWTAuthenticate.php:66
Stack trace:
#0 /var/www/html/app/Http/Middleware/JWTAuthenticate.php(26): App\Http\Middleware\JWTAuthenticate->authenticate(Object(Illuminate\Http\Request))
#...
#48 {main} {
"app_version": "UNKNOWN",
"instance_id": "acb25dd1a048",
"transaction_id": "766ffefe-ac00-47ef-bc68-4cd7553decea",
"trace_number": 0
}
다음의 정보를 얻을 수 있습니다.
- 사용자가 제출한 JWT 토큰이 블랙리스트되서 발생한
NOTICE
레벨의 예외. - 블랙리스트 여부를 검사하고 예외를 던진 위치는
/var/www/html/vendor/tymon/jwt-auth/src/Manager.php
파일의 109 줄. - 예외가 발생시킨 함수는
Tymon\JWTAuth\Manager::decode()
이며, 이 함수를/var/www/html/vendor/tymon/jwt-auth/src/JWT.php
200 줄에서 호출. My\Domain\Auth\Exceptions\UnauthorizedTokenException
라는 도메인 예외가 발생했고, (핸들링 로직에 따라) 사용자는 “Invalid token, please sign in again”라는 메시지를 담은 JSON 예외 응답을 받았을 것.
5 다른 프로그래밍 언어
5.1 Java
class CustomException extends RuntimeException {
public CustomException(String message, Throwable previous) {
super(message, previous);
}
}
public class main {
static void foo() {
bar();
}
static void bar() {
baz();
}
static void baz() {
throw new RuntimeException("런타임 예외가 발생했습니다");
// System.out.println("이 라인은 컴파일 오류를 일으킵니다");
}
public static void main(String[] args) {
try {
foo();
} catch (RuntimeException e) {
throw new CustomException("사용자 정의 예외가 발생했습니다", e);
}
}
}
$ javac main.java && java main
Exception in thread "main" CustomException: 사용자 정의 예외가 발생했습니다
at main.main(main.java:25)
Caused by: java.lang.RuntimeException: 런타임 예외가 발생했습니다
at main.baz(main.java:17)
at main.bar(main.java:13)
at main.foo(main.java:9)
at main.main(main.java:23)
예외의 근본원인을 Caused by:
로 표시하는 부분을 제외하면 거의 똑같습니다.
5.2 Ruby
class CustomError < RuntimeError
end
def foo()
bar()
end
def bar()
baz()
end
def baz()
raise RuntimeError.new('런타임 예외가 발생했습니다')
p '이 라인은 출력되지 않습니다'
end
begin
foo()
rescue
raise CustomError.new('사용자 정의 예외가 발생했습니다')
end
$ ruby main.rb
Traceback (most recent call last):
3: from main.rb:18:in `<main>'
2: from main.rb:5:in `foo'
1: from main.rb:9:in `bar'
main.rb:13:in `baz': 런타임 예외가 발생했습니다 (RuntimeError)
1: from main.rb:17:in `<main>'
main.rb:20:in `rescue in <main>': 사용자 정의 예외가 발생했습니다 (CustomError)
크게 다르지 않습니다. 다만 시간 역순이 아니라 시간순으로 표현하고 있으며, 예외 메시지를 중간에 찍어 주고 있습니다.