C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]
이 글의 핵심
shared_ptr 순환 참조로 메모리 누수가 발생하는 4가지 시나리오(부모-자식, 옵저버, 그래프, 캐시). 완전한 예제 코드, 자주 하는 실수, best practice, 프로덕션 패턴까지. C++ 실전 가이드 시리즈.
들어가며: shared_ptr 순환 참조, 왜 이렇게 자주 터지나요?
실무에서 겪는 문제 시나리오
시나리오 1: 채팅 서버 Room-Session 누수
채팅 서버에서 Room이 Session을 shared_ptr로 보관하고, Session이 자신이 속한 Room을 shared_ptr로 참조했습니다. 사용자가 채팅방을 나가도 Room과 Session이 서로를 잡고 있어 메모리가 해제되지 않았습니다. 24시간 운영 시 수천 개의 “좀비” Room이 쌓여 OOM이 발생했습니다.
- 증상:
top/htop에서 프로세스 RSS가 시간이 지날수록 증가. 채팅방 입퇴장 반복 시 메모리 선형 증가. - 원인 분석:
Session::leave()호출 후에도Room의participants_에서 제거되지 않거나,Session이Room을 shared_ptr로 보관해 순환 발생. - 해결:
Session→Room을weak_ptr로 변경.Room이Session을 소유하는 단방향 구조로 설계.
시나리오 2: DOM 트리 파서 메모리 폭증
HTML 파서에서 노드가 부모와 자식을 모두 shared_ptr로 가리켰습니다. 큰 문서를 파싱한 뒤 트리를 버려도 부모↔자식 순환 때문에 노드가 해제되지 않아, 수백 MB가 누적되었습니다.
- 증상: 10MB HTML 파싱 후 트리 해제해도 프로세스 메모리가 감소하지 않음.
valgrind --leak-check=full로는 “누수 없음”으로 나옴(프로그램 종료 시 OS가 회수). - 원인 분석:
child->parent = root로 양방향 shared_ptr.root해제 시children만 해제되고,child가root를 잡고 있어root의 참조 카운트가 0이 안 됨. - 해결:
parent를weak_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로 하거나, 그래프 전체를Graph가unique_ptr로 소유하고 노드 간 참조는 인덱스/raw 포인터로.
이 글에서는 shared_ptr 순환 참조가 발생하는 4가지 전형적인 패턴(부모-자식, 옵저버, 그래프, 캐시)을 완전한 예제 코드로 다루고, 자주 하는 실수, best practice, 프로덕션 패턴까지 정리합니다.
이 글에서 다루는 것:
- 문제 시나리오: 채팅 서버, DOM, 이벤트 버스, 그래프
- 4가지 순환 참조 패턴: 부모-자식, 옵저버, 그래프, 캐시
- 완전한 예제: Before/After 실행 가능 코드
- 자주 하는 실수: use-after-free, lock 실패, 방향 선택 오류
- best practice: 소유권 설계, lock 패턴
- 프로덕션 패턴: Room-Session, 리소스 캐시, 이벤트 시스템
목차
- 순환 참조의 본질: 참조 카운트가 0이 안 되는 이유
- 패턴 1: 부모-자식 트리
- 패턴 2: 옵저버 패턴
- 패턴 3: 그래프 노드
- 패턴 4: 리소스 캐시
- 자주 하는 실수와 해결법
- best practice와 설계 원칙
- 프로덕션 패턴
- 순환 참조 진단과 디버깅
- 성능 고려: shared_ptr vs weak_ptr
- 자주 묻는 질문 (FAQ)
- 면접에서 이렇게 답하기
- 체크리스트와 정리
개념을 잡는 비유
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을 얻어, 자식의 parent에 weak_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_ptr | weak_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_ptr | Observer→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]