순환 참조의 본질
shared_ptr는 C++에서 메모리 관리를 자동화하는 강력한 도구이지만, 서로를 가리키는 구조에서는 치명적인 약점을 드러낸다. 두 객체가 서로를 shared_ptr로 참조하면 참조 카운트가 영원히 0에 도달하지 못해 메모리 누수가 발생한다. 이를 해결하려면 weak_ptr을 활용해 소유권과 참조를 분리해야 한다.
weak_ptr의 설계 의도
weak_ptr은 객체의 생 여부를 관찰할 수는 있지만, 생명주기에 영향을 주지 않는다. 즉, 참조 카운트를 증가시키지 않는다. 실제 접근이 필요할 때 lock() 멤버 함수로 임시 shared_ptr을 획득하며, 객체가 이미 소멸된 경우에는 빈 포인터를 반환한다.
lock()의 동작 메커니즘
lock()은 원자적 연산으로 weak_ptr을 shared_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;
}위 코드에서 Branch는 Leaf를 shared_ptr로 소유하고, Leaf는 Branch를 weak_ptr로 참조한다. Branch가 소멸되면 Leaf의 lock()은 실패하여 안전하게 상태를 감지한다.
다중 스레드 환경에서의 주의사항
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_ptr과 lock()의 조합은 메모리 안전성을 보장하는 최소한의 메커니즘이다.