C++ noexcept 지정자: 이동 생성자·swap·소멸자에 붙이는 이유와 위반 시 terminate

이 글의 핵심

noexcept 지정자가 함수 계약과 최적화에 주는 의미, 조건부 noexcept와 noexcept 연산자 사용법을 정리합니다. 이동 생성자에 noexcept가 없으면 vector 재할당 때 복사로 되돌아가는 이유, 위반 시 std::terminate가 호출되는 동작과 적용 권장 기준도 다룹니다.

noexcept란?

함수가 예외를 던지지 않음을 명시

noexcept는 함수의 시그니처에 새겨지는 컴파일 타임 계약이지만, 그 계약을 강제하는 방식이 흥미롭습니다 — 컴파일러는 noexcept 함수 안에서 예외를 던지는 코드가 있어도 컴파일 에러를 내지 않고, 대신 그 예외가 실제로 함수 경계를 벗어나려는 순간 런타임에 std::terminate를 호출해 프로그램을 즉시 종료시킵니다. 이 “사후 강제” 방식이 선택된 이유는, noexcept가 단순히 “예외를 던지지 않는 코드를 강제”하는 것이 아니라 “이 함수를 호출하는 쪽이 예외 처리를 걱정하지 않아도 된다”는 더 강한 보장을 제공하기 위함입니다 — 만약 컴파일 타임에 이를 완벽히 검증하려 했다면, 함수가 호출하는 모든 하위 함수의 예외 명세까지 재귀적으로 추적해야 해 실용적이지 않았을 것입니다. noexcept(true)와 noexcept는 완전히 동일하며, 매개변수 없는 noexcept는 noexcept(true)의 축약형입니다.

void func() noexcept {
    // 예외 던지지 않음
}

void func2() noexcept(true) {
    // 예외 던지지 않음
}

void func3() noexcept(false) {
    // 예외 던질 수 있음 (기본값)
}

noexcept의 장점

세 가지 장점은 모두 “예외 처리 경로를 고려할 필요가 없다”는 하나의 사실에서 파생됩니다. 컴파일러 최적화 관점에서, 예외를 던질 수 있는 함수는 호출 지점마다 “예외가 발생하면 지금까지 생성된 지역 객체들을 스택을 거슬러 올라가며 정리(스택 언와인딩)해야 한다”는 코드를 함께 준비해 둬야 하는데, noexcept 함수는 이 준비가 필요 없어 코드 크기가 줄고 인라이닝이 더 자유로워집니다. 이동 생성자 최적화는 뒤에서 자세히 다루겠지만 핵심만 미리 말하면, std::vector가 재할당 중 예외가 나도 원래 상태로 되돌릴 수 있다는 강한 예외 보장을 지키려면 이동 연산이 절대 실패하지 않는다는 확신이 필요하고, noexcept가 정확히 그 확신을 제공합니다. “명확한 계약” 항목은 코드 자체의 가독성 이야기입니다 — int calculate(int x) noexcept라는 시그니처만 보고도 호출자는 이 함수를 try/catch로 감쌀 필요가 없다는 것을 즉시 알 수 있습니다.

// 1. 컴파일러 최적화
void swap(int& a, int& b) noexcept {
    int temp = a;
    a = b;
    b = temp;
}

// 2. 이동 생성자 최적화
class MyClass {
public:
    MyClass(MyClass&&) noexcept {
        // vector 등에서 이동 사용
    }
};

// 3. 명확한 계약
int calculate(int x) noexcept {
    return x * 2;  // 예외 없음 보장
}

조건부 noexcept

템플릿 함수는 자신이 다룰 타입 T가 무엇인지 작성 시점에 알 수 없으므로, swap이 실제로 예외를 던질 수 있는지는 T의 이동 생성자가 예외를 던질 수 있는지에 전적으로 달려 있습니다 — T가 원시 타입(int 등)이면 이동은 절대 실패하지 않지만, T가 예외를 던질 수 있는 생성자를 가진 사용자 정의 타입이라면 swap도 그 예외를 전파할 수 있어야 합니다. noexcept(noexcept(T(std::move(a))))처럼 두 개의 noexcept가 중첩된 것이 처음엔 낯설 수 있는데, 안쪽 noexcept(T(std::move(a)))는 “이 표현식이 예외를 던지는가”를 컴파일 타임에 검사하는 연산자(뒤에서 다루는 “noexcept 연산자”)이고, 바깥쪽 noexcept(...)는 그 검사 결과(참/거짓)를 그대로 함수의 예외 명세로 사용하는 지정자입니다. std::is_nothrow_move_constructible<T>::value를 쓴 두 번째 버전은 같은 것을 표준 라이브러리의 타입 트레잇으로 더 읽기 쉽게 표현한 것이며, 실무에서는 이 트레잇 기반 표기가 훨씬 흔하게 쓰입니다.

