Rust panic을 C++에서 try/catch로 잡을 수 없는 이유 — FFI 경계 예외 전파의 근본 원인

검증 환경 (실제 실행 기준)

본 글의 재현·검증은 다음 환경에서 실제로 수행했다.

항목값
OSUbuntu 22.04.5 LTS, aarch64
rustc / cargo1.98.1 (2026-09-01)
컴파일러g++ (Ubuntu) 11.4.0-1ubuntu1~22.04.3
링커GNU ld (Binutils) 2.38
Rust 패닉 전략panic = "unwind" (rustc 1.98.1 기본값) — rustc --print cfg 로 panic="unwind" 확인
정적 라이브러리cargo build (debug) → target/debug/libboom.a

출처 StackOverflow 원문(2024-04) 실행 결과의 오류 메시지(fatal runtime error: failed to initiate panic, error 5)는 최신 rustc(1.98.1)에서는 다른 형태로 출력된다. 이 차이는 아래 §4.3, §8에서 구체적으로 다룬다. 언급이 없는 한 모든 실행 결과는 위 환경에서 직접 실행해 얻은 것이다.

1. 문제 정의

Rust로 작성된 정적 라이브러리(staticlib)가 특정 조건에서 panic!()을 발생시키고, 이를 C++ main에서 사용하는 상황을 가정하자. C++ 쪽은 자연스럽게 try/catch로 잡으려고 시도한다.

Rust 쪽 라이브러리 (Cargo.toml):

1
2
3
4
5
6
7
[package]
name = "boom"
version = "0.1.0"
edition = "2018"

[lib]
crate-type=["staticlib"]

Rust 진입점 (src/lib.rs):

1
2
3
4
#[no_mangle]
pub extern "C" fn boom() {
    panic!("Oh no, something went wrong!");
}

C++ 호출부 (boom.cpp):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// g++ -g boom.cpp -L./target/debug -lboom -o boom
#include <exception>
#include <cstdio>

extern "C" {
    void boom();
}

int main() {
    try {
      boom();
    }
    catch(const std::exception& e) {
      printf("exception captured\n");
    }
}

기대는 exception captured가 출력되는 것이었지만, 이 코드는 그렇게 동작하지 않는다. Rust의 panic!은 C++의 std::exception 계열로 전달되지 않기 때문이다.

재현 절차 (직접 실행한 순서)

1
2
3
4
source $HOME/.cargo/env        # rustup 설치 환경
cd boom && cargo build         # (1) Rust staticlib 빌드
cd .. && g++ -g boom.cpp -L./boom/target/debug -lboom -o boom_naive
./boom_naive                   # (2) naive try/catch 실행

2. 표면 증상 (증상 fingerprint)

이 문제를 빠르게 판별하기 위한 특징은 다음과 같다.

구분특징한눈에 보는 판단 기준
발생 단계Rust staticlib를 g++로 링크한 뒤 런타임 호출 시점C++ try/catch 블록이 발동하지 않음
직접적인 증상panic 메시지가 출력되지만 catch(const std::exception& e)는 실행되지 않음예외가 잡히지 않고 프로세스가 abort됨
관련 도구cargo build, #[no_mangle] extern "C" fn, g++ -L./target/debug -lboomC/C++ FFI(외부 함수 인터페이스) 경계
흔한 오해“Rust panic → C++ exception으로 잡으면 되겠다”panic과 C++ exception은 서로 다른 메커니즘
핵심 힌트thread caused non-unwinding panic. aborting. (최신 rustc) / fatal runtime error: failed to initiate panic, error 5 (구버전 rustc)panic이 FFI 경계를 넘는 시점에 abort됨

결론적 증상 요약: “Rust 쪽에서 던진 것이 예외처럼 보이지만, C++의 예외 처리 메커니즘과는 전혀 다른 별개의 통신 채널을 사용한다.”

3. 원인 추적 — 원인 후보 매트릭스

왜 잡히지 않는지, 가능한 원인 후보를 하나씩 점검한다.

