C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]

C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]

이 글의 핵심

shared_ptr 순환 참조로 메모리 누수가 발생하는 4가지 시나리오(부모-자식, 옵저버, 그래프, 캐시). 완전한 예제 코드, 자주 하는 실수, best practice, 프로덕션 패턴까지. C++ 실전 가이드 시리즈.

들어가며: shared_ptr 순환 참조, 왜 이렇게 자주 터지나요?

실무에서 겪는 문제 시나리오

시나리오 1: 채팅 서버 Room-Session 누수

채팅 서버에서 RoomSessionshared_ptr로 보관하고, Session이 자신이 속한 Roomshared_ptr로 참조했습니다. 사용자가 채팅방을 나가도 Room과 Session이 서로를 잡고 있어 메모리가 해제되지 않았습니다. 24시간 운영 시 수천 개의 “좀비” Room이 쌓여 OOM이 발생했습니다.

  • 증상: top/htop에서 프로세스 RSS가 시간이 지날수록 증가. 채팅방 입퇴장 반복 시 메모리 선형 증가.
  • 원인 분석: Session::leave() 호출 후에도 Roomparticipants_에서 제거되지 않거나, SessionRoom을 shared_ptr로 보관해 순환 발생.
  • 해결: SessionRoomweak_ptr로 변경. RoomSession을 소유하는 단방향 구조로 설계.

시나리오 2: DOM 트리 파서 메모리 폭증

HTML 파서에서 노드가 부모와 자식을 모두 shared_ptr로 가리켰습니다. 큰 문서를 파싱한 뒤 트리를 버려도 부모↔자식 순환 때문에 노드가 해제되지 않아, 수백 MB가 누적되었습니다.

  • 증상: 10MB HTML 파싱 후 트리 해제해도 프로세스 메모리가 감소하지 않음. valgrind --leak-check=full로는 “누수 없음”으로 나옴(프로그램 종료 시 OS가 회수).
  • 원인 분석: child->parent = root로 양방향 shared_ptr. root 해제 시 children만 해제되고, childroot를 잡고 있어 root의 참조 카운트가 0이 안 됨.
  • 해결: parentweak_ptr로 변경. 부모가 자식을 소유하고, 자식은 부모를 “참조만” 하도록.

시나리오 3: 이벤트 버스 구독자 누수

이벤트 버스가 구독자를 shared_ptr<Observer>로 보관했습니다. 위젯이 닫혀도 구독 목록에 남아 있어 위젯이 영원히 해제되지 않는 문제가 발생했습니다. Subject가 Observer를 소유하고, Observer가 Subject를 shared_ptr로 참조하면서 순환이 생겼습니다.

  • 증상: 다이얼로그를 열었다 닫아도 메모리 해제 안 됨. 구독 해제(unsubscribe)를 호출해도 Subject가 shared_ptr로 보관해 수명 유지.
  • 원인 분석: Subject↔Observer 양방향 shared_ptr. 위젯(Observer)이 닫혀도 Subject의 observers 벡터가 shared_ptr을 들고 있어 위젯이 살아 있음.
  • 해결: Subject가 구독자를 weak_ptr로 보관. notify()expired() 체크 후 lock()으로 유효한 구독자만 호출.

시나리오 4: 그래프 알고리즘 노드 누수

경로 탐색용 그래프에서 노드가 이웃을 shared_ptr로 가리켰습니다. 양방향 엣지가 있으면 A↔B 순환이 생기고, 복잡한 그래프에서는 여러 노드가 서로를 참조해 대량의 메모리 누수가 발생했습니다.

  • 증상: 경로 탐색 후 그래프 객체 해제해도 노드 소멸자가 호출되지 않음. 10만 노드 그래프에서 수백 MB 누수.
  • 원인 분석: a->neighbors.push_back(b), b->neighbors.push_back(a)로 양방향 shared_ptr. A↔B, B↔C, C↔A 등 복잡한 순환.
  • 해결: 역방향을 weak_ptr로 하거나, 그래프 전체를 Graphunique_ptr로 소유하고 노드 간 참조는 인덱스/raw 포인터로.

이 글에서는 shared_ptr 순환 참조가 발생하는 4가지 전형적인 패턴(부모-자식, 옵저버, 그래프, 캐시)을 완전한 예제 코드로 다루고, 자주 하는 실수, best practice, 프로덕션 패턴까지 정리합니다.

이 글에서 다루는 것:

  • 문제 시나리오: 채팅 서버, DOM, 이벤트 버스, 그래프
  • 4가지 순환 참조 패턴: 부모-자식, 옵저버, 그래프, 캐시
  • 완전한 예제: Before/After 실행 가능 코드
  • 자주 하는 실수: use-after-free, lock 실패, 방향 선택 오류
  • best practice: 소유권 설계, lock 패턴
  • 프로덕션 패턴: Room-Session, 리소스 캐시, 이벤트 시스템

