1. C++에서 '리소스'란 무엇인가
대부분의 초보자는 처음에 C++의 리소스가 전적으로 new와 "힙의 어떤 바이트"라고 생각합니다. 그건 정상입니다: 메모리는 손으로 가장 쉽게 만질 수 있으니까요. 그러나 RAII의 개념은 더 넓습니다: 리소스란 획득하고 나서 반드시 해제해야 하는 모든 것입니다. 그리고 리소스가 "비쌀수록" 실수의 대가는 큽니다.
리소스는 단순히 메모리뿐만 아니라 파일, 연결, 락, 버퍼, 디스크립터 등과 같은 "한정된" 객체일 수 있습니다. C++에서 리소스 해제는 종종 특정 함수에 "묶여" 있습니다(예: delete, close, unlock). 그리고 이 함수를 호출하는 것을 잊으면 프로그램이 즉시 크래시하지 않더라도 이상하고 예측 불가능하게 동작할 수 있습니다.
바로 그래서 우리는 "잊지 않을 거야"에 기대기를 멈추고 해제가 자동으로 일어나도록 코드를 쓰고 싶어합니다.
RAII 아이디어: 소유자가 살아있는 동안 리소스도 산다
RAII는 Resource Acquisition Is Initialization의 약자입니다. 개발자 세계의 주문처럼 들리지만, 그 의미는 매우 사람적입니다: "리소스를 획득했다면 그걸 객체 안에 넣고 해제는 소멸자에 숨겨라". 그러면 소유자 객체가 사라질 때 리소스도 알아서 해제됩니다.
중요한 점: RAII는 마법이나 컴파일러의 별도 기능이 아닙니다. RAII는 C++가 보장하기 때문에 작동합니다: 객체의 소멸자는 스코프를 벗어날 때 자동으로 호출된다. 즉 코드에 많은 분기, 조기 return, 복잡한 조건문이 있더라도, 소유자가 로컬 변수로 생성되었다면 블록을 벗어날 때 소멸자가 호출됩니다.
객체 수명 ↔ 리소스 소유의 연관성이 바로 RAII의 핵심입니다.
메커니즘: 소멸자는 스스로 호출된다
소유자를 만들기 전에 소멸자가 실제로 자동 호출되는 메커니즘을 잠깐 "만져"보는 것이 도움이 됩니다. 이제 함수 진입/종료 시 메시지를 출력하는 코드가 나옵니다 — 작은 관찰 카메라처럼 동작합니다.
#include <iostream>
struct Trace {
~Trace() {
std::cout << "Trace destroyed\n"; // Trace destroyed
}
};
void demo_scope() {
Trace t;
std::cout << "Inside demo_scope\n"; // Inside demo_scope
}
int main() {
demo_scope();
}
핵심 관찰: 여러분은 직접 ~Trace()를 호출하지 않습니다. t는 로컬 변수이기 때문에 demo_scope를 벗어날 때 자동으로 호출됩니다.
그리고 이 같은 메커니즘 위에 RAII가 세워집니다: "소멸자에서 리소스를 해제한다".
2. 직접 만든 리소스 소유자: 객체와 배열
가장 단순한 new int{...} 소유자
가장 단순한 C++ 교과서용 리소스를 만들어 봅시다: new로 할당한 int. 보통은 "생성 — 사용 — delete"를 수동으로 쓰지만, 이제 우리는 사용자 코드에서 delete를 없애고 그것을 소유자의 소멸자에 숨기고 싶습니다.
#include <iostream>
struct IntOwner {
int* p{nullptr};
~IntOwner() {
delete p; // 리소스 해제
}
};
int main() {
IntOwner x{ new int{42} };
std::cout << *x.p << '\n'; // 42
}
네, 거의 너무 간단해 보입니다. 하지만 이 간단한 코드에는 강력한 특성이 있습니다: 함수에서 조기에 반환하더라도, 많은 조건이 있더라도 ~IntOwner()는 여전히 x의 스코프를 벗어날 때 실행됩니다.
이 점을 더 느끼게 하기 위해 조기 반환을 추가해봅시다:
#include <iostream>
struct IntOwner {
int* p{nullptr};
~IntOwner() { delete p; }
};
void f(bool ok) {
IntOwner x{ new int{10} };
if (!ok) {
return; // delete는 어쨌든 발생함
}
std::cout << *x.p << '\n'; // 10
}
int main() {
f(false);
f(true);
}
여기서 RAII의 실용적 의미가 드러납니다: 해제가 더 이상 분기나 반환 위치에 의존하지 않습니다.
배열을 위한 RAII: new[] 와 delete[]
중요한 뉘앙스: C++의 배열은 다른 형태인 delete[]로 해제됩니다. delete와 delete[]를 섞어 쓰면 동작이 정의되지 않습니다(UB) — "동작할 수도", "크래시할 수도", "조용히 메모리를 망가뜨릴 수도" 있습니다.
따라서 배열용 소유자는 별도로 만들거나, 적어도 자신이 배열을 소유하고 있다는 것을 확실히 알아야 합니다.
간단한 C-문자열을 저장할 char 배열용 소유자를 만들어 봅시다. "문자열을 수동으로 만드는 방법"을 깊게 파고들지는 않을 것입니다(실제로는 std::string을 사용해야 합니다), 하지만 학습용으로 배열 소유 사례는 적합합니다.
#include <cstddef>
struct CharBufferOwner {
char* data{nullptr};
~CharBufferOwner() {
delete[] data; // 중요: 배열에는 delete[] 를 사용
}
};
int main() {
CharBufferOwner buf{ new char[4]{'C','+','+','\0'} };
// 메모리는 자동으로 해제됩니다
}
다시 한 번: 우리는 main에서 delete[]를 쓰지 않습니다. 소유자를 만들면 그 소유자가 알아서 정리합니다.
5. 단일 책임 지점: 안전성에 관한 이야기
작은 프로그램을 작성할 때는 delete를 "가까이 두는" 것이 정직해 보일 수 있습니다. 하지만 함수와 조건, 반환이 늘어나면 수동 해제 코드는 여기저기로 흩어집니다. 그러면 전형적인 상황이 생깁니다: "어딘가에서 해제했고, 다른 곳에서는 까먹었고, 또 다른 곳에서는 두 번 해제했다".
RAII는 구조적으로 이 문제를 해결합니다: 리소스가 소유자 내부에 있다면, 해제 지점은 단 하나 — 소유자의 소멸자 — 뿐입니다. 덕분에 코드는 예측 가능해집니다: 파일에서 "여기서 delete가 어디 있지?"를 찾을 필요가 없습니다. 해제는 항상 소유자에 붙어 있습니다.
간단한 표로 정리하면:
| 접근 방식 | delete가 위치하는 곳 | 전형적 문제 |
|---|---|---|
| 수동(한 곳에서 new, 다른 곳에서 delete) | 분기나 함수들에 분산됨 | 잊기 쉬움 / 두 번 해제하기 쉬움 |
| RAII(리소스가 소유자 내부) | 하나의 소멸자에 위치 | 실수할 수 있는 위치가 훨씬 줄어듦 |
6. 스코프를 벗어날 때 무슨 일이 일어나는가
RAII를 "소멸자 트릭"으로만 보지 않기 위해 과정을 도식으로 보는 것이 유용합니다. 리소스를 "힙의 객체", 소유자를 "블록의 로컬 변수"로 가정해봅시다.
flowchart TD
A["블록 { ... }에 진입"] --> B["소유자 생성(로컬 변수)"]
B --> C["소유자가 리소스 획득(예: new)"]
C --> D["작업: 조건, 루프, 조기 return"]
D --> E["블록 { ... }를 벗어남"]
E --> F["소유자의 소멸자가 자동 호출됨"]
F --> G["소멸자에서 리소스 해제(delete / delete[])"]
핵심: 조기 return이 있더라도 블록을 벗어나는 동작은 항상 발생합니다. 따라서 "리소스 닫기"도 항상 발생합니다.
7. 실습: 레거시 API를 위한 임시 버퍼
RAII가 단지 int에 대한 이론으로만 남지 않도록, 작은 실제 시나리오에 아이디어를 적용해봅시다. 콘솔 노트 애플리케이션이 있고, 오래된 함수가 C-문자열(const char*)을 받는다고 합시다. 실제로는 오래된 라이브러리, C-API 또는 외부 코드일 수 있습니다.
학습용 "레거시" 함수를 만들어봅니다:
#include <iostream>
void legacy_print(const char* s) {
std::cout << "legacy: " << s << '\n'; // legacy: hello
}
우리는 noteText.c_str()를 전달해서 편하게 살 수 있지만, 여기서는 해제해야 하는 "임시 버퍼" 시나리오를 보여주고자 합니다. 예를 들어 수동으로 char[]를 구성할 수 있습니다(다시 말하지만 실제로는 std::string이 더 낫습니다).
버퍼 소유자와 채우기 함수를 만들어봅니다:
#include <cstddef>
struct CharBufferOwner {
char* data{nullptr};
~CharBufferOwner() { delete[] data; }
};
CharBufferOwner make_hello_buffer() {
CharBufferOwner buf{ new char[6]{} };
buf.data[0] = 'h';
buf.data[1] = 'e';
buf.data[2] = 'l';
buf.data[3] = 'l';
buf.data[4] = 'o';
buf.data[5] = '\0';
return buf; // 소유자가 값으로 반환됨(지금은 단순 아이디어)
}
여기서 미묘한 점이 있습니다: "값으로 소유자 반환"은 복사 관련 함정에 쉽게 걸릴 수 있는 주제입니다(이것은 별도의 큰 이야기입니다). 따라서 이 강의 범위에서는 소유자를 로컬에서 복사 없이 사용하는 것으로 하겠습니다.
안전한 사용 예제 — 소유자를 생성한 동일한 스코프에서 사용합니다:
#include <iostream>
void legacy_print(const char* s) {
std::cout << "legacy: " << s << '\n';
}
struct CharBufferOwner {
char* data{nullptr};
~CharBufferOwner() { delete[] data; }
};
int main() {
CharBufferOwner buf{ new char[6]{'h','e','l','l','o','\0'} };
legacy_print(buf.data); // legacy: hello
}
다시 한 번 핵심: 누가 소유하느냐가 해제 책임을 집니다. 사용자 코드가 어떤 형태의 delete가 필요한지 기억할 필요가 없습니다.
왜 "소유권을 밖으로 나눠줘선 안 되는가"
소유자를 만든 즉시 buf.data를 모두에게 뿌리고 전역에 저장하거나 vector<char*>에 넣고 C 스타일로 살고 싶을 수 있습니다. 그건 가능하지만 안전장치(시스템)를 스스로 해제하는 셈입니다: 포인터는 리소스의 수명을 연장하지 않습니다.
오늘의 실용 규칙은 지루하지만 중요한데, 소유자로부터 꺼낸 원시 포인터는 소유자가 확실히 살아있는 동안에만 임시 접근 용도로 주어져야 한다는 것입니다. 소유자가 파괴되면 그 포인터는 dangling이 되며, nullptr와 같지 않아 보여도 위험합니다.
참고로 dangling 포인터와 객체 수명 문제는 매우 중요해서 C++ 표준 문서와 작업 문서에서 lifetime과 객체 파괴에 관한 문구들이 자주 수정되고 있습니다(편집 보고서와 라이프타임 버그 리스트에서 자주 등장합니다).
8. RAII 소유자의 불변식
종종 RAII를 "소멸자에서 해제"로만 설명하지만, 더 넓게 생각하는 것이 유용합니다: RAII 객체는 책임의 컨테이너입니다. 그것은 단지 delete만 담당하는 것이 아니라 리소스가 항상 이해 가능한 상태에 있도록 책임집니다.
예를 들어 우리의 IntOwner는 기본적으로 nullptr를 유지합니다. 이는 좋습니다: delete nullptr;는 안전하므로 소멸자는 리소스가 "획득되지 않은" 경우에도 검사 없이 안전하게 동작합니다. 이러한 작은 세부가 모여 "우연히 깨트리기 어려운 코드" 스타일을 만듭니다.
좋은 RAII 타입은 보통 불변식이 있습니다: "리소스가 없거나(null/empty) 리소스가 유효하고 이 소유자에 속한다". 이 불변식을 지키면 대부분의 오류 클래스가 발생하지 않습니다.
9. RAII 사용 시 흔한 실수
실수 #1: 외부에서 delete를 남겨두고 소유자 내부에서도 해제하는 것.
개발자가 소유자를 만들고도 습관적으로 근처 어딘가에 delete p;를 쓰는 경우가 있습니다. 결과는 이중 해제이며, RAII가 있는데도 "왜 크래시하지?" 같은 불명확한 상황이 됩니다. RAII에서는 해제는 소유자만 해야 하며 외부 코드는 도와주면 안 됩니다.
실수 #2: 소멸자에서 delete와 delete[]를 혼동하는 것.
전형적인 함정입니다. 만약 리소스가 new[]로 할당되었다면 delete[]로 해제해야 합니다. 혼동하면 동작이 정의되지 않습니다. 따라서 소유자는 자신이 단일 객체를 소유하는지 배열을 소유하는지 확실히 알아야 합니다.
실수 #3: RAII는 "T*를 어디든 퍼뜨려도 된다"는 의미로 오해하는 것.
RAII는 리소스 해제를 보장하지만, 소유자가 파괴되기 전에 그 포인터를 사용하는 사람들이 모두 정상적으로 사용할 수 있음을 보장하지는 않습니다. 소유자가 스코프를 벗어나면 이전에 발급된 모든 원시 포인터는 잠재적으로 dangling이 됩니다. RAII는 소유권에 관한 것이지 함수들 사이의 '텔레파시'에 관한 것은 아닙니다.
실수 #4: 소유자를 만들었지만 안전한 초기 상태를 잊어버리는 것.
소유자 내부의 포인터 필드를 초기화하지 않으면 소멸자에서 쓰레기 주소를 갖게 되어 delete가 "뭔가 아닌 것"을 해제하려 할 수 있습니다. 따라서 기본 습관으로 필드를 선언 시에 nullptr로 초기화(T* p{nullptr};)하고 "null이거나 유효한 리소스"라는 불변식을 지키는 것이 좋습니다.
실수 #5: 소멸자를 수동으로 호출해서 RAII를 "개선"하려 하는 것.
초보자는 종종 "소멸자가 리소스를 해제하니까 필요할 때 obj.~Obj()를 수동으로 호출하면 된다"고 생각합니다. 이는 거의 항상 이중 파괴로 이어지며(UB) 위험합니다. RAII의 소멸자는 객체 수명에 의해 자동으로 호출됩니다. 만약 조기에 해제해야 한다면 다른 설계를 사용해야 하며, 소멸자를 수동으로 호출해서는 안 됩니다.
GO TO FULL VERSION