C++ unique_ptr vs shared_ptr: 소유권 모델과 비용 차이, 무엇을 기본으로 쓸까

이 글의 핵심

메모리 누수가 무서워 모든 포인터를 shared_ptr로 바꾼 코드에서 출발합니다. 참조 카운트 갱신이 복사마다 비용을 만들고 순환 참조가 오히려 누수를 일으키는 과정을 보여 준 뒤, 함수 전달·배열·커스텀 삭제자까지 상황별로 어떤 스마트 포인터를 쓸지 결정 트리로 정리합니다.

들어가며: “shared_ptr을 써야 할까, unique_ptr을 써야 할까?”

C++11부터 스마트 포인터가 표준 라이브러리에 추가되어 메모리 누수를 자동으로 방지할 수 있게 되었습니다. 하지만 shared_ptr과 unique_ptr 중 어느 것을 써야 할지 헷갈리는 경우가 많습니다.

비유로 말씀드리면, unique_ptr은 집 열쇠를 한 사람만 가진 소유이고, shared_ptr은 여러 사람이 같은 열쇠를 복제해 공유하는 형태입니다. 공유할수록 열쇠 개수를 세는 비용(참조 카운트)이 들고, 서로가 서로를 가리키면 순환이 생길 수 있습니다.

// unique_ptr: 단독 소유
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);

// shared_ptr: 공유 소유
std::shared_ptr<int> ptr2 = std::make_shared<int>(42);
std::shared_ptr<int> ptr3 = ptr2;  // 참조 카운트 증가

“누수가 무서우니 전부 shared_ptr로”라는 선택은 처음에는 안전해 보이지만, 코드베이스가 커지면 세 가지 문제로 돌아옵니다. 첫째, 누가 이 객체의 수명을 책임지는지 코드만 보고는 알 수 없게 됩니다. 둘째, 복사할 때마다 원자적 카운트 갱신 비용이 들고, 셋째, 객체끼리 서로 shared_ptr로 붙잡으면 오히려 영원히 해제되지 않는 누수가 생깁니다. 이 글은 두 포인터의 소유권 모델과 비용 차이를 구조적으로 설명하고, 상황별로 무엇을 고를지 정리합니다.


언제 unique_ptr을, 언제 shared_ptr을 쓰나요?

관점unique_ptrshared_ptr
성능오버헤드가 거의 없음 (raw 포인터와 크기 동일에 가깝)참조 카운트 원자적 증감 등으로 비용이 큼
사용성소유권이 한 곳으로 명확할 때 설계가 단순여러 컴포넌트가 같은 객체 수명을 공유해야 할 때
적용 시나리오팩토리에서 반환, 컨테이너 소유, 일반적인 기본 선택캐시·그래프·관찰자 등 다중 소유가 도메인에 맞을 때

기본은 unique_ptr로 두시고, 정말로 공유 소유가 필요할 때만 shared_ptr을 쓰시는 편이 안전합니다.


소유권 모델 비교

unique_ptr: 단독 소유 (Exclusive Ownership)

std::unique_ptr<int> ptr1 = std::make_unique<int>(42);

// ❌ 복사 불가
// std::unique_ptr<int> ptr2 = ptr1;  // 컴파일 에러

// ✅ 이동 가능
std::unique_ptr<int> ptr2 = std::move(ptr1);  // 소유권 이전
// 이제 ptr1은 nullptr, ptr2가 소유

특징:

  • 한 번에 하나의 소유자만 가능
  • 복사 불가, 이동 가능
  • 소유권이 명확함

unique_ptr이 복사 불가인 것은 제약이 아니라 설계 의도입니다. 복사를 허용하면 두 객체가 같은 메모리를 각자 delete하려 하므로, 컴파일러 단계에서 막아 두는 것입니다. 실수로 복사하려 하면 use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)' 같은 에러가 나는데, 이 메시지를 보면 “소유권을 넘길 것인가(std::move), 빌려줄 것인가(참조나 get())“를 결정하라는 신호로 읽으면 됩니다. std::move 뒤의 ptr1은 nullptr이 되므로 이후 역참조하면 즉시 크래시가 납니다. 이동 후 객체를 다시 쓰지 않는다는 규칙은 clang-tidy의 bugprone-use-after-move 검사로 자동으로 잡을 수 있습니다.

shared_ptr: 공유 소유 (Shared Ownership)

