C++ shared_ptr 순환 참조와 weak_ptr: 부모-자식·옵저버·그래프·캐시에서 끊는 법

순환 참조가 생기는 곳

shared_ptr은 마지막 소유자가 사라질 때 객체를 해제합니다. 그런데 두 객체가 서로를 shared_ptr로 가리키면, 바깥에서 둘을 모두 놓아도 서로가 서로를 붙잡고 있어 소멸자가 영원히 호출되지 않습니다. 이것이 순환 참조(circular reference)이며, 프로그램 어디에서도 접근할 수 없는 객체가 메모리에 남는 누수가 됩니다.

이 구조는 양방향 관계가 있는 곳이면 어디서나 생깁니다. 캐릭터와 길드가 서로를 가리키는 게임 서버, 부모와 자식이 서로를 가리키는 DOM·AST·설정 트리, 채팅방(Room)과 참가자 세션(Session)이 서로를 들고 있는 채팅 서버, 노드가 이웃을 shared_ptr 벡터로 갖는 무방향 그래프가 대표적입니다. 채팅 서버라면 모든 사용자가 나간 뒤에도 Room 소멸자가 호출되지 않고, 입퇴장을 반복할수록 RSS가 계단식으로 올라가는 식으로 드러납니다.

순환과 비슷해 보이지만 원인이 다른 경우도 있습니다. 리소스 캐시가 unordered_map<string, shared_ptr<Texture>>로 텍스처를 들고 있으면, 아무도 쓰지 않는 텍스처도 캐시가 소유하므로 해제되지 않습니다. 이벤트 버스가 구독자를 shared_ptr로 보관하면 닫힌 위젯이 구독 목록 때문에 살아 있습니다. 둘 다 “소유할 필요가 없는 쪽이 소유하고 있다”는 점에서 같은 해법, 즉 weak_ptr로 풀립니다.

// ❌ 순환 참조로 메모리 누수
struct Character;
struct Guild {
    std::vector<std::shared_ptr<Character>> members;  // 길드가 캐릭터 소유
};
struct Character {
    std::shared_ptr<Guild> guild;  // 캐릭터가 길드 소유
};
// 둘 다 shared_ptr → 서로가 서로를 소유 → 참조 카운트가 0이 되지 않음

weak_ptr는 shared_ptr가 관리하는 객체를 가리키되 강한 참조 카운트를 올리지 않는 포인터입니다. 객체가 살아 있는 동안에만 lock()으로 접근할 수 있고, 객체의 수명에는 영향을 주지 않습니다.

shared_ptr과 참조 카운트

shared_ptr은 객체와 별도로 제어 블록(control block)을 두고, 그 안에 강한 참조 카운트(use count)와 약한 참조 카운트(weak count)를 유지합니다. shared_ptr을 복사하면 use count가 1 늘고, 소멸하거나 reset()하면 1 줄며, 0이 되는 순간 객체가 delete됩니다. 두 객체가 서로를 shared_ptr로 가리키면 각자의 use count가 상대방 때문에 최소 1로 남으므로 0이 될 수 없습니다.


순환 참조와 메모리 누수

순환 참조 다이어그램

flowchart LR
    subgraph circular["순환 참조 (shared_ptr만 사용)"]
        A1[A] -->|shared_ptr| B1[B]
        B1 -->|shared_ptr| A1
        A1 -.->|ref_count: 2| A1
        B1 -.->|ref_count: 2| B1
    end
flowchart TB
    subgraph refcount[참조 카운트 흐름]
        M[main: pa, pb] --> A[A 객체]
        M --> B[B 객체]
        A -->|pa->b = pb| B
        B -->|pb->a = pa| A
    end
    note["main에서 pa, pb 스코프 종료 시\n각각 카운트 -1\n→ A: 1 (B가 가리킴)\n→ B: 1 (A가 가리킴)\n→ 절대 0 안 됨!"]

순환 참조 재현

#include <memory>
#include <iostream>
struct B;
struct A {
    std::shared_ptr<B> b;
    ~A() { std::cout << "A 소멸\n"; }
};
struct B {
    std::shared_ptr<A> a;
    ~B() { std::cout << "B 소멸\n"; }
};
int main() {
    auto pa = std::make_shared<A>();
    auto pb = std::make_shared<B>();
    pa->b = pb;   // A가 B를 소유
    pb->a = pa;   // B가 A를 소유 → 순환!
    std::cout << "A ref_count: " << pa.use_count() << "\n";  // 2
    std::cout << "B ref_count: " << pb.use_count() << "\n";  // 2
}  // pa, pb 스코프 종료 → 카운트 -1씩 → A:1, B:1 → 소멸자 호출 안 됨!

실행 결과:

A ref_count: 2
B ref_count: 2

소멸자의 “A 소멸”, “B 소멸”이 출력되지 않습니다. main이 끝나 지역 변수 pa, pb가 사라져도 각 객체의 use count는 1로 남기 때문입니다.

weak_ptr로 순환 끊기

flowchart LR
    subgraph fixed[weak_ptr로 순환 끊기]
        A2[A] -->|shared_ptr| B2[B]
        B2 -.->|weak_ptr| A2
        note2["B→A는 카운트 올리지 않음\n→ A ref_count: 1만 유지"]
    end

한쪽을 weak_ptr로 바꾸면 그 방향으로는 use count가 올라가지 않아 순환이 끊깁니다. 이중 연결 리스트로 보면 다음과 같습니다.

