한 줄 요약: RAII는 파일, 메모리, 락 같은 자원을 객체가 생성될 때 획득하고 객체 수명이 끝날 때 자동으로 해제하게 만들어 예외와 조기 반환에서도 정리 코드를 놓치지 않게 하는 C++의 핵심 설계 방식입니다.
먼저 알아야 할 배경
C++에서 자원 관리는 단순히 `delete`를 잘 호출하는 문제가 아닙니다. 함수 중간에서 예외가 발생하거나 여러 return 경로가 생기거나 유지보수 중 분기가 추가되면, 처음에는 맞아 보이던 `new/delete`, `open/close`, `lock/unlock` 짝이 쉽게 깨집니다.
RAII는 정리 코드를 사람이 기억해야 하는 절차에서 타입이 책임지는 규칙으로 옮깁니다. 객체가 스코프에 들어올 때 자원을 잡고 스코프를 벗어날 때 소멸자가 자원을 놓으면, 정상 종료와 예외 종료를 같은 경로로 다룰 수 있습니다.
modern C++에서 `std::vector`, `std::string`, `std::unique_ptr`, `std::lock_guard` 같은 타입을 자주 쓰는 이유도 여기에 있습니다. 이 타입들은 단순 편의 래퍼가 아니라 소유권과 수명을 코드 구조에 드러내는 도구이며, 직접 new/delete를 줄이는 가장 현실적인 방법입니다.
핵심 개념
RAII는 자원을 객체 수명에 묶는다
RAII는 Resource Acquisition Is Initialization의 약자입니다. 이름만 보면 생성자에서 자원을 얻는 패턴처럼 보이지만, 실제 핵심은 획득과 해제를 같은 객체 수명 안에 묶는 데 있습니다. 생성자가 파일을 열거나 메모리를 확보하거나 락을 얻었다면, 소멸자는 그 자원을 닫고 해제하고 풀어야 합니다.
이 방식이 강한 이유는 C++의 스코프 규칙과 맞물리기 때문입니다. 지역 객체는 스코프를 벗어나면 자동으로 소멸되고, 예외가 발생해 스택이 풀릴 때도 이미 완전히 생성된 지역 객체의 소멸자가 호출됩니다. 그래서 정리 작업이 return, throw, 분기마다 흩어지지 않고 객체 하나의 소멸자로 모입니다.
RAII 객체는 생성에 실패하면 예외를 던져 유효하지 않은 객체가 사용되지 않게 만들 수 있습니다. 반대로 생성에 성공한 객체는 자신의 불변식을 만족하고, 사용자는 그 객체가 살아 있는 동안 자원이 준비되어 있다고 기대할 수 있습니다. 이 점이 매번 `isOpen()`이나 `isLocked()`를 확인하는 코드보다 인터페이스를 단순하게 만듭니다.
new/delete를 직접 맞추는 코드는 실패 경로에 약하다
수동 정리 코드는 처음 작성할 때는 크게 어렵지 않아 보입니다. `new` 뒤에 `delete`, `fopen` 뒤에 `fclose`, `lock` 뒤에 `unlock`을 적으면 됩니다. 문제는 실제 함수가 한 줄로 끝나지 않고, 중간에 검증 실패, 조기 반환, 예외, 로그 추가, 다른 함수 호출이 계속 들어온다는 점입니다.
정리 호출이 함수의 마지막에만 있으면 중간에서 빠져나가는 경로를 모두 따라가야 합니다. 코드 리뷰에서 모든 분기를 확인해야 하고, 나중에 다른 사람이 return을 하나 추가하면 누수가 생길 수 있습니다. RAII는 그런 분기 추적을 줄이고, 스코프를 벗어나는 모든 경로가 같은 소멸자를 통과하게 합니다.
C++17 기준의 일반 애플리케이션 코드에서는 직접 `new`를 호출할 일이 많지 않습니다. 단독 소유는 `std::unique_ptr`, 여러 원소의 동적 배열은 `std::vector`, 문자열은 `std::string`, 락은 `std::lock_guard`나 `std::unique_lock`을 우선 검토합니다. 자원 종류에 맞는 RAII 타입을 고르는 것이 안전한 기본값입니다.
소유권이 타입에 드러나야 유지보수가 쉬워진다
RAII의 장점은 메모리 누수 방지에만 있지 않습니다. `std::unique_ptr<T>`를 멤버로 둔 클래스는 이 객체가 T를 단독 소유한다는 사실을 타입으로 말합니다. 복사할 수 없는 것도 자연스러운 결과이며, 소유권을 넘겨야 할 때는 이동을 사용한다는 의도가 코드에 드러납니다.
반대로 `T*`만 보면 그 포인터가 소유자인지, 잠깐 빌린 관찰자인지, null이 가능한지 알기 어렵습니다. 문서나 주석이 없다면 호출자는 delete 책임을 추측해야 하고, 두 객체가 같은 raw pointer를 소유한다고 착각하기 쉽습니다. 이런 모호함은 double delete, 댕글링 포인터, 누수로 이어질 수 있습니다.
RAII 타입을 쓰면 클래스의 복사와 이동 정책도 더 명확해집니다. `std::vector`와 `std::string`은 값처럼 복사되고, `std::unique_ptr`는 복사를 막고 이동만 허용합니다. 필요한 정책을 표준 타입의 의미와 조합하면, 직접 소멸자와 복사 연산을 작성해야 하는 경우가 크게 줄어듭니다.
RAII는 예외 안전성과도 연결된다
예외를 사용하지 않는 코드베이스라도 라이브러리 호출, 할당 실패, 생성자 실패 같은 경로는 항상 생각해야 합니다. RAII 객체가 이미 생성된 상태라면 이후 코드에서 예외가 발생해도 소멸자가 정리를 담당합니다. 이 덕분에 함수 본문은 성공 경로의 의미에 집중할 수 있습니다.
소멸자는 일반적으로 예외를 던지지 않아야 합니다. 스택 풀기 중 소멸자에서 또 예외가 나오면 프로그램 종료로 이어질 수 있기 때문입니다. 그래서 RAII 타입의 소멸자는 실패를 보고하기보다 가능한 한 안전하게 자원을 해제하고, 실패 가능성이 있는 종료 작업은 별도 명시적 함수로 설계할지 신중히 판단합니다.
RAII는 성능을 무조건 높여 주는 기법은 아닙니다. 다만 자원 획득과 해제 지점을 분명하게 만들어 불필요한 중복 정리나 누수를 줄이고, 컴파일러가 지역 객체 수명과 이동을 최적화할 여지도 제공합니다. 성능 주장은 항상 실제 측정이 필요하지만, 안정성과 구조 측면에서는 기본 선택지로 삼을 가치가 큽니다.
메모리와 객체 관점에서 이해하기
객체 관점에서 보면 RAII 객체는 자원 핸들을 자신의 내부 상태로 감쌉니다. 핸들이 파일 포인터든 메모리 주소든 뮤텍스 락이든, 외부 코드는 핸들의 생명주기 대신 RAII 객체의 생명주기를 추적하면 됩니다.
스코프에 들어오면 생성자가 실행되고, 스코프를 벗어나면 소멸자가 역순으로 호출됩니다. 여러 RAII 객체가 있다면 나중에 생성된 객체가 먼저 정리되므로, 의존 관계가 있는 자원도 선언 순서를 통해 어느 정도 관리할 수 있습니다.
포인터가 null이 아니라는 사실은 자원이 아직 유효하다는 보장이 아닙니다. RAII는 그 위험한 raw handle을 외부에 오래 노출하지 않고, 유효한 기간을 객체 수명으로 제한해 실수할 표면적을 줄입니다.
코드로 확인하기
FILE*도 unique_ptr과 deleter로 RAII처럼 다루기
C 라이브러리의 `FILE*`처럼 표준 C++ 클래스가 아닌 자원도 RAII로 감쌀 수 있습니다. 먼저 수동 fclose가 실패 경로에서 빠지는 문제를 보고, 그 다음 사용자 지정 deleter를 가진 `std::unique_ptr`로 고쳐 보겠습니다.
#include <cstdio>
#include <stdexcept>
void SaveText(bool failAfterOpen)
{
FILE* file = std::fopen("sample.txt", "w");
if (file == nullptr)
{
throw std::runtime_error("failed to open file");
}
if (failAfterOpen)
{
throw std::runtime_error("work failed before fclose");
}
std::fputs("hello\n", file);
std::fclose(file);
}
// 개선 코드
#include <cstdio>
#include <memory>
#include <stdexcept>
struct FileCloser
{
void operator()(FILE* file) const noexcept
{
if (file != nullptr)
{
std::fclose(file);
}
}
};
void SaveText(bool failAfterOpen)
{
std::unique_ptr<FILE, FileCloser> file(std::fopen("sample.txt", "w"));
if (!file)
{
throw std::runtime_error("failed to open file");
}
if (failAfterOpen)
{
throw std::runtime_error("work failed safely");
}
std::fputs("hello\n", file.get());
}
문제 코드에서는 `failAfterOpen` 분기에서 예외가 발생하면 `fclose`가 호출되지 않습니다. 개선 코드의 unique_ptr은 스코프를 벗어날 때 FileCloser를 호출하므로 정상 종료와 예외 종료 모두에서 파일이 닫힙니다. 여기서 unique_ptr은 메모리뿐 아니라 ‘정리 함수가 필요한 핸들’을 소유하는 일반적인 RAII 도구로 쓰였습니다.
std::lock_guard로 lock/unlock 짝을 스코프에 묶기
뮤텍스는 RAII 효과가 특히 잘 드러나는 자원입니다. lock을 얻은 뒤 예외나 return으로 빠져나가면 unlock을 놓치기 쉬우므로, 잠금 객체의 수명에 해제를 맡기는 편이 안전합니다.
#include <iostream>
#include <mutex>
#include <stdexcept>
std::mutex g_mutex;
int g_value = 0;
void AddValue(bool fail)
{
std::lock_guard<std::mutex> lock(g_mutex);
++g_value;
if (fail)
{
throw std::runtime_error("simulated failure");
}
std::cout << g_value << '\n';
}
int main()
{
try
{
AddValue(false);
AddValue(true);
}
catch (const std::exception&)
{
AddValue(false);
}
}
`std::lock_guard`는 생성자에서 뮤텍스를 잠그고 소멸자에서 잠금을 해제합니다. `AddValue(true)`가 예외를 던져도 lock_guard가 스코프를 벗어나며 unlock을 수행하므로, catch 이후 다시 `AddValue(false)`를 호출할 수 있습니다. 실제 멀티스레드 코드에서는 보호해야 하는 공유 상태 범위를 좁게 유지하는 것도 함께 검토해야 합니다.
소유 객체를 unique_ptr로 반환하기
동적으로 생성한 객체를 함수 밖으로 넘겨야 할 때 raw pointer를 반환하면 누가 delete해야 하는지 흐려집니다. `std::unique_ptr`를 반환하면 단독 소유권이 호출자에게 이동한다는 사실이 타입에 나타납니다.
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Texture
{
public:
explicit Texture(std::string name) : name_(std::move(name))
{
std::cout << "load " << name_ << '\n';
}
~Texture()
{
std::cout << "release " << name_ << '\n';
}
void Draw() const
{
std::cout << "draw " << name_ << '\n';
}
private:
std::string name_;
};
std::unique_ptr<Texture> LoadTexture(std::string name)
{
return std::make_unique<Texture>(std::move(name));
}
int main()
{
auto texture = LoadTexture("hero");
texture->Draw();
}
`LoadTexture`는 `std::make_unique`로 Texture를 만들고 unique_ptr을 반환합니다. 반환된 texture가 main 스코프를 벗어나면 Texture 소멸자가 호출되어 자원이 정리됩니다. 이 구조에서는 delete 호출이 보이지 않지만, 오히려 그래서 정리 책임이 더 명확합니다.
표준 컨테이너로 Rule of Zero에 가까운 클래스 만들기
여러 값을 저장하는 클래스라면 직접 배열을 할당하기보다 `std::vector`나 `std::string` 같은 RAII 타입을 멤버로 두는 편이 좋습니다. 그러면 바깥 클래스는 소멸자와 복사 연산을 직접 작성하지 않아도 됩니다.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Inventory
{
public:
void Add(std::string item)
{
items_.push_back(std::move(item));
}
void Print() const
{
for (const auto& item : items_)
{
std::cout << item << '\n';
}
}
private:
std::vector<std::string> items_;
};
int main()
{
Inventory inventory;
inventory.Add("sword");
inventory.Add("shield");
inventory.Print();
}
Inventory는 `std::vector<std::string>`이 자원 관리를 맡기 때문에 별도 소멸자가 없습니다. 원소 추가 중 예외가 발생하더라도 vector와 string이 이미 생성된 원소를 정리합니다. 이런 설계가 Rule of Zero의 실무적인 형태입니다.
자주 하는 실수
실수 1. manual lock/unlock을 예외가 있는 코드에 그대로 두기
락은 반드시 해제되어야 하는 자원입니다. 잠금 이후 호출한 함수가 예외를 던지거나 중간 return이 추가되면 unlock 호출이 빠질 수 있고, 그 결과 다른 스레드가 영원히 기다릴 수 있습니다.
문제 코드 — 잘못된 예시:
#include <mutex>
std::mutex g_mutex;
void Update()
{
g_mutex.lock();
DoWork(); // 잘못된 예시: 여기서 예외가 나면 unlock이 호출되지 않습니다.
g_mutex.unlock();
}
개선 코드:
#include <mutex>
std::mutex g_mutex;
void Update()
{
std::lock_guard<std::mutex> lock(g_mutex);
DoWork();
}
설명: 개선 코드에서는 lock_guard가 스코프 종료 시 unlock을 보장합니다. 중요한 점은 lock_guard 객체를 너무 넓은 스코프에 두지 않는 것입니다. 보호해야 하는 공유 데이터 접근 범위만 감싸면 안전성과 동시성을 함께 관리하기 쉽습니다.
실수 2. new로 만든 객체를 모든 return 경로에서 delete하려고 하기
함수 안에서 raw pointer를 만들고 마지막에 delete하는 방식은 분기가 늘어날수록 취약해집니다. 특히 early return은 컴파일러가 경고해 주지 않는 누수를 만들기 쉽습니다.
문제 코드 — 잘못된 예시:
class Widget
{
public:
Widget() : value_(new int(42)) {}
~Widget() { delete value_; }
private:
int* value_;
};
void UseWidget(bool earlyReturn)
{
Widget* widget = new Widget();
if (earlyReturn)
{
return; // 잘못된 예시: widget이 delete되지 않습니다.
}
delete widget;
}
개선 코드:
#include <memory>
class Widget
{
public:
Widget() : value_(std::make_unique<int>(42)) {}
private:
std::unique_ptr<int> value_;
};
void UseWidget(bool earlyReturn)
{
auto widget = std::make_unique<Widget>();
if (earlyReturn)
{
return;
}
}
설명: `std::make_unique`로 만든 객체는 unique_ptr이 스코프를 벗어날 때 자동으로 정리합니다. Widget 내부의 int도 unique_ptr 멤버가 소유하므로 Widget 소멸자를 직접 작성할 필요가 없습니다. 소유권이 타입에 드러나면 delete 호출을 빠뜨릴 위치 자체가 줄어듭니다.
실수 3. Open과 Close를 호출자 기억에 맡기기
열고 닫아야 하는 객체가 있다면 호출자가 모든 경로에서 Close를 호출하리라고 기대하기보다, 생성과 소멸을 통해 객체가 스스로 규칙을 지키게 만드는 편이 안전합니다.
문제 코드 — 잘못된 예시:
class Connection
{
public:
void Open();
void Close();
};
void SendMessage(bool fail)
{
Connection connection;
connection.Open();
if (fail)
{
return; // 잘못된 예시: Close 호출을 잊었습니다.
}
connection.Close();
}
개선 코드:
class Connection
{
public:
Connection()
{
Open();
}
~Connection()
{
Close();
}
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
private:
void Open();
void Close() noexcept;
};
void SendMessage(bool fail)
{
Connection connection;
if (fail)
{
return;
}
}
설명: 개선 코드의 Connection은 생성자에서 열고 소멸자에서 닫습니다. 복사를 삭제한 이유는 하나의 연결을 두 객체가 동시에 소유하는 상황을 막기 위해서입니다. 실제 네트워크나 파일 클래스라면 이동 생성자와 이동 대입을 제공할지, 실패 가능한 Close를 어떻게 보고할지까지 설계해야 합니다.
언제 어떤 방식을 선택해야 할까
단독 소유 동적 객체에는 `std::unique_ptr`를 먼저 검토합니다. 여러 소유자가 실제로 같은 수명을 공유해야 할 때만 `std::shared_ptr`를 고려하고, 단순 관찰자는 참조나 비소유 포인터로 표현합니다.
배열이나 목록처럼 여러 값을 저장하는 목적이라면 직접 `new[]`를 쓰기보다 `std::vector`, `std::array`, `std::string` 같은 컨테이너를 선택합니다. 컨테이너가 복사, 이동, 소멸을 이미 관리하므로 코드가 도메인 의미에 집중할 수 있습니다.
락, 파일, 소켓, 핸들처럼 반드시 반납해야 하는 자원은 작은 RAII 래퍼로 감쌉니다. 생성자에서 획득하고 소멸자에서 해제하는 규칙을 만들면 예외와 조기 반환에 강한 코드가 됩니다.
정리
RAII는 C++에서 자원 관리를 안전하게 만드는 기본 관성입니다. 자원을 얻는 순간 객체가 유효해지고, 객체 수명이 끝나는 순간 정리가 일어나도록 만들면 수동 정리 호출을 분기마다 추적하지 않아도 됩니다.
new/delete를 직접 쓰지 않는다는 목표는 단순한 스타일 문제가 아닙니다. 소유권을 `std::unique_ptr`, 값 저장을 `std::vector`와 `std::string`, 잠금을 `std::lock_guard`로 표현하면 타입이 정리 책임을 대신 드러냅니다.
직접 RAII 클래스를 작성해야 할 때는 복사 가능 여부, 이동 가능 여부, 소멸자의 noexcept 성격, 실패 보고 방식을 함께 설계해야 합니다. 그래도 기본 방향은 분명합니다. 자원은 스코프 안에 묶고, 정리는 소멸자에게 맡기는 쪽이 C++다운 안전한 출발점입니다.
참고 자료
- cppreference: RAII — RAII가 객체 수명, 생성자, 소멸자, 스택 풀기와 연결되어 자원 누수를 줄이는 기본 원리를 확인할 때 참고합니다.
- cppreference: std::unique_ptr — 단독 소유권을 표현하는 스마트 포인터와 사용자 지정 deleter를 이용한 FILE 같은 C 자원 관리 방식을 확인할 때 유용합니다.
- cppreference: std::lock_guard — 뮤텍스 잠금과 해제를 스코프에 묶는 표준 RAII 래퍼의 동작을 확인할 때 참고합니다.
- C++ Core Guidelines: E.6 Use RAII to prevent leaks — 예외와 조기 반환이 있는 코드에서 수동 정리보다 RAII를 우선해야 하는 설계 원칙을 확인할 때 좋습니다.
'C++ > C++ 기초' 카테고리의 다른 글
| C++ std::move는 무엇을 이동할까? 이동 생성자와 이동 대입 연산자 이해하기: std::move는 값을 옮기는 함수가 아니라 이동 가능 표시다 (0) | 2026.06.29 |
|---|---|
| C++ 기초 얕은 복사와 깊은 복사의 차이는 무엇일까? double delete부터 Rule of Zero까지 (0) | 2026.06.25 |
| C++ const 위치별 의미 정리: const int*, int* const부터 멤버 함수까지 (0) | 2026.06.23 |
| C++ 포인터와 참조 차이: null 가능성, 수명, 소유권으로 구분하기 (2) | 2026.06.21 |