본문 바로가기

예외 던지기

throw할 예외 타입과 정보를 설계하고 예외 객체 전달·스택 풀기·RAII 자원 정리가 이어지는 흐름을 이해합니다.

앞의 두 글에서 예외를 잡는 쪽을 보았다면, 이번에는 던지는 쪽을 봅니다. 어떤 객체를 던지느냐에 따라 받는 쪽이 실패의 종류를 구별하고 필요한 정보를 얻을 수 있는지가 정해집니다. 또 예외가 날 수 있는 함수는 실패했을 때 객체와 자원을 어떤 상태로 남길지도 정해 두어야 합니다.

이 글은 throw 문의 동작, 무엇을 예외로 던질지와 예외 클래스를 만드는 방법, 예외를 값으로 던지고 const 참조로 받는 이유, 스택 풀기와 RAII, 그리고 noexcept와 예외 안전성 보장을 다룹니다. 아래 그림은 업무 함수가 자원을 얻은 뒤 예외를 던지고, 그 자원이 정리된 뒤 호출한 쪽이 예외를 받기까지의 흐름입니다.

예외를 값으로 던지고 const reference로 받으며 RAII로 자원을 정리한다

업무 호출→FileHandler 획득→값 throw→unwind 해제→const& catch의 자원 생명 주기를 시간순으로 읽을 수 있다.

C++ · EXCEPTION OWNERSHIP

값으로 throw하고 const 참조로 catch하며 RAII로 정리한다

예외 객체는 throw 표현식으로 초기화되고 handler의 const reference에 바인딩된다. 중간 프레임은 값을 복사해 전달하는 대신 unwind하며 소유 자원을 정리한다.

예외는 값으로 던지고 const reference로 받아 RAII 수명을 보존한다본문처럼호출자와catch는동일복구경계이며두역할을한lifeline에묶었다. 세역할다섯메시지:호출자→업무call,업무→FileHandler획득,업무self메시지throw값,업무→FileHandlerunwind중release,업무→호출자catchconst참조바인딩이다. 시간은위에서아래. release는이미쓴파일내용의rollback이아니고bind는예외의물리적복사횟수를뜻하지않는다. 모바일도같은세역할다섯메시지이며FileHandler식별자줄만나눔. 호출자 · 복구 경계catch const&업무 함수throw by valueFileHandler자원 소유callacquirereleasebind const&throw value
예외는 값으로 던지고 const reference로 받아 RAII 수명을 보존한다본문처럼호출자와catch는동일복구경계이며두역할을한lifeline에묶었다. 세역할다섯메시지:호출자→업무call,업무→FileHandler획득,업무self메시지throw값,업무→FileHandlerunwind중release,업무→호출자catchconst참조바인딩이다. 시간은위에서아래. release는이미쓴파일내용의rollback이아니고bind는예외의물리적복사횟수를뜻하지않는다. 모바일도같은세역할다섯메시지이며FileHandler식별자줄만나눔.호출자catchconst&업무함수값으로throwFileHandler자원 소유callacquirereleasebind const&값으로throw
  1. 의미 있는 타입

    std::invalid_argument처럼 호출자가 구분할 수 있는 타입을 고릅니다.

  2. 값으로 throw

    지역 객체의 주소나 C 문자열이 아니라 완결된 예외 객체를 던집니다.

  3. RAII 정리

    중간 프레임의 guard가 unwind 중에도 정확히 한 번 자원을 해제합니다.

  4. const reference catch

    복사와 slicing 없이 구체 예외의 정보를 읽습니다.