// ❌ 순환 참조 → 메모리 누수
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // next와 prev가 서로를 소유
};

// ✅ 역방향만 weak_ptr → 순환 끊김
struct NodeFixed {
    std::shared_ptr<NodeFixed> next;   // 정방향: 소유
    std::weak_ptr<NodeFixed> prev;     // 역방향: 참조만
};
auto n1 = std::make_shared<NodeFixed>();
auto n2 = std::make_shared<NodeFixed>();
n1->next = n2;
n2->prev = n1;  // prev는 카운트를 올리지 않음 → n1 카운트 1 유지
// n1이 해제되면 n1->next가 사라지고 → n2 카운트 0 → n2도 해제

어느 쪽을 weak로 둘지는 “이 객체가 저 객체를 소유하는가?”를 묻고 정합니다. 리스트에서 next는 다음 노드를 소유하고 prev는 이전 노드를 참조만 하므로 prev를 weak로 둡니다.


weak_ptr 기본 사용법

weak_ptr로는 객체에 직접 접근할 수 없습니다. 접근하려면 두 함수 중 하나를 씁니다.

  • lock(): 객체가 아직 살아 있으면 그 객체를 가리키는 shared_ptr을 반환하고, 이미 해제됐으면 빈 shared_ptr을 반환합니다.
  • expired(): 객체가 이미 해제됐는지(use_count() == 0) 여부만 알려 줍니다.
std::weak_ptr<Guild> my_guild = character->getGuildWeak();
if (auto g = my_guild.lock()) {
    // 길드가 아직 살아 있음 → g로 접근
    g->sendMessage("hello");
} else {
    // 이미 해체됨 → "길드 없음" 처리
}

if (auto g = my_guild.lock())처럼 조건문 안에서 받으면 g가 유효할 때만 블록에 들어가고, g가 블록 끝까지 객체를 붙잡아 주므로 사용 도중 해제될 걱정도 없습니다.

struct B;
struct A {
    std::shared_ptr<B> b;
};
struct B {
    std::weak_ptr<A> a;  // A를 소유하지 않음 → 순환 끊김
};
auto pa = std::make_shared<A>();
auto pb = std::make_shared<B>();
pa->b = pb;
pb->a = pa;  // weak_ptr이므로 A의 참조 카운트는 1 (pa만)
// pa를 버리면 A 카운트 0 → A 해제 → A의 멤버 b가 사라져 B 카운트 감소

B가 A를 weak_ptr로만 가리키므로 A의 소유권은 B에게 없습니다. pa가 사라지면 A가 해제되고, 그와 함께 A의 멤버 b가 사라지면서 B의 카운트도 줄어 pb까지 사라지면 B도 해제됩니다.

캐릭터와 길드 예제

#include <memory>
#include <iostream>
#include <vector>
#include <string>
struct Guild;
struct Character {
    std::string name;
    std::weak_ptr<Guild> guild;  // ✅ weak_ptr: 길드를 소유하지 않음
    ~Character() { std::cout << "  Character '" << name << "' 소멸\n"; }
};
struct Guild {
    std::string name;
    std::vector<std::shared_ptr<Character>> members;  // 길드가 멤버 소유
    ~Guild() { std::cout << "Guild '" << name << "' 소멸\n"; }
};
int main() {
    auto guild = std::make_shared<Guild>();
    guild->name = "용맹의 길드";
    auto c1 = std::make_shared<Character>();
    c1->name = "전사";
    c1->guild = guild;
    guild->members.push_back(c1);
    auto c2 = std::make_shared<Character>();
    c2->name = "마법사";
    c2->guild = guild;
    guild->members.push_back(c2);
    std::cout << "guild use_count: " << guild.use_count() << "\n";  // 1 (지역 변수 guild만)
    if (auto g = c1->guild.lock()) {
        std::cout << c1->name << "의 길드: " << g->name << "\n";
    }
    std::cout << "--- 스코프 종료 ---\n";
}

실행 결과:

guild use_count: 1
전사의 길드: 용맹의 길드
--- 스코프 종료 ---
Guild '용맹의 길드' 소멸
  Character '전사' 소멸
  Character '마법사' 소멸

Character::guild가 weak_ptr이므로 길드의 use count는 지역 변수 guild 하나뿐입니다. 스코프가 끝나면 지역 변수는 선언 역순으로 파괴되어 c2, c1이 먼저 사라지지만, 이때는 members가 아직 붙잡고 있어 캐릭터는 소멸하지 않습니다. 마지막으로 guild가 0이 되면 ~Guild가 실행되고, 멤버인 members 벡터가 정리되면서 캐릭터들도 소멸합니다.


lock()과 expired() 자세히

lock(): 유효한 shared_ptr 얻기

#include <memory>
#include <iostream>
#include <string>
struct Guild {
    std::string name;
    void sendMessage(const std::string& msg) {
        std::cout << "[" << name << "] " << msg << "\n";
    }
};
struct Character {
    std::weak_ptr<Guild> guild;
};
int main() {
    auto guild = std::make_shared<Guild>();
    guild->name = "용맹의 길드";
    Character c;
    c.guild = guild;
    if (auto g = c.guild.lock()) {
        g->sendMessage("hello");  // [용맹의 길드] hello
    } else {
        std::cout << "길드가 해체되었습니다.\n";
    }
    guild.reset();  // 길드 해제
    if (auto g = c.guild.lock()) {
        g->sendMessage("hello");  // 실행 안 됨
    } else {
        std::cout << "길드가 해체되었습니다.\n";  // 이쪽으로 진입
    }
}

