본문으로 건너뛰기

안동민 개발노트

본문 시작

panic!과 복구 불가능한 오류

복구할 수 없는 상태에서 panic을 발생시키고 스택 되감기와 중단 방식을 구분하며 백트레이스로 원인을 추적합니다.

소프트웨어에서 에러는 삶의 일부이므로, 러스트는 뭔가 잘못되는 상황을 처리하기 위한 기능을 몇 가지 갖추고 있습니다.

대부분의 경우 러스트에서는 코드가 컴파일 되기 전에 에러의 가능성을 인지하고 조치를 취해야 합니다.

이러한 요구사항은 여러분의 코드를 프로덕션 환경에 배포하기 전에 에러를 발견하고 적절히 조치할 것을 보장하여 여러분의 프로그램을 더 견고하게 해 줍니다!

러스트는 에러를 복구 가능한(recoverable) 에러와 복구 불가능한(unrecoverable) 에러 두 가지 범주로 묶습니다.

파일을 찾을 수 없음 에러 같은 복구 가능한 에러에 대해서는 대부분의 경우 그저 사용자에게 문제를 보고하고 명령을 재시도하도록 하길 원합니다.

복구 불가능한 에러는 배열 끝을 넘어선 위치에 접근하는 경우처럼 내부 계약이나 불변식이 깨졌음을 나타내는 경우가 많습니다. 이런 상태에서는 정상 값을 반환하며 계속하지 않고 패닉을 시작하는 편이 안전합니다.

대부분의 언어는 예외 처리(exception)와 같은 메커니즘을 이용하여 이 두 종류의 에러를 구분하지 않고 같은 방식으로 처리합니다.

러스트에는 예외 처리 기능이 없습니다.

대신, 호출자가 처리할 수 있는 실패를 위한 Result<T, E> 타입과 정상 실행을 계속할 수 없을 때 현재 스레드를 패닉 상태로 만드는 panic! 매크로가 있습니다. 패닉이 전체 프로세스 종료로 이어지는지는 패닉 전략과 스레드 경계 등 실행 문맥에 따라 달라집니다.

이번 장에서는 panic!을 호출하는 것을 먼저 다룬 뒤, Result<T, E> 값을 반환하는 것에 대해 이야기하겠습니다.

또한 에러로부터 복구를 시도할지 아니면 실행을 멈출지를 결정할 때의 고려 사항을 탐구해보겠습니다.

이제 에러 처리 패턴을 Result 중심으로 정리해 보겠습니다.

아래 다이어그램은 복구 가능한 에러(Result)와 복구 불가능한 에러(panic!)가 어떻게 분기되고, 패닉 시 unwind/abort를 선택하는지를 한눈에 정리합니다.

Rust에서 복구 가능한 실패는 Result로 반환하고 계약 위반이나 버그는 panic으로 드러내며, 메시지와 위치에서 백트레이스의 첫 관련 사용자 프레임을 찾아 unwind와 abort의 정리 차이를 판단하는 흐름

RESULT · PANIC · BACKTRACE · STRATEGY

실패가 정상 입력이면 값으로 돌려주고, 계약이 깨졌다면 패닉을 조사한다

panic!은 범용 예외 처리 문법이 아닙니다. 호출자가 복구할 수 있는 실패와 계속 실행할 수 없는 내부 계약 위반을 먼저 나누고, 패닉이 발생하면 메시지에서 호출 경로와 깨진 전제로 조사 범위를 좁힙니다.

recoverable · Result<T, E>

실패가 예상 가능한 입력·환경 상태다

파일 없음, 잘못된 사용자 입력, 네트워크 실패처럼 호출자가 재시도·대체·보고할 수 있으면 Result로 반환합니다. 실패는 정상 제어 흐름의 값이며 호출자가 정책을 선택합니다.

contract / bug · panic!

내부 불변식이나 반드시 참인 전제가 깨졌다

범위 밖 인덱싱이나 검증된 상태의 불가능한 분기처럼 정상 결과를 만들 수 없으면 패닉이 버그를 드러낼 수 있습니다. 현재 스레드가 패닉을 시작하며, 전체 프로세스의 결과는 스레드 경계와 패닉 전략에 달려 있습니다.

message → backtrace → user frame

