C++ weak_ptr: shared_ptr 순환 참조 끊기와 lock()·expired() 올바른 사용법

이 글의 핵심

옵저버 목록이나 캐시가 대상 객체를 shared_ptr로 잡고 있으면 객체의 수명이 원래 의도보다 길어지거나 순환 참조로 영영 해제되지 않습니다. 이 글은 weak_ptr이 강한 참조 카운트를 올리지 않는 원리와, expired() 확인 뒤 lock()하는 방식이 경쟁 상태를 만드는 이유, 타이머·이벤트 리스너에서 쓰는 패턴을 보여줍니다.

weak_ptr이란?

std::weak_ptr 은 C++11에서 도입된 스마트 포인터로, shared_ptr가 관리하는 객체를 “관찰”만 하고 참조 카운트를 올리지 않습니다. 순환 참조 방지와 캐시·옵저버 패턴에 사용되며, 스마트 포인터 weak_ptr에서 더 자세히 다룹니다.

왜 필요한가?:

  • 순환 참조 방지: shared_ptr 간 순환 참조로 인한 메모리 누수 방지
  • 캐시: 객체가 사용 중일 때만 캐시 유지
  • 관찰자 패턴: 관찰자가 소멸되어도 주체에 영향 없음
  • 역참조: 부모-자식 관계에서 자식이 부모를 참조
// ❌ shared_ptr 순환 참조: 메모리 누수
class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // 순환 참조
};

auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();
n1->next = n2;
n2->prev = n1;  // 순환 참조 → 메모리 누수

// ✅ weak_ptr: 순환 방지
class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // 순환 방지
};

첫 번째 코드에서 누수가 생기는 과정을 따라가 보면 이렇습니다. n1의 객체는 지역 변수 n1과 n2->prev가, n2의 객체는 지역 변수 n2와 n1->next가 가리키므로 둘 다 참조 카운트가 2입니다. 함수가 끝나 지역 변수 두 개가 사라지면 카운트는 각각 1로 줄지만, 남은 1은 서로가 서로를 붙잡고 있는 참조라서 영원히 0이 되지 않습니다. 소멸자가 호출되지 않으므로 그 안에서 닫아야 할 파일이나 소켓도 함께 새게 됩니다. prev를 weak_ptr로 바꾸면 되돌아가는 방향의 참조는 카운트에 포함되지 않으므로, n1이 사라지는 순간 n1의 객체가 소멸하고 그 안의 next가 해제되면서 n2도 연쇄적으로 정리됩니다.

규칙은 “소유 관계를 한 방향으로 정한다”입니다. 리스트라면 앞에서 뒤로, 트리라면 부모에서 자식으로 shared_ptr를 두고, 거꾸로 올라가는 참조는 weak_ptr로 둡니다. 순환 참조로 인한 누수는 크래시 없이 조용히 메모리만 늘어나기 때문에, 오래 실행되는 서버에서 몇 시간이 지나서야 드러나는 경우가 많습니다. 의심된다면 소멸자에 로그를 넣어 호출되는지 확인하는 것이 가장 간단한 진단 방법입니다.

스마트 포인터 비교

포인터 타입소유권참조 카운트사용 시나리오
unique_ptr독점없음단일 소유자
shared_ptr공유증가여러 소유자
weak_ptr없음증가 안함관찰, 순환 참조 방지
auto shared = std::make_shared<int>(10);
std::weak_ptr<int> weak = shared;

// 참조 카운트 증가 안함
std::cout << shared.use_count() << std::endl;  // 1

정확히 말하면 shared_ptr의 제어 블록에는 카운트가 두 개 있습니다. 객체를 소유하는 shared_ptr의 수(강한 참조, use_count())와 관찰하는 weak_ptr의 수(약한 참조)입니다. 강한 참조가 0이 되면 객체의 소멸자가 호출되고, 약한 참조까지 0이 되어야 제어 블록 자체가 해제됩니다. weak_ptr가 “객체가 이미 사라졌는지”를 안전하게 확인할 수 있는 것은 객체가 사라진 뒤에도 이 제어 블록이 남아 있기 때문입니다.

