C++ noexcept 완벽 가이드 | 예외 계약·이동 최적화·프로덕션 패턴 [#42-1]
이 글의 핵심
C++ noexcept 완벽 가이드에 대한 실전 가이드입니다. 예외 계약·이동 최적화·프로덕션 패턴 [#42-1] 등을 예제와 함께 상세히 설명합니다.
들어가며: vector 재할당이 왜 이렇게 느릴까?
”커스텀 타입을 vector에 넣었는데 resize가 너무 느려요”
std::vector에 원소를 많이 넣으면 내부 버퍼가 부족해 재할당이 일어납니다. 이때 기존 원소들을 새 버퍼로 옮기는 방식이 이동과 복사 두 가지인데, 이동 생성자에 noexcept가 없으면 표준 라이브러리는 안전을 위해 복사를 선택합니다. 그 결과, 10만 개 원소를 가진 std::vector<MyBuffer>에서 push_back이 재할당을 유발할 때마다 복사가 일어나 수백 ms가 걸릴 수 있습니다.
비유하면: “이사할 때 가구를 통째로 옮기는 것(이동)“과 “가구를 하나씩 복제해서 새 집에 놓는 것(복사)“의 차이입니다. 이동은 포인터만 바꾸면 되므로 O(1)에 가깝고, 복사는 원소 수만큼 비용이 듭니다. noexcept는 “이동 중 예외가 나지 않는다”는 계약이라, 표준 라이브러리가 이동을 선택할 수 있게 합니다.
flowchart TD
subgraph realloc["vector 재할당"]
A[기존 버퍼] --> B{이동 생성자 noexcept?}
B -->|예| C[이동 사용 - 빠름]
B -->|아니오| D[복사 사용 - 느림]
C --> E[새 버퍼]
D --> E
end
이 글에서 다루는 것:
- noexcept 지정자: 함수가 예외를 던지지 않음을 선언
- noexcept 연산자: 표현식이 noexcept인지 컴파일 타임에 검사
- 이동 연산과 noexcept: std::vector가 이동을 선택하는 조건
- 조건부 noexcept: 템플릿에서
noexcept(std::is_nothrow_move_constructible_v<T>)패턴 - 예외 안전성: nothrow, strong, basic 보장과 noexcept의 관계
- 자주 발생하는 에러와 해결법
- 프로덕션 패턴과 베스트 프랙티스
문제 시나리오: noexcept 없을 때 겪는 일
시나리오 1: vector::resize 시 복사만 선택되어 느려짐
문제: 커스텀 Buffer 클래스를 std::vector<Buffer>에 넣었는데, resize(100000) 호출 시 수 초가 걸립니다. 프로파일러로 확인해 보니 재할당 시 복사 생성자가 호출되고 있습니다.
원인: Buffer의 이동 생성자에 noexcept가 없어, 표준 라이브러리가 “이동 중 예외가 나면 복구 불가”를 우려해 복사를 선택했습니다.
해결: 이동 생성자·이동 대입에 noexcept를 붙이면 std::vector가 이동을 사용해 성능이 크게 향상됩니다.
시나리오 2: std::sort에서 swap이 느림
문제: std::sort로 커스텀 타입 벡터를 정렬할 때, swap이 예외를 던질 수 있다고 가정해 내부적으로 복사 기반 폴백을 사용해 느립니다.
해결: swap을 noexcept로 선언하면 std::sort 등 제네릭 알고리즘이 더 효율적인 구현을 선택합니다.
시나리오 3: RAII 소멸자에서 예외가 나면 terminate
문제: 파일 핸들을 관리하는 RAII 클래스의 소멸자에서 close()가 실패해 예외를 던지면, 스택 언와인딩 중 “예외 처리 중 또 예외”가 되어 std::terminate가 호출됩니다.
해결: 소멸자는 절대 예외를 던지지 않도록 설계하고, noexcept로 명시합니다. 실패 시 로깅만 하거나 std::abort로 조기 종료합니다.
시나리오 4: Optional 이동 시 T가 예외를 던질 수 있음
문제: template<typename T> class Optional의 이동 생성자를 noexcept로 선언했는데, T의 이동 생성자가 예외를 던지면 계약 위반으로 std::terminate가 호출됩니다.
해결: 조건부 noexcept를 사용해 noexcept(std::is_nothrow_move_constructible_v<T>)로 “T가 noexcept이면 이 함수도 noexcept”라고 선언합니다.
시나리오 5: 라이브러리 API 계약 불명확
문제: 외부 라이브러리의 process() 함수가 예외를 던지는지 문서에 없어, 호출자가 try-catch를 쓸지 말지 결정하기 어렵습니다.
해결: API 설계 시 noexcept를 명시하면 “이 함수는 예외를 던지지 않는다”는 계약이 되어, 호출자가 예외 처리 전략을 세우기 쉽습니다.
시나리오 6: std::deque·std::list 재할당 시 동일 문제
문제: std::deque나 std::list에 커스텀 타입을 넣었을 때도, 내부 노드 이동·재배치 시 noexcept 여부에 따라 성능이 달라집니다. std::deque는 연속 메모리 블록을 사용하므로 vector와 유사한 재할당 로직이 적용됩니다.
해결: vector와 마찬가지로 이동 생성자·이동 대입에 noexcept를 붙이면 표준 컨테이너 전반에서 최적화가 적용됩니다.
시나리오 7: emplace_back과 이동
문제: vec.emplace_back(args...)로 원소를 추가할 때, 재할당이 필요하면 기존 원소들을 새 버퍼로 옮깁니다. 이때 이동 생성자가 noexcept가 아니면 복사가 사용되어, emplace의 “생성자 직접 호출” 이점이 재할당 시점에서 사라집니다.
해결: 이동 연산에 noexcept를 붙여 재할당 시에도 이동이 사용되도록 합니다.
목차
- noexcept 지정자
- noexcept 연산자
- 이동 연산과 noexcept
- 조건부 noexcept
- 예외 안전성과 noexcept
- 완전한 noexcept 예제
- 자주 발생하는 에러와 해결법
- 베스트 프랙티스
- 프로덕션 패턴
- 성능 벤치마크
- 정리
1. noexcept 지정자
기본 문법
noexcept 지정자는 “이 함수는 예외를 던지지 않는다”는 계약을 선언합니다. 예외를 던지면 std::terminate가 호출되므로, 정말로 예외를 던지지 않을 때만 사용해야 합니다.
// 기본 형태: 예외를 던지지 않음
void process() noexcept;
// noexcept(false): 예외를 던질 수 있음 (명시적)
void mayThrow() noexcept(false);
// 생략: noexcept(false)와 동일 (예외 가능)
void unspecified();
주의: noexcept를 붙인 함수에서 예외를 던지면 프로그램이 즉시 std::terminate로 종료됩니다. “예외를 던지지 않도록 설계된” 함수에만 사용하세요.
적용 대상
| 대상 | noexcept 권장 | 이유 |
|---|---|---|
| 소멸자 | 필수 | 스택 언와인딩 중 예외 시 terminate |
| 이동 생성자 | 필수 | vector 등에서 이동 선택 |
| 이동 대입 | 필수 | vector 등에서 이동 선택 |
| swap | 권장 | sort 등 알고리즘 최적화 |
| 기본 연산 (포인터 스왑 등) | 권장 | 예외 없음이 보장될 때 |
소멸자와 noexcept
소멸자에서 예외를 던지면 스택 언와인딩 중 “예외 처리 중 또 예외”가 되어 std::terminate가 호출됩니다. 소멸자는 절대 예외를 던지지 않도록 설계하고, noexcept로 명시합니다.
class FileHandle {
FILE* file_;
public:
~FileHandle() noexcept {
if (file_) {
std::fclose(file_); // 실패해도 예외 던지지 않음
file_ = nullptr;
}
}
};
실패 시 처리: fclose 실패 시 로깅만 하거나, std::abort로 조기 종료하는 정책을 선택합니다. 예외를 던지지는 않습니다.
swap과 noexcept
swap은 보통 포인터·핸들만 교환하므로 예외를 던지지 않습니다. noexcept를 붙이면 std::sort, std::rotate 등에서 더 효율적인 구현이 선택될 수 있습니다.
class Buffer {
char* data_;
size_t size_;
public:
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
};
void swap(Buffer& a, Buffer& b) noexcept {
a.swap(b);
}
2. noexcept 연산자
noexcept(expr) — 컴파일 타임 검사
noexcept 연산자 noexcept(expr)는 표현식 expr이 예외를 던질 수 있는지를 컴파일 타임에 판단합니다. true이면 noexcept, false이면 예외를 던질 수 있습니다.
// expr이 예외를 던지지 않으면 true
static_assert(noexcept(std::move(std::declval<int>())));
// std::string의 이동 생성자가 noexcept인지 확인
static_assert(noexcept(std::string(std::declval<std::string&&>())));
조건부 noexcept 지정자와 함께 사용
조건부 noexcept에서 noexcept(expr) 형태로 “이 함수가 noexcept인 조건”을 표현합니다.
template<typename T>
void swap(T& a, T& b) noexcept(
noexcept(a.swap(b)) // a.swap(b)가 noexcept이면 이 함수도 noexcept
);
type traits와 조합
std::is_nothrow_move_constructible_v<T> 등 type traits와 함께 사용해 템플릿에서 조건부 noexcept를 표현합니다.
#include <type_traits>
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_)) {}
};
3. 이동 연산과 noexcept
std::vector의 재할당 전략
std::vector는 재할당 시 기존 원소를 새 버퍼로 옮길 때 다음 규칙을 따릅니다:
- 이동 생성자가
noexcept→ 이동 사용 (빠름) - 이동 생성자가
noexcept아님 → 복사 사용 (안전하지만 느림)
이유: 이동 중 예외가 나면 “일부만 옮겨진” 상태가 되어 복구하기 어렵습니다. 복사는 예외 발생 시 원본을 유지할 수 있어 strong exception safety를 보장합니다.
완전한 이동 연산 예제
#include <utility>
#include <cstddef>
class Buffer {
char* data_;
size_t size_;
public:
Buffer(size_t n) : data_(new char[n]), size_(n) {}
// 이동 생성자: noexcept 필수
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0)) {}
// 이동 대입: noexcept 필수, 자기 대입 검사
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
}
return *this;
}
~Buffer() noexcept {
delete[] data_;
data_ = nullptr;
}
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
};
핵심:
std::exchange로 원본을 안전하게 비움if (this != &other)로 자기 대입 방지- 소멸자에서
nullptr체크 후 해제
std::move_if_noexcept
표준 라이브러리 내부에서는 std::move_if_noexcept를 사용해 “이동이 noexcept이면 이동, 아니면 복사”를 선택합니다.
// 개념적 동작
template<typename T>
T move_if_noexcept(T& x) {
if constexpr (std::is_nothrow_move_constructible_v<T>) {
return std::move(x);
} else {
return x; // 복사
}
}
std::vector 재할당 시 이와 유사한 로직으로 요소를 옮깁니다.
4. 조건부 noexcept
템플릿에서의 조건부 선언
템플릿 클래스에서 “T의 이동이 noexcept이면 이 함수도 noexcept”라고 선언할 때 조건부 noexcept를 사용합니다.
template<typename T>
class Optional {
alignas(T) unsigned char storage_[sizeof(T)];
bool has_value_;
public:
Optional(Optional&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: has_value_(other.has_value_) {
if (has_value_) {
new (&storage_) T(std::move(*other.get()));
other.has_value_ = false;
}
}
T* get() { return std::launder(reinterpret_cast<T*>(storage_)); }
const T* get() const { return std::launder(reinterpret_cast<const T*>(storage_)); }
};
swap의 조건부 noexcept
template<typename T>
void swap(T& a, T& b) noexcept(
std::is_nothrow_move_constructible_v<T> &&
std::is_nothrow_move_assignable_v<T>
) {
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
또는 멤버 swap이 있으면:
template<typename T>
void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) {
a.swap(b);
}
복합 조건
여러 조건을 &&로 연결해 “모든 하위 연산이 noexcept일 때만” 선언할 수 있습니다.
template<typename T>
class Container {
T* data_;
size_t size_;
public:
Container(Container&& other) noexcept(
std::is_nothrow_move_constructible_v<T> &&
std::is_nothrow_destructible_v<T>
)
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0)) {}
};
5. 예외 안전성과 noexcept
예외 안전성 보장 수준
C++에서 예외 안전성(exception safety)은 연산이 예외를 던질 때 객체·프로그램 상태가 어떻게 유지되는지를 나타냅니다. noexcept는 nothrow 보장의 핵심입니다.
flowchart TB
subgraph levels["예외 안전성 수준"]
N[nothrow: 예외 없음]
S[strong: 롤백 가능]
B[basic: 유효 상태 유지]
X[none: 정의되지 않음]
end
N -->|noexcept 선언| N
S -->|swap noexcept| S
B -->|최소 보장| B
| 보장 수준 | 설명 | noexcept와의 관계 |
|---|---|---|
| nothrow | 예외를 절대 던지지 않음 | noexcept로 선언 |
| strong | 예외 시 연산 전 상태로 롤백 | 내부 연산이 nothrow이면 달성 가능 |
| basic | 예외 시 객체는 유효하지만 변경될 수 있음 | 최소 보장 |
| none | 예외 시 정의되지 않은 동작 | 피해야 함 |
nothrow 보장이 중요한 이유
std::vector 재할당 시 strong exception safety를 지키려면: “이동 중 예외가 나면 원본이 그대로 유지되어야 한다”는 조건이 필요합니다. 그런데 이동은 원본을 “빈 상태”로 만들기 때문에, 이동 중 예외가 나면 원본을 복구할 수 없습니다. 따라서 표준 라이브러리는 이동 생성자가 nothrow일 때만 이동을 사용합니다.
// vector 재할당 시 내부 로직 (개념적)
// 이동이 noexcept가 아니면 → 복사 사용 (strong 보장)
// 이동이 noexcept이면 → 이동 사용 (nothrow 보장, 성능 우수)
copy-and-swap과 strong exception safety
복사 대입에서 copy-and-swap 패턴을 쓸 때, swap이 noexcept이면 전체 연산의 예외 안전성이 명확해집니다.
#include <utility>
#include <vector>
class Data {
std::vector<int> vec_;
public:
// swap이 noexcept → 복사 대입도 strong 보장 가능
Data& operator=(Data other) {
swap(other); // swap이 noexcept이므로 여기서 예외 없음
return *this;
}
void swap(Data& other) noexcept {
vec_.swap(other.vec_); // vector::swap은 noexcept
}
};
핵심: vec_.swap()은 noexcept이므로, swap(other) 호출 자체는 예외를 던지지 않습니다. 복사 생성(Data other)에서 예외가 나면 operator= 진입 전이므로 호출자 객체는 변경되지 않습니다.
이동 연산과 nothrow
이동 연산은 원본을 훼손하므로, 예외가 나면 strong 보장을 할 수 없습니다. 그래서 이동 연산은 nothrow로 설계하는 것이 표준입니다.
// ✅ 올바른 설계: 이동은 nothrow
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0)) {}
// ❌ 잘못된 설계: 이동이 예외를 던지면 vector가 복사로 폴백
Buffer(Buffer&& other) // noexcept 없음
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 이 시점에서 예외 나면 원본 복구 불가
}
예외 안전성 체크리스트
- 소멸자: nothrow (예외 던지면
std::terminate) - 이동 연산: nothrow (vector 등 최적화 + 안전성)
- swap: nothrow (알고리즘 최적화)
- 복사 대입: strong (copy-and-swap + nothrow swap)
6. 완전한 noexcept 예제
예제 1: RAII 리소스 클래스
#include <utility>
#include <cstdio>
class FileHandle {
std::FILE* file_;
public:
explicit FileHandle(const char* path)
: file_(std::fopen(path, "r")) {
if (!file_) throw std::runtime_error("fopen failed");
}
~FileHandle() noexcept {
if (file_) {
std::fclose(file_);
file_ = nullptr;
}
}
FileHandle(FileHandle&& other) noexcept
: file_(std::exchange(other.file_, nullptr)) {}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (file_) std::fclose(file_);
file_ = std::exchange(other.file_, nullptr);
}
return *this;
}
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
[[nodiscard]] bool isValid() const noexcept {
return file_ != nullptr;
}
};
예제 2: 스마트 버퍼 (동적 배열)
#include <utility>
#include <algorithm>
#include <cstddef>
class DynamicBuffer {
char* data_;
size_t size_;
size_t capacity_;
public:
explicit DynamicBuffer(size_t cap = 0)
: data_(cap ? new char[cap] : nullptr)
, size_(0)
, capacity_(cap) {}
DynamicBuffer(DynamicBuffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0))
, capacity_(std::exchange(other.capacity_, 0)) {}
DynamicBuffer& operator=(DynamicBuffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
capacity_ = std::exchange(other.capacity_, 0);
}
return *this;
}
~DynamicBuffer() noexcept {
delete[] data_;
data_ = nullptr;
}
void swap(DynamicBuffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
std::swap(capacity_, other.capacity_);
}
[[nodiscard]] size_t size() const noexcept { return size_; }
[[nodiscard]] size_t capacity() const noexcept { return capacity_; }
};
예제 3: 템플릿 Optional (조건부 noexcept)
#include <utility>
#include <type_traits>
#include <new>
template<typename T>
class Optional {
union {
T value_;
};
bool has_value_;
public:
Optional() noexcept : has_value_(false) {}
Optional(T&& val) noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(val))
, has_value_(true) {}
Optional(Optional&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: has_value_(other.has_value_) {
if (has_value_) {
new (&value_) T(std::move(other.value_));
other.has_value_ = false;
}
}
~Optional() noexcept(std::is_nothrow_destructible_v<T>) {
if (has_value_) value_.~T();
}
[[nodiscard]] bool hasValue() const noexcept { return has_value_; }
T& get() & { return value_; }
const T& get() const& { return value_; }
T&& get() && { return std::move(value_); }
};
예제 4: noexcept 연산자를 활용한 SFINAE
#include <type_traits>
#include <utility>
// T가 nothrow 이동 가능할 때만 이 함수가 noexcept
template<typename T>
void process(T&& obj) noexcept(noexcept(std::move(obj))) {
auto moved = std::move(obj);
}
// noexcept 연산자로 컴파일 타임 검사
template<typename T>
constexpr bool is_nothrow_swappable = noexcept(
std::swap(std::declval<T&>(), std::declval<T&>())
);
static_assert(is_nothrow_swappable<int>);
static_assert(noexcept(std::move(std::declval<int>())));
7. 자주 발생하는 에러와 해결법
에러 1: noexcept 함수에서 예외 던짐 → terminate
증상: noexcept 함수에서 예외를 던지면 프로그램이 즉시 std::terminate로 종료됩니다.
원인: noexcept는 “예외를 던지지 않는다”는 계약입니다. 위반 시 복구 불가.
// ❌ 잘못된 코드
void process() noexcept {
throw std::runtime_error("error"); // std::terminate 호출!
}
해결:
// ✅ 올바른 코드: 예외를 던질 수 있으면 noexcept 제거
void process() {
throw std::runtime_error("error");
}
// 또는 내부에서 예외를 잡아서 처리
void process() noexcept {
try {
doWork();
} catch (...) {
logError();
std::abort(); // 또는 다른 정책
}
}
에러 2: 이동 생성자에 noexcept 누락 → vector가 복사 사용
증상: std::vector<MyClass>에서 push_back·resize 시 성능이 매우 느립니다.
원인: 이동 생성자에 noexcept가 없어 표준 라이브러리가 복사를 선택했습니다.
// ❌ 잘못된 코드
class MyBuffer {
public:
MyBuffer(MyBuffer&& other) // noexcept 없음
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
}
};
// std::vector<MyBuffer> 재할당 시 복사 사용 → 느림
해결:
// ✅ 올바른 코드
class MyBuffer {
public:
MyBuffer(MyBuffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0)) {}
private:
char* data_;
size_t size_;
};
에러 3: 조건부 noexcept에서 T의 계약 위반
증상: Optional<std::string> 이동 시 std::terminate가 호출됩니다.
원인: Optional이 noexcept로 선언했는데, std::string의 이동 생성자가 (이론적으로) 예외를 던질 수 있는 경로가 있거나, 조건이 잘못 선언되었습니다.
// ❌ 잘못된 코드: T가 예외를 던질 수 있는데 무조건 noexcept
template<typename T>
class BadOptional {
public:
BadOptional(BadOptional&& other) noexcept // T와 무관하게 noexcept
: value_(std::move(other.value_)) {} // T가 예외 던지면 terminate
};
해결:
// ✅ 올바른 코드: 조건부 noexcept
template<typename T>
class GoodOptional {
public:
GoodOptional(GoodOptional&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(other.value_)) {}
};
에러 4: 소멸자에서 예외 던짐
증상: 스택 언와인딩 중 소멸자가 예외를 던지면 std::terminate가 호출됩니다.
원인: 소멸자는 예외를 던지면 안 됩니다. C++ 표준이 명시적으로 금지합니다.
// ❌ 잘못된 코드
~Resource() {
if (cleanupFailed()) throw std::runtime_error("cleanup failed");
}
해결:
// ✅ 올바른 코드
~Resource() noexcept {
if (!cleanup()) {
logError("cleanup failed");
// 예외 던지지 않음
}
}
에러 5: noexcept(false)를 잘못된 곳에 사용
증상: noexcept(false)를 소멸자에 붙이면 컴파일 에러가 발생할 수 있습니다 (C++11~17). C++20부터는 소멸자에 noexcept(false)를 명시할 수 있지만, 권장하지 않습니다.
해결: 소멸자는 항상 noexcept(또는 생략, 기본적으로 noexcept)로 두고, 예외를 던지지 않도록 설계합니다.
에러 6: 이동 대입에서 자기 대입 검사 누락
증상: x = std::move(x); 시 리소스가 해제된 뒤 다시 접근해 크래시 또는 정의되지 않은 동작.
원인: if (this != &other) 검사 누락.
// ❌ 잘못된 코드
Buffer& operator=(Buffer&& other) noexcept {
delete[] data_;
data_ = other.data_;
other.data_ = nullptr;
return *this;
}
// x = std::move(x) 시 data_가 먼저 해제되고, other.data_도 같은 포인터
해결:
// ✅ 올바른 코드
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
}
return *this;
}
에러 7: 가상 함수 오버라이드와 noexcept 불일치
증상: 기본 클래스 가상 함수가 noexcept인데 파생 클래스 오버라이드에 noexcept가 없으면 C++17+에서 컴파일 에러 가능.
해결: 파생 클래스 오버라이드에도 noexcept를 유지합니다.
에러 8: 복사 생성자에 noexcept 붙인 경우
증상: 복사 생성자에 noexcept를 붙였는데, 복사가 메모리 할당 등 예외를 던질 수 있는 연산을 수행하면 std::terminate 위험이 있습니다.
// ❌ 잘못된 코드: 복사는 메모리 할당으로 예외 가능
class Buffer {
Buffer(const Buffer& other) noexcept // 위험!
: data_(new char[other.size_])
, size_(other.size_) {
std::memcpy(data_, other.data_, size_);
}
};
해결:
// ✅ 올바른 코드: 복사는 예외를 던질 수 있으므로 noexcept 제거
class Buffer {
Buffer(const Buffer& other) // new가 bad_alloc 던질 수 있음
: data_(new char[other.size_])
, size_(other.size_) {
std::memcpy(data_, other.data_, size_);
}
};
원칙: 이동은 포인터/핸들만 바꾸므로 nothrow가 가능하지만, 복사는 메모리 할당 등으로 예외를 던질 수 있어 noexcept를 붙이지 않는 것이 일반적입니다.
8. 베스트 프랙티스
1. 소멸자는 항상 noexcept
소멸자에서 예외를 던지지 않도록 설계하고, noexcept로 명시합니다. 실패 시 로깅·abort 등으로 처리합니다.
2. 이동 연산에는 반드시 noexcept
std::vector, std::deque 등이 재할당 시 이동을 사용하려면 이동 생성자·이동 대입이 noexcept여야 합니다.
3. swap은 noexcept
표준 관례에 따라 swap은 예외를 던지지 않습니다. 포인터·핸들만 교환하도록 구현하면 됩니다.
4. 템플릿은 조건부 noexcept
template<typename T>에서 T의 이동이 noexcept인지에 따라 이 클래스의 이동도 noexcept로 선언합니다.
5. 확신 없으면 noexcept 붙이지 않기
예외를 던질 수 있는 함수에 noexcept를 붙이면 std::terminate 위험이 있습니다. 확신할 때만 사용합니다.
6. std::exchange 활용
이동 연산에서 std::exchange를 사용하면 원본을 안전하게 비우고, 코드가 간결해집니다.
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0)) {}
7. 복사 생성자에는 noexcept 붙이지 않기
복사 생성자는 메모리 할당(new, malloc) 등 예외를 던질 수 있는 연산을 수행하는 경우가 많습니다. 이동과 달리 복사에 noexcept를 붙이면 std::terminate 위험이 있으므로, 확실히 nothrow인 경우가 아니면 붙이지 않습니다.
체크리스트
- 소멸자에
noexcept - 이동 생성자에
noexcept - 이동 대입에
noexcept+ 자기 대입 검사 -
swap에noexcept - 템플릿은 조건부
noexcept(expr) - 예외를 던질 수 있는 함수에는
noexcept붙이지 않기 - 복사 생성자에는 일반적으로
noexcept붙이지 않기
9. 프로덕션 패턴
패턴 1: RAII 래퍼 표준 형태
class ResourceHandle {
public:
explicit ResourceHandle(/* ... */);
~ResourceHandle() noexcept;
ResourceHandle(ResourceHandle&& other) noexcept;
ResourceHandle& operator=(ResourceHandle&& other) noexcept;
ResourceHandle(const ResourceHandle&) = delete;
ResourceHandle& operator=(const ResourceHandle&) = delete;
void swap(ResourceHandle& other) noexcept;
};
패턴 2: copy-and-swap과 noexcept
복사 대입에서 copy-and-swap을 쓸 때, swap이 noexcept이면 strong exception safety를 보장할 수 있습니다.
class Data {
std::vector<int> vec_;
public:
Data& operator=(Data other) { // 값으로 받아 복사/이동
swap(other);
return *this;
}
void swap(Data& other) noexcept {
vec_.swap(other.vec_);
}
};
패턴 3: 팩토리와 noexcept
팩토리 함수가 항상 성공한다면 (예: 메모리만 할당) noexcept를 붙일 수 있습니다. 실패 시 예외를 던지면 noexcept를 붙이지 않습니다.
[[nodiscard]] std::unique_ptr<Buffer> createBuffer(size_t n) {
return std::make_unique<Buffer>(n); // 실패 시 bad_alloc
}
[[nodiscard]] std::optional<Config> loadConfig() noexcept {
// 파일 없으면 nullopt 반환, 예외 없음
return loadFromCache();
}
패턴 4: Concept과 noexcept (C++20)
template<typename T>
concept NothrowMovable = std::is_nothrow_move_constructible_v<T>;
template<NothrowMovable T>
void process(T&& obj) noexcept {
// T가 noexcept 이동 가능하므로 이 함수도 noexcept
}
패턴 5: 스레드 안전 래퍼
뮤텍스 등으로 보호되는 객체의 이동은 락을 걸고 수행하므로, 락 자체가 예외를 던지지 않으면 noexcept로 선언할 수 있습니다.
template<typename T>
class ThreadSafeQueue {
std::mutex mtx_;
std::queue<T> queue_;
public:
void push(T value) {
std::lock_guard lock(mtx_);
queue_.push(std::move(value));
}
std::optional<T> tryPop() noexcept {
std::lock_guard lock(mtx_);
if (queue_.empty()) return std::nullopt;
T val = std::move(queue_.front());
queue_.pop();
return val;
}
};
패턴 6: API 경계와 noexcept
C API 콜백이나 SDK 공개 API에서 noexcept를 명시하면 “예외를 던지지 않음” 계약이 문서화됩니다. C 라이브러리 경계에서는 예외가 C++ 밖으로 전파되면 안 되므로 noexcept가 필수입니다.
10. 성능 벤치마크
vector 재할당: noexcept vs 비-noexcept
#include <chrono>
#include <vector>
#include <iostream>
struct WithNoexcept {
std::vector<int> data;
WithNoexcept() = default;
WithNoexcept(WithNoexcept&& other) noexcept : data(std::move(other.data)) {}
WithNoexcept& operator=(WithNoexcept&& other) noexcept {
data = std::move(other.data);
return *this;
}
};
struct WithoutNoexcept {
std::vector<int> data;
WithoutNoexcept() = default;
WithoutNoexcept(WithoutNoexcept&& other) : data(std::move(other.data)) {}
WithoutNoexcept& operator=(WithoutNoexcept&& other) {
data = std::move(other.data);
return *this;
}
};
int main() {
const size_t N = 100000;
const size_t iterations = 100;
auto start = std::chrono::high_resolution_clock::now();
for (size_t i = 0; i < iterations; ++i) {
std::vector<WithNoexcept> vec;
vec.reserve(N);
for (size_t j = 0; j < N; ++j) vec.push_back(WithNoexcept{});
}
auto withNoexcept = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::high_resolution_clock::now() - start).count();
start = std::chrono::high_resolution_clock::now();
for (size_t i = 0; i < iterations; ++i) {
std::vector<WithoutNoexcept> vec;
vec.reserve(N);
for (size_t j = 0; j < N; ++j) vec.push_back(WithoutNoexcept{});
}
auto withoutNoexcept = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::high_resolution_clock::now() - start).count();
std::cout << "With noexcept: " << withNoexcept << " ms\n";
std::cout << "Without noexcept: " << withoutNoexcept << " ms\n";
std::cout << "Speedup: " << (double)withoutNoexcept / withNoexcept << "x\n";
}
예상 결과: noexcept 버전이 2~10배 정도 빠를 수 있습니다 (구현체·원소 타입에 따라 다름).
11. 정리
요약 표
| 항목 | 권장 사항 |
|---|---|
| 소멸자 | noexcept 필수 |
| 이동 생성자 | noexcept 필수 (vector 최적화) |
| 이동 대입 | noexcept 필수 + 자기 대입 검사 |
| swap | noexcept 권장 |
| 템플릿 이동 | 조건부 noexcept(std::is_nothrow_move_constructible_v<T>) |
| 예외 가능 함수 | noexcept 붙이지 않기 |
한 줄 요약
noexcept는 “이 함수는 예외를 던지지 않는다”는 계약입니다. 소멸자·이동 연산·swap에 noexcept를 붙이면 std::vector 등 표준 컨테이너가 이동을 적극 사용해 성능이 향상되고, 예외 안전성도 명확해집니다.
다음 단계
- 이동 의미론 (#14-1) — std::move, 이동 생성자 기초
- 클린 코드 (#38-1) — const, noexcept, [[nodiscard]] 통합
- 예외 기본 (#8-1) — 예외 처리 기초
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
- [C++ 클린 코드 기초: const, noexcept, ]로 인터페이스 의도 명확히 하기](/blog/cpp-series-38-1-clean-code-const-noexcept-nodiscard/)
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
이 글에서 다루는 키워드 (관련 검색어)
C++ noexcept, noexcept 지정자, noexcept 연산자, 조건부 noexcept, 이동 생성자 noexcept, std::vector 최적화, 예외 계약, C++ 예외 안전성, RAII noexcept 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 모든 함수에 noexcept를 붙여야 하나요?
A. 아니요. 예외를 던지지 않도록 설계된 함수에만 붙입니다. 소멸자, 이동 연산, swap, 순수 계산 함수 등이 해당합니다. 예외를 던질 수 있는 함수에 붙이면 std::terminate 위험이 있습니다.
Q. noexcept가 성능에 직접적인 영향을 주나요?
A. noexcept 자체는 컴파일러가 예외 경로를 생성하지 않게 해 인라인 등에 도움이 될 수 있지만, 가장 큰 효과는 std::vector 등이 이동을 선택하게 하는 것입니다. 재할당 시 복사 대신 이동을 쓰면 성능이 크게 향상됩니다.
Q. 조건부 noexcept에서 expr이 false이면 어떻게 되나요?
A. noexcept(false)와 동일해집니다. 즉, 해당 함수는 예외를 던질 수 있습니다. noexcept(expr)에서 expr이 true이면 noexcept, false이면 예외 가능입니다.
Q. 소멸자에 noexcept(false)를 붙일 수 있나요?
A. C++20부터는 가능하지만, 권장하지 않습니다. 소멸자에서 예외를 던지면 스택 언와인딩 중 std::terminate가 호출됩니다. 소멸자는 항상 예외를 던지지 않도록 설계하는 것이 좋습니다.
Q. 선행으로 읽으면 좋은 글은?
A. 이동 의미론 (#14-1), 예외 기본 (#8-1), 클린 코드 (#38-1)를 먼저 읽으면 이해가 쉽습니다.
참고 자료
- cppreference: noexcept specifier
- cppreference: noexcept operator
- C++ Core Guidelines: C.37 — Make destructors noexcept
- C++ Core Guidelines: C.66 — Make move operations noexcept
오늘 배운 것 한눈에
- noexcept 지정자: 함수가 예외를 던지지 않음을 선언
- noexcept 연산자:
noexcept(expr)로 컴파일 타임에 예외 가능 여부 검사 - 이동 연산:
std::vector등이 재할당 시noexcept이동 생성자를 선호 - 조건부 noexcept: 템플릿에서
noexcept(std::is_nothrow_move_constructible_v<T>)패턴 - 소멸자: 항상
noexcept, 예외 던지지 않기 - swap:
noexcept로 선언해 알고리즘 최적화에 활용
다음 글: C++ 제약된 환경: Exception과 RTTI 없이 (#42-2)
이전 글: C++ 클린 코드 기초: const, noexcept, [[nodiscard]] (#38-1)
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |