C++ 예외 안전성 | "예외 발생 시 리소스 누수" Basic·Strong·Nothrow 보장

C++ 예외 안전성 | "예외 발생 시 리소스 누수" Basic·Strong·Nothrow 보장

이 글의 핵심

C++ 예외 안전성에 대한 실전 가이드입니다.

들어가며: 예외 발생 시 메모리가 샜다

“try-catch를 썼는데 왜 메모리 누수가 생기죠?”

파일 처리 코드에 예외 처리를 추가했습니다. 하지만 예외 발생 시 메모리 누수가 생겼습니다.

문제의 코드에서는 new char[1024]로 버퍼를 할당한 뒤, 파일 열기 실패 시 throw로 예외를 던집니다. 예외가 발생하면 delete[] buffer에 도달하지 않고 함수가 스택 언와인딩으로 종료되므로, 그때까지 할당된 buffer는 해제되지 않아 메모리 누수가 됩니다. new/delete를 직접 쓰면 이런 “예외 경로”에서 해제를 빠뜨리기 쉽기 때문에, 버퍼도 std::unique_ptr<char[]> 같은 RAII로 감싸면 예외가 나도 소멸자에서 delete[]가 호출됩니다. 당시에는 “한 번만 throw하는데”라고 생각했지만, 나중에 processFile 안에서 호출하는 다른 함수가 예외를 던지게 되면서 누수가 재현되었습니다.

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;  // 정상 경로에서만 실행됨
}

위 코드 설명: new char[1024]로 할당한 buffer는 예외가 나면 delete[] buffer에 도달하지 못합니다. throw 이후 함수가 스택 언와인딩으로 종료되기 때문에 수동 해제 코드가 실행되지 않아 메모리 누수가 발생합니다. 리소스는 반드시 RAII로 관리해야 예외 경로에서도 안전합니다.

원인:

  • 예외가 던져지면 함수가 즉시 종료
  • delete[] buffer에 도달하지 못함
  • 메모리 누수 발생

실무 정리: 예외 안전성을 보장하려면 “리소스는 RAII로만 관리”하는 것이 가장 단순합니다. raw new/delete나 수동 fclose 같은 코드가 있으면 예외 경로에서 해제가 누락되기 쉽고, std::unique_ptr, std::ifstream, std::lock_guard 등은 소멸자에서 정리되므로 예외가 나도 누수가 발생하지 않습니다.

// 복사해 붙여넣은 뒤: 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가 나도 buffer와 file이 역순으로 정리되므로 누수가 발생하지 않습니다.

실행 결과: Cannot open file(또는 해당 예외 메시지)가 stderr에 출력됩니다.

이 경험으로 예외 안전성의 중요성을 깨달았습니다.
예외가 나면 함수가 중간에 빠져나가므로, 그 전에 획득한 리소스(메모리, 파일, 락)는 반드시 소멸자나 catch에서 정리되어야 합니다. RAII로 “획득은 생성자, 해제는 소멸자”에 묶어 두면 예외가 나도 스코프를 벗어날 때 자동으로 정리되므로, 이 글에서 다루는 basic/strong/nothrow 보장을 실전에서 적용하는 데 도움이 됩니다.

이 글을 읽으면:

  • 예외 안전성의 세 가지 수준을 이해할 수 있습니다.
  • RAII와 예외를 안전하게 결합하는 방법을 알 수 있습니다.
  • noexcept 지정자의 의미와 사용법을 익힐 수 있습니다.
  • 실전에서 예외 안전한 코드를 작성할 수 있습니다.

목차

  1. 실무에서 겪는 문제 시나리오
  2. 예외 안전성이란
  3. 세 가지 보장 수준
  4. RAII와 예외 결합
  5. noexcept 지정자
  6. 실전 패턴
  7. 자주 발생하는 문제와 해결법
  8. 모범 사례와 선택 가이드
  9. 프로덕션 패턴

1. 실무에서 겪는 문제 시나리오

시나리오 1: 멀티스레드에서 락이 해제되지 않음

서버가 멀티스레드로 동작 중입니다. mutex.lock()processRequest()에서 예외가 나면 unlock()에 도달하지 못해 데드락이 발생합니다.

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

해결: std::lock_guard 등 RAII로 락을 관리합니다.

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