여기서 잘 알려지지 않은 함정이 하나 있습니다. make_shared는 객체와 제어 블록을 한 번의 할당으로 붙여서 만듭니다. 그래서 강한 참조가 0이 되어 소멸자가 호출되더라도, weak_ptr가 하나라도 남아 있으면 제어 블록과 같은 메모리 덩어리에 있는 객체 공간까지 해제되지 않습니다. 객체가 수 MB짜리 버퍼를 멤버로 직접 가진 클래스라면(std::array<char, 4'000'000> 같은) 캐시에 남은 오래된 weak_ptr 때문에 메모리가 예상보다 오래 점유될 수 있습니다. vector처럼 내부 버퍼를 따로 할당하는 멤버는 소멸자에서 해제되므로 문제가 되지 않고, 객체 자체가 매우 큰 경우에만 std::shared_ptr<T>(new T)로 따로 할당하는 것을 고려하면 됩니다.

weak_ptr 동작 원리

graph TD
    A[shared_ptr 1] -->|소유| B[객체]
    C[shared_ptr 2] -->|소유| B
    D[weak_ptr 1] -.->|관찰| B
    E[weak_ptr 2] -.->|관찰| B
    
    B -->|참조 카운트| F[2]
    
    style A fill:#90EE90
    style C fill:#90EE90
    style D fill:#FFB6C1
    style E fill:#FFB6C1

사용 방법

weak_ptr 생명주기

sequenceDiagram
    participant Code
    participant Shared as shared_ptr
    participant Weak as weak_ptr
    participant Obj as Object
    
    Code->>Shared: make_shared
    Shared->>Obj: create (ref=1)
    Code->>Weak: from shared
    Weak->>Obj: observe (ref=1)
    
    Code->>Weak: lock()
    Weak->>Shared: temp shared_ptr
    Note over Shared: ref=2
    Code->>Code: use
    Note over Shared: ref=1
    
    Code->>Shared: destroy shared
    Shared->>Obj: destroy (ref=0)
    
    Code->>Weak: lock()
    Weak->>Code: nullptr
std::weak_ptr<int> weak;

{
    auto shared = std::make_shared<int>(10);
    weak = shared;
    
    // lock으로 shared_ptr 얻기
    if (auto ptr = weak.lock()) {
        std::cout << *ptr << std::endl;  // 10
    }
}

// shared 소멸 후
if (auto ptr = weak.lock()) {
    // 실행 안 됨
} else {
    std::cout << "만료됨" << std::endl;
}

lock()은 객체가 살아 있으면 그 객체를 가리키는 새 shared_ptr를, 이미 소멸했으면 빈 shared_ptr를 반환합니다. 핵심은 반환된 ptr가 살아 있는 동안에는 객체가 절대 소멸하지 않는다는 점입니다. 다른 스레드가 마지막 원본 shared_ptr를 놓더라도 ptr가 강한 참조 하나를 쥐고 있으므로, if 블록 안에서는 안심하고 객체를 쓸 수 있습니다. 그래서 weak_ptr에는 *나 -> 연산자가 아예 없고, 반드시 lock()을 거쳐야만 객체에 접근할 수 있게 설계되어 있습니다. if (auto ptr = weak.lock())처럼 조건문 안에서 선언하면 ptr의 범위가 블록 안으로 제한되어, 필요 이상으로 오래 객체를 붙잡아 두는 실수도 막을 수 있습니다.

실전 예시

예시 1: 순환 참조 방지

class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // 순환 방지
    int data;
    
    Node(int d) : data(d) {}
    ~Node() {
        std::cout << "Node " << data << " 소멸" << std::endl;
    }
};

int main() {
    auto node1 = std::make_shared<Node>(1);
    auto node2 = std::make_shared<Node>(2);
    
    node1->next = node2;
    node2->prev = node1;  // weak_ptr

    // 자동 소멸
}

실행하면 Node 1 소멸, Node 2 소멸 순서로 출력됩니다. main이 끝날 때 지역 변수는 선언의 역순으로 파괴되므로 node2가 먼저 사라지지만, 이때는 node1->next가 아직 node2의 객체를 잡고 있어 카운트가 1로 줄어들 뿐입니다. 이어서 node1이 사라지면 Node 1의 카운트가 0이 되어 소멸하고, 그 멤버 next가 해제되면서 Node 2도 소멸합니다. prev를 shared_ptr로 바꿔서 실행해 보면 두 줄 모두 출력되지 않는 것을 직접 확인할 수 있습니다.

주의할 점은 이 방식으로 아주 긴 리스트를 만들면 소멸이 재귀적으로 일어난다는 것입니다. 첫 노드의 소멸자가 next를 해제하고, 그것이 다음 노드의 소멸자를 부르는 식으로 이어지므로, 노드가 수십만 개면 스택이 넘쳐 크래시가 날 수 있습니다. 긴 연결 구조를 스마트 포인터로 만든다면 소멸자에서 반복문으로 하나씩 끊어 주는 처리가 필요합니다.

