학습일지/C와 C++

C++ 예외 처리의 심층 이해와 실무적 고찰

Tsukino Ren 2026. 4. 27. 23:00

C++ 예외 처리의 심층 이해와 실무적 고찰

C++ 프로그래밍에서 '예외(Exception)'는 프로그램 실행 중 발생하는 예기치 못한 상황을 제어하는 강력한 메커니즘입니다. 단순히 에러 코드를 반환하는 전통적인 방식에서 벗어나, 프로그램의 정상적인 흐름과 오류 처리 로직을 분리함으로써 코드의 가독성과 안정성을 동시에 확보하기 위해 설계되었습니다.


예외 처리의 본질: 왜 에러 코드만으로는 부족한가?

전통적인 C 언어 스타일의 에러 처리는 함수의 반환값(Return Value)을 이용합니다. 예를 들어 함수가 성공하면 0, 실패하면 -1을 반환하는 방식입니다. 하지만 이 방식은 두 가지 치명적인 한계를 가집니다. 첫째, 반환값의 중복입니다. 함수의 실제 계산 결과값과 에러 코드가 구분되지 않을 때가 많습니다. 둘째, 강제성의 부재입니다. 호출자가 반환된 에러 코드를 무시하고 다음 로직을 수행해도 컴파일러 수준에서 제어할 방법이 없습니다.

C++의 예외 처리는 이러한 문제를 '흐름의 강제 전환'을 통해 해결합니다. 예외가 발생하면 프로그램은 즉시 정상적인 실행을 중단하고, 해당 문제를 해결할 수 있는 가장 가까운 핸들러를 찾아 상위 호출 스택으로 거슬러 올라갑니다. 이는 오류가 발생한 지점에서 즉각적인 조치를 강제하며, 무시된 에러가 시스템 전체의 오염으로 번지는 것을 방지합니다.

예외 처리의 핵심 구조와 실행 흐름

C++ 예외 처리는 try, throw, catch라는 세 가지 키워드를 중심으로 동작합니다.

  • try 블록: 예외가 발생할 가능성이 있는 코드 영역을 감쌉니다. 이곳은 "이 코드 안에서 문제가 생기면 내가 감시하겠다"는 선언과 같습니다.
  • throw 문: 예외가 발생했음을 알리는 신호탄입니다. throw 뒤에 전달되는 객체는 에러의 정보를 담고 있으며, 이 순간 프로그램은 일반적인 실행 흐름을 멈추고 '예외 처리 모드'로 진입합니다.
  • catch 블록: throw된 예외 객체를 받아 처리하는 구문입니다. 특정 타입의 예외만을 처리하도록 설계되어 있어, 다양한 유형의 에러를 타입별로 분기하여 대응할 수 있습니다.

동작 원리: 스택 언와인딩(Stack Unwinding)

예외 처리 과정에서 가장 중요한 개념은 스택 언와인딩입니다. throw가 실행되면, 런타임은 해당 예외를 처리할 수 있는 catch 문을 찾을 때까지 현재 함수를 포함하여 호출 스택에 쌓인 함수들을 역순으로 빠져나갑니다. 이때 단순히 점프하는 것이 아니라, 각 함수 스택에 생성되었던 지역 객체들의 소멸자를 차례대로 호출하며 메모리를 정리합니다. 이는 예외가 발생하더라도 리소스 누수를 방지할 수 있게 설계된 C++의 핵심적인 자원 관리 보증 방식입니다.


코드 예제를 통한 흐름의 이해

기본적인 예외 전파와 스택 언와인딩

#include <iostream>
#include <string>

class Resource {
public:
    Resource(std::string name) : name_(name) { std::cout << name_ << " 확보\n"; }
    ~Resource() { std::cout << name_ << " 해제(소멸자 호출)\n"; }
private:
    std::string name_;
};

void Level2() {
    Resource res2("지역 변수 2");
    std::cout << "Level2에서 예외 발생 전진\n";
    throw std::runtime_error("Level2 에러 발생!");
    std::cout << "이 문장은 절대 실행되지 않습니다.\n";
}

void Level1() {
    Resource res1("지역 변수 1");
    Level2();
}

int main() {
    try {
        Level1();
    } catch (const std::exception& e) {
        std::cout << "Main에서 예외 처리: " << e.what() << "\n";
    }
    return 0;
}

[실행 흐름 단계별 설명]

  1. main에서 try 블록 진입 후 Level1 호출.
  2. Level1에서 res1 생성, 이후 Level2 호출.
  3. Level2에서 res2 생성 후 throw 문 실행.
  4. 언와인딩 시작: Level2의 res2 소멸자 호출 -> Level1으로 이동하여 res1 소멸자 호출.
  5. main의 catch 블록에 도달하여 에러 메시지 출력.

