C++ RAII: 생성자에서 획득하고 소멸자에서 해제하는 자원 관리 클래스 만들기

들어가며: 닫히지 않는 파일 핸들

파일을 열기만 하고 닫지 않는 경로가 있는 서버는, 그 경로를 지나는 요청이 쌓일수록 열린 파일 디스크립터가 늘어납니다. 프로세스당 열 수 있는 파일 수에는 한도가 있고(리눅스의 기본 소프트 한도는 흔히 1024입니다), 한도에 닿으면 open, socket, accept가 모두 EMFILE(“Too many open files”)로 실패하기 시작합니다. 이런 상황이 의심될 때는 lsof -p <pid>나 ls /proc/<pid>/fd | wc -l로 프로세스가 연 파일 수를 확인합니다.

아래 코드가 전형적인 원인입니다. fopen으로 연 파일은 fclose로 닫아야 핸들이 시스템에 반환되는데, shouldStop()이 true가 되어 return으로 나가는 경로에서는 fclose가 호출되지 않습니다. C 스타일 FILE*을 쓰면 언제 닫을지를 사람이 기억해야 하므로, 분기와 예외가 많을수록 이런 누락이 생기기 쉽습니다.

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);  // 정상 경로에서만 실행
}

RAII(Resource Acquisition Is Initialization)는 리소스를 획득하는 시점을 객체의 생성자에, 해제하는 시점을 소멸자에 묶어서, 객체가 스코프를 벗어나면 리소스가 자동으로 해제되게 하는 기법입니다. 파일 핸들, 소켓, 뮤텍스처럼 “획득, 사용, 해제”가 쌍을 이루는 리소스는 예외나 early return이 일어나도 반드시 해제되어야 하는데, C++은 스코프를 벗어나는 모든 경로에서 지역 객체의 소멸자 호출을 보장하므로 이 보장을 그대로 해제에 이용할 수 있습니다. std::lock_guard, std::unique_ptr, std::ifstream이 모두 이 기법을 따릅니다.

힙 메모리에는 스마트 포인터로 RAII를 적용하는 경우가 많고, 소유권 이전은 이동 의미론과 맞물립니다. Rust는 Drop과 소유권 규칙으로 비슷한 “스코프 끝에서 정리”를 컴파일러가 검사합니다. 누수를 찾는 도구 쪽은 메모리 누수 가이드, Valgrind, 누수 탐지 실전을 함께 보면 흐름이 잡힙니다.

RAII 객체의 생명 주기는 다음과 같습니다.

flowchart LR
  A[객체 생성] --> B[생성자: 리소스 획득]
  B --> C[사용]
  C --> D[스코프 종료]
  D --> E[소멸자: 리소스 해제]

새 리소스 클래스를 만들 때 “생성자에서 획득, 소멸자에서 해제” 한 쌍만 맞추면, 예외가 나거나 중간에 return해도 소멸자가 불리므로 누수를 막을 수 있습니다. 그다음에는 복사와 이동을 허용할지를 리소스 소유권 관점에서 정해야 합니다.

RAII로 해결

File 클래스는 생성자에서 fopen으로 파일을 열고 소멸자에서 fclose로 닫습니다. 사용하는 쪽에서는 File file(filename, "r");로 파일을 열고 file.get()으로 FILE*을 fgets 등에 넘기기만 하면 됩니다. 어디서 return하든 file은 지역 객체이므로 함수를 벗어날 때 소멸자가 호출되고, 예외가 나도 스택 언와인딩 과정에서 소멸자가 불립니다.

아래는 File 클래스와 main을 포함한 실행 가능한 예제입니다. g++ -std=c++17 -o raii_file raii_file.cpp && ./raii_file로 빌드·실행하면 test.txt를 만든 뒤 읽어서 RAII demo line을 출력합니다.

// g++ -std=c++17 -o raii_file raii_file.cpp && ./raii_file
#include <cstdio>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>

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(const File&) = delete;             // 핸들 복제 방지 (아래 "복사/이동 의미" 참고)
    File& operator=(const File&) = delete;
    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
}