원인 후보확인 방법후보가 맞는 경우의 증상배제/채택
① #[no_mangle]/extern "C" 구문 문제로 심볼이 링크되지 않음`nm target/debug/libboom.agrep boom` 실행링크 에러나 undefined symbol 발생
② panic이 예외가 아니라 다른 방식으로 전달된다는 C++ 쪽 오해catch(...)로 모든 예외를 잡아보기그래도 잡히지 않는다면 예외 채널 문제가 아님채택 — panic은 예외 채널을 타지 않음
③ FFI 경계에 예외 전파 ABI가 정의되어 있지 않음Rust 공식 FFI가 C FFI뿐임을 확인panic이 경계를 넘어가며 abort(아래 실행 결과)채택 — 이것이 근본 원인
④ panic 자체가 복구 가능한 오류로 설계됨panic은 unrecoverable error로 문서화됨복구가 필요한 오류는 Result<T, E> 반환이 맞음참고 — 설계 관점의 근거

원인 ③이 핵심이다. Rust의 FFI는 C++이 아니라 C를 위한 것이다. C에는 예외(exception)라는 개념이 없고, 따라서 언어 경계를 넘는 예외 전파를 위한 ABI가 정의되어 있지 않다. 이 때문에 Rust panic이 어떻게든 C/C++ 경계를 넘는다 해도 C++의 catch 절이 그것을 인식할 수 있는 경로 자체가 존재하지 않는다.

4. 근본 원인 분석

4.1 Rust는 C++ FFI가 아니라 C FFI만 제공한다

extern "C"는 함수를 C ABI로 노출시킨다. C ABI에는 예외 정보가 없다. C++ 예외가 올바르게 전파되려면 표준화된 예외 전파 ABI(unwinding 규약, 예외 테이블 등)가 필요한데, C는 그 어떤 예외 전파 규약도 정의하지 않는다. 즉 Rust → C(또는 C → Rust)로 예외를 전파하는 표준 메커니즘은 존재하지 않는다.

4.2 C 스택 프레임을 가로지르는 예외는 Undefined Behavior

더 근본적인 층위에서, C++ 예외가 C 스택 프레임을 가로지르는 것 자체가 이미 위험한 도박(gamble) 이다. C 스택 프레임은 unwinding을 처리하는 코드를 생성하지 않기 때문에, 예외 전파 중에 unwind되더라도 정리(clean-up)가 수행되지 않아 Undefined Behavior가 된다. 실제 결과는 “우연히 잘 동작함(just works)“에서 “메모리 누수(leaky)”, “상태 손상(corrupted state)“까지 다양하다. 러스트 쪽 특유의 문제라기보다, 언어 경계를 넘는 예외 전파 자체가 툴체인이 명시적으로 보장하지 않는 한 피해야 하는 행위라는 것이 답변의 요지다.

4.3 실제 실행 결과: 두 rustc 버전에서 다른 abort 출력

아래는 위 검증 환경(rustc 1.98.1)에서 naive 코드(§1)를 직접 실행한 출력이다.

1
2
3
4
5
6
7
8
9
thread '<unnamed>' (3821558) panicked at src/lib.rs:3:5:
Oh no, something went wrong!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread '<unnamed>' (3821558) panicked at /rustc/.../panicking.rs:225:5:
panic in a function that cannot unwind
  ... (stack backtrace 생략) ...
thread caused non-unwinding panic. aborting.
(중단됨, exit code 134 = SIGABRT)
  • exception captured는 출력되지 않았고 프로세스가 abort(exit 134) 로 종료되었다. C++ try/catch는 발동하지 않았다.
  • panic in a function that cannot unwind + thread caused non-unwinding panic. aborting. 메시지는 최신 rustc(1.98.1)가 extern "C" 함수에서 panic이 unwind 불가 지점임을 감지하고 abort로 처리한 결과다. 이는 StackOverflow 원문(2024-04, 구버전 rustc)의 fatal runtime error: failed to initiate panic, error 5와 다른 버전별 메시지이며, 둘 다 같은 근본 원인(경계 예외 전파 ABI 부재)에서 비롯된다.

