본문 바로가기
개발/C++ (98,03,11,14,17,20,23)

Modern C++ : std::shared_ptr (11, 17)

by snowoods 2025. 10. 14.

Modern C++

 

std::shared_ptr

 

개요

std::shared_ptr는 하나의 리소스를 여러 포인터가 공유해서 사용할 수 있게 하는 스마트 포인터입니다. 참조 카운팅(Reference Counting) 방식을 사용하여, 자신을 참조하는 shared_ptr가 몇 개인지 계산합니다. 참조 카운트가 0이 되면 자동으로 메모리를 해제합니다.

이를 통해 여러 객체가 동일한 메모리 리소스에 안전하게 접근하고 소유권을 공유할 수 있으며, 리소스의 생명 주기를 자동으로 관리하여 메모리 누수를 방지합니다.

 

C++ 버전별 주요 키워드 도입 시기

  • C++11: std::shared_ptr가 표준 라이브러리에 처음 도입되었습니다.
  • C++11: std::make_shared가 도입되어 더 안전하고 효율적인 shared_ptr 생성을 지원합니다.
  • C++17: std::shared_ptr가 배열을 지원하게 되었습니다. std::shared_ptr<T[]>와 같이 사용할 수 있습니다.

 

내용 설명

std::shared_ptr는 내부적으로 두 개의 포인터를 유지합니다.

  1. 관리 대상이 되는 리소스(객체)를 가리키는 포인터
  2. 참조 카운트, 약한 참조(weak count), 커스텀 삭제자(deleter) 등을 포함하는 제어 블록(Control Block)을 가리키는 포인터

shared_ptr가 복사될 때마다 참조 카운트가 1씩 증가하고, shared_ptr가 소멸될 때마다 참조 카운트가 1씩 감소합니다. 참조 카운트가 0이 되면 shared_ptr는 제어 블록과 함께 관리하던 리소스를 해제합니다.

 

순환 참조 (Circular Reference) 문제

shared_ptr의 가장 큰 단점은 두 개 이상의 객체가 서로를 shared_ptr로 참조하는 순환 참조 구조가 만들어질 경우, 참조 카운트가 0이 되지 않아 메모리 누수가 발생할 수 있다는 점입니다. 아래 예제 코드의 f2 함수가 이 문제를 보여줍니다.

이 문제를 해결하기 위해 std::weak_ptr를 사용할 수 있습니다. weak_ptr는 리소스를 소유하지 않으므로 참조 카운트를 증가시키지 않고, 리소스에 대한 접근만 제공합니다. 순환 참조가 발생하는 경우, 한쪽의 참조를 weak_ptr로 바꾸어 순환 고리를 끊을 수 있습니다.

 

예제 코드

#include <iostream>
#include <memory>

class ScopeTest
{
public:
    ScopeTest(int val) : m_val(val)
    {
        std::cout << "Constructor: " << m_val << '\n';
    }

    ~ScopeTest()
    {
        std::cout << "Destructor! val: " << m_val << '\n';
    }

    void test()
    {
        std::cout << m_val << '\n';
    }

    std::shared_ptr<ScopeTest> m_partner;

private:
    int m_val;
};

// 기본 사용법 예제
void f1()
{
    std::cout << "--- f1 execution ---" << std::endl;
    auto t = std::make_shared<ScopeTest>(10);
    t->test();

    std::cout << "Count: " << t.use_count() << '\n'; // Count: 1

    {
        auto t2 = t; // 참조 카운트 증가
        t2->test();

        std::cout << "Count: " << t.use_count() << '\n'; // Count: 2
    }

    std::cout << "Count: " << t.use_count() << '\n'; // Count: 1
} // t가 소멸되며 참조 카운트 0, 소멸자 호출

// 순환 참조 문제 예제
void f2()
{
    std::cout << "--- f2 execution (Circular Reference) ---" << std::endl;
    auto t4 = std::make_shared<ScopeTest>(40);
    std::cout << "Count t4: " << t4.use_count() << '\n'; // Count t4: 1
    auto t5 = std::make_shared<ScopeTest>(50);
    std::cout << "Count t5: " << t5.use_count() << '\n'; // Count t5: 1

    t4->m_partner = t5; // t5의 참조 카운트 증가
    std::cout << "Count t5: " << t5.use_count() << '\n'; // Count t5: 2
    t5->m_partner = t4; // t4의 참조 카운트 증가
    std::cout << "Count t4: " << t4.use_count() << '\n'; // Count t4: 2

} // t4, t5가 소멸되지만, 서로를 가리키고 있어 참조 카운트가 1로 남아 소멸자가 호출되지 않음 -> weak_ptr

int main()
{
    f1();
    std::cout << '\n';
    f2();

    std::cout << "\nmain function finished." << std::endl;
    return 0;
}

 

실행 결과

--- f1 execution ---
Constructor: 10
10
Count: 1
10
Count: 2
Count: 1
Destructor! val: 10

--- f2 execution (Circular Reference) ---
Constructor: 40
Count t4: 1
Constructor: 50
Count t5: 1
Count t5: 2
Count t4: 2

main function finished.

f1에서 생성된 객체(값 10)는 함수가 끝나면서 정상적으로 소멸자가 호출되었습니다. 하지만 f2에서 생성된 두 객체(값 40, 50)는 main 함수가 끝날 때까지 소멸자가 호출되지 않았습니다. 이것이 바로 순환 참조로 인한 메모리 누수입니다.

 

활용팁

  • std::make_shared 사용: new 키워드를 직접 사용하는 것보다 std::make_shared를 사용하세요. 객체와 제어 블록을 한 번의 할당으로 생성하여 더 효율적이고, 예외 발생 시에도 안전합니다.
  • 순환 참조 주의: 클래스 설계 시 객체들이 서로를 shared_ptr로 가리키는 구조가 될 수 있는지 항상 확인해야 합니다. 만약 그렇다면, 소유 관계가 없는 쪽(자식->부모 등)은 std::weak_ptr를 사용하여 순환 참조를 방지해야 합니다.
  • this 포인터: 클래스 내부에서 자기 자신을 가리키는 shared_ptr가 필요할 때는 std::enable_shared_from_this를 상속받고 shared_from_this() 멤버 함수를 사용해야 합니다. 그냥 std::shared_ptr<T>(this)를 사용하면 제어 블록이 새로 생성되어 이중 해제(double free) 문제가 발생할 수 있습니다.