목차

  1. 순환 참조의 본질: 참조 카운트가 0이 안 되는 이유
  2. 패턴 1: 부모-자식 트리
  3. 패턴 2: 옵저버 패턴
  4. 패턴 3: 그래프 노드
  5. 패턴 4: 리소스 캐시
  6. 자주 하는 실수와 해결법
  7. best practice와 설계 원칙
  8. 프로덕션 패턴
  9. 순환 참조 진단과 디버깅
  10. 성능 고려: shared_ptr vs weak_ptr
  11. 자주 묻는 질문 (FAQ)
  12. 면접에서 이렇게 답하기
  13. 체크리스트와 정리

개념을 잡는 비유

shared_ptr자동 청소 로봇이 붙은 공유 열쇠처럼, 마지막 사람이 나가면 자원을 치웁니다. weak_ptr주소록에 적어 둔 연락처처럼 소유권은 늘리지 않고, 필요할 때만 lock()으로 실제로 연결됐는지 확인합니다.


1. 순환 참조의 본질: 참조 카운트가 0이 안 되는 이유

shared_ptr 참조 카운트 동작

shared_ptr참조 카운트를 유지합니다. 복사할 때 +1, 소멸/리셋할 때 -1. 0이 되면 객체가 해제됩니다.

flowchart LR
    subgraph normal["정상: 단방향 참조"]
        M1[main] -->|shared_ptr| A1[A]
        A1 -->|shared_ptr| B1[B]
        note1["main 해제 → A:0 → B:0 순차 해제"]
    end
flowchart LR
    subgraph circular["순환: 양방향 shared_ptr"]
        A2[A] -->|shared_ptr| B2[B]
        B2 -->|shared_ptr| A2
        note2["A ref:1(B가 가리킴)\nB ref:1(A가 가리킴)\n→ 절대 0 안 됨!"]
    end

핵심: 한쪽을 weak_ptr로 끊기

weak_ptr은 참조 카운트를 올리지 않습니다. 한쪽 방향을 weak로 바꾸면 순환이 끊깁니다.

flowchart LR
    subgraph fixed["weak_ptr로 순환 끊기"]
        A3[A] -->|shared_ptr| B3[B]
        B3 -.->|weak_ptr| A3
        note3["B→A는 카운트 안 올림\n→ A 해제 가능"]
    end

원칙: 소유권이 없는 쪽을 weak_ptr로 선택합니다. 부모가 자식을 소유하면 → 자식→부모는 weak. Subject가 구독자를 관리하면 → Observer→Subject는 weak.


2. 패턴 1: 부모-자식 트리

문제: DOM, AST, 설정 트리

부모가 자식을 shared_ptr로 소유하고, 자식이 부모를 shared_ptr로 참조하면 순환이 발생합니다.

❌ 잘못된 예: 양방향 shared_ptr

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

struct TreeNode {
    std::string name;
    std::shared_ptr<TreeNode> parent;   // ❌ 부모를 shared_ptr로
    std::vector<std::shared_ptr<TreeNode>> children;

    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;  // ❌ 순환! root ref_count: 2, child ref_count: 2

    std::cout << "root use_count: " << root.use_count() << "\n";   // 2
    std::cout << "child use_count: " << child.use_count() << "\n"; // 2
}  // 스코프 종료 → 소멸자 호출 안 됨! 메모리 누수

실행 결과:

root use_count: 2
child use_count: 2

(소멸자 “TreeNode 소멸” 출력 없음 → 메모리 누수)

✅ 올바른 예: 자식→부모는 weak_ptr

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

struct TreeNode {
    std::string name;
    std::weak_ptr<TreeNode> parent;       // ✅ 부모는 weak_ptr
    std::vector<std::shared_ptr<TreeNode>> children;

    TreeNode(const std::string& n) : name(n) {}
    ~TreeNode() { std::cout << "TreeNode '" << name << "' 소멸\n"; }

    std::shared_ptr<TreeNode> getParent() const {
        return parent.lock();
    }
};

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 ref_count: 1 유지

    std::cout << "root use_count: " << root.use_count() << "\n";   // 1
    std::cout << "child use_count: " << child.use_count() << "\n"; // 2 (root가 소유)

    if (auto p = child->getParent()) {
        std::cout << "child의 부모: " << p->name << "\n";
    }
}  // root 해제 → children 해제 → child 해제 → 정상 소멸

실행 결과:

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

enable_shared_from_this 활용 (부모가 자식 추가 시)

