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

들어가며: 순환 참조로 메모리 누수가 발생해요

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

시나리오 1: MMORPG 서버 메모리 누수 MMORPG 서버를 개발하다가 메모리 사용량이 시간이 지날수록 계속 증가하는 현상을 발견했습니다. 플레이어가 로그아웃해도 캐릭터와 길드 객체가 해제되지 않아, 24시간 운영 시 수 GB의 메모리가 누적되는 문제였습니다. 원인은 캐릭터 ↔ 길드가 서로를 shared_ptr로 가리키면서 순환 참조가 발생한 것이었습니다.

시나리오 2: GUI 이벤트 핸들러 누수 Qt나 커스텀 GUI 프레임워크에서 위젯이 이벤트 버스에 구독 등록한 뒤, 위젯이 닫혀도 구독자 목록에 shared_ptr로 남아 있어 위젯이 해제되지 않는 문제가 발생했습니다. 부모-자식 위젯이 서로를 참조하면서 순환이 생기거나, 이벤트 발행자가 구독자를 소유해 수명이 늘어나는 경우입니다.

시나리오 3: 리소스 캐시 무한 증가 게임 엔진에서 텍스처·모델을 unordered_map<string, shared_ptr<Texture>>로 캐시했더니, 한 번 로드된 리소스가 절대 해제되지 않아 메모리가 계속 쌓였습니다. “어디서도 사용 중이 아닐 때” 자동으로 해제되길 원했지만, 캐시가 shared_ptr을 들고 있어 수명이 무한히 유지되었습니다.

시나리오 4: 부모-자식 트리 구조 DOM 트리, 스크립트 엔진의 객체 그래프, 설정 파서의 노드 트리 등에서 부모가 자식을, 자식이 부모를 shared_ptr로 가리키면 순환이 발생합니다. 트리 탐색을 위해 양방향 링크가 필요할 때 weak_ptr 없이는 메모리 누수가 불가피합니다.

시나리오 5: 플러그인/모듈 시스템 플러그인 매니저가 로드된 플러그인 목록을 shared_ptr로 보관하며, 플러그인이 매니저를 참조하면 순환이 생깁니다. 플러그인이 언로드돼도 매니저가 shared_ptr로 들고 있어 플러그인이 해제되지 않는 문제가 발생합니다. 플러그인 목록을 weak_ptr로 바꾸면, 플러그인 해제 시 자동으로 만료 처리됩니다.

시나리오 6: 네트워크 세션·연결 풀 서버가 클라이언트 세션을 shared_ptr로 관리하며, 세션이 서버를 참조하면 순환이 됩니다. 세션이 끊겨도 서버가 세션을 소유해 연결이 정리되지 않는 현상이 생깁니다. 세션 목록을 weak_ptr로 두면, 클라이언트 연결 종료 시 자연스럽게 정리됩니다.

시나리오 7: 채팅 서버 Room ↔ Session 채팅방 Room이 참가자 Session을 shared_ptr로 보관하고, Session도 자신이 속한 Room을 shared_ptr로 들고 있으면, 모든 사용자가 나간 뒤에도 방이 사라지지 않습니다. 증상은 입퇴장을 반복할수록 프로세스 RSS가 계단식으로 올라가는 것입니다. 방이 비었는데도 Room 소멸자가 불리지 않는다면 거의 이 구조입니다. Session→Room을 weak_ptr로 바꾸고 Room만 Session을 소유하게 하면 풀립니다(아래 Room-Session 예제 참고).

시나리오 8: 그래프의 양방향 엣지 경로 탐색용 그래프에서 노드가 이웃을 shared_ptr 벡터로 들고 있으면, 무방향 그래프의 모든 엣지가 A↔B 순환이 됩니다. 그래프 객체를 버려도 노드 소멸자가 하나도 호출되지 않습니다. 그래프는 “누가 누구를 소유하는가”가 자연스럽게 정해지지 않는 구조라 weak_ptr보다 컨테이너가 전부 소유하고 노드끼리는 인덱스로 참조하는 방식이 더 잘 맞는 경우가 많습니다(그래프 패턴 참고).

