본문으로 건너뛰기 C++ RAII 심화 완전 가이드 — 생성자·소멸자 보장, 예외 안전, 스코프 가드, 핸들 설계

C++ RAII 심화 완전 가이드 — 생성자·소멸자 보장, 예외 안전, 스코프 가드, 핸들 설계

C++ RAII 심화 완전 가이드 — 생성자·소멸자 보장, 예외 안전, 스코프 가드, 핸들 설계

이 글의 핵심

RAII는 단순히 ‘소멸자에서 닫는다’를 넘어, 객체 수명·예외·이동 의미론과 맞물립니다. 생성자·소멸자가 보장하는 것, 예외 안전 수준, 스코프 가드·핸들 설계, 운영 환경에서의 패턴을 한 글에서 정리합니다.

들어가며

RAII 기본시리즈 RAII 실무에서 다룬 “생성자에서 획득·소멸자에서 해제”는 여전히 중심 원리입니다. 다만 프로덕션 코드에서는 그 너머로, 객체 수명 규칙, 예외가 터졌을 때 불변식이 어떻게 유지되는지, 이동·복사와 소멸자 계약이 설계를 좌우합니다. 이 글은 그 내부 메커니즘과 설계 선택에 초점을 맞춥니다.


1. 생성자·소멸자가 보장하는 것

1.1 부분 생성과 역순 파괴

C++에서 생성자 본문이 실행되기 전에 기본 클래스와 멤버는 이미 부분적으로 구성됩니다. 생성자 본문 중 예외가 나면, 이미 완전히 구성된 하위 객체는 생성의 역순으로 소멸자가 호출됩니다. 따라서 “절반만 열린 리소스”를 남기지 않으려면 멤버를 스마트 포인터·RAII 보조 타입으로 두고, 원시 핸들은 가능한 한 즉시 그런 타입으로 옮기는 편이 안전합니다.

#include <memory>
#include <stdexcept>

struct Inner {
    Inner(int x) {
        if (x < 0) throw std::runtime_error("inner");
    }
};

struct Owner {
    std::unique_ptr<int[]> buf; // 먼저 기본 구성 → 예외 시 unique_ptr 소멸자가 해제
    Inner inner;

    Owner() : buf(std::make_unique<int[]>(16)), inner(1) {}
};

위에서 inner의 생성이 실패하면, 이미 만들어진 bufunique_ptr 소멸자 경로로 정리됩니다. 반대로 두 개의 원시 new 쓰면 첫 번째 할당만 남는 누수가 생깁니다.

1.2 소멸 순서와 스택 풀다운

블록을 나가거나 예외로 스택이 풀릴 때, 자동 저장 기간을 가진 객체는 선언의 역순으로 소멸합니다. RAII는 이 규칙에 기대어 “나중에 잡은 락을 먼저 푼다” 같은 직관과 맞습니다. 여러 뮤텍스를 잡을 때는 std::scoped_lock으로 한 번에 잠그거나, 잠금 순서를 문서화해야 합니다.

1.3 소멸자와 noexcept

C++11 이후 소멸자는 암시적으로 noexcept(true)인 경우가 많습니다. 소멸자에서 예외가 빠져나가면 스택 언와인딩 중 또 다른 예외가 되어 std::terminate로 이어질 수 있습니다. 파일 닫기·락 해제·핸들 반환이 실패할 수 있는 API라면, 로그만 남기고 삼키는 쪽이 일반적이며, “실패를 호출자에게 알려야 한다”는 요구는 소멸자가 아닌 명시적 close() 같은 멤버로 분리하는 설계가 권장됩니다.

1.4 = default / 삭제된 소멸자와 Rule of Zero

멤버가 모두 잘 정의된 소멸·이동을 가지면 Rule of Zero로 컴파일러 생성 특수 멤버에 맡길 수 있습니다. 반면 std::mutex처럼 이동 불가 멤버가 있으면 클래스 전체가 이동 불가가 되고, 컨테이너에 넣기 어려워집니다. 이때는 mutexunique_ptr<std::mutex>로 감싸거나, 별도 동기화 전용 컨텍스트 객체로 분리하는 식이 현실적인 타협입니다.


2. 예외 안전 수준: 기본·강·nothrow

예외 안전성은 “연산이 실패했을 때 프로그램 상태가 어떻게 남는가”를 말합니다. 예외 안전성 시리즈와 연결해 읽으면 좋습니다.

2.1 세 가지 수준