예시 2: 캐시

class Cache {
    std::map<int, std::weak_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> get(int id) {
        auto it = cache.find(id);
        if (it != cache.end()) {
            if (auto ptr = it->second.lock()) {
                return ptr;  // 캐시 히트
            }
        }
        
        // 캐시 미스: 새로 생성
        auto ptr = std::make_shared<Resource>(id);
        cache[id] = ptr;
        return ptr;
    }
};

이 캐시는 weak_ptr만 들고 있으므로 누군가 쓰고 있는 동안만 리소스를 공유합니다. 여러 곳에서 같은 id를 요청하면 같은 객체를 받고, 마지막 사용자가 놓는 순간 리소스가 해제됩니다. 캐시가 shared_ptr를 들고 있었다면 한 번 불러온 리소스는 아무도 쓰지 않아도 프로그램이 끝날 때까지 메모리에 남았을 것입니다. 텍스처, 폰트, 설정 파일처럼 “공유하되 쓰지 않으면 버려도 되는” 자원에 잘 맞는 구조입니다.

반대로 이 성질은 단점이 되기도 합니다. 사용자가 잠깐 놓았다가 곧바로 다시 요청하면 매번 새로 불러오게 되어 캐시 효과가 없습니다. 자주 쓰는 항목을 일정 개수만큼은 유지하고 싶다면 LRU 목록에 shared_ptr를 몇 개 함께 보관하는 방식을 섞어야 합니다. 또 만료된 항목도 map에서 지워지지 않고 제어 블록과 함께 계속 쌓이므로, 아래 “문제 4”처럼 주기적으로 정리해야 합니다. 여러 스레드에서 get을 호출한다면 map 접근 전체를 뮤텍스로 보호해야 하는데, lock() 자체는 스레드 안전하지만 map을 찾고 삽입하는 과정은 그렇지 않기 때문입니다.

예시 3: 관찰자 패턴

class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
    
public:
    void attach(std::shared_ptr<Observer> obs) {
        observers.push_back(obs);
    }
    
    void notify() {
        // 만료된 관찰자 제거
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                [](const std::weak_ptr<Observer>& weak) { return weak.expired(); }),
            observers.end()
        );
        
        // 알림
        for (auto& weak : observers) {
            if (auto obs = weak.lock()) {
                obs->update();
            }
        }
    }
};

관찰자 패턴에서 Subject가 관찰자를 shared_ptr로 들고 있으면, 관찰자 쪽에서 더 이상 필요 없어져도 Subject가 붙잡고 있어 소멸하지 않습니다. 관찰자가 명시적으로 detach를 호출하는 것을 잊는 순간 누수가 되고, 소멸했어야 할 객체가 계속 알림을 받아 엉뚱한 동작을 하기도 합니다. weak_ptr로 들고 있으면 관찰자는 그냥 사라지면 되고, Subject는 알림을 보낼 때 만료된 항목을 걸러 냅니다.

notify 안에서 update()를 호출하는 동안 관찰자가 스스로를 attach하거나 다른 관찰자를 추가하면, observers 벡터가 재할당되어 순회 중인 이터레이터가 무효화됩니다. 알림 도중 목록이 바뀔 수 있는 구조라면 auto snapshot = observers;로 복사본을 만든 뒤 복사본을 순회하는 편이 안전합니다. 참고로 첫 번째 remove_if 단계는 만료 항목을 미리 지우는 정리 작업일 뿐이고, 두 번째 루프에서 lock() 결과를 다시 검사하는 것이 실제 안전장치입니다. 정리와 알림 사이에 다른 스레드에서 관찰자가 소멸할 수도 있기 때문입니다.

예시 4: 부모-자식 관계

class Child;

class Parent {
public:
    std::vector<std::shared_ptr<Child>> children;
    
    ~Parent() {
        std::cout << "Parent 소멸" << std::endl;
    }
};

class Child {
public:
    std::weak_ptr<Parent> parent;  // 순환 방지

    ~Child() {
        std::cout << "Child 소멸" << std::endl;
    }
};

부모는 자식을 소유하고(shared_ptr), 자식은 부모를 가리키기만 합니다(weak_ptr). 부모가 소멸하면 children 벡터가 해제되면서 자식들도 함께 정리되므로 출력은 Parent 소멸 다음에 Child 소멸이 이어집니다. 자식이 부모에게 접근할 때는 if (auto p = parent.lock())으로 부모가 아직 살아 있는지 확인해야 하는데, 부모의 소멸자가 실행되는 도중에 자식이 부모를 찾으면 lock()이 이미 빈 포인터를 반환한다는 점도 기억해 두세요.

