RAII로 C++ 리소스 수명 묶기: Rule of 0/5와 guard 패턴
파일·소켓·힙 버퍼·환경 설정을 수동으로 열고 닫으면, 중간에 예외가 나거나 조기 return이 생길 때 반납 코드를 건너뛰기 쉽습니다. 이 글에서는 C++의 RAII(Resource Acquisition Is Initialization)로 리소스 수명을 객체 스코프에 묶고, Rule of 0 / Rule of 5 와 실무에서 자주 쓰는 guard 패턴을 정리합니다.
완성할 예제
다음 네 가지 패턴을 코드로 구성합니다.
ProcessEnvGuard: 프로세스 시작 시 환경 변수를 고정하는 단방향 guard
FileGuard: FILE*를 생성·이동·소멸에 묶기
SocketHandle: 정수 파일 디스크립터를 RAII로 관리하기
unique_ptr custom deleter: C API 포인터를 표준 스마트 포인터로 감싸기
읽고 나면 raw handle이 보일 때 Rule of 5를 적용할지, 표준 타입만으로 Rule of 0에 맡길지 판단할 수 있습니다.
왜 수동 반납이 자주 깨지는가
전형적인 실패 경로는 다음과 같습니다.
파일을 연다.
파싱 중 예외가 발생하거나 중간에 return한다.
fclose에 도달하지 못해 핸들이 남는다.
goto cleanup이나 중첩 if로 정리 경로를 맞출 수는 있지만, 함수가 커질수록 누락 확률이 올라갑니다. RAII는 정리 코드를 소멸자에 한 번만 적어 두고, 모든 탈출 경로에서 컴파일러가 호출하게 만듭니다.
스코프와 소멸자의 관계는 단순합니다.
스코프에 진입하면 생성자가 자원을 획득합니다.
정상 사용이 이어집니다.
return이든 예외든 스코프를 벗어나면 소멸자가 반납합니다.
Rule of 0과 Rule of 5
Rule of 0
std::vector, std::string, std::unique_ptr처럼 이미 RAII를 지키는 멤버만으로 클래스를 구성하면, 소멸자·복사·이동을 직접 정의하지 않는 편이 가장 안전합니다. 컴파일러가 생성한 특수 멤버 함수가 멤버의 소멸·복사· 이동을 순서대로 호출합니다.
Rule of 5
날것(raw) 핸들을 직접 소유한다면 다음 다섯 가지를 세트로 설계해야 합니다.
소멸자 — 자원 반납
복사 생성자
복사 대입 연산자
이동 생성자
이동 대입 연산자
복사가 의미 없으면 = delete로 막고, 소유권 이전만 허용하는 경우가 많습니다. 핸들 클래스에서는 이 선택이 일반적입니다.
환경 변수 guard
복원이 필요 없는 프로세스 전역 설정은, 생성자에서만 값을 쓰는 단방향 guard로 충분합니다.
Cpp
#include <cstdlib> #if defined(_WIN32) namespace { struct ProcessEnvGuard { ProcessEnvGuard() { _putenv_s("APP_LOG_LEVEL", "debug"); _putenv_s("OMP_NUM_THREADS", "4"); } }; ProcessEnvGuard process_env_guard; } #endif
전역 객체는 main보다 먼저 생성될 수 있어 초기화 순서 의존 이 생깁니다. 가능하면 main() 첫 줄에서 스택 지역 객체로 두는 편이 예측 가능합니다. 이전 값을 되돌려야 한다면 생성자에서 old value를 저장하고 소멸자에서 복원하세요.
파일 핸들 guard
FILE*는 획득과 반납 쌍이 명확하므로 RAII 예제로 적합합니다. 복사는 금지하고, 이동 시 원본 포인터를 비웁니다.
Cpp
#include <cstdio> #include <utility> struct FileGuard { FILE* fp = nullptr; explicit FileGuard(const char* path, const char* mode) : fp(std::fopen(path, mode)) {} ~FileGuard() { if (fp) { std::fclose(fp); } } FileGuard(const FileGuard&) = delete; FileGuard& operator=(const FileGuard&) = delete; FileGuard(FileGuard&& other) noexcept : fp(other.fp) { other.fp = nullptr; } FileGuard& operator=(FileGuard&& other) noexcept { if (this != &other) { if (fp) { std::fclose(fp); } fp = other.fp; other.fp = nullptr; } return *this; } explicit operator bool() const { return fp != nullptr; } };
사용부는 스코프만 지키면 됩니다.
Cpp
void write_greeting(const char* path) { FileGuard file(path, "w"); if (!file) { return; } std::fputs("hello\n", file.fp); } // 여기서 fclose
예외가 나거나 중간에 return해도 소멸자가 fclose를 호출합니다.
소켓·디스크립터 핸들
POSIX 스타일 정수 디스크립터도 같은 규칙입니다. 유효하지 않은 값을 -1로 두고, 이동 후 원본을 무효화합니다.
Cpp
#include <unistd.h> struct SocketHandle { int fd = -1; ~SocketHandle() { if (fd >= 0) { ::close(fd); } } SocketHandle(const SocketHandle&) = delete; SocketHandle& operator=(const SocketHandle&) = delete; SocketHandle(SocketHandle&& other) noexcept : fd(other.fd) { other.fd = -1; } SocketHandle& operator=(SocketHandle&& other) noexcept { if (this != &other) { if (fd >= 0) { ::close(fd); } fd = other.fd; other.fd = -1; } return *this; } };
unique_ptr와 custom deleter
직접 Rule of 5를 쓰기보다, 이미 RAII인 std::unique_ptr에 삭제자를 넘기는 방법이 종종 더 짧습니다. C 라이브러리 포인터에 특히 유용합니다.
Cpp
#include <memory> template <typename T, void (*Deleter)(T*)> using CHandle = std::unique_ptr<T, decltype(Deleter)>; // 예: OpenSSL EVP_CIPHER_CTX* 를 감싸는 형태 // CHandle<EVP_CIPHER_CTX, EVP_CIPHER_CTX_free> ctx{ // EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free}; struct FileCloser { void operator()(FILE* fp) const { if (fp) { std::fclose(fp); } } }; using UniqueFile = std::unique_ptr<FILE, FileCloser>; UniqueFile open_unique_file(const char* path, const char* mode) { return UniqueFile(std::fopen(path, mode)); }
UniqueFile은 Rule of 0에 가깝습니다. 복사·이동·소멸을 클래스에 다시 적을 필요가 거의 없습니다.
설계 체크리스트
새 타입은 가능하면 Rule of 0(표준 RAII 멤버만)으로 만듭니다.
raw handle을 직접 소유할 때만 Rule of 5를 적용합니다.
복사가 불필요하면 = delete하고 이동만 허용합니다.
이동 후 원본 핸들은 무효 값(nullptr, -1)으로 만듭니다.
소멸자에서는 예외를 던지지 않습니다.
전역 guard보다 main 근처의 지역 guard를 선호합니다.
C API 포인터는 unique_ptr + custom deleter를 먼저 검토합니다.
자주 발생하는 문제
예외가 난 뒤 파일이 계속 열려 있음
fopen 이후 fclose 전에 throw나 조기 return이 있는지 확인합니다. FileGuard나 UniqueFile로 감싸면 스코프 탈출 시 자동으로 닫힙니다.
이동한 뒤 프로그램이 크래시남
이동 대입에서 자기 대입(this == &other)을 처리하지 않았거나, 원본 핸들을 비우지 않아 이중 해제된 경우가 많습니다.
소멸자에서 예외를 던지고 종료됨
정리 실패를 예외로 올리지 마세요. 로그 후 반환하는 쪽이 안전합니다. 이미 스택 풀와인드 중인 소멸자에서 또 예외가 나가면 std::terminate로 이어집니다.
전역 guard 때문에 초기화가 꼬임
다른 전역 객체가 환경 변수에 의존하는데 guard 생성 순서가 보장되지 않을 수 있습니다. 의존이 있다면 main에서 명시적으로 구성하세요.
unique_ptr에 raw delete를 또 호출함
소유권을 넘긴 뒤에는 raw 포인터로 delete하거나 fclose하지 않습니다. 관찰만 필요하면 raw 포인터를 비소유 로 잠깐 쓰되, 수명 책임은 항상 한곳에만 둡니다.
다음 단계
RAII guard가 익숙해지면 다음으로 확장하기 좋습니다.
std::fstream / std::unique_ptr만으로 Rule of 0 클래스 설계하기
스레드 수명을 소멸자 join에 묶기 (이 시리즈의 thread·mutex 글)
C 라이브러리(curl, OpenSSL 등)를 extern "C" + RAII로 감싸기
shared_ptr 순환 참조를 weak_ptr로 끊는 소유권 그래프 정리하기
C++ Core Guidelines의 리소스 관리 절과 cppreference의 Rule of Three/Five/Zero, std::unique_ptr 문서를 함께 보면 API 세부 규칙을 놓치지 않습니다.