본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

C++ 예외 처리의 내부 동작

작성 2026. 10. 14.추가
목차8개 항목
출처와 참고

팔만코딩경(42Seoul 커뮤니티 위키)의 「std::exception 동작원리」, 「exception은 스택과 힙 어디에 할당될까」, 「C++ 에러 코드와 에러 메시지 관리」를 바탕으로 재구성한 노트다. 구현 세부는 Itanium C++ ABI 예외 처리 문서를 기준으로 맞췄다.

throw가 하는 일

throw는 단순한 점프가 아니다. 맞는 catch를 찾을 때까지 호출 스택을 한 단계씩 거슬러 오르며, 지나치는 함수마다 지역 객체의 소멸자를 불러야 한다. 이를 스택 되감기(stack unwinding)라 한다.

flowchart TB
  t["throw 발생"] --> q{"① 지금 함수에 이 타입을 받는 catch가 있나?"}
  q -->|"있다"| c["catch 블록으로 이동"]
  q -->|"없다"| d["② 이 함수의 살아 있는 지역 객체 소멸자 호출"]
  d --> r["③ 호출자 프레임으로 복귀<br/>(return을 대신 수행)"]
  r -->|"①부터 반복"| q
  r -->|"끝까지 없으면"| term["std::terminate()"]

③은 프레임 포인터와 복귀 주소만 있으면 된다. 그러나 ①과 ②는 스택만 봐서는 알 수 없다. 스택에는 "이 프레임이 어떤 함수인지", "어느 지점까지 실행되어 어떤 객체가 살아 있는지", "catch가 있는지"가 적혀 있지 않기 때문이다. 그래서 이 정보를 어딘가에 따로 기록해야 하고, 그 방식에 따라 구현이 갈린다.

두 가지 구현 방식

C++ 표준은 예외의 내부 구현을 정하지 않는다. 컴파일러와 플랫폼마다 다르며 크게 두 계열이 있다.

방식원리비용사용처
동적 등록(dynamic registration)함수에 들어갈 때마다 예외 처리 정보를 런타임 체인에 등록하고 나갈 때 해제try만 써도 매 호출마다 비용32비트 MSVC(SEH 프레임), 예전 GCC(setjmp/longjmp 방식)
테이블 기반(table-driven, zero-cost)컴파일할 때 함수·구간별 정보를 테이블로 만들어 두고, throw가 나면 그때 테이블을 조회정상 경로 비용 없음, throw 시 큰 비용 + 테이블만큼 바이너리 증가x86-64·ARM64의 GCC·Clang(Itanium ABI), 64비트 MSVC

현대 64비트 환경은 거의 모두 테이블 기반이다. "zero-cost"는 예외가 나지 않을 때 비용이 없다는 뜻이지 예외가 싸다는 뜻이 아니다.

테이블 기반 되감기의 구조

ELF 바이너리(GCC·Clang) 기준으로 두 종류의 테이블이 쓰인다.

테이블위치담는 정보
함수 범위 테이블.eh_frame각 함수의 시작 주소와 크기, 프레임을 복원하는 방법
LSDA(언어별 데이터).gcc_except_table함수 안 call site 구간들, 구간별 정리 코드 주소(landing pad), catch할 타입 정보

call site가 필요한 이유는 같은 함수라도 throw 지점에 따라 정리할 객체가 다르기 때문이다.

void f2() {
    D d1;
    f1();      // call site #1: 여기서 던지면 d1만 소멸
    D d2;
    f1();      // call site #2: 여기서 던지면 d2, d1 소멸
}

되감기 루틴은 현재 명령어 주소(IP)로 .eh_frame에서 함수를 찾고, LSDA에서 IP가 속한 call site를 찾아 그 구간의 landing pad로 점프한다. landing pad는 해당 지점에 살아 있는 객체의 소멸자를 부르는 코드다. 실제로 컴파일 결과를 보면 call site #1의 landing pad는 D의 소멸자를 한 번, #2는 두 번 호출한다. catch 후보를 만나면 LSDA의 타입 테이블로 던진 예외의 타입과 맞는지 비교한다.

예외 객체는 어디에 있나

throw MyError();의 예외 객체는 스택이 아니라 힙에 있다. 되감기 중에 스택 프레임이 차례로 사라지므로 스택에 둘 수 없다. Itanium ABI의 처리 순서는 다음과 같다.

단계런타임 함수하는 일
할당__cxa_allocate_exception예외 객체 + 헤더 크기만큼 malloc. 메모리가 없으면 비상 버퍼(emergency pool)를 쓴다
던지기__cxa_throw객체 타입 정보를 넘기고 되감기 시작
잡기__cxa_begin_catch처리 중인 예외 목록(스레드별)에 올리고 참조 카운트 증가
끝__cxa_end_catchrethrow가 아니면 카운트를 줄이고, 0이면 소멸자 호출 후 __cxa_free_exception으로 해제

