본문 바로가기

스마트 포인터

unique_ptr·shared_ptr·weak_ptr의 소유권과 해제 시점을 비교하고 RAII로 누수와 순환 참조를 예방합니다.

동적 메모리 할당에서 new로 만든 객체를 원시 포인터로 들고 있으면, 조기 반환이나 예외 때문에 delete를 빠뜨리거나 두 번 하기 쉽다는 것을 보았습니다. 스마트 포인터(smart pointer)는 포인터처럼 쓰면서 객체의 소유를 맡고, 소유자가 없어지면 객체를 자동으로 정리하는 RAII 객체입니다.

이 글은 <memory>에 있는 세 가지 스마트 포인터를 다룹니다. 혼자 소유하는 std::unique_ptr, 여럿이 함께 소유하는 std::shared_ptr, 소유하지 않고 지켜보기만 하는 std::weak_ptr의 차이와, 공유 소유에서 생기는 순환 참조를 정리합니다.

C++ · OWNERSHIP CHOICE

동적 수명과 소유권 책임을 기준으로 포인터를 고른다

값·컨테이너를 먼저 고려한다. 동적 객체에 단일 소유자가 있으면 unique_ptr, 실제 공유 수명이 필요하면 shared_ptr, 소유하지 않는 관찰에는 weak_ptr 또는 유효 범위가 명확한 참조를 사용한다.

소유·동적 수명·공유 수명에 따른 분기객체를소유하지않으면weak_ptr또는유효범위참조.소유하지만동적수명불필요면값컨테이너.동적단일소유unique_ptr,실제공유수명shared_ptr. 객체를소유하는가?아니요예동적 소유가필요한가?아니요예공유 수명이필요한가?아니요예weak_ptr / 참조비소유 관찰값 / 컨테이너가장 단순한 소유unique_ptr단일 소유shared_ptr공유 수명
소유·동적 수명·공유 수명에 따른 분기객체를소유하지않으면weak_ptr또는유효범위참조.소유하지만동적수명불필요면값컨테이너.동적단일소유unique_ptr,실제공유수명shared_ptr.객체를소유하는가?예아니요동적 소유가필요한가?예아니요공유 수명이필요한가?예아니요weak_ptr / 참조비소유 관찰값 / 컨테이너가장 단순한 소유unique_ptr단일 소유shared_ptr공유 수명

보조 설명 · 코드와 수명 조건

  1. 값을 우선

    동적 수명이 필요하지 않으면 값과 표준 컨테이너가 가장 안전합니다.

  2. 단일 소유

    unique_ptr는 복사할 수 없고 이동으로 소유권을 넘깁니다.

  3. 공유 소유

    shared_ptr는 복사·이동 가능하며 control block의 강한 참조 수를 공유합니다.

  4. 비소유 관찰

    shared_ptr가 관리하는 객체의 비소유 관찰에는 weak_ptr를 씁니다. 참조·원시 포인터는 대상의 유효 범위를 따로 보장합니다.

get()은 소유권을 넘기지 않고, release()는 unique_ptr의 deleter 책임을 호출자에게 함께 넘긴다.

위 그림처럼 스마트 포인터를 고르기 전에, 객체를 동적으로 만들어야 하는지부터 확인합니다. 지역 변수나 std::vector 같은 컨테이너로 충분하다면 그 편이 가장 단순합니다. 동적 객체가 필요할 때 소유자가 하나면 unique_ptr, 정말로 여러 곳이 수명을 함께 정해야 할 때만 shared_ptr을 쓰고, 소유하지 않고 보기만 하는 곳에는 weak_ptr이나 참조를 씁니다.

std::unique_ptr

std::unique_ptr는 객체를 혼자 소유합니다. 복사할 수 없고 이동만 할 수 있어 소유자가 언제나 하나이며, 소유자가 사라지거나 reset()하면 객체를 정리합니다. 만들 때는 C++14의 std::make_unique를 씁니다.