// ❌ 문제의 코드: 순환 참조로 메모리 누수
// 타입 정의
struct Character;
struct Guild {
    std::vector<std::shared_ptr<Character>> members;  // 길드가 캐릭터 소유
};
struct Character {
    std::shared_ptr<Guild> guild;  // 캐릭터가 길드 소유
};
// 둘 다 shared_ptr → 서로가 서로를 "소유" → 참조 카운트 0 안 됨 → 누수!

unique_ptr은 “소유권 하나”, shared_ptr은 “참조 카운팅으로 공유”라고 이해해도, weak_ptr은 “참조 카운트를 올리지 않으며, 가리킨 객체가 살아 있으면 접근할 수 있는 포인터”로, 순환 참조를 끊을 때 핵심 역할을 합니다. 이 글에서는 순환 참조가 왜 메모리 누수를 일으키는지, weak_ptr로 어떻게 해결하는지, lock()·expired() 사용법, 옵저버·캐시 패턴, 자주 하는 실수, 성능 비교, 프로덕션 패턴(이벤트 시스템, 리소스 캐시)까지 다룹니다. 이 글에서 다루는 것: 순환 참조와 메모리 누수, weak_ptr·lock()·expired() 사용법, 옵저버·캐시·부모-자식·그래프 패턴, enable_shared_from_this 관련 실수, 누수 진단, 성능 비교, 프로덕션 패턴(채팅 Room-Session, 이벤트 시스템, 리소스 캐시).

shared_ptr과 참조 카운트 복습

  • shared_ptr은 같은 대상을 여러 곳에서 공유하며, 그 대상을 가리키는 shared_ptr이 하나도 없어질 때 객체를 해제합니다.
  • 내부적으로 참조 카운트를 유지하며, 복사할 때 +1, 소멸/리셋할 때 -1 합니다. 0이 되면 delete가 호출됩니다.
  • 문제: 두 객체가 서로를 shared_ptr로 가리키면, 카운트가 절대 0이 되지 않아 메모리 누수가 발생합니다.

순환 참조와 메모리 누수

순환 참조란?

순환 참조(Circular Reference)는 객체 A가 B를 가리키고, B가 다시 A를 가리키는 구조입니다. 양쪽 모두 shared_ptr을 사용하면 “서로가 서로를 소유”한 셈이 되어 참조 카운트가 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 소멸”이 출력되지 않음 → 메모리 누수)

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로 바꾸면, 그 방향으로는 참조 카운트가 올라가지 않아 순환이 끊깁니다.

순환 참조 방지 전략: Before/After 비교

Before (shared_ptr만 사용):

// ❌ 순환 참조 → 메모리 누수
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // prev가 next를, next가 prev를 가리킴
};
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();
n1->next = n2;
n2->prev = n1;  // n1↔n2 순환, 참조 카운트 0 안 됨

After (weak_ptr로 한쪽 끊기):

// ✅ 역방향만 weak_ptr → 순환 끊김
// 타입 정의
struct Node {
    std::shared_ptr<Node> next;   // 정방향: 소유
    std::weak_ptr<Node> prev;     // 역방향: 참조만
};
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();
n1->next = n2;
n2->prev = n1;  // prev는 카운트 올리지 않음 → n1 카운트 1 유지
// n1 해제 시 n1→n2 끊김 → n2 카운트 0 → 정상 해제

선택 기준: “이 객체가 저 객체를 소유하는가?”를 묻으며, 소유하지 않으면 weak_ptr을 사용합니다. 리스트에서 next는 “다음 노드를 소유”하며, prev는 “이전 노드를 참조만”하므로 prev를 weak로 둡니다.


weak_ptr로 순환 끊기

