C++ 예외 안전성 Basic·Strong·Nothrow 보장: RAII와 copy-and-swap으로 구현하기

이 글은 예외 처리 기초(#8-1)에 이어지는 내용입니다. 예외가 함수를 중간에 빠져나갈 때 자원과 객체 상태를 어떻게 지킬지를 다룹니다.

들어가며: 예외 경로에서 새는 메모리

아래 코드는 new char[1024]로 버퍼를 할당한 뒤 파일 열기에 실패하면 예외를 던집니다. 예외가 던져지면 함수는 스택 언와인딩으로 즉시 종료되므로 delete[] buffer에 도달하지 못하고, 버퍼는 해제되지 않습니다.

void processFile(const std::string& path) {
    char* buffer = new char[1024];  // 메모리 할당
    
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file");
        // ❌ buffer가 해제 안 됨!
    }
    
    // 파일 처리...
    
    delete[] buffer;  // 정상 경로에서만 실행됨
}

지금은 throw가 한 곳뿐이라 그 앞에 delete[]를 한 줄 넣으면 해결될 것처럼 보입니다. 하지만 나중에 “파일 처리…” 부분에서 호출하는 함수가 예외를 던지게 되면 같은 누수가 다시 생깁니다. 해제 코드를 모든 예외 경로에 맞춰 손으로 넣는 방식은 코드가 바뀔 때마다 깨지기 쉽습니다. 자원을 RAII 객체로 감싸면 예외가 나도 소멸자에서 해제됩니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o safe_file safe_file.cpp && ./safe_file
#include <memory>
#include <fstream>
#include <stdexcept>
#include <string>
#include <iostream>

void processFile(const std::string& path) {
    auto buffer = std::make_unique<char[]>(1024);  // RAII
    std::ifstream file(path);  // RAII
    if (!file) {
        throw std::runtime_error("Cannot open file");
        // ✅ buffer와 file 소멸자가 자동 호출됨!
    }
    // 파일 처리...
}

int main() {
    try {
        processFile("nonexistent.txt");
    } catch (const std::exception& e) {
        std::cerr << e.what() << "\n";
    }
    return 0;
}

std::make_unique<char[]>(1024)와 std::ifstream은 생성 시 자원을 획득하고, 스코프를 벗어날 때(예외로 벗어나는 경우 포함) 소멸자에서 해제합니다. throw가 나도 file과 buffer가 생성의 역순으로 정리되므로 누수가 생기지 않습니다. 실행하면 Cannot open file이 stderr에 출력됩니다.

예외 안전성은 이 질문을 체계화한 것입니다. 함수가 중간에 예외로 빠져나가도 자원이 새지 않는가, 그리고 객체는 어떤 상태로 남는가. 뒤의 질문에 대한 답이 Basic, Strong, Nothrow라는 세 단계 보장입니다.

락 미해제, 상태 불일치, 부분 생성: 예외가 남기는 흔적

락이 해제되지 않음

mutex.lock() 후 processRequest()에서 예외가 나면 unlock()에 도달하지 못합니다. 이후 같은 뮤텍스를 잠그려는 모든 스레드가 영원히 대기합니다.

// ❌ 나쁜 예: 락이 예외 경로에서 해제되지 않음
void handleRequest() {
    mutex.lock();
    processRequest();  // 예외 발생 시 unlock() 호출 안 됨
    mutex.unlock();
}

std::lock_guard 같은 RAII 객체로 락을 관리하면 소멸자에서 unlock이 호출됩니다.

// ✅ 좋은 예: lock_guard는 소멸자에서 자동 unlock
void handleRequest() {
    std::lock_guard<std::mutex> lock(mutex);
    processRequest();  // 예외 발생해도 lock 소멸 시 unlock
}

컨테이너 수정 중 예외로 인한 상태 불일치

std::vector에 여러 요소를 차례로 추가하다 예외가 나면, 일부만 추가된 중간 상태가 남아 재시도나 롤백이 어렵습니다. 개별 push_back은 강한 보장을 제공하지만, 여러 번의 호출을 묶은 함수 전체는 그렇지 않습니다.

// Basic Guarantee: vec는 유효하지만 일부만 추가될 수 있음
void addItems(std::vector<Item>& vec, const std::vector<Item>& items) {
    for (const auto& item : items) {
        vec.push_back(item);  // 세 번째에서 예외가 나면 앞의 두 개만 추가된 상태
    }
}

전부 추가하거나 전혀 추가하지 않으려면 복사본에 작업한 뒤 한 번에 교체해야 합니다. 이 방법은 아래 Strong Guarantee 절에서 다룹니다.

생성자에서 예외 시 부분 생성된 객체

생성자에서 여러 자원을 획득하다 중간에 예외가 나면 객체는 생성되지 않은 것으로 취급되어 소멸자가 호출되지 않습니다. raw 포인터 멤버로 이미 획득한 자원은 그대로 샙니다.

