C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법

C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법

이 글의 핵심

C++ 스마트 포인터에 대한 실전 가이드입니다. 3일 동안 찾지 못한 순환 참조 버그 해결법 등을 예제와 함께 상세히 설명합니다.

들어가며: 3일 동안 찾지 못한 순환 참조 버그

“메모리 누수가 없는데 왜 메모리가 계속 늘어나지?”

이전 글에서 unique_ptr로 메모리 누수를 해결했다고 생각했습니다. 하지만 프로그램을 장시간 실행하면 여전히 메모리가 증가했습니다.

Valgrind로 확인해도 “메모리 누수 없음”이라고 나왔습니다.(Valgrind 사용법, 누수·ASan 실전 참고) 3일 동안 코드를 뒤지다가, 결국 순환 참조(Circular Reference—A가 B를 참조하고 B가 다시 A를 참조해 서로 해제되지 않는 상태) 문제를 발견했습니다.

메모리 관리 맥락: unique_ptr·shared_ptrRAII로 스코프와 생명 주기를 묶을 때 가장 안전하게 동작하며, 소유권 이전은 이동 의미론과 함께 이해하는 것이 좋습니다. Rust의 소유권·빌림은 컴파일 타임에 유사한 규칙을 강제하고, shared_ptr의 참조 카운팅은 런타임 비용이 있다는 점에서 철학이 다릅니다. 구조체 안에서 소유권을 모델링하는 관점은 Rust 구조체와도 연결됩니다. 기본적인 누수 패턴은 메모리 누수 가이드를 참고하세요.
shared_ptr참조 카운트(해당 객체를 가리키는 shared_ptr 개수. 0이 되면 객체가 삭제됨)가 0이 되어야 객체가 파괴되는데, A가 B를 가리키고 B가 다시 A를 가리키면 둘 다 카운트가 1 이상으로 남아서 해제되지 않습니다. 이런 경우 한쪽은 weak_ptr로 바꾸거나, 소유 관계를 한 방향으로만 두는 설계가 필요합니다.

문제의 코드:

node1node2가 서로를 shared_ptr로 가리키면, node1의 참조 카운트는 node2->prev 때문에 1, node2의 참조 카운트는 node1->next 때문에 1이 됩니다. main이 끝나면 node1, node2 지역 변수가 사라지지만, 서로가 서로를 참조하고 있어서 카운트가 0이 되지 않고, 따라서 소멸자가 호출되지 않습니다. 이렇게 shared_ptr만으로는 “양방향 연결”을 만들 때 순환 참조가 생기기 쉽고, 한쪽을 weak_ptr로 바꿔서 “소유권은 없이 참조만” 하도록 해야 합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o cycle_bad cycle_bad.cpp && ./cycle_bad
#include <memory>
#include <iostream>

class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ 순환 참조!
    
    ~Node() { std::cout << "Node destroyed\n"; }
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;  // node1 → node2
    node2->prev = node1;  // node2 → node1 (순환!)
    
    // node1과 node2가 서로를 참조
    // 참조 카운트가 0이 되지 않음
    // 메모리 해제 안 됨!
    
    return 0;
}
// "Node destroyed" 출력 안 됨!

위 코드 설명: node1->next = node2node2->prev = node1으로 서로를 shared_ptr로 가리키면, 각 노드의 참조 카운트가 1씩 남습니다. main의 지역 변수 node1, node2가 사라져도 서로가 서로를 참조하고 있어 카운트가 0이 되지 않으므로 소멸자가 호출되지 않고 메모리가 해제되지 않습니다.

실행 결과: Node destroyed 가 출력되지 않습니다(순환 참조로 소멸자가 호출되지 않음).

해결 후 (아래는 복사해 붙여넣은 뒤 g++ -std=c++17 -o cycle_ok cycle_ok.cpp && ./cycle_ok 로 실행 가능):

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o cycle_ok cycle_ok.cpp && ./cycle_ok
#include <memory>
#include <iostream>

class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // ✅ weak_ptr 사용!
    
    ~Node() { std::cout << "Node destroyed\n"; }
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;
    node2->prev = node1;  // weak_ptr는 참조 카운트 증가 안 함
    
    return 0;
}
// "Node destroyed" 두 번 출력됨!

위 코드 설명: prevweak_ptr로 바꾸면 node2->prev = node1이 node1의 참조 카운트를 올리지 않습니다. main이 끝날 때 node1의 카운트가 0이 되어 먼저 소멸되고, 이어서 node2의 카운트도 0이 되어 소멸되므로 두 번 “Node destroyed”가 출력됩니다.

실행 결과: Node destroyed 가 두 번 출력됩니다.

추가 문제 시나리오

시나리오 2: 게임 엔진의 엔티티-컴포넌트 참조
엔티티가 컴포넌트를 shared_ptr로 들고, 컴포넌트가 다시 부모 엔티티를 shared_ptr로 참조하면 순환 참조가 발생합니다. 한쪽을 weak_ptr로 바꾸거나 소유 관계를 단방향으로 설계해야 합니다.

시나리오 3: 이벤트 리스너 등록
Subject가 Observer를 shared_ptr로 들고, Observer가 Subject를 shared_ptr로 참조하면 순환이 생깁니다. Observer 쪽은 weak_ptr로 Subject를 참조하는 것이 안전합니다.

시나리오 4: C API와의 상호 운용
C 라이브러리가 malloc/free로 할당한 메모리를 C++에서 관리할 때, unique_ptr에 커스텀 삭제자(free)를 지정하면 RAII로 안전하게 해제할 수 있습니다.

