C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]

C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]

이 글의 핵심

C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]에 대한 실전 가이드입니다.

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

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

시나리오 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로 두면, 클라이언트 연결 종료 시 자연스럽게 정리됩니다.

// ❌ 문제의 코드: 순환 참조로 메모리 누수
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() 사용법, 옵저버·캐시 패턴, 자주 하는 실수, 성능 비교, 프로덕션 패턴(이벤트 시스템, 리소스 캐시).

목차

  1. shared_ptr과 참조 카운트 복습
  2. 순환 참조와 메모리 누수
  3. weak_ptr로 순환 끊기
  4. lock()과 expired() 상세 예제
  5. weak_ptr 사용 패턴: 옵저버·캐시
  6. 자주 하는 실수와 해결법 6.5. weak_ptr 사용 모범 사례
  7. 성능 비교: shared_ptr vs weak_ptr
  8. 프로덕션 패턴: 이벤트 시스템·리소스 캐시
  9. 언제 weak_ptr을 쓰는가: 캐릭터와 길드
  10. 면접에서 이렇게 답하기

개념을 잡는 비유

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


1. shared_ptr과 참조 카운트 복습

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

2. 순환 참조와 메모리 누수

순환 참조란?

순환 참조(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로 둡니다.


3. 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::guildweak_ptr이므로 길드의 참조 카운트를 올리지 않습니다. Guild::membersshared_ptr이지만, 이 관계는 “길드가 멤버 목록을 관리”하는 단방향 소유입니다. 캐릭터가 길드를 weak로만 참조하므로 순환이 끊기고, 스코프 종료 시 모든 객체가 정상 해제됩니다.


4. 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_ptruse_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을 반환합니다. listweak_ptr만 보관하므로 플레이어 수명에 영향을 주지 않습니다.


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


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

1. 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");
}

2. 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>();  // 기본값 반환
}

3. 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();

4. 순환 참조 방향 잘못 선택

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

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

5. 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();
}

6. 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 스코프 종료 → 참조 해제
    }
}

7. 스레드 안전성 오해

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 등 동기화 필요

8. 순환 참조가 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로
// 이렇게 하면 순환 끊김

9. weak_ptr 복사 vs 참조 전달

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

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

10. raw 포인터에서 weak_ptr 생성

weak_ptrshared_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;

6.5 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: 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;
}

7. 성능 비교: shared_ptr vs weak_ptr

연산 비용

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

weak_ptrlock()은 내부적으로 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을 반환합니다.


8. 프로덕션 패턴: 이벤트 시스템·리소스 캐시

이벤트 시스템

이벤트 발행자가 구독자를 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(),
                 { 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, 설정 트리)

DOM 노드, 추상 구문 트리(AST), 설정 파서의 노드 등에서 부모가 자식을, 자식이 부모를 참조할 때, 자식→부모를 weak_ptr로 두면 순환을 끊을 수 있습니다.

#include <memory>
#include <vector>

struct TreeNode : std::enable_shared_from_this<TreeNode> {
    std::string name;
    std::weak_ptr<TreeNode> parent;  // 부모는 소유하지 않음
    std::vector<std::shared_ptr<TreeNode>> children;  // 자식은 소유

    static std::shared_ptr<TreeNode> create(const std::string& n) {
        auto node = std::make_shared<TreeNode>();
        node->name = n;
        return node;
    }

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

설명: TreeNodeparent를 weak_ptr로 두므로, 부모가 먼저 해제되어도 자식이 부모를 살려 두지 않습니다. 반대로 부모가 children을 shared_ptr로 관리하므로, 부모가 살아 있는 한 자식도 유지됩니다. 루트를 해제하면 트리 전체가 순서대로 해제됩니다.

콜백/핸들러 수명 관리

비동기 콜백에서 “객체가 아직 살아 있으면 호출, 없으면 무시”할 때 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(),
             { 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();
    });
}

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