// ❌ 나쁜 예: 생성자 본문에서 예외가 나면 소멸자가 호출되지 않아 socket1이 샘
class DualConnection {
    Socket* socket1;
    Socket* socket2;
public:
    DualConnection() : socket1(new Socket()), socket2(nullptr) {
        socket1->connect("host1");  // 여기서 예외 시 socket1 누수
        socket2 = new Socket();     // 여기서 예외 시에도 socket1 누수
        socket2->connect("host2");  // 여기서 예외 시 socket1, socket2 모두 누수
    }
};

멤버를 std::unique_ptr 같은 RAII 타입으로 두면, 생성자가 예외로 끝나도 이미 초기화가 끝난 멤버들의 소멸자는 호출됩니다.

소멸자에서 예외를 던짐

C++11부터 소멸자는 따로 지정하지 않으면 암시적으로 noexcept입니다. 그래서 소멸자 밖으로 예외가 빠져나가면 스택 언와인딩 중이든 아니든 std::terminate()가 호출됩니다. noexcept(false)로 선언해도 다른 예외로 언와인딩하는 도중에 소멸자가 또 예외를 던지면 역시 std::terminate()입니다.

// ❌ 나쁜 예: 소멸자에서 예외
~Resource() {
    if (!cleanup())  // 정리에 실패하면
        throw std::runtime_error("Cleanup failed");  // terminate() 호출!
}

소멸자에서는 예외를 던지지 않고, 정리 중 생긴 예외는 try-catch로 잡아 로그만 남깁니다. 실패를 호출자에게 알려야 한다면 close() 같은 별도 함수를 두어 호출자가 명시적으로 부르게 합니다.


예외가 스택을 풀 때 프로그램 상태에 일어나는 일

예외가 던져지면 현재 함수의 실행이 중단되고, 맞는 catch 블록을 찾을 때까지 호출 스택을 거슬러 올라가며 각 스코프의 지역 객체를 생성의 역순으로 소멸시킵니다. 이 과정을 스택 언와인딩이라고 합니다.

void func3() {
    Resource r3;
    throw std::runtime_error("Error!");
    // r3 소멸자 호출됨
}

void func2() {
    Resource r2;
    func3();
    // 예외 전파, r2 소멸자 호출됨
}

void func1() {
    Resource r1;
    try {
        func2();
    } catch (...) {
        // 여기서 잡힘
        // r1은 아직 살아 있고, func1이 끝날 때 정상적으로 소멸됨
    }
}

func3에서 throw가 나면 r3 소멸자가 먼저 호출되고, 이어서 func2의 r2가 소멸된 뒤 func1의 catch 블록으로 제어가 넘어갑니다. r1은 try 블록 밖에 선언되었으므로 언와인딩 대상이 아니고, catch가 끝난 뒤 func1이 반환될 때 평소처럼 소멸됩니다. try-catch는 func1에만 있지만 중간에 있는 Resource 객체들은 모두 정리됩니다.

flowchart TD
    A[func3: throw] --> B[r3 소멸자 호출]
    B --> C[func2로 전파]
    C --> D[r2 소멸자 호출]
    D --> E[func1의 catch 블록 실행]
    E --> F[func1 반환 시 r1 소멸]

이 정리가 항상 일어난다고 가정하면 안 되는 경우가 있습니다. 예외를 받아 줄 catch가 끝내 없으면 std::terminate가 호출되는데, 이때 스택 언와인딩을 먼저 할지는 구현이 정합니다. GCC·Clang이 쓰는 Itanium ABI의 2단계 예외 처리는 처리기를 먼저 찾고 나서 언와인딩하므로, 처리기가 없으면 언와인딩 없이 바로 종료되어 RAII에 맡겨 둔 파일 flush나 임시 파일 삭제가 실행되지 않습니다. 그래서 정리가 중요한 프로그램은 main(과 스레드 진입 함수)의 가장 바깥에 catch (...)를 두어 언와인딩이 반드시 일어나게 합니다. noexcept 함수 밖으로 예외가 빠져나가는 경우도 같은 이유로 언와인딩이 보장되지 않습니다.

catch 쪽에도 두 가지 규칙이 있습니다. catch 절은 위에서부터 차례로 검사되므로 std::runtime_error 같은 구체적인 타입을 먼저, std::exception이나 ...을 마지막에 둬야 하고, 예외는 catch (const std::exception& e)처럼 참조로 받아야 파생 타입의 정보가 잘려 나가지(slicing) 않습니다. 잡은 예외를 상위로 다시 넘길 때도 throw e;가 아니라 throw;를 씁니다. throw e;는 정적 타입(std::exception)으로 복사본을 새로 던지므로 원래의 파생 타입이 사라집니다.


Basic, Strong, Nothrow 보장의 차이

Basic Guarantee (기본 보장)

예외가 나도 자원이 새지 않고 모든 객체가 유효한(불변식을 만족하는) 상태로 남지만, 그 상태가 호출 전과 같다는 보장은 없는 수준입니다. 객체를 소멸시키거나 새 값을 대입하는 것은 안전하지만, 어떤 값이 들어 있는지는 알 수 없습니다.