#include <memory>
#include <vector>
#include <algorithm>

struct TreeNode : std::enable_shared_from_this<TreeNode> {
    std::string name;
    std::weak_ptr<TreeNode> parent;
    std::vector<std::shared_ptr<TreeNode>> children;

    void addChild(std::shared_ptr<TreeNode> child) {
        child->parent = std::weak_ptr<TreeNode>(shared_from_this());
        children.push_back(std::move(child));
    }
};

설명: shared_from_this()로 현재 객체의 shared_ptr을 얻어, 자식의 parentweak_ptr로 할당합니다. 부모가 자식을 소유하고, 자식은 부모를 “참조만” 하므로 순환이 끊깁니다.


3. 패턴 2: 옵저버 패턴

문제: Subject가 Observer를 shared_ptr로 소유

Subject가 구독자를 shared_ptr<Observer>로 보관하면, Observer가 Subject를 shared_ptr로 참조할 때 순환이 발생합니다. 또한 Observer가 먼저 소멸해도 Subject가 shared_ptr로 잡고 있어 수명이 늘어납니다.

❌ 잘못된 예: 양방향 shared_ptr

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

struct Subject;
struct Observer {
    std::shared_ptr<Subject> subject;  // ❌ Subject를 shared_ptr로
    virtual void onEvent(const std::string& msg) = 0;
    virtual ~Observer() { std::cout << "Observer 소멸\n"; }
};

struct Subject {
    std::vector<std::shared_ptr<Observer>> observers;  // Observer 소유

    void subscribe(std::shared_ptr<Observer> o) {
        o->subject = std::shared_ptr<Subject>(this);  // ❌ 위험 + 순환
        observers.push_back(std::move(o));
    }
    void notify(const std::string& msg) {
        for (auto& o : observers) o->onEvent(msg);
    }
};

문제점: Subject와 Observer가 서로 shared_ptr로 참조. 또한 shared_ptr<Subject>(this)는 잘못된 사용(별도 제어 블록 생성).

✅ 올바른 예: Observer→Subject는 weak_ptr

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