시나리오 5: 멀티스레드에서 객체 수명 공유
스레드 A가 생성한 객체를 스레드 B가 사용할 때, 언제 해제할지 알기 어렵습니다. shared_ptr로 공유하면 “마지막 사용자가 해제”가 보장되어 스레드 안전하게 관리할 수 있습니다.

세 가지 스마트 포인터의 역할과 관계를 요약하면 아래와 같습니다.

flowchart LR
  subgraph U["unique_ptr"]
    U1[단일 소유]
    U2[이동만 가능]
  end
  subgraph S["shared_ptr"]
    S1[참조 카운트]
    S2[공유 소유]
  end
  subgraph W["weak_ptr"]
    W1[소유권 없음]
    W2[순환 참조 방지]
  end
  U --> S
  S --> W

이 글에서는 스마트 포인터의 모든 것을 실전 예제로 설명합니다.

이 글을 읽으면:

  • unique_ptr, shared_ptr, weak_ptr의 차이를 명확히 이해할 수 있습니다.
  • 각 스마트 포인터를 언제 사용해야 하는지 판단할 수 있습니다.
  • 순환 참조 문제를 해결할 수 있습니다.
  • 실전에서 스마트 포인터를 효과적으로 활용할 수 있습니다.

목차

  1. 스마트 포인터란?
  2. unique_ptr: 독점 소유권
  3. shared_ptr: 공유 소유권과 참조 카운팅
  4. weak_ptr: 순환 참조 방지
  5. 스마트 포인터 선택 가이드
  6. 실전 활용 패턴
  7. 자주 발생하는 에러와 해결법
  8. 성능 비교
  9. 프로덕션 패턴

1. 스마트 포인터란?

RAII (Resource Acquisition Is Initialization)

스마트 포인터는 RAII 원칙을 따르는 클래스입니다:

  • 생성자: 리소스 획득 (메모리 할당)
  • 소멸자: 리소스 해제 (메모리 해제)

비유하면, delete를 직접 호출하는 raw 포인터는 “나간 뒤에도 직접 불을 끄고 문을 잠가야 하는 집”에 가깝고, 스마트 포인터는 방을 나가면 알아서 청소·소등까지 해 주는 자동 청소 로봇에 가깝습니다. 로봇이 내부에서 delete를 어떻게 호출하는지 몰라도, 스코프를 벗어나면 힙 메모리는 정리된 상태로 남습니다.

어떤 걸 쓸지 헷갈릴 때: “이 포인터를 여러 곳에서 같이 써야 하나?”가 아니면 일단 unique_ptr을 쓰는 것이 좋습니다. shared_ptr은 참조 카운팅 비용과 순환 참조 위험이 있기 때문에, “정말로 공유 소유가 필요할 때”만 쓰면 됩니다. 예를 들어 “한 객체만 이 리소스를 소유하고, 다른 건 그냥 사용만 한다”면 unique_ptr과 (필요 시) raw 포인터나 참조로 전달하는 패턴이 안전하고 단순합니다.

아래 블록에서 make_unique<int>(42)는 힙에 42를 넣은 int를 만들고 그 주소를 unique_ptr로 감쌉니다. ptr은 이 메모리를 “독점 소유”하므로, 블록을 벗어날 때 unique_ptr 소멸자가 자동으로 delete를 호출합니다. return이나 예외가 나와도 스코프를 벗어나면 정리되므로, 직접 delete를 쓸 필요가 없고 누수 가능성도 사라집니다.

{
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    // 생성자: 메모리 할당
    
    std::cout << *ptr << std::endl;
    
    // 스코프 종료 → 소멸자 자동 호출 → 메모리 자동 해제
}

위 코드 설명: std::make_unique<int>(42)가 힙에 int를 만들고 그 주소를 unique_ptr로 감싸 반환합니다. ptr이 블록을 벗어날 때(return·예외 포함) 소멸자에서 자동으로 delete가 호출되므로, 수동 delete가 필요 없고 누수와 이중 삭제를 피할 수 있습니다.

3가지 스마트 포인터 비교

타입소유권복사이동오버헤드사용 사례
unique_ptr독점없음 (8 bytes)대부분의 경우 (기본 선택)
shared_ptr공유있음 (16 bytes + 제어 블록)여러 곳에서 참조 필요
weak_ptr비소유있음 (16 bytes)순환 참조 방지

2. unique_ptr: 독점 소유권

unique_ptr의 특징

  • 독점 소유: 한 번에 하나의 unique_ptr만 객체를 소유
  • 복사 불가: 복사 생성자와 복사 대입 연산자가 삭제됨
  • 이동 가능: std::move로 소유권 이전 가능
  • 오버헤드 없음: 원시 포인터와 동일한 크기 (8 bytes) 및 성능

기본 사용법

make_unique<T>(인자)는 T 객체를 힙에 만들고 그 주소를 unique_ptr로 감싸서 반환합니다. new를 직접 쓰지 않으면 예외 발생 시에도 누수 없이 안전하고, get()으로 원시 포인터가 꼭 필요한 경우(예: C API에 int*를 넘길 때)만 꺼내 씁니다. get()으로 넘긴 포인터에 delete를 하거나 소유권을 다른 스마트 포인터에 넘기면 안 됩니다. 소유권은 여전히 unique_ptr에 있으므로, 해당 unique_ptr이 파괴될 때 한 번만 해제되어야 합니다. 배열은 make_unique<int[]>(크기)로 만들고 []로 접근합니다.