#include <cstddef>
#include <memory>

template <typename T>
class SimpleVector {
    std::unique_ptr<T[]> data_;
    std::size_t size_ = 0;
    std::size_t capacity_ = 0;

public:
    // Basic Guarantee: push_back 실패 시 리소스 누수 없음, 객체는 유효
    void push_back(const T& value) {
        if (size_ >= capacity_) {
            auto new_cap = (capacity_ == 0) ? 4 : capacity_ * 2;
            auto new_data = std::make_unique<T[]>(new_cap);  // T를 기본 생성
            for (std::size_t i = 0; i < size_; ++i) {
                new_data[i] = data_[i];  // 복사 대입 예외 가능
            }
            data_ = std::move(new_data);
            capacity_ = new_cap;
        }
        data_[size_++] = value;  // 복사 대입 예외 가능
    }
};

재할당 부분은 예외가 나면 new_data만 해제되고 data_는 건드리지 않으므로 그 자체로는 원래 상태가 유지됩니다. 문제는 마지막 줄입니다. data_[size_++]가 먼저 평가되어 size_가 증가한 뒤 T의 복사 대입이 호출되므로, 대입이 예외를 던지면 size_에는 기본 생성된 원소 하나가 더 포함된 채 남습니다. 객체는 여전히 유효하고 누수도 없지만 내용이 바뀌었으므로 Basic Guarantee입니다. 대입이 성공한 뒤에 ++size_를 하도록 순서만 바꾸면 이 함수는 Strong Guarantee가 됩니다.

Strong Guarantee (강한 보장)

예외가 나면 프로그램 상태가 호출 전으로 돌아가는 수준입니다. 연산은 완전히 성공하거나, 아무 효과도 없거나 둘 중 하나입니다(commit or rollback).

#include <string>
#include <vector>

// Strong Guarantee: 성공 시 전부 반영, 실패 시 vec는 변경 전 그대로
template <typename T>
void appendAll(std::vector<T>& vec, const std::vector<T>& to_add) {
    std::vector<T> temp = vec;           // 1. 복사 (실패 가능)
    temp.reserve(temp.size() + to_add.size());
    for (const auto& item : to_add) {
        temp.push_back(item);            // 2. temp에만 추가 (실패 시 vec 무관)
    }
    vec = std::move(temp);               // 3. 성공 시에만 교체 (noexcept)
}

int main() {
    std::vector<std::string> vec = {"a", "b"};
    std::vector<std::string> add = {"c", "d"};
    try {
        appendAll(vec, add);             // vec == {"a","b","c","d"}
    } catch (const std::bad_alloc&) {
        // vec는 여전히 {"a","b"} - 변경 전 상태 유지
    }
}

temp에만 작업하며, 모든 작업이 성공한 뒤 vec = std::move(temp)로 한 번에 교체합니다. 기본 할당자를 쓰는 std::vector의 이동 대입은 noexcept이므로 이 시점 이후에는 예외가 나지 않습니다. 중간에 예외가 나면 vec는 전혀 건드리지 않았으므로 Strong Guarantee가 만족됩니다. 대가는 vec 전체를 복사하는 비용입니다.

Nothrow Guarantee (예외 없음 보장)

연산이 절대 예외를 던지지 않는 수준입니다. 위의 두 보장을 구현하는 데 쓰이는 기반이기도 합니다. Strong Guarantee의 “마지막에 한 번에 교체” 단계가 실패하지 않아야 하기 때문입니다.

#include <utility>

class Handle {
    void* resource_ = nullptr;

public:
    void swap(Handle& other) noexcept {
        std::swap(resource_, other.resource_);  // 포인터 swap만, 예외 없음
    }

    void* get() const noexcept {
        return resource_;
    }

    void reset() noexcept {
        resource_ = nullptr;  // 단순 대입, 예외 없음
    }
};

// Nothrow Guarantee: 절대 예외를 던지지 않음
void safeIncrement(int* ptr) noexcept {
    if (ptr) {
        ++(*ptr);
    }
}

swap, get, reset, safeIncrement는 메모리 할당이나 복사 생성 없이 포인터와 정수만 다루므로 예외를 던질 수 없습니다. noexcept는 이 사실을 호출자와 표준 라이브러리에 알리는 표시입니다. 내부에서 예외가 빠져나오면 std::terminate()가 호출되므로, 정말로 던지지 않는 연산에만 붙여야 합니다.

세 가지 보장 수준 비교표

보장 수준리소스 누수객체 상태사용 예
Basic없음유효하나 변경될 수 있음여러 단계로 이루어진 일반 연산, 다중 원소 vector::insert
Strong없음변경 전으로 롤백vector::push_back, Copy-and-Swap, 트랜잭션
Nothrow없음예외 자체가 없음swap, 이동 연산, 소멸자

RAII로 예외 경로의 리소스 정리 자동화

수동 정리와 RAII 비교

void processLine(FILE*);  // 예외를 던질 수 있음