template<typename T>
void swap(T& a, T& b) noexcept(noexcept(T(std::move(a)))) {
    T temp(std::move(a));
    a = std::move(b);
    b = std::move(temp);
}

// 간단한 버전
template<typename T>
void swap(T& a, T& b) noexcept(std::is_nothrow_move_constructible<T>::value) {
    // ...
}

실전 예시

예시 1: 이동 생성자

복사 생성자와 이동 생성자가 noexcept 여부에서 갈리는 이유가 이 예시에 명확히 드러납니다 — 복사 생성자는 new char[]로 새 메모리를 할당해야 하는데 이 할당은 메모리 부족 시 std::bad_alloc을 던질 수 있는 근본적으로 실패 가능한 연산이지만, 이동 생성자는 포인터 값을 옮기고 원본을 nullptr로 되돌리는 것뿐이라 실패할 수 있는 연산이 애초에 존재하지 않습니다. 이것이 왜 이동 연산에는 noexcept를 붙이는 것이 거의 항상 가능하고 권장되는지를 보여줍니다 — 이동은 “실패할 수 있는 작업(할당)“이 아니라 “이미 존재하는 자원의 소유권만 옮기는 작업”이므로, 그 구현이 예외를 던질 이유가 설계상 없는 경우가 대부분입니다.

class String {
private:
    char* data;
    size_t length;
    
public:
    String(const char* str) {
        length = strlen(str);
        data = new char[length + 1];
        strcpy(data, str);
    }
    
    ~String() {
        delete[] data;
    }
    
    // 복사 생성자 (예외 가능)
    String(const String& other) {
        length = other.length;
        data = new char[length + 1];  // 예외 가능
        strcpy(data, other.data);
    }
    
    // 이동 생성자 (noexcept)
    String(String&& other) noexcept 
        : data(other.data), length(other.length) {
        other.data = nullptr;
        other.length = 0;
    }
    
    // 이동 대입 연산자 (noexcept)
    String& operator=(String&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            length = other.length;
            other.data = nullptr;
            other.length = 0;
        }
        return *this;
    }
};

예시 2: swap 함수

swap에 noexcept가 특히 중요한 이유는 표준 라이브러리 전반이 “강한 예외 보장을 제공하는 연산은 내부적으로 swap을 활용해 구현한다”는 관용구(copy-and-swap 등)에 크게 의존하기 때문입니다 — 만약 swap 자체가 예외를 던질 수 있다면, 그 위에 세워진 모든 예외 안전성 보장이 함께 무너집니다. swap(a.data, b.data)가 단순히 포인터 두 개를 맞바꾸는 것뿐이라 실패할 이유가 전혀 없는 것도 예시 1과 같은 논리이며, using std::swap; 뒤에 비한정 swap 호출을 쓴 것은 이 글의 다른 곳에서 다룬 ADL(인자 의존 탐색)을 활용해 int* 같은 기본 타입에는 std::swap을, 사용자 정의 타입에는 그 타입 전용 swap을 자동으로 선택하게 하는 관용구입니다.

template<typename T>
void swap(T& a, T& b) noexcept {
    T temp(std::move(a));
    a = std::move(b);
    b = std::move(temp);
}

class MyClass {
private:
    int* data;
    
public:
    MyClass(int value) : data(new int(value)) {}
    
    ~MyClass() {
        delete data;
    }
    
    // swap (noexcept)
    friend void swap(MyClass& a, MyClass& b) noexcept {
        using std::swap;
        swap(a.data, b.data);
    }
};

예시 3: 소멸자

C++11부터 사용자가 명시적으로 noexcept(false)를 붙이지 않는 한 모든 소멸자는 암묵적으로 noexcept로 취급됩니다 — 이는 “예외 처리 중 스택을 풀어나가다가(unwinding) 그 과정에서 호출되는 소멸자가 또 다른 예외를 던지면 어느 예외를 처리해야 할지 알 수 없어 std::terminate가 호출된다”는 언어 차원의 오래된 규칙을 문서화한 것에 가깝습니다. 소멸자가 예외를 던질 수 있는 코드(파일 닫기 실패, 네트워크 연결 정리 실패 등)를 담고 있어야 한다면, 그 예외를 소멸자 밖으로 내보내지 말고 내부에서 try/catch로 완전히 처리해 삼켜야 합니다 — “정리 작업이 실패했다”는 사실을 호출자에게 알릴 방법이 소멸자에는 없다는 것이 이 규칙의 실질적인 제약이며, 정말 그 실패를 알려야 한다면 소멸자가 아닌 명시적인 close()/cleanup() 메서드에서 처리하는 것이 유일한 해법입니다.