#include <memory>

// 생성 (권장)
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);

// 생성 (구식, 사용 지양)
std::unique_ptr<int> ptr2(new int(42));

// 접근
std::cout << *ptr1 << std::endl;  // 42 (역참조)
std::cout << ptr1.get() << std::endl;  // 주소 출력

// 배열
std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);
arr[0] = 10;
arr[99] = 20;

위 코드 설명: make_unique<int>(42)는 단일 객체, make_unique<int[]>(100)은 배열을 힙에 만들고 unique_ptr로 감쌉니다. get()으로 raw 포인터를 꺼내 C API에 넘길 수 있지만, 그 포인터에 delete를 하거나 소유권을 이전하면 안 됩니다. 배열은 arr[i]로 접근합니다.

소유권 이전

unique_ptr은 복사가 불가하고 이동만 가능합니다. std::move(ptr1)로 넘기면 ptr1은 nullptr가 되고, 대상 메모리의 소유권만 ptr2로 넘어갑니다. 그래서 “한 시점에 하나의 소유자만 있다”가 보장됩니다.

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가 메모리 소유

위 코드 설명: std::move(ptr1)은 ptr1이 가리키던 메모리의 소유권을 ptr2로 넘깁니다. 이동 후 ptr1은 nullptr가 되고, 그 메모리를 소유하는 것은 ptr2 하나뿐이므로 소멸 시 delete도 한 번만 호출됩니다. 복사는 막혀 있어서 같은 메모리를 두 unique_ptr이 소유하는 일은 없습니다.

함수에서 사용

소유권 이전 (함수가 소유권을 가져감)

void takeOwnership(std::unique_ptr<int> ptr) {
    std::cout << *ptr << std::endl;
    // 함수 종료 시 자동 해제
}

int main() {
    auto ptr = std::make_unique<int>(42);
    takeOwnership(std::move(ptr));  // 소유권 이전
    
    // ptr은 이제 nullptr
    // if (ptr) { ... }  // false
    
    return 0;
}

위 코드 설명: takeOwnership(std::move(ptr))로 넘기면 호출 후 ptr은 nullptr가 되고, 함수 안의 ptr 파라미터가 그 메모리를 소유합니다. 함수가 끝날 때 파라미터의 소멸자에서 delete가 호출되므로, 호출자가 따로 해제할 필요가 없습니다.

소유권 유지 (읽기 전용)

void useValue(const std::unique_ptr<int>& ptr) {
    std::cout << *ptr << std::endl;
    // 소유권 유지, 읽기만
}

int main() {
    auto ptr = std::make_unique<int>(42);
    useValue(ptr);  // 소유권 유지
    
    // ptr 여전히 유효
    std::cout << *ptr << std::endl;
    
    return 0;
}

위 코드 설명: 인자를 const std::unique_ptr<int>&로 받으면 복사·이동이 일어나지 않고, 호출 측의 ptr이 그대로 메모리를 소유합니다. 함수는 값만 읽으면 되고 소유권을 가져갈 필요가 없을 때 이렇게 쓰면 됩니다.

소유권 반환

std::unique_ptr<int> createObject() {
    return std::make_unique<int>(42);
}

int main() {
    auto ptr = createObject();  // 소유권 받기
    std::cout << *ptr << std::endl;
    
    return 0;
}

위 코드 설명: createObject()make_unique를 반환하면 RVO(반환값 최적화) 또는 이동으로 호출자에게 소유권이 넘어갑니다. 호출 측에서 auto ptr = createObject()로 받으면 그 메모리를 ptr이 소유하고, 스코프를 벗어날 때 자동으로 해제됩니다.

커스텀 삭제자 (Custom Deleter)

사용한 파일 핸들 관리:

struct FileCloser {
    void operator()(FILE* fp) const {
        if (fp) {
            std::cout << "Closing file\n";
            fclose(fp);
        }
    }
};

void readFile() {
    std::unique_ptr<FILE, FileCloser> file(fopen("data.txt", "r"));
    
    if (!file) {
        throw std::runtime_error("File not found");
        // ✅ 예외 발생해도 안전
    }
    
    // 파일 읽기...
    
    // ✅ 함수 종료 시 자동으로 fclose 호출!
}

위 코드 설명: std::unique_ptr<FILE, FileCloser>는 두 번째 템플릿 인자로 삭제자를 지정합니다. 소멸 시 delete 대신 FileCloser::operator()(fp)가 호출되어 fclose(fp)가 실행됩니다. 예외나 return으로 빠져나가도 소멸자가 불리므로 파일 핸들이 누수되지 않습니다.

unique_ptr 실전 활용

class ResourceManager {
    std::vector<std::unique_ptr<Resource>> resources;
    
public:
    void addResource(std::unique_ptr<Resource> res) {
        resources.push_back(std::move(res));
    }
    
    std::unique_ptr<Resource> removeResource(size_t index) {
        auto res = std::move(resources[index]);
        resources.erase(resources.begin() + index);
        return res;  // 소유권 반환
    }
};

위 코드 설명: addResourcestd::move(res)로 소유권을 벡터 안으로 넘기고, removeResourcestd::move(resources[index])로 해당 요소의 소유권을 꺼내 반환합니다. vector<unique_ptr<Resource>>가 소멸될 때 각 요소의 소멸자가 호출되어 리소스가 자동으로 정리됩니다.