weak_ptr이란?

  • shared_ptr이 관리하는 객체를 “소유하지 않고” 가리킬 수 있는 포인터입니다.
  • 참조 카운트를 올리지 않습니다. 그래서 “나 때문에 객체가 살아 있으면 안 된다”는 한쪽을 weak_ptr로 바꾸면, 그 방향으로는 카운트가 올라가지 않아 순환이 끊깁니다.

사용 방법

  • lock(): 해당 객체가 아직 살아 있으면 shared_ptr을 반환하며, 이미 해제됐으면 빈 shared_ptr을 반환합니다. 따라서 “있으면 쓰며, 없으면 무시”하는 패턴에 씁니다.
  • expired(): 이미 해제됐는지 여부만 확인할 수 있습니다. lock() 사용 예시:
std::weak_ptr<Guild> my_guild = character->getGuildWeak();
if (auto g = my_guild.lock()) {
    // 길드가 아직 살아 있음 → g로 접근
    g->sendMessage("hello");
} else {
    // 이미 해체됨 → "길드 없음" 처리
}

코드 설명: lock()은 shared_ptr을 반환하므로, if 조건 안에서 auto g = my_guild.lock()으로 받으면 g가 유효할 때만 블록 안으로 들어갑니다. 만료됐으면 lock()이 빈 shared_ptr을 반환해 false가 되므로 else로 빠집니다. 이 패턴만 익혀 두면 weak_ptr 사용이 쉽습니다.

// 타입 정의
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 (main의 pa만)
// main에서 pa를 버리면 A 카운트 0 → A 해제 → pa->b(pb) 해제 → B 카운트 0 → B 해제
  • B가 A를 weak_ptr로만 가리키므로, “A의 소유권”을 B가 가지지 않습니다. 따라서 main에서 pa를 버리면 A가 먼저 해제되고, 그에 따라 A가 가진 b(shared_ptr to B)도 사라져서 B의 카운트가 0이 되고, 비로소 B도 해제됩니다. 순환이 끊겨서 누수가 사라집니다.

완전한 순환 참조 해결 예제: 캐릭터와 길드

아래는 실행 가능한 전체 코드로, weak_ptr 적용 전후를 비교합니다.

#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 (members만)
    // 길드 정보 접근 (lock 사용)
    if (auto g = c1->guild.lock()) {
        std::cout << c1->name << "의 길드: " << g->name << "\n";
    }
    std::cout << "--- 스코프 종료 ---\n";
}  // guild, c1, c2 해제 → 순서대로 정상 소멸

실행 결과:

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

설명: Character::guild가 weak_ptr이므로 길드의 참조 카운트를 올리지 않습니다. Guild::members는 shared_ptr이지만, 이 관계는 “길드가 멤버 목록을 관리”하는 단방향 소유입니다. 캐릭터가 길드를 weak로만 참조하므로 순환이 끊기고, 스코프 종료 시 모든 객체가 정상 해제됩니다.


lock()과 expired() 상세 예제

lock() — 유효한 shared_ptr 얻기

lock()은 weak_ptr이 가리키는 객체가 아직 살아 있으면 shared_ptr을 반환하며, 이미 해제됐으면 빈 shared_ptr을 반환합니다.

#include <memory>
#include <iostream>
// 타입 정의
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;
    // ✅ lock()으로 유효할 때만 접근
    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";  // 이쪽으로 진입
    }
}

핵심: if (auto g = weak.lock()) 패턴으로, g가 유효할 때만 블록 안으로 들어갑니다. 만료됐으면 lock()이 빈 shared_ptr을 반환해 false가 되므로 else로 빠집니다.

expired() — 만료 여부만 확인

객체에 접근할 필요 없이 “이미 해제됐는지”만 확인할 때 사용합니다.

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

주의: expired()가 false여도, 그 다음 줄에서 lock() 호출 전에 다른 스레드가 객체를 해제할 수 있습니다. 따라서 멀티스레드 환경에서는 lock() 결과만 신뢰하며, expired()는 “대략적인 필터”로만 쓰는 것이 안전합니다.

