weak_ptr와 lock으로 순환 참조 해결하기

순환 참조의 본질

shared_ptr는 C++에서 메모리 관리를 자동화하는 강력한 도구이지만, 서로를 가리키는 구조에서는 치명적인 약점을 드러낸다. 두 객체가 서로를 shared_ptr로 참조하면 참조 카운트가 영원히 0에 도달하지 못해 메모리 누수가 발생한다. 이를 해결하려면 weak_ptr을 활용해 소유권과 참조를 분리해야 한다.

weak_ptr의 설계 의도

weak_ptr은 객체의 생 여부를 관찰할 수는 있지만, 생명주기에 영향을 주지 않는다. 즉, 참조 카운트를 증가시키지 않는다. 실제 접근이 필요할 때 lock() 멤버 함수로 임시 shared_ptr을 획득하며, 객체가 이미 소멸된 경우에는 빈 포인터를 반환한다.

lock()의 동작 메커니즘

lock()은 원자적 연산으로 weak_ptrshared_ptr로 승격한다. 이 과정에서 객체의 소멸과 동시에 호출되는 것을 방지하므로, 다중 스레드 환경에서도 안전하다. 성능 측면에서 대부분의 구현은 락-프리 알고리즘을 채택해 오버헤드를 최소화한다.

실전 적용 패턴

계층 구조에서 자식 노드가 부모를 참조할 때, 또는 옵저버 패턴에서 주제(subject)와 구독자 간의 관계를 설정할 때 weak_ptr이 필수적이다. 다음 예시는 트리 구조에서의 전형적인 활용법을 보여준다.

#include <memory>
#include <iostream>
#include <vector>

class Branch;

class Leaf {
public:
    explicit Leaf(std::string name) : label(std::move(name)) {}
    
    void attach_to(const std::shared_ptr<Branch>& trunk) {
        // weak_ptr로 저장하여 순환 참조 방지
        stem = trunk;
    }
    
    void inspect() const {
        auto locked = stem.lock();
        if (locked) {
            std::cout << label << " 연결됨\n";
        } else {
            std::cout << label << " 고아 상태\n";
        }
    }
    
private:
    std::string label;
    std::weak_ptr<Branch> stem;
};

class Branch : public std::enable_shared_from_this<Branch> {
public:
    explicit Branch(std::string name) : id(std::move(name)) {}
    
    std::shared_ptr<Leaf> sprout(const std::string& leaf_name) {
        auto needle = std::make_shared<Leaf>(leaf_name);
        needle->attach_to(shared_from_this());
        foliage.push_back(needle);
        return needle;
    }
    
    ~Branch() {
        std::cout << id << " 소멸\n";
    }
    
private:
    std::string id;
    std::vector<std::shared_ptr<Leaf>> foliage;
};

int main() {
    auto trunk = std::make_shared<Branch>("메인 가지");
    auto leaf = trunk->sprout("새 잎");
    
    trunk.reset();  // Branch 소멸 시도
    leaf->inspect(); // 고아 상태 확인
    
    return 0;
}

위 코드에서 BranchLeafshared_ptr로 소유하고, LeafBranchweak_ptr로 참조한다. Branch가 소멸되면 Leaflock()은 실패하여 안전하게 상태를 감지한다.

다중 스레드 환경에서의 주의사항

lock()의 결과를 검증하기 전에 객체를 사용해서는 안 된다. 다음 패턴은 잠재적인 경쟁 조건을 피하는 안전한 코딩 관례다.

class AsyncWatcher {
public:
    void set_target(const std::shared_ptr<int>& target) {
        observed = target;
    }
    
    void on_event() {
        // 반드시 lock() 결과를 지역 변수에 저장
        if (auto handle = observed.lock()) {
            // 이 블록 내에서 handle은 유효 보장
            process(*handle);
        }
    }
    
private:
    void process(int value) {
        std::cout << "처리 중: " << value << "\n";
    }
    
    std::weak_ptr<int> observed;
};

expired()와 lock()의 차이

expired()로 먼저 검증 후 lock()을 호출하는 것은 위험하다. 두 호출 사이에 객체가 소멸될 수 있기 때문이다. 반드시 lock()의 반환값으로 직접 판단해야 한다.

// 위험한 패턴
if (!wp.expired()) {          // 객체가 살아있음 확인
    // 이 시점에서 다른 스레드가 객체 소멸 가능!
    auto sp = wp.lock();      // 무효한 포인터 획득 가능
}

// 안전한 패턴
if (auto sp = wp.lock()) {    // 원자적 검증과 획득
    // sp는 반드시 유효
}

현대 C++에서의 위치

C++17 이후 std::weak_ptr은 캐시 설계, 이벤트 시스템, 그래프 순회 등에서 표준적으로 자리 잡았다. std::shared_ptr만으로는 해결할 수 없는 복잡한 소유권 그래프를 구성할 때, weak_ptrlock()의 조합은 메모리 안전성을 보장하는 최소한의 메커니즘이다.

태그: C++ shared_ptr weak_ptr smart pointer RAII

6월 11일 22:00에 게시됨