3. shared_ptr: 공유 소유권과 참조 카운팅

shared_ptr의 특징

  • 공유 소유: 여러 shared_ptr이 동일한 객체를 소유
  • 참조 카운팅: 내부적으로 참조 횟수를 추적
  • 자동 해제: 마지막 shared_ptr이 소멸될 때 객체 해제
  • 스레드 안전: 참조 카운팅은 원자적 연산 (atomic)

기본 사용법

#include <memory>

// 생성 (권장)
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);

// 복사 (참조 카운트 증가)
std::shared_ptr<int> ptr2 = ptr1;

std::cout << ptr1.use_count() << std::endl;  // 2

// ptr1 소멸 (참조 카운트 감소)
ptr1.reset();

std::cout << ptr2.use_count() << std::endl;  // 1

// ptr2 소멸 (참조 카운트 0 → 메모리 해제)
ptr2.reset();

위 코드 설명: ptr2 = ptr1으로 복사하면 같은 제어 블록을 가리키며 참조 카운트가 2가 됩니다. ptr1.reset()으로 ptr1만 버리면 카운트가 1이 되고, ptr2.reset()으로 ptr2까지 버리면 카운트가 0이 되어 그때 비로소 힙 객체가 delete 됩니다.

참조 카운팅 동작 시각화

void demonstrateRefCount() {
    auto ptr1 = std::make_shared<int>(42);
    std::cout << "Count: " << ptr1.use_count() << std::endl;  // 1
    
    {
        auto ptr2 = ptr1;
        std::cout << "Count: " << ptr1.use_count() << std::endl;  // 2
        
        {
            auto ptr3 = ptr1;
            std::cout << "Count: " << ptr1.use_count() << std::endl;  // 3
            
            // ptr3 소멸
        }
        
        std::cout << "Count: " << ptr1.use_count() << std::endl;  // 2
        
        // ptr2 소멸
    }
    
    std::cout << "Count: " << ptr1.use_count() << std::endl;  // 1
    
    // ptr1 소멸 → 참조 카운트 0 → 메모리 해제
}

위 코드 설명: 중첩 스코프에서 ptr2 = ptr1, ptr3 = ptr1으로 복사할 때마다 참조 카운트가 늘고, 각 스코프를 벗어날 때 해당 shared_ptr 소멸로 카운트가 줄어듭니다. 마지막에 ptr1만 남을 때 1이 되고, ptr1이 소멸될 때 0이 되어 메모리가 해제됩니다.

shared_ptr 내부 구조

graph LR
    subgraph Stack["Stack 영역"]
        PTR1["ptr1br/━━━━━━━━br/data ptrbr/ctrl ptr"]
        PTR2["ptr2br/━━━━━━━━br/data ptrbr/ctrl ptr"]
    end
    
    subgraph Heap["Heap 영역"]
        CTRL["Control Blockbr/━━━━━━━━━━━━br/ref count: 2br/weak count: 0br/deleter"]
        OBJ["Object intbr/━━━━━━━━━━━━br/value: 42"]
    end
    
    PTR1 -->|data ptr| OBJ
    PTR1 -->|ctrl ptr| CTRL
    PTR2 -->|data ptr| OBJ
    PTR2 -->|ctrl ptr| CTRL
    
    style Stack fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Heap fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style PTR1 fill:#bbdefb,stroke:#1976d2,stroke-width:2px
    style PTR2 fill:#bbdefb,stroke:#1976d2,stroke-width:2px
    style CTRL fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
    style OBJ fill:#ffe0b2,stroke:#f57c00,stroke-width:2px

shared_ptr 사용 사례

사례 1: 그래프 구조

class Node {
public:
    int value;
    std::vector<std::shared_ptr<Node>> neighbors;
    
    Node(int v) : value(v) {}
};

int main() {
    auto node1 = std::make_shared<Node>(1);
    auto node2 = std::make_shared<Node>(2);
    auto node3 = std::make_shared<Node>(3);
    
    // 양방향 연결
    node1->neighbors.push_back(node2);
    node2->neighbors.push_back(node1);
    node2->neighbors.push_back(node3);
    node3->neighbors.push_back(node2);
    
    // ✅ 자동으로 모든 노드 해제
    return 0;
}

위 코드 설명: 여러 노드가 서로를 neighbors로 가리켜도, 이 그래프는 순환이 없거나 한 방향만 있어서 main이 끝날 때 지역 변수 node1·node2·node3가 사라지면 참조 카운트가 0이 되는 경로가 생깁니다. 그래서 모든 노드가 자동으로 해제됩니다. 양방향으로 서로만 가리키면 순환 참조가 되므로 한쪽은 weak_ptr을 씁니다.

사례 2: 캐시 구현

구현한 리소스 캐시:

class ResourceCache {
    std::map<std::string, std::shared_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        auto it = cache.find(key);
        if (it != cache.end()) {
            return it->second;  // 참조 카운트 증가
        }
        
        // 캐시 미스
        auto resource = std::make_shared<Resource>(key);
        cache[key] = resource;
        return resource;
    }
    
    void clear() {
        cache.clear();  // 모든 리소스 자동 해제
    }
};

위 코드 설명: get에서 캐시에 있으면 it->second를 반환해 참조 카운트가 올라가고, 없으면 make_shared<Resource>로 만들어 캐시에 넣은 뒤 반환합니다. cache.clear()는 맵의 shared_ptr들이 소멸되면서 참조 카운트가 0이 된 리소스부터 자동으로 해제됩니다.