수준의미직관
기본 보장 (basic)예외 후에도 리소스 누수 없이 유효한 상태로 남음불변식은 깨질 수 있으나 객체는 파괴되지 않음
강한 보장 (strong)연산 실패 시 연산 전 상태로 롤백“전부 성공하거나 아무 일도 없었던 것처럼”
nothrow 보장예외를 던지지 않음스왑·소멸·이동 등 저수준 연산에 중요

2.2 기본 보장만으로 충분한 경우

많은 내부 유틸리티·버퍼 축소·일부 파싱 단계는 기본 보장만 제공합니다. 호출자가 불변식을 다시 검증하거나, 상위에서 트랜잭션을 묶을 수 있기 때문입니다.

2.3 강한 보장: 복사 후 교환(copy-and-swap)

강한 보장의 대표 패턴은 임시 객체에 새 상태를 만든 뒤 swap으로 바꿔 끼우는 것입니다. 실패는 임시 쪽에서만 일어나으며, 성공 시에만 *this와 교체됩니다.

#include <utility>
#include <vector>

class Buffer {
    std::vector<int> data_;
public:
    void assignFrom(const std::vector<int>& v) {
        Buffer tmp;
        tmp.data_ = v; // 여기서 예외 나면 *this 불변
        data_.swap(tmp.data_);
    }
};

2.4 vector::push_back와 이동 연산

std::vector가 재할당할 때 기존 요소를 이동하려면 이동 연산이 예외를 던지지 않아야 합니다. 그렇지 않으면 강한 보장을 위해 복사로 되돌아가거나, 구현이 복잡해집니다. 이것이 이동 생성자·이동 대입에 noexcept를 붙이라는 권고의 실무적 이유 중 하나입니다.

2.5 nothrow와 RAII

락 해제·delete·대부분의 fclose 류는 nothrow로 취급하고 설계하는 것이 스택 언와인딩과 맞습니다. 반면 네트워크 플러시처럼 실패를 전파해야 하는 작업은 RAII 소멸자가 아니라 명시적 멤버 함수에서 처리합니다.


3. 스코프 가드 구현

스코프 가드(scope guard)는 함수·블록을 빠져나갈 때 한 번 실행할 콜백을 등록하는 RAII입니다. 기존 패턴 글의 간단한 ScopeGuard를, 이동·무효화·취소(dismiss)까지 갖춘 형태로 다듬을 수 있습니다.

3.1 핵심 연산

  • 실행: 소멸 시 등록된 함수 호출(한 번).
  • dismiss: 성공 경로에서만 정리가 필요 없을 때 실행을 취소.
  • 이동: 소유권만 이전하고 이전 객체는 더 이상 실행하지 않음.
#include <utility>

template <typename F>
class ScopeGuard {
    F f_;
    bool active_{true};

public:
    explicit ScopeGuard(F&& f) : f_(std::forward<F>(f)) {}

    ScopeGuard(ScopeGuard&& other) noexcept
        : f_(std::move(other.f_)), active_(other.active_) {
        other.active_ = false;
    }

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

    void dismiss() noexcept { active_ = false; }

    ~ScopeGuard() noexcept(noexcept(f_())) {
        if (active_) f_();
    }
};

template <typename F>
ScopeGuard<F> make_scope_guard(F&& f) {
    return ScopeGuard<F>(std::forward<F>(f));
}

noexcept(noexcept(...))F가 던지지 않는다고 약속할 때 소멸자도 noexcept가 되게 합니다. 범용 콜백이 예외를 던질 수 있다면, 프로덕션에서는 내부에서 try/catch로 로깅 후 삼키는 래퍼를 두는 편이 안전합니다.

3.2 매크로 스타일과 주의점

과거에 널리 쓰이던 SCOPE_EXIT류 매크로는 라인 번호로 고유 이름을 만드는 방식입니다. C++11 이후에는 위와 같은 템플릿 + 람다가 가독성과 디버깅에 유리합니다. 람다는 참조 캡처 시 매개변수 수명에 특히 주의해야 합니다.

3.3 표준·라이브러리

프로젝트 정책에 따라 Microsoft GSL finally, Boost.ScopeExit 등을 쓸 수 있습니다. 표준 라이브러리에 스코프 가드가 들어오는 논의는 진행되어 왔으며, 팀 컴파일러·표준 버전에 맞춰 커스텀 구현을 한 곳에 모아두는 것이 유지보수에 유리합니다.


4. 리소스 핸들 설계 패턴

4.1 unique_ptr + 커스텀 Deleter

C API가 FILE*·HANDLE·sqlite3* 같은 불투명 핸들을 실행하여줄 때, 가장 단순한 패턴은 삭제자를 지정한 unique_ptr입니다.

