본문으로 건너뛰기 C++ Exception Specifications | '예외 명세' 가이드

C++ Exception Specifications | '예외 명세' 가이드

C++ Exception Specifications | '예외 명세' 가이드

이 글의 핵심

C++ Exception Specifications: "예외 명세" 가이드. 예외 명세 역사·noexcept 기본.

들어가며

예외 명세는 함수가 던질 수 있는 예외를 컴파일러와 호출자에게 알려주는 기능입니다. 함수 시그니처에 “이 함수는 예외를 던지지 않는다” 혹은 “특정 예외만 던진다”는 정보를 붙이면, 컴파일러는 그 정보를 바탕으로 더 공격적인 최적화를 수행할 수 있고, 호출하는 쪽에서는 굳이 try/catch를 준비하지 않아도 되는지 미리 판단할 수 있습니다. 문제는 C++가 이 기능을 두 번에 걸쳐 완전히 다른 방식으로 설계했다는 점입니다. C++03의 동적 예외 명세(throw(...))는 실제로는 런타임 검사만 추가할 뿐 컴파일 타임 최적화에는 거의 도움이 되지 않아 C++11에서 사실상 폐기되었고, 대신 훨씬 단순하고 실효성 있는 noexcept가 그 자리를 대체했습니다.


실전 경험에서 배운 교훈

noexcept를 처음 접하면 “그냥 예외를 안 던진다는 표시 아닌가” 정도로 가볍게 넘기기 쉽지만, 실제로는 std::vector의 재할당 전략, swap 기반 예외 안전성 관용구, 소멸자의 암시적 규칙 등 C++ 표준 라이브러리 전반의 동작을 좌우하는 핵심 개념입니다. 특히 이동 생성자에 noexcept를 빠뜨리면 컴파일 오류는 전혀 나지 않으면서도 std::vector::push_back이 조용히 이동 대신 복사를 선택해버리는, 발견하기 매우 어려운 성능 저하가 발생합니다. 이 글에서는 예외 명세가 왜 지금의 형태로 정착했는지, 그리고 noexcept를 어디에 붙여야 하고 어디에는 붙이면 위험한지를 실제 코드로 짚어봅니다.

1. 예외 명세 역사

C++03 - throw()

C++03의 동적 예외 명세는 함수가 던질 수 있는 예외의 목록을 시그니처에 나열하는 방식이었습니다. throw()는 아무 예외도 던지지 않는다는 뜻이고, throw(std::exception)처럼 특정 타입을 나열하면 그 타입만 던질 수 있다는 의미였습니다. 하지만 명세에 없는 예외가 실제로 던져지면 컴파일 타임이 아니라 런타임std::unexpected가 호출되는 방식으로 처리되었고, 컴파일러가 이 정보를 이용해 최적화를 하기도 어려웠습니다. 실질적인 이득 없이 코드만 복잡해진다는 비판이 많아 C++11에서 deprecated되었고, C++17에서 완전히 제거되었습니다.

// 예외 없음
void func() throw();

// 특정 예외만
void func() throw(std::exception);
void func() throw(int, double);

// C++17에서 제거됨

C++11 - noexcept

noexcept는 C++03 예외 명세와 접근 방식이 근본적으로 다릅니다. 어떤 예외를 던질 수 있는지 나열하는 대신, 단순히 “예외를 던지는가/아닌가”라는 이진 정보만 표현합니다. 대신 이 정보를 어길 경우(즉 noexcept로 선언된 함수 내부에서 예외가 실제로 빠져나오려 할 때) 스택을 풀지 않고 즉시 std::terminate를 호출합니다. noexcept(true)noexcept(false)처럼 불리언 표현식을 인자로 받을 수도 있어, 템플릿 코드에서 타입 파라미터의 성질에 따라 조건부로 예외 여부를 결정하는 것도 가능합니다.

// 예외 없음
void func() noexcept;

// 조건부
void func() noexcept(true);   // noexcept
void func() noexcept(false);  // 예외 가능

2. noexcept 기본

기본 사용

noexcept를 함수 선언에 붙이는 것 자체는 문법적으로 매우 간단하지만, 그 의미는 “이 함수 안에서는 예외가 절대 밖으로 나가지 않는다”는 강한 약속입니다. 아래 예제는 예외를 던지지 않는 safe_func와 예외를 던지는 risky_func를 대비시켜 보여줍니다. risky_funcnoexcept가 없기 때문에 throw로 던진 예외가 정상적으로 호출자 쪽 try/catch까지 전파되지만, 만약 risky_funcnoexcept를 붙였다면 throw 시점에 곧바로 std::terminate가 호출되어 catch 블록에 도달하기도 전에 프로그램이 종료됩니다.

#include <iostream>

void safe_func() noexcept {
    std::cout << "예외 없음" << std::endl;
}

void risky_func() {
    throw std::runtime_error("에러");
}

int main() {
    safe_func();
    
    try {
        risky_func();
    } catch (const std::exception& e) {
        std::cout << e.what() << std::endl;
    }
    
    return 0;
}

noexcept 검사