시나리오 2: 컨테이너 수정 중 예외로 인한 상태 불일치

std::vector에 여러 요소를 추가하는 중 예외가 나면, 일부만 추가된 중간 상태가 남아 재시도나 롤백이 어렵습니다.

// Basic Guarantee: vec는 유효하지만 일부만 추가됨
void addItems(std::vector<Item>& vec, const std::vector<Item>& items) {
    for (const auto& item : items) {
        vec.push_back(item);  // 3번째에서 예외 시 vec는 2개만 추가됨
    }
}

해결: Strong Guarantee를 위해 복사 후 한 번에 교체하는 방식입니다.

시나리오 3: 생성자에서 예외 시 부분 생성된 객체

생성자에서 여러 리소스를 획득하다가 중간에 예외가 나면, 이미 획득한 리소스가 해제되지 않을 수 있습니다.

// ❌ 나쁜 예: 생성자에서 예외 시 socket1만 해제되고 socket2는 누수
class DualConnection {
    Socket* socket1;
    Socket* socket2;
public:
    DualConnection() : socket1(new Socket()), socket2(nullptr) {
        socket1->connect("host1");
        socket2 = new Socket();  // 여기서 예외 시 socket1 누수
        socket2->connect("host2");
    }
};

해결: 멤버를 std::unique_ptr 등 RAII로 두면, 생성자에서 예외가 나도 이미 초기화된 멤버의 소멸자가 호출됩니다.

시나리오 4: 소멸자에서 예외를 던짐

소멸자에서 예외를 던지면 스택 언와인딩 중에 std::terminate()가 호출됩니다.

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

해결: 소멸자에서는 예외를 던지지 않고, try-catch로 잡아 로그만 남깁니다.


2. 예외 안전성이란

예외 발생 시 프로그램 상태

예외가 던져지면:

  1. 현재 함수 실행 중단
  2. 스택 언와인딩 (Stack Unwinding) 시작
  3. 지역 객체들의 소멸자가 역순으로 호출됨
  4. 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은 정상적으로 소멸됨
    }
}

위 코드 설명: func3에서 throw가 나면 r3 소멸자가 먼저 호출되고, 그다음 func2의 r2, func1의 r1 순으로 역순 소멸자가 호출됩니다(스택 언와인딩). try-catch는 func1에만 있지만, 중간에 있는 Resource 객체들은 모두 정상적으로 정리됩니다.

예외 안전성이란:

  • 예외가 발생해도 리소스 누수가 없고
  • 프로그램이 유효한 상태를 유지하는 것

스택 언와인딩 흐름도

flowchart TD
    subgraph Throw["throw 발생"]
        A[func3: throw]
    end
    
    subgraph Unwind["스택 언와인딩"]
        B[r3 소멸자 호출]
        C[func2로 전파]
        D[r2 소멸자 호출]
        E[func1로 전파]
        F[r1 소멸자 호출]
    end
    
    subgraph Catch["catch 블록"]
        G[예외 처리]
    end
    
    A --> B --> C --> D --> E --> F --> G

위 다이어그램 설명: 예외가 던져지면 가장 안쪽 스코프의 지역 객체(r3)부터 역순으로 소멸자가 호출되고, catch 블록을 찾을 때까지 호출 스택을 거슬러 올라갑니다.


3. 세 가지 보장 수준

Level 1: Basic Guarantee (기본 보장)

정의: 예외 발생 시 리소스 누수 없음, 객체는 유효하지만 내용은 변경될 수 있음

void basicSafe(std::vector<int>& vec, int value) {
    vec.push_back(value);  // 실패 시 vec는 유효하지만 변경됨
}

int main() {
    std::vector<int> vec = {1, 2, 3};
    try {
        basicSafe(vec, 4);  // 메모리 부족 시 예외
    } catch (...) {
        // vec는 유효하지만 4가 추가되었을 수도, 안 되었을 수도
    }
}

위 코드 설명: vec.push_back(value)가 메모리 부족 등으로 예외를 던지면, vec는 그 전까지의 상태는 유효하지만 “4가 들어갔는지 안 들어갔는지”는 보장되지 않을 수 있습니다. 리소스 누수만 없고 객체는 파괴되지 않는다는 것이 Basic Guarantee입니다.