RAII: 리소스 수명을 객체 수명에 묶는다

RAII라는 이름은 “리소스 획득은 초기화다”라는 뜻으로, 생성자가 리소스(메모리, 파일, 소켓, 락 등)를 획득하고 소멸자가 이를 해제한다는 구조를 가리킵니다. 핵심은 소멸자 호출을 언어가 보장한다는 점입니다.

스코프 진입 때 자원을 획득하고 정상 종료, return, 예외 어느 경로에서도 소멸자가 해제하는 RAII 흐름

그림: RAII의 핵심: 해제 코드를 소멸자에 두면 나가는 경로가 달라도 같은 정리가 실행된다.

std::mutex mtx;

// ❌ RAII 없이 (수동 관리)
void badFunction() {
    FILE* file = fopen("data.txt", "r");
    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");
    if (!file) return;

    std::lock_guard<std::mutex> lock(mtx);

    // ... 복잡한 로직 ...

    if (error1) return;  // lock 해제, file 닫힘
    if (error2) return;  // lock 해제, file 닫힘
}   // 정상 종료 시에도 동일

badFunction에서는 fopen과 mtx.lock()을 수동으로 호출한 뒤 error1, error2마다 fclose와 mtx.unlock()을 따로 불러야 해서 누락과 중복이 생깁니다. 게다가 “복잡한 로직”이 예외를 던지면 어느 정리 코드도 실행되지 않고 뮤텍스가 잠긴 채로 남습니다. goodFunction에서는 std::ifstream과 std::lock_guard가 생성 시 리소스를 잡고 소멸 시 닫고 풀어 주므로, return이나 예외가 어디서 나와도 해제가 보장됩니다.


뮤텍스, 소켓, DB 커넥션, mmap에서 생기는 누수

뮤텍스: 예외로 unlock을 건너뜀

mtx.lock() 뒤에 예외가 나면 unlock()이 호출되지 않고, 이후 같은 뮤텍스를 잠그려는 모든 스레드가 영원히 기다립니다.

// ❌ 수동 잠금
void writeLog(const std::string& msg) {
    mtx.lock();
    logFile << msg << "\n";  // 스트림에 예외가 켜져 있으면 여기서 throw 가능 → unlock() 호출 안 됨
    mtx.unlock();
}

// ✅ RAII
void writeLog(const std::string& msg) {
    std::lock_guard<std::mutex> lock(mtx);
    logFile << msg << "\n";
}

소켓: 실패 경로에서 close 누락

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;
}

DB 트랜잭션: 예외로 rollback 누락

트랜잭션 도중 예외가 나서 commit()도 rollback()도 호출되지 않으면, 연결이 열린 트랜잭션을 붙든 채 남아 락을 쥐고 있거나 풀에 반환되지 못합니다.

// ❌ 수동 트랜잭션
void transfer() {
    db.begin();
    db.withdraw(...); db.deposit(...);  // 예외 시 rollback 안 됨
    db.commit();
}

// ✅ Transaction RAII (아래에서 구현)
void transfer() {
    Transaction txn(db);
    db.withdraw(...); db.deposit(...);
    txn.commit();
}

mmap: munmap 누락

mmap() 후 munmap()을 건너뛰는 경로가 있으면 매핑이 프로세스 주소 공간에 남고, 파일 디스크립터까지 열려 있으면 둘 다 샙니다.

// ❌ 수동 mmap: 처리 중 예외가 나면 munmap/close 모두 누락
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(addr, size);
    close(fd);
}

완전한 MappedFile 클래스는 뒤의 예제에서 구현합니다.

메모리와 GPU 리소스

new 후 예외가 나서 delete가 호출되지 않는 문제는 std::unique_ptr로 해결합니다. OpenGL의 glGenTextures/glDeleteTextures 같은 GPU 리소스도 같은 방식으로, 생성자에서 만들고 소멸자에서 지우는 Texture 클래스로 감쌀 수 있습니다.


