'C++' RAII & Smart Pointers

토스트·2025년 9월 24일
0

'C++' basic

목록 보기
35/35

RAII (Resource Acquisition Is Initialization)

자원 확보는 초기화다라는 뜻으로,

C++에서 주로 사용되는 프로그래밍 기법으로, 자원의 생명주기를 객체의 생명주기와 일치시켜 자원 관리를 자동화하는 방식입니다.

RAII의 핵심 원리

  • 자원 획득(Acquisition): 클래스의 생성자에서 파일 핸들, 메모리, 뮤텍스(mutex) 등과 같은 자원을 획득(할당)합니다.
  • 자원 해제(Release): 클래스의 소멸자에서 획득했던 자원을 해제(반납)합니다.

이렇게 하면 객체가 생성될 때 자동으로 자원을 얻고, 객체가 소멸될 때 (범위를 벗어나거나 delete 될 때) 자동으로 자원을 반환하게 됩니다.

RAII와 Scope

RAII 기법을 사용하는 객체는 주로 스택(stack)에 할당됩니다. 스택에 할당된 변수나 객체는 해당 변수가 선언된 범위(scope)를 벗어나면 자동으로 소멸됩니다.

이것이 중요한 이유는 다음과 같습니다.

  • 자원 누수 방지: 함수가 정상적으로 끝나는 경우뿐만 아니라, 함수 중간에 return 문을 만나거나 예외(exception)가 발생하여 함수가 비정상적으로 종료되는 경우에도 스택에 있는 객체는 소멸됩니다. 이 과정에서 객체의 소멸자가 자동으로 호출되어 자원을 안전하게 해제할 수 있습니다.

  • 간결한 코드: 개발자가 일일이 close(), free()와 같은 자원 해제 코드를 작성할 필요가 없습니다. 자원 관리를 컴파일러와 런타임 환경에 맡기게 되어 코드가 훨씬 간결하고 오류가 줄어듭니다.

어디에서 사용할까?

  • 파일 핸들(File Handles): 파일을 열고(획득), 작업 후 닫는(해제) 과정.
  • 네트워크 소켓(Network Sockets): 소켓 연결을 열고, 통신이 끝난 후 닫는 과정.
  • 뮤텍스 및 락(Mutexes and Locks): 멀티스레드 환경에서 공유 자원에 접근하기 위해 락을 걸고(획득), 작업 후 락을 푸는(해제) 과정.
  • 데이터베이스 연결(Database Connections): 데이터베이스에 연결하고, 작업 후 연결을 끊는 과정.
  • 그래픽 자원(Graphics Resources): GPU 메모리, 텍스처, 셰이더 등.
  • 자료 구조(e.g., std::vector, std::map): 집단적인 데이터의 메모리를 관리합니다. 이들은 내부적으로 RAII를 사용하여 여러 요소를 저장할 공간을 할당하고 해제합니다. 따라서 개발자가 push_back()이나 insert()를 사용할 때 메모리 할당/해제에 대해 걱정할 필요가 없습니다.
  • 스마트 포인터(e.g., std::unique_ptr, std::shared_ptr): 단일 객체의 수명을 관리합니다. 이들은 자료 구조 안에 들어가는 개별적인 객체나, 또는 다른 함수나 객체와 소유권을 공유해야 하는 독립적인 객체에 대한 메모리를 관리하는 데 사용됩니다. 즉, 포인터가 가리키는 대상이 더 이상 필요 없을 때 메모리를 해제하는 역할을 합니다.

RAII의 단점은 없을까?

RAII 객체의 생성과 소멸에 미세한 오버헤드가 발생할 수 있지만, 이는 대부분의 경우 무시할 수 있는 수준입니다.


<memory>

Smart Pointer

일반 포인터는 메모리 주소만 가리킬 뿐 해당 메모리에 대한 책임이 없지만, 스마트 포인터는 가리키는 메모리(자원)에 대한 소유권(Ownership)을 가지고 그 수명을 관리합니다.

이때, 소유권의 역할은 new를 통해 메모리를 할당한 사람이 반드시 delete를 통해 해제해야 하는 책임을 대신 가져와, 자신이 소멸될 때 소유하고 있는 자원을 자동으로 해제합니다.

1. std::unique_ptr

단독 소유권을 가지는 스마트 포인터입니다. 하나의 객체는 오직 하나의 unique_ptr만이 소유할 수 있으며, 소유권 이전은 가능하지만 복사는 허용되지 않습니다. 소유권이 이전되면 원래 포인터는 null이 됩니다.

  • 성능 오버헤드가 거의 없어 일반 포인터와 유사한 속도를 가집니다.

std::unique_ptr를 생성할 때 new 키워드를 직접 사용하기보다는 std::make_unique 함수를 사용하는 것이 C++14 이후의 표준이자 권장되는 방식입니다.