struct Subject;
struct Observer {
    std::weak_ptr<Subject> subject;  // ✅ Subject는 weak_ptr
    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::enable_shared_from_this<Subject> {
    std::vector<std::weak_ptr<Observer>> observers;  // ✅ weak_ptr로 보관

    void subscribe(std::shared_ptr<Observer> o) {
        o->subject = weak_from_this();
        observers.push_back(o);
    }

    void notify(const std::string& msg) {
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                 { 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가 살려 두지 않음

    subject->notify("두 번째 공지");  // 구독자1만 수신
}

실행 결과:

[구독자1] 첫 공지
[구독자2] 첫 공지
Observer '구독자2' 소멸
[구독자1] 두 번째 공지

핵심: Subject가 구독자를 weak_ptr로 보관하므로, Observer가 먼저 소멸해도 Subject가 수명을 늘리지 않습니다. notify()expired()인 항목을 제거하고 lock()으로 유효한 구독자만 호출합니다.


4. 패턴 3: 그래프 노드

문제: 양방향 엣지가 있는 그래프

노드가 이웃을 shared_ptr로 가리키면, A↔B 같은 양방향 엣지에서 순환이 발생합니다. 복잡한 그래프에서는 여러 노드가 서로를 참조해 대량 누수가 발생합니다.

❌ 잘못된 예: 이웃을 shared_ptr로

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

struct Node {
    int id;
    std::vector<std::shared_ptr<Node>> neighbors;  // ❌ 양방향이면 순환

    Node(int i) : id(i) {}
    ~Node() { std::cout << "Node " << id << " 소멸\n"; }
};

int main() {
    auto a = std::make_shared<Node>(1);
    auto b = std::make_shared<Node>(2);
    a->neighbors.push_back(b);
    b->neighbors.push_back(a);  // ❌ 순환! a ref:2, b ref:2

    std::cout << "a use_count: " << a.use_count() << "\n";  // 2
    std::cout << "b use_count: " << b.use_count() << "\n";  // 2
}  // 소멸자 호출 안 됨! "Node 1 소멸", "Node 2 소멸" 출력 없음

실행 결과:

a use_count: 2
b use_count: 2

(소멸자 출력 없음 → 메모리 누수)

✅ 올바른 예: 역방향은 weak_ptr

그래프에서 “소유” 방향을 정합니다. 예: 노드 ID가 작은 쪽 → 큰 쪽만 shared_ptr, 역방향은 weak_ptr.

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

struct Node : std::enable_shared_from_this<Node> {
    int id;
    std::vector<std::shared_ptr<Node>> neighbors;   // "소유"하는 이웃
    std::vector<std::weak_ptr<Node>> reverse_edges;  // 역방향은 weak

    Node(int i) : id(i) {}
    ~Node() { std::cout << "Node " << id << " 소멸\n"; }

    void addBidirectional(std::shared_ptr<Node> other) {
        if (id < other->id) {
            neighbors.push_back(other);
            other->reverse_edges.push_back(shared_from_this());
        } else {
            other->neighbors.push_back(shared_from_this());
            reverse_edges.push_back(other);
        }
    }
};

더 단순한 방법: 한쪽만 weak_ptr. A→B를 shared, B→A를 weak로 두면 됩니다.

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

struct Node;
struct Edge {
    std::shared_ptr<Node> from;
    std::weak_ptr<Node> to;  // ✅ 역방향은 weak
};

struct Node : std::enable_shared_from_this<Node> {
    int id;
    std::vector<std::shared_ptr<Edge>> outgoing;

    Node(int i) : id(i) {}
    ~Node() { std::cout << "Node " << id << " 소멸\n"; }
};

int main() {
    auto a = std::make_shared<Node>(1);
    auto b = std::make_shared<Node>(2);
    auto e = std::make_shared<Edge>();
    e->from = a;
    e->to = b;
    a->outgoing.push_back(e);
    // b는 e를 소유하지 않음 → a 해제 시 e 해제 → b 해제
}

실행 결과:

Node 2 소멸
Node 1 소멸

(순서: a 해제 → outgoing 해제 → e 해제 → b 해제 → a 해제 완료)

실용적 패턴: 외부에서 그래프 소유

그래프 전체를 한 컨테이너가 소유하고, 노드 간 참조는 raw 포인터 또는 인덱스로 하는 방법입니다. 순환 참조 자체가 생기지 않습니다.

#include <memory>
#include <vector>

struct Graph {
    std::vector<std::unique_ptr<Node>> nodes;  // Graph가 모든 노드 소유
    // 노드 간 참조: 인덱스 또는 Node* (소유권 없음)
};

struct Node {
    int id;
    std::vector<size_t> neighbor_indices;  // 인덱스로 참조
};

5. 패턴 4: 리소스 캐시

문제: 캐시가 shared_ptr로 보관하면 영원히 해제 안 됨

unordered_map<string, shared_ptr<Texture>>로 캐시하면, 한 번 넣은 리소스가 절대 해제되지 않습니다. “어디서도 사용 중이 아닐 때” 해제되길 원하면 weak_ptr을 써야 합니다.

❌ 잘못된 예: shared_ptr 캐시

#include <memory>
#include <unordered_map>
#include <string>

struct Texture {
    std::string path;
    Texture(const std::string& p) : path(p) {}
};

class TextureCache {
    std::unordered_map<std::string, std::shared_ptr<Texture>> cache_;  // ❌
public:
    std::shared_ptr<Texture> get(const std::string& path) {
        auto it = cache_.find(path);
        if (it != cache_.end()) return it->second;
        auto tex = std::make_shared<Texture>(path);
        cache_[path] = tex;  // 캐시가 shared_ptr 소유 → 영원히 해제 안 됨
        return tex;
    }
};

✅ 올바른 예: 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 소멸 (캐시는 weak만 보관)

    auto tex3 = cache.get("grass.png");  // weak 만료 → 다시 로드
}

실행 결과:

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

설명: 캐시가 weak_ptr만 보관하므로, 외부 shared_ptr이 모두 사라지면 리소스가 자동으로 해제됩니다. 이후 get() 호출 시 lock()이 실패하면 캐시에서 제거하고 새로 로드합니다.


6. 자주 하는 실수와 해결법

실수 1: lock() 결과를 검사하지 않음

// ❌ 위험: lock()이 빈 shared_ptr 반환 시 UB
std::weak_ptr<Guild> wp = getGuildWeak();
wp.lock()->sendMessage("hello");  // wp 만료 시 크래시!

// ✅ 안전
if (auto g = wp.lock()) {
    g->sendMessage("hello");
}

실수 2: weak_ptr 방향 잘못 선택

소유권이 없는 쪽을 weak로 바꿔야 합니다.

// ✅ 올바름: 캐릭터가 길드를 소유하지 않음
struct Character {
    std::weak_ptr<Guild> guild;
};
struct Guild {
    std::vector<std::shared_ptr<Character>> members;  // 길드가 멤버 관리
};

// ❌ 잘못됨: 길드가 멤버를 weak로? → 캐릭터가 먼저 삭제되면 목록만 비어 있음
// 설계에 따라 다르지만, "멤버 목록 관리"는 보통 shared

실수 3: expired()만 믿고 lock() 생략

멀티스레드에서는 expired() 체크 후 lock() 사이에 객체가 해제될 수 있습니다.

// ❌ 위험
if (!w.expired()) {
    auto p = w.lock();  // p가 비어 있을 수 있음
}

// ✅ 안전: lock() 결과만 신뢰
if (auto p = w.lock()) {
    // p 유효 보장
}

실수 4: lock() 결과를 멤버로 오래 보관

// ⚠️ weak_ptr 효과 퇴색
class Handler {
    std::shared_ptr<Service> service_;  // lock() 결과를 계속 보관
public:
    void init(std::weak_ptr<Service> wp) {
        service_ = wp.lock();  // Service 수명이 Handler만큼 늘어남
    }
};

// ✅ 권장: 필요할 때마다 lock()
void handle(std::weak_ptr<Service> wp) {
    if (auto s = wp.lock()) {
        s->doWork();
    }
}

실수 5: shared_ptr(this) 사용

// ❌ 위험: 새 제어 블록 생성, 이중 해제 가능
void subscribe() {
    subject_->addObserver(std::shared_ptr<Observer>(this));
}

// ✅ enable_shared_from_this + shared_from_this()
struct Observer : std::enable_shared_from_this<Observer> {
    void subscribe(std::shared_ptr<Subject> s) {
        s->addObserver(shared_from_this());
    }
};

실수 6: 3개 이상 순환에서 모든 역방향을 weak로 바꿈

A→B→C→A 순환이어도 한 군데만 weak로 바꿔도 됩니다.

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

실수 7: enable_shared_from_this 누락

shared_from_this()를 호출하는 클래스는 반드시 enable_shared_from_this를 상속해야 합니다. 그렇지 않으면 std::bad_weak_ptr 예외가 발생합니다.

// ❌ 위험: enable_shared_from_this 없이 shared_from_this() 호출
struct BadNode {
    void addChild(std::shared_ptr<BadNode> child) {
        child->parent = shared_from_this();  // 컴파일 에러 또는 bad_weak_ptr
    }
};

// ✅ 올바름
struct GoodNode : std::enable_shared_from_this<GoodNode> {
    std::weak_ptr<GoodNode> parent;
    void addChild(std::shared_ptr<GoodNode> child) {
        child->parent = weak_from_this();
    }
};

실수 8: 생성자에서 shared_from_this() 호출

객체가 아직 shared_ptr로 관리되기 전에는 shared_from_this()를 호출할 수 없습니다. 생성자 내부에서 호출하면 bad_weak_ptr이 발생합니다.

// ❌ 위험
struct Widget : std::enable_shared_from_this<Widget> {
    Widget() {
        auto self = shared_from_this();  // bad_weak_ptr! 아직 shared_ptr로 감싸지지 않음
    }
};

// ✅ 올바름: 생성 완료 후, shared_ptr로 감싼 뒤에만 호출
auto w = std::make_shared<Widget>();
w->setup();  // setup() 내부에서 shared_from_this() 사용

실수 9: weak_ptr을 shared_ptr로 변환 후 오래 보관

비동기 콜백에서 lock() 결과를 캡처해 오래 들고 있으면, 원래 weak_ptr로 끊으려 했던 수명 관리가 무효화됩니다.

// ❌ 위험: shared_ptr 캡처로 수명 연장
void scheduleCallback(std::weak_ptr<Service> wp) {
    if (auto sp = wp.lock()) {
        scheduler.schedule([sp]() {  // sp를 캡처 → Service 수명 연장
            sp->doWork();
        });
    }
}

// ✅ 권장: 콜백 내부에서 lock()
void scheduleCallback(std::weak_ptr<Service> wp) {
    scheduler.schedule([wp]() {
        if (auto sp = wp.lock()) {
            sp->doWork();
        }
    });
}

7. best practice와 설계 원칙

원칙 1: 소유권을 먼저 정하라

“누가 누구를 소유하는가?”를 명확히 합니다. 부모가 자식을, Subject가 구독자 목록을, 캐시는 “참조만” 합니다.

원칙 2: lock() 결과는 항상 검사

if (auto p = wp.lock()) 패턴을 습관화합니다. 검사 없이 wp.lock()->foo()는 use-after-free 위험입니다.

원칙 3: expired()는 보조 수단

멀티스레드에서는 lock() 결과만 신뢰합니다. expired()는 필터나 통계용으로만 사용합니다.

원칙 4: 만료된 weak_ptr 정리

옵저버 목록, 캐시 등에서 expired()인 항목을 주기적으로 제거해 메모리와 순회 비용을 줄입니다.

원칙 5: 기본은 unique_ptr, 공유가 필요할 때만 shared_ptr

shared_ptr은 참조 카운팅 비용과 순환 참조 위험이 있으므로, “정말 공유가 필요할 때”만 사용합니다.

원칙 6: weak_ptr 사용 시 문서화

팀 협업 시 “왜 이쪽을 weak로 했는지” 주석으로 남기면, 나중에 리팩터링 시 실수를 방지합니다.

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

원칙 7: 순환 가능성 검토 체크리스트

새로 양방향 참조를 추가할 때마다 다음을 확인합니다:

  • A가 B를 shared_ptr로 가리키는가?
  • B가 A를 가리키는가? (shared_ptr이면 순환!)
  • 소유권이 없는 쪽을 weak_ptr로 했는가?

8. 프로덕션 패턴

패턴 1: 채팅 서버 Room-Session

#include <memory>
#include <set>
#include <string>

struct Session;
struct Room {
    std::string id;
    std::set<std::shared_ptr<Session>> participants_;  // Room이 Session 관리

    void join(std::shared_ptr<Session> s);
    void leave(std::shared_ptr<Session> 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이 참가자를 shared_ptr로 관리하고, Session은 자신이 속한 Room을 weak_ptr로만 참조합니다. Session이 나가도 Room이 Session을 잡고 있지 않고, Room이 삭제되면 Session의 room_.lock()이 실패해 안전하게 처리할 수 있습니다.

패턴 2: 비동기 콜백 수명 관리

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

패턴 3: 게임 캐릭터-길드 (상세 예제)

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

struct Guild;
struct Character {
    std::string name;
    std::weak_ptr<Guild> guild;  // ✅ 길드를 소유하지 않음
    ~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);

    std::cout << "guild use_count: " << guild.use_count() << "\n";  // 1

    if (auto g = c1->guild.lock()) {
        std::cout << c1->name << "의 길드: " << g->name << "\n";
    }
}  // guild 해제 → members 해제 → c1 해제 → 정상 소멸

설명: Character::guild가 weak_ptr이므로 길드의 참조 카운트를 올리지 않습니다. Guild가 members를 shared_ptr로 관리하는 단방향 소유이므로 순환이 끊깁니다.

패턴 4: 이벤트 버스 구독자

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)) {
        handlers.push_back({
            std::weak_ptr<void>(subscriber),
            [wp = std::weak_ptr<T>(subscriber), method](int v) {
                if (auto s = wp.lock()) (s.get()->*method)(v);
            }
        });
    }