expired(): 만료 여부만 확인

객체에 접근할 필요 없이 이미 해제됐는지만 확인할 때 씁니다.

void notifyMembers(const std::vector<std::weak_ptr<Character>>& members) {
    for (const auto& w : members) {
        if (auto c = w.lock()) {
            c->receiveNotification("공지사항");
        } else {
            std::cout << "멤버 이미 퇴장\n";
        }
    }
}

expired()가 false를 돌려줘도 다음 줄에서 lock()을 호출하기 전에 다른 스레드가 마지막 shared_ptr을 놓을 수 있습니다. 그래서 접근이 목적이라면 위처럼 lock() 결과만으로 판단합니다.

// ❌ 위험: expired() 체크 후 lock() 사이에 객체가 해제될 수 있음
if (!w.expired()) {
    auto p = w.lock();  // p가 비어 있을 수 있음
    p->doWork();
}
// ✅ 안전: lock() 결과만으로 판단
if (auto p = w.lock()) {
    p->doWork();  // p가 살아 있는 동안 객체도 유효
}

expired()는 만료된 항목을 목록에서 걸러 내는 정리 작업처럼, 틀려도 다음 번에 다시 확인하면 되는 곳에 씁니다.

use_count()

auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
std::cout << "use_count: " << wp.use_count() << "\n";  // 1
sp.reset();
std::cout << "expired: " << wp.expired() << "\n";      // 1 (true)

weak_ptr::use_count()는 같은 객체를 가리키는 shared_ptr의 개수입니다. 멀티스레드 환경에서는 읽는 순간 이미 바뀌었을 수 있으므로 디버깅과 로그 용도로만 씁니다.

lock()과 expired()를 함께 쓰는 예제

#include <memory>
#include <iostream>
#include <vector>
#include <string>
struct Player {
    std::string name;
    Player(const std::string& n) : name(n) {}
    ~Player() { std::cout << "  Player '" << name << "' 소멸\n"; }
};
int main() {
    std::vector<std::weak_ptr<Player>> list;
    auto p1 = std::make_shared<Player>("알리스");
    auto p2 = std::make_shared<Player>("밥");
    list.push_back(p1);
    list.push_back(p2);
    for (size_t i = 0; i < list.size(); ++i) {
        if (auto p = list[i].lock())
            std::cout << "[" << i << "] " << p->name << " (유효)\n";
        else
            std::cout << "[" << i << "] (만료)\n";
    }
    p1.reset();  // 알리스의 마지막 shared_ptr → 여기서 소멸
    std::cout << "p1 해제 후 list[0].expired(): " << list[0].expired() << "\n";
    if (auto p = list[0].lock())
        std::cout << "[0] " << p->name << "\n";
    else
        std::cout << "[0] (만료됨)\n";
    if (auto p = list[1].lock())
        std::cout << "[1] " << p->name << " (유효)\n";
}

실행 결과:

[0] 알리스 (유효)
[1] 밥 (유효)
  Player '알리스' 소멸
p1 해제 후 list[0].expired(): 1
[0] (만료됨)
[1] 밥 (유효)
  Player '밥' 소멸

p1.reset()이 알리스의 유일한 shared_ptr을 놓는 순간 소멸자가 실행되므로 “알리스 소멸”은 reset() 직후에 찍힙니다. list는 weak_ptr만 보관하므로 플레이어의 수명에 영향을 주지 않으며, 밥은 main이 끝나 p2가 사라질 때 소멸합니다.


weak_ptr 사용 패턴: 옵저버·캐시·트리·그래프

옵저버 패턴

발행자(Subject)가 구독자(Observer)를 shared_ptr로 보관하면, 구독자가 화면을 닫는 등 할 일이 끝나도 발행자 때문에 살아 있습니다. 구독자 목록을 weak_ptr로 유지하면 이미 소멸한 구독자는 자연스럽게 걸러집니다.

#include <memory>
#include <vector>
#include <algorithm>
#include <iostream>
#include <string>
struct Observer {
    std::string name;
    virtual void onEvent(const std::string& msg) {
        std::cout << "[" << name << "] 이벤트 수신: " << msg << "\n";
    }
    virtual ~Observer() { std::cout << "Observer '" << name << "' 소멸\n"; }
};
struct Subject {
    std::vector<std::weak_ptr<Observer>> observers;
    void subscribe(std::shared_ptr<Observer> o) {
        observers.push_back(o);
    }
    void notify(const std::string& msg) {
        // 만료된 구독자 제거
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                 [](const auto& w) { return w.expired(); }),
            observers.end()
        );
        for (auto& w : observers) {
            if (auto o = w.lock()) {
                o->onEvent(msg);
            }
        }
    }
};
int main() {
    auto subject = std::make_shared<Subject>();
    auto obs1 = std::make_shared<Observer>();
    obs1->name = "구독자1";
    subject->subscribe(obs1);
    {
        auto obs2 = std::make_shared<Observer>();
        obs2->name = "구독자2";
        subject->subscribe(obs2);
        subject->notify("첫 공지");  // 둘 다 수신
    }  // obs2 스코프 종료 → 소멸
    subject->notify("두 번째 공지");  // 구독자1만 수신
}

실행 결과:

[구독자1] 이벤트 수신: 첫 공지
[구독자2] 이벤트 수신: 첫 공지
Observer '구독자2' 소멸
[구독자1] 이벤트 수신: 두 번째 공지
Observer '구독자1' 소멸