#include <cstdio>
#include <memory>

struct FileDeleter {
    void operator()(FILE* f) const noexcept {
        if (f) std::fclose(f);
    }
};

using UniqueFile = std::unique_ptr<FILE, FileDeleter>;

UniqueFile open_file(const char* path, const char* mode) {
    FILE* f = std::fopen(path, mode);
    if (!f) throw std::runtime_error("open");
    return UniqueFile(f);
}

4.2 unique_resource 스타일(이름 있는 RAII)

동일한 패턴을 명시적인 클래스로 감싸면 API가 읽기 쉬워지으며, get(), release() 등의 의미를 문서화하기 좋습니다. 핵심은 이동만 허용하며, 해제 함수를 템플릿 매개변수나 함수 포인터로 고정해 오버헤드를 줄이는 것입니다.

4.3 소유권 vs 차용(borrow)

함수 인자로 핸들을 받을 때 소유권을 넘기는지, 잠시 빌리는지를 타입으로 구분하면 버그가 줄어듭니다. 소유권은 unique_ptr·이동 전용 타입, 차용은 원시 포인터에 수명 주석(스마트 포인터에서 .get()으로 넘길 때 원본 수명이 더 길다는 전제)을 명확히 합니다.

4.4 복사 가능한 핸들: shared_ptr과 비용

공유 소유가 맞을 때만 shared_ptr을 쓰며, 그렇지 않으면 복사 비용과 순환 참조를 피하기 위해 unique_ptr을 기본으로 둡니다.


5. 프로덕션 RAII 패턴

5.1 트랜잭션 가드(커밋/롤백)

DB·파일 시스템 작업에서 성공 시에만 커밋하며, 스코프 탈출 시 자동 롤백하려면 스코프 가드와 플래그를 조합합니다.

#include <functional>

struct TransactionGuard {
    bool& committed_;
    std::function<void()> rollback_;

    ~TransactionGuard() {
        if (!committed_) rollback_();
    }
};

실제 코드에서는 예외·조기 return·성공 분기를 한 경로로 모으기 위해 committed_를 명시적으로 설정합니다.

5.2 스레드 조인·취소

std::thread반드시 join 또는 detach가 필요합니다. RAII로 감싸면 잊지 않습니다.

#include <thread>

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이 블로킹이 길면 설계를 재검토합니다.

5.3 OS·COM·Win32 핸들

운영체제 핸들은 닫기 한 번이 원칙입니다. CloseHandle, RegCloseKey 등을 Deleter로 묶으며, 같은 핸들을 두 번 닫지 않도록 이동 시 null로 비웁니다.

5.4 락 범위 최소화와 계층

이미 lock_guard/unique_lock이 RAII이지만, 잠금 블록을 의도적으로 짧게 쪼개는 것이 성능·데드락 모두에 중요합니다. 읽기 많은 경로에는 shared_mutex와 RAII 읽기/쓰기 락을 검토합니다.

5.5 관측·로깅

요청 단위로 타이머·스팬(span) 을 소멸 시 로그에 남기는 패턴은 관측 가능성을 높입니다. 이때 소멸자는 nothrow를 유지하며, 로깅 실패는 버퍼에만 기록하는 식으로 처리합니다.


정리

  • 생성자·소멸자는 부분 생성·역순 파괴·noexcept 계약까지 포함해 이해해야 RAII가 깨지지 않습니다.
  • 예외 안전 수준은 API 계약에 맞게 선택하며, 강한 보장이 필요하면 copy-and-swap·nothrow 이동을 설계에 반영합니다.
  • 스코프 가드는 블록 단위 정리에 강하며, 이동·dismiss·noexcept를 명확히 하는 것이 실무 품질을 가릅니다.
  • 핸들unique_ptr+Deleter 또는 전용 타입으로 소유권을 표현하며, 차용은 수명 규칙을 문서화합니다.
  • 프로덕션에서는 트랜잭션·스레드·OS 핸들·관측까지 같은 RAII 원칙을 일관되게 적용합니다.

같이 보면 좋은 글: C++ RAII 패턴, RAII와 스마트 포인터, 예외 안전성, RAII 실무 가이드.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. RAII 내부: 생성자·소멸자 순서와 noexcept, 기본·강·nothrow 예외 안전, 스코프 가드, unique_resource형 핸들, 프로덕션 패턴까지. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, RAII, 예외안전, 스코프가드, 소멸자, 리소스관리 등으로 검색하시면 이 글이 도움이 됩니다.