std::shared_ptr<int> ptr1 = std::make_shared<int>(42);

// ✅ 복사 가능
std::shared_ptr<int> ptr2 = ptr1;  // 참조 카운트 2
std::shared_ptr<int> ptr3 = ptr1;  // 참조 카운트 3

// ptr1, ptr2, ptr3 모두 소멸되면 메모리 해제

특징:

  • 여러 소유자 가능
  • 참조 카운트로 관리
  • 마지막 소유자가 소멸될 때 해제

shared_ptr의 참조 카운트는 가리키는 객체 안이 아니라 별도의 제어 블록에 있습니다. 제어 블록에는 강한 참조 수(shared_ptr 개수), 약한 참조 수(weak_ptr 개수), 삭제자가 들어 있습니다. 강한 참조가 0이 되면 객체가 파괴되고, 약한 참조까지 0이 되면 제어 블록이 해제됩니다. 여러 스레드가 동시에 복사·소멸해도 카운트가 깨지지 않도록 증감은 원자적 연산으로 이루어지는데, 이것이 shared_ptr 복사가 공짜가 아닌 이유입니다.

가장 흔한 실수는 같은 raw 포인터로 shared_ptr을 두 번 만드는 것입니다. std::shared_ptr<T> a(p); std::shared_ptr<T> b(p);처럼 쓰면 제어 블록이 두 개 생겨 각자 카운트를 세고, 둘 다 0이 될 때 같은 객체를 두 번 delete해 double free or corruption 에러로 죽습니다. 멤버 함수 안에서 std::shared_ptr<T>(this)를 만드는 것도 같은 문제라서, 이럴 때는 클래스가 std::enable_shared_from_this<T>를 상속하고 shared_from_this()를 써야 합니다.

소유권 다이어그램

flowchart TB
    subgraph unique_ptr
        U1[unique_ptr] --> Obj1[Object]
    end
    
    subgraph shared_ptr
        S1[shared_ptr] --> Obj2[Object]
        S2[shared_ptr] --> Obj2
        S3[shared_ptr] --> Obj2
        Obj2 -.->|ref count = 3| RC[Control Block]
    end

성능 오버헤드 비교

메모리 크기

// raw 포인터
int* raw = new int(42);
// 크기: 8바이트 (64비트)

// unique_ptr
std::unique_ptr<int> uptr = std::make_unique<int>(42);
// 크기: 8바이트 (raw 포인터와 동일)

// shared_ptr
std::shared_ptr<int> sptr = std::make_shared<int>(42);
// 크기: 16바이트 (포인터 8바이트 + 제어 블록 포인터 8바이트)
// 제어 블록: 참조 카운트, weak 카운트 등 (make_shared면 객체와 함께, 아니면 별도 힙 할당)

shared_ptr이 포인터를 두 개 들고 있는 이유는 객체 포인터와 제어 블록 포인터가 다를 수 있기 때문입니다. 예를 들어 shared_ptr<Derived>를 shared_ptr<Base>로 변환하면 가리키는 주소는 Base 부분으로 바뀌지만 제어 블록은 그대로 공유합니다. 컨테이너에 수백만 개의 포인터를 저장한다면 이 8바이트 차이도 캐시 효율에 영향을 줍니다.

할당 비용

// unique_ptr: 1번 할당
auto uptr = std::make_unique<int>(42);
// malloc 1번: int 객체

// shared_ptr: 1번 할당 (make_shared 사용 시)
auto sptr = std::make_shared<int>(42);
// malloc 1번: int 객체 + 제어 블록 (함께 할당)

// shared_ptr: 2번 할당 (new 사용 시)
std::shared_ptr<int> sptr2(new int(42));
// malloc 2번: int 객체 + 제어 블록 (따로 할당)

권장: make_shared를 사용하세요 (할당 1번).

make_shared는 할당 횟수 외에 예외 안전성도 줍니다. C++17 이전에는 f(std::shared_ptr<T>(new T), g()) 같은 식에서 컴파일러가 new T → g() → shared_ptr 생성 순서로 평가할 수 있었고, 이때 g()가 예외를 던지면 new T로 만든 객체가 누수됐습니다. C++17에서 함수 인자 평가 규칙이 바뀌어 이 특정 누수는 사라졌지만, make_shared/make_unique를 쓰면 new가 코드에 드러나지 않아 이런 걱정 자체를 할 필요가 없습니다. 반대로 make_shared를 쓰면 안 되는 경우도 있습니다. 커스텀 삭제자가 필요하거나, 생성자가 private이거나, 객체가 크고 weak_ptr이 오래 살아남는 경우입니다(마지막 FAQ 참고).