이 구조를 처음 만들 때 흔히 막히는 곳은 부모가 자기 자신을 자식에게 넘겨주는 부분입니다. Parent의 멤버 함수 안에서 child->parent = std::shared_ptr<Parent>(this);처럼 쓰면, 이미 다른 shared_ptr가 관리하는 객체에 두 번째 제어 블록이 생겨 나중에 이중 해제로 크래시가 납니다. class Parent : public std::enable_shared_from_this<Parent>로 상속한 뒤 child->parent = weak_from_this();(C++17) 또는 shared_from_this()를 써야 합니다. 이 함수들은 객체가 이미 shared_ptr로 관리되고 있을 때만 동작하므로, 생성자 안에서 호출하면 shared_from_this()는 std::bad_weak_ptr 예외(C++17부터)를 던지고 weak_from_this()는 빈 weak_ptr를 돌려준다는 점도 주의해야 합니다.

멤버 함수

std::weak_ptr<int> weak;

// expired: 만료 확인
if (weak.expired()) {
    std::cout << "만료됨" << std::endl;
}

// lock: shared_ptr 얻기
if (auto ptr = weak.lock()) {
    std::cout << *ptr << std::endl;
}

// use_count: 참조 카운트
std::cout << weak.use_count() << std::endl;

// reset: 초기화
weak.reset();

use_count()는 weak_ptr가 아니라 관찰 중인 객체의 강한 참조 수를 반환합니다. 디버깅 용도로는 유용하지만, 여러 스레드가 동시에 shared_ptr를 복사하거나 해제하는 환경에서는 값을 읽은 직후에 바뀔 수 있으므로 로직의 판단 근거로 쓰면 안 됩니다. expired()는 use_count() == 0과 같은 의미이며, 앞에서 말했듯 “지금 이 순간 만료되었는가”만 알려 줄 뿐 이후를 보장하지 않습니다.

자주 발생하는 문제

문제 1: lock 없이 사용

std::weak_ptr<int> weak;

// ❌ 직접 접근 불가
// *weak;  // 에러
// weak->func();  // 에러

// ✅ lock 사용
if (auto ptr = weak.lock()) {
    *ptr = 20;
}

문제 2: 만료 확인

// ❌ expired 후 lock
if (!weak.expired()) {
    auto ptr = weak.lock();  // 사이에 만료될 수 있음
}

// ✅ lock만 사용
if (auto ptr = weak.lock()) {
    // 안전
}

첫 번째 코드는 단일 스레드에서는 동작하지만, expired() 검사와 lock() 호출 사이에 다른 스레드가 마지막 shared_ptr를 해제하면 ptr가 비어 있게 됩니다. 이후 코드가 ptr를 검사 없이 역참조하면 널 포인터 접근으로 크래시가 납니다. “확인한 시점”과 “사용하는 시점”이 달라서 생기는 전형적인 TOCTOU(time-of-check to time-of-use) 문제입니다. lock()은 확인과 소유권 획득을 원자적으로 한 번에 하므로 이런 틈이 없습니다. 단일 스레드 코드라도 두 번 확인할 이유가 없으니 항상 lock() 한 번으로 쓰는 습관을 들이는 편이 좋습니다. expired()가 의미 있는 경우는 위의 remove_if처럼 “만료된 것을 정리할지” 판단할 때처럼, 틀려도 다음 번에 다시 처리하면 되는 상황뿐입니다.

문제 3: 순환 참조

// ❌ shared_ptr 순환
class Node {
    std::shared_ptr<Node> parent;  // 순환
    std::shared_ptr<Node> child;
};

// ✅ weak_ptr 사용
class Node {
    std::weak_ptr<Node> parent;  // 순환 방지
    std::shared_ptr<Node> child;
};

문제 4: 캐시 정리

class Cache {
    std::map<int, std::weak_ptr<Data>> cache;
    
public:
    void cleanup() {
        for (auto it = cache.begin(); it != cache.end();) {
            if (it->second.expired()) {
                it = cache.erase(it);
            } else {
                ++it;
            }
        }
    }
};

