C++ shared_ptr 순환 참조 누수: weak_ptr로 끊는 기준과 디버깅 방법

이 글의 핵심

shared_ptr를 쓰면 메모리 관리가 끝났다고 생각하기 쉽지만, 양방향 연결 리스트나 이벤트 리스너처럼 서로를 소유하는 구조에서는 누수가 조용히 쌓입니다. 소유 관계를 한 방향으로 정하고 역방향은 weak_ptr와 lock()으로 접근하는 것이 핵심이며, 소멸자 로그만으로도 순환을 빠르게 의심할 수 있습니다. 자주 하는 실수 3가지와 트러블슈팅 사례를 함께 정리했습니다.

들어가며: “shared_ptr을 썼는데 메모리 누수가 생겼어요”

C++에서 shared_ptr은 자동 메모리 관리를 제공하지만, 순환 참조(Circular Reference)가 발생하면 참조 카운트가 0이 되지 않아 메모리 누수가 발생합니다.

// ❌ 순환 참조
// 타입 정의
class Node {
public:
    std::shared_ptr<Node> next;  // 다음 노드
    std::shared_ptr<Node> prev;  // 이전 노드
    
    ~Node() {
        std::cout << "~Node\n";  // 호출 안 됨!
    }
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;  // node1 → node2
    node2->prev = node1;  // node2 → node1 (순환!)
    
    // node1과 node2의 참조 카운트가 2로 유지됨
    // main 끝나도 소멸자 호출 안 됨 → 메모리 누수
}

이 버그가 까다로운 이유는 아무 증상이 없다는 점입니다. 크래시도, 경고도, 예외도 없이 소멸자가 호출되지 않을 뿐입니다. 짧게 실행되는 프로그램에서는 종료 시 운영체제가 메모리를 회수하므로 문제를 모르고 지나가고, 오래 도는 서버에서만 메모리 사용량이 서서히 늘어나는 형태로 드러납니다. 소멸자가 파일이나 소켓을 닫는 역할까지 맡고 있었다면 메모리보다 Too many open files 같은 리소스 고갈이 먼저 나타나기도 합니다. 이 글은 순환이 생기는 원리, weak_ptr로 끊는 기준, 부모-자식·캐시·옵저버 패턴, 그리고 누수를 찾아내는 방법을 차례로 다룹니다.


순환 참조란?

참조 카운팅 원리

// shared_ptr 참조 카운팅
auto ptr1 = std::make_shared<int>(42);
// 참조 카운트: 1

auto ptr2 = ptr1;
// 참조 카운트: 2

ptr2.reset();
// 참조 카운트: 1

ptr1.reset();
// 참조 카운트: 0 → 메모리 해제

순환 참조 발생

// ❌ 순환 참조 예시
class Person {
public:
    std::string name;
    std::shared_ptr<Person> partner;  // 배우자
    
    Person(const std::string& n) : name(n) {
        std::cout << name << " 생성\n";
    }
    
    ~Person() {
        std::cout << name << " 소멸\n";
    }
};

int main() {
    auto alice = std::make_shared<Person>("Alice");
    auto bob = std::make_shared<Person>("Bob");
    
    alice->partner = bob;   // Alice → Bob (Bob의 참조 카운트: 2)
    bob->partner = alice;   // Bob → Alice (Alice의 참조 카운트: 2)
    
    std::cout << "alice use_count: " << alice.use_count() << '\n';  // 2
    std::cout << "bob use_count: " << bob.use_count() << '\n';      // 2
    
    // main 끝
    // alice의 지역 변수 소멸 → Alice의 참조 카운트: 1 (Bob이 여전히 참조)
    // bob의 지역 변수 소멸 → Bob의 참조 카운트: 1 (Alice가 여전히 참조)
    // 둘 다 참조 카운트가 0이 안 됨 → 메모리 누수!
}

// 출력:
// Alice 생성
// Bob 생성
// alice use_count: 2
// bob use_count: 2
// (소멸자 호출 안 됨!)

문제: Alice와 Bob이 서로를 참조해서 참조 카운트가 0이 되지 않음.


weak_ptr 기초

weak_ptr이란?

weak_ptr은 참조 카운트를 증가시키지 않는 약한 참조입니다.

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

std::weak_ptr<int> weak = ptr;
std::cout << ptr.use_count() << '\n';  // 1 (변화 없음!)

ptr.reset();
// 객체는 파괴되고 weak는 만료(expired) 상태 — 댕글링 포인터와 달리 안전하게 확인 가능