// ❌ 위험: expired() 체크 후 lock() 사이에 객체 해제될 수 있음
if (!w.expired()) {
    // 여기서 다른 스레드가 객체 해제 가능!
    auto p = w.lock();  // p가 비어 있을 수 있음
}
// ✅ 안전: lock() 결과만으로 판단
if (auto p = w.lock()) {
    // p가 유효함이 보장됨
}

use_count()와 함께 쓰기

weak_ptr은 use_count()를 통해 현재 shared_ptr 개수를 알 수 있습니다.

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";      // true

lock()과 expired() 완전한 실행 예제

아래는 lock()과 expired()를 함께 사용하는 실행 가능한 전체 예제입니다.

#include <memory>
#include <iostream>
#include <vector>
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);
    // lock()으로 유효한 것만 처리
    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();  // p1 해제
    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] 밥 (유효)
p1 해제 후 list[0].expired(): 1
[0] (만료됨)
[1] 밥 (유효)
  Player '알리스' 소멸
  Player '밥' 소멸

설명: p1.reset() 후 list[0]은 만료되고 lock()은 빈 shared_ptr을 반환합니다. list는 weak_ptr만 보관하므로 플레이어 수명에 영향을 주지 않습니다.


weak_ptr 사용 패턴: 옵저버·캐시

옵저버 패턴 (Observer Pattern)

구독자(Observer)가 발행자(Subject)를 참조할 때, 발행자가 구독자를 소유하면 안 됩니다. 구독자 목록은 weak_ptr로 유지해, 이미 삭제된 구독자는 자동으로 걸러냅니다.

#include <memory>
#include <vector>
#include <algorithm>
struct Observer {
    virtual void onEvent(const std::string& msg) = 0;
    virtual ~Observer() = default;
};
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);
            }
        }
    }
};

옵저버 패턴 완전한 예제 (실행 가능):

#include <memory>
#include <vector>
#include <algorithm>
#include <iostream>
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만 수신 (구독자2는 expired)
}

실행 결과:

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

설명: Subject가 구독자를 weak_ptr로 보관하므로, obs2가 스코프를 벗어나 소멸해도 Subject가 살려 두지 않습니다. notify() 시 expired()인 항목을 제거하며, lock()으로 유효한 구독자만 알림을 보냅니다.

캐시 패턴 (Cache Pattern)

캐시는 “있으면 쓰며, 없으면 새로 만들거나 무시”하는 구조입니다. 캐시된 객체를 weak_ptr로 들고 있으면, 외부에서 더 이상 사용하지 않을 때 자동으로 메모리가 해제됩니다.

#include <memory>
#include <unordered_map>
#include <string>
template<typename T>
class ResourceCache {
    std::unordered_map<std::string, std::weak_ptr<T>> cache;
public:
    std::shared_ptr<T> get(const std::string& key) {
        auto it = cache.find(key);
        if (it != cache.end()) {
            if (auto resource = it->second.lock()) {
                return resource;  // 캐시 히트
            }
            cache.erase(it);  // 만료된 항목 제거
        }
        return nullptr;  // 캐시 미스
    }
    void put(const std::string& key, std::shared_ptr<T> resource) {
        cache[key] = resource;
    }
};

캐시 패턴 완전한 예제 (실행 가능):

#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

설명: 캐시가 weak_ptr로 텍스처를 보관하므로, tex1과 tex2가 해제되면 grass.png의 참조 카운트가 0이 되어 자동으로 메모리가 해제됩니다. 이후 get("grass.png")를 호출하면 캐시에 weak_ptr만 남아 있어 만료 상태이므로, 새로 로드합니다. 이렇게 하면 “어디서도 사용 중이 아닐 때” 리소스가 자동으로 해제됩니다.