특징:

  • 최소한의 보장
  • 리소스 누수만 없으면 됨
  • 객체 상태는 변경될 수 있음

완전한 Basic Guarantee 예시: 커스텀 컨테이너

#include <vector>
#include <stdexcept>
#include <memory>

template <typename T>
class SimpleVector {
    std::unique_ptr<T[]> data_;
    size_t size_ = 0;
    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);
            for (size_t i = 0; i < size_; ++i) {
                new_data[i] = data_[i];  // 복사 생성자 예외 가능
            }
            data_ = std::move(new_data);
            capacity_ = new_cap;
        }
        data_[size_++] = value;  // 대입 연산자 예외 가능
    }
};

위 코드 설명: push_back에서 메모리 재할당이나 복사/대입 중 예외가 나면, data_는 유효하고 size_는 변경되지 않았을 수 있습니다. 리소스 누수는 없지만(unique_ptr이 정리), “정확히 어떤 상태인지”는 보장되지 않습니다. 이게 Basic Guarantee입니다.

Level 2: Strong Guarantee (강한 보장)

정의: 예외 발생 시 상태가 변경 전으로 롤백됨 (commit or rollback)

void strongSafe(std::vector<int>& vec, int value) {
    std::vector<int> temp = vec;  // 복사
    temp.push_back(value);        // 실패 가능
    vec = std::move(temp);        // 성공 시에만 반영 (noexcept)
}

int main() {
    std::vector<int> vec = {1, 2, 3};
    try {
        strongSafe(vec, 4);
    } catch (...) {
        // vec는 {1, 2, 3} 그대로 (변경 전 상태)
    }
}

위 코드 설명: temp에 복사한 뒤 temp에만 push_back하고, 성공하면 vec = std::move(temp)로 한 번에 바꿉니다. push_back이 예외를 던지면 vec는 건드리지 않았으므로 원래 상태 그대로이고, move 대입은 noexcept라서 예외 없이 완료됩니다. 따라서 “전부 성공하거나 전부 롤백”인 Strong Guarantee가 됩니다.

특징:

  • 트랜잭션 의미론
  • 성공하거나 실패하거나 (중간 상태 없음)
  • 복사 비용이 들 수 있음

완전한 Strong Guarantee 예시: 여러 요소 추가

#include <vector>
#include <algorithm>

// 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의 move 대입은 noexcept이므로 이 시점 이후에는 예외가 나지 않습니다. 중간에 예외가 나면 vec는 전혀 건드리지 않았으므로 Strong Guarantee가 만족됩니다.

Level 3: Nothrow Guarantee (예외 없음 보장)

정의: 절대 예외를 던지지 않음

void nothrowOp(int& value) noexcept {
    value++;  // 예외 없음
}

int main() {
    int x = 5;
    nothrowOp(x);  // 예외 걱정 없음
}

위 코드 설명: noexcept로 선언된 함수는 예외를 던지지 않음을 약속합니다. 내부에서 예외가 발생하면 catch되지 않고 std::terminate()가 호출되어 프로그램이 종료됩니다. 단순 연산만 하므로 예외가 나지 않는 경우에 사용하는 Nothrow Guarantee 예시입니다.

특징:

  • noexcept 키워드로 명시
  • 예외를 던지면 std::terminate() 호출 (프로그램 종료)
  • 최고 수준의 보장

완전한 Nothrow Guarantee 예시: swap, 포인터 조작

#include <utility>
#include <cstdint>

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::vector 재할당 시 move 대신 복사를 쓰지 않게 됩니다.

세 가지 보장 수준 비교표

보장 수준리소스 누수객체 상태사용 예
Basic없음유효하나 변경될 수 있음vector::push_back, 일반 연산
Strong없음변경 전으로 롤백Copy-and-Swap, 트랜잭션
Nothrow없음변경 없음 (예외 자체 없음)swap, move, 소멸자

보장 수준 선택 흐름도

flowchart TD
    A[연산 수행] --> B{예외 발생?}
    B -->|아니오| C[정상 완료]
    B -->|예| D{리소스 누수?}
    D -->|있음| E[❌ 보장 없음]
    D -->|없음| F{객체 상태}
    F -->|원래대로| G[✅ Strong Guarantee]
    F -->|유효하나 변경됨| H[✅ Basic Guarantee]
    A --> I{예외 가능?}
    I -->|아니오| J[✅ Nothrow Guarantee]