생성자에서 획득, 소멸자에서 해제, 복사와 이동 규칙

생성자에서 획득하고 소멸자에서 해제

class Resource {
    int* data;

public:
    explicit Resource(size_t size) {
        data = new int[size];  // 생성자에서 획득
        std::cout << "Resource acquired\n";
    }

    ~Resource() {
        delete[] data;  // 소멸자에서 해제
        std::cout << "Resource released\n";
    }
};

void use() {
    Resource res(100);  // "Resource acquired"
    // 리소스 사용...
}   // 스코프 종료 → 소멸자 자동 호출 → "Resource released"

Resource 생성자에서 new int[size]로 힙 메모리를 얻고 소멸자에서 delete[] data로 해제합니다. use()가 끝나는 } 시점에 res의 소멸자가 자동으로 호출되므로 개발자가 delete를 부를 필요가 없습니다.

복사와 이동 의미 정하기

위 클래스는 아직 위험합니다. 컴파일러가 만든 복사 생성자는 data 포인터 값만 복사하므로, 복사본과 원본이 같은 배열을 가리키다가 각자 delete[]를 호출합니다. 소유권이 하나여야 하는 리소스라면 복사를 막고 이동만 허용합니다.

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;
    }
};

이동 생성자와 이동 대입에서는 other.data를 nullptr로 바꿔 소유권만 넘기므로, 원본의 소멸자가 delete[] nullptr(아무 일도 하지 않음)를 호출하게 되어 해제는 정확히 한 번만 일어납니다. 이동 연산에 noexcept를 붙이는 것도 중요합니다. std::vector는 재할당할 때 요소의 이동 생성자가 noexcept인 경우에만 복사 대신 이동을 사용합니다.


파일 핸들: ifstream과 FILE* 래퍼

표준 라이브러리 스트림

#include <fstream>
#include <stdexcept>
#include <string>

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은 생성자에서 파일을 열고 소멸자에서 닫으므로, 중간에 return하거나 processLine이 예외를 던져도 파일이 닫힙니다. C++ 코드에서 파일을 다룬다면 직접 래퍼를 만들기 전에 표준 스트림부터 고려하는 것이 좋습니다.

C 스타일 파일 래퍼

C API를 써야 하는 경우에는 FILE*을 감싼 클래스를 둡니다.

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);
        }
    }

    ~CFile() {
        if (file) {
            fclose(file);
        }
    }

    // 복사 금지
    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;  // 소멸자에서 fclose
    }
}

복사 생성과 복사 대입을 = delete로 막아서 파일 핸들이 복제되지 않게 하고, get()으로만 C API에 FILE*을 넘깁니다. 생성자가 열기에 실패하면 예외를 던지므로, CFile 객체가 존재한다는 것 자체가 “파일이 열려 있다”는 뜻이 됩니다.


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

std::lock_guard<std::mutex> lock(mtx)는 생성 시 잠그고 소멸 시 해제하므로, return이 어디서 나와도 뮤텍스가 풀립니다.

unique_lock

void advancedLocking() {
    std::unique_lock<std::mutex> lock(mtx);

    doWork1();

    // 락이 필요 없는 구간에서는 잠시 해제
    lock.unlock();
    doExpensiveWork();

    lock.lock();

    doWork2();
}   // 잠겨 있으면 소멸자에서 unlock

std::unique_lock은 lock_guard처럼 RAII로 잠금과 해제를 관리하지만, 중간에 unlock()으로 잠시 풀었다가 lock()으로 다시 잡을 수 있습니다. 락이 필요 없는 오래 걸리는 작업 동안 락을 풀어 두면 다른 스레드가 그동안 진입할 수 있어 경합이 줄어듭니다. 다만 다시 잠근 뒤에는 그 사이 다른 스레드가 공유 상태를 바꿨을 수 있으므로, 풀기 전에 읽은 값을 그대로 믿으면 안 됩니다. 소멸자는 “현재 잠겨 있을 때만” 해제하므로 어느 상태로 스코프를 벗어나도 안전합니다.