shared_ptr의 비용

sizeof(std::unique_ptr<int>);  // 8 bytes (포인터만)
sizeof(std::shared_ptr<int>);  // 16 bytes (포인터 + 제어 블록 포인터)

위 코드 설명: unique_ptr은 raw 포인터 하나만 들고 있어 8바이트이고, shared_ptr은 객체 포인터와 제어 블록(참조 카운트, weak 카운트, 삭제자 등) 포인터를 들고 있어 16바이트입니다. 참조 카운팅과 스레드 안전을 위해 제어 블록이 힙에 따로 할당됩니다.

제어 블록 (Control Block) 구조:

  • 참조 카운트 (4 bytes)
  • weak 카운트 (4 bytes)
  • 삭제자 포인터 (8 bytes)
  • 할당자 정보 (가변)

성능 영향:

  • 복사 시 원자적 연산 (atomic increment)
  • 멀티스레드 환경에서 약간의 오버헤드
  • 대부분의 경우 무시할 수 있는 수준

4. weak_ptr: 순환 참조 방지

순환 참조 문제 상세 분석

3일 동안 헤맨 버그:

class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ 문제의 원인
    
    ~Node() { std::cout << "Node destroyed\n"; }
};

int main() {
    auto node1 = std::make_shared<Node>();  // ref_count = 1
    auto node2 = std::make_shared<Node>();  // ref_count = 1
    
    node1->next = node2;  // node2 ref_count = 2
    node2->prev = node1;  // node1 ref_count = 2
    
    // main 종료:
    // - node1 스코프 벗어남 → node1 ref_count = 1 (아직 node2->prev가 참조)
    // - node2 스코프 벗어남 → node2 ref_count = 1 (아직 node1->next가 참조)
    // - 둘 다 ref_count > 0 → 메모리 해제 안 됨!
    
    return 0;
}
// "Node destroyed" 출력 안 됨!

메모리 상태:

graph LR
    NODE1["node1 힙br/━━━━━━━━br/ref count: 1br/next → node2"]
    NODE2["node2 힙br/━━━━━━━━br/ref count: 1br/prev → node1"]
    
    NODE1 -->|next| NODE2
    NODE2 -->|prev| NODE1
    
    NOTE["⚠️ 순환 참조br/서로 참조하여br/해제 불가"]
    
    style NODE1 fill:#ffcdd2,stroke:#c62828,stroke-width:3px
    style NODE2 fill:#ffcdd2,stroke:#c62828,stroke-width:3px
    style NOTE fill:#fff9c4,stroke:#f57f17,stroke-width:2px

weak_ptr로 해결

class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // ✅ weak_ptr 사용
    
    ~Node() { std::cout << "Node destroyed\n"; }
};

int main() {
    auto node1 = std::make_shared<Node>();  // ref_count = 1
    auto node2 = std::make_shared<Node>();  // ref_count = 1
    
    node1->next = node2;  // node2 ref_count = 2
    node2->prev = node1;  // node1 ref_count = 1 (weak_ptr는 증가 안 함!)
    
    // main 종료:
    // - node1 스코프 벗어남 → node1 ref_count = 0 → node1 해제
    // - node1 해제 → node1->next 소멸 → node2 ref_count = 1
    // - node2 스코프 벗어남 → node2 ref_count = 0 → node2 해제
    
    return 0;
}
// "Node destroyed" 두 번 출력됨!

weak_ptr 사용법

std::shared_ptr<int> shared = std::make_shared<int>(42);
std::weak_ptr<int> weak = shared;

// weak_ptr은 직접 접근 불가
// std::cout << *weak << std::endl;  // ❌ 컴파일 에러!

// lock()으로 shared_ptr 얻기
if (auto locked = weak.lock()) {
    std::cout << *locked << std::endl;  // 42
    std::cout << "Object alive\n";
} else {
    std::cout << "Object destroyed\n";
}

// shared_ptr 소멸
shared.reset();

// weak_ptr은 여전히 존재하지만 객체는 없음
if (auto locked = weak.lock()) {
    // 실행 안 됨
} else {
    std::cout << "Object destroyed\n";  // 출력됨
}

위 코드 설명: weak_ptr*weak처럼 직접 역참조할 수 없고, lock()으로 shared_ptr을 얻어서 사용합니다. lock()은 객체가 아직 살아 있으면 유효한 shared_ptr을 반환하고, 이미 해제되었으면 빈 shared_ptr을 반환합니다. shared.reset() 후에는 weak.lock()이 빈 포인터를 반환합니다.

weak_ptr 실전 활용: 캐시

구현한 약한 참조 캐시:

class WeakResourceCache {
    std::map<std::string, std::weak_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        auto it = cache.find(key);
        
        if (it != cache.end()) {
            if (auto resource = it->second.lock()) {
                // 캐시 히트 + 리소스 아직 살아있음
                return resource;
            } else {
                // 캐시에 있지만 리소스는 이미 해제됨
                cache.erase(it);
            }
        }
        
        // 캐시 미스 또는 만료
        auto resource = std::make_shared<Resource>(key);
        cache[key] = resource;  // weak_ptr로 저장
        return resource;
    }
};

위 코드 설명: 캐시에 weak_ptr을 넣어 두면, 외부에서 그 리소스를 쓰는 shared_ptr이 모두 사라졌을 때 리소스가 자동으로 해제됩니다. get에서 lock()이 성공하면 아직 살아 있는 리소스를 반환하고, 실패하면 캐시에서 제거한 뒤 새로 만들어 넣습니다. 메모리를 아끼면서도 사용 중인 리소스는 유지할 수 있습니다.