weak_ptr이 raw 포인터와 다른 점은 객체가 사라졌는지를 알 수 있다는 것입니다. 객체가 파괴돼도 제어 블록은 약한 참조 카운트가 0이 될 때까지 남아 있어서, weak_ptr은 그 제어 블록을 보고 객체가 살아 있는지 확인합니다. 그래서 객체의 메모리가 해제된 뒤에도 weak_ptr을 통해 잘못된 메모리를 읽을 일이 없습니다. 한 가지 주의할 점은 make_shared로 만든 경우 객체와 제어 블록이 한 덩어리로 할당되므로, 객체의 소멸자는 즉시 호출되지만 메모리 자체는 마지막 weak_ptr이 사라질 때까지 반환되지 않는다는 것입니다. 큰 객체를 오래 사는 weak_ptr로 가리키면 메모리가 예상보다 늦게 줄어듭니다.

weak_ptr 사용법

auto ptr = std::make_shared<int>(42);
std::weak_ptr<int> weak = ptr;

// ✅ lock()으로 shared_ptr로 변환
if (auto shared = weak.lock()) {
    std::cout << *shared << '\n';  // 42
} else {
    std::cout << "객체가 소멸됨\n";
}

// ✅ expired()로 유효성 확인
if (weak.expired()) {
    std::cout << "객체가 소멸됨\n";
}

expired()는 “이미 사라졌는가”를 확인하는 용도로만 믿을 수 있습니다. false가 나왔더라도 다음 줄로 넘어가는 사이에 다른 스레드가 마지막 shared_ptr을 놓으면 객체가 사라질 수 있기 때문입니다. 객체를 실제로 쓸 때는 항상 lock()으로 shared_ptr을 얻어 그 결과를 검사해야 하고, lock()이 돌려준 shared_ptr이 살아 있는 동안에는 객체가 파괴되지 않습니다.

순환 참조 해결

// ✅ weak_ptr로 해결
class Person {
public:
    std::string name;
    std::weak_ptr<Person> partner;  // weak_ptr 사용!
    
    Person(const std::string& n) : name(n) {
        std::cout << name << " 생성\n";
    }
    
    ~Person() {
        std::cout << name << " 소멸\n";
    }
    
    void setPartner(std::shared_ptr<Person> p) {
        partner = p;
        
        // partner 사용 시 lock() 필요
        if (auto p = partner.lock()) {
            std::cout << name << "의 배우자: " << p->name << '\n';
        }
    }
};

int main() {
    auto alice = std::make_shared<Person>("Alice");
    auto bob = std::make_shared<Person>("Bob");
    
    alice->setPartner(bob);   // Alice → Bob (weak_ptr)
    bob->setPartner(alice);   // Bob → Alice (weak_ptr)
    
    std::cout << "alice use_count: " << alice.use_count() << '\n';  // 1
    std::cout << "bob use_count: " << bob.use_count() << '\n';      // 1
    
    // main 끝
    // alice 소멸 → Alice의 참조 카운트: 0 → 소멸자 호출
    // bob 소멸 → Bob의 참조 카운트: 0 → 소멸자 호출
}

// 출력:
// Alice 생성
// Bob 생성
// Alice의 배우자: Bob
// Bob의 배우자: Alice
// alice use_count: 1
// bob use_count: 1
// Bob 소멸
// Alice 소멸

(지역 변수는 선언의 역순으로 소멸하므로 bob이 먼저 파괴됩니다.)

여기서 짚고 넘어갈 점이 있습니다. 배우자처럼 대등한 관계를 양쪽 모두 weak_ptr로 만들면 순환은 사라지지만, 이제 두 객체를 소유하는 것은 main의 지역 변수 alice, bob뿐입니다. 실제 프로그램에서는 이들을 소유하는 별도의 컨테이너(예: std::vector<std::shared_ptr<Person>>)가 있어야 합니다. weak_ptr로 바꾸는 작업은 결국 “이 객체의 수명은 누가 책임지는가”를 정하는 설계 결정이고, 그 질문에 답하지 않은 채 weak_ptr만 넣으면 이번에는 객체가 너무 일찍 사라지는 반대 문제가 생깁니다.


실전 패턴

패턴 1: 부모-자식 관계