잠금 순서가 엇갈려 생기는 데드락

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으로 한 번에 잠그면 방지할 수 있습니다.

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_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 클래스

#include <stdexcept>
#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");
        }
    }

    ~Socket() {
        if (sockfd >= 0) {
            close(sockfd);
        }
    }

    // 복사 금지
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;

    // 이동 허용
    Socket(Socket&& other) noexcept : sockfd(other.sockfd) {
        other.sockfd = -1;
    }
    Socket& operator=(Socket&& other) noexcept {
        if (this != &other) {
            if (sockfd >= 0) close(sockfd);
            sockfd = other.sockfd;
            other.sockfd = -1;
        }
        return *this;
    }

    int get() const { return sockfd; }
};

void handleConnection() {
    Socket sock(AF_INET, SOCK_STREAM, 0);

    // 연결 설정...

    if (error) {
        return;  // 소멸자에서 close
    }

    // 데이터 송수신...
}   // 소멸자에서 close

이동 연산은 원본의 sockfd를 -1로 바꿔 소유권만 넘기므로, 원본의 소멸자는 아무것도 닫지 않습니다. 이동 대입은 덮어쓰기 전에 자신이 가진 소켓을 먼저 닫아야 한다는 점이 이동 생성자와 다릅니다. 이 처리를 빠뜨리면 대입될 때마다 디스크립터가 하나씩 샙니다.


commit하지 않으면 rollback하는 트랜잭션 가드

class Transaction {
    Database& db;
    bool committed = false;

public:
    explicit Transaction(Database& database) : db(database) {
        db.begin();
    }

    ~Transaction() {
        if (!committed) {
            try {
                db.rollback();
            } catch (...) {
                // 소멸자 밖으로 예외를 내보내면 std::terminate. 로그만 남김
            }
        }
    }

    void commit() {
        db.commit();
        committed = true;  // commit이 예외 없이 끝난 경우에만 표시
    }

    Transaction(const Transaction&) = delete;
    Transaction& operator=(const Transaction&) = delete;
};