noexcept는 지정자(specifier)로 함수에 붙일 수도 있지만, 연산자로도 쓸 수 있습니다. noexcept(표현식) 형태로 어떤 표현식을 감싸면, 그 표현식이 예외를 던지지 않는다고 컴파일러가 판단하는지를 컴파일 타임에 bool 값으로 알려줍니다. 이는 실제로 함수를 호출하지 않고도(런타임 비용 없이) 정적으로 평가되므로, 템플릿 메타프로그래밍에서 “이 타입의 연산이 noexcept인가”를 조건부 로직에 활용할 때 특히 유용합니다.

#include <iostream>
#include <type_traits>

void func1() noexcept {}
void func2() {}

int main() {
    std::cout << std::boolalpha;
    std::cout << "func1: " << noexcept(func1()) << std::endl;  // true
    std::cout << "func2: " << noexcept(func2()) << std::endl;  // false
    
    return 0;
}

3. 이동 연산과 noexcept

이동 생성자

noexcept가 실무에서 가장 큰 영향을 미치는 곳은 이동 생성자입니다. std::vector는 용량이 부족해 재할당이 필요할 때, 기존 원소들을 새 메모리로 옮기는 과정에서 이동 생성자를 쓸지 복사 생성자를 쓸지 선택합니다. 이때 만약 이동 도중 예외가 발생하면 이미 옮겨진 원소와 아직 옮기지 못한 원소가 뒤섞여 벡터가 일관성 없는 상태가 될 위험이 있습니다. 그래서 std::vector강한 예외 안전성 보장을 지키기 위해, 이동 생성자가 noexcept로 선언되어 있을 때만 실제로 이동을 사용하고, 그렇지 않으면 안전하게 복사를 선택합니다. 아래 Widget 클래스는 이동 생성자에 noexcept를 명시했기 때문에 push_back 시 재할당이 일어나도 항상 이동이 사용됩니다.

#include <vector>
#include <iostream>

class Widget {
public:
    Widget() = default;
    
    // 이동 생성자 (noexcept 권장)
    Widget(Widget&& other) noexcept
        : data(std::move(other.data)) {
        std::cout << "이동 생성" << std::endl;
    }
    
    // 복사 생성자
    Widget(const Widget& other)
        : data(other.data) {
        std::cout << "복사 생성" << std::endl;
    }
    
private:
    std::vector<int> data{1, 2, 3};
};

int main() {
    std::vector<Widget> vec;
    vec.reserve(10);
    
    Widget w;
    vec.push_back(std::move(w));  // 이동 생성 (noexcept)
    
    return 0;
}

중요: std::vector는 이동 생성자가 noexcept일 때만 이동을 사용합니다. 이동 생성자에 noexcept를 빠뜨리면 컴파일은 정상적으로 되지만, 눈에 보이지 않는 곳에서 매 재할당마다 불필요한 복사가 일어나는 성능 저하가 조용히 발생한다는 점을 꼭 기억해야 합니다.


4. swap과 noexcept

swap 구현

swap은 예외 안전성을 보장하는 관용구(swap idiom)의 핵심 도구로, 대입 연산자를 “복사 후 스왑(copy-and-swap)” 방식으로 구현할 때나 컨테이너 내부에서 원소 순서를 바꿀 때 광범위하게 쓰입니다. swap이 예외를 던지지 않는다는 보장이 있어야 이 관용구 전체가 예외에 안전해지기 때문에, 사용자 정의 swap은 관례적으로 noexcept로 선언합니다. 아래 코드는 멤버 함수 swap을 만들고, 이를 std::swap의 특수화에서 그대로 호출하는 표준적인 패턴을 보여줍니다. 이렇게 하면 std::swap(d1, d2)를 호출했을 때 ADL(인자 종속 탐색)을 통해 더 효율적인 사용자 정의 버전이 선택됩니다.

class Data {
public:
    Data(int v) : value(v) {}
    
    void swap(Data& other) noexcept {
        std::swap(value, other.value);
    }
    
    int getValue() const noexcept { return value; }
    
private:
    int value;
};

namespace std {
    template<>
    void swap(Data& lhs, Data& rhs) noexcept {
        lhs.swap(rhs);
    }
}

int main() {
    Data d1(10), d2(20);
    std::swap(d1, d2);
    
    std::cout << d1.getValue() << std::endl;  // 20
    std::cout << d2.getValue() << std::endl;  // 10
    
    return 0;
}

5. 조건부 noexcept

템플릿과 noexcept

템플릿 클래스를 작성할 때는 내부에서 사용하는 타입 T의 성질을 미리 알 수 없기 때문에, T의 이동 생성자나 소멸자가 noexcept인지 여부에 따라 이 템플릿 자신의 연산도 noexcept 여부가 달라져야 합니다. std::is_nothrow_move_constructible_v<T>std::is_nothrow_destructible_v<T> 같은 타입 특성(type trait)을 noexcept(...)의 조건식으로 넘기면, T가 무엇이냐에 따라 컴파일 타임에 자동으로 올바른 예외 명세가 결정됩니다. 표준 라이브러리 컨테이너들도 내부적으로 이런 조건부 noexcept를 광범위하게 사용해 원소 타입의 특성을 그대로 전파합니다.