void processData() {
    FILE* file = fopen("data.txt", "r");
    if (!file) {
        throw std::runtime_error("Cannot open");
    }
    
    char* buffer = new char[1024];
    
    // processLine(file) 등이 예외를 던지면
    // ❌ delete[], fclose에 도달하지 못함
    processLine(file);
    
    // 정상 종료
    delete[] buffer;
    fclose(file);
}

fopen과 new로 획득한 자원은 함수를 빠져나가는 모든 경로에서 수동으로 delete[]와 fclose를 호출해야 합니다. 경로마다 정리 코드가 중복되고, 새 예외 경로가 생기면 해제를 빼먹기 쉽습니다.

void processLine(std::ifstream&);  // 예외를 던질 수 있음

void processData() {
    std::ifstream file("data.txt");  // RAII
    if (!file) {
        throw std::runtime_error("Cannot open");
    }
    
    auto buffer = std::make_unique<char[]>(1024);  // RAII
    
    // processLine(file) 예외 발생해도
    // ✅ buffer와 file 소멸자가 자동 호출됨
    processLine(file);
}

std::ifstream과 std::unique_ptr은 소멸자에서 파일 닫기와 메모리 해제를 수행합니다. 어디서 throw가 나도 스코프를 벗어날 때 buffer, file 순으로 정리되므로 정리 코드를 여러 곳에 쓸 필요가 없습니다.

여러 리소스 관리

void multipleResources(const std::string& path1, const std::string& path2) {
    std::ifstream file1(path1);
    if (!file1) {
        throw std::runtime_error("Cannot open file1");
    }
    
    std::ifstream file2(path2);
    if (!file2) {
        throw std::runtime_error("Cannot open file2");
        // ✅ file2, file1 순으로 소멸되며 file1이 닫힘
    }
    
    auto buffer = std::make_unique<char[]>(1024);
    
    // 작업 중 예외 발생 시
    // buffer, file2, file1 순서로 자동 정리
}

여러 자원을 각각 RAII 객체로 두면 획득의 역순으로 자동 정리되므로, 몇 번째 단계에서 실패하든 그때까지 획득한 자원만 정확히 해제됩니다.

커스텀 RAII 래퍼

#include <cstdio>
#include <stdexcept>

class FileHandle {
    FILE* file;
public:
    FileHandle(const char* path, const char* mode) 
        : file(fopen(path, mode)) {
        if (!file) {
            throw std::runtime_error("Cannot open file");
        }
    }
    
    ~FileHandle() {
        if (file) fclose(file);
    }
    
    // 복사 금지
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
    
    FILE* get() const { return file; }
};

void readAll(FILE*);  // 예외를 던질 수 있음

void useFile() {
    FileHandle file("data.txt", "r");  // RAII
    readAll(file.get());               // 예외가 나도 파일은 닫힘
}

FileHandle은 생성자에서 fopen, 소멸자에서 fclose를 호출합니다. 열기에 실패하면 생성자에서 예외를 던지므로 “열리지 않은 핸들”이라는 상태 자체가 존재하지 않습니다. 복사를 막는 이유는 두 객체가 같은 FILE*를 들고 각자 fclose하는 이중 해제를 막기 위해서입니다.


noexcept가 필요한 곳: 이동 생성자, swap, 소멸자

noexcept의 의미

noexcept를 붙이면 “이 함수는 예외를 던지지 않는다”는 계약을 컴파일러와 호출자에게 알립니다. 컴파일러가 이를 검사해 주지는 않습니다. noexcept 함수 밖으로 예외가 빠져나가면 std::terminate()가 호출되고, 그 전에 스택이 언와인딩되는지는 구현이 정합니다.

#include <exception>
#include <stdexcept>
void func() noexcept {
    throw std::runtime_error("Error!");  // 컴파일은 되지만(경고는 날 수 있음) 실행 시 terminate
}

int main() {
    try {
        func();  // std::terminate() 호출, 프로그램 종료
    } catch (...) {
        // 여기에 도달하지 않음
    }
}

호출자 쪽에서 catch를 두어도 잡을 수 없으므로, noexcept는 예외가 나지 않음을 보장할 수 있는 함수에만 지정해야 합니다.

소멸자

class Resource {
public:
    ~Resource() {  // 따로 지정하지 않아도 암시적으로 noexcept
        try {
            cleanup();
        } catch (...) {
            // 로그만 남기고 삼킴
        }
    }
};

소멸자는 예외로 인한 스택 언와인딩 중에도 호출되므로 예외를 던지면 안 됩니다. 정리 중 생기는 에러는 try-catch로 잡아 로그만 남기는 방식이 안전합니다.

이동 생성자와 이동 대입

class MyClass {
public:
    MyClass(MyClass&& other) noexcept : data(other.data) {
        other.data = nullptr;
    }
    
    MyClass& operator=(MyClass&& other) noexcept {
        if (this != &other) {
            delete data;
            data = other.data;
            other.data = nullptr;
        }
        return *this;
    }
    
    ~MyClass() { delete data; }
    
private:
    int* data = nullptr;
};

std::vector는 재할당할 때 기존 원소를 새 버퍼로 옮기면서 std::move_if_noexcept를 씁니다. 이동 생성자가 noexcept이면 이동하고, 그렇지 않은데 복사가 가능하면 복사합니다. 이동 도중 예외가 나면 원래 버퍼의 원소 일부가 이미 이동되어 롤백할 수 없으므로, push_back의 강한 보장을 지키려면 이렇게 할 수밖에 없습니다. 그래서 이동 연산은 noexcept로 선언하는 것이 관례입니다.

swap

#include <utility>

class MyClass {
public:
    void swap(MyClass& other) noexcept {
        std::swap(data, other.data);
    }
    
private:
    int* data = nullptr;
};

swap은 포인터나 핸들만 맞바꾸므로 예외를 던지지 않습니다. Copy-and-Swap 패턴에서 swap이 noexcept여야 대입 연산자가 Strong Guarantee를 제공할 수 있습니다.

noexcept 조건부 지정

#include <type_traits>

template <typename T>
void process(const T& value) noexcept(std::is_nothrow_copy_constructible_v<T>) {
    T copy = value;  // T의 복사 생성자가 noexcept면 이 함수도 noexcept
    // ...
}

noexcept(조건)은 템플릿에서 타입에 따라 예외 가능성이 달라질 때 씁니다. 조건은 함수 본문이 실제로 수행하는 연산과 맞아야 합니다. 본문이 복사 생성을 한다면 복사 생성자의 예외 명세를 검사해야 하고, 흔히 보이는 noexcept(noexcept(T()))처럼 기본 생성자를 검사하면 실제로 쓰는 연산과 무관한 조건이 되어 약속을 어길 수 있습니다.


Copy-and-Swap, 트랜잭션, 예외 변환 패턴

패턴 1: Copy-and-Swap (Strong Guarantee)

#include <algorithm>
#include <cstddef>
#include <utility>

class Buffer {
    char* data;
    std::size_t size;
    
public:
    explicit Buffer(std::size_t sz) : data(new char[sz]), size(sz) {}
    
    Buffer(const Buffer& other) : data(new char[other.size]), size(other.size) {
        std::copy(other.data, other.data + other.size, data);
    }
    
    ~Buffer() {
        delete[] data;
    }
    
    // Copy-and-Swap으로 Strong Guarantee
    Buffer& operator=(const Buffer& other) {
        Buffer temp(other);  // 복사 (실패 가능, *this는 아직 그대로)
        swap(temp);          // swap (noexcept)
        return *this;        // temp 소멸 시 기존 data 해제
    }
    
    void swap(Buffer& other) noexcept {
        std::swap(data, other.data);
        std::swap(size, other.size);
    }
};

대입 시 temp에 복사한 뒤, 성공하면 swap으로 멤버를 맞바꿉니다. 복사 생성자가 예외를 던지면 *this는 손대지 않은 상태이고, swap은 noexcept이므로 그 이후에는 예외가 나지 않습니다. temp가 소멸할 때 기존 버퍼가 해제됩니다. 자기 대입도 복사본을 만들어 바꿀 뿐이므로 this != &other 검사 없이 올바르게 동작합니다.

이 패턴은 복사 생성자가 깊은 복사를 정확히 구현했다는 전제 위에서만 성립합니다. 복사 생성자를 빠뜨리면 컴파일러가 만든 얕은 복사가 쓰여 temp와 원본이 같은 버퍼를 가리키고, temp 소멸 시 원본의 버퍼가 해제됩니다.

패턴 2: 트랜잭션 스타일

class Database {
public:
    void updateRecord(int id, const std::string& value) {
        // 1. 백업
        auto backup = getRecord(id);
        
        try {
            // 2. 변경 시도
            setRecord(id, value);
            commit();
        } catch (...) {
            // 3. 실패 시 롤백
            setRecord(id, backup);
            throw;  // 예외 재전파
        }
    }
};

현재 값을 백업해 두고 변경을 시도한 뒤, 예외가 나면 백업으로 되돌리고 throw;로 예외를 다시 던져 호출자에게 실패를 알립니다. 이 방식의 약점은 롤백 자체가 실패할 수 있다는 점입니다. catch 블록의 setRecord가 예외를 던지면 원래 예외는 사라지고 상태도 복구되지 않습니다. 롤백 경로는 예외를 던지지 않는 연산으로만 구성하거나, 실제 데이터베이스라면 DB가 제공하는 트랜잭션 롤백에 맡기는 것이 안전합니다.

패턴 3: 예외 변환

#include <exception>
#include <stdexcept>
#include <string>

struct UserDataError : std::runtime_error {
    using std::runtime_error::runtime_error;
};

// 하위 레벨 예외를 상위 레벨 예외로 변환하되, 원인은 중첩해서 보존
void loadUserData(int userId) {
    try {
        database.query("SELECT * FROM users WHERE id = " + std::to_string(userId));
    } catch (const DatabaseException&) {
        std::throw_with_nested(UserDataError("Failed to load user " + std::to_string(userId)));
    }
}

