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]]를 한 번에 다루는 글이 도움이 됩니다.


같이 보면 좋은 글

자주 묻는 질문 (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가 호출됩니다. 소멸자는 항상 예외를 던지지 않도록 설계하는 것이 좋습니다.

참고 자료