    void publish(int value) {
        handlers.erase(
            std::remove_if(handlers.begin(), handlers.end(),
                 { return h.target.expired(); }),
            handlers.end()
        );
        for (auto& h : handlers) h.callback(value);
    }
};

패턴 5: 플러그인-호스트 관계

플러그인이 호스트를 참조할 때 weak_ptr를 사용하면, 호스트가 플러그인을 먼저 해제해도 안전합니다.

struct PluginHost;
struct Plugin {
    std::weak_ptr<PluginHost> host;  // ✅ 호스트를 소유하지 않음
    virtual void onLoad() = 0;
};

struct PluginHost {
    std::vector<std::shared_ptr<Plugin>> plugins;  // 호스트가 플러그인 관리

    void callHostApi() {
        for (auto& p : plugins) {
            if (auto h = p->host.lock()) {
                h->doSomething();
            }
        }
    }
};

패턴 6: 리소스 매니저 + 사용자

리소스 매니저가 리소스를 weak_ptr로 캐시하고, 사용자는 shared_ptr로 소유합니다. 사용이 끝나면 자동 해제됩니다.

class ResourceManager {
    std::unordered_map<std::string, std::weak_ptr<Resource>> cache_;

public:
    std::shared_ptr<Resource> load(const std::string& path) {
        if (auto r = cache_[path].lock()) return r;
        auto res = std::make_shared<Resource>(path);
        cache_[path] = res;
        return res;
    }