장점:

  • 리소스를 사용 중이면 캐시 유지
  • 사용 안 하면 자동 해제 (메모리 절약)
  • 순환 참조 없음

5. 스마트 포인터 선택 가이드

선택 플로우차트

소유권이 필요한가?
  ├─ No → 원시 포인터 또는 참조 사용
  └─ Yes → 다음 질문

여러 곳에서 소유해야 하는가?
  ├─ No → unique_ptr 사용 (기본 선택)
  └─ Yes → 다음 질문

순환 참조 가능성이 있는가?
  ├─ No → shared_ptr 사용
  └─ Yes → shared_ptr + weak_ptr 조합

실전 선택 가이드

unique_ptr을 사용해야 하는 경우 (90%)

// ✅ 팩토리 함수
std::unique_ptr<Widget> createWidget() {
    return std::make_unique<Widget>();
}

// ✅ 클래스 멤버
class Window {
    std::unique_ptr<Button> closeButton;
    std::unique_ptr<TextBox> titleBar;
};

// ✅ 컨테이너
std::vector<std::unique_ptr<Shape>> shapes;

위 코드 설명: 한 곳만 소유하면 unique_ptr이 적합합니다. 팩토리 반환값, 클래스 멤버, 컨테이너 요소처럼 “이 객체는 여기서만 소유하고 다른 쪽은 참조만 한다”면 unique_ptr로 두고, 필요 시 get()이나 참조로 전달하면 됩니다.

shared_ptr을 사용해야 하는 경우 (9%)

// ✅ 그래프 구조
class Node {
    std::vector<std::shared_ptr<Node>> neighbors;
};

// ✅ 캐시
std::map<std::string, std::shared_ptr<Data>> cache;

// ✅ 비동기 작업
void asyncTask(std::shared_ptr<Data> data) {
    std::thread([data]() {
        // data는 스레드가 끝날 때까지 유효
        processData(data);
    }).detach();
}

위 코드 설명: 여러 곳에서 같은 객체를 소유해야 할 때(그래프의 노드, 캐시, 스레드에 넘기는 데이터 등) shared_ptr을 씁니다. 람다에 [data]로 복사해 넣으면 스레드가 끝날 때까지 참조 카운트가 유지되어 객체가 살아 있습니다.

weak_ptr을 사용해야 하는 경우 (1%)

// ✅ 순환 참조 방지
class Parent {
    std::vector<std::shared_ptr<Child>> children;
};
class Child {
    std::weak_ptr<Parent> parent;  // 순환 참조 방지
};

// ✅ 옵저버 패턴
class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
};

위 코드 설명: Parent–Child처럼 서로를 참조할 때 한쪽을 weak_ptr로 두면 순환 참조가 끊깁니다. 옵저버 패턴에서 Subject가 Observer를 weak_ptr로 들면, Observer가 먼저 파괴되어도 Subject가 남은 Observer를 역참조하지 않게 할 수 있습니다.


6. 실전 활용 패턴

패턴 1: 팩토리 함수

class ShapeFactory {
public:
    static std::unique_ptr<Shape> createCircle(double radius) {
        return std::make_unique<Circle>(radius);
    }
    
    static std::unique_ptr<Shape> createRectangle(double w, double h) {
        return std::make_unique<Rectangle>(w, h);
    }
};

int main() {
    auto circle = ShapeFactory::createCircle(5.0);
    auto rect = ShapeFactory::createRectangle(10.0, 20.0);
    
    circle->draw();
    rect->draw();
    
    return 0;
}

위 코드 설명: 팩토리 메서드가 std::make_unique<Circle> 등을 반환하면 호출자가 소유권을 받아 스코프 안에서 안전하게 사용할 수 있습니다. 다형성(Shape → Circle, Rectangle)이 필요하고 소유권이 한 곳에만 있을 때 적합한 패턴입니다.

패턴 2: Pimpl (Pointer to Implementation)

// Widget.h
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
    
private:
    class Impl;  // 전방 선언
    std::unique_ptr<Impl> pImpl;
};

// Widget.cpp
class Widget::Impl {
public:
    void doSomethingInternal() {
        // 구현...
    }
};

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // unique_ptr이 자동 해제

void Widget::doSomething() {
    pImpl->doSomethingInternal();
}

위 코드 설명: Widgetunique_ptr<Impl>만 들고 있어서 헤더에는 Impl의 정의가 필요 없고 전방 선언만 하면 됩니다. 구현은 .cpp에 두어 컴파일 의존성이 줄고, Widget 소멸 시 pImpl 소멸자에서 Impl이 자동으로 delete 됩니다. 소멸자를 사용자 정의로 두려면 .cpp에 구현해 두어야 합니다.

장점:

  • 헤더 파일에서 구현 숨김
  • 컴파일 의존성 감소
  • ABI 안정성

패턴 3: 다형성 컨테이너

class Animal {
public:
    virtual void speak() = 0;
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void speak() override { std::cout << "Woof!\n"; }
};

class Cat : public Animal {
public:
    void speak() override { std::cout << "Meow!\n"; }
};

int main() {
    std::vector<std::unique_ptr<Animal>> animals;
    
    animals.push_back(std::make_unique<Dog>());
    animals.push_back(std::make_unique<Cat>());
    animals.push_back(std::make_unique<Dog>());
    
    for (const auto& animal : animals) {
        animal->speak();  // 다형성!
    }
    
    // ✅ 모든 동물 자동 해제
    return 0;
}

위 코드 설명: vector<unique_ptr<Animal>>make_unique<Dog>(), make_unique<Cat>()를 넣으면 베이스 포인터로 다형성을 쓰면서도 소유권이 벡터 하나에만 있습니다. 벡터가 소멸될 때 각 unique_ptr 소멸자가 올바른 파생 타입의 소멸자를 호출해 메모리가 안전하게 해제됩니다.

완전한 예제: 엔티티-컴포넌트 (unique_ptr + shared_ptr + weak_ptr)

// Entity: unique_ptr로 컴포넌트 소유
// World: shared_ptr로 엔티티 관리
// ParentRefComponent: weak_ptr로 부모 참조 (순환 참조 방지)
class Entity {
    std::vector<std::unique_ptr<Component>> components_;
public:
    template<typename T, typename... Args>
    T* addComponent(Args&&... args) {
        auto c = std::make_unique<T>(std::forward<Args>(args)...);
        T* p = c.get();
        components_.push_back(std::move(c));
        return p;
    }
};
// World::createEntity() → make_shared<Entity>()
// ParentRefComponent::parent → weak_ptr<Entity>

위 코드 설명: Entity는 컴포넌트를 unique_ptr로 소유, World는 엔티티를 shared_ptr로 관리, 부모 참조는 weak_ptr로 순환 참조를 막습니다.

패턴 4: 옵저버 패턴 (weak_ptr 활용)

구현한 이벤트 시스템:

class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
    
public:
    void attach(std::shared_ptr<Observer> observer) {
        observers.push_back(observer);
    }
    
    void notify() {
        // 만료된 옵저버 제거
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                 {
                    return wp.expired();
                }),
            observers.end()
        );
        
        // 살아있는 옵저버에게만 알림
        for (auto& wp : observers) {
            if (auto observer = wp.lock()) {
                observer->update();
            }
        }
    }
};

int main() {
    Subject subject;
    
    {
        auto observer1 = std::make_shared<Observer>();
        subject.attach(observer1);
        
        subject.notify();  // observer1 호출됨
        
        // observer1 소멸
    }
    
    subject.notify();  // 만료된 옵저버는 자동 제거
    
    return 0;
}

위 코드 설명: observersweak_ptr을 저장하면 Observer가 먼저 파괴되어도 Subject가 dangling 포인터를 들지 않습니다. notify()에서 expired()로 만료된 것을 걸러 내고, lock()으로 유효한 Observer만 꺼내 update()를 호출합니다. Observer가 스코프를 벗어나 소멸된 뒤에도 notify()를 호출해도 안전합니다.


7. 자주 발생하는 에러와 해결법

에러 1: unique_ptr 복사 시도

증상: error: use of deleted function 'std::unique_ptr<T>::unique_ptr(const std::unique_ptr<T>&)'

원인: unique_ptr은 복사 생성자가 삭제되어 있음.

// ❌ 잘못된 코드
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = ptr1;  // 컴파일 에러!

해결법:

// ✅ 올바른 코드: 이동 사용
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr1);

에러 2: get()으로 얻은 포인터에 delete 적용

증상: 이중 해제(double-free)로 크래시.

해결법: get()은 참조만 반환. delete 금지. C API에 넘길 때만 사용.

에러 3: shared_ptr 생성 시 raw 포인터 중복 사용

증상: 같은 raw 포인터로 shared_ptr을 두 번 만들면 이중 해제.

해결법: make_shared 사용. raw가 꼭 필요하면 한 번만 shared_ptr 생성 후 복사로 공유.

에러 4: weak_ptr을 lock() 없이 역참조

증상: weak_ptroperator* 없음 → 컴파일 에러.

해결법: if (auto locked = weak.lock()) { /* locked 사용 */ } 형태로 사용.

에러 5: 순환 참조로 인한 메모리 누수

증상: Valgrind에서 “누수 없음”인데 런타임에 메모리가 계속 증가.

해결법: 한쪽을 weak_ptr로 변경. 예: class B { std::weak_ptr<A> a_; };

에러 6: this를 shared_ptr로 감싸기

증상: std::shared_ptr<MyClass>(this) 사용 시 이중 해제.

해결법: enable_shared_from_this 상속 후 shared_from_this() 사용. shared_ptr로 생성된 객체에서만 호출 가능.

에러 7: make_shared 대신 new + shared_ptr

증상: 할당 2회(객체 + 제어 블록), 성능 저하.

해결법: auto p = std::make_shared<Widget>(1, 2, 3); 사용.


8. 성능 비교

unique_ptr vs shared_ptr vs raw 포인터

항목unique_ptrshared_ptrraw 포인터
크기8 bytes16 bytes8 bytes
할당 오버헤드없음 (포인터만)제어 블록 할당없음
복사 비용불가 (이동만)원자적 증가 (atomic)포인터 복사
해제 비용delete 1회참조 카운트 감소 + 조건부 delete수동 delete

벤치마크: 생성/해제 반복

// 100만 회 생성/해제 시 typical 결과:
// unique_ptr: ~50ms, shared_ptr: ~100ms (1.5~3배 차이)
for (int i = 0; i < 1000000; ++i) {
    auto p = std::make_unique<int>(42);   // 또는 make_shared
}