class Resource {
private:
    int* data;
    
public:
    Resource(int value) : data(new int(value)) {}
    
    // 소멸자는 기본적으로 noexcept
    ~Resource() noexcept {
        delete data;
        // 예외 던지면 안 됨!
    }
};

예시 4: 벡터 최적화

std::vector가 용량 초과로 재할당할 때, 기존 원소들을 새로운 더 큰 메모리 블록으로 옮기는 작업 도중 예외가 발생하면 어떻게 될지가 이 최적화의 핵심 배경입니다 — 이미 몇 개의 원소를 새 위치로 이동시킨 상태에서 그다음 원소의 이동이 예외를 던진다면, vector는 이미 원본과 사본에 걸쳐 반쯤 이동된 일관성 없는 상태에 빠지게 됩니다. 이런 상황을 피하기 위해 vector는 표준이 요구하는 강한 예외 보장(재할당이 실패하면 원래 상태를 그대로 유지)을 지켜야 하는데, 이동 생성자가 noexcept임을 컴파일 타임에 확인할 수 있을 때만 “이동은 절대 실패하지 않으니 안전하게 쓸 수 있다”고 판단하고, 그렇지 않으면 안전을 위해 (느리지만 실패해도 원본이 온전한) 복사로 대체합니다. Widget의 두 버전을 나란히 보면, 똑같은 로직인데 noexcept 한 단어의 유무만으로 vector가 완전히 다른 전략을 선택한다는 것이 드러납니다.

#include <vector>

class Widget {
public:
    Widget() = default;
    
    // noexcept 없으면 vector가 복사 사용
    Widget(Widget&& other) {
        // ...
    }
    
    // noexcept 있으면 vector가 이동 사용
    Widget(Widget&& other) noexcept {
        // ...
    }
};

int main() {
    std::vector<Widget> vec;
    
    // vec.push_back()나 vec.resize() 시
    // noexcept 이동 생성자가 있으면 이동 사용
    // 없으면 복사 사용 (예외 안전성)
}

noexcept 연산자

여기서 noexcept는 지정자가 아니라 연산자로 쓰이고 있으며(같은 키워드가 두 가지 역할을 한다는 점이 처음엔 헷갈릴 수 있습니다), sizeof처럼 실제로 표현식을 실행하지 않고 컴파일 타임에 그 표현식이 예외를 던질 수 있는지만 검사해 bool 값을 반환합니다. noexcept(func1())이 func1을 실제로 호출하는 것이 아니라 “만약 호출한다면 예외를 던질 수 있는가”를 정적으로 판단하는 것이라는 점이 핵심이며, 이 성질 덕분에 앞서 “조건부 noexcept” 절에서 본 noexcept(noexcept(T(std::move(a))))처럼 함수 정의 안에서 다른 표현식의 예외 던짐 여부를 검사해 그 결과를 자신의 명세로 재사용하는 메타프로그래밍이 가능해집니다.

// noexcept 연산자: 표현식이 예외를 던지는지 확인
void func1() noexcept {}
void func2() {}

int main() {
    std::cout << noexcept(func1()) << std::endl;  // 1 (true)
    std::cout << noexcept(func2()) << std::endl;  // 0 (false)
    
    std::cout << noexcept(1 + 2) << std::endl;    // 1
    std::cout << noexcept(throw 1) << std::endl;  // 0
}

자주 발생하는 문제

문제 1: noexcept 위반

이 코드가 특히 위험한 이유는 try/catch로 감싸도 전혀 소용이 없다는 데 있습니다 — 일반적으로 예외는 호출 스택을 거슬러 올라가며 이를 처리할 수 있는 가장 가까운 catch 블록을 찾지만, noexcept 함수 경계를 넘으려는 순간 표준은 그 탐색 과정 자체를 건너뛰고 곧바로 std::terminate를 호출하도록 규정합니다. 그래서 main의 try/catch가 func()를 감싸고 있어도 그 catch 블록은 결코 실행되지 않으며, 프로그램은 즉시 비정상 종료됩니다. 이것이 noexcept가 단순한 “권장 사항”이 아니라 위반 시 프로그램 전체를 강제 종료시키는 진짜 계약인 이유이며, noexcept 함수 내부에서 호출하는 모든 하위 함수가 실제로 예외를 던지지 않는지 신중하게 검토해야 하는 이유이기도 합니다.