    void pruneExpired() {
        for (auto it = cache_.begin(); it != cache_.end(); ) {
            if (it->second.expired()) it = cache_.erase(it);
            else ++it;
        }
    }
};

9. 순환 참조 진단과 디버깅

진단 흐름도

순환 참조를 의심할 때 따라갈 수 있는 진단 흐름입니다.

flowchart TD
    A[메모리 누수 의심] --> B{스코프 종료 후\n소멸자 호출되는가?}
    B -->|예| C[순환 참조 아님\n다른 원인 조사]
    B -->|아니오| D[use_count 확인]
    D --> E{스코프 종료 시\nuse_count > 1?}
    E -->|예| F[다른 shared_ptr이 참조 중]
    E -->|아니오| G[양방향 참조 확인]
    F --> H[참조 경로 추적]
    G --> I[한쪽을 weak_ptr로 변경]
    H --> I

use_count()로 의심 구간 확인

shared_ptr::use_count()로 참조 카운트를 확인할 수 있습니다. 스코프를 벗어나도 카운트가 1 이상이면 다른 곳에서 참조를 잡고 있다는 뜻입니다.

void debugRefCount() {
    auto obj = std::make_shared<MyObject>();
    std::cout << "생성 직후: " << obj.use_count() << "\n";  // 1

    other->hold(obj);
    std::cout << "다른 객체가 보관 후: " << obj.use_count() << "\n";  // 2

}  // obj 스코프 종료
// use_count가 0이 되어야 정상. 1 이상이면 순환 가능성