출력 한 줄에서 깨진 계약까지 세 단계로 거슬러 간다

  1. 패닉 메시지와 보고 위치를 읽는다

    메시지는 실패한 검사, 위치는 패닉이 발생한 지점을 보여줍니다. 라이브러리 내부 위치라면 그 자체를 근본 원인으로 단정하지 않습니다.

  2. RUST_BACKTRACE=1로 호출 경로를 연다

    디버그 심볼이 있는 빌드에서 백트레이스를 다시 얻습니다. 더 자세한 출력이 필요하면 환경과 비용을 확인한 뒤 RUST_BACKTRACE=full을 사용합니다.

  3. 첫 관련 사용자 코드 프레임과 직전 상태를 조사한다

    사용자 파일이 처음 보인다는 이유만으로 끝내지 않고, 패닉 메시지와 관련된 프레임의 입력·인덱스·상태 변경이 어떤 계약을 깨뜨렸는지 확인합니다.

정상 실패와 두 패닉 전략의 제어·정리 차이
경로제어 흐름Drop과 자원
Result::Err호출자에게 오류 값을 정상 반환합니다.정상 범위 종료와 소유권 규칙에 따라 Drop이 실행됩니다.
unwind현재 스레드의 스택 프레임을 거꾸로 빠져나옵니다.되감는 범위의 값에 Drop을 실행합니다. 패닉이 프로세스 전체를 끝내는지는 스레드 경계와 처리 방식에 달려 있습니다.
abort스택을 되감지 않고 프로세스를 중단합니다.스택에 남은 값의 Drop은 실행되지 않습니다. 운영 체제는 프로세스 자원을 회수하지만 애플리케이션 수준 정리를 대신하지는 않습니다.
Result::Err

오류 값을 정상 반환

호출자가 처리하며 정상 범위 종료와 소유권 규칙에 따라 Drop이 실행됩니다.

unwind

현재 스레드의 스택을 되감음

되감는 범위의 값에 Drop을 실행합니다. 전체 프로세스 결과는 스레드 경계와 처리 방식에 달려 있습니다.

abort

되감지 않고 프로세스 중단

스택 값의 Drop은 실행되지 않습니다. 운영 체제의 자원 회수는 애플리케이션 정리를 대신하지 않습니다.

v[index]

존재를 전제하는 인덱싱

범위를 벗어나면 패닉합니다. 이미 검증된 인덱스처럼 “반드시 존재한다”는 계약이 있을 때 쓰고, 패닉이 나면 그 전제가 깨진 경로를 고칩니다.

v.get(index)

부재를 Option<&T>으로 처리

외부 입력으로 인덱스가 달라질 수 있다면 SomeNone을 분기합니다. 정상적인 부재를 패닉으로 바꾸지 않고 API 타입에 드러냅니다.

패닉 출력의 정확한 문구, 위치 형식과 백트레이스 프레임은 러스트 버전, 대상 플랫폼, 빌드 프로필에 따라 달라집니다. 형태를 암기하기보다 메시지·위치 → 백트레이스 → 첫 관련 사용자 프레임 → 깨진 계약 순서를 유지합니다.

가끔은 코드에서 나쁜 일이 일어나고, 이에 대해 여러분이 할 수 있는 것이 없을 수도 있습니다.

이런 경우를 위해 러스트에는 panic! 매크로가 있습니다.

실제로 패닉을 일으키는 두 가지 방법이 있습니다.

(배열 끝부분을 넘어선 접근과 같이) 코드가 패닉을 일으킬 동작을 하는 것 혹은 panic! 매크로를 명시적으로 호출하는 것이죠.

두 경우 모두 프로그램에 패닉을 일으킵니다.

대부분의 일반적인 빌드에서는 실패 메시지를 출력한 뒤 현재 스레드의 스택 되감기(unwind)를 시작합니다. 패닉이 catch_unwind 같은 경계에서 잡히지 않고 스레드 경계에 도달하면 해당 스레드가 종료됩니다. panic = 'abort'로 빌드하면 되감지 않고 프로세스를 중단합니다.

패닉이 발생했을 때 그 패닉의 근원을 쉽게 추적하기 위해 환경 변수를 통하여 러스트가 호출 스택을 보여주도록 할 수 있습니다.


panic!에 대응하여 스택을 되감거나 그만두기