이 abort는 버그가 아니라 설계된 안전장치다. RFC 2945(C-unwind ABI)가 panic이 C 경계를 넘는 것을 막고 abort로 처리하는 보호 동작과 일치한다. 만약 이 보호가 없다면 Undefined Behavior로 넘어가 “그럭저럭 동작"하거나 상태가 손상된다.

4.4 설계 관점: panic은 원래 복구 불가 오류다

러스트 공식 문서(unrecoverable errors with panic)상 panic은 잡히지 않도록 설계된 unrecoverable error다. 복구 가능한 오류는 처음부터 Result<T, E>를 반환하는 것이 러스트의 표준 관례다. “Rust panic을 C++에서 잡겠다"는 발상은 이 두 오류 수단을 혼동한 데서 시작한다.

5. 해결책 — FFI 경계에서 예외를 번역하라

정답은 panic을 언어 경계 밖으로 그대로 새지 못 하게 막고, 대신 경계에서 오류 값을 번역(translate) 하는 것이다. 방향은 두 가지로 나뉜다.

5.1 Rust → C++ (더 흔한 방향)

  1. Rust 쪽: std::panic::catch_unwind로 panic을 잡아 오류 반환 값/오류 코드로 번역한다.
  2. C++ 쪽: 그 오류 값을 처리한다 (필요하면 C++에서 다시 throw).

catch_unwind는 Rust 1.9.0부터 안정(stable) API로 표준 라이브러리에 포함되어 있다. nightly가 아니다. (doc.rust-lang.org/std/panic/fn.catch_unwind.html — “Since 1.9.0”). 단, 같은 모듈의 abort_unwind·always_abort·get_backtrace_style 등은 아직 Experimental로 표기되어 있으므로 구분해야 한다.