Valgrind로 메모리 누수 확인

Valgrind는 “메모리 누수 없음”으로 나올 수 있습니다. 순환 참조는 할당된 메모리가 해제되지 않는 것이지만, 프로그램 종료 시 OS가 회수하므로 Valgrind가 “누수”로 감지하지 않을 수 있습니다. 장시간 실행 시 RSS 증가로 의심하면 됩니다.

# 메모리 누수 검사
valgrind --leak-check=full ./my_app

# 24시간 실행 후 메모리 사용량 모니터링
# top 또는 /proc/self/status의 VmRSS 확인

ASan + 수동 해제 확인

소멸자에 로그를 넣어 “객체가 해제되는 시점”을 확인합니다. 스코프를 벗어나도 소멸자가 호출되지 않으면 순환 참조를 의심합니다.

struct MyNode {
    ~MyNode() {
        std::cout << "MyNode 소멸: " << id << "\n";  // 이게 안 나오면 순환
    }
};

GDB/LLDB로 참조 경로 추적

shared_ptr의 제어 블록을 조사해 “누가 이 객체를 참조하는지” 추적할 수 있습니다. (구현체에 따라 다름)

# LLDB에서 shared_ptr 내부 확인
(lldb) p my_obj
(lldb) p my_obj.__ptr_
(lldb) p my_obj.__cntrl_  # 제어 블록 주소

프로파일링으로 누수 구간 특정

장시간 실행 시 메모리 사용량이 선형으로 증가하면, 특정 기능(채팅방 입장, 문서 파싱 등) 반복 후 메모리 차이를 비교해 의심 구간을 좁힙니다.


10. 성능 고려: shared_ptr vs weak_ptr

연산 비용

연산shared_ptrweak_ptr
복사atomic ref_count++제어 블록 접근만
소멸atomic ref_count—atomic weak_count—
lock()atomic ref_count++, shared_ptr 생성
expired()ref_count == 0 확인 (원자 연산)

lock()은 내부적으로 ref_count를 증가시키므로, shared_ptr 복사와 비슷한 비용이 듭니다. 다만 저장 자체는 shared_ptr보다 가볍습니다. 객체 수명에 영향을 주지 않기 때문입니다.

메모리 사용

  • shared_ptr: 객체 + 제어 블록 (ref_count, weak_count, deleter 등)
  • weak_ptr: 제어 블록만 참조. 객체가 해제돼도 제어 블록은 weak_ptr이 남아 있는 한 유지됩니다.

정리: “저장만 하고 가끔 접근”하는 패턴(옵저버 목록, 캐시)에서는 weak_ptr이 적합합니다.


11. 자주 묻는 질문 (FAQ)

Q. shared_ptr 순환 참조는 언제 발생하나요?

A. 두 객체가 서로를 shared_ptr로 가리킬 때 발생합니다. 부모-자식 트리, 옵저버 패턴, 그래프 노드, 캐시 등 양방향 참조가 있는 구조에서 주의해야 합니다.

Q. 순환 참조 해결은 어떻게 하나요?

A. 한쪽을 weak_ptr로 바꾸면 됩니다. 소유권이 없는 쪽(자식→부모, 옵저버→Subject, 캐시 엔트리 등)을 weak로 선택하세요.

Q. weak_ptr과 raw 포인터의 차이는?

A. raw 포인터는 “가리킨 객체가 해제됐는지” 알 수 없고, 역참조 시 미정의 동작(UB)이 발생합니다. weak_ptr은 expired()로 만료 여부를 확인하고, lock()으로 안전하게 shared_ptr을 얻을 수 있어, dangling pointer 위험이 없습니다.

Q. 순환 참조가 3개 이상일 때는?

A. A→B→C→A처럼 3개 이상이 순환해도, 한 군데만 weak_ptr로 바꿔도 순환이 끊깁니다. 모든 역방향을 weak로 바꿀 필요는 없습니다.

