모던 C++의 핵심, RAII idiom이란 무엇인가

이 글은 모던 C++ (Modern C++) - GDSC Devtalk 발표 셀프 리뷰에 이어진 글입니다.

RAII 원칙

RAII는 다음과 같이 정의할 수 있다.

Resource Acquisition Is Initialization

그러나 더 정확한 표현은 다음과 같다.

객체와 자원의 라이프 사이클을 일치시킨다

RAII란 무엇인가? RAII의 약자 Resource Acquisition Is Initialization를 직역하면 자원의 획득은 초기화라는 뜻이다. 그러나 RAII의 원칙은 더 엄밀히 따지면 자원의 initialization, 초기화보다는 destruction, 파괴에 초점을 맞추고 있다.

RAII의 다른 말은 객체와 자원의 라이프 사이클을 일치시키는 것이다. 말로만 들어서는 추상적이고 어렵다. 더 직관적으로 설명해보자.

RAII의 핵심은 프로그래머가 직접 자원을 획득하고 관리하는 것이 아니라, 자원의 생성, 파괴, 관리를 모두 객체에 위임하는 것을 의미한다.

먼저 RAII가 필요한 이유는 무엇이고, 왜 프로그래머가 직접 자원을 관리하는 것을 지양해야 할까? C++은 다른 언어에는 있는 가비지 컬렉터가 없다. 런타임 도중에 개입되는 가비지 컬렉터가 없기에 그 어떤 언어보다 빠르고 최적화된 성능을 뽑아낼 수 있지만, 가비지 컬렉터가 없기에 기본적으로 자원의 할당과 해제를 코드 상에서 구현해야 한다. 그러나 프로젝트의 규모가 커지고, 다수의 프로그래머들이 참여하게 되는 프로젝트에서, 할당된 자원의 소유권이 누구에게 있고 누가 할당 해제의 책임을 지니는지 등이 불분명했고, 종종 프로그래머가 실수로 자원 해제를 잊어버리는 경우도 있었기에, 이로인한 메모리 누수, 파괴된 객체에 대한 접근 등의 문제는 빈번하게 발생했다. 프로그래머들은 코드에 내용을 담고, 로직에 집중하는 시간보다 자원의 할당과 해제, 관리에 허비되는 시간이 더 많았기도 했다.

이를 방지하고자 등장한 게 RAII이다. 예를 들어서 다음 코드를 보자.

Widget w;
w.doSomething();

스택 위에 올라간 Widget은 스코프가 끝나는 순간 자동으로 파괴된다. 그러나 다음 예시는 힙에 할당한 경우이다.

Widget* w = new Widget();
w->doSomething();

delete w;

Widget이라는 객체가 동적으로 할당되었고, 이를 프로그래머가 스스로 할당 해제한다. 이는 전통적인 클래식 C++에서의 동적 할당 방식이고, 사용자가 직접 자원을 획득해서 관리하는 방식이다.

참고로 C++에서 스택을 사용할 수 있으면 최대한 스택을 사용하는 게 좋은데, 이에 대한 글은 Heap VS Stack 무엇을 써야 하나 고민될 때 에서 다루었다.

그러나 위에 예시는 한 블록 안에서 자원의 획득과 파괴가 이루어졌다. 그러나 다음과 같은 형태의 함수는 어떨까?

Widget* Drawer::WidgetFactory() const;

위 함수의 형태만 보고서, 반환되는 포인터, 즉 자원의 소유권이 나한테 있는 지 혹은 클래스에게 있는 지 추측할 수 있겠는가? Factory라고 이름이 붙은 함수니깐 나에게 있을 것이라 추측할 수는 있는데, 그러나 실제로 소유권을 Drawer가 갖고 있어서, Drawer 클래스가 파괴될 때 Widget * 역시 파괴되는 지, 아니면 내가 직접 delete 를 통해 파괴시켜줘야 하는 지는 함수의 형태만 보고서는 단정지을 수 없다.

또한 일반적으로 클래스 내부에서 할당된 객체 역시, 자원의 해제를 항상 destructor에서 관리해주어야 한다. 예를 들어 다음 코드를 보자.

