C++에서 스마트 포인터를 공부하다 보면 std::unique_ptr 다음으로 std::shared_ptr을 만나게 됩니다.
이름만 보면 여러 곳에서 함께 쓰기 좋은 포인터처럼 보입니다.
하지만 shared_ptr은 단순히 “복사 가능한 스마트 포인터”가 아닙니다.
shared_ptr은 하나의 객체를 여러 소유자가 함께 책임지는 구조를 표현합니다.
shared_ptr은 공유 소유권을 표현하는 도구이고, weak_ptr은 그 소유권에 참여하지 않고 객체를 관찰하는 도구입니다.
이번 글에서는 std::shared_ptr이 어떤 방식으로 객체 수명을 관리하는지, 왜 순환 참조가 문제가 되는지, 그리고 std::weak_ptr을 언제 사용해야 하는지 코드 예제로 정리해보겠습니다.
1. shared_ptr은 무엇인가?
std::shared_ptr은 C++ 표준 라이브러리가 제공하는 스마트 포인터입니다.
핵심은 공유 소유권입니다.
여러 shared_ptr이 같은 객체를 가리킬 수 있고, 마지막 shared_ptr이 사라질 때 객체가 자동으로 해제됩니다.
| 구분 | 의미 |
| std::unique_ptr | 하나의 객체가 자원을 단독으로 소유 |
| std::shared_ptr | 여러 객체가 같은 자원을 공유 소유 |
| std::weak_ptr | 자원을 소유하지 않고 살아 있는지만 관찰 |
| raw pointer | 소유권이 없거나 불명확할 수 있음 |
shared_ptr은 내부적으로 참조 카운트를 관리합니다.
같은 객체를 소유하는 shared_ptr이 늘어나면 카운트가 증가하고, shared_ptr이 사라지면 카운트가 감소합니다.
카운트가 0이 되는 순간 객체가 해제됩니다.
그래서 shared_ptr은 “이 객체를 누가 마지막까지 책임질지 명확하지 않지만, 여러 곳에서 소유해야 하는 상황”에 사용합니다.
2. 왜 shared_ptr이 필요한가?
대부분의 경우에는 unique_ptr이 더 좋은 기본 선택입니다.
소유자가 하나라면 객체의 생성과 해제 책임을 추적하기 쉽기 때문입니다.
하지만 실무에서는 객체 하나를 여러 컴포넌트가 함께 사용하는 상황도 있습니다.
- 비동기 작업과 UI가 같은 세션 객체를 함께 참조한다.
- 캐시와 서비스 객체가 같은 설정 객체를 공유한다.
- 이벤트 구독자와 작업 큐가 같은 작업 상태를 참조한다.
- 여러 노드가 같은 리소스 객체를 공유한다.
이런 상황에서 raw pointer만 사용하면 객체가 이미 사라졌는데 접근하는 문제가 생길 수 있습니다.
반대로 무조건 객체를 복사하면 비용이 크거나, 복사 자체가 의미적으로 맞지 않을 수 있습니다.
shared_ptr은 이 중간 지점에서 사용할 수 있습니다.
객체를 복사하는 것이 아니라, 같은 객체의 소유권을 여러 shared_ptr이 나누어 갖습니다.
3. 참조 카운트 흐름을 그림으로 보기
shared_ptr의 객체 관리 흐름은 다음처럼 볼 수 있습니다.
- shared_ptr reference count
std::make_shared<Session>()
|
v
객체 생성 + control block 생성
|
v
shared_ptr A --------------+
|
shared_ptr B --------------+--> 같은 Session 객체
|
shared_ptr C --------------+
참조 카운트: 3
A, B, C가 차례로 사라짐
|
v
참조 카운트가 0이 되는 순간 Session 소멸
여기서 중요한 것은 shared_ptr이 포인터 값만 들고 있는 것이 아니라는 점입니다.
shared_ptr은 객체 포인터와 함께 참조 카운트를 관리하는 control block을 사용합니다.
그래서 shared_ptr을 복사하면 단순한 포인터 복사보다 비용이 더 있습니다.
특히 멀티스레드 환경에서는 참조 카운트 증가와 감소가 동기화되어야 하므로 비용을 의식해야 합니다.
4. make_shared를 기본으로 사용하자
shared_ptr을 만들 때는 보통 std::make_shared를 사용하는 것이 좋습니다.
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class User {
public:
explicit User(std::string name) : name_(std::move(name)) {
std::cout << "User created: " << name_ << '\n';
}
~User() {
std::cout << "User destroyed: " << name_ << '\n';
}
private:
std::string name_;
};
int main() {
auto user = std::make_shared<User>("kim");
{
auto copied = user;
std::cout << "inside count: " << user.use_count() << '\n';
}
std::cout << "outside count: " << user.use_count() << '\n';
}
실행 결과
User created: kim
inside count: 2
outside count: 1
User destroyed: kim
copied가 만들어지는 동안 같은 User 객체를 소유하는 shared_ptr이 두 개가 됩니다.
블록을 벗어나 copied가 사라지면 참조 카운트는 다시 1이 됩니다.
마지막으로 main 함수가 끝나 user까지 사라지면 User 객체가 해제됩니다.
이처럼 shared_ptr은 객체의 수명을 참조 카운트로 관리합니다.
5. shared_ptr을 남용하면 안 되는 이유
shared_ptr은 편리하지만, 기본 포인터처럼 아무 곳에나 쓰면 설계가 흐려질 수 있습니다.
가장 큰 문제는 소유권이 잘 보이지 않는다는 점입니다.
unique_ptr은 “여기가 주인이다”라는 의도가 코드에 드러납니다.
반면 shared_ptr이 여러 곳으로 퍼지면 객체를 실제로 누가 얼마나 오래 붙잡고 있는지 추적하기 어려워집니다.
| 상황 | 더 먼저 고려할 선택 |
| 객체 소유자가 하나다 | std::unique_ptr |
| 잠깐 읽기만 한다 | 참조 또는 raw pointer |
| 없을 수도 있는 대상을 관찰한다 | std::weak_ptr |
| 여러 곳이 수명에 책임을 가진다 | std::shared_ptr |
즉, shared_ptr은 “편하니까” 쓰는 도구가 아니라 “공유 소유가 실제 요구사항이기 때문에” 쓰는 도구입니다.
함수 인자로도 마찬가지입니다.
함수가 객체를 소유권 없이 읽기만 한다면 shared_ptr을 값으로 받을 필요가 없습니다.
void printUser(const User& user); // 읽기만 함
void maybePrintUser(const User* user); // 없을 수도 있음
void keepUser(std::shared_ptr<User> user); // 소유권을 함께 가짐
shared_ptr을 값으로 받는 함수는 참조 카운트를 증가시키고, 의미적으로도 객체 수명에 참여한다는 신호를 줍니다.
그 의도가 없다면 const 참조나 포인터가 더 적절할 수 있습니다.
6. 순환 참조 문제가 생길 수 있다
shared_ptr에서 가장 조심해야 할 문제는 순환 참조입니다.
두 객체가 서로를 shared_ptr로 소유하면 참조 카운트가 0이 되지 않을 수 있습니다.
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Node {
public:
explicit Node(std::string name) : name_(std::move(name)) {
std::cout << "Node created: " << name_ << '\n';
}
~Node() {
std::cout << "Node destroyed: " << name_ << '\n';
}
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;
private:
std::string name_;
};
int main() {
auto first = std::make_shared<Node>("first");
auto second = std::make_shared<Node>("second");
first->next = second;
second->prev = first;
std::cout << "first count: " << first.use_count() << '\n';
std::cout << "second count: " << second.use_count() << '\n';
}
실행 결과
Node created: first
Node created: second
first count: 2
second count: 2
이 코드에서는 소멸자 출력이 나오지 않습니다.
main 함수가 끝나면서 지역 변수 first와 second는 사라지지만, 두 Node 객체가 서로를 shared_ptr로 붙잡고 있기 때문입니다.
first 입장에서는 second가 자신을 소유하고 있고, second 입장에서는 first가 자신을 소유하고 있습니다.
결과적으로 참조 카운트가 0이 되지 않아 객체가 해제되지 않습니다.
7. weak_ptr로 순환 참조를 끊기
순환 참조를 끊을 때 사용하는 도구가 std::weak_ptr입니다.
weak_ptr은 shared_ptr이 관리하는 객체를 가리킬 수 있지만, 참조 카운트를 증가시키지 않습니다.
즉, 객체를 소유하지 않습니다.
앞의 Node 예제에서 prev는 보통 소유권을 가질 필요가 없습니다.
이전 노드를 관찰할 수만 있으면 충분합니다.
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Node {
public:
explicit Node(std::string name) : name_(std::move(name)) {
std::cout << "Node created: " << name_ << '\n';
}
~Node() {
std::cout << "Node destroyed: " << name_ << '\n';
}
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev;
private:
std::string name_;
};
int main() {
auto first = std::make_shared<Node>("first");
auto second = std::make_shared<Node>("second");
first->next = second;
second->prev = first;
std::cout << "first count: " << first.use_count() << '\n';
std::cout << "second count: " << second.use_count() << '\n';
}
실행 결과
Node created: first
Node created: second
first count: 1
second count: 2
Node destroyed: first
Node destroyed: second
second->prev는 first를 가리키지만 소유하지 않습니다.
그래서 first의 참조 카운트는 지역 변수 first 하나만 계산되어 1입니다.
main 함수가 끝나면 first가 먼저 해제되고, first가 가지고 있던 next도 함께 사라지면서 second도 정상적으로 해제됩니다.
이처럼 weak_ptr은 shared_ptr 구조에서 “관계는 있지만 소유권은 없는 연결”을 표현할 때 사용합니다.
8. weak_ptr은 lock해서 사용한다
weak_ptr은 객체를 소유하지 않기 때문에, 사용할 때 객체가 아직 살아 있는지 확인해야 합니다.
이때 사용하는 함수가 lock입니다.
lock을 호출하면 객체가 살아 있으면 shared_ptr을 얻고, 이미 사라졌으면 빈 shared_ptr을 얻습니다.
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
#include <utility>
class Session {
public:
explicit Session(std::string id) : id_(std::move(id)) {
std::cout << "Session created: " << id_ << '\n';
}
~Session() {
std::cout << "Session destroyed: " << id_ << '\n';
}
void send(std::string_view message) const {
std::cout << "[" << id_ << "] " << message << '\n';
}
private:
std::string id_;
};
void notify(std::weak_ptr<Session> weakSession) {
if (auto session = weakSession.lock()) {
session->send("event arrived");
} else {
std::cout << "session is already closed\n";
}
}
int main() {
std::weak_ptr<Session> watcher;
{
auto session = std::make_shared<Session>("A");
watcher = session;
std::cout << "use_count: " << session.use_count() << '\n';
notify(watcher);
}
notify(watcher);
}
실행 결과
Session created: A
use_count: 1
[A] event arrived
Session destroyed: A
session is already closed
watcher는 Session을 관찰하지만 소유하지 않습니다.
블록 안에서는 Session이 살아 있으므로 lock이 성공하고 메시지를 보낼 수 있습니다.
블록을 벗어나 Session이 해제된 뒤에는 lock이 실패하고, 빈 shared_ptr이 반환됩니다.
이 패턴은 이벤트 구독자, 캐시, 비동기 콜백처럼 대상 객체가 먼저 사라질 수 있는 구조에서 자주 사용됩니다.
9. 자주 하는 오해
9-1. shared_ptr을 쓰면 메모리 누수가 사라진다
shared_ptr은 마지막 소유자가 사라질 때 객체를 해제합니다.
하지만 순환 참조가 있으면 마지막 소유자가 사라지지 않는 구조가 될 수 있습니다.
그래서 shared_ptr을 쓴다고 해서 메모리 누수 가능성이 완전히 사라지는 것은 아닙니다.
9-2. weak_ptr은 raw pointer의 완전한 대체재다
weak_ptr은 shared_ptr이 관리하는 객체를 관찰할 때 쓰는 도구입니다.
모든 non-owning pointer를 weak_ptr로 바꿔야 한다는 뜻은 아닙니다.
객체 수명이 호출 범위 안에서 명확하다면 참조나 raw pointer가 더 단순할 수 있습니다.
9-3. use_count로 로직을 짜면 된다
use_count는 디버깅이나 학습에는 도움이 되지만, 비즈니스 로직의 기준으로 삼기에는 위험합니다.
특히 멀티스레드 환경에서는 다른 스레드가 동시에 shared_ptr을 복사하거나 해제할 수 있습니다.
실무 코드에서는 use_count 값에 의존하기보다 소유권 구조를 명확하게 설계하는 편이 좋습니다.
10. 실무에서는 어떻게 볼까?
실무에서 shared_ptr을 볼 때는 먼저 “정말 공유 소유가 필요한가?”를 확인해야 합니다.
단순히 여러 함수에서 같은 객체를 읽는다는 이유만으로 shared_ptr이 필요한 것은 아닙니다.
다음 기준을 잡아두면 코드 리뷰에서 판단하기 쉽습니다.
- 소유자가 하나면 unique_ptr을 기본으로 고려한다.
- 소유권 없이 읽기만 하면 참조를 우선 고려한다.
- 없을 수도 있는 대상을 잠깐 관찰하면 raw pointer 또는 weak_ptr을 비교한다.
- shared_ptr을 값으로 넘긴다면 객체 수명에 참여한다는 의도가 있어야 한다.
- 양방향 관계나 그래프 구조에서는 순환 참조 가능성을 반드시 확인한다.
특히 콜백과 비동기 작업에서는 shared_ptr이 객체 수명을 예상보다 오래 붙잡는 경우가 있습니다.
예를 들어 람다가 shared_ptr을 캡처하면 작업이 끝날 때까지 객체가 살아 있을 수 있습니다.
이것이 의도한 동작이면 괜찮지만, UI 객체나 세션 객체처럼 명확히 닫혀야 하는 대상이라면 weak_ptr 캡처가 더 적절할 수 있습니다.
std::weak_ptr<Session> weakSession = session;
auto callback = [weakSession] {
if (auto session = weakSession.lock()) {
session->send("callback");
}
};
이 코드는 콜백이 Session을 소유하지 않습니다.
콜백 실행 시점에 Session이 살아 있으면 사용하고, 이미 사라졌으면 아무 작업도 하지 않도록 만들 수 있습니다.
이처럼 shared_ptr과 weak_ptr은 문법보다 소유권 모델이 더 중요합니다.
11. 정리
std::shared_ptr과 std::weak_ptr은 C++에서 공유 소유권을 다룰 때 중요한 도구입니다.
- shared_ptr은 여러 소유자가 같은 객체 수명에 참여할 때 사용한다.
- shared_ptr은 참조 카운트로 객체를 관리하며, 마지막 소유자가 사라질 때 객체를 해제한다.
- 순환 참조가 생기면 참조 카운트가 0이 되지 않아 객체가 해제되지 않을 수 있다.
- weak_ptr은 객체를 소유하지 않고 관찰하며, lock으로 안전하게 접근한다.
- 기본 선택은 unique_ptr이고, shared_ptr은 공유 소유가 실제로 필요할 때 사용한다.
결국 shared_ptr을 잘 쓴다는 것은 스마트 포인터를 많이 쓰는 것이 아니라, 객체의 수명과 소유권 관계를 코드에 정확히 드러내는 것입니다.
'C++ > Concepts' 카테고리의 다른 글
| C++ unordered_map bucket과 rehash 이해하기 (0) | 2026.07.02 |
|---|---|
| C++ optional, 언제 쓰고 언제 피할까 (0) | 2026.06.29 |
| std::unique_ptr 제대로 이해하기 (0) | 2026.06.18 |
| RAII로 자원 관리 이해하기 (0) | 2026.06.15 |
| C++ std::move는 진짜 객체를 이동시킬까? (0) | 2026.06.12 |