[2026] 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, 리소스 캐시, 이벤트 시스템 실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 순환 참조의 본질: 참조 카운트가 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이 되면 객체가 해제됩니다.
아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
flowchart LR
subgraph normal["정상: 단방향 참조"]
M1[main] -->|shared_ptr| A1[A]
A1 -->|shared_ptr| B1[B]
note1["main 해제 → A:0 → B:0 순차 해제"]
end
아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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로 바꾸면 순환이 끊깁니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 해제 → 정상 소멸
실행 결과: 아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
root use_count: 1
child use_count: 2
child의 부모: root
TreeNode 'child' 소멸
TreeNode 'root' 소멸
enable_shared_from_this 활용 (부모가 자식 추가 시)
아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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만 수신
}
실행 결과: 다음은 간단한 text 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
[구독자1] 첫 공지
[구독자2] 첫 공지
Observer '구독자2' 소멸
[구독자1] 두 번째 공지
핵심: Subject가 구독자를 weak_ptr로 보관하므로, Observer가 먼저 소멸해도 Subject가 수명을 늘리지 않습니다. notify() 시 expired()인 항목을 제거하고 lock()으로 유효한 구독자만 호출합니다.
4. 패턴 3: 그래프 노드
문제: 양방향 엣지가 있는 그래프
노드가 이웃을 shared_ptr로 가리키면, A↔B 같은 양방향 엣지에서 순환이 발생합니다. 복잡한 그래프에서는 여러 노드가 서로를 참조해 대량 누수가 발생합니다.
❌ 잘못된 예: 이웃을 shared_ptr로
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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로 두면 됩니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 포인터 또는 인덱스로 하는 방법입니다. 순환 참조 자체가 생기지 않습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 캐시
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 캐시
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 만료 → 다시 로드
}
실행 결과: 아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
캐시 미스: grass.png
Texture 로드: grass.png
캐시 히트: grass.png
Texture 해제: grass.png
캐시 미스: grass.png
Texture 로드: grass.png
설명: 캐시가 weak_ptr만 보관하므로, 외부 shared_ptr이 모두 사라지면 리소스가 자동으로 해제됩니다. 이후 get() 호출 시 lock()이 실패하면 캐시에서 제거하고 새로 로드합니다.
6. 자주 하는 실수와 해결법
실수 1: lock() 결과를 검사하지 않음
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험: 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로 바꿔야 합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 올바름: 캐릭터가 길드를 소유하지 않음
struct Character {
std::weak_ptr<Guild> guild;
};
struct Guild {
std::vector<std::shared_ptr<Character>> members; // 길드가 멤버 관리
};
// ❌ 잘못됨: 길드가 멤버를 weak로? → 캐릭터가 먼저 삭제되면 목록만 비어 있음
// 설계에 따라 다르지만, "멤버 목록 관리"는 보통 shared
실수 3: expired()만 믿고 lock() 생략
멀티스레드에서는 expired() 체크 후 lock() 사이에 객체가 해제될 수 있습니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험
if (!w.expired()) {
auto p = w.lock(); // p가 비어 있을 수 있음
}
// ✅ 안전: lock() 결과만 신뢰
if (auto p = w.lock()) {
// p 유효 보장
}
실수 4: lock() 결과를 멤버로 오래 보관
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ⚠️ 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) 사용
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 위험: 새 제어 블록 생성, 이중 해제 가능
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 예외가 발생합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 위험: 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이 발생합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 위험
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로 끊으려 했던 수명 관리가 무효화됩니다.
다음은 cpp를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 위험: 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
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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: 비동기 콜백 수명 관리
아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
void asyncFetch(std::weak_ptr<Widget> widget) {
fetchFromNetwork([widget](Response r) {
if (auto w = widget.lock()) {
w->onDataReceived(r); // 위젯이 살아 있으면 업데이트
}
// 위젯이 이미 닫혔으면 무시
});
}
패턴 3: 게임 캐릭터-길드 (상세 예제)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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: 이벤트 버스 구독자
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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를 사용하면, 호스트가 플러그인을 먼저 해제해도 안전합니다.
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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로 소유합니다. 사용이 끝나면 자동 해제됩니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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. 순환 참조 진단과 디버깅
진단 흐름도
순환 참조를 의심할 때 따라갈 수 있는 진단 흐름입니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 이상이면 다른 곳에서 참조를 잡고 있다는 뜻입니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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 증가로 의심하면 됩니다. 아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# 메모리 누수 검사
valgrind --leak-check=full ./my_app
# 24시간 실행 후 메모리 사용량 모니터링
# top 또는 /proc/self/status의 VmRSS 확인
ASan + 수동 해제 확인
소멸자에 로그를 넣어 “객체가 해제되는 시점”을 확인합니다. 스코프를 벗어나도 소멸자가 호출되지 않으면 순환 참조를 의심합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
struct MyNode {
~MyNode() {
std::cout << "MyNode 소멸: " << id << "\n"; // 이게 안 나오면 순환
}
};
GDB/LLDB로 참조 경로 추적
shared_ptr의 제어 블록을 조사해 “누가 이 객체를 참조하는지” 추적할 수 있습니다. (구현체에 따라 다름)
다음은 간단한 bash 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# 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 등으로 검색하시면 이 글이 도움이 됩니다.
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가? 이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.