C++ · 기본
C++ 객체와 자원 관리
C++ RAII - 파일 핸들과 뮤텍스를 소멸자에 맡기기 (C++ 객체와 자원 관리 3장)
조기 반환과 예외에서 새는 파일 핸들을 세어 보고, 생성자에서 얻고 소멸자에서 돌려주는 RAII 클래스로 누수를 없앤다. lock_guard 도 같은 원리다.
개발자 · 원고 갱신
이 장에서 배우는 것
RAII는 Resource Acquisition Is Initialization의 줄임말이다. 이름은 어렵지만 내용은 한 문장이다. 자원은 객체의 생성자에서 얻고 소멸자에서 돌려준다. 2장에서 확인한 대로 스택 객체의 소멸자는 블록을 어떻게 빠져나가든 반드시 불리므로, 자원 반납을 잊을 방법이 사라진다. 이 책의 나머지 장은 모두 이 한 문장의 응용이다.
- C 스타일 파일 처리에서 조기 반환 때문에 파일 핸들이 새는 것을 숫자로 확인한다.
- 같은 일을 RAII 클래스
File로 바꾸고, 조기 반환·예외·생성 실패에서도 누수가 0인 것을 확인한다. - 표준 라이브러리의
std::lock_guard가 같은 원리로 뮤텍스를 관리하는 것을 본다.
C의 fopen/fclose와 파일 핸들 자체는 『메모리 그림으로 배우는 C 기본』에서 다룬다고 가정하고, 여기서는 "언제 닫히는가"에만 집중한다.
문제 상황
로봇이 주행 기록을 파일로 남긴다. 저장 함수는 파일을 열고, 값을 검사하고, 쓰고, 닫는다. 처음에는 깔끔했는데, 누군가 "음수 값이면 저장하지 말자"는 조건을 중간에 넣으면서 return false;를 추가했다. 그 경로에는 fclose가 없다. 한 번에 파일 하나씩 새지만, 로봇은 몇 주씩 켜져 있고 운영체제가 프로세스에 허용하는 열린 파일 수에는 한도가 있다. 어느 날 새벽 로그 파일도, 설정 파일도, 소켓도 열리지 않는 상태로 로봇이 멈춘다.
이런 버그가 고약한 이유는 세 가지다. 코드 리뷰에서 잘 보이지 않고, 테스트에서는 한두 번만 실행되니 드러나지 않고, 증상이 원인과 멀리 떨어진 곳(엉뚱한 파일 열기 실패)에서 나타난다.
완성 코드
raii.cpp로 저장한다. open_counted/close_counted는 fopen/fclose를 감싸 지금 열려 있는 파일 수를 세는 연습용 함수다.
#include <cstdio>
#include <iostream>
#include <stdexcept>
#include <string>
int open_files = 0;
std::FILE* open_counted(const char* path, const char* mode) {
std::FILE* f = std::fopen(path, mode);
if (f) ++open_files;
return f;
}
void close_counted(std::FILE* f) {
std::fclose(f);
--open_files;
}
// C 방식: 빠져나가는 길마다 직접 닫아야 한다
bool save_manual(const char* path, int value) {
std::FILE* f = open_counted(path, "w");
if (!f) return false;
if (value < 0) return false; // 여기서 닫는 것을 잊었다
std::fprintf(f, "%d\n", value);
close_counted(f);
return true;
}
// RAII 방식: 생성자에서 얻고 소멸자에서 돌려준다
class File {
public:
File(const char* path, const char* mode) : f_(open_counted(path, mode)) {
if (!f_) throw std::runtime_error(std::string("열 수 없음: ") + path);
}
~File() { close_counted(f_); }
File(const File&) = delete;
File& operator=(const File&) = delete;
void write_line(int value) { std::fprintf(f_, "%d\n", value); }
private:
std::FILE* f_;
};
bool save_raii(const char* path, int value) {
File f(path, "w");
if (value < 0) return false;
f.write_line(value);
return true;
}
void save_then_fail(const char* path) {
File f(path, "w");
f.write_line(1);
throw std::runtime_error("저장 도중 오류");
}
int main() {
save_manual("ok.txt", 10);
save_manual("bad.txt", -1);
std::cout << "수동 관리 뒤 열린 파일: " << open_files << '\n';
save_raii("ok2.txt", 10);
save_raii("bad2.txt", -1);
std::cout << "RAII 뒤 열린 파일: " << open_files << '\n';
try {
save_then_fail("fail.txt");
} catch (const std::exception& e) {
std::cout << "예외 받음: " << e.what() << '\n';
}
std::cout << "예외 뒤 열린 파일: " << open_files << '\n';
try {
File missing("no_such_dir/log.txt", "w");
} catch (const std::exception& e) {
std::cout << "예외 받음: " << e.what() << '\n';
}
std::cout << "실패한 생성 뒤 열린 파일: " << open_files << '\n';
}
줄별 해설
save_manual — 경로마다 직접 닫는 C 방식
정상 경로는 close_counted(f)로 닫지만, value < 0인 경로는 그냥 돌아간다. 빠져나가는 길이 세 개(열기 실패, 음수, 정상)인데 그중 하나에만 닫기가 있다. 경로가 늘어날 때마다 닫기를 기억해야 하는 구조 자체가 문제다.
class File의 생성자 — 얻기
생성자에서 파일을 연다. 열기에 실패하면 예외를 던진다. 그래서 File 객체가 존재한다는 것은 곧 파일이 열려 있다는 뜻이 된다(이것이 이 클래스의 불변식이다). 멤버 함수들은 "혹시 안 열렸으면?"을 검사할 필요가 없다.
~File() — 돌려주기
소멸자에서 닫는다. 생성자가 성공한 객체에 대해서만 소멸자가 불리므로, 여기서 f_가 널인지 검사하지 않아도 된다.
File(const File&) = delete;
복사를 금지했다. 복사를 허용하면 두 객체가 같은 FILE*을 들고 있다가 둘 다 소멸자에서 닫게 된다(같은 자원을 두 번 반납). 복사를 어떻게 다뤄야 하는지가 4장의 주제다. 지금은 "자원을 가진 클래스는 복사를 먼저 막아 두고 시작한다"로 기억하자.
save_raii — 닫는 코드가 없다
함수 어디에도 닫는 코드가 없다. f는 지역 객체이므로 return false든 return true든 함수를 나가는 순간 소멸자가 파일을 닫는다. 나중에 누가 조건을 더 넣어도 누수는 생기지 않는다.
save_then_fail — 예외로 빠져나가도
예외가 던져지면 함수는 중간에 끝나고, 호출 스택을 거슬러 올라가며 catch를 찾는다. 이 과정(스택 풀기)에서 지나가는 모든 지역 객체의 소멸자가 불린다. 그래서 예외 경로에서도 파일이 닫힌다. C에는 이 기능이 없어서 goto cleanup 패턴을 쓴다.
실제 실행 결과
$ clang++ -std=c++20 -Wall -Wextra raii.cpp -o raii && ./raii
수동 관리 뒤 열린 파일: 1
RAII 뒤 열린 파일: 1
예외 받음: 저장 도중 오류
예외 뒤 열린 파일: 1
예외 받음: 열 수 없음: no_such_dir/log.txt
실패한 생성 뒤 열린 파일: 1
- 수동 관리 뒤 1 —
bad.txt가 열린 채 남았다. 이 1은 프로그램이 끝날 때까지 줄지 않는다. - RAII 뒤에도 1 — 수동 방식이 남긴 1이 그대로일 뿐, RAII 두 번 호출(정상·조기 반환)은 새 누수를 만들지 않았다.
- 예외 뒤 1 — 예외로 함수를 빠져나갔는데도
fail.txt는 닫혔다. - 실패한 생성 뒤 1 — 없는 폴더라
fopen이 실패했고, 생성자가 예외를 던졌다. 객체가 완성되지 않았으므로 소멸자는 불리지 않는다. 열린 적 없는 파일을 닫으려는 사고도 없다.
같은 원리: std::lock_guard
RAII는 파일에만 쓰는 기법이 아니다. 여러 스레드가 같은 로그 버퍼에 쓰는 코드를 보자. 뮤텍스를 잠그고 풀어야 하는데, 잠근 뒤 예외가 나면 풀기를 건너뛰어 다른 스레드가 영원히 기다린다(교착).
#include <iostream>
#include <mutex>
#include <stdexcept>
std::mutex log_mutex;
int written = 0;
void write_log(int value) {
std::lock_guard<std::mutex> lock(log_mutex);
if (value < 0) throw std::invalid_argument("음수 값");
++written;
}
int main() {
try {
write_log(1);
write_log(-1);
} catch (const std::exception& e) {
std::cout << "예외: " << e.what() << '\n';
}
bool free_now = log_mutex.try_lock();
std::cout << "잠금이 풀려 있나: " << (free_now ? "예" : "아니오") << '\n';
if (free_now) log_mutex.unlock();
std::cout << "기록 수: " << written << '\n';
}
$ clang++ -std=c++20 -Wall -Wextra lock_guard.cpp -o lock_guard && ./lock_guard
예외: 음수 값
잠금이 풀려 있나: 예
기록 수: 1
std::lock_guard는 생성자에서 lock(), 소멸자에서 unlock()을 부르는 작은 클래스다. write_log(-1)이 잠근 상태에서 예외를 던졌지만, 스택 풀기 중 lock의 소멸자가 뮤텍스를 풀었기 때문에 try_lock()이 성공했다. 게임 엔진이나 로봇 제어 루프처럼 여러 스레드가 도는 코드에서는 mutex.lock()을 직접 부르는 코드를 거의 보지 못할 것이다.
표 3-1. 자원과 표준 RAII 타입
| 자원 | 얻기 | 돌려주기 | 표준 RAII 타입 |
|---|---|---|---|
| 힙 메모리 | new | delete | std::unique_ptr, std::vector, std::string |
| 뮤텍스 | lock() | unlock() | std::lock_guard, std::scoped_lock, std::unique_lock |
| 파일 | 열기 | 닫기 | std::ifstream/std::ofstream, 삭제자를 준 unique_ptr(5장) |
| 스레드 | 시작 | 합류 | std::jthread(C++20, 소멸자에서 합류) |
표 3-2. 수동 관리와 RAII 비교(raii.cpp 실행 결과 기준)
| 경우 | 수동 관리 | RAII |
|---|---|---|
| 정상 반환 | 닫힘 | 닫힘 |
| 조기 반환(음수) | 열린 채 남음(1개 누수) | 닫힘 |
| 예외로 빠져나감 | try/catch 없으면 누수 | 닫힘 |
| 열기 실패 | 호출한 쪽이 반환값 검사 | 생성자 예외, 소멸자 안 불림 |
실무에서 자주 틀리는 것
1. RAII 객체에 이름을 붙이지 않는다
std::lock_guard<std::mutex>{log_mutex};라고 쓰면 이름 없는 임시 객체가 만들어지고, 세미콜론에서 바로 소멸한다. 잠갔다가 즉시 푼 것이다. 컴파일은 된다. 검증 환경의 clang과 libc++는 -Wall에서 "ignoring temporary created by a constructor declared with 'nodiscard' attribute" 경고를 냈지만, 표준 라이브러리 버전이나 경고 설정에 따라 아무 말 없이 지나가기도 한다. RAII 객체는 반드시 std::lock_guard<std::mutex> lock(log_mutex);처럼 이름을 붙여 블록 끝까지 살려 둔다.
2. RAII 객체를 new로 만든다
File* f = new File("a.txt", "w");라고 쓰면 소멸자는 delete f 때만 불린다. 자동 정리라는 장점이 사라진다. RAII 객체는 스택(지역 변수)이나 다른 객체의 멤버로 둔다.
3. 소멸자 밖에서 따로 반납한다
File에 close()를 추가하고 호출한 뒤 소멸자에서 또 닫으면 이중 반납이다. 명시적 닫기가 필요하다면 닫은 뒤 핸들을 널로 바꾸고, 소멸자에서는 널이 아닐 때만 닫도록 해야 한다.
4. 블록이 너무 크다
RAII 객체의 수명은 블록 끝까지다. 함수 맨 위에서 잠근 lock_guard는 함수 끝까지 잠금을 쥐고 있다. 잠금이 필요한 부분만 { }로 감싸 블록을 좁히면 다른 스레드가 덜 기다린다.
연습 문제
- 함수
work()에서Guard a("a"); Guard b("b");를 만든 뒤 예외를 던진다.Guard는 생성 때 "+이름", 소멸 때 "-이름"을 기록한다. 호출한 쪽에서 예외를 잡은 뒤 기록은 어떻게 되어 있는가? - 다음 코드는 로그를 쓰는 동안 뮤텍스를 쥐고 있을 것처럼 보이지만 그렇지 않다. 이유와 고친 코드를 써라.
void write() { std::lock_guard<std::mutex>{m}; buffer.push_back(1); } - 2장의
Motor를Motor* m = new Motor("x");로 만들고 같은 블록 안에서 예외가 날 수 있다. 소멸자가 반드시 불리게 하는 방법을 두 가지 이상 들고, 가장 권하는 방법을 골라라.
정답
+a+b-b-a. 예외로 함수를 빠져나갈 때도 지역 객체는 만든 역순으로 소멸한다. 검증 프로그램에서 이 문자열을 확인했다.std::lock_guard<std::mutex>{m};는 이름 없는 임시 객체라서 그 줄이 끝나는 순간 잠금이 풀린다. 검증 프로그램에서 같은 코드 바로 다음 줄의m.try_lock()이 성공하는 것을 확인했다.std::lock_guard<std::mutex> lock(m);처럼 이름을 붙여야 한다. C++17부터는std::scoped_lock lock(m);도 같다.- (가) 모든 경로에
delete를 넣고, 예외도try/catch로 잡아delete후 다시 던진다. 동작은 하지만 경로가 늘 때마다 깨진다. (나) 포인터를 쓰지 않고 스택 객체Motor m("x");로 만든다. (다) 힙이 꼭 필요하면auto m = std::make_unique<Motor>("x");로 소유 객체에 맡긴다. 권하는 순서는 (나) → (다)다. 힙이 필요 없으면 스택이 가장 단순하고 빠르다. 수명이 블록을 넘어야 하거나 크기가 커서 힙이 필요할 때 (다)를 쓴다. (가)는 새 코드에서 쓰지 않는다.
다음 장에서는 RAII 클래스를 복사하거나 옮길 때 무슨 일이 생기는지 본다. 이 장의 File은 복사를 막아서 문제를 피했지만, 버퍼처럼 복사가 필요한 자원도 있다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.