Subject가 구독자를 weak_ptr로 보관하므로 obs2가 스코프를 벗어나면 바로 소멸합니다. 마지막 줄은 main이 끝나 obs1이 사라질 때 찍힙니다.

여기서 expired()로 걸러 내는 단계는 정리일 뿐이고, 실제 안전장치는 두 번째 루프의 lock() 검사입니다. 정리와 알림 사이에 다른 스레드에서 구독자가 소멸할 수 있기 때문입니다. 또 onEvent() 안에서 새 구독자를 subscribe하면 observers 벡터가 재할당되어 순회 중인 반복자가 무효화됩니다. 알림 도중 목록이 바뀔 수 있는 구조라면 auto snapshot = observers;로 복사본을 만든 뒤 복사본을 순회하는 편이 안전합니다.

캐시 패턴

캐시가 리소스를 weak_ptr로 들고 있으면, 외부에서 더 이상 쓰지 않을 때 리소스가 자동으로 해제되고, 쓰는 동안에는 같은 리소스를 여러 곳이 공유합니다.

#include <memory>
#include <unordered_map>
#include <string>
#include <iostream>
struct Texture {
    std::string path;
    Texture(const std::string& p) : path(p) {
        std::cout << "  Texture 로드: " << path << "\n";
    }
    ~Texture() { std::cout << "  Texture 해제: " << path << "\n"; }
};
class TextureCache {
    std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
public:
    std::shared_ptr<Texture> get(const std::string& path) {
        auto it = cache_.find(path);
        if (it != cache_.end()) {
            if (auto tex = it->second.lock()) {
                std::cout << "캐시 히트: " << path << "\n";
                return tex;
            }
            cache_.erase(it);  // 만료된 항목 제거
        }
        std::cout << "캐시 미스, 로드: " << path << "\n";
        auto tex = std::make_shared<Texture>(path);
        cache_[path] = tex;
        return tex;
    }
};
int main() {
    TextureCache cache;
    {
        auto tex1 = cache.get("grass.png");   // 캐시 미스 → 로드
        auto tex2 = cache.get("grass.png");   // 캐시 히트
    }  // tex1, tex2 해제 → Texture "grass.png" 소멸
    auto tex3 = cache.get("grass.png");  // weak_ptr만 남아 만료 → 다시 로드
}

실행 결과:

캐시 미스, 로드: grass.png
  Texture 로드: grass.png
캐시 히트: grass.png
  Texture 해제: grass.png
캐시 미스, 로드: grass.png
  Texture 로드: grass.png
  Texture 해제: grass.png

tex1과 tex2가 사라지면 grass.png의 use count가 0이 되어 텍스처가 해제됩니다. 이후 get은 만료된 weak_ptr만 찾으므로 새로 로드합니다. 마지막 줄은 main이 끝나 tex3가 사라질 때 찍힙니다.

weak_ptr 캐시에는 반대 방향의 함정도 있습니다. 같은 텍스처를 쓰는 화면이 잠깐 닫혔다 열리면 그 사이에 참조가 0이 되어 디스크에서 다시 로드합니다. 로딩이 비싼 리소스라면 최근에 쓴 일정 개수는 shared_ptr로 붙잡아 두는 LRU와 나머지를 weak_ptr로 두는 2단 구조를 써야 반복 로딩을 피할 수 있습니다. 순수 weak_ptr 캐시는 “사용 중인 것끼리 공유”를 보장할 뿐, 재사용 비용을 줄여 주는 전통적인 의미의 캐시는 아닙니다.

부모-자식 트리 (DOM, AST, 설정 트리)

부모가 자식을 shared_ptr로 소유하고 자식도 부모를 shared_ptr로 가리키면 트리의 모든 간선이 순환이 됩니다. 큰 문서를 파싱한 뒤 루트를 버려도 노드가 하나도 해제되지 않습니다.

#include <memory>
#include <vector>
#include <iostream>
#include <string>
struct TreeNode {
    std::string name;
    std::weak_ptr<TreeNode> parent;                  // ✅ 부모는 소유하지 않음
    std::vector<std::shared_ptr<TreeNode>> children; // 자식은 소유
    explicit TreeNode(const std::string& n) : name(n) {}
    ~TreeNode() { std::cout << "TreeNode '" << name << "' 소멸\n"; }
};
int main() {
    auto root = std::make_shared<TreeNode>("root");
    auto child = std::make_shared<TreeNode>("child");
    root->children.push_back(child);
    child->parent = root;  // weak_ptr → root use_count는 1 유지
    std::cout << "root use_count: " << root.use_count() << "\n";   // 1
    std::cout << "child use_count: " << child.use_count() << "\n"; // 2 (child + root->children)
    if (auto p = child->parent.lock()) {
        std::cout << "child의 부모: " << p->name << "\n";
    }
}

실행 결과:

root use_count: 1
child use_count: 2
child의 부모: root
TreeNode 'root' 소멸
TreeNode 'child' 소멸

지역 변수는 선언 역순으로 파괴되므로 child가 먼저 참조를 놓지만(카운트 2→1), 이때는 root->children이 아직 잡고 있어 소멸하지 않습니다. 이어서 root가 0이 되면 ~TreeNode("root")가 실행되고, 그 멤버인 children이 정리되면서 child도 소멸합니다. parent를 shared_ptr로 바꿔 보면 두 소멸자 출력이 모두 사라집니다.