대부분의 대상에서 기본 패닉 전략은 되감기(unwinding) 입니다. 현재 스레드의 스택 프레임을 거꾸로 빠져나오며 범위를 벗어나는 값의 Drop을 실행합니다. 반대로 그만두기(aborting) 를 선택하면 스택을 되감지 않고 프로세스를 중단하므로 스택에 남은 값의 Drop은 실행되지 않습니다.

프로세스가 끝나면 운영 체제가 프로세스 자원을 회수하지만, Drop이 수행하던 파일 기록·프로토콜 통지 같은 애플리케이션 수준의 정리가 대신 실행되는 것은 아닙니다. Cargo.toml 내의 적합한 [profile] 섹션에 panic = 'abort'를 추가하여 전략을 바꿀 수 있습니다. 이 선택은 실패 동작과 코드 생성에 영향을 주며 일부 빌드에서 바이너리 크기를 줄일 수 있지만, 크기 감소가 보장되지는 않습니다. 예를 들어, 여러분이 릴리즈 모드에서는 패닉 시 그만두기 방식을 쓰고 싶다면, 다음을 추가하세요.

[profile.release]
panic = 'abort'

간단한 프로그램에서 panic!을 호출해봅시다.

src/main.rs
fn main() {
    panic!("crash and burn");
}

프로그램을 실행하면 다음과 같은 형태의 내용이 나타납니다. 정확한 문구, 위치 표기, 빌드 로그는 러스트와 Cargo 버전, 대상 플랫폼, 빌드 프로필에 따라 달라질 수 있습니다.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

panic!의 호출이 마지막 두 줄의 에러 메시지를 일으킵니다.

첫 번째 줄은 작성해둔 패닉 메시지와 패닉이 발생한 소스 코드 지점을 보여줍니다.

src/main.rs:2:5src/main.rs 파일의 두 번째 줄 다섯 번째 문자를 나타냅니다.

이 예제에서는 표시된 줄이 직접 작성한 코드 부분이고, 해당 라인에서 panic! 매크로 호출을 눈으로 직접 볼 수 있습니다.

그 외의 경우, 우리가 호출한 외부 코드에서 panic! 호출이 있을 수도 있습니다.

에러 메시지에 의해 보고되는 파일 이름과 라인 번호는 panic! 매크로가 호출된 다른 누군가의 코드일 것이며, 궁극적으로 panic!을 발생시킨 것이 우리 코드 라인이 아닐 것입니다.

문제를 일으킨 코드 조각을 발견하기 위해서 panic! 호출이 발생한 함수에 대한 백트레이스(backtrace)를 사용할 수 있습니다.

백트레이스에 대해서는 뒤에 더 자세히 다룰 것입니다.


panic! 백트레이스 이용하기

직접 매크로를 호출하는 대신 우리 코드의 버그 때문에 라이브러리로부터 panic! 호출이 발생할 때는 어떻게 되는지 다른 예제를 통해서 살펴봅시다.

예제 8-1은 유효한 범위를 넘어서는 인덱스로 벡터에 접근을 시도하는 코드입니다.

예제 8-1: panic!을 일으키는 벡터의 끝을 넘어서는 요소에 대한 접근 시도
src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

여기서는 벡터의 100번째 요소(0부터 시작하므로 99입니다)에 접근하기를 시도하고 있지만, 이 벡터는 단 3개의 요소만 가지고 있습니다.

이 경우 러스트는 패닉을 일으킬 것입니다.

[]의 사용은 어떤 요소의 반환을 가정하지만, 유효하지 않은 인덱스를 넘기게 되면 러스트가 반환할 올바른 요소가 없습니다.

C에서 배열 범위를 벗어난 접근은 정의되지 않은 동작(undefined behavior)입니다. 따라서 특정 값을 읽거나 반드시 충돌한다고 보장할 수 없으며, 컴파일러도 그런 접근이 없다고 가정해 코드를 최적화할 수 있습니다.

이러한 것을 버퍼 초과 읽기(buffer overread) 라 하며, 접근이 허용되어서는 안 되는 데이터를 읽기 위해 어떤 공격자가 배열 뒤에 저장된 데이터를 읽어낼 요량으로 인덱스를 다루게 된다면, 이는 보안 취약점으로 이어질 수 있습니다.

프로그램을 이러한 취약점으로부터 보호하기 위해서, 인덱싱 문법으로 존재하지 않는 요소를 읽으려 하면 러스트는 잘못된 값을 반환하는 대신 패닉을 일으킵니다. 부재를 정상적으로 처리해야 한다면 .get()으로 Option을 받습니다.