unique_ptr_demo.cpp
#include <iostream>
#include <memory>
#include <utility>

struct Resource {
    int id;
    explicit Resource(int i) : id(i) { std::cout << "acquire " << id << '\n'; }
    ~Resource() { std::cout << "release " << id << '\n'; }
};

int main() {
    auto a = std::make_unique<Resource>(1);
    // auto copy = a; // 컴파일 오류: unique_ptr는 복사할 수 없음
    auto b = std::move(a);
    std::cout << std::boolalpha << (a == nullptr) << ' ' << b->id << '\n';
    b.reset();
    std::cout << "after reset\n";
}
acquire 1
true 1
release 1
after reset

std::move(a)로 소유를 b에 옮기면 a는 비어 nullptr과 같아지고, b가 Resource를 소유합니다. b.reset()은 소유한 객체를 정리하므로 release 1이 after reset보다 먼저 출력됩니다. unique_ptr는 대개 원시 포인터 하나만큼의 크기이고 따로 세는 값이 없어, 원시 포인터를 직접 관리하는 것과 비용이 거의 같습니다.

함수가 동적 객체를 만들어 돌려줄 때는 std::unique_ptr<Resource> make()처럼 unique_ptr로 돌려주어 소유가 호출한 쪽으로 넘어간다는 것을 드러냅니다. 배열은 std::make_unique<int[]>(n)으로 만듭니다. 메모리가 아닌 자원은 포인터를 받아 자원을 정리하는 함수나 함수 객체인 삭제자(deleter)의 타입을 두 번째 템플릿 인수로 적고 그 삭제자를 생성자의 두 번째 인자로 함께 넘겨, delete 대신 삭제자로 정리하게 할 수 있습니다.

std::shared_ptr

std::shared_ptr는 여러 포인터가 같은 객체를 함께 소유하게 합니다. 복사할 때마다 소유자의 수인 참조 횟수(reference count)가 늘고, 소유자가 사라질 때마다 줄며, 0이 되면 객체를 정리합니다. 만들 때는 std::make_shared를 씁니다.

shared_ptr_demo.cpp
#include <iostream>
#include <memory>

struct Resource {
    int id;
    explicit Resource(int i) : id(i) { std::cout << "acquire " << id << '\n'; }
    ~Resource() { std::cout << "release " << id << '\n'; }
};

int main() {
    auto first = std::make_shared<Resource>(2);
    {
        auto second = first;
        std::cout << first.use_count() << '\n';
    }
    std::cout << first.use_count() << '\n';
    first.reset();
    std::cout << "end\n";
}
acquire 2
2
1
release 2
end

second가 first를 복사하는 동안 참조 횟수는 2이고, 블록이 끝나 second가 사라지면 1로 줄어듭니다. first.reset()으로 마지막 소유자가 사라지면 참조 횟수가 0이 되어 객체가 정리됩니다. use_count()는 이렇게 흐름을 확인할 때만 쓰고, 여러 스레드가 함께 쓰는 프로그램에서는 읽는 사이에도 값이 바뀔 수 있으므로 판단에 쓰지 않습니다.

shared_ptr는 참조 횟수를 관리하는 별도의 공간을 쓰고, 복사할 때마다 그 수를 안전하게 바꾸는 비용이 듭니다. std::make_shared는 객체와 이 공간을 한 번에 할당해 할당 횟수를 줄입니다. 같은 원시 포인터로 shared_ptr를 두 번 만들면 관리 공간이 둘이 되어 각자 객체를 정리하려 하므로 이중 해제가 일어나며, make_shared를 쓰면 이런 실수를 피할 수 있습니다.

더 깊이: 제어 블록과 make_shared의 대가