class Widget {
public:
    Widget(const int size) {
        data = new int[size]; // acquire
    }
    ~Widget() {
        delete[] data; // release
    }

    void doSomething() {
        // ...
    }
private:
    int *data;
}

void func() {
    Widget w(1000000);  // lifetime automatically tied to enclosing scope
                        // constructs w, including the w.data member
    w.doSomething();
}

Widget 클래스 내부에서 data 변수의 할당과 해제가 이루어지고 있다. 사실 위 코드는 일반적으로 충분히 좋은 코드이다. 왜냐면 사용자가 스스로 자원을 관리하지 않고 있고, 자원의 할당과 해제, 관리를 Widget 클래스에 위임하고 있기 때문이다. 때문에 위 Widget 클래스 역시 충분히 RAII를 준수하고 있다고 해도 무방하다.

하지만 모던 C++에서는, 이를 관리할 수 있는 더 좋은 방법이 존재한다. 바로 std::unique_ptr을 이용하는 것이다.

class widget
{
public:
    widget(const int size) { data = std::make_unique<int[]>(size); }
    void doSomething() {}
private:
    std::unique_ptr<int[]> data;
    // 더 나아가서, data를 포인터가 아닌, std::vector<int> 로 관리하는 방법 등을 사용할 수 있다. 둘다 RAII에 부합한다.
};

void func() {
    Widget w(1000000);  // lifetime automatically tied to enclosing scope
                        // constructs w, including the w.data gadget member
    w.doSomething();
}

std::unique_ptr은 자원의 소유권을 객체 하나에게 위임할 때 사용한다. std::unique_ptr<T> 내부에서는 T 타입 자원의 할당과 해제를 관리한다. 내부적으로 newdelete 를 이용한다. 그러나 사용자는 객체의 생성만 할 뿐, T *라는 자원의 관리를 직접 하지는 않는다. 자원의 할당과 관리를 std::unique_ptr에게 위임한다.

마찬가지로 동적 배열 역시 마찬가지이다. T *arr = new T[size] 이런 식의 코드는 위험하다. 사용자가 직접 delete[] arr을 통해 배열을 해제해주어야 하기 때문이다. 그렇기 때문에 동적 배열의 할당과 해제를 std::vector<T>에게 위임하는 형태가 더 좋다.

Widget * widget = new Widget(); // bad
int * arr = new int[100]; // bad

std::unique_ptr<Widget> widget = std::make_unique<Widget>(); // good
std::vector<int> v(100); // good

RAII의 원칙은 사용자에게 자원의 관리 책임을 부과하지 않고, 그냥 잊어버리게 해도 된다는 원칙이다.

RAII에 따르면, 사실 거의 모든 경우에서 직접적인 new의 사용은 기피하는 게 좋다. 더 나아가서 raw pointer의 사용 역시 피할 수 있으면 피하는 게 좋다(사실 참조가 있는 C++에서 포인터를 사용할 이유가 거의 없다). 왜냐면 new의 직접적인 사용은 사용자에게 자원 관리의 책임을 부과하기 때문이다.

그리고 이제 RAII의 원칙, 즉 객체와 자원의 라이프 사이클을 일치시킨다는 말 역시 설명이 된다. 자원의 관리 자체를 객체를 통해서 위임 관리하기 때문에, 객체의 소멸이 곧 자원의 소멸을 의미하기 때문이다. 그렇기 때문에 RAII를 준수하는 모던 C++에서는 더 이상 객체와 자원의 라이프 사이클을 별도로 고민할 필요가 없다.

Fire and Forget

RAII 원칙은 기본적으로 발사 후 망각, 즉 Fire and Forget에 비유하고 싶다. 발사 후 망각이란 원래 군사 용어에서 비롯되었는데, 미사일을 발사한 후 사용자가 이를 더 이상 추적 관찰, 관리하지 않고 그 자리를 떠나도 된다는 의미이다.