부모가 자식을 추가하면서 자기 자신을 넘겨야 한다면 enable_shared_from_this를 상속합니다. C++17부터는 weak_from_this()로 바로 weak_ptr을 얻을 수 있습니다.

struct TreeNode : std::enable_shared_from_this<TreeNode> {
    std::weak_ptr<TreeNode> parent;
    std::vector<std::shared_ptr<TreeNode>> children;
    void addChild(std::shared_ptr<TreeNode> child) {
        child->parent = weak_from_this();   // C++14 이하: shared_from_this()
        children.push_back(std::move(child));
    }
};

트리가 매우 깊다면 루트를 해제할 때 소멸자가 재귀적으로 연쇄 호출되어 스택 오버플로가 날 수 있습니다. 한 줄로 길게 이어진 AST처럼 깊이가 수십만 단계에 이를 수 있는 구조라면, 소멸자에서 자식을 반복문으로 떼어 내 해제하는 방식을 고려합니다.

그래프 노드

그래프는 트리와 달리 소유 방향이 자연스럽게 정해지지 않습니다. 이웃을 모두 shared_ptr로 들면 무방향 엣지마다 순환이 생깁니다.

struct Node {
    int id;
    std::vector<std::shared_ptr<Node>> neighbors;  // ❌ 양방향이면 순환
};
auto a = std::make_shared<Node>(Node{1, {}});
auto b = std::make_shared<Node>(Node{2, {}});
a->neighbors.push_back(b);
b->neighbors.push_back(a);  // a↔b 순환: 스코프를 벗어나도 둘 다 해제되지 않음

해결책은 두 가지입니다.

첫째는 소유 방향을 규칙으로 정하는 것입니다. 예를 들어 “id가 작은 쪽이 큰 쪽을 소유”하도록 하면 소유 간선은 항상 작은 id에서 큰 id로만 흐르므로 순환이 생길 수 없습니다. 역방향은 weak_ptr로 둡니다.

struct Node : std::enable_shared_from_this<Node> {
    int id;
    std::vector<std::shared_ptr<Node>> owned;     // id가 큰 이웃 (소유)
    std::vector<std::weak_ptr<Node>> back_edges;  // id가 작은 이웃 (참조만)
    explicit Node(int i) : id(i) {}
    void connect(const std::shared_ptr<Node>& other) {
        if (id < other->id) {
            owned.push_back(other);
            other->back_edges.push_back(weak_from_this());
        } else {
            other->owned.push_back(shared_from_this());
            back_edges.push_back(other);
        }
    }
};

다만 이 방식에서는 그래프 전체의 수명이 “id가 작은 노드를 누가 들고 있느냐”에 달려 있고, 중간 노드만 들고 있으면 그보다 작은 id의 노드는 해제될 수 있습니다. 규칙이 코드 곳곳에 퍼지기도 쉬워서 실무에서는 다음 방식을 더 많이 씁니다.

둘째는 그래프 객체가 모든 노드를 소유하고 노드끼리는 인덱스로 참조하는 것입니다. 순환 참조가 구조적으로 생기지 않고, 노드 수명이 그래프 객체 하나로 명확해지며, 노드가 연속된 메모리에 있어 캐시 지역성도 좋아집니다.

struct Node {
    int id;
    std::vector<std::size_t> neighbor_indices;  // 소유권 없는 참조
};
struct Graph {
    std::vector<Node> nodes;  // Graph가 모든 노드를 소유
    void connect(std::size_t a, std::size_t b) {
        nodes[a].neighbor_indices.push_back(b);
        nodes[b].neighbor_indices.push_back(a);
    }
};

노드를 자주 삭제해야 한다면 인덱스가 무효화되므로, 세대(generation) 번호가 붙은 핸들이나 삭제 표시(tombstone) 방식을 함께 씁니다.


자주 하는 실수

lock() 결과를 검사하지 않음

// ❌ 위험: lock()이 빈 shared_ptr을 반환할 수 있음
std::weak_ptr<Guild> wp = getGuildWeak();
wp.lock()->sendMessage("hello");  // wp가 만료됐으면 nullptr 역참조 (UB)
// ✅ 안전
if (auto g = wp.lock()) {
    g->sendMessage("hello");
}

기본 생성된 weak_ptr이나 reset()된 weak_ptr도 lock()이 빈 shared_ptr을 반환합니다. 만료된 경우를 처리하지 않고 그대로 반환하는 함수도 같은 문제를 호출자에게 넘깁니다.

// ❌ 만료 시 빈 shared_ptr을 그대로 반환 → 호출자가 검사하지 않으면 크래시
std::shared_ptr<Config> getConfig() {
    return configWeak_.lock();
}
// ✅ 만료 시 동작을 명시
std::shared_ptr<Config> getConfig() {
    if (auto c = configWeak_.lock()) return c;
    return std::make_shared<Config>();  // 기본값 반환
}

“없을 수 있음”을 타입으로 드러내고 싶다면 필요한 값만 꺼내 std::optional로 반환하는 방법도 있습니다.

std::optional<std::string> getGuildName(const std::weak_ptr<Guild>& wp) {
    if (auto g = wp.lock()) return g->name;
    return std::nullopt;
}

weak_ptr을 shared_ptr로 직접 변환

std::weak_ptr<int> wp = sp;
std::shared_ptr<int> sp2 = wp;          // ❌ 컴파일 에러: 암시적 변환 없음
std::shared_ptr<int> sp3 = wp.lock();   // ✅ 만료 시 빈 포인터
std::shared_ptr<int> sp4(wp);           // 만료 시 std::bad_weak_ptr 예외