shared_ptr가 쓰는 관리 공간을 제어 블록(control block)이라고 합니다. 제어 블록에는 관리하는 객체를 가리키는 포인터나 객체 자체, 타입을 감춘 삭제자와 할당자, 그리고 객체를 소유하는 shared_ptr의 수와 객체를 가리키는 weak_ptr의 수가 들어 있습니다. 객체는 앞의 수가 0이 될 때 정리되지만, 제어 블록은 weak_ptr의 수까지 0이 되어야 해제됩니다.

std::make_shared가 객체와 제어 블록을 한 번에 할당하면 이 차이가 메모리 사용에 드러납니다. 둘이 한 덩어리이므로, 마지막 shared_ptr가 사라져 객체의 소멸자가 실행된 뒤에도 weak_ptr가 하나라도 남아 있으면 객체가 차지하던 메모리까지 해제되지 않습니다. 그래서 큰 객체를 오래 사는 weak_ptr로 지켜본다면 std::shared_ptr<T>(new T(...))처럼 따로 할당하는 편이 나을 수 있으며, 사용자 정의 삭제자가 필요할 때도 make_shared는 쓸 수 없습니다.

참조 횟수는 여러 스레드가 서로 다른 shared_ptr 객체를 동시에 복사하고 없애도 안전하도록 관리됩니다. cppreference의 std::shared_ptr 설명에 따르면 흔한 구현은 횟수를 원자적 덧셈으로 늘리므로, 복사할 때마다 그만큼의 비용이 듭니다. 다만 같은 shared_ptr 객체 하나를 여러 스레드가 동기화 없이 바꾸는 것은 데이터 경쟁이어서 이때는 C++20의 std::atomic<std::shared_ptr<T>>를 쓰며, 가리키는 객체 자체를 함께 쓰는 일은 따로 보호해야 합니다.

std::weak_ptr와 순환 참조

두 객체가 서로를 shared_ptr로 가리키면, 바깥의 소유자가 모두 사라져도 서로가 서로의 참조 횟수를 1로 붙잡아 둘 다 정리되지 않습니다. 이를 순환 참조(circular reference)라고 합니다. std::weak_ptr는 shared_ptr가 관리하는 객체를 가리키지만 참조 횟수를 늘리지 않으므로, 한쪽을 weak_ptr로 바꾸면 순환이 끊어집니다.

C++ · OWNERSHIP CYCLE

역방향 weak 관계로 강한 소유권 순환을 끊는다

약한 관계는 강한 참조 수를 늘리지 않는다. 문제 그림은 외부 owner 해제 후, 해결 그림은 본문의 a·b가 살아 있는 시점이다.

같은 시점 안의 강한 소유권과 약한 관찰문제는 외부 소유자 해제 뒤 A와B의 강한 수가 각각1남는다. 해결은 본문a,b가 살아 있는 동안 A1,B2이고 B에서A로 약한 관찰이다. 두 비교는 서로 다른 시점이며 본문 종료순서를caption으로 보존한다.문제 · 외부 a·b를 해제한 뒤shared · 소유shared · 소유Ashared_ptr<B>강한 참조 수 1Bshared_ptr<A>강한 참조 수 1해결 · 본문 a·b가 살아 있는 동안shared · 소유weak · 비소유Ashared_ptr<B>강한 참조 수 1Bweak_ptr<A>강한 참조 수 2
같은 시점 안의 강한 소유권과 약한 관찰문제는 외부 소유자 해제 뒤 A와B의 강한 수가 각각1남는다. 해결은 본문a,b가 살아 있는 동안 A1,B2이고 B에서A로 약한 관찰이다. 두 비교는 서로 다른 시점이며 본문 종료순서를caption으로 보존한다.문제 · 외부 owner 해제소유sharedA강한 참조 수 1B강한 참조 수 1해결 · 본문 a·b 생존소유weakA강한 참조 수 1B강한 참조 수 2

보조 설명 · 코드와 수명 조건

강한 순환 · 외부 owner 해제 뒤