복사 비용

// unique_ptr: 복사 불가, 이동 O(1)
std::unique_ptr<int> uptr1 = std::make_unique<int>(42);
std::unique_ptr<int> uptr2 = std::move(uptr1);  // O(1), 포인터만 이동

// shared_ptr: 복사 O(1) (참조 카운트 증가)
std::shared_ptr<int> sptr1 = std::make_shared<int>(42);
std::shared_ptr<int> sptr2 = sptr1;  // O(1), 참조 카운트 증가 (atomic)

벤치마크

// 100만 번 할당/해제
void benchUniquePtr() {
    for (int i = 0; i < 1000000; ++i) {
        auto ptr = std::make_unique<int>(i);
    }
}

void benchSharedPtr() {
    for (int i = 0; i < 1000000; ++i) {
        auto ptr = std::make_shared<int>(i);
    }
}

이런 마이크로벤치마크의 절대 수치는 컴파일러, 최적화 옵션, 메모리 할당기(glibc malloc, jemalloc, Windows 힙 등)에 따라 크게 달라지므로 직접 측정해 보는 것이 가장 정확합니다. 구조에서 예상할 수 있는 경향은 이렇습니다. 세 방식 모두 비용의 대부분은 힙 할당 자체이고, make_shared는 제어 블록 초기화와 원자적 카운트 감소가 조금 더해지며, shared_ptr<T>(new T)는 할당이 한 번 더 일어나 가장 느립니다.

측정할 때는 최적화 빌드(-O2)를 쓰되, 컴파일러가 “쓰이지 않는 할당”을 통째로 제거하지 않도록 결과를 어딘가에 사용해야 합니다. Clang은 짝이 맞는 new/delete를 최적화로 없앨 수 있어서, 루프가 0ms로 측정되는 결과가 나오면 대개 이 때문입니다. Google Benchmark의 benchmark::DoNotOptimize()가 이 문제를 막아 줍니다.


사용법 비교

생성

// unique_ptr
auto uptr = std::make_unique<int>(42);  // C++14
std::unique_ptr<int> uptr2(new int(42));  // C++11 (비권장)

// shared_ptr
auto sptr = std::make_shared<int>(42);  // 권장
std::shared_ptr<int> sptr2(new int(42));  // 비권장 (할당 2번)

배열

// unique_ptr: 배열 지원
std::unique_ptr<int[]> uarr = std::make_unique<int[]>(10);
uarr[0] = 42;  // operator[] 사용 가능
// 자동으로 delete[] 호출

// shared_ptr: 배열 지원 (shared_ptr<T[]>는 C++17, make_shared<T[]>는 C++20)
std::shared_ptr<int[]> sarr = std::make_shared<int[]>(10);
sarr[0] = 42;

// C++11/14에서는 커스텀 삭제자 필요
std::shared_ptr<int> sarr2(new int[10], std::default_delete<int[]>());

함수 전달

// unique_ptr: 소유권 이전
void takeOwnership(std::unique_ptr<int> ptr) {
    // 소유권 이전됨
}

std::unique_ptr<int> uptr = std::make_unique<int>(42);
takeOwnership(std::move(uptr));  // 이동
// uptr은 이제 nullptr

// unique_ptr: 단순 사용 (소유권 유지)
void useOnly(int* ptr) {
    std::cout << *ptr << '\n';
}

std::unique_ptr<int> uptr2 = std::make_unique<int>(42);
useOnly(uptr2.get());  // raw 포인터 전달
// uptr2는 여전히 유효

// shared_ptr: 복사로 전달
void share(std::shared_ptr<int> ptr) {
    // 참조 카운트 증가
}

std::shared_ptr<int> sptr = std::make_shared<int>(42);
share(sptr);  // 복사
// sptr 여전히 유효