// ✅ 부모-자식 관계 (shared_from_this()를 쓰려면 enable_shared_from_this 상속 필요)
class TreeNode : public std::enable_shared_from_this<TreeNode> {
public:
    int value;
    std::shared_ptr<TreeNode> left;   // 자식 (강한 참조)
    std::shared_ptr<TreeNode> right;  // 자식 (강한 참조)
    std::weak_ptr<TreeNode> parent;   // 부모 (약한 참조)
    
    TreeNode(int v) : value(v) {
        std::cout << "TreeNode(" << value << ") 생성\n";
    }
    
    ~TreeNode() {
        std::cout << "TreeNode(" << value << ") 소멸\n";
    }
    
    void setLeft(std::shared_ptr<TreeNode> node) {
        left = node;
        if (node) {
            node->parent = shared_from_this();
        }
    }
    
    void setRight(std::shared_ptr<TreeNode> node) {
        right = node;
        if (node) {
            node->parent = shared_from_this();
        }
    }
    
    void printPath() {
        std::cout << value;
        if (auto p = parent.lock()) {
            std::cout << " → ";
            p->printPath();
        } else {
            std::cout << " (루트)\n";
        }
    }
};

int main() {
    auto root = std::make_shared<TreeNode>(1);
    auto left = std::make_shared<TreeNode>(2);
    auto right = std::make_shared<TreeNode>(3);
    
    root->setLeft(left);
    root->setRight(right);
    
    left->printPath();   // 2 → 1 (루트)
    right->printPath();  // 3 → 1 (루트)
    
    // root 소멸 → left, right도 자동 소멸
}

// 출력:
// TreeNode(1) 생성
// TreeNode(2) 생성
// TreeNode(3) 생성
// 2 → 1 (루트)
// 3 → 1 (루트)
// TreeNode(1) 소멸
// TreeNode(3) 소멸   ← 멤버는 선언 역순(right → left)으로 소멸
// TreeNode(2) 소멸

shared_from_this()는 “이미 shared_ptr이 이 객체를 소유하고 있을 때” 그 소유권을 공유하는 새 shared_ptr을 만듭니다. 생성자 안에서 호출하거나, 스택에 만든 TreeNode에서 호출하면 아직 소유하는 shared_ptr이 없으므로 C++17부터는 std::bad_weak_ptr 예외가 던져집니다. 그래서 setLeft처럼 객체가 완전히 만들어지고 make_shared로 소유된 뒤에 호출되는 멤버 함수에서만 써야 합니다. 반대로 std::shared_ptr<TreeNode>(this)를 직접 만들면 제어 블록이 두 개 생겨 이중 해제로 이어지므로, 자기 자신의 shared_ptr이 필요할 때는 반드시 이 방식을 씁니다.

main이 끝날 때 right, left 지역 변수가 먼저 사라지지만 root가 여전히 자식을 소유하므로 자식은 살아 있습니다. root가 파괴되면서 소멸자 본문 이후 멤버 right, left가 파괴되고, 그때 자식의 참조 카운트가 0이 됩니다. 부모 포인터가 weak_ptr이어서 이 연쇄가 막히지 않는 것입니다.

패턴 2: 캐시

// ✅ 캐시 (weak_ptr)
class ResourceCache {
    std::map<std::string, std::weak_ptr<Resource>> cache_;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        // 캐시에서 찾기
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            if (auto resource = it->second.lock()) {
                std::cout << "캐시 히트: " << key << '\n';
                return resource;
            } else {
                // 만료된 항목 제거
                cache_.erase(it);
            }
        }
        
        // 캐시 미스 → 새로 생성
        std::cout << "캐시 미스: " << key << '\n';
        auto resource = std::make_shared<Resource>(key);
        cache_[key] = resource;  // weak_ptr로 저장
        return resource;
    }
    
    void cleanup() {
        // 만료된 항목 제거
        for (auto it = cache_.begin(); it != cache_.end(); ) {
            if (it->second.expired()) {
                std::cout << "만료된 항목 제거: " << it->first << '\n';
                it = cache_.erase(it);
            } else {
                ++it;
            }
        }
    }
};

int main() {
    ResourceCache cache;
    
    {
        auto res1 = cache.get("data.txt");  // 캐시 미스
        auto res2 = cache.get("data.txt");  // 캐시 히트
        
        // res1, res2 소멸
    }
    
    cache.cleanup();  // 만료된 항목 제거
    
    auto res3 = cache.get("data.txt");  // 캐시 미스 (이미 소멸됨)
}

// 출력:
// 캐시 미스: data.txt
// 캐시 히트: data.txt
// 만료된 항목 제거: data.txt
// 캐시 미스: data.txt