erase가 다음 원소의 이터레이터를 반환하므로 it = cache.erase(it)로 받고, 지우지 않을 때만 ++it를 하는 것이 순회 중 삭제의 정석입니다. cache.erase(it); ++it;처럼 쓰면 이미 무효화된 이터레이터를 증가시키는 정의되지 않은 동작이 됩니다. C++20에서는 std::erase_if(cache, [](const auto& kv) { return kv.second.expired(); }); 한 줄로 쓸 수 있습니다. 정리 시점은 get이 호출될 때마다 하면 매번 전체를 훑는 비용이 들므로, 일정 호출 횟수마다 또는 캐시 크기가 기준을 넘을 때 한 번씩 하는 방식이 일반적입니다.

사용 패턴

// 1. 순환 참조 방지
std::weak_ptr<Parent> parent;

// 2. 캐시
std::map<Key, std::weak_ptr<Value>> cache;

// 3. 관찰자
std::vector<std::weak_ptr<Observer>> observers;

// 4. 역참조
std::weak_ptr<Node> backref;

실무 패턴

패턴 1: 타이머 시스템

class Timer {
    std::weak_ptr<Task> task_;
    
public:
    Timer(std::shared_ptr<Task> task) : task_(task) {}
    
    void tick() {
        if (auto task = task_.lock()) {
            task->execute();
        } else {
            std::cout << "Task 만료됨\n";
        }
    }
};

// 사용
auto task = std::make_shared<Task>();
Timer timer(task);

timer.tick();  // 실행
task.reset();  // Task 소멸
timer.tick();  // "Task 만료됨"

타이머가 shared_ptr<Task>를 들고 있다면 작업을 취소하려고 원본을 놓아도 타이머가 붙잡고 있어서 작업이 계속 실행됩니다. weak_ptr로 두면 “작업의 수명은 작업을 만든 쪽이 결정하고, 타이머는 살아 있을 때만 실행한다”는 관계가 코드에 그대로 드러납니다. 비동기 콜백에서 this를 캡처하는 코드에서도 같은 패턴이 매우 중요합니다. 네트워크 요청이 끝나기 전에 객체가 소멸하면 콜백이 해제된 this를 건드려 크래시가 나는데, [weak = weak_from_this()] { if (auto self = weak.lock()) self->onDone(); }처럼 약한 참조를 캡처하면 객체가 사라진 경우 콜백이 조용히 아무 일도 하지 않습니다. Boost.Asio 같은 비동기 라이브러리를 쓸 때 가장 흔히 보는 크래시 원인이 이 수명 문제입니다.

패턴 2: 이벤트 리스너

class EventManager {
    std::vector<std::weak_ptr<Listener>> listeners_;
    
public:
    void addListener(std::shared_ptr<Listener> listener) {
        listeners_.push_back(listener);
    }
    
    void notify(const Event& event) {
        // 만료된 리스너 제거
        listeners_.erase(
            std::remove_if(listeners_.begin(), listeners_.end(),
                [](const std::weak_ptr<Listener>& weak) { return weak.expired(); }),
            listeners_.end()
        );
        
        // 알림
        for (auto& weak : listeners_) {
            if (auto listener = weak.lock()) {
                listener->onEvent(event);
            }
        }
    }
};

// 사용
EventManager manager;
{
    auto listener = std::make_shared<MyListener>();
    manager.addListener(listener);
    manager.notify(event);  // 알림 받음
}
// listener 소멸
manager.notify(event);  // 자동으로 제거됨

패턴 3: 공유 리소스 풀

class ResourcePool {
    // 풀이 리소스를 소유해야 재사용할 수 있음 (weak_ptr만 들면 반납 즉시 소멸)
    std::vector<std::shared_ptr<Resource>> pool_;

public:
    std::shared_ptr<Resource> acquire() {
        // 풀 외에는 아무도 쓰지 않는 리소스 찾기
        for (auto& resource : pool_) {
            if (resource.use_count() == 1) {
                return resource;  // 재사용
            }
        }

        // 새로 생성
        auto resource = std::make_shared<Resource>();
        pool_.push_back(resource);
        return resource;
    }
};

이 패턴은 이전 버전에서 풀이 weak_ptr만 들고 있었는데, 그 구조로는 리소스를 재사용할 수 없습니다. 사용자가 리소스를 반납(마지막 shared_ptr 해제)하는 순간 강한 참조가 0이 되어 리소스가 소멸하고, 풀에는 만료된 weak_ptr만 남기 때문입니다. lock()에 성공했다면 다른 누군가가 아직 쓰고 있다는 뜻이라 use_count() == 1인 경우는 사실상 나오지 않고, 결국 매번 새 리소스를 만들게 됩니다. 재사용을 원한다면 위처럼 풀이 shared_ptr로 소유하고 “풀 외의 사용자가 없는가”(use_count() == 1)를 검사해야 합니다.

