C++ noexcept 키워드 | '예외 없음 지정' 가이드
이 글의 핵심
C++ noexcept 키워드 - "예외 없음 지정" 가이드. C++ noexcept 키워드의 noexcept란?, 조건부 noexcept, 실전 예시를 실전 코드와 함께 설명합니다.
noexcept란?
noexcept 는 C++11에서 도입된 키워드로, 함수가 예외를 던지지 않음을 명시합니다. 예외를 던지면 std::terminate가 호출되어 프로그램이 종료됩니다.
void func() noexcept {
// 예외 던지면 std::terminate
}
void func2() noexcept(true) { // noexcept와 동일
// ...
}
void func3() noexcept(false) { // 예외 가능
// ...
}
왜 필요한가?:
- 최적화: 컴파일러가 스택 되감기 코드 생략 가능
- STL 최적화:
std::vector가 noexcept 이동 연산자 확인 - 명확성: 함수가 예외를 던지지 않음을 명시
- 안전성: 예외 안전 보장이 필요한 곳에서 사용
// ❌ noexcept 없음
class Widget {
public:
Widget(Widget&& other) {
// std::vector는 복사 사용 (안전)
}
};
// ✅ noexcept 추가
class Widget {
public:
Widget(Widget&& other) noexcept {
// std::vector는 이동 사용 (빠름)
}
};
noexcept의 동작:
void func() noexcept {
throw std::runtime_error("에러"); // std::terminate 호출
}
// 개념적 동작
void func() {
try {
// 함수 본문
throw std::runtime_error("에러");
} catch (...) {
std::terminate(); // 모든 예외를 terminate로
}
}
noexcept vs throw():
| 특징 | noexcept (C++11) | throw() (C++98, deprecated) |
|---|---|---|
| 예외 시 | std::terminate | std::unexpected → std::terminate |
| 최적화 | ✅ 가능 | ❌ 제한적 |
| 조건부 | ✅ 가능 | ❌ 불가 |
| 권장 | ✅ 사용 | ❌ 사용 안함 |
// ❌ throw(): deprecated
void func() throw() {
// ...
}
// ✅ noexcept: 권장
void func() noexcept {
// ...
}
조건부 noexcept
템플릿 코드를 작성할 때는 타입 파라미터 T가 무엇이냐에 따라 특정 연산이 예외를 던질 수도, 던지지 않을 수도 있습니다. noexcept 뒤에 표현식을 괄호로 감싸면(noexcept(표현식)), 그 표현식의 참/거짓 값에 따라 함수의 예외 명세가 조건부로 결정됩니다. 아래 코드의 noexcept(noexcept(T(value)))는 언뜻 중복처럼 보이지만, 안쪽 noexcept(T(value))는 “T(value)가 예외를 던지지 않는가”를 검사하는 연산자이고, 바깥쪽 noexcept(...)는 그 결과를 함수의 예외 명세로 사용하는 지정자입니다. 이렇게 T의 성질을 그대로 이어받는 패턴은 표준 라이브러리 전반에서 널리 쓰입니다.
template<typename T>
void func(T value) noexcept(noexcept(T(value))) {
T copy(value);
}
실전 예시
아래 네 가지 예시는 이동 연산, swap, 소멸자, 조건부 noexcept라는 noexcept가 실무에서 가장 자주 붙는 위치를 보여줍니다.
예시 1: 이동 연산
Buffer는 원시 포인터로 힙 메모리를 직접 관리하는 클래스입니다. 이동 생성자와 이동 대입 연산자는 상대방의 포인터를 그대로 가져오고 원본은 nullptr로 만드는 단순한 포인터 교체이므로, 실패할 여지(메모리 할당 등)가 전혀 없습니다. 이렇게 “실패할 수 없는 연산”이라는 것이 확실하다면 noexcept를 붙이는 것이 옳으며, 이 표시 덕분에 이 Buffer를 담은 std::vector는 재할당 시 항상 효율적인 이동을 사용하게 됩니다.
class Buffer {
int* data;
size_t size;
public:
// ✅ noexcept 이동 생성자
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// ✅ noexcept 이동 대입
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};
예시 2: swap
swap은 두 객체의 내부 상태를 맞바꾸는 연산으로, 복사 후 스왑(copy-and-swap) 같은 예외 안전성 관용구의 근간이 됩니다. swap이 예외를 던지지 않는다는 보장이 있어야만 이 관용구가 실제로 “절대 실패하지 않는” 단계로 활용될 수 있기 때문에, 사용자 정의 swap은 관례적으로 noexcept로 선언합니다. 아래 코드처럼 멤버 swap을 만들고 std::swap의 특수화에서 그대로 호출하도록 연결해두면, ADL을 통해 표준 알고리즘들이 이 효율적인 버전을 자동으로 찾아 사용합니다.
class Widget {
public:
void swap(Widget& other) noexcept {
std::swap(data, other.data);
}
private:
int data;
};
// std::swap 특수화
namespace std {
template<>
void swap(Widget& a, Widget& b) noexcept {
a.swap(b);
}
}
예시 3: 소멸자
C++11부터 소멸자는 별도로 표시하지 않아도 컴파일러가 암시적으로 noexcept로 취급합니다. 이는 스택 되감기(unwinding) 도중 이미 하나의 예외가 전파되고 있을 때 소멸자가 또 다른 예외를 던지면, 프로그램이 어느 예외를 우선 처리해야 할지 알 수 없어 즉시 std::terminate가 호출되기 때문입니다. 아래 코드처럼 명시적으로 noexcept를 붙이는 것은 선택 사항이지만, 이 계약을 코드에 드러내 두면 리뷰어나 다른 개발자가 소멸자에서 예외를 던지는 코드를 실수로 추가하는 것을 막는 데 도움이 됩니다.
// 소멸자는 기본적으로 noexcept
class MyClass {
public:
~MyClass() noexcept { // 명시적 (선택적)
// 예외 던지면 std::terminate
}
};
예시 4: 조건부 noexcept
Container::push_back은 내부적으로 std::vector의 push_back을 그대로 호출하는데, 그 호출이 예외를 던지지 않는지 여부는 원소 타입 T의 이동 생성자가 noexcept인지에 달려 있습니다. noexcept(noexcept(data.push_back(std::move(value))))처럼 실제로 호출할 표현식을 그대로 noexcept 연산자에 넣어두면, T가 바뀔 때마다 이 함수의 예외 명세도 컴파일러가 자동으로 올바르게 계산해줍니다. 이렇게 “실제로 호출하는 코드를 그대로 검사식으로 재사용”하는 패턴은 조건부 noexcept를 정확하게 유지하는 안전한 방법입니다.
template<typename T>
class Container {
public:
void push_back(T&& value)
noexcept(noexcept(data.push_back(std::move(value)))) {
data.push_back(std::move(value));
}
private:
std::vector<T> data;
};
noexcept 연산자
noexcept는 함수 선언에 붙이는 지정자(specifier)뿐 아니라, 표현식을 검사하는 연산자로도 쓰입니다. noexcept(표현식)은 그 표현식이 예외를 던지지 않는다고 컴파일러가 정적으로 판단하는지를 컴파일 타임에 bool 값으로 알려주며, 실제로 표현식을 실행하지는 않으므로 런타임 비용이 전혀 없습니다. 이 연산자는 주로 static_assert로 특정 연산의 예외 안전성을 문서화하거나, 조건부 noexcept 지정자 안에서 다른 표현식의 성질을 재사용할 때 활용됩니다.
// noexcept(표현식): bool 반환
void func() noexcept {}
static_assert(noexcept(func())); // true
void func2() {}
static_assert(!noexcept(func2())); // false
자주 발생하는 문제
문제 1: 예외 던지기
noexcept로 선언된 함수 안에서 실제로 예외가 던져지면, 그 예외는 정상적으로 호출자에게 전파되지 않고 즉시 std::terminate가 호출되어 프로그램 전체가 종료됩니다. 이는 컴파일 에러가 아니라 런타임에만 드러나는 문제이기 때문에, noexcept 함수 내부에서 예외를 던질 수 있는 다른 함수를 호출한다면 반드시 try/catch로 감싸 예외가 함수 경계를 넘지 않도록 막아야 합니다.
// ❌ noexcept에서 예외
void func() noexcept {
throw std::runtime_error("에러"); // std::terminate
}
// ✅ try-catch
void func() noexcept {
try {
riskyOperation();
} catch (...) {
// 예외 처리
}
}
문제 2: std::vector 최적화
std::vector는 용량이 부족해 재할당할 때, 기존 원소를 새 메모리로 옮기다가 도중에 예외가 발생하면 벡터가 일관성 없는 상태로 남을 위험을 피하기 위해 강한 예외 안전성을 지킵니다. 이 보장을 지키기 위해 이동 생성자가 noexcept임이 확인될 때만 실제로 이동을 사용하고, 그렇지 않으면 안전하지만 느린 복사로 대체해버립니다. 이동 생성자에 noexcept를 빠뜨리는 것은 컴파일 에러 없이 조용히 성능만 저하시키는, 발견하기 까다로운 실수 중 하나입니다.
class Widget {
public:
// ❌ noexcept 없음
Widget(Widget&&) {
// vector 재할당 시 복사 사용
}
// ✅ noexcept 추가
Widget(Widget&&) noexcept {
// vector 재할당 시 이동 사용
}
};
문제 3: 조건부 noexcept
제네릭 래퍼 클래스를 만들 때 이동 생성자의 예외 명세를 무조건 noexcept로 고정해버리면, 내부에 담긴 타입 T의 이동 생성자가 실제로는 예외를 던질 수 있는 경우에 거짓 계약을 하게 되어 위험합니다. std::is_nothrow_move_constructible_v<T> 같은 타입 특성(type trait)을 조건으로 넘기면, T가 무엇이냐에 따라 이 래퍼의 noexcept 여부가 정확하게 결정됩니다. 표준 라이브러리 컨테이너들도 이런 조건부 noexcept를 광범위하게 사용해 원소 타입의 성질을 그대로 이어받습니다.
template<typename T>
class Wrapper {
public:
// T의 이동 생성자가 noexcept면 noexcept
Wrapper(Wrapper&& other)
noexcept(std::is_nothrow_move_constructible_v<T>)
: value(std::move(other.value)) {}
private:
T value;
};
문제 4: 소멸자
소멸자를 명시적으로 noexcept(false)로 선언해서 예외를 던지도록 만드는 것은 문법적으로는 가능하지만 실무에서는 거의 항상 피해야 하는 안티패턴입니다. 소멸자는 객체가 스코프를 벗어나거나 컨테이너가 소멸할 때 자동으로 호출되는데, 이미 다른 예외가 전파되고 있는 도중(스택 되감기 중) 소멸자가 또 예외를 던지면 두 예외가 충돌해 std::terminate가 호출됩니다. 소멸자 안에서 실패할 수 있는 작업이 있다면, 아래 Bad 클래스처럼 그대로 던지지 말고 내부에서 try/catch로 처리해 소멸자 밖으로는 절대 예외가 나가지 않도록 해야 합니다.
// 소멸자는 암시적 noexcept
class MyClass {
public:
~MyClass() {
// 예외 던지면 std::terminate
}
};
// 명시적 noexcept(false) (권장 안함)
class Bad {
public:
~Bad() noexcept(false) {
throw std::runtime_error("에러");
}
};
최적화
noexcept 함수는 예외가 전파될 가능성을 배제하므로, 컴파일러는 그 함수 호출 지점에 스택 되감기(unwinding)를 위한 예외 처리 테이블이나 정리 코드를 생성하지 않아도 됩니다. 이는 코드 크기를 줄이고 일부 경우 실행 속도도 개선하지만, noexcept의 진짜 가치는 이런 미시적인 컴파일러 최적화보다는 std::vector처럼 STL 컨테이너가 이동 연산 사용 여부를 결정하는 거시적인 알고리즘 선택에 있습니다. 아래처럼 push_back이 벡터 재할당을 유발할 때, 원소 타입의 이동 생성자가 noexcept인지가 실제 성능을 몇 배씩 좌우할 수 있습니다.
// noexcept는 최적화 가능
void func() noexcept {
// 컴파일러가 스택 되감기 코드 생략 가능
}
// std::vector
std::vector<Widget> vec;
vec.push_back(Widget()); // noexcept 이동이면 이동 사용
실무 패턴
아래 세 가지 패턴은 RAII 리소스 래퍼, 예외 안전한 swap, 제네릭 래퍼 클래스라는 실무에서 noexcept가 실제로 적용되는 대표적인 위치를 보여줍니다.
패턴 1: RAII 래퍼
FileHandle은 생성자에서 파일을 열 때는 실패할 수 있으므로(throw) 예외를 허용하지만, 소멸자에서 파일을 닫는 fclose는 실패해도 프로그램을 계속 진행하는 것 외에 달리 할 수 있는 일이 없으므로 noexcept로 선언되어 있습니다. 이동 생성자 역시 포인터만 교체하는 연산이라 실패할 여지가 없어 noexcept이고, 복사는 아예 삭제해 하나의 파일 핸들이 두 곳에서 동시에 소유되는 상황을 원천 차단합니다. 이런 조합은 파일, 소켓, 뮤텍스 락처럼 “명확한 단일 소유자와 확실한 해제 시점”이 필요한 리소스 래퍼의 표준적인 설계 패턴입니다.
class FileHandle {
FILE* file_;
public:
FileHandle(const char* path) : file_(fopen(path, "r")) {
if (!file_) {
throw std::runtime_error("파일 열기 실패");
}
}
// noexcept 소멸자
~FileHandle() noexcept {
if (file_) {
fclose(file_); // 예외 안던짐
}
}
// noexcept 이동
FileHandle(FileHandle&& other) noexcept
: file_(other.file_) {
other.file_ = nullptr;
}
// 복사 금지
FileHandle(const FileHandle&) = delete;
};
패턴 2: 예외 안전 swap
이 Buffer는 내부 상태를 std::vector<char> 하나에 위임하고 있으므로, 자신의 swap을 구현할 때 vector::swap(표준이 noexcept를 보장)을 그대로 호출하면 됩니다. 멤버 함수 swap뿐 아니라 같은 이름의 프렌드 자유 함수도 함께 정의해두면, std::swap(b1, b2)처럼 표준 방식으로 호출했을 때도 ADL을 통해 이 효율적인 버전이 우선 선택됩니다. 이런 계층적 swap 위임 패턴은 복합 객체의 swap을 예외 안전하게 구현하는 표준적인 방법입니다.
class Buffer {
std::vector<char> data_;
public:
// noexcept swap
void swap(Buffer& other) noexcept {
data_.swap(other.data_); // vector::swap은 noexcept
}
friend void swap(Buffer& a, Buffer& b) noexcept {
a.swap(b);
}
};
// 사용
Buffer b1, b2;
swap(b1, b2); // 예외 없음 보장
패턴 3: 조건부 noexcept 템플릿
제네릭 라이브러리 코드를 작성할 때는 이 클래스가 어떤 타입 T로 인스턴스화될지 미리 알 수 없으므로, 이동 생성자나 swap 같은 연산의 예외 명세를 T의 실제 성질에 맞춰 조건부로 계산해야 합니다. 이 Wrapper는 이동 생성자에서 std::is_nothrow_move_constructible_v<T>를, swap에서는 noexcept(std::swap(value_, other.value_))를 조건으로 사용해, T가 std::string처럼 noexcept 연산을 제공하는 타입이면 Wrapper 자신도 자동으로 noexcept가 되도록 만듭니다. 이렇게 타입 특성을 그대로 전파하는 설계는 표준 라이브러리 컨테이너와 동일한 원칙을 따르는 것입니다.
template<typename T>
class Wrapper {
T value_;
public:
// T의 이동 생성자가 noexcept면 noexcept
Wrapper(Wrapper&& other)
noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(other.value_)) {}
// T의 swap이 noexcept면 noexcept
void swap(Wrapper& other)
noexcept(noexcept(std::swap(value_, other.value_))) {
using std::swap;
swap(value_, other.value_);
}
};
// 사용
Wrapper<std::string> w1, w2;
static_assert(noexcept(w1.swap(w2))); // string::swap은 noexcept
FAQ
Q1: noexcept는 언제 사용하나요?
A:
- 이동 연산: 이동 생성자, 이동 대입 연산자
- swap: 스왑 함수
- 소멸자: 항상 noexcept (암시적)
- 예외 없는 함수: 예외를 던지지 않는 함수
class MyClass {
public:
MyClass(MyClass&&) noexcept; // 이동 생성자
MyClass& operator=(MyClass&&) noexcept; // 이동 대입
void swap(MyClass&) noexcept; // swap
~MyClass() noexcept; // 소멸자 (암시적)
};
Q2: noexcept의 성능 이점은?
A:
- 최적화: 컴파일러가 스택 되감기 코드 생략
- STL 최적화:
std::vector가 noexcept 이동 연산자 사용
class Widget {
public:
// ❌ noexcept 없음
Widget(Widget&& other) {
// std::vector는 복사 사용 (안전)
}
};
std::vector<Widget> vec;
vec.reserve(100); // 복사로 재할당
class Widget {
public:
// ✅ noexcept 추가
Widget(Widget&& other) noexcept {
// std::vector는 이동 사용 (빠름)
}
};
std::vector<Widget> vec;
vec.reserve(100); // 이동으로 재할당
Q3: noexcept 위반 시 어떻게 되나요?
A: std::terminate가 호출되어 프로그램이 종료됩니다.
void func() noexcept {
throw std::runtime_error("에러"); // std::terminate
}
int main() {
func(); // 프로그램 종료
}
Q4: 조건부 noexcept는 어떻게 사용하나요?
A: noexcept(조건) 을 사용합니다. 조건이 true면 noexcept, false면 예외 가능합니다.
template<typename T>
void func(T value) noexcept(noexcept(T(value))) {
T copy(value); // T의 복사 생성자가 noexcept면 noexcept
}
// 또는 type_traits 사용
template<typename T>
void func2(T value) noexcept(std::is_nothrow_copy_constructible_v<T>) {
T copy(value);
}
Q5: 소멸자는 항상 noexcept인가요?
A: 기본적으로 noexcept입니다. 명시적으로 noexcept(false)를 지정하지 않는 한 noexcept입니다.
class MyClass {
public:
~MyClass() { // 암시적 noexcept
// 예외 던지면 std::terminate
}
};
// 명시적 noexcept(false) (권장 안함)
class Bad {
public:
~Bad() noexcept(false) {
throw std::runtime_error("에러");
}
};
Q6: noexcept 연산자는 무엇인가요?
A: 표현식이 noexcept인지 확인하는 연산자입니다. 컴파일 타임에 bool 값을 반환합니다.
void func() noexcept {}
void func2() {}
static_assert(noexcept(func())); // true
static_assert(!noexcept(func2())); // false
// 조건부 noexcept에서 사용
template<typename T>
void wrapper(T value) noexcept(noexcept(T(value))) {
T copy(value);
}
Q7: noexcept는 함수 타입의 일부인가요?
A: C++17부터 함수 타입의 일부입니다. C++14까지는 아니었습니다.
// C++17:
void (*ptr1)() noexcept = func; // OK
void (*ptr2)() = func; // 에러 (타입 불일치)
// 함수 포인터 타입이 다름
using FuncNoexcept = void(*)() noexcept;
using Func = void(*)();
Q8: noexcept 학습 리소스는?
A:
- “Effective Modern C++” by Scott Meyers (Item 14)
- “C++ Concurrency in Action” by Anthony Williams
- cppreference.com - noexcept
관련 글: exception-handling, move-semantics, exception-safety.
한 줄 요약: noexcept는 함수가 예외를 던지지 않음을 명시하여 최적화를 가능하게 하는 C++11 키워드입니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ noexcept | “예외 명세” 가이드
- C++ Exception Performance | “예외 성능” 가이드
- C++ noexcept 완벽 가이드 | 예외 계약·이동 최적화·프로덕션 패턴 [#42-1]
관련 글
- C++ noexcept 지정자
- C++ Exception Performance
- C++ noexcept 완벽 가이드 | 예외 계약·이동 최적화·프로덕션 패턴 [#42-1]
- C++ async & launch
- C++ Atomic Operations
이 글에서 다루는 키워드 (관련 검색어)
C++, noexcept, exception, C++11, optimization 등으로 검색하시면 이 글이 도움이 됩니다.