void transferMoney(Database& db, 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()을 호출합니다. 예외가 나면 commit()까지 도달하지 못하므로 자동으로 롤백되고, 개발자가 commit() 호출을 잊은 경우에도 변경이 반영되지 않는 안전한 쪽으로 끝납니다. committed = true를 db.commit() 뒤에 두었기 때문에 커밋 자체가 실패해 예외를 던져도 소멸자가 롤백을 시도합니다. 롤백 실패를 소멸자 안에서 삼키는 이유는 아래 “소멸자에서 예외 던지기”에서 설명합니다.


타이머, 임시 디렉토리, 스레드 join용 RAII 클래스

구간 시간을 재는 타이머

#include <chrono>
#include <iostream>
#include <string>

class Timer {
    std::string name;
    std::chrono::steady_clock::time_point start;

public:
    explicit Timer(std::string n)
        : name(std::move(n)), start(std::chrono::steady_clock::now()) {}

    ~Timer() {
        auto end = std::chrono::steady_clock::now();
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
        std::cout << name << " took " << ms.count() << "ms\n";
    }
};

void processData() {
    Timer timer("processData");

    // ... 작업 ...
}   // 함수가 어떻게 끝나든 경과 시간 출력

생성자에서 시작 시각을 저장하고 소멸자에서 경과 시간을 출력하므로, processData()가 return하거나 예외로 끝나도 측정이 빠지지 않습니다. 구간 측정에는 steady_clock을 씁니다. high_resolution_clock은 구현에 따라 system_clock의 별칭일 수 있어서, 측정 도중 시스템 시각이 조정되면 경과 시간이 틀어지거나 음수가 될 수 있습니다.

임시 디렉토리

#include <filesystem>
#include <random>
#include <stdexcept>
#include <string>
#include <system_error>

class TempDirectory {
    std::filesystem::path path;

public:
    explicit TempDirectory(const std::string& prefix) {
        std::random_device rd;
        std::mt19937_64 gen(rd());
        for (int attempt = 0; attempt < 10; ++attempt) {
            auto candidate = std::filesystem::temp_directory_path() /
                             (prefix + "_" + std::to_string(gen()));
            // create_directory는 이미 존재하면 false를 반환 → 남의 디렉터리를 가져오지 않음
            if (std::filesystem::create_directory(candidate)) {
                path = candidate;
                return;
            }
        }
        throw std::runtime_error("cannot create unique temp dir");
    }

    ~TempDirectory() {
        std::error_code ec;  // 소멸자에서는 예외를 던지지 않는 오버로드 사용
        std::filesystem::remove_all(path, ec);
    }

    TempDirectory(const TempDirectory&) = delete;
    TempDirectory& operator=(const TempDirectory&) = delete;

    const std::filesystem::path& getPath() const { return path; }
};

void processFiles() {
    TempDirectory tempDir("myapp");
    auto tempFile = tempDir.getPath() / "data.tmp";

    if (error) {
        return;  // 임시 디렉토리 자동 삭제
    }
}   // 임시 디렉토리 자동 삭제

이름을 만들 때 rand()를 그대로 쓰면 시드를 주지 않은 경우 실행할 때마다 같은 이름이 나오고, create_directory는 디렉터리가 이미 있으면 에러 없이 false만 반환합니다. 그 결과를 확인하지 않으면 다른 프로세스가 쓰던 디렉터리를 소멸자에서 통째로 지울 수 있으므로, 새로 만들었을 때만 소유권을 갖도록 반환값을 확인합니다. remove_all의 기본 오버로드는 실패 시 filesystem_error를 던지므로, 소멸자에서는 std::error_code를 받는 오버로드를 씁니다. 운영체제가 제공하는 mkdtemp 같은 함수를 쓸 수 있다면 그쪽이 경쟁 조건 면에서 더 확실합니다.

스레드 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예외가 나면 호출 전 상태 그대로copy-and-swap 대입, 트랜잭션
Basic예외가 나도 누수가 없고 불변식이 유지됨(값은 바뀌었을 수 있음)RAII 멤버만 쓰는 대부분의 코드
보장 없음예외가 나면 누수나 깨진 상태가 남을 수 있음수동 자원 관리 코드

RAII는 Basic 보장의 토대이고, 그 위에 copy-and-swap 같은 기법을 더하면 Strong 보장을 만들 수 있습니다. 각 수준의 자세한 설명은 예외 안전성 글에서 다룹니다.

RAII로 Strong 보장 구현

#include <algorithm>
#include <memory>
#include <utility>

class Data {
    std::unique_ptr<int[]> buffer;
    size_t size;

public:
    explicit Data(size_t s) : buffer(std::make_unique<int[]>(s)), size(s) {}

    // unique_ptr 멤버 때문에 복사 생성자가 자동으로 만들어지지 않으므로 직접 정의
    Data(const Data& other)
        : buffer(std::make_unique<int[]>(other.size)), size(other.size) {
        std::copy(other.buffer.get(), other.buffer.get() + size, buffer.get());
    }

    void swap(Data& other) noexcept {
        std::swap(buffer, other.buffer);
        std::swap(size, other.size);
    }

    Data& operator=(const Data& other) {
        Data temp(other);  // 복사 (할당 실패 시 예외 가능, *this는 그대로)
        swap(temp);        // 예외 없음
        return *this;
    }   // temp 소멸 → 이전 버퍼 해제
};

swap은 포인터와 크기만 바꾸므로 예외가 날 수 없어 noexcept로 둡니다. operator=는 먼저 Data temp(other)로 복사본을 만들고, 성공한 경우에만 swap(temp)로 내용을 바꿉니다. 복사 중 할당 실패로 예외가 나면 *this는 손대지 않은 상태로 남고, swap 이후에는 예외가 날 단계가 없으므로 대입은 “완전히 성공하거나 아무 변화도 없거나” 둘 중 하나가 됩니다. 함수가 끝날 때 temp가 소멸하면서 예전 버퍼가 해제됩니다.

스택 언와인딩과 소멸자 호출 순서

예외가 발생하면 그 시점까지 완전히 생성된 지역 객체들이 생성의 역순으로 소멸됩니다.

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");
        // 소멸 순서: memory 해제 → lock 해제 → file2 닫힘 → file1 닫힘
    }

    // ... 작업 ...
}   // 정상 종료 시에도 같은 역순으로 해제