void func() noexcept {
    throw std::runtime_error("에러");  // std::terminate 호출!
}

int main() {
    try {
        func();
    } catch (...) {
        // 여기 도달 안함
        // std::terminate가 먼저 호출됨
    }
}

문제 2: 조건부 noexcept 누락

process<T>를 무조건 noexcept로 선언하는 것은, 그 함수가 어떤 T로 인스턴스화되든 항상 성립한다고 컴파일러에게 거짓 약속을 하는 것과 같습니다 — T copy = value가 T의 복사 생성자를 호출하는데, 만약 어떤 T의 복사 생성자가 실제로 예외를 던진다면 앞서 다룬 “noexcept 위반”이 그대로 재현되어 std::terminate로 프로그램이 종료됩니다. 이 실수가 특히 위험한 이유는 컴파일 시점에는 전혀 드러나지 않는다는 것입니다 — process<int>처럼 예외를 던지지 않는 타입으로 쓰는 동안은 아무 문제가 없다가, 나중에 예외를 던질 수 있는 타입으로 이 템플릿을 인스턴스화하는 순간에야 잠재된 문제가 런타임 크래시로 터집니다. noexcept(std::is_nothrow_copy_constructible<T>::value)처럼 조건을 T에 맞게 정확히 계산하면, 이 함수는 각 타입에 대해 진실만을 말하는 명세를 갖게 됩니다.

// ❌ 항상 noexcept (잘못됨)
template<typename T>
void process(T value) noexcept {
    T copy = value;  // T의 복사 생성자가 예외 던질 수 있음
}

// ✅ 조건부 noexcept
template<typename T>
void process(T value) noexcept(std::is_nothrow_copy_constructible<T>::value) {
    T copy = value;
}

문제 3: 소멸자 예외

BadClass의 소멸자가 명시적으로 noexcept를 붙이지 않았어도, 앞서 “예시 3: 소멸자”에서 설명했듯 이는 C++11부터 암묵적으로 noexcept로 취급되므로 이 코드는 사실상 앞서 다룬 “noexcept 위반”과 똑같은 문제를 안고 있습니다 — throw가 실행되는 순간 그 예외는 암묵적 noexcept 경계를 넘으려 시도하고, 즉시 std::terminate가 호출됩니다. 소멸자가 std::vector나 컨테이너 안에서 호출될 때(원소가 지워지거나 컨테이너 자체가 소멸될 때) 이 문제는 더 미묘해지는데, 한 원소의 소멸 도중 예외가 발생하면 나머지 원소들의 소멸 처리 자체가 불확실한 상태에 빠지기 때문에 표준 라이브러리 전체가 “소멸자는 예외를 던지지 않는다”는 전제를 깔고 설계되어 있습니다. 그래서 소멸자 안에서 실패할 수 있는 작업이 있다면 반드시 그 자리에서 try/catch로 감싸 예외가 소멸자 밖으로 나가지 못하게 막아야 합니다.

// ❌ 소멸자에서 예외
class BadClass {
public:
    ~BadClass() {
        throw std::runtime_error("에러");  // 위험!
    }
};

// ✅ 소멸자는 noexcept
class GoodClass {
public:
    ~GoodClass() noexcept {
        try {
            // 예외 발생 가능한 코드
        } catch (...) {
            // 예외 처리
        }
    }
};

문제 4: 이동 생성자 noexcept 누락

이는 앞서 “예시 4: 벡터 최적화”에서 다룬 원리가 실제 코드 작성 시 놓치기 쉬운 실수로 나타나는 지점입니다 — 컴파일 에러도, 눈에 보이는 크래시도 없이 그저 vector가 재할당할 때마다 조용히 복사를 선택해 성능이 저하될 뿐이라, 이 실수는 프로파일링 없이는 발견하기 매우 어렵습니다. 실무에서는 이동 생성자를 작성할 때마다 그것이 정말 예외를 던지지 않는지 확인하고 noexcept를 붙이는 것을 습관화하는 것이 유일한 예방책이며, 정적 분석 도구나 코드 리뷰 체크리스트에 “새로 작성한 이동 생성자에 noexcept가 있는가”를 포함시키는 것도 실용적인 방법입니다.

