C++ 예외 안전성과 RAII

네이버 블로그에 작성한 Modern CPP — Exceptions (2025-05-28)를 바탕으로 설명과 예제를 보완했다.

예외를 사용하는 코드에서 중요한 것은 try와 catch 문법뿐 아니라 실패 후 남는 상태다. 파일·메모리 같은 자원은 회수됐는지, 기존 데이터는 유효한지, 호출자가 다시 시도해도 되는지까지 함수의 계약으로 정해야 한다.

스택 언와인딩과 객체 수명

예외가 호출 스택을 거슬러 처리기로 전달되는 동안 해당 범위를 벗어나는, 생성이 끝난 자동 객체의 소멸자가 실행된다. 이 정리 과정을 이용해 자원의 수명과 객체의 수명을 묶는 것이 RAII다.

로컬 변수에 raw pointer를 저장했다고 그 포인터가 가리키는 동적 객체까지 자동으로 삭제되지는 않는다. 소유권을 std::unique_ptr 같은 객체에 맡기면 소멸자가 자원을 회수한다. 예외가 발생했다는 이유만으로 메모리가 누수되는 것이 아니라 자원의 소유·정리 방식이 중요하다.

세 가지 보장 구분하기

보장 실패 후 약속
기본 보장 자원이 누수되지 않고 객체의 불변식 유지. 값은 달라질 수 있음
강한 보장 실패하면 관찰 가능한 상태가 작업 이전과 같음
예외를 내보내지 않는 보장 함수 밖으로 예외가 전달되지 않음

자원을 모두 정리하는 것과 데이터를 원래대로 돌리는 것은 다르다. RAII는 자원 관리에 도움을 주지만 임의의 상태 변경을 자동으로 롤백하지 않는다.

임시 상태에서 작업한 뒤 교체하기

다음 C++17 예제는 임시 벡터를 검사한 뒤 성공한 경우에만 기존 값과 교체한다. 음수를 거부하는 것은 예외 흐름을 보여 주기 위한 예제 규칙이다.

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

struct Resource {
    inline static int live = 0;
    Resource() { ++live; }
    ~Resource() { --live; }
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
};

void replace_values(std::vector<int>& values,
                    const std::vector<int>& input) {
    auto resource = std::make_unique<Resource>();
    auto next = input;
    for (int value : next) {
        if (value < 0) {
            throw std::invalid_argument("negative value");
        }
    }
    values.swap(next);
}

int main() {
    std::vector<int> values{1, 2};
    try {
        replace_values(values, {3, -1});
    } catch (const std::exception& error) {
        std::cout << error.what() << '\n';
    }
    std::cout << values[0] << ' ' << values[1] << '\n';
    std::cout << "live=" << Resource::live << '\n';
    replace_values(values, {3, 4});
    std::cout << values[0] << ' ' << values[1] << '\n';
}

exceptions.cpp로 저장하고 실행한다.

c++ -std=c++17 -Wall -Wextra -pedantic exceptions.cpp -o exceptions
./exceptions
negative value
1 2
live=0
3 4

실패한 호출은 values를 변경하지 않았고, Resource도 정리됐다. 이 예제의 기본 allocator를 사용하는 std::vector<int>에서는 마지막 swap이 예외를 던지지 않는다는 점이 중요하다. 사용자 정의 타입·allocator·외부 시스템까지 포함하면 마지막 반영 단계의 실패 가능성을 따로 검토해야 한다.

noexcept가 예외를 없애 주지는 않는다

noexcept는 함수가 예외로 빠져나오지 않는다는 선언이다. 내부에서 발생한 예외를 자동으로 처리하거나 오류를 성공으로 바꾸지 않는다. 예외가 noexcept 함수 밖으로 나가려 하면 std::terminate가 호출된다.

따라서 이동 생성자나 이동 대입 연산자에 무조건 붙이지 않는다. 멤버가 수행하는 연산까지 계약을 지킬 수 있는지 확인해야 한다. 소멸자가 언와인딩 중 또 다른 예외를 내보내는 문제도 피해야 한다.

오류 코드와 예외 사이의 선택

“예외가 오류 코드보다 항상 우수하다”는 기준으로 정하지 않는다. 생성자 실패, 여러 호출 단계를 넘어 전달할 실패, 기존 라이브러리 계약에는 예외가 적합할 수 있다. 반면 이미 상태 코드 중심으로 설계된 시스템이나 예상되는 결과 분기에는 반환값 방식이 자연스러울 수 있다.

std::optional은 값의 존재 여부를 표현하지만 오류 원인을 자동으로 담지는 않는다. 팀의 오류 모델과 호출자가 필요한 정보를 함께 고려한다. Google C++ Style Guide의 예외 금지도 해당 코드베이스의 기존 제약과 비용을 전제로 읽어야 한다.

자료: C++ Core Guidelines — RAII, C++ 예외 명세, 스택 언와인딩