weak_ptr 캐시에는 반대 방향의 함정도 있습니다. 같은 텍스처를 쓰는 화면이 잠깐 닫혔다 열리면 그 사이에 참조가 0이 되어 디스크에서 다시 로드합니다. 로딩이 비싼 리소스라면 “최근 N개는 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));
    }
};

그래프 노드

그래프는 트리와 달리 “소유 방향”이 자연스럽게 정해지지 않습니다. 이웃을 모두 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() 미검사, shared_ptr(this), 생성자 안 shared_from_this 같은 실수

use-after-free: lock() 결과를 확인하지 않음

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

lock() 실패 시 기본값 처리 누락

// ❌ 로직 오류: 만료 시 nullptr 반환을 처리 안 함
std::shared_ptr<Config> getConfig() {
    return configWeak_.lock();  // 만료 시 빈 shared_ptr
}
// 호출자가 nullptr 체크를 안 하면 크래시
// ✅ 명시적 처리
std::shared_ptr<Config> getConfig() {
    if (auto c = configWeak_.lock()) return c;
    return std::make_shared<Config>();  // 기본값 반환
}

weak_ptr을 shared_ptr로 직접 변환 시도

// ❌ weak_ptr은 shared_ptr로 암시 변환 안 됨
std::weak_ptr<int> wp = sp;
std::shared_ptr<int> sp2 = wp;  // 컴파일 에러
// ✅ lock() 사용
std::shared_ptr<int> sp2 = wp.lock();

순환 참조 방향 잘못 선택

한쪽만 weak_ptr로 바꿀 수 있는데, “소유권이 없는 쪽”을 weak로 바꿔야 합니다.

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

weak_ptr을 빈 상태로 두고 lock() 호출

weak_ptr이 아무 shared_ptr에서도 생성되지 않았거나, 이미 reset()된 경우 lock()은 빈 shared_ptr을 반환합니다. 이는 정상 동작이지만, 호출자가 “반드시 유효하다”고 가정하면 문제가 됩니다.

// ❌ 위험: wp가 빈 weak_ptr일 수 있음
std::weak_ptr<Config> wp;  // 기본 생성 → 빈 상태
auto config = wp.lock();   // 빈 shared_ptr 반환
config->getValue();        // nullptr 역참조 → 크래시
// ✅ 안전: lock() 결과 검사
if (auto config = wp.lock()) {
    config->getValue();
}

lock() 반환값을 너무 오래 보관

lock()으로 얻은 shared_ptr을 오래 들고 있으면, 원래 “참조만 하고 빨리 놓자”는 weak_ptr의 의도와 맞지 않습니다. 특히 옵저버·캐시에서는 필요한 작업만 하고 곧 놓는 것이 좋습니다.

// ⚠️ 주의: shared_ptr을 멤버로 오래 보관하면 weak_ptr 의미 퇴색
class Handler {
    std::shared_ptr<Service> service_;  // lock() 결과를 계속 들고 있음
public:
    void init(std::weak_ptr<Service> wp) {
        service_ = wp.lock();  // 여기서 한 번 lock하고 계속 보관
    }
    // service_가 살아 있는 한 Service는 해제되지 않음 → weak_ptr 효과 없음
};
// ✅ 권장: 필요할 때마다 lock()
void handle(std::weak_ptr<Service> wp) {
    if (auto s = wp.lock()) {
        s->doWork();  // 작업 후 s 스코프 종료 → 참조 해제
    }
}

스레드 안전성 오해

weak_ptr 자체의 lock(), expired()는 스레드 안전하지만, lock()으로 얻은 객체를 여러 스레드에서 동시에 사용할 때는 별도의 동기화가 필요합니다. weak_ptr이 “스레드 안전한 포인터”를 제공하는 것은 아닙니다.

// ❌ 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(); });
// SharedData::modify() 자체에 mutex 등 동기화 필요

순환 참조가 3개 이상일 때