A는 B를, B는 A를 shared_ptr로 소유한다. A·B의 강한 참조 수가 각각 1로 남아 소멸되지 않는다.

weak 역방향 · 본문 scope 안

A의 shared_ptr<B>는 B를 소유하고 B의 weak_ptr<A>는 A를 관찰한다. 지역 a, b가 살아 있을 때 강한 참조 수는 A=1, B=2이다.

본문 scope 종료 · A 다음 B

지역 b 해제: B 2→1. 지역 a 해제: A 1→0, A 소멸자 실행. A의 b_ptr 해제: B 1→0, B 소멸자 실행.

본문 scope 종료 순서는 지역 b → 지역 a → A 소멸자 → A의 b_ptr → B 소멸자이다. 접근 전 weak_ptr::lock() 결과가 비었는지 확인한다.

weak_ptr_cycle.cpp
#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> b;
    ~A() { std::cout << "A destroyed\n"; }
};

class B {
public:
    std::weak_ptr<A> a;
    ~B() { std::cout << "B destroyed\n"; }

    void checkA() const {
        if (auto sharedA = a.lock()) {
            std::cout << "A is alive\n";
        } else {
            std::cout << "A is gone\n";
        }
    }
};

int main() {
    {
        auto a = std::make_shared<A>();
        auto b = std::make_shared<B>();
        a->b = b;
        b->a = a;
        std::cout << a.use_count() << ' ' << b.use_count() << '\n';
        b->checkA();
    }
    std::cout << "end of main\n";
}
1 2
A is alive
A destroyed
B destroyed
end of main

그림의 오른쪽처럼 B가 A를 weak_ptr로 가리키므로 A의 참조 횟수는 1, B는 지역 b와 A의 멤버가 소유해 2입니다. 블록이 끝나면 지역 b가 먼저 사라져 B의 횟수가 1이 되고, 이어서 지역 a가 사라져 A의 횟수가 0이 되어 A가 정리되며, A의 멤버 b가 사라지면서 B도 0이 되어 정리됩니다. B의 멤버를 shared_ptr<A>로 두었다면 그림의 왼쪽처럼 두 객체가 서로를 1로 붙잡아 소멸자 메시지가 하나도 출력되지 않았을 것입니다.

weak_ptr로는 객체에 바로 접근할 수 없고, lock()으로 shared_ptr를 얻어야 합니다. 객체가 이미 정리되었다면 lock()은 빈 shared_ptr를 돌려주므로, 그 결과를 확인하고 결과가 살아 있는 동안만 객체를 씁니다.

weak_lock.cpp
#include <iostream>
#include <memory>

int main() {
    auto owner = std::make_shared<int>(7);
    std::weak_ptr<int> observer = owner;
    auto held = observer.lock();
    std::cout << held.use_count() << '\n';

    owner.reset();
    std::cout << std::boolalpha << observer.expired() << ' ' << *held << '\n';

    held.reset();
    std::cout << observer.expired() << ' ' << static_cast<bool>(observer.lock()) << '\n';
}
2
false 7
true false

observer는 참조 횟수를 늘리지 않지만, lock()이 돌려준 held는 소유자이므로 횟수가 2가 됩니다. 그래서 owner를 reset()해도 held가 남아 있는 동안 객체는 살아 있고, held까지 사라져야 expired()가 참이 됩니다. expired()로 확인한 뒤 따로 접근하면 그사이에 객체가 사라질 수 있으므로, 접근할 때는 lock()의 결과를 쓰는 것이 안전합니다.

원시 포인터와 오가기

함수하는 일
get()관리하는 원시 포인터를 돌려줌, 소유는 그대로
reset()지금의 소유를 놓고, 새 객체를 소유하거나 비움
release()unique_ptr만, 소유를 포기하고 원시 포인터를 돌려줌

