에러 반환 추적(Error Return Traces) — 에러가 어디서부터 거슬러 왔는지

에러 반환 추적(Error Return Traces) — 에러가 어디서부터 거슬러 왔는지

에러가 발생했을 때 그 에러가 어디서 시작됐는지를 보여주는 디버깅 기능이에요. 프로그램 전체에 try를 마음껏 쓰면서도, 에러가 애플리케이션 밖까지 떠오르면 무슨 일이 있었는지 끝까지 추적할 수 있게 해주죠. 일반적인 스택 트레이스와는 달라서, 이게 어떤 차이를 만드는지부터 풀어볼게요.

출처: Zig Documentation

본문

에러 반환 추적(Error Return Traces) 은 에러가 호출하는 함수로 반환된 코드의 모든 지점을 보여줘요. 덕분에 어디서나 try를 쓰면서도, 에러가 애플리케이션 끝까지 거슬러 올라오면 그동안 무슨 일이 있었는지를 끝까지 알 수 있게 됩니다. 실제 예제를 볼게요.

// error_return_trace.zig
pub fn main() !void {
    try foo(12);
}

fn foo(x: i32) !void {
    if (x >= 5) {
        try bar();
    } else {
        try bang2();
    }
}

fn bar() !void {
    if (baz()) {
        try quux();
    } else |err| switch (err) {
        error.FileNotFound => try hello(),
    }
}

fn baz() !void {
    try bang1();
}

fn quux() !void {
    try bang2();
}

fn hello() !void {
    try bang2();
}

fn bang1() !void {
    return error.FileNotFound;
}

fn bang2() !void {
    return error.PermissionDenied;
}
// Shell
$ zig build-exe error_return_trace.zig
$ ./error_return_trace
error: PermissionDenied
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:34:5: 0x11e75fc in bang1 (error_return_trace.zig)
    return error.FileNotFound;
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:22:5: 0x11e7657 in baz (error_return_trace.zig)
    try bang1();
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:38:5: 0x11e769c in bang2 (error_return_trace.zig)
    return error.PermissionDenied;
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:30:5: 0x11e7777 in hello (error_return_trace.zig)
    try bang2();
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:17:31: 0x11e7885 in bar (error_return_trace.zig)
        error.FileNotFound => try hello(),
                              ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:7:9: 0x11e7965 in foo (error_return_trace.zig)
        try bar();
        ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:2:5: 0x11e7a6c in main (error_return_trace.zig)
    try foo(12);
    ^

이 예제를 자세히 볼게요. 이건 스택 트레이스가 아니에요. 좀 더 엄밀히 말하면, 에러가 어느 지점에서 만들어졌는지를 따라가 보는 출력이에요.

여기서 마지막까지 떠오른 에러는 PermissionDenied인데, 처음에는 FileNotFound였다는 점을 볼 수 있어요. bar 함수 안에서 처음 에러 코드를 처리하고 나서, switch 문에서 또 다른 에러를 반환하죠. 그때 이 PermissionDenied가 새로 시작된 거예요. 에러 반환 추적은 이 흐름을 명확히 보여줘요. 반대로 일반적인 스택 트레이스라면 이렇게 나오죠.

// stack_trace.zig
pub fn main() void {
    foo(12);
}

fn foo(x: i32) void {
    if (x >= 5) {
        bar();
    } else {
        bang2();
    }
}

fn bar() void {
    if (baz()) {
        quux();
    } else {
        hello();
    }
}

fn baz() bool {
    return bang1();
}

fn quux() void {
    bang2();
}

fn hello() void {
    bang2();
}

fn bang1() bool {
    return false;
}

