C++ 예외 안전성 Basic·Strong·Nothrow 보장: RAII와 copy-and-swap으로 구현하기
💡 핵심 개념: 예외는 갑자기 문이 닫히는 출구와 비슷합니다. 그때도 책상이 치워져야 한다면,
try/catch가 아니라 소멸자(RAII)에 맡기는 편이 안전합니다. 예외 기초(#8-1) 다음으로 이 글을 읽는 순서가 자연스럽다.
들어가며: 예외 발생 시 메모리가 샜다
파일 처리 코드에 예외 처리를 추가했습니다. 하지만 예외 발생 시 메모리 누수가 생겼다.
문제의 코드에서는 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: 멀티스레드에서 락이 해제되지 않음
서버가 멀티스레드로 동작 중입니다. 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로 잡아 로그만 남깁니다.
예외가 스택을 풀 때 프로그램 상태에 일어나는 일
예외 발생 시 프로그램 상태
예외가 던져지면:
- 현재 함수 실행 중단
- 스택 언와인딩 (Stack Unwinding) 시작
- 지역 객체들의 소멸자가 역순으로 호출됨
- 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 블록을 찾을 때까지 호출 스택을 거슬러 올라갑니다.
Basic, Strong, Nothrow 보장의 차이
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]
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를 호출해야 합니다. 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로 감싸는 전형적인 패턴입니다.
noexcept가 필요한 곳: 이동 생성자, swap, 소멸자
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”라는 조건부 지정입니다. 템플릿에서 타입에 따라 예외를 던질 수 있는지가 달라질 때, 호출자에게 정확한 예외 명세를 전달할 수 있습니다.
Copy-and-Swap, 트랜잭션, 예외 변환 패턴
패턴 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만 제공하며, 호출자가 예외를 처리하거나 더 위로 전파할 수 있습니다.
메모리 증가, 데드락, push_back 중 예외: 증상별 해결법
문제 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의 소멸자 자동 호출
}
};
보장 수준 고르기와 예외 안전 체크리스트
보장 수준 선택 가이드
| 상황 | 권장 보장 | 이유 |
|---|---|---|
| 리소스 관리 (RAII) | Nothrow | 소멸자에서 예외를 던지면 안 됨 |
| 대입 연산자 | Strong | Copy-and-Swap 패턴 |
swap, move | Nothrow | 표준 라이브러리와의 호환, 최적화 |
| 컨테이너 수정 | 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: 예외 경로 없음 → 컴파일러 최적화 유리
GCC·Clang·MSVC(x64)가 쓰는 테이블 기반 예외 구현에서는 예외가 던져지지 않는 정상 경로에 거의 비용이 없습니다. try 블록에 들어가는 순간 뭔가를 실행하는 게 아니라, “이 주소 범위에서 예외가 나면 어디로 가서 무엇을 소멸시킬지”를 담은 테이블을 바이너리에 따로 넣어 두기 때문입니다. 비용은 실제로 던질 때(테이블 탐색, 스택 언와인딩으로 수 마이크로초 단위)와 바이너리 크기에 몰려 있습니다. 그래서 RAII로 예외 안전하게 만드는 것 자체를 성능 이유로 피할 필요는 없고, “예외를 정상 흐름 제어에 쓰지 않는다”만 지키면 됩니다.
실제로 비용이 드는 건 Strong Guarantee를 모든 곳에 요구할 때입니다. copy-and-swap 대입은 대입할 때마다 전체 복사본을 만들기 때문에, 큰 컨테이너를 멤버로 가진 클래스에서는 기존 버퍼를 재사용하는 대입보다 눈에 띄게 느려질 수 있습니다. 표준 라이브러리도 이 트레이드오프를 인정해서, vector::push_back은 Strong을 보장하지만 여러 원소를 중간에 끼워 넣는 insert는 복사·이동이 예외를 던질 수 있으면 Basic만 보장합니다. 연산마다 “실패하면 호출자가 무엇을 기대하는가”를 따져서, 되돌릴 수 있어야 하는 연산(설정 적용, 트랜잭션)에만 Strong을 주고 나머지는 Basic으로 두는 것이 일반적인 선택입니다.
noexcept가 성능에 영향을 주는 대표적인 경로는 std::vector 재할당입니다. 재할당 중 요소를 옮기다 예외가 나면 Strong Guarantee를 지킬 수 없으므로, 표준 라이브러리는 std::move_if_noexcept로 이동 생성자가 noexcept일 때만 이동하고 아니면 복사합니다. 이동 생성자를 직접 작성하면서 noexcept를 빠뜨리면 컴파일은 멀쩡히 되고, 벡터가 커질 때마다 모든 원소를 복사하는 형태로만 드러납니다. static_assert(std::is_nothrow_move_constructible_v<MyClass>);를 클래스 옆에 두면 이 실수를 컴파일 시점에 잡을 수 있습니다.
Scope Guard, 예외 안전한 팩토리, 로깅과 예외 전파
패턴 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++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
자주 묻는 질문 (FAQ)
Q. 예외를 쓰지 않는 코드베이스(-fno-exceptions)에서도 예외 안전성을 신경 써야 하나요?
A. 개념은 그대로 적용됩니다. 예외 대신 에러 코드나 std::expected로 중간에 return하는 경로가 생길 뿐이고, “중간에 빠져나가도 자원이 새지 않고 객체가 유효한 상태인가”라는 질문은 똑같습니다. RAII로 자원을 묶어 두면 예외든 조기 return이든 같은 방식으로 정리됩니다. 다만 -fno-exceptions에서는 new 실패나 vector::at 범위 초과가 예외 대신 프로그램 종료로 이어진다는 점은 알아 두세요.
Q. f(std::unique_ptr<T>(new T), g()) 같은 코드는 안전한가요?
A. C++14까지는 인자 평가 순서가 섞일 수 있어서 new T → g() → unique_ptr 생성 순으로 실행되다 g()가 던지면 T가 샐 수 있었습니다. C++17부터는 각 인자의 평가가 서로 끼어들지 않도록 규칙이 바뀌어 이 누수는 사라졌지만, std::make_unique<T>()로 쓰면 버전과 상관없이 안전하고 타입 이름도 한 번만 쓰면 되므로 여전히 권장됩니다.
Q. 어떤 보장 수준을 문서에 적어야 하나요?
A. 공개 API라면 함수마다 최소한 “실패 시 객체 상태”를 적어 두는 게 좋습니다. 표준 라이브러리 문서가 push_back은 Strong, insert는 조건부 Basic처럼 명시하는 것과 같은 방식입니다. 적어 두지 않으면 호출자는 Strong을 기대하고 롤백 코드를 생략하기 쉽고, 그 기대가 틀렸을 때의 버그는 예외가 실제로 나는 드문 상황에서만 드러납니다.