4. RAII(Resource Acquisition Is Initialization, 리소스 획득은 초기화)와 예외 결합

자동 리소스 정리

나쁜 예: 수동 정리

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를 호출해야 합니다. someError 분기와 정상 종료 경로에 중복 정리 코드가 들어가고, 새 예외 경로가 생기면 해제를 빼먹기 쉽습니다.

좋은 예: RAII

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::make_unique는 소멸자에서 파일 닫기와 메모리 해제를 수행합니다. someError에서 throw를 해도 스코프를 벗어날 때 buffer, file이 역순으로 정리되므로 정리 코드를 여러 곳에 쓸 필요가 없습니다.

여러 리소스 관리

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

위 코드 설명: file2 열기 실패로 throw가 나면 file1을 가진 unique_ptr이 먼저 소멸하면서 파일이 닫힙니다. 여러 리소스를 RAII 객체로 두면 획득의 역순으로 자동 정리되므로, 예외 경로에서도 누수가 나지 않습니다.

커스텀 RAII 래퍼

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 useFile() {
    FileHandle file("data.txt", "r");  // RAII
    // 예외 발생해도 자동으로 닫힘
    processFile(file.get());
}

위 코드 설명: FileHandle은 생성자에서 fopen, 소멸자에서 fclose를 호출합니다. 복사는 막고, useFile에서 예외가 나든 정상 종료든 스코프를 벗어날 때 소멸자가 호출되어 파일이 닫힙니다. C 스타일 FILE*를 RAII로 감싸는 전형적인 패턴입니다.


5. noexcept 지정자

noexcept의 의미

void safeFunction() noexcept {
    // 이 함수는 예외를 던지지 않음을 보장
}

void riskyFunction() {
    // noexcept 없음: 예외를 던질 수 있음
}

위 코드 설명: noexcept를 붙이면 “이 함수는 예외를 던지지 않는다”는 계약을 컴파일러와 호출자에게 알립니다. noexcept 함수 안에서 예외가 나면 스택 언와인딩 없이 std::terminate()가 호출되므로, 예외를 절대 던지지 않는 연산에만 사용해야 합니다.

noexcept 위반 시:

void func() noexcept {
    throw std::runtime_error("Error!");  // noexcept 위반
}

int main() {
    func();  // std::terminate() 호출, 프로그램 종료
}

위 코드 설명: noexcept로 선언된 함수에서 throw가 실행되면 C++ 런타임이 std::terminate()를 호출해 프로그램을 종료합니다. catch로 잡을 수 없으므로, noexcept는 “예외가 나지 않음을 보장할 수 있는” 함수에만 지정해야 합니다.

noexcept가 필수인 경우

1. 소멸자

class Resource {
public:
    ~Resource() noexcept {  // 소멸자는 기본적으로 noexcept
        // 예외를 던지면 안 됨
        try {
            cleanup();
        } catch (...) {
            // 에러를 삼킴
        }
    }
};

위 코드 설명: 소멸자는 예외가 나도 스택 언와인딩 중에 호출되므로, 소멸자에서 예외를 던지면 위험합니다. 그래서 소멸자는 기본적으로 noexcept처럼 동작하며, 정리 중 에러는 try-catch로 잡아 로그만 남기고 삼키는 방식이 안전합니다.

2. move 생성자/대입 연산자

class MyClass {
public:
    MyClass(MyClass&& other) noexcept {
        // move는 noexcept여야 std::vector 등에서 최적화됨
        data = other.data;
        other.data = nullptr;
    }
    
    MyClass& operator=(MyClass&& other) noexcept {
        if (this != &other) {
            delete data;
            data = other.data;
            other.data = nullptr;
        }
        return *this;
    }
    
private:
    int* data = nullptr;
};

위 코드 설명: std::vector 등이 재할당할 때 요소를 옮길 때 move 생성자/대입을 쓰는데, 이들이 noexcept일 때만 move를 사용하고 아니면 복사를 씁니다. move가 실패하면 Strong Guarantee를 지키기 어렵기 때문에, move 연산은 noexcept로 선언하는 것이 표준 관례입니다.