다만 이 수정 버전도 단일 스레드용입니다. use_count()를 확인한 직후 다른 스레드가 같은 리소스를 가져갈 수 있으므로, 멀티스레드 풀이라면 “사용 중” 플래그를 뮤텍스로 보호하거나, 커스텀 삭제자를 가진 shared_ptr/unique_ptr을 반환해 사용자가 놓을 때 풀의 빈 목록으로 되돌려 넣는 방식이 더 견고합니다. 이 글의 주제와 연결해 정리하면, weak_ptr는 소유하지 않고 관찰만 하는 곳에 쓰는 도구이고, 리소스 풀처럼 수명을 붙잡아 두는 것이 목적인 곳에는 맞지 않습니다.

FAQ

Q1: weak_ptr은 언제 사용하나요?

A:

  • 순환 참조 방지: 부모-자식, 양방향 링크
  • 캐시: 객체가 사용 중일 때만 유지
  • 관찰자 패턴: 관찰자 소멸 시 자동 제거
  • 역참조: 소유하지 않고 참조만
// 순환 참조 방지
class Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak_ptr
};

Q2: weak_ptr은 참조 카운트를 증가시키나요?

A: 아니요. 관찰만 하고 참조 카운트를 증가시키지 않습니다.

auto shared = std::make_shared<int>(10);
std::cout << shared.use_count() << '\n';  // 1

std::weak_ptr<int> weak = shared;
std::cout << shared.use_count() << '\n';  // 1 (증가 안함)

Q3: weak_ptr은 어떻게 사용하나요?

A: lock() 으로 shared_ptr을 얻어 사용합니다.

std::weak_ptr<int> weak;

{
    auto shared = std::make_shared<int>(10);
    weak = shared;
    
    if (auto ptr = weak.lock()) {
        std::cout << *ptr << '\n';  // 10
    }
}

// shared 소멸 후
if (auto ptr = weak.lock()) {
    // 실행 안 됨
} else {
    std::cout << "만료됨\n";
}

Q4: 만료 확인은 어떻게 하나요?

A: expired() 또는 lock() 을 사용합니다. lock()이 더 안전합니다.

// ❌ expired 후 lock: 경쟁 조건
if (!weak.expired()) {
    auto ptr = weak.lock();  // 사이에 만료될 수 있음
}

// ✅ lock만 사용: 안전
if (auto ptr = weak.lock()) {
    // ptr이 유효함을 보장
}

Q5: weak_ptr의 성능은?

A: shared_ptr과 동일한 제어 블록을 사용합니다. lock() 호출 시 원자적 연산이 필요합니다.

// weak_ptr 생성: O(1)
std::weak_ptr<int> weak = shared;

// lock(): 원자적 연산
auto ptr = weak.lock();

lock()은 강한 참조 카운트가 0이 아닌지 확인하면서 1을 증가시키는 작업을 원자적으로 해야 하므로, 대부분의 구현에서 compare-and-swap 반복으로 동작합니다. 단일 스레드에서는 비용이 매우 작지만, 여러 스레드가 같은 객체를 동시에 lock()하면 같은 캐시 라인을 두고 경쟁하게 되어 느려질 수 있습니다. 알림 루프처럼 호출 빈도가 높은 곳이라면 lock()으로 얻은 shared_ptr를 반복문 밖에서 한 번만 얻어 재사용하는 편이 좋습니다.

Q6: weak_ptr은 nullptr을 가질 수 있나요?

A: 가능합니다. 기본 생성 시 비어있습니다.

std::weak_ptr<int> weak;  // 비어있음

if (weak.expired()) {
    std::cout << "비어있음\n";
}

Q7: weak_ptr의 크기는?

A: shared_ptr과 동일합니다. 제어 블록 포인터를 저장합니다.

std::cout << sizeof(std::weak_ptr<int>) << '\n';     // 16 (64비트)
std::cout << sizeof(std::shared_ptr<int>) << '\n';   // 16 (64비트)

Q8: weak_ptr 학습 리소스는?

A:

관련 글: 스마트 포인터, weak_ptr 상세, 순환 참조, 메모리 누수.

weak_ptr은 shared_ptr을 관찰하되 참조 카운트를 증가시키지 않는 C++11 스마트 포인터입니다.


같이 보면 좋은 글