예외 객체의 물리적 복사 횟수나 stdout/stderr의 상대 출력 순서를 계약으로 삼지 않는다. 호출자와 catch는 같은 복구 경계의 두 역할로 묶었다. release는 파일을 닫는 자원 정리이며 이미 쓴 데이터의 취소가 아니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
도표 제목예외를 값으로 던지고 const reference로 받으며 RAII로 자원을 정리한다
관계·조건 설명본문처럼 호출자와 catch는 동일 복구 경계이며 두 역할을 한 lifeline에 묶었다. 세 역할의 다섯 메시지는 호출자→업무 call, 업무→FileHandler 획득, 업무 self 메시지 throw 값, 업무→FileHandler unwind 중 release, 업무→호출자 catch const 참조 바인딩이다. 시간은 위에서 아래로 흐른다. release는 이미 쓴 파일 내용의 rollback이 아니고 bind는 예외의 물리적 복사 횟수를 뜻하지 않는다. 모바일도 같은 세 역할의 다섯 메시지이며 FileHandler 식별자 줄만 나눈다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
FileHandler 획득

호출된 업무 함수가 파일 자원을 관리한다.

  • 소유자: FileHandler
  • 자원: 획득됨
  • 자동 객체의 수명이 정리 책임을 묶는다.
값 throw와 unwind

예외가 전달되며 벗어난 범위의 파일 자원이 정리된다.

  • 예외: 값으로 throw
  • FileHandler: 소멸·자원 해제
  • 스택을 벗어나는 완성된 소유 객체의 소멸자가 실행된다.
참조로 catch

상위 경계가 const 참조로 예외를 읽는다.

  • catch: const&
  • 파일 자원: 이미 해제됨
  • handler의 예외 읽기와 파일의 정리 책임은 별개다.

예외를 값으로 던지고 const reference로 받으며 RAII로 자원을 정리한다

업무 호출→FileHandler 획득→값 throw→unwind 해제→const& catch의 자원 생명 주기를 시간순으로 읽을 수 있다.

C++ · EXCEPTION OWNERSHIP

값으로 throw하고 const 참조로 catch하며 RAII로 정리한다

예외 객체는 throw 표현식으로 초기화되고 handler의 const reference에 바인딩된다. 중간 프레임은 값을 복사해 전달하는 대신 unwind하며 소유 자원을 정리한다.

예외는 값으로 던지고 const reference로 받아 RAII 수명을 보존한다본문처럼호출자와catch는동일복구경계이며두역할을한lifeline에묶었다. 세역할다섯메시지:호출자→업무call,업무→FileHandler획득,업무self메시지throw값,업무→FileHandlerunwind중release,업무→호출자catchconst참조바인딩이다. 시간은위에서아래. release는이미쓴파일내용의rollback이아니고bind는예외의물리적복사횟수를뜻하지않는다. 모바일도같은세역할다섯메시지이며FileHandler식별자줄만나눔. 호출자 · 복구 경계catch const&업무 함수throw by valueFileHandler자원 소유callacquirereleasebind const&throw value
예외는 값으로 던지고 const reference로 받아 RAII 수명을 보존한다본문처럼호출자와catch는동일복구경계이며두역할을한lifeline에묶었다. 세역할다섯메시지:호출자→업무call,업무→FileHandler획득,업무self메시지throw값,업무→FileHandlerunwind중release,업무→호출자catchconst참조바인딩이다. 시간은위에서아래. release는이미쓴파일내용의rollback이아니고bind는예외의물리적복사횟수를뜻하지않는다. 모바일도같은세역할다섯메시지이며FileHandler식별자줄만나눔.호출자catchconst&업무함수값으로throwFileHandler자원 소유callacquirereleasebind const&값으로throw
  1. 의미 있는 타입

    std::invalid_argument처럼 호출자가 구분할 수 있는 타입을 고릅니다.

  2. 값으로 throw

    지역 객체의 주소나 C 문자열이 아니라 완결된 예외 객체를 던집니다.

  3. RAII 정리

    중간 프레임의 guard가 unwind 중에도 정확히 한 번 자원을 해제합니다.

  4. const reference catch

    복사와 slicing 없이 구체 예외의 정보를 읽습니다.