검증 환경에서 실제 컴파일·실행한 Rust 쪽 번역 코드 (src/lib.rs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::panic::catch_unwind;

#[no_mangle]
pub extern "C" fn boom() -> i32 {
    // panic을 잡아 오류 코드로 번역
    match catch_unwind(|| unsafe_rust_fn()) {
        Ok(result) => result,   // 정상 경로
        Err(_)     => -1,       // panic을 오류 코드로 변환
    }
}

fn unsafe_rust_fn() -> i32 {
    panic!("Oh no, something went wrong!");
}

실제 catch_unwind의 시그니처(공식 문서)는 pub fn catch_unwind<F: FnOnce() -> R + UnwindSafe, R>(f: F) -> Result<R> 이다. 즉 반환 타입이 Option이 아니라 Result<i32, Box<dyn Any + Send>> 이므로, 위처럼 match Ok/Err(또는 .unwrap_or(-1)) 패턴을 쓴다. if let Some(...) 패턴은 이 API에 맞지 않는다.

  • C++ 쪽은 반환값이 정상/오류인지를 검사하고, 오류인 경우에만 throw std::runtime_error(...) 하는 식으로 처리한다. 이렇게 하면 C++ 예외는 C 경계를 건너지 않는다 (C++ 스택 안에서만 발생하고 처리되기 때문).

5.2 C++ → Rust (반대 방향)

  1. C++ 쪽: catch로 예외를 잡아 오류 값을 번역한다.
  2. Rust 쪽: 오류 값을 처리한다 (필요하면 다시 panic!).

5.3 해결책 비교

접근법동작 방식경계를 건너는가판정
C++ try/catch로 Rust panic 직접 캐치panic이 C++ 예외처럼 전파되길 기대예(경계를 통과 시도)불가 / 진행 시 abort, UB
catch_unwind로 Rust 쪽에서 panic → 오류 값 변환panic이 FFI 경계를 넘어가지 못하게 원천 차단아니요(경계 안쪽에서 종결)권장 (Rust→C++)
C++ catch로 예외 → 오류 값 변환C++ 예외가 C 경계를 건너지 못하게 차단아니요(경계 안쪽에서 종결)권장 (C++→Rust)
경계 통과를 그냥 방치컴파일/런타임에 abort 또는 UB예금지 — RFC 2945가 abort로 구원

6. 검증 명령 (실제 실행 결과 포함, verification_command)

해결책을 적용한 뒤 성공 여부를 확인하는 명령과, 위 검증 환경(rustc 1.98.1 / g++ 11.4.0 / GNU ld 2.38 / Ubuntu 22.04 aarch64)에서 실제로 실행해 얻은 출력이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# (1) Rust staticlib 빌드
source $HOME/.cargo/env && cd boom && cargo build
#   → Finished `dev` profile [unoptimized + debuginfo] in 0.42s   (성공)

# (2) 심볼이 실제로 C ABI로 노출됐는지 확인
nm ./target/debug/libboom.a | grep -i boom
#   → 0000000000000000 T boom    (T = 전역 등록 심볼, 노출 확인)

# (3) C++ main과 링크
cd .. && g++ -g boom.cpp -L./boom/target/debug -lboom -o boom_naive
#   → (컴파일·링크 성공, 에러 없음)

# (4) naive try/catch 실행 — panic이 오류 값으로 안 바뀌면 abort
./boom_naive
#   → thread '<unnamed>' panicked at ...: Oh no, something went wrong!
#     thread caused non-unwinding panic. aborting.
#     (중단됨) exit code 134   ← C++ catch 미발동, panic 번역 안 됨

# (5) catch_unwind 번역 후 C++에서 오류 처리 (정상 경로)
./boom_clean
#   → thread ... panicked at src/lib.rs:13:5: Oh no, something went wrong!
#     exception captured: Rust panic was translated to error code -1
#     (정상 종료) exit code 0   ← EXIT_OK, 프로세스가 abort 없이 빠져나감

# (6) 정상(panic 없음) 경로 확인
./boom_ok
#   → ok: boom returned 42 (expect 42)
#     exit code 0

검증 포인트는 “panic이 발생해도 프로세스가 Undefined Behavior로 오염되지 않고, 오류 경로(exit 0)가 정상적으로 실행되는지” 이다. §5.1의 catch_unwind 코드를 넣은 실행은 panic이 오류 코드 -1로 번역된 뒤 C++에서 exception captured 출력 후 exit 0으로 정상 종료했다. 반면 naive 코드는 exit 134(abort) 로 종료되어, 두 메커니즘의 차이를 실제 출력으로 확인할 수 있다.

7. 실패한 해결책 (권장하지 않는 방법)

7.1 Rust panic을 C++ try/catch/catch(...)로 그대로 잡기

왜 안 되는가: panic은 예외 채널을 타지 않으므로 catch가 인식할 메커니즘이 없다. 설령 예외가 경계를 넘는다 해도 C 스택 프레임은 unwinding 정리를 하지 않아 Undefined Behavior다. 임시방편이 아니라 구조적으로 불가능한 경로다. (실제 실행: §6 (4)에서 exit 134로 abort.)

7.2 경계 통과를 방치하고 “그럭저럭 돈다"는 기대

왜 위험한가: 최신 rustc는 panic in a function that cannot unwind로 즉시 abort시킨다. 그러나 rustc 버전·패닉 전략·링커에 따라 언제 abort되고 언제 조용히 상태가 손상될지 보장할 수 없다. “테스트만 통과하면 괜찮겠지"는 운영 환경에서 랜덤 크래시/데이터 오염으로 이어지는 전형적인 함정이다. RFC 2945의 abort조차 우연한 보호일 뿐, 그 위에 로직을 세우면 안 된다.

7.3 서드파티 라이브러리가 panic을 반환 대신 panic으로 처리하는 경우 무턱대고 fork

질문자가 실제 사용 중인 jexl-rs 같은 라이브러리가 오류 반환 대신 panic을 던지는 경우, “라이브러리를 fork해서 고치기"는 대응이지만 FFI 경계(Fix)가 먼저다. 라이브러리 수정 전에, 호출부에서 catch_unwind로 감싸 번역하는 방식이 더 안전하고 유지보수 비용이 낮다.

8. 버전/패닉 전략 차이 (실제 확인된 기준)

구분구체 동작실제 확인 결과 (이 환경)
catch_unwind 안정화Rust 1.9.0부터 stable API. nightly 아님공식 문서 “Since 1.9.0” (std::panic). abort_unwind·always_abort만 Experimental
panic 전략 panic=unwind(기본)catch_unwind가 unwind panic을 잡아 Result::Err 반환rustc --print cfg → panic="unwind", run (5)에서 panic→오류 코드 번역 성공
panic 전략 panic=abortcatch_unwind도 아무것도 못 잡고 프로세스가 abortRUSTFLAGS="-Cpanic=abort" cargo build 후 run: panic 메시지 출력 뒤 exit 134 — exception captured 미출력
extern "C" 함수에서의 최신 rustc 동작unwind 불가 지점 자체를 감지해 즉시 abortnaive 실행 시 panic in a function that cannot unwind ... aborting. (exit 134)
오류 메시지 형식rustc 버전에 따라 다름구버전(원문, 2024) failed to initiate panic, error 5 / 1.98.1 thread caused non-unwinding panic. aborting.
정적/동적 라이브러리staticlib(crate-type)는 g++ -L... -lboom 직접 링크, cdylib는 런타임 로더 해석본 글은 staticlib로 검증

핵심 판단: catch_unwind를 기대한다면 반드시 panic=unwind 설정이어야 한다. panic=abort로 빌드하면 catch_unwind가 panic을 잡지 못해 §6 (5)의 번역 경로가 동작하지 않고 abort(exit 134)로 끝난다. 이 차이는 위 환경에서 RUSTFLAGS="-Cpanic=abort"로 빌드해 직접 확인했다.

9. 재발 방지 (운영 예방책)

  1. FFI 경계 함수는 예외를 반환값으로만 넘긴다 — extern "C" 함수는 i32/bool 같은 오류 코드를 돌려주고, panic이 경계 밖으로 나가지 않게 catch_unwind로 감싼다.
  2. 모든 extern "C" 진입점에 panic 차단층을 설계한다 — 라이브러리를 새로 붙일 때마다 “이 함수가 panic할 수 있나?“를 검토하고, 금지 목록에 panic-유발 함수를 기록한다.
  3. CI에서 C 경계 abort를 테스트로 감지한다 — 프로세스가 thread caused non-unwinding panic. aborting.(또는 old failed to initiate panic)로 죽는 케이스를 회귀 테스트로 넣어, 새로운 panic이 경계를 넘는 순간 테스트가 실패하도록 한다.
  4. 패닉 전략을 문서화·CI에 고정한다 — panic=unwind(catch_unwind 동작) vs panic=abort(번역 불가)를 프로젝트에 명시하고, .cargo/config.toml의 [profile] rustflags로 고정한다. 복구 가능한 실패는 Result<T, E> 전파를 기본 원칙으로 삼는다.

10. DevTrace 독자 결론

이 문제의 근본 원인은 “패키지가 없어서"도 아니고 “try/catch 문법이 틀려서"도 아니다. Rust의 FFI는 C를 위한 것이므로, C에는 예외가 없어 panic을 C++ 예외로 전파할 언어 경계 ABI 자체가 존재하지 않는다는 점이다. Rust panic은 C++ 쪽에서 절대 catch로 잡을 수 없으며, 반드시 catch_unwind(Rust 1.9.0부터 stable)로 경계 안쪽에서 오류 값으로 번역한 뒤 C++에서 처리(또는 재throw)해야 한다. 이는 단순히 버그가 아니라 언어 경계의 설계적 한계다.


출처