template<typename T>
class Container {
public:
    // T의 이동 생성자가 noexcept면 noexcept
    Container(Container&& other) 
        noexcept(std::is_nothrow_move_constructible_v<T>)
        : data(std::move(other.data)) {}
    
    // T의 소멸자가 noexcept면 noexcept
    void clear() noexcept(std::is_nothrow_destructible_v<T>) {
        data.clear();
    }
    
private:
    std::vector<T> data;
};

6. 소멸자와 noexcept

암시적 noexcept

C++11부터 소멸자는 명시적으로 noexcept를 붙이지 않아도 암시적으로 noexcept로 취급됩니다(멤버나 기반 클래스의 소멸자가 noexcept(false)가 아닌 이상). 이는 소멸자가 예외를 던지는 것이 애초에 매우 위험한 상황이기 때문입니다. 스택 풀기(stack unwinding) 도중 이미 하나의 예외가 전파되고 있는 상태에서 소멸자가 또 다른 예외를 던지면, 프로그램은 어느 예외를 처리해야 할지 알 수 없어 즉시 std::terminate를 호출합니다. 그래서 소멸자에서는 어떤 경우에도 예외가 밖으로 나가지 않도록 설계하는 것이 원칙입니다.

class MyClass {
public:
    // 소멸자는 암시적으로 noexcept
    ~MyClass() {
        // 예외 던지면 std::terminate
    }
};

class Explicit {
public:
    // 명시적 noexcept
    ~Explicit() noexcept {
        // 예외 던지면 std::terminate
    }
};

소멸자에서 예외 처리

소멸자 안에서 예외를 던질 수 있는 함수(예: 파일 닫기, 네트워크 연결 정리 등 실패할 수 있는 정리 작업)를 호출해야 한다면, 그 호출을 반드시 소멸자 내부에서 try/catch로 감싸 예외가 소멸자 밖으로 새어 나가지 않도록 막아야 합니다. 아래 SafeClasscleanup()이 예외를 던질 가능성이 있음에도, 소멸자 자체는 noexcept를 유지하면서 내부에서 예외를 잡아 로그만 남기는 안전한 패턴을 보여줍니다. 이렇게 하면 정리 작업의 실패가 프로그램 전체를 종료시키는 대참사로 이어지는 것을 막을 수 있습니다.

class SafeClass {
public:
    ~SafeClass() noexcept {
        try {
            cleanup();
        } catch (const std::exception& e) {
            std::cerr << "정리 중 에러: " << e.what() << std::endl;
        }
    }
    
private:
    void cleanup() {
        // 예외 가능
    }
};

7. 실전 예제

예제 1: 안전한 리소스 관리

지금까지 다룬 원칙들(이동 생성자·이동 대입에 noexcept, 소멸자는 암시적으로 noexcept)을 실제 리소스 관리 클래스에 적용한 예제입니다. Managerstd::unique_ptrResource를 소유하며, 이동 생성자와 이동 대입 연산자 모두 noexcept로 선언되어 있습니다. unique_ptr의 이동 자체가 내부 포인터만 교환하는 가벼운 연산이라 예외를 던질 이유가 없기 때문에, 이렇게 소유권을 이전하는 래퍼 클래스의 이동 연산은 거의 항상 noexcept로 선언할 수 있습니다. 이 패턴 덕분에 Manager를 컨테이너에 담아도 재할당 시 항상 효율적인 이동이 보장됩니다.

#include <iostream>
#include <memory>

class Resource {
public:
    Resource() {
        std::cout << "리소스 할당" << std::endl;
    }
    
    ~Resource() noexcept {
        std::cout << "리소스 해제" << std::endl;
    }
    
    void use() noexcept {
        std::cout << "리소스 사용" << std::endl;
    }
};

class Manager {
public:
    Manager() : res(std::make_unique<Resource>()) {}
    
    Manager(Manager&& other) noexcept
        : res(std::move(other.res)) {}
    
    Manager& operator=(Manager&& other) noexcept {
        res = std::move(other.res);
        return *this;
    }
    
    void process() noexcept {
        if (res) {
            res->use();
        }
    }
    
private:
    std::unique_ptr<Resource> res;
};

int main() {
    Manager m1;
    m1.process();
    
    Manager m2 = std::move(m1);
    m2.process();
    
    return 0;
}

정리

핵심 요약

  1. noexcept: 예외 없음 명세 (C++11)
  2. throw(): deprecated (C++17 제거)
  3. 이동 연산: noexcept 권장
  4. 소멸자: 암시적 noexcept
  5. 조건부: noexcept(expression)

noexcept 사용 권장

함수noexcept이유
이동 생성자성능 최적화
이동 대입성능 최적화
swap예외 안전성
소멸자암시적 noexcept
간단한 getter예외 없음
복잡한 로직예외 가능

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Everything about C++ Exception Specifications : from basic concepts to practical applications.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, exception-specifications, throw, noexcept, deprecated 등으로 검색하시면 이 글이 도움이 됩니다.