예외 객체의 물리적 복사 횟수나 stdout/stderr의 상대 출력 순서를 계약으로 삼지 않는다. 호출자와 catch는 같은 복구 경계의 두 역할로 묶었다. release는 파일을 닫는 자원 정리이며 이미 쓴 데이터의 취소가 아니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
도표 제목예외를 값으로 던지고 const reference로 받으며 RAII로 자원을 정리한다
관계·조건 설명본문처럼 호출자와 catch는 동일 복구 경계이며 두 역할을 한 lifeline에 묶었다. 세 역할의 다섯 메시지는 호출자→업무 call, 업무→FileHandler 획득, 업무 self 메시지 throw 값, 업무→FileHandler unwind 중 release, 업무→호출자 catch const 참조 바인딩이다. 시간은 위에서 아래로 흐른다. release는 이미 쓴 파일 내용의 rollback이 아니고 bind는 예외의 물리적 복사 횟수를 뜻하지 않는다. 모바일도 같은 세 역할의 다섯 메시지이며 FileHandler 식별자 줄만 나눈다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
FileHandler 획득

호출된 업무 함수가 파일 자원을 관리한다.

  • 소유자: FileHandler
  • 자원: 획득됨
  • 자동 객체의 수명이 정리 책임을 묶는다.
값 throw와 unwind

예외가 전달되며 벗어난 범위의 파일 자원이 정리된다.

  • 예외: 값으로 throw
  • FileHandler: 소멸·자원 해제
  • 스택을 벗어나는 완성된 소유 객체의 소멸자가 실행된다.
참조로 catch

상위 경계가 const 참조로 예외를 읽는다.

  • catch: const&
  • 파일 자원: 이미 해제됨
  • handler의 예외 읽기와 파일의 정리 책임은 별개다.

throw 문의 동작

throw 식;을 실행하면 식의 값으로 예외 객체가 초기화되고, 그 뒤의 문장은 실행되지 않습니다. 실행 중인 try 가운데 가장 가까운 것부터 맞는 catch를 찾고, 그곳에 도달하기까지 벗어나는 범위의 지역 객체는 만들어진 순서의 역순으로 정리됩니다. 맞는 catch가 끝까지 없으면 std::terminate가 호출됩니다.

예외 객체는 throw한 함수가 끝나도 사라지지 않고, 그 예외를 처리하는 catch가 끝날 때까지 컴파일러와 런타임이 관리합니다. 그래서 지역 변수로 만든 객체를 던져도 받는 쪽에서 안전하게 쓸 수 있습니다.

어떤 것을 throw로 던질 것인가?

C++에서는 int, 문자열 리터럴, std::string, 클래스 객체 등 거의 모든 타입의 값을 던질 수 있습니다. 하지만 throw 100;이나 throw "error";는 받는 쪽이 숫자와 글자의 의미를 따로 알아야 하고, 상속으로 묶을 수도 없어 실패의 종류를 구별하기 어렵습니다. 그래서 보통은 표준 예외 클래스나 그것을 상속한 클래스의 객체를 던집니다.

예외 클래스쓰는 경우
std::invalid_argument함수에 맞지 않는 인자가 들어옴
std::out_of_range인덱스나 값이 허용 범위를 벗어남
std::logic_error프로그램의 논리가 지켜야 할 전제를 어김
std::runtime_error실행 환경 때문에 생긴 실패, 예를 들어 파일을 열 수 없음
std::overflow_error계산 결과가 표현 범위를 넘음

모두 <stdexcept>에 있고 std::exception을 기반으로 하므로, catch (const std::exception& e)로 함께 받고 what()으로 메시지를 얻을 수 있습니다. 실패의 종류마다 추가 정보가 필요하다면, 이 클래스들을 상속해 예외 클래스를 만듭니다.

custom_exception.cpp
#include <iostream>
#include <stdexcept>
#include <string>