전통적인 미사일은 사수가 미사일을 발사한 후, 표적에 명중하기까지 직접 유도를 해주어야 했다. 와이어 선을 연결해서 유선으로 조종을 하든, 레이저 유도를 하든 어쨌든 발사 후에도 끊임없이 미사일이라는 '자원'을 관리해주어야 하는 책임이 생겼다. 당연히 이는 사수의 위치를 노출시키고 위험에 처하게 했다.

그러나 미사일 기술이 발전하면서 사수는 표적을 향해 미사일을 발사하면 미사일이 '알아서' 표적을 향해 날아간다. 특별한 이유가 없는 한 사수는 발사 후 미사일의 유도 및 관리 책임 없이 자리를 벗어나도 된다. 이를 미사일을 발사한 후 망각해도 된다는 뜻에서 발사 후 망각, fire and forget이라 한다.

RAII 역시 자원을 사용하고서 이를 해제하는 것을 '망각'해도 된다는 점에서 발사 후 망각이라 표현하였다.

RAII의 예시

mutex

pointer 외에 RAII 원칙을 준수하는 예시를 살펴보자. 병렬 처리에서 우리가 관리하는 중요한 자원이 하나 있다. 바로 뮤텍스(mutex)이다. 뮤텍스는 두 개 이상의 스레드가 서로 공유된 자원에 접근할 때, 데이터 레이싱 문제를 해결하기 위해서 도입된 가드같은 개념이다. 만약에 a, b가 자원 R에 동시에 쓰기 접근을 한다고 치자. 그러면 a가 쓰기 작업을 끝내기 이전에 b가 도착해서 쓰기 작업을 할 수 있고, 이는 곧바로 데이터 레이싱 등의 예측할 수 없는 문제를 일으킨다. 이를 해결하기 위해서, 먼저 도착한 스레드에서 뮤텍스로 락을 건다. 이러면 b는 락이 해제되기까지 기다려야 하고, a가 안전하게 쓰기 작업을 끝낸 후에 뮤텍스를 해제하면 b가 비로소 접근할 수 있다. 당연히 b 역시 뮤텍스를 걸고 작업을 해야 하고.

이때 중요한 점이 뮤텍스도 하나의 자원이라서, 반드시 뮤텍스로 락을 걸었으면 이를 해제해주어야 한다. 안 그러면 뮤텍스로 관리되는 그 자원은 영원히 뮤텍스에 남게 되어서, 다른 스레드에서 접근이 불가능해질 수가 있다.

class Widget {
public:
    void doSomething() {
        mtx.lock();
        shared_resource->doSomething();
        mtx.unlock(); // require unlock mutex
    }

private:
    std::shared_ptr<Resource> shared_resource;
    std::mutex mtx;
}

위에 doSomething 함수에서 보이는 것처럼 사용자는 반드시 뮤텍스를 직접 unlock 해주어야 한다. 그러나 모던 C++에서는 뮤텍스를 관리하는 더 좋은 방법이 있다.

void Widget::doSomething() {
    std::lock_guard<std::mutex> lock(mtx);
    shared_resource->doSomething();
}

이렇게 std::lock_guard를 사용하게 되면, 뮤텍스는 알아서 해제가 된다. 즉 뮤텍스 역시 그 자원의 할당과 해제를 사용자가 직접 관리하지 않고, lock_guard라는 객체에 위임하는 것이다.

RAII를 준수하지 않았을 때 다음과 같은 상황이 벌어질 수도 있다.

static std::mutex mtx;

void bad() {
    m.lock();             
    f();                         // 만약 f에서 exception이 발생한다면?
    if(!everything_ok()) return; // early return
    m.unlock();
}

위 함수에서 만약 f에서 exception이 발생한다면? early return으로 인한 조기 종료 시에 실수로 mutex 해제를 까먹었다면? 뮤텍스는 영원히 해제되지 않는다.

이를 RAII에 맞추어서 작성하면 더 이상 자원의 흐름에 대해 신경쓸 필요가 사라진다.

void good() {
    std::lock_guard<std::mutex> lk(m); 
    f();                               // if f() throws an exception, the mutex is released
    if(!everything_ok()) return;       // early return, the mutex is released
}                                      // if good() returns normally, the mutex is released

jthread

