C++ RAII: 생성자에서 획득하고 소멸자에서 해제하는 자원 관리 클래스 만들기
들어가며: 파일을 닫지 않아서 생긴 장애
“파일을 열 수 없습니다” - 리소스 누수의 공포
서비스 런칭 후 일주일, 갑자기 “Too many open files” 에러가 발생하며 서버가 멈췄습니다.
확인한 것:
$ lsof -p $(pgrep myserver) | wc -l
1024 # 열린 파일 개수 (시스템 한계!)
원인: 파일을 열기만 하고 닫지 않음
파일 핸들, 소켓, 뮤텍스처럼 “획득 → 사용 → 해제”가 쌍을 이루는 리소스는, 예외나 early return 시에도 반드시 해제되어야 합니다. 정의를 풀어 쓰면 RAII(Resource Acquisition Is Initialization—리소스 획득은 초기화다)는 “리소스를 획득하는 순간(생성자)과 해제하는 순간(소멸자)을 묶어서, 스코프를 벗어나면 자동으로 해제되게 하는 패턴”이다. 예를 들면 자동문이 “열림 = 들어갈 때, 닫힘 = 나올 때”로 정해져 있어서 손으로 닫을 필요가 없듯이, RAII는 “열기 = 객체 생성, 닫기 = 객체 소멸”로 한 쌍을 맞춥니다. std::lock_guard, std::unique_ptr, std::ifstream이 모두 이 패턴을 따릅니다.
힙 메모리에는 스마트 포인터로 RAII를 적용하는 경우가 많고, 소유권 이전·예외 안전성은 이동 의미론과 맞물립니다. Rust는 Drop과 소유권 규칙으로 비슷한 “스코프 끝에서 정리”를 컴파일러가 검사합니다. 누수·도구 측면은 메모리 누수 가이드, Valgrind, 누수 탐지 실전을 함께 보면 흐름이 잡힙니다.
RAII 객체의 생명 주기를 한눈에 보면 아래와 같습니다.
flowchart LR A[객체 생성] --> B[생성자: 리소스 획득] B --> C[사용] C --> D[스코프 종료] D --> E[소멸자: 리소스 해제]
실무 정리: 새로 리소스 클래스를 만들 때는 “생성자에서 획득, 소멸자에서 해제” 한 쌍만 맞추면, 예외가 나거나 중간에 return해도 소멸자가 불리므로 누수를 막을 수 있습니다. 복사/이동을 허용할지도 “리소스 소유권” 관점에서 정해야 합니다.
문제의 코드:
fopen으로 연 파일은 fclose로 닫아야 핸들이 시스템에 반환됩니다. 아래 코드에서는 shouldStop()이 true일 때나 fopen 실패 시 return으로 나가면서 fclose를 호출하지 않습니다. 이런 경로가 반복되면 열린 파일 개수가 쌓여 “Too many open files”에 도달합니다. C 스타일 FILE*을 쓰면 “언제 닫을지”를 사람이 기억해야 해서, 예외나 분기가 많을수록 누수가 나기 쉽습니다. 따라서 “열기 = 생성자, 닫기 = 소멸자”로 묶는 RAII 클래스를 두면, return이 어디서 나오든 스코프를 벗어날 때 소멸자가 fclose를 호출해 줍니다.
void processLog(const std::string& filename) {
FILE* file = fopen(filename.c_str(), "r");
if (!file) {
return; // ❌ 파일 열기 실패 시 그냥 리턴
}
char buffer[1024];
while (fgets(buffer, sizeof(buffer), file)) {
processLine(buffer);
if (shouldStop()) {
return; // ❌ fclose 없이 리턴!
}
}
fclose(file); // 정상 경로에서만 실행
}
문제:
- 하루 10,000개 로그 파일 처리
- 10% 정도가 early return
- 1,000개 파일 핸들 누수
- 시스템 한계 (1024) 도달 → 크래시
RAII로 해결:
File 클래스는 생성자에서 fopen으로 리소스를 획득하며, 소멸자에서 fclose로 해제합니다. processLog 안에서는 File file(filename, “r”);로만 파일을 열고, file.get()으로 FILE*을 넘겨 fgets 등에 씁니다. shouldStop()에서 return을 해도 file은 지역 객체이므로 함수를 벗어날 때 소멸자가 호출되고, 소멸자 안의 fclose(file)가 실행됩니다. 즉 “닫기”를 개발자가 호출할 필요가 없으며, 예외가 나도 스택 언와인딩 과정에서 소멸자가 불리므로 리소스가 누수되지 않습니다. 이 패턴은 std::lock_guard, std::unique_ptr과 같은 원리입니다.
아래는 File 클래스와 main을 포함한 실행 가능한 예제입니다. 복사해 붙여넣은 뒤 g++ -std=c++17 -o raii_file raii_file.cpp && ./raii_file 로 빌드·실행하면 됩니다 (실행 시 test.txt를 만들고 읽어서 출력합니다).
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o raii_file raii_file.cpp && ./raii_file
#include <cstdio>
#include <iostream>
#include <string>
#include <fstream>
#include <stdexcept>
class File {
FILE* file;
public:
File(const std::string& filename, const char* mode) {
file = fopen(filename.c_str(), mode);
if (!file) throw std::runtime_error("File open failed");
}
~File() {
if (file) fclose(file); // ✅ 항상 실행됨!
}
FILE* get() { return file; }
};
int main() {
{ // test.txt 생성
std::ofstream out("test.txt");
out << "RAII demo line\n";
}
File file("test.txt", "r"); // ✅ 생성자에서 파일 열기
char buffer[1024];
while (fgets(buffer, sizeof(buffer), file.get())) {
std::cout << buffer; // 읽은 내용 출력
}
return 0; // ✅ 소멸자에서 자동으로 fclose!
}
실행 결과: test.txt 내용인 RAII demo line 이 한 줄 출력됩니다.
이 경험으로 RAII(Resource Acquisition Is Initialization, 리소스 획득은 초기화) 패턴의 중요성을 깨달았습니다.
이 글을 읽으면:
- RAII 패턴의 원리와 장점을 이해할 수 있습니다.
- 파일, 소켓, 뮤텍스 등 모든 리소스를 안전하게 관리할 수 있습니다.
- 예외 안전성을 보장하는 코드를 작성할 수 있습니다.
- 실전에서 RAII 클래스를 직접 구현할 수 있습니다.
RAII: 리소스 수명을 객체 수명에 묶는다
RAII (Resource Acquisition Is Initialization)
정의: 리소스의 획득은 초기화다
핵심 아이디어:
- 생성자: 리소스 획득 (메모리, 파일, 소켓, 락 등)
- 소멸자: 리소스 해제 (자동 호출 보장)
RAII의 장점
// ❌ RAII 없이 (수동 관리)
void badFunction() {
FILE* file = fopen("data.txt", "r");
std::mutex mtx;
if (!file) return; // ❌ 에러 처리 복잡
mtx.lock();
// ... 복잡한 로직 ...
if (error1) {
fclose(file); // 잊기 쉬움!
mtx.unlock(); // 잊기 쉬움!
return;
}
if (error2) {
fclose(file); // 중복 코드!
mtx.unlock(); // 중복 코드!
return;
}
fclose(file);
mtx.unlock();
}
// ✅ RAII 사용 (자동 관리)
void goodFunction() {
std::ifstream file("data.txt");
std::lock_guard<std::mutex> lock(mtx);
if (!file) return; // ✅ 자동으로 모두 해제!
// ... 복잡한 로직 ...
if (error1) return; // ✅ 자동으로 모두 해제!
if (error2) return; // ✅ 자동으로 모두 해제!
// ✅ 자동으로 모두 해제!
}
badFunction에서는 fopen·mtx.lock()을 수동으로 호출한 뒤, error1·error2마다 fclose와 mtx.unlock()을 따로 호출해야 해서 누락·중복이 생깁니다. goodFunction에서는 std::ifstream과 std::lock_guard가 생성 시 리소스를 잡으며, 스코프를 벗어날 때 소멸자에서 자동으로 닫고 풀어 주므로, return이 어디서 나와도 해제가 보장됩니다.
뮤텍스, 소켓, DB 커넥션, mmap에서 터지는 누수 시나리오
시나리오 1: 뮤텍스 데드락
문제: mtx.lock() 후 예외 발생 시 unlock() 미호출 → 데드락.
// ❌ 수동 잠금
void writeLog(const std::string& msg) {
mtx.lock();
logFile << msg << "\n"; // 예외 시 unlock() 호출 안 됨!
mtx.unlock();
}
// ✅ RAII
void writeLog(const std::string& msg) {
std::lock_guard<std::mutex> lock(mtx);
logFile << msg << "\n";
}
시나리오 2: 소켓 핸들 누수
문제: connect() 실패/타임아웃 시 close() 미호출 → EMFILE.
// ❌ early return 시 누수
void handleRequest() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (connect(sock, ...) < 0) return; // close(sock) 누락!
close(sock);
}
// ✅ Socket RAII
void handleRequest() {
Socket sock(AF_INET, SOCK_STREAM, 0);
if (connect(sock.get(), ...) < 0) return;
}
시나리오 3: DB 커넥션 풀 고갈
문제: 트랜잭션 중 예외 시 rollback() 미호출 → 커넥션 풀 exhaustion.
// ❌ 수동 트랜잭션
void transfer() {
db.begin();
db.withdraw(...); db.deposit(...); // 예외 시 rollback 안 됨!
db.commit();
}
// ✅ Transaction RAII
void transfer() {
Transaction txn(db);
db.withdraw(...); db.deposit(...);
txn.commit();
}
시나리오 4: mmap 누수
문제: mmap() 후 munmap() 미호출 경로 → 메모리 사용량 증가.
// ❌ 수동 mmap - early return 시 munmap 누락
void processLargeFile(const char* path) {
int fd = open(path, O_RDONLY);
if (fd < 0) return;
void* addr = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (addr == MAP_FAILED) { close(fd); return; }
// ... 처리 중 예외 발생 시 munmap/close 모두 누락!
munmap(addr, size);
close(fd);
}
// ✅ MappedFile RAII - 소멸자에서 munmap/close
class MappedFile {
void* addr_; size_t size_; int fd_;
public:
~MappedFile() {
if (addr_) munmap(addr_, size_);
if (fd_ >= 0) close(fd_);
}
};
시나리오 5: 메모리·GPU 리소스 누수
메모리: new 후 예외 시 delete 미호출 → std::unique_ptr로 해결. GPU/OpenGL: glGenTextures 후 glDeleteTextures 누락 → Texture RAII 클래스로 생성자/소멸자에 묶어 해결.
생성자에서 획득, 소멸자에서 해제, 복사와 이동 규칙
원칙 1: 생성자에서 리소스 획득
class Resource {
int* data;
public:
Resource(size_t size) {
data = new int[size]; // 생성자에서 획득
std::cout << "Resource acquired\n";
}
~Resource() {
delete[] data; // 소멸자에서 해제
std::cout << "Resource released\n";
}
};
Resource 생성자에서 new int[size]로 힙 메모리를 획득하며, 소멸자에서 delete[] data로 해제합니다. 리소스 “획득”을 객체 생성과 묶었기 때문에, 이 객체가 살아 있는 동안만 메모리가 유효합니다.
원칙 2: 소멸자에서 리소스 해제
{
Resource res(100); // 생성자 호출 → "Resource acquired"
// 리소스 사용...
} // 스코프 종료 → 소멸자 자동 호출 → "Resource released"
Resource res(100)으로 블록 안에 객체를 만들면, 블록이 끝나는 } 시점에 res의 소멸자가 자동으로 호출됩니다. 개발자가 delete를 부르지 않아도, 스코프를 벗어날 때마다 “Resource released”가 출력되며 메모리가 해제됩니다.
원칙 3: 복사/이동 의미 정의
class Resource {
int* data;
public:
// 복사 금지 (리소스는 하나만 소유)
Resource(const Resource&) = delete;
Resource& operator=(const Resource&) = delete;
// 이동 허용 (소유권 이전)
Resource(Resource&& other) noexcept : data(other.data) {
other.data = nullptr;
}
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
other.data = nullptr;
}
return *this;
}
};
Resource(const Resource&) = delete로 복사를 막아 같은 리소스를 두 객체가 가리키지 않게 합니다. 이동 생성자·이동 대입에서는 other.data를 nullptr로 바꿔 소유권만 넘기고, 소멸자에서 delete[]는 한 번만 호출되도록 합니다. 리소스 클래스는 “소유권이 하나”일 때 이렇게 복사 금지·이동 허용으로 정리하는 경우가 많습니다.
파일 핸들: ifstream과 FILE* 래퍼
C++ 표준 라이브러리 (권장)
#include <fstream>
void readFile(const std::string& filename) {
std::ifstream file(filename); // ✅ 생성자에서 파일 열기
if (!file) {
throw std::runtime_error("File not found");
// ✅ 예외 발생해도 소멸자에서 자동으로 파일 닫힘
}
std::string line;
while (std::getline(file, line)) {
processLine(line);
if (shouldStop()) {
return; // ✅ 소멸자에서 자동으로 파일 닫힘
}
}
// ✅ 소멸자에서 자동으로 파일 닫힘
}
std::ifstream file(filename)이 생성자에서 파일을 열고, std::getline(file, line)으로 한 줄씩 읽습니다. throw나 return이 나와도 file은 지역 변수이므로 스코프를 벗어날 때 소멸자가 호출되어 파일이 자동으로 닫힙니다. if (!file) 검사는 열기 실패 시 예외를 던져 호출자에게 알리는 용도입니다.
커스텀 파일 RAII 클래스
구현한 C 스타일 파일 래퍼:
class CFile {
FILE* file;
std::string filename;
public:
CFile(const std::string& fname, const char* mode)
: filename(fname) {
file = fopen(fname.c_str(), mode);
if (!file) {
throw std::runtime_error("Cannot open file: " + fname);
}
std::cout << "File opened: " << filename << "\n";
}
~CFile() {
if (file) {
fclose(file);
std::cout << "File closed: " << filename << "\n";
}
}
// 복사 금지
CFile(const CFile&) = delete;
CFile& operator=(const CFile&) = delete;
FILE* get() { return file; }
};
void processFile() {
CFile file("data.txt", "r");
char buffer[1024];
while (fgets(buffer, sizeof(buffer), file.get())) {
if (error) return; // ✅ 자동으로 파일 닫힘
}
}
CFile 생성자에서 fopen으로 FILE*을 얻으며, 소멸자에서 if (file) fclose(file)로 닫습니다. 복사 생성·대입은 = delete로 막아서 파일 핸들이 복제되지 않게 하며, get()으로만 C API(fgets 등)에 FILE*을 넘깁니다. processFile 안에서 return이 나와도 file 소멸 시 fclose가 호출되어 핸들이 누수되지 않습니다.
lock_guard와 unique_lock으로 뮤텍스 잠금 관리
lock_guard: 기본 뮤텍스 관리
#include <mutex>
std::mutex mtx;
int sharedCounter = 0;
// ❌ 수동 잠금/해제 (위험)
void badIncrement() {
mtx.lock();
sharedCounter++;
if (error) {
mtx.unlock(); // 잊기 쉬움!
return;
}
mtx.unlock();
}
// ✅ RAII (안전)
void goodIncrement() {
std::lock_guard<std::mutex> lock(mtx);
sharedCounter++;
if (error) {
return; // ✅ 자동으로 unlock!
}
// ✅ 자동으로 unlock!
}
badIncrement는 mtx.lock() 뒤에 error 시 unlock()을 빼먹기 쉽으며, 정상 경로에서도 unlock()을 반드시 호출해야 합니다. goodIncrement는 std::lock_guard<std::mutex> lock(mtx)로 생성 시 잠금, 소멸 시 자동 해제가 한 쌍이 되어, return이 어디서 나와도 뮤텍스가 풀립니다.
unique_lock: 유연한 뮤텍스 관리
void advancedLocking() {
std::unique_lock<std::mutex> lock(mtx);
// 작업 1
doWork1();
// 일시적으로 unlock (다른 스레드가 접근 가능)
lock.unlock();
doExpensiveWork(); // 락 없이 실행
// 다시 lock
lock.lock();
// 작업 2
doWork2();
// ✅ 소멸자에서 자동으로 unlock
}
std::unique_lock은 lock_guard처럼 RAII로 잠금/해제를 하지만, 중간에 lock.unlock()으로 잠시 풀었다가 lock.lock()으로 다시 잡을 수 있습니다. doExpensiveWork()처럼 락이 필요 없는 구간에서는 unlock 해 두면 다른 스레드가 진입할 수 있어 데드락 위험을 줄일 수 있습니다. 스코프를 벗어날 때 소멸자에서 자동으로 unlock 됩니다.
겪은 데드락 (Deadlock)
문제의 코드:
std::mutex mtx1, mtx2;
void thread1() {
mtx1.lock();
// ... 작업 ...
mtx2.lock(); // ❌ thread2가 mtx2를 잡고 있으면 데드락!
// ...
mtx2.unlock();
mtx1.unlock();
}
void thread2() {
mtx2.lock();
// ... 작업 ...
mtx1.lock(); // ❌ thread1이 mtx1을 잡고 있으면 데드락!
// ...
mtx1.unlock();
mtx2.unlock();
}
thread1은 mtx1 → mtx2 순서로 잠그고, thread2는 mtx2 → mtx1 순서로 잠급니다. 두 스레드가 동시에 각각 첫 뮤텍스만 잡은 상태에서 상대의 두 번째 뮤텍스를 기다리면 데드락이 발생합니다. 뮤텍스 잠금 순서를 모든 스레드에서 동일하게 맞추거나 std::scoped_lock으로 한 번에 잠그면 방지할 수 있습니다.
RAII로 해결:
void thread1() {
std::scoped_lock lock(mtx1, mtx2); // ✅ 데드락 방지
}
void thread2() {
std::scoped_lock lock(mtx2, mtx1); // ✅ 인자 순서가 달라도 안전
}
std::scoped_lock(mtx1, mtx2)는 여러 뮤텍스를 std::lock과 같은 데드락 회피 알고리즘으로 한 번에 잠급니다. 하나를 잡은 뒤 나머지를 try_lock으로 시도하고, 실패하면 잡았던 것을 풀고 다시 시도하는 방식이라 인자를 어떤 순서로 넘겨도 서로 물고 늘어지는 상태가 생기지 않습니다. 주소 순으로 정렬해 잠근다는 설명을 종종 보지만, 표준이 보장하는 것은 “데드락이 나지 않는다”는 결과이고 구체적인 방법은 구현에 맡겨져 있습니다. 생성 시 lock, 소멸 시 자동 unlock이므로 RAII로 안전하게 사용할 수 있습니다.
lock_guard, unique_lock, scoped_lock 중 무엇을 쓸까
세 타입 모두 “생성 시 잠금, 소멸 시 해제”라는 RAII 뼈대는 같고, 차이는 잠금 상태를 얼마나 조작할 수 있느냐입니다.
| 타입 | 할 수 있는 것 | 주로 쓰는 곳 |
|---|---|---|
lock_guard | 생성 시 잠그고 소멸 시 푸는 것뿐. 중간에 풀 수 없음 | 뮤텍스 하나를 스코프 전체 동안 잡을 때 |
unique_lock | 중간에 unlock()/lock(), 잠그지 않고 생성(std::defer_lock), try_lock, 이동 | condition_variable::wait, 락을 잠시 풀어야 하는 구간 |
scoped_lock (C++17) | 여러 뮤텍스를 데드락 없이 한꺼번에 잠금 | 두 개 이상의 뮤텍스를 동시에 잡을 때 |
condition_variable::wait가 unique_lock을 요구하는 이유는, 대기하는 동안 락을 풀었다가 깨어날 때 다시 잡아야 하기 때문입니다. lock_guard는 풀 수단이 없으니 이 역할을 못 합니다. 반대로 unique_lock은 “지금 잠겨 있는지”를 기억하는 플래그를 하나 더 들고 다니므로, 그 유연함이 필요 없는 곳에서는 lock_guard가 의도를 더 분명하게 드러냅니다.
C++17 이후 코드라면 뮤텍스가 하나일 때도 scoped_lock을 기본으로 쓰자는 의견이 있습니다. 인자가 하나면 lock_guard와 똑같이 동작하고, 나중에 뮤텍스가 추가돼도 그대로 확장할 수 있어서입니다. 다만 std::scoped_lock lock;처럼 인자를 빠뜨리면 아무것도 잠그지 않는 객체가 조용히 만들어지는 함정이 있으니, 어느 쪽을 택하든 팀 안에서 한 가지로 통일해 두는 편이 리뷰하기 쉽습니다.
소켓 디스크립터를 닫아 주는 RAII 클래스
소켓 RAII 클래스
구현한 소켓 래퍼:
#include <sys/socket.h>
#include <unistd.h>
class Socket {
int sockfd;
public:
Socket(int domain, int type, int protocol) {
sockfd = socket(domain, type, protocol);
if (sockfd < 0) {
throw std::runtime_error("Socket creation failed");
}
std::cout << "Socket created: " << sockfd << "\n";
}
~Socket() {
if (sockfd >= 0) {
close(sockfd);
std::cout << "Socket closed: " << sockfd << "\n";
}
}
// 복사 금지
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
// 이동 허용
Socket(Socket&& other) noexcept : sockfd(other.sockfd) {
other.sockfd = -1;
}
int get() const { return sockfd; }
};
void handleConnection() {
Socket sock(AF_INET, SOCK_STREAM, 0);
// 연결 설정...
if (error) {
return; // ✅ 소멸자에서 자동으로 소켓 닫힘
}
// 데이터 송수신...
// ✅ 소멸자에서 자동으로 소켓 닫힘
}
Socket 생성자에서 socket()으로 fd를 얻으며, 소멸자에서 close(sockfd)로 닫습니다. 복사는 = delete, 이동은 other.sockfd = -1로 소유권만 넘겨서 소멸자에서 이중 close가 나오지 않게 합니다. handleConnection 안에서 예외나 early return이 나와도 소켓이 자동으로 닫혀 리소스가 누수되지 않습니다.
commit하지 않으면 rollback하는 트랜잭션 가드
트랜잭션 RAII 클래스
구현한 자동 롤백 트랜잭션:
class Transaction {
Database& db;
bool committed = false;
public:
Transaction(Database& database) : db(database) {
db.begin();
std::cout << "Transaction started\n";
}
~Transaction() {
if (!committed) {
db.rollback();
std::cout << "Transaction rolled back\n";
}
}
void commit() {
db.commit();
committed = true;
std::cout << "Transaction committed\n";
}
};
void transferMoney(int from, int to, int amount) {
Transaction txn(db); // ✅ 트랜잭션 시작
db.withdraw(from, amount);
if (db.getBalance(from) < 0) {
throw std::runtime_error("Insufficient funds");
// ✅ 예외 발생 → 소멸자에서 자동 롤백!
}
db.deposit(to, amount);
txn.commit(); // ✅ 명시적 커밋
// 예외 없으면 커밋됨
// 예외 있으면 자동 롤백됨
}
Transaction 생성자에서 db.begin()으로 트랜잭션을 시작하며, 소멸자에서 committed가 false이면 db.rollback()을 호출합니다. transferMoney에서 txn.commit()을 호출하면 committed = true가 되어 정상 종료 시에는 롤백되지 않습니다. 예외가 나면 commit()이 실행되지 않으므로 소멸자에서 자동 롤백되어 데이터 정합성이 유지됩니다.
장점:
- 예외 발생 시 자동 롤백
commit()잊어도 안전 (롤백됨)- 코드 간결성
타이머, 임시 디렉토리, 스레드 join용 RAII 클래스
타이머 RAII 클래스
성능 측정에 사용하는 클래스:
#include <chrono>
#include <iostream>
class Timer {
std::string name;
std::chrono::time_point<std::chrono::high_resolution_clock> start;
public:
Timer(const std::string& n) : name(n) {
start = std::chrono::high_resolution_clock::now();
}
~Timer() {
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << name << " took " << duration.count() << "ms\n";
}
};
void processData() {
Timer timer("processData"); // ✅ 시작
// ... 복잡한 작업 ...
// ✅ 함수 종료 시 자동으로 시간 출력
}
Timer 생성자에서 high_resolution_clock::now()로 시작 시각을 저장하며, 소멸자에서 다시 시각을 잰 뒤 차이를 밀리초로 출력합니다. processData()가 return하거나 예외로 끝나도 스코프를 벗어날 때 소멸자가 호출되므로, 구간 시간 측정을 RAII로 안전하게 할 수 있습니다.
출력:
processData took 1234ms
임시 디렉토리 RAII 클래스
#include <filesystem>
class TempDirectory {
std::filesystem::path path;
public:
TempDirectory(const std::string& prefix) {
path = std::filesystem::temp_directory_path() / (prefix + "_" + std::to_string(rand()));
std::filesystem::create_directory(path);
std::cout << "Created temp dir: " << path << "\n";
}
~TempDirectory() {
std::filesystem::remove_all(path);
std::cout << "Removed temp dir: " << path << "\n";
}
std::filesystem::path getPath() const { return path; }
};
void processFiles() {
TempDirectory tempDir("myapp");
// 임시 파일 생성...
auto tempFile = tempDir.getPath() / "data.tmp";
if (error) {
return; // ✅ 임시 디렉토리 자동 삭제!
}
// ✅ 임시 디렉토리 자동 삭제!
}
TempDirectory 생성자에서 temp_directory_path() 아래에 고유한 이름으로 디렉터리를 만들고, 소멸자에서 remove_all(path)로 삭제합니다. processFiles에서 return이 나와도 지역 객체 tempDir의 소멸자가 호출되어 임시 디렉터리가 자동으로 정리됩니다. 테스트나 배치 작업에서 임시 디렉터리를 쓸 때 유용합니다.
스레드 join 보장
std::thread는 소멸될 때 아직 joinable 상태면 std::terminate를 호출합니다. 스레드를 만든 함수에서 예외가 나거나 조기 return하면 join() 호출을 건너뛰기 쉬운데, 그 순간 프로그램 전체가 종료됩니다. C++20이라면 소멸자에서 자동으로 request_stop()과 join()을 해 주는 std::jthread를 쓰는 것이 가장 간단한 RAII 해법입니다. C++17 이하라면 같은 역할을 하는 작은 가드를 둡니다.
// C++20: 소멸 시 stop 요청 + join
void runWorker() {
std::jthread worker([](std::stop_token st) {
while (!st.stop_requested()) { /* 작업 */ }
});
mayThrow(); // 예외가 나도 worker는 정지 후 join됨
}
// C++17 이하: join 가드
struct JoinOnExit {
std::thread& t;
explicit JoinOnExit(std::thread& th) : t(th) {}
~JoinOnExit() { if (t.joinable()) t.join(); }
JoinOnExit(const JoinOnExit&) = delete;
JoinOnExit& operator=(const JoinOnExit&) = delete;
};
join은 스레드가 끝날 때까지 블록되므로, 오래 도는 작업이라면 가드만 두는 것으로는 부족하고 stop_token이나 플래그 같은 취소 수단을 함께 설계해야 소멸자에서 멈춰 버리지 않습니다.
예외가 나도 리소스가 새지 않는 이유: 스택 언와인딩
예외 안전성 수준
| 수준 | 설명 | 예제 |
|---|---|---|
| No-throw | 예외 절대 발생 안 함 | 소멸자, swap |
| Strong | 예외 발생 시 상태 변경 안 됨 | 트랜잭션 |
| Basic | 예외 발생 시 리소스 누수 없음 | RAII |
| No guarantee | 예외 발생 시 정의되지 않음 | 레거시 코드 |
RAII로 Strong 보장 구현
구현한 예외 안전한 swap:
class Data {
std::unique_ptr<int[]> buffer;
size_t size;
public:
Data(size_t s) : buffer(std::make_unique<int[]>(s)), size(s) {}
void swap(Data& other) noexcept {
std::swap(buffer, other.buffer);
std::swap(size, other.size);
}
Data& operator=(const Data& other) {
// Copy-and-swap idiom (Strong 보장)
Data temp(other); // 복사 (예외 발생 가능)
swap(temp); // swap (예외 없음)
return *this;
// temp 소멸 → 이전 데이터 자동 해제
}
};
swap은 std::swap으로 포인터와 크기만 바꾸므로 noexcept로 두어 예외가 나지 않게 합니다. operator=에서는 먼저 Data temp(other)로 복사본을 만들고, 성공하면 swap(temp)로 바꾼 뒤, 함수가 끝날 때 temp가 소멸하며 예전 버퍼가 해제됩니다. 복사 중 예외가 나면 *this는 그대로이며, swap 후에는 예외 없이 대입이 완료되는 copy-and-swap 관용구입니다.
스택 언와인딩과 소멸자 호출 순서
예외가 발생하면 생성된 객체들이 생성의 역순으로 소멸됩니다. file1 → file2 → lock → memory 순으로 생성했다면, 예외 시 memory → lock → file2 → file1 순으로 소멸자가 호출됩니다.
예외 안전한 리소스 관리
void complexOperation() {
// 여러 리소스 동시 관리
std::ifstream file1("input.txt");
std::ofstream file2("output.txt");
std::lock_guard<std::mutex> lock(mtx);
auto memory = std::make_unique<char[]>(1024);
if (!file1 || !file2) {
throw std::runtime_error("File error");
// ✅ 모든 리소스 자동 해제 (역순으로)
// 1. memory 해제
// 2. lock 해제
// 3. file2 닫힘
// 4. file1 닫힘
}
// ... 작업 ...
// ✅ 정상 종료 시에도 모든 리소스 자동 해제
}
file1, file2, lock, memory는 모두 생성 순서의 역순으로 소멸자가 호출됩니다. 예외가 나면 스택 언와인딩 시 memory → lock → file2 → file1 순으로 해제되므로, 어떤 리소스도 누수되지 않습니다. RAII 객체만 쓰면 예외 안전성이 자연스럽게 따라옵니다.
Rule of Five, 스코프 가드, MappedFile 예제
예제 1: Rule of Five를 따르는 리소스 클래스
복사 금지, 이동 허용, 소멸자 noexcept를 적용한 완전한 RAII 구현입니다.
#include <cstdio>
#include <stdexcept>
class FileHandle {
FILE* file_ = nullptr;
public:
explicit FileHandle(const char* filename, const char* mode) {
file_ = fopen(filename, mode);
if (!file_) throw std::runtime_error("Cannot open file");
}
~FileHandle() noexcept {
if (file_) { fclose(file_); file_ = nullptr; }
}
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& other) noexcept : file_(other.file_) {
other.file_ = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (file_) fclose(file_);
file_ = other.file_;
other.file_ = nullptr;
}
return *this;
}
FILE* get() const { return file_; }
};
핵심: 소멸자 noexcept, if (this != &other) 자기 대입 방지, other.file_ = nullptr로 이중 해제 방지.
예제 2: 스코프 가드 (Scope Guard)
스코프를 벗어날 때 사용자 정의 동작을 실행하는 scope_exit 패턴입니다.
#include <functional>
class ScopeGuard {
std::function<void()> on_exit_;
bool active_ = true;
public:
explicit ScopeGuard(std::function<void()> f) : on_exit_(std::move(f)) {}
~ScopeGuard() noexcept {
if (active_ && on_exit_) on_exit_();
}
void release() { active_ = false; }
};
// 사용 예: guard.release()로 성공 시 복원 생략 가능
예제 3: 완전한 MappedFile RAII 클래스
mmap/munmap을 래핑한 예제. 생성자에서 open→mmap, 소멸자에서 munmap→close 순으로 해제합니다.
class MappedFile {
void* addr_ = nullptr;
size_t size_ = 0;
int fd_ = -1;
public:
MappedFile(const char* path) {
fd_ = open(path, O_RDONLY);
if (fd_ < 0) throw std::runtime_error("open failed");
size_ = lseek(fd_, 0, SEEK_END);
addr_ = mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd_, 0);
if (addr_ == MAP_FAILED) { close(fd_); fd_ = -1; throw std::runtime_error("mmap failed"); }
}
~MappedFile() noexcept {
if (addr_) munmap(addr_, size_);
if (fd_ >= 0) close(fd_);
}
MappedFile(const MappedFile&) = delete;
void* data() const { return addr_; }
size_t size() const { return size_; }
};
예제 4: lock_guard + 파일 핸들 통합 예제
여러 RAII 객체를 함께 사용하는 실전 패턴입니다.
void threadSafeLog(const std::string& msg, const std::string& path) {
std::lock_guard<std::mutex> lock(logMtx); // ✅ 락 RAII
std::ofstream file(path, std::ios::app); // ✅ 파일 RAII
if (!file) throw std::runtime_error("Cannot open log");
file << msg << "\n";
// return/throw 어디서 나와도 lock 해제, file 닫힘
}
소멸자 예외, 복사 누락, 부분 해제 누락 같은 RAII 실수
에러 1: 소멸자에서 예외 던지기
증상: std::terminate() 호출로 프로그램 비정상 종료.
원인: 소멸자에서 예외를 던지면 스택 언와인딩 중 추가 예외가 발생합니다.
// ❌ 위험
~BadFile() {
if (fclose(f) != 0) throw std::runtime_error("Close failed");
}
// ✅ 소멸자에서는 예외를 던지지 않음
~GoodFile() noexcept {
if (f && fclose(f) != 0) std::cerr << "fclose failed\n";
}
에러 2: 복사/이동 의미 정의 누락
증상: 리소스 이중 해제, undefined behavior.
원인: 복사를 허용하면 두 객체가 같은 리소스를 가리켜 소멸 시 두 번 해제됩니다.
// ❌ 기본 복사 → 얕은 복사 → 이중 해제
class Resource {
int* data;
public:
Resource(size_t n) : data(new int[n]) {}
~Resource() { delete[] data; }
};
// ✅ 복사 금지, 이동 허용
class Resource {
int* data;
public:
Resource(const Resource&) = delete;
Resource& operator=(const Resource&) = delete;
Resource(Resource&& other) noexcept : data(other.data) { other.data = nullptr; }
~Resource() { delete[] data; }
};
에러 3: 리소스 획득 실패 시 부분 해제 누락
증상: 생성자에서 여러 리소스 획득 중 예외 시 이미 획득한 리소스 누수.
해결법: std::unique_ptr 등으로 각 리소스를 RAII 객체로 감싸면 부분 실패 시에도 자동 해제됩니다.
이게 동작하는 이유는 생성자가 예외로 끝나면 그 클래스의 소멸자는 호출되지 않지만, 이미 완전히 생성된 멤버와 기본 클래스는 생성의 역순으로 소멸되기 때문입니다. 원시 포인터 멤버 두 개에 생성자 본문에서 new를 두 번 하면, 두 번째 new가 실패했을 때 첫 번째 메모리를 정리해 줄 코드가 어디에도 없습니다. 멤버 자체가 RAII 타입이면 “이미 만들어진 것”은 멤버 소멸자가 알아서 정리합니다.
class MultiResource {
std::unique_ptr<int[]> a{std::make_unique<int[]>(100)};
std::unique_ptr<int[]> b{std::make_unique<int[]>(100)};
};
에러 4: lock_guard 범위 과대
증상: 불필요하게 긴 구간을 잠가 성능 저하, 데드락 위험 증가.
해결법: 락이 필요한 구간만 {} 블록으로 감싸 최소화합니다.
void process() {
auto data = loadData();
{ std::lock_guard<std::mutex> lock(mtx); transformData(); }
saveData();
}
에러 5: RAII 객체를 힙에 동적 할당
증상: new로 만든 RAII 객체를 delete하지 않으면 리소스 누수.
해결법: RAII 객체는 스택에 두거나 std::unique_ptr로 관리합니다.
// ❌ 힙 할당 후 delete 누락
void bad() {
auto* file = new std::ifstream("data.txt");
if (error) return; // delete file 누락!
}
// ✅ 스택에 두기
void good() {
std::ifstream file("data.txt");
if (error) return;
}
// ✅ unique_ptr로 힙 관리
void goodHeap() {
auto file = std::make_unique<std::ifstream>("data.txt");
if (error) return;
}
에러 6: 뮤텍스 잠금 순서 불일치·범위 과대
데드락: 두 스레드가 서로 다른 순서로 뮤텍스 잠금 → std::scoped_lock(mtx1, mtx2)로 해결. 범위 과대: 락을 넓은 스코프에 두면 성능 저하 → 락이 필요한 구간만 {} 블록으로 감쌉니다.
에러 7: 소멸자에서 가상 함수 호출
증상: 소멸자에서 가상 함수 호출 시 파생 부분이 이미 파괴된 상태. 해결법: 소멸자에서는 비가상 메서드만 호출합니다.
RAII 클래스를 설계할 때 지킬 원칙
원칙 1: 표준 라이브러리 우선
std::ifstream, std::lock_guard, std::unique_ptr 등 표준 RAII 타입을 우선 사용합니다.
원칙 2: 리소스 획득은 생성자, 해제는 소멸자
한 곳에서 획득하고 한 곳에서 해제하여 “획득-해제” 쌍을 명확히 합니다.
원칙 3: 소멸자는 noexcept
소멸자에서 예외를 던지지 않도록 하며, noexcept를 명시합니다.
원칙 4: 복사/이동 의미 명시
단일 소유권이면 복사 금지·이동 허용, 공유 리소스면 shared_ptr 등을 고려합니다.
가장 좋은 경우는 특수 멤버를 아예 쓰지 않는 것입니다(Rule of Zero). 멤버가 전부 unique_ptr, std::vector, std::string처럼 스스로 소멸과 이동을 올바르게 처리하는 타입이면 컴파일러가 만든 소멸자와 이동 연산이 그대로 맞습니다. 흔히 걸리는 예외는 std::mutex 멤버입니다. mutex는 복사도 이동도 안 되므로 클래스 전체가 이동 불가가 되고, std::vector에 넣거나 팩토리에서 값으로 반환하기 어려워집니다. 이때는 std::unique_ptr<std::mutex>로 감싸 이동 가능하게 만들거나, 동기화를 별도 객체로 분리하는 식으로 타협합니다.
원칙 5: 리소스 획득 실패 시 예외
생성자에서 실패 시 예외를 던져 유효하지 않은 객체가 생성되지 않게 합니다.
원칙 6: 리소스 획득 순서 일관성
여러 뮤텍스를 쓸 때는 std::scoped_lock으로 한 번에 잠그거나, 모든 스레드에서 동일한 획득 순서를 유지합니다.
RAII 체크리스트
- 생성자에서 리소스 획득
- 소멸자에서 리소스 해제 (noexcept)
- 복사/이동 의미 정의 (delete 또는 구현)
- 표준 라이브러리 타입 우선 사용
- 리소스 획득 실패 시 예외
- 소멸자에서 예외 던지지 않음
연결 풀, 로그 스코프, 상태 롤백에 RAII 적용하기
패턴 1: 연결 풀 + RAII
DB 연결 풀에서 연결을 빌려 쓰고 반환하는 RAII 래퍼입니다.
class ConnectionGuard {
ConnectionPool& pool_;
Connection* conn_;
public:
ConnectionGuard(ConnectionPool& pool) : pool_(pool) { conn_ = pool_.acquire(); }
~ConnectionGuard() noexcept { if (conn_) pool_.release(conn_); }
Connection* get() const { return conn_; }
ConnectionGuard(const ConnectionGuard&) = delete;
};
패턴 2: 로그 스코프
함수 진입/퇴출을 자동 로깅하는 RAII 클래스입니다.
class LogScope {
std::string name_;
std::chrono::steady_clock::time_point start_;
public:
explicit LogScope(const std::string& name) : name_(name),
start_(std::chrono::steady_clock::now()) {
std::cout << "ENTER " << name_ << "\n";
}
~LogScope() {
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start_).count();
std::cout << "EXIT " << name_ << " " << ms << "ms\n";
}
};
패턴 3: 트랜잭션 + 예외 안전
예외 시 자동 롤백을 보장하는 RAII 트랜잭션입니다.
class ScopedTransaction {
Database& db_;
bool committed_ = false;
public:
explicit ScopedTransaction(Database& db) : db_(db) { db_.begin(); }
~ScopedTransaction() noexcept { if (!committed_) db_.rollback(); }
void commit() { db_.commit(); committed_ = true; }
};
패턴 4: Pimpl + unique_ptr
Pimpl 관용구에서 구현체를 unique_ptr로 관리하면 RAII가 자동 적용됩니다.
class Widget {
struct Impl;
std::unique_ptr<Impl> pimpl_;
public:
Widget();
~Widget(); // unique_ptr이 Impl 자동 삭제
Widget(Widget&&) = default;
};
패턴 5: 상태 롤백 (State Rollback)
작업 전 상태를 저장하며, 실패 시 ScopeGuard로 자동 복원합니다.
void modifyConfig() {
auto backup = config.save();
ScopeGuard guard([&] { config.restore(backup); });
config.applyChanges();
guard.release(); // 성공 시 복원 생략
}
패턴별 용도 한눈에 보기
| 패턴 | 용도 |
|---|---|
| ConnectionGuard | DB 연결 풀 acquire/release |
| LogScope | 함수 진입/퇴출 로깅 |
| ScopedTransaction | 예외 시 자동 rollback |
| Pimpl + unique_ptr | 구현체 자동 삭제 |
| StateRollback | 설정 변경 실패 시 복원 |
같이 보면 좋은 글
- C++ 예외 안전성 Basic·Strong·Nothrow 보장: RAII와 copy-and-swap으로 구현하기
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ 메모리 누수: 흔한 5가지 패턴과 Valgrind·ASan으로 찾는 방법
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
- Valgrind Memcheck로 C++ 메모리 누수 찾아내기
마무리
핵심 요약
✅ RAII 원칙: 생성자에서 획득, 소멸자에서 해제
✅ 자동 리소스 관리: 메모리, 파일, 소켓, 락 모두 자동
✅ 예외 안전성: 예외 발생해도 리소스 누수 없음
✅ 스마트 포인터는 RAII: unique_ptr, shared_ptr 모두 RAII 패턴
✅ 표준 라이브러리 활용: ifstream, lock_guard, unique_ptr
✅ 커스텀 RAII: 필요 시 직접 구현 가능
배운 교훈
파일 핸들 누수로 서버를 다운시킨 경험에서:
- 리소스는 항상 RAII로 관리
- 수동 관리는 실수의 온상
- 표준 라이브러리 우선 사용
- 커스텀 RAII는 간단히 구현 가능
메모리 관리 시리즈 완료
이것으로 C++ 메모리 관리 시리즈를 마칩니다. 이 4개 글을 통해 다음 내용을 배웠습니다:
- #6-1: 스택과 힙의 차이, 스택 오버플로우
- #6-2: new/delete의 위험성과 메모리 누수 탐지
- #6-3: 스마트 포인터로 안전한 메모리 관리
- #6-4: RAII 패턴으로 모든 리소스 관리
자주 묻는 질문 (FAQ)
Q. 소멸자에서 close() 같은 해제 함수가 실패하면 어떻게 처리해야 하나요?
A. 소멸자는 기본적으로 noexcept라서 예외가 빠져나가면 std::terminate가 호출되므로, 소멸자에서는 실패를 로그로 남기고 삼키는 것이 원칙입니다. 호출자가 실패 여부를 알아야 하는 자원이라면 close()나 commit() 같은 명시적 멤버 함수를 제공해 예외나 에러 코드로 결과를 알리고, 소멸자는 아직 정리되지 않았을 때만 최후의 정리를 맡게 합니다. 본문의 트랜잭션 RAII 클래스처럼 명시적으로 commit하지 않으면 소멸자에서 rollback하는 구조가 이 원칙을 적용한 예입니다.
Q. RAII 클래스에서 복사를 막고 이동만 허용하는 이유는 무엇인가요?
A. 컴파일러가 만든 기본 복사 생성자는 FILE*이나 파일 디스크립터 같은 핸들 값을 그대로 복사합니다. 그러면 두 객체가 같은 리소스를 가리키다가 각자 소멸자에서 해제해 이중 close나 이중 해제가 발생합니다. 소유권이 하나여야 하는 리소스는 복사 생성자와 복사 대입을 delete하고, 이동 연산에서 원본의 핸들을 무효값으로 바꿔 원본의 소멸자가 해제하지 않도록 구현합니다. 파일 디스크립터의 경우 이중 close는 단순한 오류로 끝나지 않을 수 있습니다. 첫 close 뒤 같은 번호가 다른 스레드의 새 파일이나 소켓에 재사용되면, 두 번째 close가 전혀 상관없는 리소스를 닫아 버립니다.
참고 자료
공식 문서
추천 도서
- “Effective Modern C++” by Scott Meyers
- “C++ Concurrency in Action” by Anthony Williams