함수 매개변수 타입은 그 함수가 소유권에 대해 무엇을 하는지를 문서화합니다. unique_ptr<T>를 값으로 받으면 “소유권을 가져간다”, shared_ptr<T>를 값으로 받으면 “공유 소유자가 된다(어딘가에 저장한다)”, T&나 T*를 받으면 “잠시 빌려 쓴다”는 뜻입니다. 흔한 안티패턴은 객체를 잠깐 읽기만 하는 함수가 const std::shared_ptr<T>&나 std::shared_ptr<T>를 받는 것입니다. 이러면 호출하는 쪽이 unique_ptr이나 스택 객체를 가진 경우 넘길 수가 없고, 값으로 받으면 호출마다 원자적 증감 비용까지 듭니다. 저장할 것이 아니라면 T&를 받는 것이 가장 유연합니다. C++ Core Guidelines의 R.30~R.37이 이 규칙을 정리하고 있습니다.

커스텀 삭제자

// unique_ptr: 타입의 일부
auto deleter = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(deleter)> file(fopen("data.txt", "r"), deleter);

// shared_ptr: 타입과 무관
auto deleter2 = [](FILE* f) { if (f) fclose(f); };
std::shared_ptr<FILE> file2(fopen("data.txt", "r"), deleter2);

삭제자가 unique_ptr에서는 타입의 일부이고 shared_ptr에서는 아닌 이유는 설계 목표가 다르기 때문입니다. unique_ptr은 비용 0을 목표로 삭제자를 템플릿 인자로 받아 컴파일 타임에 인라인하고, 상태 없는 람다라면 크기도 늘지 않습니다. 대신 삭제자가 다른 unique_ptr끼리는 서로 다른 타입이라 같은 컨테이너에 넣을 수 없습니다. shared_ptr은 삭제자를 제어 블록에 타입 소거해서 저장하므로, 삭제자가 달라도 모두 shared_ptr<FILE> 하나의 타입이 됩니다. fopen이 실패해 nullptr을 돌려줄 수 있으므로 삭제자에서 if (f)로 확인하는 부분도 빠뜨리면 안 됩니다. fclose(nullptr)은 정의되지 않은 동작입니다.


순환 참조와 weak_ptr

순환 참조 문제

// ❌ 메모리 누수
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();

a->next = b;  // a의 참조 카운트: 1, b의 참조 카운트: 2
b->prev = a;  // a의 참조 카운트: 2, b의 참조 카운트: 2

// a, b가 스코프를 벗어나도 참조 카운트가 1로 유지 → 누수!

순환 참조 누수가 까다로운 것은 크래시도 경고도 없다는 점입니다. 소멸자가 호출되지 않을 뿐이라서, 그 소멸자가 파일을 닫거나 소켓을 반납하는 역할을 했다면 메모리보다 리소스 고갈로 먼저 드러납니다. 서버가 며칠 돌고 나서 Too many open files가 나거나 메모리 사용량이 계단식으로 늘어나는데 원인이 잡히지 않는다면, 콜백 람다가 shared_from_this()를 캡처해 자기 자신을 붙잡는 순환을 먼저 의심해 볼 만합니다. 객체가 멤버로 가진 std::function 안에 자신의 shared_ptr을 캡처하는 패턴이 가장 흔한 순환 경로입니다.

weak_ptr로 해결

// ✅ 순환 끊기
struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak_ptr: 참조 카운트 증가 안 함
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();

a->next = b;  // b의 참조 카운트: 2
b->prev = a;  // a의 참조 카운트: 1 (weak_ptr는 증가 안 함)

// a, b가 스코프를 벗어나면 정상 해제

weak_ptr 사용법

std::shared_ptr<int> sptr = std::make_shared<int>(42);
std::weak_ptr<int> wptr = sptr;  // weak 참조

// weak_ptr은 직접 역참조 불가
// std::cout << *wptr << '\n';  // 컴파일 에러

// lock()으로 shared_ptr 얻기
if (auto locked = wptr.lock()) {  // 객체가 살아있으면 shared_ptr 반환
    std::cout << *locked << '\n';  // 42
} else {
    std::cout << "Object destroyed\n";
}

weak_ptr을 직접 역참조할 수 없게 막아 둔 이유는 “확인하고 쓰는 사이에 객체가 사라지는” 경쟁을 원천 차단하기 위해서입니다. if (!wptr.expired()) use(*wptr.lock());처럼 두 단계로 나누면, 멀티스레드 환경에서는 expired() 확인 후 lock() 사이에 마지막 shared_ptr이 해제될 수 있습니다. lock()은 확인과 소유권 획득을 원자적으로 한 번에 하므로, 위 예제처럼 lock()의 결과를 바로 검사하는 형태가 올바른 사용법입니다.