// ❌ noexcept 없음 (vector가 복사 사용)
class MyClass {
public:
    MyClass(MyClass&& other) {
        // ...
    }
};

// ✅ noexcept 추가 (vector가 이동 사용)
class MyClass {
public:
    MyClass(MyClass&& other) noexcept {
        // ...
    }
};

noexcept와 성능

이 벤치마크가 실제로 측정하는 것은 100만 번의 push_back 동안 vector가 겪는 수십 번의 재할당에서, 각 재할당마다 이전 원소들을 이동할지 복사할지의 누적된 차이입니다 — Widget이 힙에 할당된 int* 하나만 가지고 있어 복사는 매번 new int(...)를 호출해야 하지만 이동은 포인터만 옮기므로, 원소 개수가 많아질수록 이 격차는 선형으로 벌어집니다. 이 예시가 보여주는 실무적 교훈은, noexcept 하나를 빠뜨리는 사소해 보이는 실수가 이동 시맨틱이 약속하는 성능 이점 전체를 무효화할 수 있다는 것입니다 — 애써 O(1) 이동 생성자를 작성해 놓고도 noexcept를 붙이지 않으면 vector는 그 존재를 신뢰하지 않고 항상 O(n) 복사로 되돌아갑니다.

#include <vector>
#include <chrono>

class Widget {
    int* data;
    
public:
    Widget(int value) : data(new int(value)) {}
    
    ~Widget() {
        delete data;
    }
    
    // noexcept 없음
    Widget(Widget&& other) 
        : data(other.data) {
        other.data = nullptr;
    }
    
    // noexcept 있음
    Widget(Widget&& other) noexcept 
        : data(other.data) {
        other.data = nullptr;
    }
};

int main() {
    std::vector<Widget> vec;
    
    // noexcept 있으면 이동 (빠름)
    // noexcept 없으면 복사 (느림)
    for (int i = 0; i < 1000000; i++) {
        vec.push_back(Widget(i));
    }
}

사용 권장사항

이 권장/비권장 목록을 관통하는 기준은 단 하나입니다 — “이 함수가 정말로 예외를 던질 수 없다고 확신하는가?” 이동 생성자·swap·간단한 getter·소멸자는 대부분 힙 할당이나 실패 가능한 외부 호출 없이 순수하게 값을 옮기거나 읽기만 하므로 이 확신을 갖기 쉽지만, 임의의 process()나 아직 구현이 안정화되지 않은 complexOperation()은 나중에 파일 I/O나 메모리 할당이 추가될 수 있어 그 확신을 갖기 어렵습니다. noexcept를 나중에 추가하는 것은 (호출자가 이미 그 함수가 예외를 던질 수 있다고 가정하고 작성한 코드를 깨뜨리지 않으므로) 대체로 안전한 반면, noexcept를 나중에 제거하는 것은 그 함수를 신뢰하고 있던 다른 코드(예: vector가 그 이동 생성자를 보고 이동 전략을 선택한 경우)의 동작을 조용히 바꿔버릴 수 있으므로, 확신이 서지 않는다면 처음에는 noexcept를 붙이지 않는 편이 더 안전한 기본값입니다.

// ✅ noexcept 사용 권장
// 1. 이동 생성자/대입 연산자
MyClass(MyClass&&) noexcept;
MyClass& operator=(MyClass&&) noexcept;

// 2. swap 함수
void swap(MyClass&, MyClass&) noexcept;

// 3. 소멸자 (기본적으로 noexcept)
~MyClass() noexcept;

// 4. 간단한 getter
int getValue() const noexcept;

// ❌ noexcept 사용 지양
// 1. 예외 던질 수 있는 함수
void process() {
    // 예외 가능
}

// 2. 불확실한 경우
void complexOperation() {
    // 나중에 예외 추가될 수 있음
}

FAQ

Q1: noexcept는 언제 사용?

A:

  • 이동 생성자/대입
  • swap 함수
  • 소멸자
  • 예외 없음 보장

Q2: noexcept 위반 시?

A: std::terminate 호출. 프로그램 종료.

Q3: 성능 이점?

A:

  • 컴파일러 최적화
  • vector 이동 사용
  • 스택 언와인딩 불필요

Q4: 조건부 noexcept?

A: noexcept(표현식). 템플릿에서 유용.

Q5: 소멸자는?

A: 기본적으로 noexcept. 예외 던지면 안됩니다.

Q6: noexcept 학습 리소스는?

A:

  • “Effective Modern C++”
  • cppreference.com
  • “C++ Primer”

같이 보면 좋은 글