weak_ptr 캐시의 의미는 “누군가 쓰고 있는 동안에만 공유하고, 아무도 안 쓰면 자동으로 버린다”입니다. 같은 파일을 여러 곳에서 동시에 열어도 한 번만 로드되지만, 모두 반납하면 다음 요청은 다시 로드합니다. 그래서 자주 쓰였다가 잠깐 비는 데이터에는 적중률이 낮습니다. 사용하지 않는 동안에도 일정 시간 유지하고 싶다면 shared_ptr로 저장하는 LRU 캐시가 필요하고, 두 방식을 섞어 “최근 N개는 shared_ptr, 나머지는 weak_ptr”로 두는 구성도 흔합니다. 만료된 항목의 키가 맵에 계속 쌓이는 것도 누수의 일종이므로, 위처럼 조회할 때 지우거나 cleanup()을 주기적으로 불러야 합니다. 여러 스레드에서 이 캐시를 쓴다면 cache_ 접근 전체를 뮤텍스로 보호해야 합니다.

패턴 3: 옵저버 패턴

// ✅ 옵저버 패턴 (weak_ptr)
class Observer {
public:
    virtual void update(const std::string& msg) = 0;
    virtual ~Observer() = default;
};

class Subject {
    std::vector<std::weak_ptr<Observer>> observers_;
    
public:
    void attach(std::shared_ptr<Observer> observer) {
        observers_.push_back(observer);
    }
    
    void notify(const std::string& msg) {
        // 만료된 옵저버 제거
        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 observer = weak.lock()) {
                observer->update(msg);
            }
        }
    }
};

class ConcreteObserver : public Observer {
    std::string name_;
public:
    ConcreteObserver(const std::string& name) : name_(name) {}
    
    void update(const std::string& msg) override {
        std::cout << name_ << " 수신: " << msg << '\n';
    }
};

int main() {
    Subject subject;
    
    {
        auto obs1 = std::make_shared<ConcreteObserver>("Observer1");
        auto obs2 = std::make_shared<ConcreteObserver>("Observer2");
        
        subject.attach(obs1);
        subject.attach(obs2);
        
        subject.notify("이벤트 1");
        // obs1, obs2 소멸
    }
    
    subject.notify("이벤트 2");  // 만료된 옵저버는 알림 안 받음
}

// 출력:
// Observer1 수신: 이벤트 1
// Observer2 수신: 이벤트 1
// (이벤트 2는 출력 없음)

옵저버를 weak_ptr로 저장하면 Subject가 옵저버의 수명을 연장하지 않으므로, 옵저버가 파괴된 뒤에 호출되는 문제와 순환 참조를 함께 막을 수 있습니다. C++20이라면 remove_if + erase 두 줄 대신 std::erase_if(observers_, [](const auto& w) { return w.expired(); }); 한 줄로 쓸 수 있습니다. 알림을 보내는 도중 update() 안에서 attach()가 호출되면 observers_가 재할당되어 순회 중인 반복자가 무효화되므로, 실무 코드에서는 알림 전에 목록을 복사해 두고 복사본을 순회하는 방식이 안전합니다.


디버깅 방법

방법 1: use_count() 확인

auto ptr = std::make_shared<int>(42);
std::cout << "참조 카운트: " << ptr.use_count() << '\n';

auto ptr2 = ptr;
std::cout << "참조 카운트: " << ptr.use_count() << '\n';  // 2

// 예상보다 높으면 순환 참조 의심

방법 2: Valgrind

터미널에서 다음 명령어를 실행합니다.

# Valgrind로 메모리 누수 탐지
valgrind --leak-check=full ./myapp

# 출력 예시 (요약 부분):
# LEAK SUMMARY:
#    definitely lost: ... bytes in ... blocks
#    indirectly lost: ... bytes in ... blocks

순환 참조로 새는 블록은 서로를 가리키고 있어서 “definitely lost”와 “indirectly lost”로 나뉘어 보고되기도 합니다. 숫자보다 중요한 것은 각 항목 아래의 할당 콜스택입니다. make_shared<Node>가 호출된 위치가 나오면, 그 타입의 멤버 중 shared_ptr로 된 것들이 서로를 가리킬 수 있는지 따라가 보면 됩니다.

방법 3: AddressSanitizer

터미널에서 다음 명령어를 실행합니다.

# ASan으로 컴파일
g++ -fsanitize=address -g -o myapp main.cpp

# 실행
./myapp