범위를 확인하는 조회
fn main() {
    let v = vec![1, 2, 3];

    match v.get(99) {
        Some(value) => println!("{value}"),
        None => println!("해당 인덱스가 없습니다"),
    }
}

한번 시도해봅시다.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

위 출력은 대표적인 예시이며 정확한 형식은 러스트 버전과 실행 환경에 따라 달라집니다. 핵심 정보는 패닉 메시지와 최초로 보고된 소스 위치입니다. 이 위치가 라이브러리 내부라면 호출 경로를 더 조사해야 합니다.

그다음 줄은 RUST_BACKTRACE 환경 변수를 설정하여 패닉까지의 추가 호출 경로를 확인할 수 있다고 말해주고 있습니다.

백트레이스(backtrace) 란 어떤 지점에 도달하기까지 호출한 모든 함수의 목록을 말합니다.

러스트의 백트레이스는 다른 언어들과 마찬가지로 동작합니다.

백트레이스를 읽는 요령은 위에서부터 호출 흐름을 따라가며 문제와 관련된 첫 사용자 코드 프레임을 찾는 것입니다.

단순히 사용자 파일이 처음 등장한 프레임이 언제나 근본 원인인 것은 아니므로, 그 프레임의 입력과 바로 앞선 상태 변경이 어떤 계약을 깨뜨렸는지 함께 확인합니다.

여러분의 파일이 나타난 줄보다 위에 있는 줄은 여러분의 코드가 호출한 코드이고, 아래의 코드는 여러분의 코드를 호출한 코드입니다.

이 전후의 줄에는 핵심 러스트 코드, 표준 라이브러리, 여러분이 이용하고 있는 크레이트가 포함될 수 있습니다.

한번 RUST_BACKTRACE 환경변수를 0이 아닌 값으로 설정하여 백트레이스를 얻어봅시다.

예제 8-2는 여러분이 보게 될 것과 유사한 출력을 나타냅니다.

예제 8-2: 환경 변수 RUST_BACKTRACE가 설정되었을 때 panic!의 호출에 의해 발생하는 백트레이스 출력
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace.
   0: rust_begin_unwind
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
   2: core::panicking::panic_bounds_check
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
   6: panic::main
             at ./src/main.rs:4:5
   7: core::ops::function::FnOnce::call_once
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

출력이 엄청 많군요!

여러분이 보는 실제 출력값은 운영 체제 및 러스트 버전에 따라 다를 수 있습니다.

이러한 정보로 백트레이스를 얻기 위해서는 디버그 심볼이 활성화되어 있어야 합니다.

디버그 심볼은 여기서처럼 여러분이 cargo buildcargo run--release 플래그 없이 실행했을 때 기본적으로 활성화됩니다.

예제 8-2 출력 내용에서는 백트레이스의 6번 라인이 문제를 일으킨 이 프로젝트 src/main.rs의 4번 줄을 가리키고 있습니다.

프로그램이 패닉에 빠지지 않도록 하려면 직접 작성한 파일이 언급된 첫 줄부터 조사해야 합니다.

고의로 패닉을 일으키도록 코드를 작성한 예제 8-1에서 패닉을 고칠 방법은 범위를 벗어난 벡터 인덱스로 요소를 요청하지 않도록 하는 것입니다.

추후 여러분의 코드에서 패닉이 발생할 때는 어떤 코드가 패닉을 일으키는지, 코드를 어떻게 고쳐야 하는지 알아야 합니다.

다음은 에러가 발생했을 때 Result를 이용하여 복구하는 방법을 살펴보겠습니다.

언제 panic!을 써야 하는지, 혹은 쓰지 말아야 하는지에 대해서는 그다음에 나올 panic!이냐, panic!이 아니냐, 그것이 문제로다’절에서 알아볼 예정입니다.

패닉 원인을 찾을 때는 메시지와 위치를 확인하고, RUST_BACKTRACE=1로 호출 경로를 연 뒤, 첫 관련 사용자 코드 프레임에서 깨진 계약과 입력을 조사합니다. 복구가 정상 흐름이라면 패닉을 잡는 우회보다 Result.get()Option으로 API 경계를 다시 설계합니다.