shared_ptr의 weak_ptr 생성자는 explicit이고, 만료된 경우 빈 포인터가 아니라 std::bad_weak_ptr 예외를 던집니다. 만료가 정상적인 상황이라면 lock()을 씁니다.

lock() 결과를 오래 보관

// ⚠️ lock() 결과를 멤버로 보관하면 weak_ptr을 쓴 의미가 사라짐
class Handler {
    std::shared_ptr<Service> service_;
public:
    void init(std::weak_ptr<Service> wp) {
        service_ = wp.lock();  // 이후 Handler가 살아 있는 한 Service도 해제되지 않음
    }
};
// ✅ 필요할 때마다 lock()
void handle(const std::weak_ptr<Service>& wp) {
    if (auto s = wp.lock()) {
        s->doWork();  // 블록이 끝나면 s가 사라져 참조 해제
    }
}

비동기 콜백에서도 같은 실수가 자주 나옵니다. 등록 시점에 lock()한 shared_ptr을 캡처하면, 콜백이 실행될 때까지 대상이 강제로 살아 있습니다. 타이머나 작업 큐가 오래 사는 경우 사실상 누수가 되므로, 캡처는 weak_ptr로 하고 콜백 안에서 lock()합니다.

// ❌ sp 캡처 → Service 수명이 콜백만큼 연장
if (auto sp = wp.lock()) scheduler.schedule([sp]() { sp->doWork(); });
// ✅ 콜백 실행 시점에 확인
scheduler.schedule([wp]() { if (auto sp = wp.lock()) sp->doWork(); });

캡처는 값으로 해야 합니다. [&wp]처럼 참조로 캡처하면 콜백이 실행될 때 wp 변수 자체가 이미 사라져 있을 수 있습니다.

스레드 안전성 오해

같은 제어 블록을 공유하는 서로 다른 weak_ptr/shared_ptr 객체에서 lock(), expired(), 복사, 소멸을 동시에 호출하는 것은 안전합니다. 참조 카운트가 원자적으로 관리되기 때문입니다. 하지만 하나의 weak_ptr 객체를 한 스레드가 대입하는 동안 다른 스레드가 읽는 것은 데이터 경쟁이고(C++20의 std::atomic<std::weak_ptr<T>>가 이 용도입니다), lock()으로 얻은 객체 자체에 대한 동시 접근은 당연히 별도의 동기화가 필요합니다.

std::weak_ptr<SharedData> wp = sharedData;
std::thread t1([wp]() { if (auto p = wp.lock()) p->modify(); });
std::thread t2([wp]() { if (auto p = wp.lock()) p->modify(); });
// lock() 자체는 안전하지만, SharedData::modify()에는 mutex 등 동기화가 필요

셋 이상의 순환

A→B→C→A처럼 셋 이상이 순환할 때는 한 군데만 weak_ptr로 바꿔도 순환이 끊깁니다. 순환을 이루는 간선 중 하나라도 카운트에 기여하지 않으면, 바깥 참조가 사라졌을 때 카운트가 0이 되는 시작점이 생기기 때문입니다.

struct A { std::shared_ptr<B> b; };
struct B { std::shared_ptr<C> c; };
struct C { std::weak_ptr<A> a; };  // A→B→C→A 중 C→A만 weak

어느 간선을 끊을지는 여기서도 소유 관계로 정합니다. 아무 간선이나 끊으면 정작 살아 있어야 할 객체가 먼저 해제될 수 있습니다.

raw 포인터에서 weak_ptr 생성

weak_ptr은 shared_ptr이 관리하는 객체에만 연결할 수 있습니다.

MyClass* raw = new MyClass();
std::weak_ptr<MyClass> wp(raw);   // ❌ 컴파일 에러: raw 포인터를 받는 생성자 없음
auto sp = std::make_shared<MyClass>();
std::weak_ptr<MyClass> wp2 = sp;  // ✅

shared_ptr(this)로 자기 자신 넘기기

멤버 함수 안에서 std::shared_ptr<T>(this)를 만들면 새 제어 블록이 생깁니다. 원래 객체를 관리하던 shared_ptr과 카운트를 공유하지 않으므로, 둘 중 먼저 0이 되는 쪽이 객체를 delete하고 나머지가 한 번 더 delete해 이중 해제가 됩니다.

// ❌ 새 제어 블록 생성 → 이중 해제
void Observer::subscribe(Subject& s) { s.add(std::shared_ptr<Observer>(this)); }
// ✅ enable_shared_from_this 상속 후
struct Observer : std::enable_shared_from_this<Observer> {
    void subscribe(Subject& s) { s.add(shared_from_this()); }
};

생성자에서 shared_from_this() 호출

shared_from_this()는 객체가 이미 shared_ptr로 관리되고 있어야 동작합니다. 생성자 실행 중에는 아직 make_shared가 객체를 내부 weak_ptr과 연결하기 전이라, C++17부터는 std::bad_weak_ptr 예외가 발생합니다(그 이전 표준에서는 정의되지 않은 동작). 자신을 등록하는 초기화는 생성 후 호출하는 init()이나 정적 팩토리 함수로 옮깁니다.

struct Widget : std::enable_shared_from_this<Widget> {
    static std::shared_ptr<Widget> create() {
        auto w = std::make_shared<Widget>();
        w->registerSelf();  // 여기서는 shared_from_this() 가능
        return w;
    }
private:
    void registerSelf();
};