상황별 선택 가이드

결정 트리

Q1. 소유권을 공유해야 하는가?
    Yes → shared_ptr
    No → Q2

Q2. 소유권을 이전해야 하는가?
    Yes → unique_ptr (std::move)
    No → Q3

Q3. 단순 사용만 하는가?
    Yes → raw 포인터 또는 참조
    No → unique_ptr (기본)

상황별 권장

상황권장이유
기본 선택unique_ptr오버헤드 없음, 명확한 소유권
소유권 공유shared_ptr여러 곳에서 접근
팩토리 함수 반환unique_ptr소유권 이전
콜백 저장shared_ptr수명 보장
부모-자식 관계unique_ptr (부모), raw ptr (자식)명확한 소유권
순환 참조 가능shared_ptr + weak_ptr누수 방지
캐시weak_ptr선택적 보관

실전 예제

예제 1: 팩토리 패턴

// unique_ptr 반환 (소유권 이전)
std::unique_ptr<Widget> createWidget(WidgetType type) {
    switch (type) {
        case WidgetType::Button:
            return std::make_unique<Button>();
        case WidgetType::Label:
            return std::make_unique<Label>();
    }
    return nullptr;  // 모든 enum 값을 처리하지 않으면 경고(control reaches end of non-void function)
}

// 사용
auto widget = createWidget(WidgetType::Button);
// widget이 소유권을 가짐

팩토리는 unique_ptr을 반환하는 것이 정석입니다. unique_ptr<Widget>은 shared_ptr<Widget>으로 암시적 변환이 되므로, 호출하는 쪽이 공유가 필요하면 std::shared_ptr<Widget> s = createWidget(...);로 받으면 됩니다. 반대 방향(shared_ptr → unique_ptr)은 불가능하므로, 처음부터 shared_ptr을 반환하면 호출자의 선택지를 없애는 셈입니다. 다형 타입을 unique_ptr<Base>로 다룰 때는 Base의 소멸자가 virtual이어야 파생 클래스 소멸자가 호출된다는 점도 잊지 말아야 합니다.

예제 2: 옵저버 패턴

// shared_ptr: 옵저버 수명 보장
class Subject {
    std::vector<std::shared_ptr<Observer>> observers_;
    
public:
    void attach(std::shared_ptr<Observer> obs) {
        observers_.push_back(obs);
    }
    
    void notify() {
        for (auto& obs : observers_) {
            obs->update();  // 옵저버가 살아있음 보장
        }
    }
};

이 설계는 옵저버의 수명을 Subject가 연장한다는 뜻이기도 합니다. 화면을 닫아 옵저버 객체를 버렸는데도 Subject가 계속 붙잡고 있어 update()가 호출되는, “좀비 옵저버” 문제가 생길 수 있습니다. 옵저버의 수명을 옵저버 쪽이 결정해야 한다면 std::vector<std::weak_ptr<Observer>>로 저장하고, notify()에서 lock()에 실패한 항목을 목록에서 지우는 방식이 더 적합합니다.

예제 3: 트리 구조

// unique_ptr: 부모 → 자식 (소유)
// raw ptr: 자식 → 부모 (비소유)
struct TreeNode {
    int value;
    std::unique_ptr<TreeNode> left;   // 소유
    std::unique_ptr<TreeNode> right;  // 소유
    TreeNode* parent;  // 비소유 (부모는 자식을 소유하지만, 자식은 부모를 소유 안 함)
    
    TreeNode(int v, TreeNode* p = nullptr) 
        : value(v), parent(p) {}
};

// 사용
auto root = std::make_unique<TreeNode>(1);
root->left = std::make_unique<TreeNode>(2, root.get());
root->right = std::make_unique<TreeNode>(3, root.get());

// root 소멸 시 left, right 자동 소멸

부모 포인터를 raw 포인터로 두는 것이 안전한 이유는 수명 관계가 구조적으로 보장되기 때문입니다. 자식은 부모가 소유하므로 자식이 살아 있는 동안 부모는 반드시 살아 있습니다. 다만 이 트리는 재귀적으로 소멸하므로, 한쪽으로 길게 치우친 트리(예: 수십만 단계 깊이의 연결 리스트 형태)에서는 소멸자 재귀가 스택 오버플로를 일으킬 수 있습니다. 깊이가 클 수 있는 구조라면 소멸자에서 반복문으로 자식을 떼어 내며 해제하는 처리가 필요합니다.