get()이 돌려준 포인터는 빌려 쓰는 것이므로 delete하면 안 되고, 스마트 포인터가 객체를 정리하면 더 이상 쓸 수 없습니다. release()는 소유를 포기하므로, 돌려받은 쪽이 정리를 맡거나 다른 소유자에게 넘겨야 합니다. 객체를 빌려 쓰기만 하는 함수는 스마트 포인터 대신 Resource&나 Resource*를 받게 하고, 소유를 넘겨받는 함수만 std::unique_ptr<Resource>를 값으로 받게 하면 서명만 보고 소유가 옮겨 가는지 알 수 있습니다.

정리

  • 스마트 포인터는 동적 객체의 소유를 맡아, 소유자가 없어지면 객체를 자동으로 정리하는 RAII 객체입니다.
  • std::unique_ptr는 혼자 소유하고 이동만 할 수 있으며, 원시 포인터와 비용이 거의 같아 기본으로 씁니다.
  • std::shared_ptr는 참조 횟수로 여러 소유자를 관리하며, 마지막 소유자가 사라지면 객체를 정리합니다.
  • 객체는 std::make_unique와 std::make_shared로 만들고, 같은 원시 포인터로 shared_ptr를 두 번 만들지 않습니다.
  • 서로를 shared_ptr로 가리키면 순환 참조로 정리되지 않으므로, 한쪽을 참조 횟수를 늘리지 않는 std::weak_ptr로 바꿉니다.
  • weak_ptr는 lock()의 결과로만 접근하며, 빌려 쓰는 함수는 참조나 원시 포인터를 받게 합니다.

연습 문제

1. 이동 대입과 참조 횟수

auto p = std::make_shared<int>(7); auto q = p; std::weak_ptr<int> w = p; 다음에 p = std::move(q);를 실행하면 p.use_count(), static_cast<bool>(q), w.expired()는 각각 무엇인가요? 이어서 auto held = w.lock(); p.reset();을 실행하면 객체는 살아 있나요?

해설 보기

처음에는 p와 q의 두 소유자가 있습니다. 이동 대입은 p가 원래 가진 소유를 놓고 q의 소유를 넘겨받으므로 소유자는 p 하나가 되어 use_count()는 1, q는 비어 false, 객체는 살아 있으므로 expired()는 false입니다. lock()이 소유자 held를 만들었으므로 p.reset() 뒤에도 객체는 살아 있고, held까지 사라질 때 정리됩니다.

2. 같은 원시 포인터로 만든 두 shared_ptr

int* raw = new int(5); std::shared_ptr<int> s1(raw); std::shared_ptr<int> s2(raw);를 실행하면 어떤 문제가 생기나요?

해설 보기

s1과 s2가 서로 모르는 관리 공간을 각각 만들어, 둘 다 참조 횟수가 1입니다. 그래서 둘이 사라질 때 같은 int를 두 번 delete해 정의되지 않은 동작이 됩니다. auto s1 = std::make_shared<int>(5); auto s2 = s1;처럼 처음부터 make_shared로 만들고, 공유는 복사로 합니다.

3. 순환 참조의 결과

weak_ptr_cycle.cpp에서 B의 멤버를 std::shared_ptr<A> a;로 바꾸고 checkA에서 a.lock() 대신 a를 그대로 쓰도록 고치면, 출력은 어떻게 되나요?

해설 보기

첫 줄은 두 객체가 서로를 소유하므로 2 2가 되고, 둘째 줄은 A is alive입니다. 블록이 끝나 지역 a와 b가 사라져도 두 객체가 서로의 참조 횟수를 1로 붙잡아 아무것도 정리되지 않으므로, A destroyed와 B destroyed는 출력되지 않고 바로 end of main이 출력됩니다. 두 객체의 메모리는 프로그램이 끝날 때까지 새어 나갑니다.

다음 글 move 의미론과 rvalue 참조에서는 std::move가 실제로 하는 일과 rvalue 참조, 그리고 값을 원래 형태 그대로 전달하는 완벽한 전달을 다룹니다.