C++ shared_ptr vs unique_ptr | "어떤 스마트 포인터?" 선택 가이드

C++ shared_ptr vs unique_ptr | "어떤 스마트 포인터?" 선택 가이드

이 글의 핵심

C++ shared_ptr vs unique_ptr에 대한 실전 가이드입니다.

들어가며: “shared_ptr을 써야 할까, unique_ptr을 써야 할까?"

"메모리 누수가 무서워서 전부 shared_ptr로 바꿨어요”

C++11부터 스마트 포인터가 표준 라이브러리에 추가되어 메모리 누수를 자동으로 방지할 수 있게 되었습니다. 하지만 shared_ptrunique_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_ptrshared_ptr
성능오버헤드가 거의 없음 (raw 포인터와 크기 동일에 가깝)참조 카운트 원자적 증감 등으로 비용이 큼
사용성소유권이 한 곳으로 명확할 때 설계가 단순여러 컴포넌트가 같은 객체 수명을 공유해야 할 때
적용 시나리오팩토리에서 반환, 컨테이너 소유, 일반적인 기본 선택캐시·그래프·관찰자 등 다중 소유가 도메인에 맞을 때

기본은 unique_ptr로 두시고, 정말로 공유 소유가 필요할 때만 shared_ptr을 쓰시는 편이 안전합니다.


목차

  1. 소유권 모델 비교
  2. 성능 오버헤드 비교
  3. 사용법 비교
  4. 순환 참조와 weak_ptr
  5. 상황별 선택 가이드
  6. 정리

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_ptr850ms1.0x (기준)
shared_ptr (make_shared)920ms1.08x (약간 느림)
shared_ptr (new)1100ms1.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)850ms1.0x (기준)
unique_ptr850ms1.0x (동일)
shared_ptr (make_shared)920ms1.08x (약간 느림)
shared_ptr (new)1100ms1.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

핵심 규칙

  1. 기본은 unique_ptr (99%의 경우)
  2. 소유권 공유가 필요하면 shared_ptr
  3. make_unique, make_shared 사용 (예외 안전성)
  4. 순환 참조는 weak_ptr로 끊기
  5. 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의 선택소유권 모델에 달려 있습니다.

핵심 원칙:

  1. 기본은 unique_ptr (오버헤드 없음, 명확한 소유권)
  2. 소유권 공유가 필요하면 shared_ptr
  3. 순환 참조는 weak_ptr로 끊기
  4. 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 |