하위 레이어의 예외(DatabaseException)를 상위 레이어의 의미(UserDataError)로 바꿔 던지면, 호출자는 DB 세부 사항을 몰라도 처리할 수 있습니다. 이때 원래 예외를 버리면 연결 실패인지 쿼리 오류인지 같은 원인 정보가 사라집니다. std::throw_with_nested는 새 예외 안에 현재 처리 중인 예외를 함께 담아 던지므로, 로깅하는 쪽에서 std::rethrow_if_nested로 원인을 꺼낼 수 있습니다. 같은 이유로 모든 DB 예외를 “사용자 없음” 같은 특정 의미로 바꾸는 것은 피해야 합니다.

패턴 4: 생성자에서 획득한 자원 정리

// 수동 정리
class ConnectionManual {
    Socket* socket;
    bool connected;
    
public:
    ConnectionManual(const std::string& host, int port) 
        : socket(new Socket()), connected(false) {
        try {
            socket->connect(host, port);
            connected = true;
        } catch (...) {
            delete socket;  // 생성자 실패 시 소멸자가 호출되지 않으므로 직접 정리
            throw;
        }
    }
    
    ~ConnectionManual() {
        if (connected) {
            socket->disconnect();
        }
        delete socket;
    }
};

// unique_ptr 사용
class Connection {
    std::unique_ptr<Socket> socket;
    bool connected;
    
public:
    Connection(const std::string& host, int port) 
        : socket(std::make_unique<Socket>()), connected(false) {
        socket->connect(host, port);  // 예외 발생해도 socket 멤버는 자동 해제
        connected = true;
    }
    
    ~Connection() {
        if (connected) {
            socket->disconnect();
        }
    }
};

첫 번째 클래스는 생성자에서 connect가 실패하면 직접 delete socket 후 다시 던집니다. 두 번째는 unique_ptr 멤버가 이미 완전히 생성되었으므로, 생성자 본문에서 예외가 나면 그 멤버의 소멸자가 호출되어 자동으로 해제됩니다. 자원이 늘어날수록 두 번째 방식의 이점이 커집니다.

패턴 5: 예외 중립 함수

template <typename T>
void processContainer(std::vector<T>& vec) {
    // 이 함수는 T의 연산이 던지는 예외를 그대로 전파
    for (auto& item : vec) {
        item.process();  // T::process()가 예외 던질 수 있음
    }
    // 예외 발생 시 vec는 부분적으로 처리된 상태 (Basic Guarantee)
}

이 함수는 스스로 예외를 만들지 않지만 item.process()가 던지는 예외를 그대로 위로 전파합니다. 이런 함수를 예외 중립적이라고 합니다. 중간에 예외가 나면 그 시점까지 처리된 항목은 변경된 상태로 남으므로 Basic Guarantee만 제공합니다. 템플릿 라이브러리 코드는 대개 이렇게 예외를 삼키지도 바꾸지도 않고 통과시키는 것이 원칙입니다.


메모리 증가, 데드락, 부분 추가: 증상별 해결법

”예외가 나는데 왜 메모리가 계속 늘어나요?”

new/delete를 직접 사용하면서 예외 경로에서 delete가 호출되지 않는 경우입니다.

// ❌ 잘못된 코드
void process() {
    char* buf = new char[1024];
    doWork();  // 예외 시 delete[] 호출 안 됨
    delete[] buf;
}

// ✅ 올바른 코드
void process() {
    auto buf = std::make_unique<char[]>(1024);
    doWork();  // 예외 시 unique_ptr 소멸자에서 자동 해제
}

”mutex가 풀리지 않아 데드락이 발생해요”

lock() 후 예외가 나서 unlock()에 도달하지 못한 경우입니다.

// ❌ 잘못된 코드
void criticalSection() {
    mutex.lock();
    riskyOperation();  // 예외 시 unlock 안 됨
    mutex.unlock();
}

// ✅ 올바른 코드
void criticalSection() {
    std::lock_guard<std::mutex> lock(mutex);
    riskyOperation();
}

”소멸자에서 예외를 던지면 프로그램이 종료돼요”

소멸자는 암시적으로 noexcept이므로 예외가 빠져나가면 std::terminate()가 호출됩니다.

// ❌ 잘못된 코드
~Resource() {
    if (!cleanup()) {
        throw std::runtime_error("Cleanup failed");  // terminate()!
    }
}

// ✅ 올바른 코드
~Resource() {
    try {
        cleanup();
    } catch (const std::exception& e) {
        std::cerr << "Cleanup error: " << e.what() << "\n";
        // 로그만 남기고 삼킴
    }
}

”여러 요소를 추가하다 예외가 나면 일부만 들어가 있어요”