# 출력 예시:
# ERROR: LeakSanitizer: detected memory leaks

AddressSanitizer에 포함된 LeakSanitizer는 Linux에서 기본으로 켜져 있어, 프로그램이 정상 종료될 때 누수를 보고합니다. Valgrind보다 훨씬 빨라서 단위 테스트를 ASan 빌드로 CI에서 돌리면 순환 참조가 새로 생기는 순간 테스트가 실패하도록 만들 수 있습니다. macOS의 Apple Clang은 LeakSanitizer를 지원하지 않는 경우가 있어, 그때는 Instruments의 Leaks 도구를 씁니다.

방법 4: 소멸자 로그

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass 생성\n";
    }
    
    ~MyClass() {
        std::cout << "MyClass 소멸\n";  // 호출 안 되면 누수
    }
};

같이 보면 좋은 글


자주 하는 실수

실수 1: 양방향 연결 리스트

// ❌ 흔한 실수: 양방향 연결 리스트
class Node {
public:
    int data;
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ 순환 참조!
};

// ✅ 올바른 구현
class Node {
public:
    int data;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // ✅ weak_ptr 사용
};

실수 2: 이벤트 리스너

// ❌ 실수: 리스너가 발행자를 참조
class Publisher {
    std::vector<std::shared_ptr<Listener>> listeners_;
};

class Listener {
    std::shared_ptr<Publisher> publisher_;  // ❌ 순환!
};

// ✅ 올바른 구현
class Listener {
    std::weak_ptr<Publisher> publisher_;  // ✅ weak_ptr
};

실수 3: 부모-자식 양방향 참조

// ❌ 실수: 자식이 부모를 shared_ptr로 참조
class Parent {
    std::vector<std::shared_ptr<Child>> children_;
};

class Child {
    std::shared_ptr<Parent> parent_;  // ❌ 순환!
};

// ✅ 올바른 구현
class Child {
    std::weak_ptr<Parent> parent_;  // ✅ weak_ptr
};

실무 트러블슈팅

문제: 메모리 사용량이 계속 증가

증상:

# 메모리 사용량 모니터링
$ top -p <pid>
# 메모리가 계속 증가하고 해제되지 않음

진단:

// use_count() 확인
std::cout << "참조 카운트: " << ptr.use_count() << '\n';
// 예상보다 높으면 순환 참조 의심

해결:

// 1. weak_ptr로 변경
// 2. 소멸자에 로그 추가
~MyClass() {
    std::cout << "소멸자 호출\n";
}
// 3. Valgrind로 누수 확인
// valgrind --leak-check=full ./myapp

문제: 프로그램 종료 시 크래시

증상:

Segmentation fault (core dumped)

원인: lock()의 결과를 확인하지 않고 바로 역참조하는 경우입니다. 객체가 이미 사라졌다면 lock()은 빈 shared_ptr을 돌려주고, 이것을 ->로 쓰면 널 포인터 역참조로 죽습니다. 종료 시점에만 크래시가 나는 것은 객체들이 한꺼번에 파괴되는 순서 속에서 “아직 살아 있을 것”이라는 가정이 깨지기 때문입니다. 순환을 끊으려고 weak_ptr로 바꾼 직후 이런 크래시가 생긴다면, 그 객체를 소유하는 쪽이 생각보다 일찍 사라지는 것은 아닌지도 함께 확인해야 합니다.

해결:

// ✅ 항상 lock() 후 nullptr 체크
if (auto ptr = weak.lock()) {
    ptr->doSomething();
} else {
    std::cout << "객체가 이미 소멸됨\n";
}

성능 영향 분석

참조 카운트 오버헤드

작업shared_ptrweak_ptr
생성힙 할당(make_shared면 1회) + 카운터 초기화약한 카운트 원자적 증가
복사강한 카운트 원자적 증가약한 카운트 원자적 증가
lock()-강한 카운트가 0이 아니면 원자적으로 증가 (보통 compare-and-swap 루프)
소멸강한 카운트 감소, 0이면 객체 파괴약한 카운트 감소, 0이면 제어 블록 해제

구체적인 비용은 플랫폼과 경합 정도에 따라 달라서 수치로 일반화하기 어렵습니다. 구조적으로 알 수 있는 것은 lock()이 단순 복사보다 조금 비싸다는 점(카운트가 0이 아닌지 확인하면서 증가해야 하므로)과, 여러 스레드가 같은 객체의 카운트를 동시에 건드리면 캐시 라인 경합으로 비용이 커진다는 점입니다. 핫 루프에서 매번 lock()을 호출하고 있다면, 루프 밖에서 한 번 lock()해 얻은 shared_ptr을 재사용하는 것만으로 대부분의 비용이 사라집니다.


