C++ throw()가 폐기되고 noexcept로 바뀐 이유: vector 재할당에서 이동 대신 복사가 일어나는 경우
이 글의 핵심
throw()는 런타임에 검사되는 약속이라 비용만 있고 쓸모가 적었고, noexcept는 컴파일러와 표준 라이브러리가 최적화 판단에 쓰는 신호라는 점이 가장 큰 차이입니다. 그래서 noexcept를 이동 연산과 swap에 제대로 붙이는지가 컨테이너 성능과 강한 예외 보장에 직결됩니다. 소멸자가 암시적으로 noexcept라서 생기는 함정과 템플릿에서 noexcept(noexcept(...))를 쓰는 경우까지 정리했습니다.
들어가며
예외 명세는 함수가 던질 수 있는 예외를 컴파일러와 호출자에게 알려주는 기능입니다. 함수 시그니처에 “이 함수는 예외를 던지지 않는다” 혹은 “특정 예외만 던진다”는 정보를 붙이면, 컴파일러는 그 정보를 바탕으로 더 공격적인 최적화를 수행할 수 있고, 호출하는 쪽에서는 굳이 try/catch를 준비하지 않아도 되는지 미리 판단할 수 있습니다. 문제는 C++가 이 기능을 두 번에 걸쳐 완전히 다른 방식으로 설계했다는 점입니다. C++03의 동적 예외 명세(throw(...))는 실제로는 런타임 검사만 추가할 뿐 컴파일 타임 최적화에는 거의 도움이 되지 않아 C++11에서 사실상 폐기되었고, 대신 훨씬 단순하고 실효성 있는 noexcept가 그 자리를 대체했습니다.
noexcept를 가볍게 봤다가 겪는 일
noexcept를 처음 접하면 “그냥 예외를 안 던진다는 표시 아닌가” 정도로 가볍게 넘기기 쉽지만, 실제로는 std::vector의 재할당 전략, swap 기반 예외 안전성 관용구, 소멸자의 암시적 규칙 등 C++ 표준 라이브러리 전반의 동작을 좌우하는 핵심 개념입니다. 특히 이동 생성자에 noexcept를 빠뜨리면 컴파일 오류는 전혀 나지 않으면서도 std::vector::push_back이 조용히 이동 대신 복사를 선택해버리는, 발견하기 매우 어려운 성능 저하가 발생합니다. 이 글에서는 예외 명세가 왜 지금의 형태로 정착했는지, 그리고 noexcept를 어디에 붙여야 하고 어디에는 붙이면 위험한지를 실제 코드로 짚어봅니다.
throw()에서 noexcept로 바뀐 이유
C++03 동적 예외 명세 throw()
C++03의 동적 예외 명세는 함수가 던질 수 있는 예외의 목록을 시그니처에 나열하는 방식이었습니다. throw()는 아무 예외도 던지지 않는다는 뜻이고, throw(std::exception)처럼 특정 타입을 나열하면 그 타입만 던질 수 있다는 의미였습니다. 하지만 명세에 없는 예외가 실제로 던져지면 컴파일 타임이 아니라 런타임에 std::unexpected가 호출되는 방식으로 처리되었고, 컴파일러가 이 정보를 이용해 최적화를 하기도 어려웠습니다. 실질적인 이득 없이 코드만 복잡해진다는 비판이 많아 C++11에서 deprecated되었습니다. 타입 목록을 나열하는 throw(std::exception) 형태는 C++17에서 제거되었고, 빈 throw()만 C++17에서 noexcept(true)의 별칭으로 잠시 남았다가 C++20에서 완전히 제거되었습니다.
동적 명세가 실패한 이유는 설계 자체에 있습니다. 명세는 호출자가 믿을 수 있는 컴파일 타임 계약이 아니라, 위반했을 때 런타임에 std::unexpected를 부르는 검사였습니다. 컴파일러는 명세를 지키는지 확인해 주지 않으면서 모든 함수 출구에 검사 코드를 넣어야 했고, 템플릿 코드에서는 T가 무엇을 던질지 알 수 없어 명세를 정확히 쓰는 것 자체가 불가능했습니다. Java의 checked exception과 비슷해 보이지만 강제력은 없고 비용만 있는 구조였던 셈입니다.
// 예외 없음
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 함수에 정리용 코드를 만들지 않아도 됩니다. 이 “풀지 않아도 된다”는 허용이 최적화 여지를 만드는 핵심입니다. noexcept(true)와 noexcept(false)처럼 불리언 표현식을 인자로 받을 수도 있어, 템플릿 코드에서 타입 파라미터의 성질에 따라 조건부로 예외 여부를 결정하는 것도 가능합니다.
// 예외 없음
void func() noexcept;
// 조건부
void func() noexcept(true); // noexcept
void func() noexcept(false); // 예외 가능
C++17부터는 noexcept가 함수 타입의 일부가 되었습니다. 그래서 void (*fp)() noexcept = &func2;처럼 noexcept가 아닌 함수를 noexcept 함수 포인터에 담으면 컴파일 에러가 나고(반대 방향은 허용), noexcept 여부만 다른 두 함수는 서로 다른 타입으로 템플릿 추론됩니다. C++14 코드를 C++17로 올릴 때 함수 포인터 대입이나 템플릿 특수화에서 드물게 컴파일 에러가 나는 원인이 이것입니다.
noexcept 지정자와 noexcept 연산자
함수에 noexcept 붙이기
noexcept를 함수 선언에 붙이는 것 자체는 문법적으로 매우 간단하지만, 그 의미는 “이 함수 안에서는 예외가 절대 밖으로 나가지 않는다”는 강한 약속입니다. 아래 예제는 예외를 던지지 않는 safe_func와 예외를 던지는 risky_func를 대비시켜 보여줍니다. risky_func는 noexcept가 없기 때문에 throw로 던진 예외가 정상적으로 호출자 쪽 try/catch까지 전파되지만, 만약 risky_func에 noexcept를 붙였다면 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;
}
noexcept 연산자는 선언만 봅니다. func2의 본문이 실제로는 아무것도 던지지 않더라도 noexcept가 선언되어 있지 않으면 false를 돌려주고, 반대로 noexcept로 선언한 함수가 안에서 예외를 던져도 연산자는 true라고 답합니다. 컴파일러가 본문을 분석해 추론해 주지 않는다는 점에서, noexcept는 전적으로 작성자의 약속입니다.
vector 재할당과 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이거나 복사 생성자가 없을 때 이동을 사용합니다(std::move_if_noexcept 규칙). 이동 생성자에 noexcept를 빠뜨리면 컴파일은 정상적으로 되지만, 눈에 보이지 않는 곳에서 매 재할당마다 불필요한 복사가 일어나는 성능 저하가 조용히 발생한다는 점을 꼭 기억해야 합니다.
위 예제는 reserve(10)으로 재할당을 막았기 때문에 push_back에서는 이동이 한 번만 보이고, 차이는 용량이 넘칠 때 드러납니다. reserve를 빼고 push_back을 여러 번 한 뒤 출력을 보면, noexcept를 지운 버전에서는 재할당마다 기존 원소 수만큼 “복사 생성”이 찍힙니다. 가장 흔한 실수는 이동 생성자를 직접 작성하는 순간 발생합니다. 컴파일러가 만들어 주는 암시적 이동 생성자나 = default로 선언한 이동 생성자는 멤버들의 이동이 모두 noexcept면 자동으로 noexcept가 되지만, 본문을 직접 쓰면 명시하지 않는 한 noexcept가 아닙니다. 가장 안전한 방법은 클래스 정의 뒤에 static_assert(std::is_nothrow_move_constructible_v<Widget>);를 두어 이 속성을 컴파일 타임에 고정하는 것입니다.
swap을 noexcept로 구현하기
swap은 예외 안전성을 보장하는 관용구(swap idiom)의 핵심 도구로, 대입 연산자를 “복사 후 스왑(copy-and-swap)” 방식으로 구현할 때나 컨테이너 내부에서 원소 순서를 바꿀 때 광범위하게 쓰입니다. swap이 예외를 던지지 않는다는 보장이 있어야 이 관용구 전체가 예외에 안전해지기 때문에, 사용자 정의 swap은 관례적으로 noexcept로 선언합니다. 아래 코드는 멤버 함수 swap을 만들고, 같은 네임스페이스에 비멤버 swap을 두어 ADL(인자 종속 탐색)로 찾아지게 하는 표준적인 패턴을 보여줍니다.
#include <iostream>
#include <utility>
class Data {
public:
Data(int v) : value(v) {}
void swap(Data& other) noexcept {
using std::swap;
swap(value, other.value);
}
// 같은 네임스페이스(여기서는 hidden friend)에 둔 비멤버 swap: ADL로 찾아짐
friend void swap(Data& lhs, Data& rhs) noexcept { lhs.swap(rhs); }
int getValue() const noexcept { return value; }
private:
int value;
};
int main() {
Data d1(10), d2(20);
using std::swap; // "std 버전도 후보에 넣고"
swap(d1, d2); // 한정자 없이 호출 → ADL로 Data용 swap 선택
std::cout << d1.getValue() << std::endl; // 20
std::cout << d2.getValue() << std::endl; // 10
return 0;
}
예전 코드에서 흔히 보이는 namespace std { template<> void swap(Data&, Data&) ... } 같은 특수화는 쓰지 않는 편이 좋습니다. 표준 라이브러리 함수 템플릿을 사용자가 특수화하는 것은 C++20부터 허용되지 않고, std::swap(d1, d2)처럼 한정자를 붙여 부르면 ADL이 일어나지 않아 사용자 정의 버전이 무시됩니다. using std::swap; 뒤에 한정자 없이 swap(a, b)를 부르는 “two-step” 관용구가 사용자 타입과 기본 타입을 모두 올바르게 처리하는 방법이며, C++20의 std::ranges::swap은 이 과정을 알아서 해 줍니다.
템플릿의 조건부 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;
};
소멸자는 암시적으로 noexcept
암시적 noexcept 규칙
C++11부터 소멸자는 명시적으로 noexcept를 붙이지 않아도 암시적으로 noexcept로 취급됩니다(멤버나 기반 클래스의 소멸자가 noexcept(false)가 아닌 이상). 이 규칙은 C++03 시절 코드를 옮길 때 함정이 됩니다. 소멸자에서 의도적으로 예외를 던지던 코드는(예: 트랜잭션 커밋 실패를 알리는 스코프 가드) C++11로 빌드하는 순간 예외 대신 std::terminate로 프로그램이 종료됩니다. GCC는 이런 코드에 throw will always call terminate() [-Wterminate] 경고를 내므로, 이 경고가 보이면 소멸자에 noexcept(false)를 명시하거나 설계를 바꿔야 합니다. 이는 소멸자가 예외를 던지는 것이 애초에 매우 위험한 상황이기 때문입니다. 스택 풀기(stack unwinding) 도중 이미 하나의 예외가 전파되고 있는 상태에서 소멸자가 또 다른 예외를 던지면, 프로그램은 어느 예외를 처리해야 할지 알 수 없어 즉시 std::terminate를 호출합니다. 그래서 소멸자에서는 어떤 경우에도 예외가 밖으로 나가지 않도록 설계하는 것이 원칙입니다.
class MyClass {
public:
// 소멸자는 암시적으로 noexcept
~MyClass() {
// 예외 던지면 std::terminate
}
};
class Explicit {
public:
// 명시적 noexcept
~Explicit() noexcept {
// 예외 던지면 std::terminate
}
};
실패할 수 있는 정리 작업은 소멸자 안에서 처리
소멸자 안에서 예외를 던질 수 있는 함수(예: 파일 닫기, 네트워크 연결 정리 등 실패할 수 있는 정리 작업)를 호출해야 한다면, 그 호출을 반드시 소멸자 내부에서 try/catch로 감싸 예외가 소멸자 밖으로 새어 나가지 않도록 막아야 합니다. 아래 SafeClass는 cleanup()이 예외를 던질 가능성이 있음에도, 소멸자 자체는 noexcept를 유지하면서 내부에서 예외를 잡아 로그만 남기는 안전한 패턴을 보여줍니다. 이렇게 하면 정리 작업의 실패가 프로그램 전체를 종료시키는 대참사로 이어지는 것을 막을 수 있습니다.
class SafeClass {
public:
~SafeClass() noexcept {
try {
cleanup();
} catch (const std::exception& e) {
std::cerr << "정리 중 에러: " << e.what() << std::endl;
}
}
private:
void cleanup() {
// 예외 가능
}
};
리소스 관리 클래스에 적용하기
지금까지 다룬 원칙들(이동 생성자·이동 대입에 noexcept, 소멸자는 암시적으로 noexcept)을 실제 리소스 관리 클래스에 적용한 예제입니다. Manager는 std::unique_ptr로 Resource를 소유하며, 이동 생성자와 이동 대입 연산자 모두 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;
}
예외 명세 정리
핵심 요약
- noexcept: 예외 없음 명세 (C++11)
- throw(): deprecated (타입 목록은 C++17, 빈
throw()는 C++20에서 제거) - 이동 연산: noexcept 권장
- 소멸자: 암시적 noexcept
- 조건부:
noexcept(expression)
함수 종류별 noexcept 권장
| 함수 | noexcept | 이유 |
|---|---|---|
| 이동 생성자 | ✅ | 성능 최적화 |
| 이동 대입 | ✅ | 성능 최적화 |
| swap | ✅ | 예외 안전성 |
| 소멸자 | ✅ | 암시적 noexcept |
| 간단한 getter | ✅ | 예외 없음 |
| 복잡한 로직 | ❌ | 예외 가능 |
마지막 줄이 특히 중요합니다. noexcept는 한번 공개 API에 붙이면 떼기 어려운 약속입니다. 나중에 구현이 바뀌어 메모리 할당이나 I/O가 필요해지면, noexcept를 유지하는 한 실패를 예외로 알릴 방법이 없어지고, 떼면 그 함수를 noexcept 문맥에서 쓰던 호출자의 동작이 바뀝니다. 표준 위원회도 “전제 조건을 어기면 미정의 동작인 함수(좁은 계약)에는 noexcept를 붙이지 않는다”는 원칙(Lakos rule)을 오래 써 왔는데, 디버그 빌드에서 전제 조건 위반을 예외로 알리는 검사를 넣을 여지를 남기기 위해서입니다. 이동 연산, swap, 소멸자처럼 실패하면 안 되는 연산에는 적극적으로 붙이고, 나머지는 정말로 실패할 수 없을 때만 붙이는 것이 균형 잡힌 선택입니다.
이어서 볼 글
같이 보면 좋은 글
- C++ 예외 처리 | try/catch/throw
- C++ noexcept 지정자
- noexcept로 C++ 이동 연산 최적화하기: 예외 계약 설계 패턴
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ 예외 안전성
- C++ 복사 생성자와 이동 생성자
자주 묻는 질문 (FAQ)
Q. noexcept(noexcept(…)) 같은 조건부 noexcept는 언제 써야 하나요?
A. 감싼 타입 T의 연산이 예외를 던질지 미리 알 수 없는 템플릿 래퍼나 컨테이너에서 씁니다. 예를 들어 이동 생성자에 noexcept(std::is_nothrow_move_constructible_v<T>)를 붙이면 T의 성질이 그대로 전파됩니다. 무조건 noexcept를 붙이면 T가 예외를 던질 때 std::terminate로 프로그램이 종료되고, 아예 빼면 std::vector가 이동 대신 복사를 택하므로, 조건부 명세가 두 문제를 함께 피하는 방법입니다.