이유: std::vector가 재할당 시 move가 noexcept면 move 사용, 아니면 복사 사용

3. swap

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를 제공할 수 있으므로, swap에도 noexcept를 붙이는 것이 좋습니다.

noexcept 조건부 지정

template <typename T>
void process(T value) noexcept(noexcept(T())) {
    // T의 생성자가 noexcept면 이 함수도 noexcept
    T copy = value;
}

위 코드 설명: noexcept(noexcept(T()))는 “T의 기본 생성자가 noexcept이면 이 함수도 noexcept”라는 조건부 지정입니다. 템플릿에서 타입에 따라 예외를 던질 수 있는지가 달라질 때, 호출자에게 정확한 예외 명세를 전달할 수 있습니다.


6. 실전 패턴

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

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

위 코드 설명: 대입 시 temp에 복사한 뒤(temp 생성이 예외를 던질 수 있음), 성공하면 swap으로 멤버를 맞바꿉니다. swap은 noexcept이므로 그 시점 이후에는 예외가 나지 않고, temp 소멸 시 기존 data가 해제됩니다. 복사 실패 시 원래 객체는 그대로이므로 Strong Guarantee가 만족됩니다.

패턴 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;  // 예외 재전파
        }
    }
};

위 코드 설명: 먼저 현재 값을 backup에 두고, setRecord·commit을 시도합니다. 예외가 나면 catch에서 backup으로 롤백한 뒤 throw로 예외를 다시 던져 호출자에게 실패를 알립니다. “시도 → 실패 시 원상 복구” 트랜잭션 스타일로 Strong Guarantee를 구현한 예입니다.

패턴 3: 예외 변환

// 하위 레벨 예외를 상위 레벨 예외로 변환
void loadUserData(int userId) {
    try {
        // 데이터베이스 예외 (구체적)
        database.query("SELECT * FROM users WHERE id = " + std::to_string(userId));
    } catch (const DatabaseException& e) {
        // 애플리케이션 예외 (추상적)로 변환
        throw UserNotFoundException("User not found: " + std::to_string(userId));
    }
}

위 코드 설명: 하위 레이어(DatabaseException)의 예외를 상위 레이어용(UserNotFoundException)으로 감싸서 다시 던집니다. 호출자가 DB 상세보다 “사용자를 찾을 수 없음”이라는 도메인 의미로 처리할 수 있게 하며, 예외 타입을 계층에 맞게 변환하는 패턴입니다.

패턴 4: 리소스 획득 후 예외 안전 보장

class Connection {
    Socket* socket;
    bool connected;
    
public:
    Connection(const std::string& host, int port) 
        : socket(new Socket()), connected(false) {
        try {
            socket->connect(host, port);
            connected = true;
        } catch (...) {
            delete socket;  // 생성자 실패 시 정리
            throw;
        }
    }
    
    ~Connection() {
        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();
        }
        // socket 자동 해제
    }
};

위 코드 설명: 첫 번째 Connection은 생성자에서 connect 실패 시 수동으로 delete socket 후 재throw합니다. 두 번째는 unique_ptr을 써서 socket을 RAII로 관리하므로, connect에서 예외가 나도 소멸자에서 자동 해제되어 코드가 단순하고 누수 위험이 없습니다.

패턴 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만 제공하며, 호출자가 예외를 처리하거나 더 위로 전파할 수 있습니다.


7. 자주 발생하는 문제와 해결법

문제 1: “예외가 나는데 왜 메모리가 계속 늘어나요?”

원인: 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 소멸자에서 자동 해제
}

문제 2: “mutex가 풀리지 않아 데드락이 발생해요”

원인: lock() 후 예외가 나면 unlock()에 도달하지 못함.

해결법:

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

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

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

원인: 소멸자는 기본적으로 noexcept처럼 동작. 예외를 던지면 std::terminate() 호출.

해결법:

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

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

문제 4: “vector::push_back 중 예외가 나면 일부만 추가돼요”

원인: push_back은 Basic Guarantee. 메모리 부족 등으로 예외 시 중간 상태 가능.

해결법:

// Strong Guarantee가 필요하면: 복사 후 한 번에 교체
void addSafely(std::vector<Item>& vec, const Item& item) {
    auto temp = vec;
    temp.push_back(item);
    vec = std::move(temp);  // 성공 시에만 반영
}

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

원인: 생성자에서 예외 시 해당 객체의 소멸자는 호출되지 않음. 하지만 이미 완전히 초기화된 멤버의 소멸자는 호출됨.

해결법:

// ❌ 잘못된 코드: 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의 소멸자 자동 호출
    }
};

8. 모범 사례와 선택 가이드

보장 수준 선택 가이드

상황권장 보장이유
리소스 관리 (RAII)Nothrow소멸자에서 예외를 던지면 안 됨
대입 연산자StrongCopy-and-Swap 패턴
swap, moveNothrow표준 라이브러리와의 호환, 최적화
컨테이너 수정Basic 또는 Strong성능 vs 안전성 트레이드오프
단순 읽기/연산Nothrow예외 없음이 보장되는 경우

체크리스트: 예외 안전한 코드 작성

  • new/delete 대신 std::unique_ptr, std::shared_ptr 사용
  • fopen/fclose 대신 std::ifstream, std::ofstream 사용
  • lock/unlock 대신 std::lock_guard, std::scoped_lock 사용
  • 소멸자에서 예외를 던지지 않음 (try-catch로 감싸기)
  • move 생성자/대입 연산자에 noexcept 지정
  • swap 구현 시 noexcept 지정
  • Strong Guarantee가 필요하면 Copy-and-Swap 또는 트랜잭션 스타일

예외 안전성과 성능

  • Basic Guarantee: 복사 없이 직접 수정 가능
  • Strong Guarantee: 복사/임시 객체 필요 → 비용 증가
  • Nothrow Guarantee: 예외 경로 없음 → 컴파일러 최적화 유리

9. 프로덕션 패턴

패턴 A: 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() { 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();  // 성공 시 롤백 방지
}

패턴 B: 예외 안전한 팩토리

#include <memory>
#include <vector>

template <typename T, typename... Args>
std::unique_ptr<T> makeUniqueSafe(Args&&... args) {
    auto ptr = std::make_unique<T>(std::forward<Args>(args)...);
    return ptr;  // 생성 성공 시에만 반환, 실패 시 예외
}

// 여러 리소스 생성 시: 하나라도 실패하면 이전 것들 자동 정리
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;
}

패턴 C: 스레드 안전 + 예외 안전

#include <mutex>
#include <memory>
#include <optional>

template <typename T>
class ThreadSafeQueue {
    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));  // Basic Guarantee
    }

    std::optional<T> tryPop() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (data_.empty()) return std::nullopt;
        T value = std::move(data_.back());
        data_.pop_back();  // noexcept
        return value;
    }
};

위 코드 설명: push에서 예외가 나든 정상 종료든 lock_guard 소멸자가 unlock을 호출하므로 데드락이 발생하지 않습니다.

패턴 D: 로깅 + 예외 전파

#include <iostream>
#include <stdexcept>

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;  // 예외 재전파
    }
}

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리
  • C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
  • C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception

이 글에서 다루는 키워드 (관련 검색어)

C++ 예외 안전성, strong guarantee, RAII 예외, noexcept, 예외 스펙 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
Basic Guarantee리소스 누수 없음, 상태는 유효
Strong Guarantee상태가 변경 전으로 롤백
Nothrow Guarantee예외를 절대 던지지 않음
RAII + 예외소멸자가 자동으로 리소스 정리
noexcept소멸자, move, swap에 필수
패턴Copy-and-Swap, 트랜잭션, 변환

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 예외 안전성(exception safety) 완벽 가이드. Basic·Strong·Nothrow 세 가지 보장 수준, RAII와 예외 결합 패턴, noexcept 지정자 사용법, 예외 발생 시 리소스 누수 방… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: RAII로 리소스를 관리하면 예외가 나도 누수가 나지 않습니다. 다음으로 커스텀 예외(#8-3)를 읽어보면 좋습니다.

다음 글: C++ 실전 가이드 #8-3: 커스텀 예외와 성능 - 커스텀 예외 클래스와 예외 성능을 다룹니다.


관련 글

  • C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
  • C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception
  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing