noexcept로 C++ 이동 연산 최적화하기: 예외 계약 설계 패턴
들어가며: vector 재할당이 왜 이렇게 느릴까?
std::vector에 원소를 많이 넣으면 내부 버퍼가 부족해 재할당이 일어납니다. 이때 기존 원소들을 새 버퍼로 옮기는 방식이 이동과 복사 두 가지인데, 이동 생성자에 noexcept가 없고 복사 생성자가 있으면, 표준 라이브러리는 안전을 위해 복사를 선택합니다. 원소가 힙 버퍼를 가진 타입이라면 재할당 때마다 원소 수만큼 메모리 할당과 데이터 복사가 일어나는 셈입니다. 이동은 보통 포인터 몇 개만 옮기므로 원소 하나당 비용이 버퍼 크기와 무관하지만, 복사는 버퍼 크기에 비례합니다. noexcept는 “이동 중 예외가 나지 않는다”는 계약이라, 표준 라이브러리가 이동을 선택할 수 있게 합니다.
flowchart TD
subgraph realloc[vector 재할당]
A[기존 버퍼] --> B{이동 생성자 noexcept?}
B -->|예| C[이동 사용 - 빠름]
B -->|아니오| D[복사 사용 - 느림]
C --> E[새 버퍼]
D --> E
end
vector가 복사를 고를 때: noexcept 없을 때 겪는 일
시나리오 1: vector::resize 시 복사만 선택되어 느려짐
문제: 커스텀 Buffer 클래스를 std::vector<Buffer>에 넣었는데, resize로 크기를 크게 늘리면 예상보다 오래 걸립니다. 프로파일러로 확인해 보니 재할당 시 복사 생성자가 호출되고 있습니다.
원인: Buffer의 이동 생성자에 noexcept가 없어, 표준 라이브러리가 “이동 중 예외가 나면 복구 불가”를 우려해 복사를 선택했습니다.
해결: 이동 생성자·이동 대입에 noexcept를 붙이면 std::vector가 이동을 사용해 성능이 크게 향상됩니다.
시나리오 2: RAII 소멸자에서 예외가 나면 terminate
문제: 파일 핸들을 관리하는 RAII 클래스의 소멸자에서 close()가 실패해 예외를 던지면 std::terminate가 호출됩니다. C++11부터 소멸자는 따로 지정하지 않으면 암묵적으로 noexcept이므로, 스택 언와인딩 중이 아니어도 예외가 소멸자 밖으로 나가는 순간 종료됩니다.
해결: 소멸자는 절대 예외를 던지지 않도록 설계하며, noexcept로 명시합니다. 실패 시 로깅만 하거나 std::abort로 조기 종료합니다.
시나리오 3: Optional 이동 시 T가 예외를 던질 수 있음
문제: template<typename T> class Optional의 이동 생성자를 noexcept로 선언했는데, T의 이동 생성자가 예외를 던지면 계약 위반으로 std::terminate가 호출됩니다.
해결: 조건부 noexcept를 사용해 noexcept(std::is_nothrow_move_constructible_v<T>)로 “T가 noexcept이면 이 함수도 noexcept”라고 선언합니다.
시나리오 4: 라이브러리 API 계약 불명확
문제: 외부 라이브러리의 process() 함수가 예외를 던지는지 문서에 없어, 호출자가 try-catch를 쓸지 말지 결정하기 어렵습니다.
해결: API 설계 시 noexcept를 명시하면 “이 함수는 예외를 던지지 않는다”는 계약이 되어, 호출자가 예외 처리 전략을 세우기 쉽습니다.
시나리오 5: emplace_back과 이동
문제: vec.emplace_back(args...)로 원소를 추가할 때, 재할당이 필요하면 기존 원소들을 새 버퍼로 옮깁니다. 이때 이동 생성자가 noexcept가 아니면 복사가 사용되어, emplace의 “생성자 직접 호출” 이점이 재할당 시점에서 사라집니다.
해결: 이동 연산에 noexcept를 붙여 재할당 시에도 이동이 사용되도록 합니다.
noexcept 지정자
기본 문법
noexcept 지정자는 “이 함수는 예외를 던지지 않는다”는 계약을 선언합니다. 예외를 던지면 std::terminate가 호출되므로, 정말로 예외를 던지지 않을 때만 사용해야 합니다.
// 기본 형태: 예외를 던지지 않음
void process() noexcept;
// noexcept(false): 예외를 던질 수 있음 (명시적)
void mayThrow() noexcept(false);
// 생략: noexcept(false)와 동일 (예외 가능)
// 단, 소멸자·암묵적으로 선언되는 특수 멤버 함수는 예외 (소멸자는 기본이 noexcept)
void unspecified();
주의: noexcept를 붙인 함수에서 예외를 던지면 프로그램이 즉시 std::terminate로 종료됩니다. “예외를 던지지 않도록 설계된” 함수에만 사용하세요.
적용 대상
| 대상 | noexcept 권장 | 이유 |
|---|---|---|
| 소멸자 | 필수 | 스택 언와인딩 중 예외 시 terminate |
| 이동 생성자 | 필수 | vector 등에서 이동 선택 |
| 이동 대입 | 필수 | vector 등에서 이동 선택 |
| swap | 권장 | copy-and-swap의 strong 보장, std::is_nothrow_swappable 기반 최적화 |
| 기본 연산 (포인터 스왑 등) | 권장 | 예외 없음이 보장될 때 |
소멸자와 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를 붙여 두면 copy-and-swap 대입이 strong 보장을 갖게 되고, std::is_nothrow_swappable로 조건을 검사하는 템플릿 코드(예: 다른 클래스의 swap을 조건부 noexcept로 선언하는 경우)가 이 정보를 활용할 수 있습니다.
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);
}
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_)) {}
};
이동 연산과 noexcept
std::vector의 재할당 전략
std::vector는 재할당 시 기존 원소를 새 버퍼로 옮길 때 다음 규칙을 따릅니다:
- 이동 생성자가
noexcept→ 이동 사용 (빠름) - 이동 생성자가
noexcept아니고 복사 생성자가 있음 → 복사 사용 (안전하지만 느림) - 이동 생성자가
noexcept아니고 복사가 불가능(unique_ptr을 가진 타입 등) → 어쩔 수 없이 이동 사용, 이때는 strong 보장이 basic으로 약해짐 이유: 이동 중 예외가 나면 “일부만 옮겨진” 상태가 되어 복구하기 어렵습니다. 복사는 예외 발생 시 원본을 유지할 수 있어 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) 검사는 x = std::move(x)에서 자기 버퍼를 먼저 지우는 사고를 막습니다. 소멸자의 delete[]는 nullptr에 대해 아무 일도 하지 않으므로 이동된 객체의 소멸도 안전합니다.
std::move_if_noexcept
표준 라이브러리 내부에서는 std::move_if_noexcept를 사용해 “이동이 noexcept이면 이동, 아니면 복사”를 선택합니다.
// 개념적 동작: 실제로는 T&&나 const T&를 반환해 생성자 선택만 바꾼다
template<typename T>
decltype(auto) move_if_noexcept(T& x) noexcept {
if constexpr (!std::is_nothrow_move_constructible_v<T> &&
std::is_copy_constructible_v<T>) {
return static_cast<const T&>(x); // 복사 생성자가 선택됨
} else {
return static_cast<T&&>(x); // 이동 생성자가 선택됨
}
}
std::vector 재할당 시 이와 유사한 로직으로 요소를 옮깁니다.
조건부 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_) {
// std::optional과 같이 원본은 "값이 있지만 이동된 상태"로 남는다.
// 여기서 other.has_value_ = false로 바꾸면 원본 T의 소멸자가 호출되지 않아 누수가 생긴다.
new (&storage_) T(std::move(*other.get()));
}
}
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)) {}
};
예외 안전성과 noexcept
예외 안전성 보장 수준
C++에서 예외 안전성(exception safety)은 연산이 예외를 던질 때 객체·프로그램 상태가 어떻게 유지되는지를 나타냅니다. noexcept는 nothrow 보장의 핵심입니다.
flowchart TB
subgraph levels[예외 안전성 수준]
N[nothrow: 예외 없음]
S[strong: 롤백 가능]
B[basic: 유효 상태 유지]
X[none: 정의되지 않음]
end
N -->|"swap·이동이 nothrow면 쌓아 올릴 수 있음"| S
S -->|최소 보장| 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)) {}
// ❌ 아쉬운 설계: 실제로는 예외를 던지지 않지만 noexcept가 없어 vector가 복사를 선택
Buffer(Buffer&& other) // noexcept 없음
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
}
RAII 리소스, 스마트 버퍼, 조건부 noexcept Optional 예제
예제 1: RAII 리소스 클래스
#include <utility>
#include <cstdio>
#include <stdexcept>
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_)); // 원본은 이동된 값을 유지, 소멸자가 정리
}
}
~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>())));
noexcept 함수의 예외, 이동 생성자 noexcept 누락, 소멸자 예외
에러 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의 계약 위반
증상: 이동 생성자가 예외를 던질 수 있는 타입 T로 Optional<T>를 이동할 때 std::terminate가 호출됩니다.
원인: Optional의 이동 생성자를 T와 무관하게 noexcept로 선언했습니다. T의 이동이 예외를 던지면 noexcept 계약 위반으로 종료됩니다.
// ❌ 잘못된 코드: 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가 호출됩니다.
원인: 소멸자는 기본적으로 noexcept이므로, 소멸자 밖으로 예외가 나가면 std::terminate가 호출됩니다. noexcept(false)로 명시하면 예외를 허용할 수 있지만, 이미 다른 예외로 스택 언와인딩 중에 소멸자가 또 예외를 던지면 그때는 무조건 std::terminate입니다.
// ❌ 잘못된 코드
~Resource() {
if (cleanupFailed()) throw std::runtime_error("cleanup failed");
}
해결:
// ✅ 올바른 코드
~Resource() noexcept {
if (!cleanup()) {
logError("cleanup failed");
// 예외 던지지 않음
}
}
에러 5: 소멸자에 noexcept(false)를 붙임
증상: 컴파일은 되지만(C++11부터 허용), 이 타입을 멤버로 가진 클래스의 소멸자도 암묵적으로 noexcept(false)가 되고, std::is_nothrow_destructible이 거짓이 되어 표준 컨테이너·std::optional 등에서 쓰기 어려워집니다.
해결: 소멸자는 기본값(noexcept)으로 두고, 정리 실패는 로그나 별도의 close() 함수로 호출자에게 알립니다.
에러 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++11부터). 반대로 기본이 예외를 허용하고 파생이 noexcept인 것은 괜찮습니다.
해결: 파생 클래스 오버라이드에도 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를 붙이지 않는 것이 일반적입니다.
RAII 래퍼, copy-and-swap, API 경계의 noexcept
패턴 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<std::remove_cvref_t<T>>;
template<NothrowMovable T>
void process(T&& obj) noexcept {
auto local = std::forward<T>(obj); // 이동(또는 복사)이 nothrow여야 noexcept가 성립
}
전달 참조에서 T는 Widget&처럼 참조 타입으로 추론될 수 있고, std::is_nothrow_move_constructible_v<Widget&>는 참조 생성이므로 항상 참입니다. 그래서 제약에는 remove_cvref_t를 씌워야 합니다. 또 lvalue를 넘기면 local은 복사로 만들어지므로, 엄밀하게는 std::is_nothrow_constructible_v<std::remove_cvref_t<T>, T>를 검사하는 것이 정확합니다.
패턴 5: 스레드 안전 래퍼
락을 거는 함수에 noexcept를 붙일 때는 주의가 필요합니다. std::mutex::lock()은 실패 시 std::system_error를 던질 수 있도록 명세되어 있고, T의 이동도 예외를 던질 수 있습니다. 아래처럼 조건부 noexcept로 T의 조건을 반영하되, 락 실패는 실무에서 사실상 일어나지 않는다고 보고 받아들일지 팀 기준으로 정해야 합니다.
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::is_nothrow_move_constructible_v<T>) {
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가 필수입니다.
vector 재할당: noexcept 이동 vs 복사
vector 재할당: noexcept vs 비-noexcept
#include <chrono>
#include <vector>
#include <iostream>
struct WithNoexcept {
std::vector<int> data = std::vector<int>(64); // 복사 비용이 보이도록 힙 데이터 보유
WithNoexcept() = default;
WithNoexcept(const 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 = std::vector<int>(64);
WithoutNoexcept() = default;
WithoutNoexcept(const 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;
// reserve를 하면 재할당이 없어 noexcept 차이가 드러나지 않으므로 일부러 생략
auto start = std::chrono::steady_clock::now();
for (size_t i = 0; i < iterations; ++i) {
std::vector<WithNoexcept> vec;
for (size_t j = 0; j < N; ++j) vec.push_back(WithNoexcept{});
}
auto withNoexcept = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
start = std::chrono::steady_clock::now();
for (size_t i = 0; i < iterations; ++i) {
std::vector<WithoutNoexcept> vec;
for (size_t j = 0; j < N; ++j) vec.push_back(WithoutNoexcept{});
}
auto withoutNoexcept = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_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";
}
두 버전의 차이는 재할당 때 기존 원소를 옮기는 비용뿐입니다. WithNoexcept는 원소마다 포인터 세 개만 옮기고, WithoutNoexcept는 원소마다 int 64개짜리 버퍼를 새로 할당해 복사합니다. 2배씩 늘어나는 vector에서 재할당으로 옮기는 원소의 총수는 N의 약 1~2배이므로, 차이는 원소의 복사 비용이 클수록 커집니다. 실제 배수는 할당자와 CPU에 따라 다르므로 직접 실행해 확인하세요.
이어서 읽을 글
noexcept를 붙일지 말지는 결국 “이 이동 연산이 실패할 수 있는가”라는 질문으로 모입니다. 이동 생성자와 이동 대입을 직접 작성하는 일이 많다면 이동 의미론 자체를 먼저 단단히 해 두는 것이 좋고, 팀 코드 규칙으로 정리하려면 const·noexcept·[[nodiscard]]를 한 번에 다루는 글이 도움이 됩니다.
- 이동 의미론 (#14-1): std::move와 이동 생성자가 언제 호출되는지
- 클린 코드 (#38-1): const, noexcept, [[nodiscard]]를 함께 적용하는 기준
- 예외 기본 (#8-1): noexcept 함수에서 예외가 새면 왜 std::terminate로 끝나는지 이해하는 바탕
같이 보면 좋은 글
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
- const·noexcept·[[nodiscard]]로 C++ 인터페이스 의도 명확히 하기
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ 시리즈 전체 보기
- C++ noexcept 지정자: 이동 생성자·swap·소멸자에 붙이는 이유와 위반 시 terminate
- C++ weak_ptr: shared_ptr 순환 참조 끊기와 lock()·expired() 올바른 사용법
자주 묻는 질문 (FAQ)
Q. noexcept가 성능에 직접적인 영향을 주나요?
A. noexcept 자체는 컴파일러가 예외 경로를 생성하지 않게 해 인라인 등에 도움이 될 수 있지만, 가장 큰 효과는 std::vector 등이 이동을 선택하게 하는 것입니다. 재할당 시 복사 대신 이동을 쓰면 성능이 크게 향상됩니다.
Q. noexcept는 함수 타입의 일부인가요?
A. C++17부터 그렇습니다. C++14까지는 예외 지정이 타입에 포함되지 않아 void f() noexcept와 void g()가 같은 타입이었지만, C++17부터는 void() noexcept와 void()가 서로 다른 함수 타입입니다. 변환 방향이 비대칭이라는 점을 기억하면 됩니다. noexcept 함수는 일반 함수 포인터에 담을 수 있지만(보장을 버리는 쪽이라 안전), 예외를 던질 수 있는 함수를 noexcept 함수 포인터에 담는 것은 컴파일 에러입니다. C++14에서 컴파일되던 코드가 C++17로 올리면서 템플릿 오버로드 선택이 바뀌거나 함수 포인터 대입이 막히는 경우가 이 때문입니다.
void safe() noexcept;
void risky();
void (*p1)() = safe; // OK: noexcept → 일반 포인터 (보장을 버림)
void (*p2)() noexcept = risky; // 에러: 보장이 없는 함수를 noexcept 포인터에
void (*p3)() noexcept = safe; // OK
Q. 조건부 noexcept에서 expr이 false이면 어떻게 되나요?
A. noexcept(false)와 동일해집니다. 즉, 해당 함수는 예외를 던질 수 있습니다. noexcept(expr)에서 expr이 true이면 noexcept, false이면 예외 가능입니다.
Q. 소멸자에 noexcept(false)를 붙일 수 있나요?
A. 문법상으로는 C++11부터 가능합니다(소멸자는 기본적으로 암시적 noexcept이고, noexcept(false)로 명시해야 예외를 허용). 하지만 권장하지 않습니다. 소멸자에서 예외를 던지면 스택 언와인딩 중 std::terminate가 호출됩니다. 소멸자는 항상 예외를 던지지 않도록 설계하는 것이 좋습니다.
참고 자료
- cppreference: noexcept specifier
- cppreference: noexcept operator
- C++ Core Guidelines: C.37 — Make destructors noexcept
- C++ Core Guidelines: C.66 — Make move operations noexcept 다음 글: C++ 제약된 환경: Exception과 RTTI 없이 (#42-2) 이전 글: C++ 클린 코드 기초: const, noexcept, [[nodiscard]] (#38-1)