C++ RAII | "파일을 열 수 없습니다" 장애의 원인과 자동 리소스 관리
이 글의 핵심
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의 핵심 원칙
- 파일 리소스 관리
- 뮤텍스와 동기화
- 소켓과 네트워크 리소스
- 데이터베이스 트랜잭션
- 커스텀 RAII 클래스 구현
- 예외 안전성 보장
- 완전한 RAII 예제
- 자주 발생하는 에러와 해결법
- RAII 모범 사례
- 프로덕션 패턴
1. 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이 어디서 나와도 해제가 보장됩니다.
2. 추가 문제 시나리오
시나리오 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 클래스로 생성자/소멸자에 묶어 해결.
3. 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[]는 한 번만 호출되도록 합니다. 리소스 클래스는 “소유권이 하나”일 때 이렇게 복사 금지·이동 허용으로 정리하는 경우가 많습니다.
4. 파일 리소스 관리
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가 호출되어 핸들이 누수되지 않습니다.
5. 뮤텍스와 동기화
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(mtx1, mtx2); // ✅ 같은 순서로 잠금
}
위 코드 설명: std::scoped_lock(mtx1, mtx2)는 두 뮤텍스를 한 번에 잠그며, 내부적으로 항상 같은 순서(예: 주소 순)로 잠금을 걸어 데드락을 피합니다. 생성 시 lock, 소멸 시 자동 unlock이므로 RAII로 안전하게 사용할 수 있습니다.
6. 소켓과 네트워크 리소스
소켓 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이 나와도 소켓이 자동으로 닫혀 리소스가 누수되지 않습니다.
7. 데이터베이스 트랜잭션
트랜잭션 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()잊어도 안전 (롤백됨)- 코드 간결성
8. 커스텀 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의 소멸자가 호출되어 임시 디렉터리가 자동으로 정리됩니다. 테스트나 배치 작업에서 임시 디렉터리를 쓸 때 유용합니다.
9. 예외 안전성 보장
예외 안전성 수준
| 수준 | 설명 | 예제 |
|---|---|---|
| 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 객체만 쓰면 예외 안전성이 자연스럽게 따라옵니다.
10. 완전한 RAII 예제
예제 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 닫힘
}
11. 자주 발생하는 에러와 해결법
에러 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 객체로 감싸면 부분 실패 시에도 자동 해제됩니다.
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: 소멸자에서 가상 함수 호출
증상: 소멸자에서 가상 함수 호출 시 파생 부분이 이미 파괴된 상태. 해결법: 소멸자에서는 비가상 메서드만 호출합니다.
12. RAII 모범 사례
원칙 1: 표준 라이브러리 우선
std::ifstream, std::lock_guard, std::unique_ptr 등 표준 RAII 타입을 우선 사용합니다.
원칙 2: 리소스 획득은 생성자, 해제는 소멸자
한 곳에서 획득하고 한 곳에서 해제하여 “획득-해제” 쌍을 명확히 합니다.
원칙 3: 소멸자는 noexcept
소멸자에서 예외를 던지지 않도록 하고, noexcept를 명시합니다.
원칙 4: 복사/이동 의미 명시
단일 소유권이면 복사 금지·이동 허용, 공유 리소스면 shared_ptr 등을 고려합니다.
원칙 5: 리소스 획득 실패 시 예외
생성자에서 실패 시 예외를 던져 유효하지 않은 객체가 생성되지 않게 합니다.
원칙 6: 리소스 획득 순서 일관성
여러 뮤텍스를 쓸 때는 std::scoped_lock으로 한 번에 잠그거나, 모든 스레드에서 동일한 획득 순서를 유지합니다.
RAII 체크리스트
- 생성자에서 리소스 획득
- 소멸자에서 리소스 해제 (noexcept)
- 복사/이동 의미 정의 (delete 또는 구현)
- 표준 라이브러리 타입 우선 사용
- 리소스 획득 실패 시 예외
- 소멸자에서 예외 던지지 않음
13. 프로덕션 패턴
패턴 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 보장
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
이 글에서 다루는 키워드 (관련 검색어)
C++ RAII, 리소스 관리, 생성자 소멸자, 스마트 포인터 원리, 예외 안전, lock_guard, 자동 해제 등으로 검색하시면 이 글이 도움이 됩니다.
마무리
핵심 요약
✅ RAII 원칙: 생성자에서 획득, 소멸자에서 해제
✅ 자동 리소스 관리: 메모리, 파일, 소켓, 락 모두 자동
✅ 예외 안전성: 예외 발생해도 리소스 누수 없음
✅ 스마트 포인터는 RAII: unique_ptr, shared_ptr 모두 RAII 패턴
✅ 표준 라이브러리 활용: ifstream, lock_guard, unique_ptr
✅ 커스텀 RAII: 필요 시 직접 구현 가능
실무 체크리스트
- 모든 리소스를 RAII로 관리
- 수동
close(),unlock()호출 제거 - 예외 발생 시에도 안전한지 확인
- 소멸자는
noexcept(예외 던지지 않음) - 복사/이동 의미 명확히 정의
배운 교훈
파일 핸들 누수로 서버를 다운시킨 경험에서:
- 리소스는 항상 RAII로 관리
- 수동 관리는 실수의 온상
- 표준 라이브러리 우선 사용
- 커스텀 RAII는 간단히 구현 가능
메모리 관리 시리즈 완료
이것으로 C++ 메모리 관리 시리즈를 마칩니다. 이 4개 글을 통해 다음 내용을 배웠습니다:
- #6-2: new/delete의 위험성과 메모리 누수 탐지
- #6-3: 스마트 포인터로 안전한 메모리 관리
- #6-3: 스마트 포인터로 자동 메모리 관리
- #6-4: RAII 패턴으로 모든 리소스 관리
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ RAII(Resource Acquisition Is Initialization) 패턴 완벽 가이드. 생성자·소멸자로 메모리·파일·소켓·뮤텍스 자동 관리, 예외 안전성 보장, lock_guard·unique_p… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: 생성자에서 획득·소멸자에서 해제하는 RAII로 리소스 누수를 막을 수 있습니다. 다음으로 스레드 기초(#7-1)를 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #7-1: std::thread와 멀티스레딩 기초 - thread, mutex, condition_variable, atomic, 그리고 실제로 겪은 race condition 버그와 해결법을 다룹니다.
참고 자료
공식 문서
추천 도서
- “Effective Modern C++” by Scott Meyers
- “C++ Concurrency in Action” by Anthony Williams
관련 글
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
- C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지