jthread, 혹은 joinable thread는 C++20부터 도입되었다. 사실 최근에 막 도입된 기능이기에 아직 충분한 사용자 풀과 사용 경험이 갖추어지지 않은 기능이여서, 아직까지는 많은 곳에서 적극적으로 사용되지는 않는 듯 하다. 그러나 std::jthread 역시 RAII 원칙을 준수하는 중요한 예시이다.

스레드도 자원으로 친다면, 스레드 역시 일반적으로는 사용자가 직접 할당과 해제를 해주어야 한다. 그리고 스레드의 조인(join) 역시 잊어버리면 안 된다. 사용자가 반드시 적당한 시점에 이를 메인 스레드에 합류시킬 책임을 지니게 한다.

std::thread t1(widget0->doSomething());
std::thread t2(widget1->doSomething());

t1.join();
t2.join(); // 반드시 적당한 시점에는 작성되어야 함

그러나 jthread를 사용하게 된다면, jthread 객체가 파괴될 때 알아서 main 스레드로 합류시킨다. 즉 스레드라는 자원 역시 jthread라는 객체에 위임하는 것을 의미한다.

std::jthread t1(widget0->doSomething());
std::jthread t2(widget1->doSomething());

// t1.join(); t2.join(); -> 필요 없음, 객체가 파괴 시 알아서 join

스마트 포인터

위에서 소개한 std::unique_ptr을 포함하여 std::shared_ptr, std::weak_ptr 들을 함께 모아서 스마트 포인터라고 한다. 스마트 포인터는 raw pointer를 사용하는 것보다 자원의 관리를 더 안전하게 도와준다.

사실 스마트 포인터의 작동 원리는 객체의 소유권에 연결된다. 먼저 std::unique_ptr의 경우 내부적으로 자원을 참조하고 있고, 이 자원이 다른 unique pointer에 의해서 소유되는 것을 방지한다. 만약에 어떤 unique pointer A가 갖고 있는 자원을 다른 unique pointer B가 참조하고 싶으면 std::move()를 통해 소유권을 B에게 이전시켜야 한다. 이로써 A는 자원에 대한 소유권을 잃게 된다. 자원은 B가 파괴될 시 자동으로 해제된다.

std::unique_ptr<Widget> A = std::make_unique<Widget>();
std::unique_ptr<Widget> B = std::move(A); // A의 소유권을 B에게 이전

B->doSomething();

이렇게 설계한 이유가 뭐냐면, 기존 raw pointer 사용 시 소유권이 불분명해서, 과연 어느 포인터를 통해서 누가 자원을 해제하여야 할 지 알 수 없었고, 이로인해서 잘못된 자원의 라이프 사이클 문제가 발생하였기 때문이다. 그래서 소유권을 단 한 객체에게만 고정시켜서, 자원의 수명을 오직 소유하고 있는 객체의 수명과 일치시키는 의도이다.

한편 만약에 한 자원을 여러 객체에서 사용해야 할 때는 어떻게 해야 할 까? 이때는 공유된 자원에 접근하기 위해서 std::shared_ptr를 이용한다. std::shared_ptr은 내부적으로 자원을 참조하고 있는 다른 shared_ptr 들의 참조 개수를 카운팅해서 저장한다. 그래서 자원을 다른 shared pointer가 공유해서 소유하게 되면 이 참조 개수가 증가하고, 반대로 소유하고 있는 shared pointer 객체가 파괴될 때마다 이 개수가 감소한다. 만약에 참조 개수가 0이 된다면 자원을 해제하는 식이다.

std::shared_ptr<Widget> A = std::make_shared<Widget>();
std::shared_ptr<Widget> B = A; // A와 B가 같은 자원을 공유

A->doSomething();
B->doSomething(); // A, B 둘다 자원 사용 가능

std::cout << A.use_count() << std::endl; // 2
std::cout << B.use_count() << std::endl; // 2

하지만 불운하게도, 이러한 std::shared_ptr은 순환 참조 문제 등, 일부 상황에서는 여전히 메모리 누수를 일으킬 수 있다는 결점을 갖고 있다. 그래서 이를 해결하기 위해 도입된 포인터가 std::weak_ptr이다. weak pointer는 공유된 자원을 참조하지만 shared pointer의 참조 개수 증가시키지는 않는다.