예제 4: 캐시 (weak_ptr)

// weak_ptr: 캐시가 객체를 소유하지 않음
class Cache {
    std::unordered_map<Key, std::weak_ptr<Value>> cache_;
    
public:
    std::shared_ptr<Value> get(const Key& key) {
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            if (auto locked = it->second.lock()) {  // 살아있으면
                return locked;  // 캐시 히트
            } else {
                cache_.erase(it);  // 죽었으면 제거
            }
        }
        
        // 캐시 미스: 새로 생성
        auto value = std::make_shared<Value>(loadFromDB(key));
        cache_[key] = value;  // weak_ptr로 저장
        return value;
    }
};

성능 비교 상세

벤치마크: 생성/소멸

// 100만 번 생성/소멸
void benchUniquePtr() {
    for (int i = 0; i < 1000000; ++i) {
        auto ptr = std::make_unique<int>(i);
    }  // 자동 소멸
}

void benchSharedPtr() {
    for (int i = 0; i < 1000000; ++i) {
        auto ptr = std::make_shared<int>(i);
    }  // 자동 소멸
}

분석: 최적화 빌드에서 unique_ptr은 수동 new/delete와 같은 코드로 컴파일되므로 차이가 나지 않는 것이 정상입니다. shared_ptr은 제어 블록 처리만큼 느리고, new로 만들면 할당이 한 번 더 들어 차이가 벌어집니다. 차이의 크기는 할당기 성능에 크게 좌우됩니다.

벤치마크: 복사

// shared_ptr 복사 (참조 카운트 증감)
std::shared_ptr<int> sptr = std::make_shared<int>(42);

for (int i = 0; i < 1000000; ++i) {
    std::shared_ptr<int> copy = sptr;  // atomic 증가
}  // atomic 감소

분석: shared_ptr 복사와 소멸은 원자적 증가·감소를 한 번씩 수행합니다. 단일 스레드에서는 원자적 연산도 비교적 싸지만, 여러 스레드가 같은 객체의 shared_ptr을 동시에 복사하면 제어 블록의 캐시 라인을 코어끼리 주고받느라 비용이 크게 늘어납니다. 반면 unique_ptr 이동은 포인터 복사와 원본을 nullptr로 만드는 대입뿐입니다. 핫 루프에서 shared_ptr을 값으로 넘기고 있다면 const&나 raw 참조로 바꾸는 것만으로도 차이가 날 수 있습니다.


같이 보면 좋은 글


디버깅할 때 알아 둘 것

순환 참조 누수는 프로그램 종료 시점에 도달 불가능한 메모리로 남으므로 Valgrind(--leak-check=full)나 AddressSanitizer의 LeakSanitizer가 보고해 줍니다. 보고서의 할당 위치가 make_shared 호출 지점으로 나오면, 그 객체를 누가 shared_ptr로 붙잡고 있는지 역추적하면 됩니다. use_count()는 디버깅용으로만 쓰는 것이 좋습니다. 멀티스레드 환경에서는 값을 읽는 순간 이미 바뀌어 있을 수 있어, if (p.use_count() == 1) 같은 로직에 쓰면 경쟁 조건이 됩니다(C++20에서 unique()가 제거된 이유이기도 합니다). weak_ptr의 expired()도 같은 이유로 “지금은 살아 있다”를 보장하지 못하므로, 실제로 쓸 때는 lock()을 사용합니다.


자주 묻는 질문 (FAQ)

Q. shared_ptr을 만들 때 make_shared와 shared_ptr(new T)는 무엇이 다른가요?

A. shared_ptr(new T)는 객체와 참조 카운트를 담는 제어 블록을 따로 할당해 할당이 두 번 일어나지만, make_shared는 둘을 한 번에 연속된 메모리로 할당합니다. 할당 횟수가 줄고 캐시 지역성도 좋아지므로 특별한 이유가 없다면 make_shared를 쓰는 것이 권장됩니다. 다만 객체와 제어 블록이 한 덩어리라서 weak_ptr이 남아 있는 동안에는 객체 메모리도 함께 해제되지 않는다는 점을 큰 객체에서는 고려해야 합니다.