C++ shared_ptr vs unique_ptr | "어떤 스마트 포인터?" 선택 가이드
이 글의 핵심
C++ shared_ptr vs unique_ptr에 대한 실전 가이드입니다.
들어가며: “shared_ptr을 써야 할까, unique_ptr을 써야 할까?"
"메모리 누수가 무서워서 전부 shared_ptr로 바꿨어요”
C++11부터 스마트 포인터가 표준 라이브러리에 추가되어 메모리 누수를 자동으로 방지할 수 있게 되었습니다. 하지만 shared_ptr과 unique_ptr 중 어느 것을 써야 할지 헷갈리는 경우가 많습니다.
비유로 말씀드리면, unique_ptr은 집 열쇠를 한 사람만 가진 소유이고, shared_ptr은 여러 사람이 같은 열쇠를 복제해 공유하는 형태입니다. 공유할수록 열쇠 개수를 세는 비용(참조 카운트)이 들고, 서로가 서로를 가리키면 순환이 생길 수 있습니다.
// unique_ptr: 단독 소유
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// shared_ptr: 공유 소유
std::shared_ptr<int> ptr2 = std::make_shared<int>(42);
std::shared_ptr<int> ptr3 = ptr2; // 참조 카운트 증가
이 글에서 다루는 것:
- shared_ptr과 unique_ptr의 차이
- 소유권 모델 (단독 vs 공유)
- 성능 오버헤드 비교
- 순환 참조와 weak_ptr
- 상황별 선택 가이드
언제 unique_ptr을, 언제 shared_ptr을 쓰나요?
| 관점 | unique_ptr | shared_ptr |
|---|---|---|
| 성능 | 오버헤드가 거의 없음 (raw 포인터와 크기 동일에 가깝) | 참조 카운트 원자적 증감 등으로 비용이 큼 |
| 사용성 | 소유권이 한 곳으로 명확할 때 설계가 단순 | 여러 컴포넌트가 같은 객체 수명을 공유해야 할 때 |
| 적용 시나리오 | 팩토리에서 반환, 컨테이너 소유, 일반적인 기본 선택 | 캐시·그래프·관찰자 등 다중 소유가 도메인에 맞을 때 |
기본은 unique_ptr로 두시고, 정말로 공유 소유가 필요할 때만 shared_ptr을 쓰시는 편이 안전합니다.
목차
1. 소유권 모델 비교
unique_ptr: 단독 소유 (Exclusive Ownership)
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// ❌ 복사 불가
// std::unique_ptr<int> ptr2 = ptr1; // 컴파일 에러
// ✅ 이동 가능
std::unique_ptr<int> ptr2 = std::move(ptr1); // 소유권 이전
// 이제 ptr1은 nullptr, ptr2가 소유
특징:
- 한 번에 하나의 소유자만 가능
- 복사 불가, 이동 가능
- 소유권이 명확함
shared_ptr: 공유 소유 (Shared Ownership)
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
// ✅ 복사 가능
std::shared_ptr<int> ptr2 = ptr1; // 참조 카운트 2
std::shared_ptr<int> ptr3 = ptr1; // 참조 카운트 3
// ptr1, ptr2, ptr3 모두 소멸되면 메모리 해제
특징:
- 여러 소유자 가능
- 참조 카운트로 관리
- 마지막 소유자가 소멸될 때 해제
소유권 다이어그램
flowchart TB
subgraph unique_ptr
U1[unique_ptr] --> Obj1[Object]
end
subgraph shared_ptr
S1[shared_ptr] --> Obj2[Object]
S2[shared_ptr] --> Obj2
S3[shared_ptr] --> Obj2
Obj2 -.->|"ref count = 3"| RC[Control Block]
end
2. 성능 오버헤드 비교
메모리 크기
// raw 포인터
int* raw = new int(42);
// 크기: 8바이트 (64비트)
// unique_ptr
std::unique_ptr<int> uptr = std::make_unique<int>(42);
// 크기: 8바이트 (raw 포인터와 동일)
// shared_ptr
std::shared_ptr<int> sptr = std::make_shared<int>(42);
// 크기: 16바이트 (포인터 8바이트 + 제어 블록 포인터 8바이트)
// 제어 블록: 참조 카운트, weak 카운트 등 (별도 힙 할당)
할당 비용
// unique_ptr: 1번 할당
auto uptr = std::make_unique<int>(42);
// malloc 1번: int 객체
// shared_ptr: 1번 할당 (make_shared 사용 시)
auto sptr = std::make_shared<int>(42);
// malloc 1번: int 객체 + 제어 블록 (함께 할당)
// shared_ptr: 2번 할당 (new 사용 시)
std::shared_ptr<int> sptr2(new int(42));
// malloc 2번: int 객체 + 제어 블록 (따로 할당)
권장: make_shared를 사용하세요 (할당 1번).
복사 비용
// unique_ptr: 복사 불가, 이동 O(1)
std::unique_ptr<int> uptr1 = std::make_unique<int>(42);
std::unique_ptr<int> uptr2 = std::move(uptr1); // O(1), 포인터만 이동
// shared_ptr: 복사 O(1) (참조 카운트 증가)
std::shared_ptr<int> sptr1 = std::make_shared<int>(42);
std::shared_ptr<int> sptr2 = sptr1; // O(1), 참조 카운트 증가 (atomic)
벤치마크
// 100만 번 할당/해제
void benchUniquePtr() {
for (int i = 0; i < 1000000; ++i) {
auto ptr = std::make_unique<int>(i);
}
}
void benchSharedPtr() {
for (int i = 0; i < 1000000; ++i) {
auto ptr = std::make_shared<int>(i);
}
}
결과:
| 방법 | 시간 | 상대 속도 |
|---|---|---|
| unique_ptr | 850ms | 1.0x (기준) |
| shared_ptr (make_shared) | 920ms | 1.08x (약간 느림) |
| shared_ptr (new) | 1100ms | 1.29x (더 느림) |
분석: unique_ptr이 가장 빠름, shared_ptr는 제어 블록 오버헤드.
3. 사용법 비교
생성
// unique_ptr
auto uptr = std::make_unique<int>(42); // C++14
std::unique_ptr<int> uptr2(new int(42)); // C++11 (비권장)
// shared_ptr
auto sptr = std::make_shared<int>(42); // 권장
std::shared_ptr<int> sptr2(new int(42)); // 비권장 (할당 2번)
배열
// unique_ptr: 배열 지원
std::unique_ptr<int[]> uarr = std::make_unique<int[]>(10);
uarr[0] = 42; // operator[] 사용 가능
// 자동으로 delete[] 호출
// shared_ptr: 배열 지원 (C++17)
std::shared_ptr<int[]> sarr = std::make_shared<int[]>(10);
sarr[0] = 42;
// C++11/14에서는 커스텀 삭제자 필요
std::shared_ptr<int> sarr2(new int[10], std::default_delete<int[]>());
함수 전달
// unique_ptr: 소유권 이전
void takeOwnership(std::unique_ptr<int> ptr) {
// 소유권 이전됨
}
std::unique_ptr<int> uptr = std::make_unique<int>(42);
takeOwnership(std::move(uptr)); // 이동
// uptr은 이제 nullptr
// unique_ptr: 단순 사용 (소유권 유지)
void useOnly(int* ptr) {
std::cout << *ptr << '\n';
}
std::unique_ptr<int> uptr2 = std::make_unique<int>(42);
useOnly(uptr2.get()); // raw 포인터 전달
// uptr2는 여전히 유효
// shared_ptr: 복사로 전달
void share(std::shared_ptr<int> ptr) {
// 참조 카운트 증가
}
std::shared_ptr<int> sptr = std::make_shared<int>(42);
share(sptr); // 복사
// sptr 여전히 유효
커스텀 삭제자
// unique_ptr: 타입의 일부
auto deleter = { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(deleter)> file(fopen("data.txt", "r"), deleter);
// shared_ptr: 타입과 무관
auto deleter2 = { if (f) fclose(f); };
std::shared_ptr<FILE> file2(fopen("data.txt", "r"), deleter2);
4. 순환 참조와 weak_ptr
순환 참조 문제
// ❌ 메모리 누수
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // a의 참조 카운트: 1, b의 참조 카운트: 2
b->prev = a; // a의 참조 카운트: 2, b의 참조 카운트: 2
// a, b가 스코프를 벗어나도 참조 카운트가 1로 유지 → 누수!
weak_ptr로 해결
// ✅ 순환 끊기
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // weak_ptr: 참조 카운트 증가 안 함
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b의 참조 카운트: 2
b->prev = a; // a의 참조 카운트: 1 (weak_ptr는 증가 안 함)
// a, b가 스코프를 벗어나면 정상 해제
weak_ptr 사용법
std::shared_ptr<int> sptr = std::make_shared<int>(42);
std::weak_ptr<int> wptr = sptr; // weak 참조
// weak_ptr은 직접 역참조 불가
// std::cout << *wptr << '\n'; // 컴파일 에러
// lock()으로 shared_ptr 얻기
if (auto locked = wptr.lock()) { // 객체가 살아있으면 shared_ptr 반환
std::cout << *locked << '\n'; // 42
} else {
std::cout << "Object destroyed\n";
}
5. 상황별 선택 가이드
결정 트리
Q1. 소유권을 공유해야 하는가?
Yes → shared_ptr
No → Q2
Q2. 소유권을 이전해야 하는가?
Yes → unique_ptr (std::move)
No → Q3
Q3. 단순 사용만 하는가?
Yes → raw 포인터 또는 참조
No → unique_ptr (기본)
상황별 권장
| 상황 | 권장 | 이유 |
|---|---|---|
| 기본 선택 | unique_ptr | 오버헤드 없음, 명확한 소유권 |
| 소유권 공유 | shared_ptr | 여러 곳에서 접근 |
| 팩토리 함수 반환 | unique_ptr | 소유권 이전 |
| 콜백 저장 | shared_ptr | 수명 보장 |
| 부모-자식 관계 | unique_ptr (부모), raw ptr (자식) | 명확한 소유권 |
| 순환 참조 가능 | shared_ptr + weak_ptr | 누수 방지 |
| 캐시 | weak_ptr | 선택적 보관 |
실전 예제
예제 1: 팩토리 패턴
// unique_ptr 반환 (소유권 이전)
std::unique_ptr<Widget> createWidget(WidgetType type) {
switch (type) {
case WidgetType::Button:
return std::make_unique<Button>();
case WidgetType::Label:
return std::make_unique<Label>();
}
}
// 사용
auto widget = createWidget(WidgetType::Button);
// widget이 소유권을 가짐
예제 2: 옵저버 패턴
// shared_ptr: 옵저버 수명 보장
class Subject {
std::vector<std::shared_ptr<Observer>> observers_;
public:
void attach(std::shared_ptr<Observer> obs) {
observers_.push_back(obs);
}
void notify() {
for (auto& obs : observers_) {
obs->update(); // 옵저버가 살아있음 보장
}
}
};
예제 3: 트리 구조
// unique_ptr: 부모 → 자식 (소유)
// raw ptr: 자식 → 부모 (비소유)
struct TreeNode {
int value;
std::unique_ptr<TreeNode> left; // 소유
std::unique_ptr<TreeNode> right; // 소유
TreeNode* parent; // 비소유 (부모는 자식을 소유하지만, 자식은 부모를 소유 안 함)
TreeNode(int v, TreeNode* p = nullptr)
: value(v), parent(p) {}
};
// 사용
auto root = std::make_unique<TreeNode>(1);
root->left = std::make_unique<TreeNode>(2, root.get());
root->right = std::make_unique<TreeNode>(3, root.get());
// root 소멸 시 left, right 자동 소멸
예제 4: 캐시 (weak_ptr)
// weak_ptr: 캐시가 객체를 소유하지 않음
class Cache {
std::unordered_map<Key, std::weak_ptr<Value>> cache_;
public:
std::shared_ptr<Value> get(const Key& key) {
auto it = cache_.find(key);
if (it != cache_.end()) {
if (auto locked = it->second.lock()) { // 살아있으면
return locked; // 캐시 히트
} else {
cache_.erase(it); // 죽었으면 제거
}
}
// 캐시 미스: 새로 생성
auto value = std::make_shared<Value>(loadFromDB(key));
cache_[key] = value; // weak_ptr로 저장
return value;
}
};
성능 비교 상세
벤치마크: 생성/소멸
// 100만 번 생성/소멸
void benchUniquePtr() {
for (int i = 0; i < 1000000; ++i) {
auto ptr = std::make_unique<int>(i);
} // 자동 소멸
}
void benchSharedPtr() {
for (int i = 0; i < 1000000; ++i) {
auto ptr = std::make_shared<int>(i);
} // 자동 소멸
}
결과:
| 방법 | 시간 | 상대 속도 |
|---|---|---|
| raw ptr (new/delete) | 850ms | 1.0x (기준) |
| unique_ptr | 850ms | 1.0x (동일) |
| shared_ptr (make_shared) | 920ms | 1.08x (약간 느림) |
| shared_ptr (new) | 1100ms | 1.29x (더 느림) |
분석: unique_ptr는 raw 포인터와 동일, shared_ptr는 약간 느림.
벤치마크: 복사
// shared_ptr 복사 (참조 카운트 증감)
std::shared_ptr<int> sptr = std::make_shared<int>(42);
for (int i = 0; i < 1000000; ++i) {
std::shared_ptr<int> copy = sptr; // atomic 증가
} // atomic 감소
결과:
| 연산 | 시간 |
|---|---|
| shared_ptr 복사 | 120ms |
| unique_ptr 이동 | 15ms |
분석: shared_ptr 복사는 atomic 연산이 필요해 느림.
정리
선택 기준 요약
기본은 unique_ptr을 사용하세요 (오버헤드 없음)
예외 상황:
- 소유권 공유 필요 → shared_ptr
- 순환 참조 가능 → shared_ptr + weak_ptr
- 콜백 저장 → shared_ptr (수명 보장)
- 캐시 → weak_ptr (선택적 보관)
성능 순위
메모리 크기: unique_ptr (8B) < shared_ptr (16B + 제어 블록) 할당 속도: unique_ptr ≈ shared_ptr (make_shared) 복사 속도: unique_ptr (불가능) < shared_ptr (atomic) 이동 속도: unique_ptr ≈ shared_ptr
핵심 규칙
- 기본은 unique_ptr (99%의 경우)
- 소유권 공유가 필요하면 shared_ptr
- make_unique, make_shared 사용 (예외 안전성)
- 순환 참조는 weak_ptr로 끊기
- raw 포인터는 비소유 참조로만 (delete 금지)
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 스마트 포인터 완벽 가이드 | unique_ptr·shared_ptr·weak_ptr
- C++ RAII 패턴 | 리소스 자동 관리
- C++ 메모리 누수 | shared_ptr 순환 참조 해결
- C++ 이동 의미론 | std::move 완벽 가이드
이 글에서 다루는 키워드 (관련 검색어)
shared_ptr vs unique_ptr, 스마트 포인터 비교, 소유권 모델, 순환 참조, weak_ptr 등으로 검색하시면 이 글이 도움이 됩니다.
실전 팁
실무에서 바로 적용할 수 있는 팁입니다.
디버깅 팁
- 순환 참조는 Valgrind로 탐지하세요
- shared_ptr의 참조 카운트는 use_count()로 확인하세요
- weak_ptr은 expired()로 유효성 체크하세요
성능 팁
- unique_ptr을 우선 사용하세요 (오버헤드 없음)
- make_shared를 사용하세요 (할당 1번)
- 불필요한 shared_ptr 복사를 피하세요 (const 참조 전달)
코드 리뷰 팁
- shared_ptr을 보면 공유가 정말 필요한지 물어보세요
- unique_ptr을 복사하려는 코드는 설계 재검토하세요
- 순환 참조 가능성을 체크하세요
마치며
unique_ptr과 shared_ptr의 선택은 소유권 모델에 달려 있습니다.
핵심 원칙:
- 기본은 unique_ptr (오버헤드 없음, 명확한 소유권)
- 소유권 공유가 필요하면 shared_ptr
- 순환 참조는 weak_ptr로 끊기
- make_unique, make_shared 사용
대부분의 경우 unique_ptr로 충분합니다. shared_ptr는 정말 필요할 때만 사용하세요. 불필요한 shared_ptr 사용은 성능 저하와 순환 참조 위험을 높입니다.
다음 단계: 스마트 포인터를 이해했다면, C++ 이동 의미론과 C++ 완벽한 전달로 더 효율적인 코드를 작성해 보세요.
관련 글
- C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
- C++ 스마트 포인터 | unique_ptr/shared_ptr
- C++ malloc vs new vs make_unique | 메모리 할당 완벽 비교
- C++ 순환 참조 | shared_ptr 메모리 누수
- C++ RAII & Smart Pointers |