A→B→C→A처럼 3개 이상이 순환할 때는, 한 군데만 weak_ptr로 바꿔도 순환이 끊깁니다. “모든 역방향을 weak로 바꿔야 하나?”라고 생각할 수 있지만, 하나만 weak로 바꿔도 참조 카운트가 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로
// 이렇게 하면 순환 끊김

weak_ptr 복사 vs 참조 전달

람다에 캡처할 때는 값으로 캡처해야 스코프를 벗어나도 안전합니다. 참조 캡처 시 dangling reference 위험이 있습니다.

// ✅ 값 캡처
void registerCallback(std::weak_ptr<Service> wp) {
    queue.push([wp]() { if (auto s = wp.lock()) s->handle(); });
}

raw 포인터에서 weak_ptr 생성

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

// ❌ 컴파일 에러: raw 포인터에서 weak_ptr 생성 불가
MyClass* raw = new MyClass();
std::weak_ptr<MyClass> wp(raw);
// ✅ shared_ptr에서 생성
auto sp = std::make_shared<MyClass>();
std::weak_ptr<MyClass> wp = 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가 제어 블록과 연결하기 전이라 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();
};

lock() 결과를 콜백에 캡처해 수명 연장

비동기 콜백 등록 시점에 lock()한 shared_ptr을 캡처하면, 콜백이 실행될 때까지 대상이 강제로 살아 있습니다. weak_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(); });

weak_ptr을 쓸 때 지킬 원칙

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

lock()이 반환하는 shared_ptr를 검사하지 않고 사용하면 use-after-free 위험이 있습니다. if (auto p = wp.lock()) 패턴을 습관화하세요.

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

멀티스레드에서는 expired() 체크 후 lock() 사이에 객체가 해제될 수 있으므로, lock() 결과만으로 판단하는 것이 안전합니다. expired()는 “대략적인 필터”나 “통계 수집”용으로만 사용하세요.

원칙 3: 소유권이 없는 쪽을 weak로

“이 객체가 저 객체를 소유한다”는 관계를 명확히 하며, 소유하지 않는 쪽을 weak_ptr로 두세요. 예: 캐릭터가 길드를 소유하지 않음 → Character::guild는 weak_ptr.

원칙 4: lock() 결과는 짧게 보관

옵저버·캐시 패턴에서는 lock()으로 얻은 shared_ptr을 지역 변수로만 사용하며, 멤버로 오래 보관하지 마세요. 그래야 “참조만 하고 빨리 놓자”는 weak_ptr의 의도가 유지됩니다.

원칙 5: 만료된 항목 정리

옵저버 목록, 캐시 등에서 expired()인 weak_ptr을 주기적으로 제거하면, 메모리와 순회 비용을 줄일 수 있습니다.

원칙 6: 람다 캡처 시 값으로 캡처

비동기 콜백에서 weak_ptr을 값으로 캡처해야 안전합니다. 참조 캡처 시 dangling reference 위험이 있습니다.

// ✅ 값 캡처
asyncTask([wp]() { if (auto w = wp.lock()) w->update(); });
// ❌ 참조 캡처: wp가 스코프 밖에서 소멸하면 UB
asyncTask([&wp]() { if (auto w = wp.lock()) w->update(); });

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

순환 참조는 shared_ptr끼리만 생깁니다. 소유자가 하나로 정해지는 관계(부모→자식, 컨테이너→원소)는 unique_ptr로 두고 역방향은 raw 포인터나 참조로 두면, 순환이 구조적으로 불가능해집니다. “혹시 몰라서” shared_ptr을 쓰기 시작하면 소유 관계가 흐려지고 순환 위험이 커집니다.

원칙 8: 왜 weak인지 주석으로 남기기

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

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

원칙 9: optional과 조합해 “없음” 표현

lock()이 빈 shared_ptr을 반환할 때, std::optional로 “객체 없음”을 명시적으로 표현할 수 있습니다.

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