”소유” vs “참조만”

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

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

  • 캐릭터는 자신이 속한 길드를 알아야 합니다. 길드가 삭제되면(해체되면) 캐릭터는 “길드 없음”이 되면 됩니다.
  • 길드는 소속 캐릭터 목록을 가집니다. 캐릭터가 로그아웃하거나 삭제되면 목록에서만 빠지면 됩니다.

이때 길드 → 캐릭터캐릭터 → 길드 모두 “소유”가 아니라 “참조만”이면 weak_ptr이 적합합니다. 길드가 멤버를 weak로, 캐릭터가 길드를 weak로 두면 순환이 끊기고, 한쪽이 먼저 삭제돼도 만료 처리만 하면 됩니다.

  • 캐릭터의 “내 길드” → weak_ptr<Guild>: lock()으로 유효할 때만 사용.
  • 길드의 “멤버 목록” → weak_ptr<Character>: lock()으로 살아 있는 캐릭터만 순회.

weak_ptr을 쓰는 전형적인 상황은 “서로를 참조하지만, 한쪽이 먼저 죽을 수 있고, 그쪽을 ‘소유’하면 안 되는 관계”입니다.


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

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로 한쪽 끊기 → 실사용 예(캐릭터–길드)“까지 말할 수 있으면 됩니다.


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

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

  • C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]
  • C++ 스마트 포인터 | unique_ptr/shared_ptr “메모리 안전” 가이드
  • C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법

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

weak_ptr, 순환 참조, shared_ptr 메모리 누수, lock expired 등으로 검색하시면 이 글이 도움이 됩니다.

weak_ptr 적용 체크리스트

  • 소유권이 없는 쪽을 weak_ptr로 선택
  • lock() 호출 후 반환값 검사 (if (auto p = wp.lock()))
  • 만료된 weak_ptr에 직접 접근 금지 (wp.lock()->foo() ❌)
  • 멀티스레드 환경에서 lock() 결과만 신뢰
  • 옵저버/캐시에서 만료된 항목 정리

정리

  • shared_ptr만 쓰면 순환 참조(A→B→A) 시 참조 카운트가 0이 안 되어 메모리 누수가 난다.
  • weak_ptr은 참조 카운트를 올리지 않아, “한쪽 방향”을 weak로 바꾸면 순환이 끊긴다. 필요할 때 lock()으로 유효한 shared_ptr을 얻어 사용한다.
  • weak_ptr 쓰임새: 순환 참조 끊기, 그리고 “있으면 쓰고 없으면 만료”인 참조만 필요한 관계(예: 캐릭터–길드, 옵저버, 캐시)에서 사용한다.
  • lock() 결과만 신뢰하고, expired()는 보조 수단으로만 사용한다.
  • 프로덕션에서는 이벤트 시스템, 리소스 캐시 등에서 weak_ptr을 활용해 수명을 안전하게 관리한다.

실전 체크리스트

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

코드 작성 전

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

코드 작성 중

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

코드 리뷰 시

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

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


자주 묻는 질문 (FAQ)

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

A. 게임 서버(캐릭터–길드), GUI 이벤트 구독, 리소스 캐시, 옵저버 패턴 등에서 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로 보관하는 것보다 메모리 해제가 잘 되어 전체적으로 유리한 경우가 많습니다.

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

A. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요.

한 줄 요약: weak_ptr로 순환 참조를 끊고 shared_ptr만으로는 해결되지 않는 구조를 다룰 수 있습니다. 다음으로 Data Race·Mutex·Atomic(#34-1)를 읽어보면 좋습니다.

이전 글: C++ 면접 #33-2: 얕은/깊은 복사·이동 의미론

다음 글: C++ shared_ptr 순환 참조 완전 정복 (부모-자식·옵저버·그래프·캐시)


관련 글

  • C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]
  • C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
  • C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]
  • C++ 순환 참조 | shared_ptr 메모리 누수
  • C++ Data Race |