vector::push_back 하나는 강한 보장을 제공하지만(원소의 이동 생성자가 예외를 던질 수 있고 복사가 불가능한 경우는 예외), 여러 번의 push_back을 묶은 루프 전체는 Basic Guarantee입니다. 전부 아니면 전무가 필요하면 앞의 appendAll처럼 복사본에 모두 추가한 뒤 한 번에 교체합니다.

”생성자에서 예외가 나면 이미 할당한 멤버가 누수돼요”

생성자가 예외로 끝나면 그 객체의 소멸자는 호출되지 않습니다. 하지만 이미 완전히 초기화된 멤버와 기반 클래스의 소멸자는 호출됩니다.

// ❌ 잘못된 코드: raw 포인터
class Bad {
    int* a;
    int* b;
public:
    Bad() : a(new int(1)), b(nullptr) {
        b = new int(2);  // 예외 시 a 누수
    }
};

// ✅ 올바른 코드: RAII 멤버
class Good {
    std::unique_ptr<int> a;
    std::unique_ptr<int> b;
public:
    Good() : a(std::make_unique<int>(1)), b(std::make_unique<int>(2)) {
        // b 생성에서 예외 시 a의 소멸자 자동 호출
    }
};

보장 수준 고르기

상황권장 보장이유
소멸자, 해제 함수Nothrow언와인딩 중에 호출되므로 실패할 수 없어야 함
대입 연산자Strong (필요하면)Copy-and-Swap으로 간단히 얻을 수 있음
swap, 이동 연산Nothrow다른 연산의 강한 보장과 vector 재할당 최적화의 기반
컨테이너 수정Basic 또는 Strong복사 비용과 롤백 필요성의 트레이드오프
단순 읽기·산술Nothrow할당이 없으면 대개 자연스럽게 성립

예외 안전성과 성능

GCC·Clang·MSVC(x64)가 쓰는 테이블 기반 예외 구현에서는 예외가 던져지지 않는 정상 경로에 거의 비용이 없습니다. try 블록에 들어가는 순간 뭔가를 실행하는 것이 아니라, “이 주소 범위에서 예외가 나면 어디로 가서 무엇을 소멸시킬지”를 담은 테이블을 바이너리에 따로 넣어 두기 때문입니다. 비용은 실제로 던질 때(테이블 탐색과 스택 언와인딩)와 바이너리 크기에 몰려 있고, 던지는 경로는 일반 반환보다 훨씬 느립니다. 그래서 RAII로 예외 안전하게 만드는 것 자체를 성능 이유로 피할 필요는 없고, 예외를 정상 흐름 제어에 쓰지 않는다는 원칙만 지키면 됩니다.

실제로 비용이 드는 것은 Strong Guarantee를 모든 곳에 요구할 때입니다. copy-and-swap 대입은 대입할 때마다 전체 복사본을 만들기 때문에, 큰 컨테이너를 멤버로 가진 클래스에서는 기존 버퍼를 재사용하는 대입보다 눈에 띄게 느려질 수 있습니다. 표준 라이브러리도 이 트레이드오프를 인정해서, vector::push_back은 Strong을 보장하지만 여러 원소를 중간에 끼워 넣는 insert는 복사·이동이 예외를 던질 수 있으면 Basic만 보장합니다. 연산마다 “실패하면 호출자가 무엇을 기대하는가”를 따져서, 되돌릴 수 있어야 하는 연산(설정 적용, 트랜잭션)에만 Strong을 주고 나머지는 Basic으로 두는 것이 일반적인 선택입니다.

noexcept가 성능에 영향을 주는 대표적인 경로는 std::vector 재할당입니다. 앞에서 본 것처럼 표준 라이브러리는 std::move_if_noexcept로 이동 생성자가 noexcept일 때만 이동하고 아니면 복사합니다. 이동 생성자를 직접 작성하면서 noexcept를 빠뜨리면 컴파일은 멀쩡히 되고, 벡터가 커질 때마다 모든 원소를 복사하는 형태로만 드러납니다. static_assert(std::is_nothrow_move_constructible_v<MyClass>);를 클래스 옆에 두면 이 실수를 컴파일 시점에 잡을 수 있습니다.


Scope Guard, 여러 객체 생성, 스레드 안전 컨테이너

Scope Guard (RAII 확장)

#include <functional>
#include <utility>

class ScopeGuard {
    std::function<void()> on_exit_;
    bool active_ = true;

public:
    explicit ScopeGuard(std::function<void()> f) : on_exit_(std::move(f)) {}
    ~ScopeGuard() {
        if (active_) {
            try {
                on_exit_();
            } catch (...) {
                // 예외 삼킴 (소멸자에서 던지면 안 됨)
            }
        }
    }
    void release() noexcept { active_ = false; }
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;
};

// 사용 예: 작업 실패 시 롤백
void updateConfig(const Config& cfg) {
    auto backup = loadConfig();
    ScopeGuard guard([&] { saveConfig(backup); });  // 예외 시 롤백
    saveConfig(cfg);
    guard.release();  // 성공 시 롤백 방지
}

자원 하나를 위한 클래스를 따로 만들기 번거로울 때, 스코프를 벗어날 때 실행할 동작을 람다로 등록하는 방식입니다. 성공 경로 끝에서 release()를 호출해 롤백을 취소합니다. std::function은 람다를 담을 때 메모리를 할당할 수 있으므로 생성 자체가 예외를 던질 수 있지만, 그 시점에는 아직 아무것도 바뀌지 않았으므로 문제가 되지 않습니다.

여러 객체를 만들다 실패하는 경우

#include <memory>
#include <string>
#include <vector>

// 하나라도 실패하면 이미 만든 연결은 자동 정리
std::vector<std::unique_ptr<Connection>> createConnections(
    const std::vector<std::string>& hosts) {
    std::vector<std::unique_ptr<Connection>> result;
    result.reserve(hosts.size());
    for (const auto& host : hosts) {
        result.push_back(std::make_unique<Connection>(host));
        // 예외 시 result가 소멸하면서 기존 요소들도 정리됨
    }
    return result;
}

make_unique가 만든 임시 unique_ptr은 push_back이 실패해도 스스로 해제되고, result에 이미 들어간 연결들은 예외로 함수를 빠져나갈 때 result의 소멸자가 정리합니다. 미리 reserve해 두었으므로 루프 안의 push_back은 재할당을 하지 않습니다.

스레드 안전 + 예외 안전

#include <mutex>
#include <optional>
#include <vector>

template <typename T>
class ThreadSafeStack {
    mutable std::mutex mtx_;
    std::vector<T> data_;

public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mtx_);
        data_.push_back(std::move(value));  // 실패 시 data_는 그대로
    }

    std::optional<T> tryPop() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (data_.empty()) return std::nullopt;
        std::optional<T> result(std::move(data_.back()));  // 이동 실패 시 data_는 그대로
        data_.pop_back();  // 예외 없음
        return result;
    }
};

push에서 예외가 나든 정상 종료든 lock_guard 소멸자가 unlock을 호출하므로 데드락이 생기지 않습니다. tryPop은 고전적인 함정을 피하도록 순서를 정했습니다. 원소를 컨테이너에서 먼저 제거한 뒤 반환값으로 옮기다 예외가 나면 그 원소는 영영 사라집니다. 반환할 객체를 먼저 완성한 뒤에 pop_back하면, 이동이 실패해도 원소는 컨테이너에 남습니다. std::stack이 top()과 pop()을 분리해 둔 것도 같은 이유입니다. 반환 시 optional 자체가 다시 이동되는 단계까지 완전히 보호하려면 T의 이동 생성자가 noexcept여야 합니다.

로깅 + 예외 전파

#include <iostream>
#include <stdexcept>
#include <string>

void processWithLogging(const std::string& input) {
    std::cerr << "[INFO] Processing: " << input << "\n";
    try {
        doProcess(input);
        std::cerr << "[INFO] Success\n";
    } catch (const std::exception& e) {
        std::cerr << "[ERROR] " << e.what() << "\n";
        throw;  // 원래 타입 그대로 재전파
    }
}

예외를 잡아서 로그만 남기고 다시 던질 때는 throw;를 써야 원래 예외 객체와 동적 타입이 보존됩니다.

참고 자료


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

예외를 쓰지 않는 코드베이스(-fno-exceptions)에서도 예외 안전성을 신경 써야 하나요?

개념은 그대로 적용됩니다. 예외 대신 에러 코드나 std::expected로 중간에 return하는 경로가 생길 뿐이고, “중간에 빠져나가도 자원이 새지 않고 객체가 유효한 상태인가”라는 질문은 똑같습니다. RAII로 자원을 묶어 두면 예외든 조기 return이든 같은 방식으로 정리됩니다. 다만 -fno-exceptions에서는 new 실패나 vector::at 범위 초과가 예외 대신 프로그램 종료로 이어진다는 점은 알아 두세요.

f(std::unique_ptr<T>(new T), g()) 같은 코드는 안전한가요?

C++14까지는 인자 평가 순서가 섞일 수 있어서 new T → g() → unique_ptr 생성 순으로 실행되다 g()가 던지면 T가 샐 수 있었습니다. C++17부터는 각 인자의 평가가 서로 끼어들지 않도록 규칙이 바뀌어 이 누수는 사라졌지만, std::make_unique<T>()로 쓰면 버전과 상관없이 안전하고 타입 이름도 한 번만 쓰면 되므로 여전히 권장됩니다.

어떤 보장 수준을 문서에 적어야 하나요?

공개 API라면 함수마다 최소한 “실패 시 객체 상태”를 적어 두는 것이 좋습니다. 표준 라이브러리 문서가 push_back은 Strong, insert는 조건부 Basic처럼 명시하는 것과 같은 방식입니다. 적어 두지 않으면 호출자는 Strong을 기대하고 롤백 코드를 생략하기 쉽고, 그 기대가 틀렸을 때의 버그는 예외가 실제로 나는 드문 상황에서만 드러납니다.