그렇기 떄문에 순환 참조 문제를 효과적으로 해결하면서 shared pointer가 가리키는 자원을 참조할 수 있는데, 문제는 weak pointer는 참조 개수를 증가시키지는 않아서 shared pointer가 파괴되면 weak pointer는 nullptr을 가리킬 수 있게 된다. 이 상황을 방지하기 위해서 원칙적으로 std::weak_ptrstd::shared_ptr로 변환해서 생성해야 한다. 이러면 자원이 존재할 때는 해당 자원을 참조하는 shared pointer로, 자원이 이미 파괴되었다면 빈 객체를 가리키는 shared pointer로 변환된다. 변환 작업은 std::weak_ptr::lock() 함수로 이루어진다.

사용 예제를 보기 전에, weak pointer는 주로 트리와 같은 구조에서 사용된다. 트리의 구조를 생각해보자.

위와 같은 트리에서, 한 노드는 자식에 대한 노드 정보와 자신의 부모에 대한 노드 정보를 갖는다. 이때 자신의 부모 노드를 가리키기 위해서 어떤 타입을 작성해야 할까?

class Node {
private:
    std::vector<std::shared_ptr<Node>> children;
    Node * parent;
public:
    // ...
};

위와 같이 정의할 경우, 부모 노드에 대한 자원 관리가 어려워진다. 특히 여러 자식이 한 부모를 갖는 구조가 트리 구조이기 때문에, 실수로 어느 한 자식이 부모 노드를 파괴하였을 때 다른 자식 노드에서 부모 노드를 참조하려고 한다면 크래시가 발생할 것이다.

그러나 부모 노드를 std::shared_ptr로 저장하는 것은 순환 참조 문제를 일으킨다. 이 문제를 std::weak_ptr을 통해서 해결할 수 있다.

class Node {
private:
    std::vector<std::shared_ptr<Node>> children;
    std::weak_ptr<Node> parent;
public:
    void doSomething();
    void setParent(const std::weak_ptr<Node> &parent) {
        this->parent = parent;
    }
    void accessParent() {
        std::shared_ptr<Node> p = this->parent.lock();
        if (!p) {
            // 부모 노드가 파괴되었을 때
            return;
        }
        p->doSomething();
    }
};

accessParent 함수를 보면 std::weak_ptrstd::shared_ptr로 캐스팅해서 사용하고 있음을 알 수 있다.

참고로, std::unique_ptrstd::shared_ptr로 캐스팅해서 사용하는 것은 가능하지만, 그 반대는 안 된다는 것도 기억하자. 그 이유는 여기에 나와있다.

file stream

원래 C 언어에서는 파일 스트림(file stream)을 열었으면 직접 닫아야 했다. 그러나 이 파일 스트림의 관리 역시 std::fstream이라는 객체에 위임하고 있으므로, 파일 스트림을 닫아야 하는 의무에서 해방된다. std::fstream이 파괴될 때 자동으로 파일 스트림도 닫아주기 때문이다.

void WriteToFile(const std::string& message) {
  static std::mutex mutex;

  // Lock |mutex| before accessing |file|.
  std::lock_guard<std::mutex> lock(mutex);

  // Try to open file.
  std::ofstream file("example.txt");
  if (!file.is_open()) {
    throw std::runtime_error("unable to open file");
  }

  // Write |message| to |file|.
  file << message << std::endl;
}

위에 예시에서 mutex로 파일 쓰기 작업을 보호해주고 있고, std::ofstream 객체를 통해 파일 출력 스트림을 관리하고 있다. 그리고 파일 출력 스트림이라는 자원은 std::ofstream이 파괴될 때 자동으로 닫힐 것이다. 뮤텍스 역시 마찬가지이다.

exception과 RAII

C++에서 exception handling 을 이용하고자 한다면, RAII idiom을 사용하는 것이 강력하게 권장된다. 이에 대한 글은 C++ 예외 처리(Error Handling) 가이드 + exception safety rules 이 글에서 다룬 바 있다.