예상 결과: shared_ptrunique_ptr보다 1.5~3배 정도 느린 경우가 많습니다. 참조 카운팅의 원자적 연산과 제어 블록 할당 때문입니다.

make_shared vs new + shared_ptr

// make_shared: 객체 + 제어 블록 한 번에 할당 (캐시 친화적)
auto p1 = std::make_shared<LargeObject>(args...);

// new + shared_ptr: 할당 2회
std::shared_ptr<LargeObject> p2(new LargeObject(args...));

권장: make_shared 사용. 할당 1회, 메모리 지역성 향상.

성능 요약 표

연산unique_ptrshared_ptr비고
생성 (make_*)O(1)O(1) + 제어 블록shared가 약간 느림
복사불가원자적 증가shared만 복사 가능
이동O(1)O(1)둘 다 빠름
해제O(1)O(1) + 원자적 감소shared가 약간 느림
역참조포인터 접근포인터 접근동일

결론: 소유권이 한 곳에만 있으면 unique_ptr이 가장 빠르고 메모리 효율적입니다. 공유가 꼭 필요할 때만 shared_ptr을 사용하세요.


9. 프로덕션 패턴

패턴 1: 팩토리에서 unique_ptr 반환

DocumentFactory::create(path)make_unique<Document>를 반환하면 호출자가 소유권을 명확히 받습니다.

패턴 2: 클래스 멤버로 리소스 소유

DatabaseConnectionunique_ptr<ConnectionHandle>, unique_ptr<StatementCache>를 멤버로 두면 소멸 시 자동 해제되고 Rule of Five가 불필요합니다.

패턴 3: 스레드 간 객체 공유 (shared_ptr)

std::thread([config]() { runTask(config); })처럼 람다에 shared_ptr을 복사해 캡처하면 스레드가 끝날 때까지 객체가 유효합니다.

패턴 4: 캐시 + weak_ptr (메모리 효율)

캐시에 weak_ptr을 저장하고 lock()으로 유효한지 확인. 사용 중이면 반환, 해제됐으면 캐시에서 제거 후 새로 생성합니다.

패턴 5: Pimpl + unique_ptr (ABI 안정성)

헤더에 struct Impl; 전방 선언과 std::unique_ptr<Impl> pImpl_만 두면 구현 변경 시 바이너리 호환을 유지할 수 있습니다.

패턴 6: enable_shared_from_this (자기 참조)

비동기 콜백에서 this를 캡처할 때 shared_from_this()shared_ptr을 넘기면 객체 수명이 콜백 완료까지 유지됩니다.

프로덕션 체크리스트

  • 기본은 unique_ptr, 공유가 필요할 때만 shared_ptr
  • make_unique, make_shared 사용 (new 직접 사용 지양)
  • 양방향 참조 시 한쪽은 weak_ptr
  • get()으로 얻은 포인터에 delete 금지
  • this를 shared로 쓸 때는 enable_shared_from_this + shared_from_this()
  • 성능이 중요한 경로에서는 unique_ptr 우선 고려

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리
  • C++ auto와 decltype | 타입 추론으로 코드 간결하게 만드는 방법
  • C++ 예외 안전성 | “예외 발생 시 리소스 누수” Basic·Strong·Nothrow 보장

이 글에서 다루는 키워드 (관련 검색어)

C++ 스마트 포인터, unique_ptr shared_ptr weak_ptr, make_unique make_shared, 순환 참조, 참조 카운팅, RAII, 메모리 자동 관리, 모던 C++ 등으로 검색하시면 이 글이 도움이 됩니다.

마무리

핵심 요약

unique_ptr: 기본 선택 (90% 사용)

  • 독점 소유권
  • 오버헤드 없음
  • 이동만 가능

shared_ptr: 공유 필요 시 (9% 사용)

  • 여러 곳에서 소유
  • 참조 카운팅
  • 약간의 오버헤드

weak_ptr: 순환 참조 방지 (1% 사용)

  • 비소유 참조
  • lock()으로 접근
  • 옵저버 패턴, 캐시

실무 규칙

  1. 기본은 unique_ptr (의심스러우면 unique_ptr)
  2. 정말 공유 필요할 때만 shared_ptr
  3. 양방향 참조는 weak_ptr
  4. 원시 포인터는 non-owning 참조로만

배운 교훈

  • 순환 참조는 조용히 발생한다 (Valgrind도 못 찾음)
  • weak_ptr은 필수 도구다 (양방향 구조에서)
  • 성능보다 안전성 우선 (대부분의 경우)

다음 글

스마트 포인터의 기반이 되는 RAII 패턴을 배우면, 메모리뿐 아니라 모든 리소스를 안전하게 관리할 수 있습니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 스마트 포인터 완벽 마스터 가이드. unique_ptr 독점 소유권, shared_ptr 참조 카운팅, weak_ptr로 순환 참조 해결, make_unique·make_shared 사용법, 실제 순환 참조 … 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: unique_ptr·shared_ptr·weak_ptr로 소유권과 순환 참조를 정리하면 메모리 안전성이 올라갑니다. 다음으로 RAII(#6-4)를 읽어보면 좋습니다.

다음 글: C++ 실전 가이드 #6-4: RAII 패턴과 리소스 관리 - 파일, 소켓, 뮤텍스 등 모든 리소스를 자동으로 관리하는 방법을 설명합니다.


참고 자료

공식 문서

추천 자료

관련 글

  • C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
  • C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
  • C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
  • C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지
  • C++ RAII |