참조 카운트는 원자적으로 관리된다. std::exception_ptr로 예외를 다른 스레드에 넘겨도 마지막 참조가 사라질 때 해제되는 이유다.

그래서 어떻게 쓰나

  • 예외는 진짜 예외 상황에만 쓴다. 입력 검증 실패처럼 자주 일어나는 분기를 try/catch로 처리하면 throw마다 테이블 조회와 되감기 비용을 치른다. 일상적인 실패는 반환값(std::optional, std::expected(C++23), 오류 코드)으로 표현한다.
  • 소멸자에서 예외를 던지지 않는다. 되감기 중 소멸자에서 또 예외가 나면 즉시 std::terminate()다. C++11부터 소멸자는 기본적으로 noexcept다.
  • 던지지 않는 함수는 noexcept로 표시한다. 컴파일러가 정리 코드를 줄일 수 있고, std::vector는 이동 생성자가 noexcept일 때만 재할당에서 이동을 쓴다.
  • 자원은 RAII 객체(스마트 포인터, std::lock_guard)에 맡긴다. 되감기가 소멸자를 불러 주므로 예외가 나도 자원이 새지 않는다.
  • 게임 엔진, 임베디드, 일부 대형 코드베이스는 -fno-exceptions로 예외를 끄고 오류 코드만 쓴다. 이 경우 표준 라이브러리의 throw는 abort로 바뀐다는 점을 감안한다.

예외 대신 오류 코드

예외를 끄거나 아끼는 코드베이스는 실패를 반환값으로 알린다. 외부 인터페이스는 오류 코드를 돌려주고, 호출자가 코드를 검사해 메시지를 출력하거나 복구한다.

방식형태특징
정수·enum 오류 코드ErrorCode open(const char* path, File* outFile)C와 호환되고 비용이 없다. 검사를 빼먹어도 컴파일러가 모른다([[nodiscard]]로 경고)
std::error_code (C++11)값 + 범주(category) 쌍OS 오류(errno)와 라이브러리 오류를 한 타입으로 다룬다. message()로 문자열을 얻는다. <filesystem>, Asio가 쓴다
std::expected<T, E> (C++23)성공 값 T 또는 오류 E결과와 오류를 한 반환값에 담는다. and_then·transform으로 이어 붙인다
std::expected<int, ErrorCode> parsePort(std::string_view s);
 
if (auto port = parsePort(arg)) use(*port);
else std::cerr << getErrorMessage(port.error());

코드와 메시지를 한 곳에서 관리: X-macro

오류 코드 enum과 switch로 짠 메시지 함수를 따로 두면, 코드를 하나 추가할 때 두 곳을 고쳐야 하고 둘이 어긋나기 쉽다. X-macro는 목록을 한 번만 쓰고 전처리기로 enum과 메시지 표를 둘 다 생성한다.

#define ERROR_LIST(X)                       \
    X(SUCCESS,        "Success")            \
    X(FAILED_TO_OPEN, "Failed to open")     \
    X(FAILED_TO_BIND, "Failed to bind")
 
enum class ErrorCode {
#define X(name, msg) name,
    ERROR_LIST(X)
#undef X
};
 
inline const char* getErrorMessage(ErrorCode code) {
    switch (code) {
#define X(name, msg) case ErrorCode::name: return msg;
        ERROR_LIST(X)
#undef X
    }
    return "Unknown error";
}

새 오류는 ERROR_LIST에 한 줄만 더하면 enum 값과 메시지가 함께 생긴다. 같은 목록으로 문자열 이름 표나 std::error_category 구현까지 생성할 수 있다.

정리

  • throw는 catch를 찾아 스택을 거슬러 오르며 지역 객체 소멸자를 부르는 과정이다.
  • 현대 컴파일러는 컴파일 타임 테이블(.eh_frame, LSDA)로 함수와 call site를 찾아 되감는다.
  • zero-cost는 정상 경로 이야기이고, throw 자체는 매우 비싸며 테이블만큼 바이너리가 커진다.
  • 예외 객체는 힙에 할당되고 참조 카운트로 해제된다.
  • 예외를 if 대신 쓰지 않는다. 자주 일어나는 실패는 오류 코드, std::error_code, std::expected로 표현한다.
  • 오류 코드와 메시지는 X-macro로 한 목록에서 생성해 어긋나지 않게 한다.

관련 노트: C++ 상속과 가상 함수, 메모리 풀, 포인터와 메모리, C++ 기초

원본 기록6

댓글

GitHub 계정으로 댓글을 남길 수 있어요.