설계 원칙

순환 참조는 shared_ptr끼리만 생깁니다. 소유자가 하나로 정해지는 관계(부모→자식, 컨테이너→원소)는 unique_ptr로 두고 역방향은 raw 포인터나 참조로 두면, 역방향 포인터가 부모보다 오래 살지 않는 한 순환이 구조적으로 불가능해집니다. “혹시 몰라서” shared_ptr를 쓰기 시작하면 소유 관계가 흐려지고 순환 위험이 커집니다. weak_ptr이 필요한 경우는 상대가 먼저 사라질 수 있고, 그 사실을 안전하게 확인해야 하는 관계입니다.

어느 쪽이 weak인지는 코드만 봐서는 이유가 드러나지 않으므로 주석으로 남겨 두는 편이 좋습니다.

// 부모가 자식을 소유하므로 자식→부모는 weak (순환 방지)
std::weak_ptr<TreeNode> parent;

나중에 누군가 “lock() 하기 귀찮다”며 shared_ptr로 바꾸는 순간 순환이 되살아나는데, 한 줄 주석이 그런 리팩터링을 막아 줍니다.


성능과 메모리

연산shared_ptrweak_ptr
복사use count 원자적 증가weak count 원자적 증가
소멸use count 원자적 감소weak count 원자적 감소
lock()—use count가 0이 아닐 때만 증가 (CAS 루프), shared_ptr 생성
expired()—use count 원자적 읽기

lock()은 “0이 아니면 1 증가”를 원자적으로 해야 하므로 보통 compare-and-swap 루프로 구현되며, 비용은 shared_ptr 복사와 비슷하거나 조금 더 큽니다. 크기는 shared_ptr와 weak_ptr 모두 포인터 두 개(객체 포인터와 제어 블록 포인터)입니다. 결국 성능보다는 소유 관계에 맞춰 고르는 것이 기준입니다.

메모리 측면에서 알아 둘 점은 제어 블록의 수명입니다. 객체는 use count가 0이 되면 소멸하지만, 제어 블록은 weak count까지 0이 되어야 해제됩니다. make_shared는 객체와 제어 블록을 한 덩어리로 할당하므로, 소멸자는 이미 호출됐더라도 weak_ptr이 하나라도 남아 있는 동안에는 객체가 차지하던 메모리까지 반환되지 않습니다. 큰 객체를 오래 사는 weak_ptr(캐시 항목 등)로 참조한다면 만료된 항목을 주기적으로 정리하거나, std::shared_ptr<T>(new T(...))처럼 객체와 제어 블록이 따로 할당되는 방식으로 생성하는 것을 고려합니다.

sequenceDiagram
    participant Caller
    participant weak_ptr
    participant ControlBlock
    Caller->>weak_ptr: lock()
    weak_ptr->>ControlBlock: use count 확인
    alt use count > 0
        ControlBlock->>ControlBlock: use count 증가
        weak_ptr->>Caller: shared_ptr (유효)
    else use count == 0
        weak_ptr->>Caller: 빈 shared_ptr
    end

Room-Session, 이벤트 시스템, 콜백에 weak_ptr 적용

채팅 서버 Room-Session

struct Session;
struct Room {
    std::string id;
    std::set<std::shared_ptr<Session>> participants_;  // Room이 Session을 소유
    void broadcast(const std::string& msg);
    void leave(const std::shared_ptr<Session>& s) { participants_.erase(s); }
};
struct Session {
    std::weak_ptr<Room> room_;  // ✅ Session은 Room을 소유하지 않음
    void send(const std::string& msg) {
        if (auto r = room_.lock()) r->broadcast(msg);
    }
};

Room이 참가자를 소유하고 Session은 Room을 참조만 하므로, 마지막 참가자가 나가고 방 목록에서 Room을 지우면 Room이 해제됩니다. 그래도 leave()를 호출하지 않으면 participants_가 Session을 계속 붙잡는다는 문제는 남습니다. weak_ptr은 순환을 끊어 줄 뿐, 명시적으로 빼야 하는 것을 빼지 않은 논리적 누수까지 막아 주지는 않습니다. 연결 종료 핸들러에서 예외 경로를 포함해 leave()가 항상 호출되는지를 함께 점검해야 합니다.

이벤트 시스템

#include <memory>
#include <vector>
#include <functional>
#include <algorithm>
class EventBus {
    struct Handler {
        std::weak_ptr<void> target;
        std::function<void(int)> callback;
    };
    std::vector<Handler> handlers;
public:
    template<typename T>
    void subscribe(std::shared_ptr<T> subscriber, void (T::*method)(int)) {
        std::weak_ptr<T> wp = subscriber;
        handlers.push_back({
            std::weak_ptr<void>(subscriber),
            [wp, method](int value) {
                if (auto s = wp.lock()) (s.get()->*method)(value);
            }
        });
    }
    void publish(int value) {
        handlers.erase(
            std::remove_if(handlers.begin(), handlers.end(),
                 [](const Handler& h) { return h.target.expired(); }),
            handlers.end()
        );
        for (auto& h : handlers) h.callback(value);  // 콜백 안에서 lock()으로 재확인
    }
};

weak_ptr<void>는 타입을 지운 채 만료 여부만 확인하는 용도로 쓰고, 실제 호출은 람다가 캡처한 weak_ptr<T>를 lock()해서 합니다. 구독자가 먼저 소멸해도 다음 publish에서 핸들러가 정리되므로 구독 해제를 잊어도 dangling 호출이 생기지 않습니다.