file1, file2, lock, memory 순으로 생성했으므로 예외가 나면 memory, lock, file2, file1 순으로 소멸자가 호출됩니다. 지역 리소스를 모두 RAII 객체로 들고 있으면 이 순서만으로 정리가 끝나므로 별도의 정리 코드가 필요 없습니다. 단, 예외가 어디에서도 잡히지 않으면 스택 언와인딩을 할지 여부 자체가 구현에 맡겨지므로, main이나 스레드 진입 함수에서 예외를 잡아 두는 것이 안전합니다.


Rule of Five, 스코프 가드, MappedFile 예제

예제 1: 복사 금지·이동 허용 파일 핸들

#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_);
    }
    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_; }
};

소멸자, 복사 두 개, 이동 두 개의 다섯 특수 멤버를 모두 명시한 형태입니다. 이동 대입의 if (this != &other) 검사는 h = std::move(h)처럼 자기 자신에게 이동할 때 파일을 닫은 뒤 닫힌 핸들을 다시 가져오는 사고를 막습니다.

예제 2: 스코프 가드

스코프를 벗어날 때 임의의 동작을 실행하는 패턴입니다. 전용 클래스를 만들 정도는 아닌 일회성 정리 작업에 씁니다.

#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; }

    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;
};

release()를 호출하면 정리 동작을 취소할 수 있어 “실패했을 때만 되돌리기”에 쓸 수 있습니다. 등록한 동작이 예외를 던지면 noexcept 소멸자 때문에 std::terminate가 호출되므로, 정리 동작 자체는 예외를 던지지 않게 작성해야 합니다. std::function은 할당이 일어날 수 있으므로, 성능이 중요한 곳에서는 람다 타입을 템플릿 인자로 받는 구현이 흔히 쓰입니다.

예제 3: MappedFile

open, mmap을 생성자에서 하고 소멸자에서 munmap, close 순으로 해제합니다.

#include <fcntl.h>
#include <stdexcept>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

class MappedFile {
    void* addr_ = nullptr;
    size_t size_ = 0;
    int fd_ = -1;
public:
    explicit MappedFile(const char* path) {
        fd_ = open(path, O_RDONLY);
        if (fd_ < 0) throw std::runtime_error("open failed");

        struct stat st;
        if (fstat(fd_, &st) < 0 || st.st_size == 0) {  // 크기 0인 파일은 mmap이 EINVAL
            close(fd_);
            throw std::runtime_error("fstat failed or empty file");
        }
        size_ = static_cast<size_t>(st.st_size);

        addr_ = mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd_, 0);
        if (addr_ == MAP_FAILED) {
            close(fd_);
            throw std::runtime_error("mmap failed");
        }
    }
    ~MappedFile() noexcept {
        munmap(addr_, size_);
        close(fd_);
    }
    MappedFile(const MappedFile&) = delete;
    MappedFile& operator=(const MappedFile&) = delete;

    const void* data() const { return addr_; }
    size_t size() const { return size_; }
};

생성자가 예외로 끝나면 소멸자는 호출되지 않으므로, 실패 경로에서는 이미 연 fd_를 생성자 안에서 직접 닫아야 합니다. 생성자가 성공했다면 addr_와 fd_는 항상 유효하므로 소멸자는 검사 없이 해제합니다. 복사 생성자만 delete하고 복사 대입을 빠뜨리면 컴파일러가 복사 대입을 여전히 만들어 주므로(폐기 예정이지만 아직 생성됨) 두 개를 함께 막아야 합니다. 매핑은 fd를 닫은 뒤에도 유효하므로, 디스크립터를 아끼고 싶다면 mmap 직후에 닫는 설계도 가능합니다.

