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지정자의 의미와 사용법을 익힐 수 있습니다.- 실전에서 예외 안전한 코드를 작성할 수 있습니다.
목차
- 실무에서 겪는 문제 시나리오
- 예외 안전성이란
- 세 가지 보장 수준
- RAII와 예외 결합
- noexcept 지정자
- 실전 패턴
- 자주 발생하는 문제와 해결법
- 모범 사례와 선택 가이드
- 프로덕션 패턴
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. 예외 안전성이란
예외 발생 시 프로그램 상태
예외가 던져지면:
- 현재 함수 실행 중단
- 스택 언와인딩 (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 블록을 찾을 때까지 호출 스택을 거슬러 올라갑니다.
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 | 소멸자에서 예외를 던지면 안 됨 |
| 대입 연산자 | 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: 예외 경로 없음 → 컴파일러 최적화 유리
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