본문으로 건너뛰기 C++ Exception Safety | '예외 안전성' 가이드

C++ Exception Safety | '예외 안전성' 가이드

C++ Exception Safety | '예외 안전성' 가이드

이 글의 핵심

C++ Exception Safety: "예외 안전성" 가이드. 예외 안전성이란?·보장 수준.

예외 안전성이란?

예외 안전성이란 함수 실행 도중 예외가 발생하더라도 프로그램이 자원 누수나 손상된 상태에 빠지지 않도록 보장하는 설계 원칙입니다. C++는 예외가 발생하면 현재 실행 중인 함수를 즉시 벗어나 스택을 거슬러 올라가며 되감기(stack unwinding)를 수행하는데, 이 과정에서 deleteclose 같은 정리 코드가 실행되지 못하고 건너뛰어질 수 있습니다. 아래 예시에서 new로 직접 할당한 ptrprocess()가 예외를 던지는 순간 delete ptr에 도달하지 못하고 함수를 벗어나 버려 메모리가 영구히 누수됩니다. 반면 std::make_unique로 관리되는 스마트 포인터는 예외로 인해 스코프를 벗어나는 순간에도 소멸자가 자동으로 호출되어 자원을 해제하므로, 예외 발생 여부와 무관하게 안전합니다.

// ❌ 예외 안전하지 않음
void func() {
    int* ptr = new int(10);
    process();  // 예외 발생 시 누수
    delete ptr;
}

// ✅ 예외 안전
void func() {
    auto ptr = std::make_unique<int>(10);
    process();  // 예외 발생해도 자동 정리
}

보장 수준

예외 안전성은 흔히 세 단계로 구분해서 이야기합니다. 기본 보장은 예외가 나도 최소한 자원 누수가 없고 객체가 파괴 가능하며 불변식이 깨지지 않는다는 것을 뜻하며, 사실상 모든 공개 API가 만족해야 할 최소 기준입니다. 강한 보장은 여기서 한 걸음 더 나아가 연산이 “완전히 성공하거나 아무 일도 없었던 것처럼 원래 상태로 되돌아간다”는 것을 보장하는 수준으로, 트랜잭션처럼 중간 상태가 존재해서는 안 되는 연산에 필요합니다. nothrow 보장은 가장 강력한 수준으로, 해당 연산이 애초에 예외를 던지지 않음을 noexcept로 명시하는 것이며 소멸자·swap·이동 연산처럼 실패하면 안 되는 연산에 요구됩니다. 어떤 API를 설계할 때는 이 세 수준 중 어느 것을 약속할지 미리 정하고 문서화하는 것이 호출자에게도, 유지보수하는 입장에서도 훨씬 예측 가능한 코드를 만듭니다.

// 1. 기본 보장 (Basic Guarantee)
// - 자원 누수 없음
// - 불변식 유지

// 2. 강한 보장 (Strong Guarantee)
// - 성공 또는 원래 상태
// - 원자적 연산

// 3. nothrow 보장
// - 예외 발생 안함
// - noexcept

실전 예시

세 가지 보장 수준은 추상적인 개념처럼 느껴지기 쉽지만, 실제로는 같은 resize 함수를 어떻게 구현하느냐에 따라 결과가 완전히 달라집니다. 아래 예시들은 동일한 버퍼 리사이즈 문제를 기본 보장, 강한 보장, nothrow 보장, 그리고 이 세 가지를 통합적으로 다루는 copy-and-swap 패턴 순서로 비교합니다.

예시 1: 기본 보장

resize 구현은 새 메모리를 할당한 뒤 기존 데이터를 복사하고, 마지막에 예전 메모리를 해제하는 순서로 동작합니다. new int[newSize]std::copy 도중 예외가 발생하면 함수는 즉시 중단되지만, 이 시점까지 datasize는 전혀 건드리지 않았으므로 객체는 여전히 유효한 이전 상태를 유지합니다. 다만 새로 할당한 newData는 지역 변수이므로 예외가 나면 그대로 누수됩니다. 즉 이 코드는 객체의 불변식은 지키지만(기본 보장), 할당한 임시 자원까지 완벽하게 정리하지는 못하는 절충안입니다.

class Buffer {
    int* data;
    size_t size;
    
public:
    void resize(size_t newSize) {
        int* newData = new int[newSize];  // 예외 가능
        
        // 복사
        size_t copySize = std::min(size, newSize);
        std::copy(data, data + copySize, newData);
        
        delete[] data;  // 기존 자원 해제
        data = newData;
        size = newSize;
    }
};

예시 2: 강한 보장

앞선 예시의 누수 문제를 해결하려면 try/catch로 복사 과정을 감싸고, 실패 시 새로 할당한 메모리를 직접 정리한 뒤 예외를 다시 던지면 됩니다. std::copy가 예외를 던지는 순간 catch 블록이 newData를 즉시 delete[]하므로 자원 누수가 사라지고, throw로 원래 예외를 그대로 재전파해 호출자가 실패 사실을 알 수 있게 합니다. 복사가 성공한 뒤에야 기존 data를 해제하고 포인터를 교체하므로, 함수가 실패하면 객체는 정확히 리사이즈 이전 상태 그대로 남고 성공하면 완전히 새 상태로 바뀝니다. 이것이 바로 “전부 성공하거나 전부 그대로”라는 강한 보장의 정의입니다.

class Buffer {
    int* data;
    size_t size;
    
public:
    void resize(size_t newSize) {
        int* newData = new int[newSize];
        
        try {
            std::copy(data, data + std::min(size, newSize), newData);
        } catch (...) {
            delete[] newData;  // 실패 시 정리
            throw;  // 재던지기
        }
        
        delete[] data;
        data = newData;
        size = newSize;
    }
};

예시 3: nothrow 보장

swap과 이동 생성자는 포인터 몇 개를 맞바꾸거나 옮기는 것이 전부이므로, 실패할 이유가 없는 연산입니다. 이런 연산에 noexcept를 명시하면 컴파일러와 표준 라이브러리 모두에게 “이 연산은 절대 예외를 던지지 않는다”는 명확한 계약을 전달하게 되고, 뒤에서 다룰 copy-and-swap 패턴이나 std::vector의 재할당 로직이 이 계약을 근거로 더 빠른 경로를 선택할 수 있습니다. Widget::swapnoexcept가 아니라면, 이 스왑을 활용하는 강한 보장 대입 연산자조차 강한 보장을 잃어버릴 수 있으므로, nothrow 보장은 다른 두 보장 수준을 뒷받침하는 기반이 되는 셈입니다.

class Widget {
public:
    // nothrow 보장
    void swap(Widget& other) noexcept {
        std::swap(data, other.data);
    }
    
    // 이동 연산 (noexcept 권장)
    Widget(Widget&& other) noexcept 
        : data(other.data) {
        other.data = nullptr;
    }
    
private:
    int* data;
};

예시 4: copy-and-swap

copy-and-swap은 앞서 본 강한 보장과 nothrow 보장을 조합해 대입 연산자를 우아하게 구현하는 관용구입니다. 먼저 Buffer temp(other)로 복사본을 만드는데, 이 단계에서 예외가 발생해도 *this는 아직 전혀 손대지 않았으므로 안전합니다. 복사가 성공하면 noexcept로 선언된 swap(temp)를 호출해 temp*this의 내부 포인터를 맞바꾸는데, 이 단계는 절대 실패하지 않으므로 여기서부터는 대입이 확정적으로 완료됩니다. 함수가 끝나면 temp가 소멸되면서 원래 *this가 갖고 있던 옛 자원을 자동으로 정리해 주므로, 명시적인 delete조차 필요 없이 예외 안전성과 자원 관리를 동시에 해결하는 것이 이 패턴의 장점입니다.

class Buffer {
    int* data;
    size_t size;
    
public:
    Buffer& operator=(const Buffer& other) {
        // 강한 보장
        Buffer temp(other);  // 복사 (예외 가능)
        swap(temp);          // nothrow
        return *this;
    }
    
    void swap(Buffer& other) noexcept {
        std::swap(data, other.data);
        std::swap(size, other.size);
    }
};

RAII 활용

RAII(Resource Acquisition Is Initialization)는 예외 안전성을 확보하는 가장 근본적인 도구입니다. 자원의 획득과 해제를 객체의 생성자·소멸자에 묶어두면, 함수가 정상적으로 끝나든 예외로 중간에 빠져나가든 스코프를 벗어나는 순간 소멸자가 항상 호출되어 자원을 정리해 줍니다. 아래 Transaction 클래스는 이 원리를 트랜잭션 처리에 적용한 예시로, 생성자에서 트랜잭션을 시작하고 commit()이 명시적으로 호출되지 않은 채 객체가 소멸되면 소멸자가 자동으로 rollback()을 실행합니다. 즉 commit() 이전에 예외가 발생해 함수를 빠져나가더라도, 스택 되감기 과정에서 Transaction 객체의 소멸자가 호출되어 트랜잭션이 안전하게 롤백되는 것이 보장됩니다.

class Transaction {
public:
    Transaction() {
        begin();
    }
    
    ~Transaction() {
        if (!committed) {
            rollback();  // 예외 시 롤백
        }
    }
    
    void commit() {
        // ...
        committed = true;
    }
    
private:
    bool committed = false;
    void begin() {}
    void rollback() noexcept {}
};

자주 발생하는 문제

지금까지 본 원칙들을 알고 있어도 실제 코드에서는 놓치기 쉬운 패턴들이 있습니다. 아래 네 가지는 실무에서 예외 안전성이 깨지는 가장 흔한 사례입니다.

문제 1: 부분 변경

객체의 여러 멤버를 순서대로 바꾸는 코드는 중간에 예외가 발생하면 객체가 “절반만 갱신된” 어중간한 상태로 남는 문제를 안고 있습니다. 아래 update 함수에서 d.y = compute()가 예외를 던지면 d.x는 이미 바뀐 채로, d.z는 원래 값 그대로 남아 객체의 불변식이 깨질 수 있습니다. 이를 해결하는 가장 단순한 방법은 원본을 직접 바꾸지 않고 임시 복사본(temp)에 모든 변경을 먼저 적용한 뒤, 모든 계산이 성공적으로 끝난 마지막 순간에 한 번의 대입으로 원본에 반영하는 것입니다. 이렇게 하면 compute()가 예외를 던지더라도 원본 d는 전혀 건드려지지 않은 채 남아, 사실상 강한 보장에 가까운 결과를 얻을 수 있습니다.

// ❌ 부분 변경 후 예외
void update(Data& d) {
    d.x = 10;
    d.y = compute();  // 예외 발생
    d.z = 30;  // 실행 안 됨
}

// ✅ 임시 객체 사용
void update(Data& d) {
    Data temp = d;
    temp.x = 10;
    temp.y = compute();
    temp.z = 30;
    d = temp;  // 원자적 대입
}

문제 2: 자원 누수

new로 직접 자원을 두 개 이상 할당하는 코드는 자원 사이에 숨은 순서 의존성을 만들어 냅니다. 아래 코드에서 r1을 성공적으로 할당한 직후 r2를 할당하다가 예외가 발생하면, r1을 가리키던 포인터는 지역 변수인 채로 함수가 종료되어 그 메모리를 해제할 기회가 영영 사라집니다. 이런 패턴은 자원이 두세 개일 때는 당장 눈에 띄지 않지만, 자원 개수가 늘어나거나 코드가 리팩터링되면서 순서가 바뀔 때마다 새로운 누수 지점이 생길 위험이 있습니다. std::make_unique처럼 RAII로 감싸서 할당하면 각 자원의 수명이 해당 스마트 포인터 변수의 스코프에 자동으로 묶이므로, 어느 지점에서 예외가 발생하든 이미 생성된 자원들은 순서와 무관하게 안전하게 정리됩니다.

// ❌ 수동 관리
void func() {
    Resource* r1 = new Resource();
    Resource* r2 = new Resource();  // 예외 시 r1 누수
    
    delete r1;
    delete r2;
}

// ✅ RAII
void func() {
    auto r1 = std::make_unique<Resource>();
    auto r2 = std::make_unique<Resource>();
}

문제 3: 소멸자 예외

소멸자에서 예외를 던지는 것은 C++에서 가장 위험한 실수 중 하나입니다. 예외로 인한 스택 되감기 도중에는 이미 스택에 쌓여 있던 객체들의 소멸자가 연쇄적으로 호출되는데, 이 소멸자들 중 하나가 또 다른 예외를 던지면 동시에 두 개의 예외를 처리해야 하는 모순된 상황에 빠져 런타임은 std::terminate를 호출해 프로그램을 즉시 종료시켜 버립니다. 그래서 소멸자는 관례적으로 예외를 절대 밖으로 내보내지 않아야 하며, C++11부터는 소멸자가 기본적으로 암묵적인 noexcept로 취급됩니다. 정리 작업이 실패할 가능성이 있다면 아래 Good 클래스처럼 소멸자 내부에서 try/catch로 예외를 직접 삼키고, 필요하다면 로그만 남기는 방식으로 처리하는 것이 안전합니다.

// ❌ 소멸자에서 예외
class Bad {
public:
    ~Bad() {
        throw std::runtime_error("에러");  // 위험
    }
};

// ✅ noexcept 소멸자
class Good {
public:
    ~Good() noexcept {
        try {
            cleanup();
        } catch (...) {
            // 예외 삼킴
        }
    }
};

문제 4: 다중 자원

앞서 본 자원 누수 문제는 자원 종류나 개수가 늘어날수록 더 치명적으로 커집니다. 파일 핸들, 뮤텍스 락, 네트워크 소켓처럼 서로 다른 종류의 자원을 한 함수 안에서 순서대로 여러 개 획득하는 경우, 몇 번째 자원을 할당하다가 예외가 발생했는지에 따라 정리해야 할 자원의 개수와 순서가 매번 달라져 수동으로 관리하기가 사실상 불가능해집니다. 이런 복잡성을 근본적으로 없애는 방법은 각 자원을 RAII 래퍼(스마트 포인터, std::lock_guard, std::fstream 등)로 감싸 개별 변수로 선언하는 것입니다. 그러면 각 자원의 정리 책임이 해당 변수의 소멸자에게 완전히 위임되므로, 예외가 어느 지점에서 발생하든 이미 생성된 자원들만 정확히 역순으로 정리되는 것이 언어 차원에서 보장됩니다.

// ❌ 예외 안전하지 않음
void func() {
    Resource* r1 = new Resource();
    Resource* r2 = new Resource();  // 예외 시 r1 누수
}

// ✅ 순차적 RAII
void func() {
    auto r1 = std::make_unique<Resource>();
    auto r2 = std::make_unique<Resource>();
}

설계 원칙

지금까지 살펴본 내용을 정리하면 예외 안전한 클래스를 설계하는 네 가지 기본 원칙으로 압축할 수 있습니다. 자원은 항상 RAII 래퍼로 감싸 소유하고, 소멸자와 swap, 이동 연산처럼 실패해서는 안 되는 연산에는 반드시 noexcept를 붙여 표준 라이브러리가 최적화된 경로를 선택할 수 있도록 해야 합니다. 이 네 가지를 습관적으로 지키기만 해도 대부분의 예외 안전성 버그는 애초에 발생할 여지가 없어지며, 별도의 try/catch 코드를 여기저기 흩뿌리지 않고도 견고한 클래스를 만들 수 있습니다.

// 1. RAII 사용
std::unique_ptr<Resource> resource;

// 2. 소멸자는 noexcept
~MyClass() noexcept {}

// 3. swap은 noexcept
void swap(MyClass& other) noexcept {}

// 4. 이동 연산은 noexcept
MyClass(MyClass&&) noexcept {}

FAQ

Q1: 예외 안전성 수준?

A:

  • 기본: 자원 누수 없음
  • 강한: 원자적 연산
  • nothrow: 예외 없음

Q2: RAII는?

A: 자동 자원 관리. 예외 안전성 핵심.

Q3: 소멸자 예외?

A: 절대 안됩니다. noexcept.

Q4: 강한 보장은?

A: copy-and-swap 패턴.

Q5: 성능?

A: RAII는 비용 없음. 예외는 비용 있음.

Q6: 예외 안전성 학습 리소스는?

A:

  • “Effective C++”
  • “C++ Coding Standards”
  • “Exception Safety in C++“

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

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

관련 글


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

C++, exception-safety, exception, RAII, guarantee 등으로 검색하시면 이 글이 도움이 됩니다.