class ConfigError : public std::runtime_error {
private:
    int line_;

public:
    ConfigError(const std::string& message, int line)
        : std::runtime_error(message), line_(line) {}

    int line() const noexcept { return line_; }
};

void loadConfig(int badLine) {
    if (badLine > 0) {
        throw ConfigError("missing value", badLine);
    }
    std::cout << "config loaded\n";
}

int main() {
    try {
        loadConfig(0);
        loadConfig(12);
    } catch (const ConfigError& e) {
        std::cout << "config error at line " << e.line() << ": " << e.what() << '\n';
    } catch (const std::exception& e) {
        std::cout << "other error: " << e.what() << '\n';
    }
}
config loaded
config error at line 12: missing value

ConfigError는 std::runtime_error를 상속해 메시지를 넘기고, 문제가 생긴 줄 번호를 멤버로 더합니다. 받는 쪽은 ConfigError로 잡아 줄 번호까지 쓸 수 있고, 줄 번호가 필요 없는 곳에서는 std::exception으로 받아도 됩니다. 예외 클래스의 복사 생성자가 예외를 던지면 예외를 전달하는 도중에 또 예외가 나므로, 예외 클래스의 멤버는 이처럼 복사해도 예외가 나지 않는 것으로 두는 편이 좋습니다.

값으로 던지고 const 참조로 받기

예외는 throw ConfigError(...)처럼 값으로 던지고, try-catch 구문에서 본 것처럼 const 참조로 받습니다. 값으로 던지면 예외 객체의 수명을 언어가 관리하고, 참조로 받으면 복사와 슬라이싱 없이 원래 타입의 객체를 그대로 씁니다.

throw new ConfigError(...)처럼 포인터로 던지는 것은 피합니다. 받는 쪽이 catch (ConfigError* e)로 받아 delete e;를 직접 해야 하는데, 이를 빠뜨리거나 받는 쪽이 std::exception&로 받으려 하면 포인터는 맞지 않아 잡히지도 않습니다. 예외 객체의 소유를 받는 쪽에 떠넘기지 않는 것이 원칙입니다.

스택 풀기와 RAII

예외 처리의 기본 개념에서 본 것처럼 예외가 함수를 빠져나갈 때 지역 객체는 역순으로 정리되므로, 자원을 소멸자에서 해제하는 RAII 객체는 예외가 나도 자원을 돌려줍니다. 다음 예는 파일을 여는 클래스를 직접 만들어 그 흐름을 출력합니다.

raii_file.cpp
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>

class FileHandler {
private:
    std::ofstream file_;
    std::string name_;

public:
    explicit FileHandler(const std::string& name) : file_(name), name_(name) {
        if (!file_.is_open()) {
            throw std::runtime_error("cannot open " + name);
        }
        std::cout << "opened " << name_ << '\n';
    }

    void write(const std::string& data) {
        file_ << data << '\n';
        if (!file_) {
            throw std::runtime_error("cannot write " + name_);
        }
        std::cout << "wrote " << data << '\n';
    }

    ~FileHandler() {
        std::cout << "closed " << name_ << '\n';
    }
};

void manipulate(const std::string& name, bool fail) {
    FileHandler handler(name);
    handler.write("first line");
    if (fail) {
        throw std::runtime_error("simulated error");
    }
    handler.write("second line");
}

int main() {
    try {
        manipulate("output1.txt", false);
        manipulate("output2.txt", true);
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}
opened output1.txt
wrote first line
wrote second line
closed output1.txt
opened output2.txt
wrote first line
closed output2.txt
caught: simulated error

둘째 호출에서 예외가 나면 second line을 쓰지 않고 manipulate를 빠져나가며, 그때 handler의 소멸자가 실행되어 파일이 닫힙니다. closed output2.txt가 caught보다 먼저 출력되는 것은, 예외가 catch에 도착하기 전에 벗어나는 범위를 먼저 정리하기 때문입니다. 소멸자가 실행된 std::ofstream 멤버도 그때 파일을 닫으므로, 이 예제의 직접 출력은 흐름을 보여 주기 위한 것입니다.

정리는 자원을 돌려줄 뿐 이미 한 일을 되돌리지는 않습니다. output2.txt에는 예외 전에 쓴 first line이 그대로 남으며, 실패했을 때 파일을 원래대로 두어야 한다면 임시 파일에 다 쓴 뒤 바꿔치기하는 식으로 따로 설계해야 합니다. 또 생성자가 예외를 던지면 그 객체는 만들어지지 않은 것이므로 그 객체의 소멸자는 실행되지 않고, 이미 만들어진 멤버만 정리됩니다.

noexcept

함수 선언 뒤에 noexcept를 붙이면 그 함수가 예외를 밖으로 내보내지 않는다는 약속이 됩니다. 이 약속을 어기고 예외가 noexcept 함수 밖으로 나가려 하면, 바깥에 catch가 있어도 잡히지 않고 std::terminate가 호출됩니다.

소멸자는 따로 적지 않아도 기본으로 noexcept입니다. 스택 풀기 중에 소멸자가 또 예외를 던지면 처리할 방법이 없어 프로그램이 끝나므로, 소멸자에서는 예외를 내보내지 않습니다. Rule of Three, Five, Zero 실전에서 본 것처럼 이동 생성자에 noexcept를 붙이면 std::vector가 재할당할 때 복사 대신 이동을 쓰므로, 예외가 날 수 없는 이동 연산에는 noexcept를 붙입니다.

더 깊이: noexcept 연산자와 표준에서 바뀐 예외 명세

noexcept라는 낱말은 지정자로만 쓰이지 않습니다. 식 안에 noexcept(식)을 쓰면 그 식이 예외를 던지지 않는다고 선언되어 있는지를 컴파일할 때 검사해 bool 값을 돌려주며, 괄호 안의 식은 실제로 실행되지 않습니다. 이것을 지정자와 함께 쓴 noexcept(noexcept(a.swap(b))) 같은 조건부 noexcept는, 템플릿이 타입에 따라 예외를 던지지 않는 함수가 될지를 정하게 해 줍니다. cppreference의 noexcept 연산자 예처럼, 소멸자를 직접 선언해 이동 생성자가 만들어지지 않은 클래스는 rvalue로 만들어도 복사 생성자가 쓰이므로, std::vector 멤버가 있어 복사 생성자가 예외를 던질 수 있으면 이 검사가 거짓으로 나옵니다.

예외 명세는 표준에 따라 모습이 바뀌어 왔습니다. 예전에는 throw(std::bad_alloc)처럼 던질 수 있는 예외의 목록을 적는 동적 예외 명세를 썼지만, 이 방식은 C++11에서 권장되지 않게 되었고 C++17에서 없어졌습니다. C++17부터는 noexcept가 함수 타입의 일부가 되어, noexcept 함수를 가리키는 함수 포인터와 그렇지 않은 함수 포인터는 다른 타입입니다. 예외를 던지지 않는다는 뜻의 throw()는 C++17에서 noexcept(true)와 같은 뜻으로 바뀐 뒤 C++20에서 없어졌습니다. 또 noexcept 함수 밖으로 예외가 나가 std::terminate가 불릴 때 스택이 다 풀릴지, 일부만 풀릴지, 전혀 풀리지 않을지는 구현이 정하므로, 그때 지역 객체가 정리되리라고 기대하지 않습니다.

예외 안전성 보장

예외가 날 수 있는 함수는 실패했을 때 객체를 어떤 상태로 남기는지 정해 두어야 하며, 이를 예외 안전성 보장(exception safety guarantee)이라고 합니다.

보장실패했을 때의 상태
기본 보장(basic)자원이 새지 않고 객체는 올바른 상태이지만, 값은 바뀌었을 수 있음
강한 보장(strong)함수를 부르기 전과 똑같은 상태로 남음
예외 없음(nothrow)예외를 던지지 않음

Rule of Three, Five, Zero 실전의 Buffer 복사 대입은 새 배열을 먼저 만들어 값을 복사한 뒤 기존 배열과 바꾸므로, 할당이 실패해도 대상 객체가 원래 상태로 남는 강한 보장을 줍니다. 모든 함수가 강한 보장을 줄 필요는 없지만, 적어도 기본 보장은 지켜 자원이 새거나 객체가 깨진 상태로 남지 않게 해야 합니다.

정리

  • throw는 예외 객체를 만들어 나머지 코드를 건너뛰고, 범위를 정리하며 맞는 catch로 갑니다.
  • 예외로는 std::invalid_argument, std::runtime_error 같은 표준 예외 클래스나 그것을 상속한 클래스를 던집니다.
  • 실패의 정보가 더 필요하면 표준 예외를 상속해 멤버를 더하며, 받는 쪽은 구체적인 타입이나 std::exception으로 받습니다.
  • 예외는 값으로 던지고 const 참조로 받으며, 포인터로 던지지 않습니다.
  • 예외가 함수를 빠져나갈 때 RAII 객체가 자원을 정리하지만, 이미 한 작업을 되돌리지는 않습니다.
  • noexcept 함수에서 예외가 나가면 std::terminate가 호출되며, 소멸자는 기본으로 noexcept입니다.
  • 함수는 실패했을 때의 상태를 기본 보장, 강한 보장, 예외 없음 가운데 하나로 정해 지킵니다.

연습 문제

1. 남아 있는 파일 내용

raii_file.cpp를 실행한 뒤 output2.txt에는 무엇이 들어 있나요? 예외가 났는데 그 내용이 지워지지 않은 이유는 무엇인가요?

해설 보기

예외 전에 쓴 first line 한 줄이 들어 있습니다. 스택 풀기는 handler의 소멸자를 실행해 파일을 닫을 뿐, 이미 파일에 쓴 내용을 되돌리지는 않습니다. 실패했을 때 파일을 원래대로 두려면 임시 파일에 모두 쓴 뒤 성공했을 때만 원래 파일과 바꾸는 식으로 강한 보장을 따로 만들어야 합니다.

2. 약속을 어긴 noexcept 함수

void risky() noexcept { throw std::runtime_error("x"); }를 main에서 try { risky(); } catch (const std::exception&) { std::cout << "caught\n"; }로 부르면 caught가 출력되나요?

해설 보기

출력되지 않습니다. 예외가 noexcept 함수 밖으로 나가려는 순간 std::terminate가 호출되어, 바깥의 catch에 도달하지 못하고 프로그램이 끝납니다. GCC는 noexcept 함수 안에서 예외를 던지는 것이 분명한 코드에 경고를 냅니다. 예외가 날 수 있는 함수에는 noexcept를 붙이지 않습니다.

3. 포인터로 던진 예외

throw new ConfigError("missing value", 3);으로 던진 예외를 catch (const std::exception& e)로 받으려 하면 어떻게 되나요?

해설 보기

던진 것은 ConfigError* 포인터이고, 포인터는 std::exception&에 맞지 않으므로 잡히지 않습니다. 받을 곳이 없으면 std::terminate로 끝나고, catch (ConfigError* e)로 받더라도 delete e;를 직접 해야 하므로 빠뜨리면 메모리가 샙니다. throw ConfigError("missing value", 3);처럼 값으로 던지면 두 문제가 모두 사라집니다.

이것으로 13장의 예외 처리를 마칩니다. 다음 장의 auto 키워드와 타입 추론에서는 현대 C++의 기능을 차례로 살펴보며, 먼저 컴파일러가 변수의 타입을 추론하게 하는 auto와 decltype을 다룹니다.