비동기 콜백과 타이머

네트워크 응답이나 타이머처럼 “객체가 아직 살아 있으면 호출하고, 없으면 무시”해야 하는 콜백에는 weak_ptr을 값으로 캡처합니다.

void asyncFetch(std::weak_ptr<Widget> widget) {
    fetchFromNetwork([widget](Response r) {
        if (auto w = widget.lock()) {
            w->onDataReceived(r);  // 위젯이 아직 있으면 업데이트
        }
        // 위젯이 이미 닫혔으면 무시
    });
}

void scheduleUpdate(std::weak_ptr<GameObject> obj, int delayMs) {
    timer.schedule(delayMs, [obj]() {
        if (auto o = obj.lock()) o->update();
    });
}

반대로 비동기 작업이 끝날 때까지 객체가 반드시 살아 있어야 한다면(예: 소켓에 쓰는 도중의 세션), 그때는 shared_from_this()로 shared_ptr을 캡처해 수명을 의도적으로 연장하는 것이 맞습니다. 핵심은 “작업이 대상의 수명을 연장해야 하는가”를 먼저 정하는 것입니다.


순환 참조 진단

flowchart TD
    A[메모리 누수 의심] --> B{스코프 종료 후 소멸자가 호출되는가?}
    B -->|예| C[순환 참조 아님 - 다른 원인 조사]
    B -->|아니오| D[use_count 확인]
    D --> E{예상보다 use_count가 큰가?}
    E -->|예| F[누가 shared_ptr을 들고 있는지 추적]
    E -->|아니오| G[양방향 참조 구조 확인]
    F --> H[소유하지 않는 쪽을 weak_ptr로 변경]
    G --> H

가장 빠른 확인법은 의심 클래스의 소멸자에 로그를 넣고, 해제되어야 할 시점에 찍히는지 보는 것입니다. 찍히지 않으면 그 시점에 use_count()를 출력해, 예상보다 몇 개 더 많은지로 범인 후보를 좁힙니다.

순환에 갇힌 객체는 프로그램 어디에서도 도달할 수 없으므로, 프로그램이 정상 종료하면 LeakSanitizer(Linux에서 -fsanitize=address에 포함)와 valgrind --leak-check=full이 누수로 보고합니다. 순환 속 블록들은 도구에 따라 direct/indirect leak(Valgrind에서는 definitely/indirectly lost)으로 나뉘어 표시됩니다. 문제는 서버처럼 종료하지 않는 프로세스입니다. 이때는 해당 기능(방 입장·퇴장, 문서 파싱 등)을 반복 실행한 뒤 정상 종료하는 재현 프로그램을 만들어 도구를 돌리는 편이 빠릅니다. 반대로 객체가 전역 컨테이너에 남아 있는 논리적 누수는 도달 가능하므로 누수로 잡히지 않습니다(Valgrind에서는 “still reachable”로만 표시).

운영 환경에서는 특정 기능을 N번 반복한 전후의 RSS(/proc/<pid>/status의 VmRSS)를 비교해 기능 단위로 범위를 좁힙니다. 반복 횟수에 비례해 선형으로 증가한다면 해제되지 않는 객체가 있다는 강한 신호입니다.

디버거로 제어 블록을 볼 수도 있습니다. GDB의 libstdc++ pretty printer는 shared_ptr을 출력할 때 use count와 weak count를 함께 보여 줍니다(LLDB + libc++에서는 __cntrl_ 멤버로 확인). 해제돼야 할 객체의 use count가 1 이상이라면, 그 객체를 가리키는 shared_ptr 멤버를 가진 클래스부터 의심하면 됩니다.


소유인가, 참조인가

shared_ptr로 둘지 weak_ptr로 둘지는 “이 객체가 없어지면 저 객체도 함께 정리돼야 하는가”로 판단합니다. 그렇다면 소유 관계이고 shared_ptr(또는 unique_ptr)이 맞습니다. 반대로 “저쪽이 있으면 쓰고, 이미 없어졌으면 무시하면 되는” 관계라면 참조이며 weak_ptr이 맞습니다.

게임의 캐릭터와 길드로 보면, 캐릭터는 자신이 속한 길드를 알아야 하지만 길드가 해체되면 “길드 없음”이 되면 그만입니다. 길드도 소속 캐릭터 목록이 필요하지만, 캐릭터가 로그아웃하면 목록에서 빠지면 됩니다. 이렇게 양쪽 모두 참조일 뿐이라면 캐릭터와 길드의 실제 소유자는 따로 있어야 합니다. 예를 들어 월드 매니저가 접속 중인 캐릭터와 존재하는 길드를 각각 shared_ptr로 소유하고, 캐릭터의 “내 길드”는 weak_ptr<Guild>, 길드의 “멤버 목록”은 weak_ptr<Character>로 두면 어느 쪽이 먼저 사라져도 상대는 lock() 실패로 알아챌 수 있습니다. 앞의 예제처럼 길드가 멤버를 소유하는 설계는 “길드가 사라지면 멤버 객체도 사라진다”는 뜻이므로, 게임 규칙이 그렇지 않다면 맞지 않습니다.

참고 자료


같이 보면 좋은 글


이전 글: C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화 다음 글: C++ mutex: 경쟁 상태, 데드락, lock_guard·scoped_lock 사용 원칙