코드 리뷰에서 순환 참조를 의심할 세 가지 패턴

// 🔍 리뷰 시 확인사항
// 1. shared_ptr끼리 서로 참조하는가?
class A {
    std::shared_ptr<B> b_;  // ⚠️ B도 A를 참조?
};

// 2. 컨테이너에 shared_ptr을 저장하는가?
std::vector<std::shared_ptr<Observer>> observers_;  // ⚠️ Observer가 역참조?

// 3. 콜백에 shared_ptr을 캡처하는가?
[self = shared_from_this()](){ self->foo(); };  // ⚠️ 이 람다를 자기 멤버에 저장하면 순환

세 번째 경우가 실무에서 가장 찾기 어렵습니다. 비동기 I/O 라이브러리(Boost.Asio 등)에서는 작업이 끝날 때까지 객체를 살려 두려고 일부러 shared_from_this()를 캡처하는 관용구를 쓰는데, 이 람다가 완료 후 바로 사라지면 문제가 없습니다. 문제는 같은 람다를 타이머 콜백이나 이벤트 핸들러처럼 객체 자신의 멤버(std::function)에 저장할 때입니다. 이 경우 객체 → 멤버 람다 → 캡처된 shared_ptr → 객체로 순환이 생기므로, 저장되는 콜백에는 weak_from_this()(C++17)로 얻은 weak_ptr을 캡처하고 호출 시점에 lock()합니다.


실무 시나리오

시나리오 1: GUI 위젯 트리

// ✅ 실무 예시: GUI 위젯 계층
class Widget {
    std::weak_ptr<Widget> parent_;  // 부모 참조
    std::vector<std::shared_ptr<Widget>> children_;  // 자식 소유
    
public:
    void setParent(std::shared_ptr<Widget> parent) {
        parent_ = parent;
    }
    
    void addChild(std::shared_ptr<Widget> child) {
        children_.push_back(child);
        child->setParent(shared_from_this());  // Widget이 enable_shared_from_this<Widget>을 상속해야 함
    }
    
    std::shared_ptr<Widget> getParent() const {
        return parent_.lock();
    }
};

// 사용
auto window = std::make_shared<Widget>();
auto button = std::make_shared<Widget>();
window->addChild(button);
// window 소멸 → button도 자동 소멸

시나리오 2: 게임 엔티티 시스템

// ✅ 실무 예시: 게임 엔티티
class Entity {
    std::weak_ptr<Scene> scene_;  // 씬 참조
    std::vector<std::shared_ptr<Component>> components_;  // 컴포넌트 소유
};

class Scene {
    std::vector<std::shared_ptr<Entity>> entities_;  // 엔티티 소유
};

// 엔티티가 씬을 weak_ptr로 참조 → 순환 참조 방지

시나리오 3: 네트워크 연결 관리

// ✅ 실무 예시: 연결 풀
class ConnectionPool {
    std::vector<std::weak_ptr<Connection>> connections_;
    
public:
    void cleanup() {
        // 만료된 연결 제거
        connections_.erase(
            std::remove_if(connections_.begin(), connections_.end(),
                [](const std::weak_ptr<Connection>& weak) {
                    return weak.expired();
                }),
            connections_.end()
        );
    }
    
    size_t activeConnections() const {
        return std::count_if(connections_.begin(), connections_.end(),
            [](const std::weak_ptr<Connection>& weak) {
                return !weak.expired();
            });
    }
};

자주 묻는 질문 (FAQ)

Q. 람다 콜백이 shared_ptr을 캡처해도 순환 참조가 생기나요?

A. 네, 객체가 멤버로 콜백(std::function)을 들고 있고 그 람다가 shared_from_this()로 얻은 자기 자신의 shared_ptr을 값으로 캡처하면 객체 → 람다 → 객체로 이어지는 순환이 생깁니다. 본문의 이벤트 리스너 실수처럼 참조 카운트가 0이 되지 않아 소멸자가 호출되지 않으며, 코드상으로는 캡처 한 줄이라 발견하기 어렵습니다. 콜백에는 std::weak_ptr을 캡처하고 호출 시점에 lock()으로 살아 있는지 확인한 뒤 사용하는 것이 안전합니다.