예제 4: 여러 RAII 객체 함께 쓰기

void threadSafeLog(const std::string& msg, const std::string& path) {
    std::lock_guard<std::mutex> lock(logMtx);
    std::ofstream file(path, std::ios::app);
    if (!file) throw std::runtime_error("Cannot open log");
    file << msg << "\n";
}   // return이든 throw든 file이 먼저 닫히고 lock이 해제됨

RAII 클래스에서 자주 하는 실수

소멸자에서 예외 던지기

C++11부터 소멸자는 따로 지정하지 않으면 암묵적으로 noexcept입니다. 그래서 소멸자 밖으로 예외가 빠져나가면 스택 언와인딩 중이든 아니든 즉시 std::terminate()가 호출됩니다. noexcept(false)로 바꾸더라도, 다른 예외로 언와인딩하는 도중에 소멸자가 또 예외를 던지면 역시 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";
}

복사/이동 의미 정의 누락

복사를 막지 않으면 기본 복사가 핸들 값만 복사하는 얕은 복사가 되어, 두 객체가 같은 리소스를 가리키다가 소멸 시 두 번 해제합니다.

// ❌ 기본 복사 → 얕은 복사 → 이중 해제
class Resource {
    int* data;
public:
    explicit Resource(size_t n) : data(new int[n]) {}
    ~Resource() { delete[] data; }
};

// ✅ 복사 금지, 이동 허용
class Resource {
    int* data;
public:
    explicit Resource(size_t n) : data(new int[n]) {}
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
    Resource(Resource&& other) noexcept : data(other.data) { other.data = nullptr; }
    ~Resource() { delete[] data; }
};

생성자 도중 실패 시 부분 해제 누락

생성자에서 여러 리소스를 차례로 얻다가 중간에 예외가 나면, 이미 얻은 리소스가 샐 수 있습니다. 생성자가 예외로 끝나면 그 클래스의 소멸자는 호출되지 않지만, 이미 완전히 생성된 멤버와 기본 클래스는 생성의 역순으로 소멸됩니다. 원시 포인터 멤버 두 개에 생성자 본문에서 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)};  // 여기서 실패해도 a는 해제됨
};

락을 너무 넓게 잡기

락 가드를 함수 맨 위에 두면 락이 필요 없는 I/O까지 잠긴 채로 실행되어 다른 스레드가 오래 기다립니다. 락이 필요한 구간만 {} 블록으로 감싸 가드의 수명을 줄입니다.

void process() {
    auto data = loadData();
    {
        std::lock_guard<std::mutex> lock(mtx);
        transformData();
    }
    saveData();
}

RAII 객체를 new로 만들기

new로 만든 RAII 객체는 delete할 때까지 소멸자가 불리지 않으므로, RAII의 이점이 사라집니다. RAII 객체는 스택에 두거나, 힙이 필요하면 std::unique_ptr로 관리합니다.

// ❌ 힙 할당 후 delete 누락
void bad() {
    auto* file = new std::ifstream("data.txt");
    if (error) return;  // 객체도 파일 핸들도 샘
}

// ✅ 스택에 두기
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;
}

소멸자에서 가상 함수 호출

소멸자 안에서 가상 함수를 호출하면, 파생 클래스 부분은 이미 소멸된 상태이므로 파생 클래스의 오버라이드가 아니라 현재 소멸 중인 클래스의 버전이 호출됩니다. 정의되지 않은 동작은 아니지만, 파생 클래스별 정리를 기대했다면 조용히 실행되지 않습니다. 그 함수가 순수 가상 함수라면 미정의 동작이 되고 흔히 “pure virtual function called”로 종료됩니다. 파생 클래스별 정리가 필요하면 각 클래스가 자기 소멸자에서 처리하게 합니다.