성능 비교: shared_ptr vs weak_ptr

연산 비용

연산shared_ptrweak_ptr
복사atomic ref_count++atomic weak_count++
소멸atomic ref_count—atomic weak_count—
lock()—atomic ref_count++, shared_ptr 생성
expired()—ref_count == 0 확인 (원자 연산)

weak_ptr의 lock()은 내부적으로 ref_count를 증가시키므로, shared_ptr 복사와 비슷한 비용이 듭니다. 다만 참조 카운트를 올리지 않는 저장 자체는 shared_ptr보다 가벼운데, 객체 수명에 영향을 주지 않기 때문입니다.

메모리 사용

  • shared_ptr: 객체 + control block (ref_count, weak_count, deleter 등)
  • weak_ptr: control block만 참조. 객체가 해제돼도 control block은 weak_ptr이 남아 있는 한 유지됩니다. 정리: “저장만 하고 가끔 접근”하는 패턴(옵저버 목록, 캐시)에서는 weak_ptr이 적합합니다.

lock() 호출 시퀀스

sequenceDiagram
    participant Caller
    participant weak_ptr
    participant ControlBlock
    participant Object
    Caller->>weak_ptr: lock()
    weak_ptr->>ControlBlock: ref_count 확인
    alt ref_count > 0
        ControlBlock->>ControlBlock: ref_count++
        ControlBlock->>Object: shared_ptr 반환
        weak_ptr->>Caller: shared_ptr (유효)
    else ref_count == 0
        weak_ptr->>Caller: 빈 shared_ptr
    end

설명: lock()은 control block의 ref_count를 원자적으로 확인합니다. 0이면 객체가 이미 해제된 것이므로 빈 shared_ptr을 반환하며, 0보다 크면 ref_count를 증가시킨 뒤 유효한 shared_ptr을 반환합니다.


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()가 항상 호출되는지(예외 경로 포함)를 함께 점검해야 합니다.

이벤트 시스템

이벤트 발행자가 구독자를 weak_ptr로 관리하면, 구독자가 먼저 소멸해도 안전합니다.

#include <memory>
#include <vector>
#include <functional>
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) {
            if (!h.target.expired()) h.callback(value);
        }
    }
};

리소스 캐시 (텍스처, 모델 등)

게임/렌더링 엔진에서 리소스를 캐시할 때, weak_ptr로 보관하면 “어디서도 사용 중이 아닐 때” 자동으로 메모리가 해제됩니다. 위 캐시 패턴 완전한 예제에서 다룬 TextureCache와 동일한 패턴입니다.

class TextureCache {
    std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
    std::shared_ptr<Texture> loader_(const std::string& path);
public:
    std::shared_ptr<Texture> get(const std::string& path) {
        if (auto it = cache_.find(path); it != cache_.end()) {
            if (auto tex = it->second.lock()) return tex;
            cache_.erase(it);
        }
        auto tex = loader_(path);
        cache_[path] = tex;
        return tex;
    }
};

부모-자식 트리

DOM·AST·설정 트리는 위 부모-자식 트리 예제처럼 자식→부모만 weak_ptr로 둡니다. 트리가 매우 깊다면 루트 해제 시 소멸자가 재귀적으로 연쇄 호출되어 스택 오버플로가 날 수 있으므로, 수십만 단계 깊이의 트리(예: 한 줄로 이어진 연결 리스트형 AST)는 소멸자에서 자식을 반복문으로 떼어 내 해제하는 방식을 고려하세요.

콜백/핸들러 수명 관리

비동기 콜백에서 “객체가 아직 살아 있으면 호출, 없으면 무시”할 때 weak_ptr을 람다에 캡처합니다.

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

플러그인/모듈 시스템

플러그인 매니저가 로드된 플러그인을 weak_ptr로 보관하면, 플러그인 해제 시 자동으로 만료됩니다.