사용자 정의 예외와 std::exception 구조

실무에서는 단순한 정수나 문자열을 던지지 않습니다. 대신 std::exception을 상속받은 객체를 사용합니다. 이는 예외의 표준화된 인터페이스를 제공하기 위함입니다. std::exception 계열의 클래스들은 virtual const char* what() const noexcept; 메서드를 제공하여, 어떤 핸들러에서도 일관된 방식으로 에러 메시지를 추출할 수 있게 해줍니다.

사용자 정의 예외 클래스 활용

class NetworkException : public std::runtime_error {
public:
    NetworkException(const std::string& msg, int errorCode) 
        : std::runtime_error(msg), errorCode_(errorCode) {}
    int GetErrorCode() const { return errorCode_; }
private:
    int errorCode_;
};

void ConnectServer() {
    // 서버 연결 실패 가정
    throw NetworkException("연결 타임아웃", 408);
}

noexcept의 의미와 목적

noexcept 키워드는 "이 함수는 절대로 예외를 밖으로 던지지 않는다"는 것을 컴파일러와 개발자에게 약속하는 명세입니다.

  • 사용 이유: 컴파일러는 noexcept 함수를 최적화할 때, 예외가 발생하지 않음을 보장받으므로 스택 언와인딩을 위한 추가적인 정보를 유지할 필요가 없어 성능적 이득을 취할 수 있습니다. 특히 이동 생성자(Move Constructor)나 소멸자에 자주 사용됩니다.
  • 위반 시 동작: 만약 noexcept로 선언된 함수에서 예외가 발생하여 함수 밖으로 나가려고 하면, 프로그램은 catch 문을 찾지 않고 즉시 std::terminate()를 호출하여 강제 종료됩니다. 이는 약속을 어긴 것에 대한 엄격한 처벌이자 보안 조치입니다.

실무에서의 예외 처리 전략

실무에서 예외 처리를 사용하는 기준은 매우 엄격합니다. 모든 에러를 예외로 처리하면 성능 저하와 흐름의 복잡성을 초래하기 때문입니다.

예외를 사용하는 경우

  1. 생성자 실패: 생성자는 반환값이 없으므로, 객체 생성이 실패했음을 알릴 유일한 방법이 예외를 던지는 것입니다.
  2. 드물게 발생하는 치명적 오류: 파일 시스템 누락, 메모리 부족, 네트워크 단절 등 로직상 '정상' 범위를 벗어난 상황에서 사용합니다.
  3. 깊은 호출 스택: 여러 단계를 거쳐야 하는 로직에서 중간 단계마다 에러 체크 코드를 넣기 힘들 때 유용합니다.

예외를 사용하지 않는 경우 (게임 및 실시간 시스템)

게임 엔진이나 임베디드 환경에서는 예외 처리를 지양하거나 아예 컴파일 옵션에서 끄기도(-fno-exceptions) 합니다. 그 이유는 다음과 같습니다.

  • 성능의 비결정성: 예외가 발생했을 때 스택을 되감는 비용은 상당히 큽니다. 초당 60프레임을 유지해야 하는 게임에서 갑작스러운 프레임 드랍을 유발할 수 있습니다.
  • 바이너리 크기 증가: 예외 처리를 지원하기 위해 컴파일러는 방대한 양의 메타데이터를 바이너리에 포함시키며, 이는 메모리가 제한된 환경에서 부담이 됩니다.
  • 대안: 이러한 분야에서는 std::optional, std::variant, 혹은 결과값과 에러를 함께 담는 구조체를 반환하는 방식을 선호합니다.

마무리

C++의 예외 처리는 안전한 자원 해제(RAII)와 오류 처리의 분리를 목표로 설계된 고도의 메커니즘이다.. throw로 시작된 예외는 스택 언와인딩을 통해 안전하게 자원을 정리하며 상위로 전파된다. 하지만 이러한 강력함 뒤에는 런타임 오버헤드라는 기회비용이 존재한다.

즉 훌륭한 C++ 개발자는 모든 문제를 예외로 해결하려 하지 않아야 하며 예외적인 상황에서만 예외를 사용하라는 원칙 아래, 성능이 중요한 루프나 실시간 로직에서는 상태 코드나 std::optional을 활용하고 시스템의 경계나 객체 생성 시점 등 안정성이 최우선인 곳에서만 사용하는게 좋아보인다.