RAII 클래스를 설계할 때

먼저 표준 라이브러리에 이미 있는 RAII 타입(std::ifstream, std::lock_guard, std::unique_ptr, std::jthread)으로 해결되는지 확인합니다. 직접 만들어야 한다면 획득은 생성자 한 곳, 해제는 소멸자 한 곳에 두고, 획득에 실패하면 예외를 던져 유효하지 않은 객체가 만들어지지 않게 하며, 소멸자는 예외를 던지지 않게 합니다. 그리고 소유권이 하나인 리소스는 복사를 막고 이동을 허용하고, 여러 곳에서 공유해야 한다면 std::shared_ptr 같은 참조 카운팅을 고려합니다.

가장 좋은 경우는 특수 멤버를 아예 쓰지 않는 것입니다(Rule of Zero). 멤버가 전부 unique_ptr, std::vector, std::string처럼 스스로 소멸과 이동을 올바르게 처리하는 타입이면 컴파일러가 만든 소멸자와 이동 연산이 그대로 맞습니다. FILE*처럼 C API 핸들도 std::unique_ptr<FILE, decltype(&fclose)>처럼 커스텀 삭제자를 붙이면 별도 클래스 없이 RAII로 다룰 수 있습니다. 흔히 걸리는 예외는 std::mutex 멤버입니다. mutex는 복사도 이동도 안 되므로 클래스 전체가 이동 불가가 되고, std::vector에 넣거나 팩토리에서 값으로 반환하기 어려워집니다. 이때는 std::unique_ptr<std::mutex>로 감싸 이동 가능하게 만들거나, 동기화를 별도 객체로 분리하는 식으로 타협합니다.


연결 풀, 로그 스코프, 상태 롤백에 RAII 적용하기

연결 풀에서 빌린 연결 반환

class ConnectionGuard {
    ConnectionPool& pool_;
    Connection* conn_;
public:
    explicit ConnectionGuard(ConnectionPool& pool) : pool_(pool), conn_(pool_.acquire()) {}
    ~ConnectionGuard() noexcept { if (conn_) pool_.release(conn_); }
    Connection* get() const { return conn_; }

    ConnectionGuard(const ConnectionGuard&) = delete;
    ConnectionGuard& operator=(const ConnectionGuard&) = delete;
};

연결을 빌린 함수가 어떤 경로로 끝나든 연결이 풀에 돌아가므로, “예외 경로에서 반환을 빠뜨려 풀이 고갈되는” 문제를 막습니다.

함수 진입과 퇴출 로깅

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";
    }
};

Pimpl과 unique_ptr

// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pimpl_;
public:
    Widget();
    ~Widget();                     // 선언만, 정의는 Impl이 완전한 타입인 widget.cpp에서
    Widget(Widget&&) noexcept;     // 이동 연산도 마찬가지
    Widget& operator=(Widget&&) noexcept;
};

// widget.cpp
struct Widget::Impl { /* ... */ };
Widget::Widget() : pimpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;

구현체를 unique_ptr로 들고 있으면 해제는 자동이지만, unique_ptr<Impl>의 소멸자는 Impl이 완전한 타입인 곳에서만 만들어질 수 있습니다. 헤더에서 소멸자나 이동 연산을 = default로 정의하면 그 자리에서 불완전한 Impl을 삭제하는 코드가 만들어지려 해 컴파일 에러가 나므로, 헤더에는 선언만 두고 .cpp에서 = default로 정의합니다.

실패하면 상태 되돌리기

앞의 ScopeGuard로 작업 전 상태를 저장해 두고, 성공했을 때만 복원을 취소합니다.

void modifyConfig() {
    auto backup = config.save();
    ScopeGuard guard([&] { config.restore(backup); });
    config.applyChanges();  // 예외가 나면 guard 소멸자가 restore 실행
    guard.release();        // 성공 시 복원 생략
}

같이 보면 좋은 글

참고 자료