void PluginManager::broadcast(const std::string& event) {
    plugins_.erase(
        std::remove_if(plugins_.begin(), plugins_.end(),
             [](const auto& w) { return w.expired(); }),
        plugins_.end()
    );
    for (auto& w : plugins_) {
        if (auto p = w.lock()) p->onEvent(event);
    }
}

네트워크 세션·타이머 콜백

서버 세션 목록이나 지연 실행 콜백에서 weak_ptr로 대상 객체를 보관하면, 연결 종료·객체 삭제 시 자동으로 만료 처리됩니다.

// 세션 브로드캐스트
void broadcast(const Message& msg) {
    for (auto& w : sessions_) {
        if (auto s = w.lock()) s->send(msg);
    }
}
// 타이머 콜백: 객체가 아직 있으면 업데이트, 없으면 무시
void scheduleUpdate(std::weak_ptr<GameObject> obj, int delayMs) {
    timer.schedule(delayMs, [obj]() {
        if (auto o = obj.lock()) o->update();
    });
}

순환 참조 진단

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

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

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


언제 weak_ptr을 쓰는가: 캐릭터와 길드

”소유” vs “참조만”

  • 소유 관계: 이 객체가 없어지면 저 객체도 의미가 없거나 같이 정리돼야 함 → shared_ptr.
  • 참조만: “저쪽이 있으면 쓰며, 없으면(이미 삭제됐으면) 무시” → weak_ptr.

예: 게임에서 캐릭터와 길드

  • 캐릭터는 자신이 속한 길드를 알아야 합니다. 길드가 삭제되면(해체되면) 캐릭터는 “길드 없음”이 되면 됩니다.
  • 길드는 소속 캐릭터 목록을 가집니다. 캐릭터가 로그아웃하거나 삭제되면 목록에서만 빠지면 됩니다. 이때 길드 → 캐릭터와 캐릭터 → 길드 모두 “소유”가 아니라 “참조만”이면 weak_ptr이 적합합니다. 길드가 멤버를 weak로, 캐릭터가 길드를 weak로 두면 순환이 끊기고, 한쪽이 먼저 삭제돼도 만료 처리만 하면 됩니다.
  • 캐릭터의 “내 길드” → weak_ptr<Guild>: lock()으로 유효할 때만 사용.
  • 길드의 “멤버 목록” → weak_ptr<Character>: lock()으로 살아 있는 캐릭터만 순회. weak_ptr을 쓰는 전형적인 상황은 “서로를 참조하지만, 한쪽이 먼저 죽을 수 있고, 그쪽을 ‘소유’하면 안 되는 관계”입니다.

면접에서 이렇게 답하기

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

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

Q: weak_ptr을 언제 쓰나요?

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

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

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

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 객체는 소멸했는데 weak_ptr이 남아 있으면 메모리가 바로 반환되지 않는 이유는 무엇인가요?

A. make_shared는 객체와 제어 블록을 한 덩어리로 할당합니다. 강한 참조가 0이 되면 소멸자는 호출되지만, weak_ptr이 하나라도 남아 있는 동안에는 제어 블록을 해제할 수 없어 같은 덩어리인 객체 메모리도 함께 남습니다. 큰 객체를 캐시처럼 오래 사는 weak_ptr로 참조한다면 만료된 weak_ptr을 주기적으로 정리하거나, 객체와 제어 블록이 따로 할당되는 방식으로 생성하는 것을 고려합니다.

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

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

Q. 순환 참조가 여러 개일 때는?

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

Q. weak_ptr의 오버헤드는 어떤가요?

A. lock() 호출 시 atomic 연산이 필요해 shared_ptr 복사와 비슷한 비용이 듭니다. 다만 “저장만 하고 가끔 접근”하는 패턴에서는 shared_ptr로 보관하는 것보다 메모리 해제가 잘 되어 전체적으로 유리한 경우가 많습니다.


이전 글: C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론 [#33-2] 다음 글: 멀티스레드 Data Race와 Mutex/Atomic