fn bang2() void {
    @panic("PermissionDenied");
}
// Shell
$ zig build-exe stack_trace.zig
$ ./stack_trace
thread 974266 panic: PermissionDenied
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:38:5: 0x11e890a in bang2 (stack_trace.zig)
    @panic("PermissionDenied");
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:30:10: 0x11e898c in hello (stack_trace.zig)
    bang2();
         ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:17:14: 0x11e8943 in bar (stack_trace.zig)
        hello();
             ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:7:12: 0x11e8328 in foo (stack_trace.zig)
        bar();
           ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:2:8: 0x11e8291 in main (stack_trace.zig)
    foo(12);
       ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:788:64: 0x11e7bbb in callMain (std.zig)
    if (fn_info.param_types.len == 0) return wrapMain(root.main());
                                                               ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:248:5: 0x11e75e1 in _start (std.zig)
    asm volatile (switch (native_arch) {
    ^
(process terminated by signal)

이 스택 트레이스에서는 bar 안의 제어 흐름이 어떻게 hello() 호출까지 이르렀는지가 드러나지 않아요. 디버거를 열거나 앱에 계측을 더 붙여야만 알 수 있죠. 반면 에러 반환 추적은 에러가 정확히 어떤 경로로 거슬러 올라왔는지를 보여줘요.

이런 디버깅 기능 덕분에 모든 에러 조건을 견고하게 처리하는 코드를 더 빠르게 반복·개선할 수 있어요. 다시 말해, Zig 개발자들은 자연스럽게 개발 속도를 올리기 위해 정확하고 견고한 코드를 쓰게 되는 셈이죠.

에러 반환 추적은 기본적으로 Debug 빌드에서는 켜져 있고, ReleaseFast, ReleaseSafe, ReleaseSmall 빌드에서는 꺼져 있어요.

이 기능을 활성화하는 방법은 몇 가지가 있어요.

  • main에서 에러를 반환하거나
  • 에러가 catch unreachable까지 다다르고, 기본 패닉 핸들러를 바꾸지 않았거나
  • @errorReturnTrace로 현재 반환 추적에 접근하는 방법이에요. std.debug.dumpErrorReturnTrace로 그 내용을 출력할 수 있어요. 이 함수는 에러 반환 추적 없이 빌드할 때는 컴파일 타임에 알려진 null을 반환해요.

구현 세부 사항

성능 비용을 분석하기 위해 두 가지 경우로 나눠볼게요.

  • 에러가 반환되지 않는 경우
  • 에러가 반환되는 경우

에러가 반환되지 않는 경우, 비용은 단 한 번의 메모리 쓰기뿐이에요. 그것도 콜 그래프에서 처음으로 '실패 가능한 함수'를 호출하는 '실패 불가능한 함수'에서만 해요. 즉 void를 반환하는 함수가 error를 반환하는 함수를 호출하는 지점이죠. 그 함수가 이 구조체를 스택 메모리에 초기화하는 거예요.

// stack_trace_struct.zig
pub const StackTrace = struct {
    index: usize,
    instruction_addresses: [N]usize,
};

여기서 N은 콜 그래프 분석으로 정해진 최대 함수 호출 깊이예요. 재귀는 무시하고 2로 계산돼요.

StackTrace 포인터는 에러를 반환할 수 있는 모든 함수에 **비밀 매개변수(secret parameter)**로 전달돼요. 다만 항상 첫 번째 매개변수라서 레지스터에 그대로 머물 가능성이 커요.

에러가 없는 경로는 여기까지예요. 성능 면에서 사실상 공짜나 다름없죠.

반대로 에러를 반환하는 함수의 코드를 생성할 때는, return 문(에러를 반환하는 return 문만) 바로 앞에서 Zig가 이런 함수를 호출해요.

// zig_return_error_fn.zig
// marked as "no-inline" in LLVM IR
fn __zig_return_error(stack_trace: *StackTrace) void {
    stack_trace.instruction_addresses[stack_trace.index] = @returnAddress();
    stack_trace.index = (stack_trace.index + 1) % N;
}

비용은 산술 연산 2번에 메모리 읽기·쓰기가 몇 번 붙는 정도예요. 접근하는 메모리 영역이 제한적이라, 에러가 거슬러 올라오는 동안 캐시에 그대로 남아 있을 가능성이 커요.

코드 크기 비용도 return 문 앞에 함수 호출 하나가 붙는 정도라서 부담이 없어요. 그래도 저는 __zig_return_error 호출을 꼬리 호출(tail call)로 만들 계획을 갖고 있어요. 그렇게 하면 코드 크기 비용이 실제로 0이 되죠. 에러 반환 추적이 없는 코드의 return 문은, 추적이 있는 코드에서는 점프 명령어 하나가 될 수 있어요.

더 알아보기

  • 이 함수가 반환하는 StackTrace 구조체와 @errorReturnTrace, std.debug.dumpErrorReturnTrace의 관계를 기억해 두면 디버깅할 때 유용해요.
  • try가 어떻게 에러를 바깥으로 떠올리는지, 에러를 잡아 처리하는 catch와 함께 보면 이 기능의 가치가 더 잘 와 닿아요.
  • 에러 반환을 직접 만드는 방법이 궁금하다면 에러 셋 타입(error set type)과 에러 유니온(error union) 쪽을 함께 살펴보세요.