<예시>

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
    std::cout << *ptr1 << std::endl;

    std::unique_ptr<int> ptr2 = std::move(ptr1);
    if (ptr1 == nullptr) std::cout << "ptr1 is now null" << std::endl;
    
    std::cout << *ptr2 << std::endl;
    
    return 0;
}

<결과>

2. std::shared_ptr

공동 소유권을 가지는 스마트 포인터입니다. 여러 shared_ptr가 하나의 객체를 공유할 수 있으며, 객체에 대한 참조 횟수를 추적하는 참조 카운터를 사용합니다. 마지막 shared_ptr가 소멸될 때 객체가 해제됩니다.

  • 참조 카운터로 인해 unique_ptr보다 약간의 성능 오버헤드가 발생합니다.
  • 순환 참조(circular reference) 문제가 발생할 수 있으며, 이로 인해 메모리 누수가 일어날 수 있습니다.

std::shared_ptr를 생성할 때 new 키워드를 직접 사용하기보다는 std::make_shared 함수를 사용하는 것이 C++14 이후의 표준이자 권장되는 방식입니다.

<예시>

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> ptr1 = std::make_shared<int>(20);
    std::cout << "Reference count: " << ptr1.use_count() << std::endl;

    std::shared_ptr<int> ptr2 = ptr1;
    std::cout << "Reference count: " << ptr1.use_count() << std::endl;

    ptr2 = nullptr;
    std::cout << "Reference count: " << ptr1.use_count() << std::endl;

    ptr1 = nullptr;
    std::cout << "Reference count: " << ptr1.use_count() << std::endl;
    
    return 0;
}

use_count() : 참조 카운트를 리턴하는 함수

<결과>

순환 참조(Circular reference)

두 개 이상의 객체가 서로를 소유하거나 참조하면서 발생하는 상황을 말합니다. 이로 인해 한 객체의 소멸자가 다른 객체에 의해 호출되지 못하고, 결과적으로 메모리 누수(Memory Leak)가 발생할 수 있습니다.

<예시> | 순환 참조(Circular reference)

#include <iostream>
#include <memory>

class Child;

class Parent {
public:
    std::shared_ptr<Child> child_ptr;
    ~Parent() {
        std::cout << "Parent 소멸자 호출" << std::endl;
    }
};

class Child {
public:
    std::shared_ptr<Parent> parent_ptr;
    ~Child() {
        std::cout << "Child 소멸자 호출" << std::endl;
    }
};

void create_objects() {
    std::shared_ptr<Parent> parent = std::make_shared<Parent>();
    std::shared_ptr<Child> child = std::make_shared<Child>();

    parent->child_ptr = child;
    child->parent_ptr = parent;
}

int main() {
    create_objects(); 
    // RAII에 의해 함수가 종료되는 순간 함수 내부에서 생성된 객체는 소멸해야 합니다.
    
    std::cout << "소멸자 호출 확인" << std::endl;
    
    return 0;
}

순환 참조로 인해 소멸자가 호출되지 않는 것을 확인할 수 있습니다.

3. std::weak_ptr

std::shared_ptr가 가지는 순환 참조 문제를 해결하기 위해 사용됩니다. 약한 참조를 제공하며, 객체를 소유하지 않고 관찰만 합니다.

  • std::shared_ptr와 함께 사용됩니다.
  • lock() 함수를 통해 std::shared_ptr로 변환해야만 객체에 접근할 수 있습니다.

lock() : weak_ptr가 가리키는 객체가 유효한지 확인하고, 유효하다면 그 객체를 가리키는 std::shared_ptr를 반환합니다. 만약 객체가 이미 소멸되었다면 nullptr를 반환합니다.

std::weak_ptr는 std::shared_ptr를 인자로 받아 생성되기 때문에 std::make_shared와 같은 헬퍼 함수가 필요하지 않습니다.

<예시>

#include <iostream>
#include <memory>

class Parent;

class Child {
public:
    std::shared_ptr<Parent> parent_ptr;
    ~Child() {
        std::cout << "Child 소멸자 호출" << std::endl;
    }
};

class Parent {
public:
    std::weak_ptr<Child> child_ptr; // weak_ptr로 변경
    ~Parent() {
        std::cout << "Parent 소멸자 호출" << std::endl;
    }
};

void create_objects() {
    std::shared_ptr<Parent> parent = std::make_shared<Parent>();
    std::shared_ptr<Child> child = std::make_shared<Child>();

    parent->child_ptr = child; // weak_ptr에 할당
    child->parent_ptr = parent;
}

int main() {
    create_objects();
    
    std::cout << "소멸자 호출 확인" << std::endl;
    
    return 0;
}

<결과>

0개의 댓글