Q. 이 내용을 실무에서 언제 쓰나요?

A. 게임 서버(캐릭터-길드), GUI 이벤트 구독, DOM/AST 트리, 리소스 캐시, 채팅 서버(Room-Session) 등에서 weak_ptr로 순환을 끊습니다.

Q. 선행으로 읽으면 좋은 글은?

A. C++ 스마트 포인터와 순환 참조 해결법에서 weak_ptr 기초를, C++ 스마트 포인터에서 shared_ptr/unique_ptr을 먼저 익히면 좋습니다.


13. 면접에서 이렇게 답하기

Q: shared_ptr 순환 참조가 뭔가요? 어떻게 해결하나요?

  • “A가 B를 shared_ptr로 가지고 B가 A를 shared_ptr로 가지면, 서로가 서로를 소유해 참조 카운트가 0이 되지 않아 메모리 누수가 납니다. 이걸 순환 참조라고 합니다. 해결은 한쪽을 weak_ptr로 바꾸는 것입니다. weak_ptr은 카운트를 올리지 않으므로 순환이 끊기고, 필요할 때만 lock()으로 shared_ptr을 얻어 사용합니다.”

Q: weak_ptr을 언제 쓰나요?

  • 순환 참조를 끊을 때 씁니다. 두 객체가 서로를 shared_ptr로 가리키면 참조 카운트가 0이 안 돼서 메모리 누수가 납니다. 한쪽을 weak_ptr로 바꾸면 그쪽으로는 카운트가 올라가지 않아 순환이 끊기고, 객체가 정상 해제됩니다. 또 ‘있으면 쓰고 없으면 무시’하는 참조만 필요한 관계에서도 씁니다. 예를 들어 게임에서 캐릭터가 속한 길드를 weak_ptr로 들고 있으면, 길드가 해체돼도 캐릭터가 길드를 살려 두지 않고, lock()으로 유효할 때만 사용할 수 있습니다.”

Q: lock()과 expired()의 차이는?

  • expired()는 객체가 해제됐는지 여부만 확인합니다. lock()은 유효하면 shared_ptr을 반환하고, 만료됐으면 빈 shared_ptr을 반환합니다. 멀티스레드에서는 expired() 체크 후 lock() 사이에 객체가 해제될 수 있으므로, lock() 결과만으로 판단하는 것이 안전합니다.”

13. 체크리스트와 정리

shared_ptr 순환 참조 해결 체크리스트

  • 양방향 참조에서 소유권이 없는 쪽을 weak_ptr로 선택했는가?
  • lock() 호출 후 반환값 검사를 하는가? (if (auto p = wp.lock()))
  • 만료된 weak_ptr에 직접 접근하지 않는가? (wp.lock()->foo() ❌)
  • 멀티스레드에서 expired()만 믿지 않고 lock() 결과로 판단하는가?
  • 옵저버/캐시에서 만료된 항목 정리 로직이 있는가?
  • shared_ptr(this) 대신 enable_shared_from_this를 사용하는가?

정리

패턴순환 원인해결
부모-자식부모↔자식 shared_ptr자식→부모를 weak_ptr
옵저버Subject↔Observer shared_ptrObserver→Subject를 weak_ptr, Subject는 구독자를 weak_ptr로 보관
그래프노드 간 양방향 shared_ptr역방향을 weak_ptr 또는 인덱스/raw 포인터
캐시캐시가 shared_ptr 소유캐시는 weak_ptr로 보관

한 줄 요약: shared_ptr 순환 참조는 한쪽을 weak_ptr로 바꾸면 끊깁니다. 소유권이 없는 쪽을 weak로 선택하고, lock() 결과를 항상 검사하세요.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]
  • C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
  • C++ shared_ptr 고급 완벽 가이드 | enable_shared_from_this·aliasing

이 글에서 다루는 키워드 (관련 검색어)

shared_ptr 순환 참조, weak_ptr 사용법, 메모리 누수 해결, 부모 자식 트리 shared_ptr, 옵저버 패턴 weak_ptr, 캐시 패턴 weak_ptr, lock expired 등으로 검색하시면 이 글이 도움이 됩니다.

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이전 글: C++ 스마트 포인터와 순환 참조 해결법

다음 글: 멀티스레드 Data Race와 Mutex/Atomic

참고: 33-3에서 weak_ptr 기초를 다룹니다. 이 글(33-4)은 4가지 패턴별 완전한 예제와 프로덕션 패턴에 집중합니다.


관련 글

  • C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]
  • C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
  • C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]
  • C++ 순환 참조 | shared_ptr 메모리 누수
  • C++ 백준/프로그래머스 C++ 세팅과 입출력 최적화 한 번에 정리 [#32-1]