기본적으로 RAII를 사용하게 되면 exception이 언제 어디에서 발생하더라도 자원의 해제는 보장된다. 당연한게 exception이 던져져도 스택 언와인딩을 통한 스택 위에서의 객체의 소멸은 반드시 보장되기 때문이다. 그렇기 때문에 exception을 대비한 코드를 별도로 작성할 필요도 없고, 적어도 자동으로 exception safety rule - Basic Guarantee를 만족하게 된다.

RAII idiom의 필요성

RAII가 좋다는 것은 알았다. 그런데 왜 RAII을 사용해야 할까? 프로그래머가 그냥 직접 자원을 실수하지 않고 '잘' 관리하면 안 되는 걸까?

물론 내가 실수를 하지 않는 완벽하고 이상적인 프로그래머라면, RAII의 혜택이 그리 와닿지 않을 수 있다. 그럼에도, RAII가 도입된 배경은 협업과 생산성의 측면에 있다.

엔지니어들의 실력이나 부주의함과는 관계없이, 프로젝트의 규모가 커지고 참여하는 사람들의 수가 늘어날 수록, 자원을 직접 관리하였을 때의 복잡성은 크게 증가한다. 이는 구글의 코딩 스타일 가이드에서도 잘 드러나는데, 구글에서도 마찬가지로 raw pointer의 사용보다는 스마트 포인터를 사용할 것을 권하고 있다. Raw pointer는 자원의 소유권에 대한 정보를 명시적으로 드러내지 않는다. 그렇기 때문에 엔지니어는 함수나 자원의 타입만 보고서 바로 이 자원의 소유권이 누구에게 있는지, 그리고 내가 직접 할당 해제를 해야 하는지 혹은 다른 객체에서 이 자원을 사용하는지, 자원의 최종 사용자는 누구인지를 알 기 어렵다.

이는 소프트웨어 명세 API를 아무리 잘 작성한다고 하더라도 똑같이 발생하는 문제이다. 하이럼의 법칙에 따라, 소프트웨어의 복잡성은 명세화와 관계없이 증가하기 때문이다. 이는 엔지니어의 실력과 주의력과는 전혀 별개의 문제점이다.

그리고 규모가 커지고 복잡성이 증가하기 시작하면, 결국 어느 시점에서부터는 개개인의 능력이 아닌 전체 시스템과 프로세스의 힘이 더 커지게 된다. 전체 시스템과 프로세스는 개개인의 능력과 방식에 맞추는 게 아니라, 모두가 혜택을 누리는 방향으로 진화해야 한다. RAII가 도입된 배경도, 일부만 능숙하게 해내는 자원 관리에 대해서, 엔지니어들 모두가 실수를 줄일 수 있는 방향으로 전환하기 위함이다.

모두의 실수를 줄이는 것은 매우 중요하다. 이는 곧 생산성과 효율성으로 집결되기 때문이다. 내가 아무리 자원의 할당과 해제를 잘 하고, 스레드를 적절한 시점에 합류시키고, 완벽한 소프트웨어에서의 흐름과 생명 주기를 만들어내도 어느 누군가가 균형을 깨는 순간, 객체의 생명 주기와 자원의 순환과 흐름이 모두 무너질 수 있다. 결국 어떻게 메모리 관리를 자동화할 수 있을까, 어떻게 하면 성능을 적절히 잃지 않으면서도 안전하게 자원을 유지시키거나 파괴를 위임할 수 있는가 등을 고민해야 한다. 그리고 RAII의 도움 없이도 이를 잘 하는 엔지니어가 있더라도, 반대로 말하면 그만큼 코드에 '내용'을 담는 시간보다 '형식'에 집중하는 시간이 더 많다는 것이다. 이것이 곧 생산성과 효율성의 저하로 이어진다.

RAII가 주는 장점이 비용보다 크다는 것은 단순히 말 뿐만이 아닌 소프트웨어 공학적으로 증명되어 있다(Guillaume Combette, 2018). RAII idiom을 통해서 생산성과 안전성을 높이는데 드는 이득이 더 크다는 것을 보여준다.

참고 자료