C++ 스마트 포인터 | unique_ptr·shared_ptr·weak_ptr와 순환 참조 끊기

포인터를 한 곳에서만 소유하면 unique_ptr, 여러 곳에서 소유하면 shared_ptr, 서로를 가리키는 구조라면 한쪽을 weak_ptr로 둡니다. 판단이 서지 않으면 make_unique로 시작하는 것이 기본입니다.

누수가 없다는데 메모리가 늘어나는 경우

이전 글에서는 new/delete를 unique_ptr로 바꿔 누수를 막았습니다. 그런데 스마트 포인터를 쓰는 프로그램도 오래 실행하면 메모리가 계속 늘어나는데, Valgrind는 확실한 누수가 없다고 보고하는 경우가 있습니다. Memcheck는 살아 있는 포인터에서 닿을 수 있는 블록을 “still reachable”로 분류할 뿐 누수로 세지 않기 때문입니다(Valgrind Memcheck로 C++ 메모리 누수 찾아내기, C++ 메모리 누수 찾기: Valgrind·ASan·CRT 디버그 힙 사용법과 누수 패턴 참고). 이런 증상의 흔한 원인이 순환 참조(circular reference), 즉 A가 B를 참조하고 B가 다시 A를 참조해 서로 해제되지 않는 상태입니다.

unique_ptr과 shared_ptr은 C++ RAII: 생성자에서 획득하고 소멸자에서 해제하는 자원 관리 클래스 만들기에서 다루는 RAII로 객체 수명을 스코프에 묶습니다. 소유권 이전은 C++ Move Semantics | std::move로 불필요한 복사 제거하기와 함께 이해해야 합니다. Rust 소유권 | Ownership, Borrowing, Lifetime은 비슷한 규칙을 컴파일 타임에 강제하고, shared_ptr의 참조 카운팅은 같은 문제를 런타임 비용을 들여 해결한다는 점에서 접근이 다릅니다.

shared_ptr은 참조 카운트(그 객체를 소유한 shared_ptr의 개수)가 0이 되어야 객체를 파괴합니다. A가 B를 가리키고 B가 다시 A를 가리키면 두 카운트가 모두 1 이상으로 남아 해제되지 않습니다. 이때는 한쪽을 weak_ptr로 바꾸거나 소유 관계를 한 방향으로만 두도록 설계해야 합니다.

문제의 코드

node1과 node2가 서로를 shared_ptr로 가리키면 node1의 카운트는 node2->prev 때문에, node2의 카운트는 node1->next 때문에 1로 남습니다. main이 끝나 지역 변수가 사라져도 두 노드가 서로를 소유하고 있으므로 카운트가 0이 되지 않고 소멸자도 호출되지 않습니다.

// 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>();  // node1 카운트 1
    auto node2 = std::make_shared<Node>();  // node2 카운트 1
    
    node1->next = node2;  // node2 카운트 2
    node2->prev = node1;  // node1 카운트 2 (순환!)
    
    // main 종료 시 지역 변수가 사라져도 두 카운트 모두 1로 남음
    return 0;
}
// "Node destroyed"가 출력되지 않음

weak_ptr로 고친 코드

// 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;  // ✅ 소유하지 않는 역방향 참조
    
    ~Node() { std::cout << "Node destroyed\n"; }
};
int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    
    node1->next = node2;  // node2 카운트 2
    node2->prev = node1;  // node1 카운트 1 (weak_ptr는 강한 카운트를 올리지 않음)
    
    return 0;
}
// "Node destroyed"가 두 번 출력됨

지역 변수는 선언의 역순으로 파괴됩니다. 먼저 node2가 사라지지만 node1->next가 아직 소유하고 있으므로 카운트가 2에서 1로 줄 뿐입니다. 이어서 node1이 사라지면 카운트가 0이 되어 소멸자가 실행되고, 그 과정에서 멤버 next가 파괴되면서 node2의 카운트도 0이 됩니다. 그래서 “Node destroyed”가 두 번, node1이 먼저 출력됩니다.

같은 구조는 여러 곳에서 나타납니다. 게임 엔진에서 엔티티가 컴포넌트를 shared_ptr로 들고 컴포넌트가 다시 부모 엔티티를 shared_ptr로 참조하면 순환이 생기고, 이벤트 시스템에서 Subject가 Observer를 소유하면서 Observer도 Subject를 소유하면 마찬가지입니다. 두 경우 모두 역방향 참조를 weak_ptr로 두거나, 소유자가 반드시 더 오래 산다는 보장이 있으면 raw 포인터로 둡니다. 스마트 포인터는 new로 만들지 않은 자원에도 쓸 수 있습니다. C 라이브러리가 malloc으로 할당한 메모리는 free를 삭제자로 지정한 unique_ptr에 담을 수 있고, 한 스레드가 만든 객체를 다른 스레드가 쓸 때는 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

RAII와 세 가지 스마트 포인터

RAII (Resource Acquisition Is Initialization)

스마트 포인터는 RAII 원칙을 따르는 클래스입니다. 생성자에서 자원의 소유권을 얻고 소멸자에서 해제합니다. 소멸자는 정상 흐름이든 return이든 예외든 스코프를 벗어날 때 항상 실행되므로 해제를 빠뜨릴 수 없습니다.

어떤 것을 쓸지 헷갈린다면 “이 객체를 여러 곳에서 소유해야 하는가?”를 먼저 묻습니다. 답이 아니라면 unique_ptr이 기본입니다. shared_ptr은 참조 카운팅 비용과 순환 참조 위험이 있으므로 공유 소유가 정말 필요할 때만 씁니다. 한 객체만 자원을 소유하고 나머지는 사용만 한다면 unique_ptr과, 사용하는 쪽에 넘기는 raw 포인터나 참조의 조합이 더 단순합니다.

{
    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를 쓸 일이 없고, 해제를 잊거나 두 번 하는 실수도 생기지 않습니다.

세 가지 스마트 포인터 비교

크기는 일반적인 64비트 플랫폼 기준입니다.

타입소유권복사이동크기사용 사례
unique_ptr독점XO기본 삭제자일 때 8바이트기본 선택
shared_ptr공유OO16바이트 + 제어 블록여러 곳에서 소유
weak_ptr비소유OO16바이트 (제어 블록 공유)순환 참조 방지, 캐시

unique_ptr: 독점 소유권

unique_ptr의 특징

한 시점에 하나의 unique_ptr만 객체를 소유합니다. 복사 생성자와 복사 대입 연산자는 삭제되어 있고, 이동은 가능하므로 std::move로 소유권을 넘길 수 있습니다. 기본 삭제자(또는 상태 없는 삭제자)를 쓰면 주요 구현에서 raw 포인터와 크기가 같습니다.

기본 사용법

make_unique<T>(인자)는 T를 힙에 만들고 unique_ptr로 감싸서 반환합니다. new를 직접 쓰지 않으면 소유자 없는 raw 포인터가 존재하는 순간이 생기지 않습니다. get()은 C API에 int*를 넘길 때처럼 raw 포인터가 꼭 필요한 경우에만 씁니다. 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[]>(100)은 요소를 값 초기화하므로 모두 0으로 시작합니다. 반대로 std::unique_ptr<int> p(new int[10]);처럼 배열을 단일 객체용 unique_ptr<T>에 넣으면 소멸 시 delete[]가 아닌 delete가 호출되어 정의되지 않은 동작이 됩니다. 배열에는 반드시 unique_ptr<T[]>를 쓰고, 대부분의 경우에는 std::vector가 더 낫습니다.

소유권 이전

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

함수에서 사용

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

#include <iostream>
#include <memory>
#include <utility>
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;
}

unique_ptr을 값으로 받는 시그니처는 “이 함수가 소유자가 된다”는 뜻을 드러냅니다. 호출 후 호출자의 ptr은 nullptr가 되고, 객체는 takeOwnership이 끝날 때 파라미터의 소멸자에서 해제됩니다.

소유권 유지 (읽기 전용)

#include <iostream>
#include <memory>
void useValue(const std::unique_ptr<int>& ptr) {
    std::cout << *ptr << std::endl;
}
int main() {
    auto ptr = std::make_unique<int>(42);
    useValue(ptr);  // 소유권 유지
    std::cout << *ptr << std::endl;  // ptr 여전히 유효
    return 0;
}

const std::unique_ptr<int>&로 받으면 복사도 이동도 일어나지 않고 호출 측의 ptr이 계속 소유합니다.

다만 함수가 객체를 쓰기만 한다면 스마트 포인터 타입을 매개변수에 드러낼 이유가 없습니다. void useValue(const int& v)나 void process(const Widget* w)처럼 참조나 raw 포인터로 받으면 호출자가 unique_ptr을 쓰든 shared_ptr을 쓰든 스택 객체를 쓰든 같은 함수를 호출할 수 있습니다(useValue(*ptr), process(ptr.get())). C++ Core Guidelines(R.30, F.7)도 스마트 포인터를 매개변수로 받는 것은 함수가 수명에 관여할 때, 즉 소유권을 가져가거나 포인터를 재설정하거나 공유 소유에 참여할 때로 한정하라고 권합니다. 이렇게 넘긴 raw 포인터는 빌려 쓰는 것이므로 함수 안에서 delete하거나 다른 스마트 포인터로 감싸면 안 됩니다.

소유권 반환

#include <iostream>
#include <memory>
std::unique_ptr<int> createObject() {
    return std::make_unique<int>(42);
}
int main() {
    auto ptr = createObject();  // 소유권 받기
    std::cout << *ptr << std::endl;
    return 0;
}

반환되는 prvalue가 ptr을 직접 초기화하므로(C++17부터 보장된 복사 생략) 이동조차 일어나지 않고, ptr이 스코프를 벗어날 때 객체가 해제됩니다.

커스텀 삭제자 (Custom Deleter)

파일 핸들을 관리하는 예입니다.

struct FileCloser {
    void operator()(FILE* fp) const {
        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)가 실행됩니다. unique_ptr의 소멸자는 보관한 포인터가 nullptr가 아닐 때만 삭제자를 호출하므로, 이 삭제자에는 null 검사가 없어도 됩니다.

unique_ptr과 shared_ptr은 삭제자를 다루는 방식이 다르고, 이 차이가 설계에 영향을 줍니다. unique_ptr에서 삭제자는 타입의 일부라서 unique_ptr<FILE, FileCloser>와 unique_ptr<FILE, void(*)(FILE*)>는 서로 다른 타입입니다. 상태 없는 함수 객체는 추가 공간을 차지하지 않지만, 함수 포인터나 상태를 캡처한 람다는 그만큼 unique_ptr 객체를 키웁니다. 반면 shared_ptr은 삭제자를 제어 블록 안에 타입 소거해 저장하므로, 삭제자가 무엇이든 모두 같은 shared_ptr<FILE> 타입입니다. 그래서 서로 다른 방식으로 해제해야 하는 핸들을 한 컨테이너에 담거나 API 경계 너머로 넘길 때는 shared_ptr이 편하고, 크기와 비용이 중요한 멤버 변수라면 상태 없는 삭제자를 쓴 unique_ptr이 유리합니다.

// shared_ptr: 삭제자가 달라도 같은 타입
std::shared_ptr<FILE> f(std::fopen("a.bin", "rb"), [](FILE* p) { if (p) std::fclose(p); });
std::vector<std::shared_ptr<FILE>> handles{f};  // 다른 삭제자를 쓴 핸들도 함께 담을 수 있음

위 람다에 if (p) 검사를 넣은 데는 이유가 있습니다. shared_ptr은 fopen이 실패해 nullptr를 넘겨받아도 삭제자와 함께 제어 블록을 만들고, 카운트가 0이 될 때 nullptr를 인자로 삭제자를 호출합니다. fclose(nullptr)는 정의되지 않은 동작이므로 shared_ptr용 삭제자에는 null 검사가 필요합니다. 또 std::unique_ptr<FILE, decltype(&std::fclose)>처럼 표준 라이브러리 함수의 주소를 삭제자로 쓰는 코드가 흔한데, C++20 표준은 “addressable function”으로 지정된 함수가 아니면 주소를 취하는 동작을 보장하지 않습니다. 대부분의 구현에서 실제로는 동작하지만, 이식성을 생각하면 람다나 함수 객체로 한 번 감싸는 편이 맞습니다.

삭제자는 소멸자 안에서 호출되므로 예외를 던지면 안 됩니다. 소멸자는 기본적으로 noexcept라서 예외가 빠져나가면 std::terminate가 호출됩니다. fclose처럼 실패할 수 있는 정리 함수는 반환값을 로그로 남기는 정도로 처리합니다. 쓰기 버퍼를 flush하다 디스크가 가득 차는 식의 실패를 호출자가 알아야 한다면 삭제자에 맡기지 말고 명시적인 close() 함수를 따로 제공하는 편이 좋습니다. 삭제자 안에서 조용히 실패하면 파일이 잘린 채 저장되어도 알 수 없습니다.

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;  // 소유권 반환
    }
};

addResource는 소유권을 벡터 안으로 옮기고, removeResource는 요소 하나의 소유권을 꺼내 호출자에게 돌려줍니다. 벡터가 소멸될 때 남은 요소의 소멸자가 각각 호출됩니다. removeResource는 index를 검사하지 않으므로 실제 코드에서는 범위를 확인해야 합니다.


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

shared_ptr의 특징

여러 shared_ptr이 같은 객체를 소유할 수 있습니다. 제어 블록이 강한 참조의 개수를 세고, 마지막 shared_ptr이 파괴되거나 reset되면 객체를 삭제합니다. 카운트 갱신은 원자적 연산이라 서로 다른 스레드가 같은 객체를 가리키는 각자의 shared_ptr을 복사하고 파괴해도 안전합니다. 다만 이 보장은 카운트에만 해당합니다. 가리키는 객체에 접근하거나, 같은 shared_ptr 변수 하나를 여러 스레드가 읽고 쓰는 경우에는 별도의 동기화가 필요합니다.

기본 사용법

#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()으로 1이 되고, ptr2.reset()으로 0이 되는 순간 힙 객체가 삭제됩니다. use_count()는 이런 실험에는 유용하지만, 멀티스레드 코드에서는 읽는 즉시 값이 바뀔 수 있으므로 로직의 근거로 쓰면 안 됩니다.

스코프에 따른 참조 카운트 변화

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 → 메모리 해제

shared_ptr 내부 구조

graph LR
    subgraph Stack[Stack 영역]
        PTR1["ptr1<br/>━━━━━━━━<br/>data ptr<br/>ctrl ptr"]
        PTR2["ptr2<br/>━━━━━━━━<br/>data ptr<br/>ctrl ptr"]
    end
    
    subgraph Heap[Heap 영역]
        CTRL["Control Block<br/>━━━━━━━━━━━━<br/>ref count: 2<br/>weak count: 0<br/>deleter"]
        OBJ["Object int<br/>━━━━━━━━━━━━<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은 객체를 가리키는 포인터와 제어 블록을 가리키는 포인터, 두 개를 들고 있습니다. 제어 블록에는 강한 참조 카운트, 약한 참조 카운트, 타입 소거된 삭제자와 할당자가 들어 있습니다. make_shared로 만들면 객체와 제어 블록이 한 번의 할당으로 붙어 있고, shared_ptr<T>(new T)로 만들면 두 번 따로 할당됩니다.

그래프: shared_ptr만으로는 순환이 생기는 구조

#include <vector>
#include <memory>
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;  // 세 노드 모두 해제되지 않음 (누수)
}

양방향 간선을 shared_ptr로 들면 그 자체로 순환입니다. main이 끝나 지역 변수가 사라져도 node1은 node2의 neighbors가, node2는 node1과 node3의 neighbors가 소유하고 있어서 어느 카운트도 0이 되지 않습니다. 일반적인 해법은 노드의 소유자를 하나로 정하는 것입니다. 예를 들어 Graph 객체가 std::vector<std::unique_ptr<Node>>로 노드를 소유하고 간선은 raw 포인터나 인덱스로 저장합니다. 또는 한 방향의 간선만 소유하고 역방향 간선은 std::weak_ptr<Node>로 둡니다.

캐시

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();  // 캐시가 가진 참조만 놓음
    }
};

캐시에 있으면 it->second를 반환해 호출자와 소유권을 공유하고, 없으면 make_shared로 만들어 넣은 뒤 반환합니다. clear()는 캐시가 가진 참조만 놓습니다. 호출자가 아직 들고 있는 리소스는 그 호출자가 놓을 때까지 살아 있습니다. 이 캐시는 모든 항목을 소유하므로 맵에 있는 동안은 아무것도 해제되지 않는데, 이것이 아래 weak_ptr 캐시가 필요한 이유입니다.

shared_ptr의 비용

sizeof(std::unique_ptr<int>);  // 일반적인 64비트에서 8 (포인터 하나)
sizeof(std::shared_ptr<int>);  // 16 (객체 포인터 + 제어 블록 포인터)

크기 외에도 shared_ptr을 복사하거나 파괴할 때마다 카운트를 원자적으로 증감하고, 생성할 때 제어 블록이 필요합니다. shared_ptr을 이동할 때는 카운트를 건드리지 않으므로, 값으로 받아 저장소로 std::move하는 편이 여러 번 복사하는 것보다 쌉니다. 대부분의 프로그램에서 이 비용은 객체로 하는 실제 작업에 비해 작지만, 빡빡한 루프에서 shared_ptr을 계속 복사하면 드러납니다.


weak_ptr로 순환 참조 끊기

weak_ptr은 객체를 소유하지 않고 관찰만 합니다. 강한 카운트를 올리지 않으므로, 앞의 Node 예제처럼 역방향 참조를 weak_ptr로 두면 순환이 끊깁니다. 일반화하면 “관계마다 소유 방향은 하나”입니다. 부모가 자식을 shared_ptr(또는 unique_ptr)로 소유하고, 자식이 부모를 가리키는 역방향 포인터는 weak_ptr로 둡니다(부모가 반드시 더 오래 산다면 raw 포인터도 됩니다). 부모-자식 트리, 옵저버 목록, 그래프, 캐시에서 순환이 생기는 패턴과 끊는 방법은 C++ shared_ptr 순환 참조와 weak_ptr: 부모-자식·옵저버·그래프·캐시에서 끊는 법에서 자세히 다룹니다.

lock()과 expired() 사용법

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

weak_ptr에는 operator*와 operator->가 없으므로 lock()으로 shared_ptr을 얻어서 씁니다. lock()은 객체가 살아 있으면 유효한 shared_ptr을, 이미 해제되었으면 빈 shared_ptr을 반환합니다.

멀티스레드 코드에서 자주 보이는 실수는 if (!weak.expired()) { auto sp = weak.lock(); sp->use(); }처럼 검사와 사용을 두 단계로 나누는 것입니다. expired()가 false를 돌려준 직후 다른 스레드가 마지막 shared_ptr을 놓으면 객체는 그 사이에 사라지고, lock()은 빈 포인터를 돌려주는데 코드는 이를 확인하지 않고 역참조합니다(TOCTOU, time-of-check to time-of-use). lock()은 “살아 있으면 강한 참조를 하나 만든다”를 원자적으로 처리하므로, if (auto sp = weak.lock()) 한 문장으로 참조를 확보한 뒤에만 객체를 쓰는 것이 안전한 형태입니다. expired()는 이미 죽은 항목을 빠르게 걸러 내는 용도로만 씁니다.

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;  // 캐시 히트, 리소스 아직 살아 있음
            }
            cache.erase(it);      // 리소스는 이미 해제됨
        }
        
        // 캐시 미스 또는 만료
        auto resource = std::make_shared<Resource>(key);
        cache[key] = resource;  // weak_ptr로 저장
        return resource;
    }
};

캐시에 weak_ptr을 넣어 두면 캐시가 리소스의 수명을 붙잡지 않습니다. 외부의 shared_ptr이 모두 사라지면 리소스가 해제되고, 다음 조회에서 만료된 항목을 발견해 새로 만듭니다. 다시 조회되지 않는 키의 항목은 만료된 weak_ptr로 맵에 남으므로, 오래 실행되는 프로그램에서는 주기적으로 정리해야 합니다.


비용 비교

unique_ptr과 shared_ptr의 크기와 use_count 변화

그림: 실제 실행 결과 — unique_ptr은 일반 포인터와 같은 8바이트지만, shared_ptr은 제어 블록 포인터까지 16바이트다. 복사할 때마다 참조 카운트가 원자적으로 오르내린다. (환경: Ubuntu 24.04 · g++ 13.3)

메모리 크기

// raw 포인터: 8바이트 (64비트)
int* raw = new int(42);

// unique_ptr: 8바이트 (기본 삭제자면 raw 포인터와 동일)
std::unique_ptr<int> uptr = std::make_unique<int>(42);

// shared_ptr: 16바이트 (객체 포인터 + 제어 블록 포인터)
std::shared_ptr<int> sptr = std::make_shared<int>(42);
// 제어 블록: make_shared면 객체와 함께, 아니면 별도 힙 할당

shared_ptr이 포인터를 두 개 들고 있는 이유는 객체 포인터와 제어 블록 포인터가 다를 수 있기 때문입니다. 예를 들어 shared_ptr<Derived>를 shared_ptr<Base>로 변환하면 가리키는 주소는 Base 부분으로 바뀌지만 제어 블록은 그대로 공유합니다. 컨테이너에 수백만 개의 포인터를 저장한다면 이 8바이트 차이도 캐시 효율에 영향을 줍니다.

할당 비용

// unique_ptr: 할당 1번 (int 객체)
auto uptr = std::make_unique<int>(42);

// shared_ptr + make_shared: 할당 1번 (int 객체 + 제어 블록을 한 덩어리로)
auto sptr = std::make_shared<int>(42);

// shared_ptr + new: 할당 2번 (int 객체, 제어 블록을 따로)
std::shared_ptr<int> sptr2(new int(42));

특별한 이유가 없다면 make_shared를 씁니다. 할당 횟수가 줄고 객체와 제어 블록이 붙어 있어 캐시 지역성도 좋아집니다. make_shared는 예외 안전성 면에서도 낫습니다. C++17 이전에는 f(std::shared_ptr<T>(new T), g()) 같은 식에서 컴파일러가 new T → g() → shared_ptr 생성 순서로 평가할 수 있었고, 이때 g()가 예외를 던지면 new T로 만든 객체가 누수됐습니다. C++17에서 함수 인자 평가 규칙이 바뀌어 이 특정 누수는 사라졌지만, make_shared/make_unique를 쓰면 new가 코드에 드러나지 않아 이런 걱정 자체를 할 필요가 없습니다.

반대로 make_shared를 쓰지 않는 편이 나은 경우도 있습니다. 커스텀 삭제자가 필요할 때(make_shared는 삭제자를 받지 않습니다), 생성자가 private이라 make_shared가 호출할 수 없을 때, 그리고 객체가 크고 weak_ptr이 오래 살아남을 때입니다. 마지막 경우는 객체와 제어 블록이 한 덩어리라서, 강한 참조가 모두 사라져 소멸자가 호출된 뒤에도 weak_ptr이 하나라도 남아 있으면 객체가 차지하던 메모리까지 함께 반환되지 않기 때문입니다.

복사와 이동 비용

// unique_ptr: 복사 불가, 이동은 포인터 복사 + 원본을 nullptr로
std::unique_ptr<int> uptr1 = std::make_unique<int>(42);
std::unique_ptr<int> uptr2 = std::move(uptr1);

// shared_ptr: 복사 시 참조 카운트 원자적 증가, 소멸 시 원자적 감소
std::shared_ptr<int> sptr1 = std::make_shared<int>(42);
std::shared_ptr<int> sptr2 = sptr1;

단일 스레드에서는 원자적 연산도 비교적 싸지만, 여러 스레드가 같은 객체의 shared_ptr을 동시에 복사하면 제어 블록이 있는 캐시 라인을 코어끼리 주고받느라 비용이 크게 늘어납니다. shared_ptr을 이동할 때는 카운트를 건드리지 않으므로, 소유권을 넘길 때는 std::move를 쓰는 편이 쌉니다. 핫 루프에서 shared_ptr을 값으로 넘기고 있다면 const&나 객체 참조로 바꾸는 것만으로도 차이가 날 수 있습니다.

직접 측정할 때 주의할 점

// 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);
    }
}

이런 마이크로벤치마크의 절대 수치는 컴파일러, 최적화 옵션, 메모리 할당기(glibc malloc, jemalloc, Windows 힙 등)에 따라 크게 달라지므로 자기 환경에서 측정해야 합니다. 구조에서 예상할 수 있는 경향은 이렇습니다. 세 방식 모두 비용의 대부분은 힙 할당 자체이고, 최적화 빌드에서 unique_ptr은 수동 new/delete와 같은 코드가 됩니다. make_shared는 제어 블록 초기화와 원자적 카운트 감소가 조금 더해지며, shared_ptr<T>(new T)는 할당이 한 번 더 일어나 가장 느립니다.

측정할 때는 최적화 빌드(-O2)를 쓰되, 컴파일러가 쓰이지 않는 할당을 통째로 제거하지 않도록 결과를 어딘가에 사용해야 합니다. Clang은 짝이 맞는 new/delete를 최적화로 없앨 수 있어서, 루프가 0ms로 측정되는 결과가 나오면 대개 이 때문입니다. Google Benchmark의 benchmark::DoNotOptimize()가 이 문제를 막아 줍니다.

어떤 스마트 포인터를 고를까

소유권이 필요한가?
  ├─ No → 원시 포인터 또는 참조 사용
  └─ Yes → 다음 질문
여러 곳에서 소유해야 하는가?
  ├─ No → unique_ptr 사용 (기본 선택)
  └─ Yes → 다음 질문
순환 참조 가능성이 있는가?
  ├─ No → shared_ptr 사용
  └─ Yes → shared_ptr + weak_ptr 조합

대부분의 코드에서는 unique_ptr이 많은 경우를 맡습니다. 팩토리 반환값, 클래스가 독점하는 멤버, 다형 객체의 컨테이너가 대표적입니다.

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;

shared_ptr은 수명이 실제로 여러 주체에 걸릴 때 씁니다. 비동기 작업에 넘기는 데이터가 대표적입니다.

void asyncTask(std::shared_ptr<Data> data) {
    std::thread([data]() {
        // 캡처한 복사본이 스레드가 끝날 때까지 객체를 살려 둠
        processData(data);
    }).detach();
}

캡처한 복사본 덕분에 Data의 수명은 보장되지만, detach한 스레드는 아무도 기다릴 수 없습니다. 프로그램이 깔끔하게 종료되어야 한다면 join하거나 스레드 풀을 쓰는 편이 낫습니다.

weak_ptr은 역방향 참조와 옵저버에 씁니다.

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;
};

팩토리·Pimpl·다형성 컨테이너·옵저버 패턴

팩토리 함수

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;
}

팩토리가 unique_ptr을 반환하면 호출 지점에서 소유권이 분명해집니다. 호출자가 공유해야 한다면 std::shared_ptr<Shape> s = ShapeFactory::createCircle(1.0);처럼 그대로 shared_ptr로 바꿀 수 있습니다. 반대로 팩토리가 shared_ptr을 반환하면 더 가벼운 선택지가 사라집니다.

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;  // 여기서는 Impl이 완전한 타입
void Widget::doSomething() {
    pImpl->doSomethingInternal();
}

헤더에는 Impl의 전방 선언만 있으면 되므로 구현을 바꿔도 이 헤더를 포함한 파일을 모두 다시 컴파일할 필요가 없고, 사용자에게 보이는 클래스 레이아웃도 그대로 유지됩니다. 소멸자는 헤더에서 선언하고 .cpp에서 정의해야 합니다. unique_ptr의 기본 삭제자는 소멸자가 인스턴스화되는 지점에서 Impl이 완전한 타입이어야 하는데, 컴파일러가 헤더에서 암시적으로 만든 소멸자는 그 조건을 만족하지 못해 컴파일 에러가 납니다. 이동 연산을 선언할 때도 마찬가지로 .cpp에서 정의합니다.

다형성 컨테이너

#include <iostream>
#include <vector>
#include <memory>
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;
}

unique_ptr<Animal>로 Dog를 삭제할 때 올바른 소멸자가 불리는 것은 Animal의 가상 소멸자 덕분입니다. 가상 소멸자가 없으면 정의되지 않은 동작입니다. (make_shared<Dog>로 만든 shared_ptr<Animal>은 가상 소멸자가 없어도 올바른 삭제자를 기억하지만, 그 동작에 기대는 설계는 깨지기 쉽습니다.)

엔티티-컴포넌트 예제 (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로 소유하고 소유하지 않는 T*를 돌려줍니다. 벡터가 재할당되어도 옮겨지는 것은 unique_ptr뿐이고 컴포넌트 자체는 그대로이므로, 엔티티가 그 컴포넌트를 갖고 있는 동안 이 포인터는 유효합니다. 컴포넌트가 엔티티를 가리키는 역방향 참조는 weak_ptr로 둡니다.

옵저버 패턴 (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(),
                [](const std::weak_ptr<Observer>& wp) {
                    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;
}

Subject가 Observer를 소유하지 않으므로, Observer가 등록 해제 없이 파괴되어도 남는 것은 만료된 weak_ptr일 뿐 dangling 포인터가 아닙니다. 만료 항목을 정리한 뒤에도 루프 안의 lock()은 필요합니다. 그 사이에 다른 스레드가 Observer를 놓을 수 있기 때문입니다.

enable_shared_from_this

shared_ptr로 관리되는 객체가 자기 자신을 가리키는 shared_ptr을 넘겨야 할 때가 있습니다. 비동기 콜백이 끝날 때까지 자신을 살려 두는 경우가 대표적입니다. 이때는 std::enable_shared_from_this<T>를 상속하고 shared_from_this()를 호출합니다. std::shared_ptr<T>(this)로 감싸면 제어 블록이 하나 더 생겨 이중 해제가 됩니다. shared_from_this()는 객체가 이미 shared_ptr로 관리되고 있을 때만 쓸 수 있고, C++17부터는 그렇지 않은 상태에서 호출하면 std::bad_weak_ptr 예외가 발생합니다.


자주 만나는 에러

unique_ptr 복사 시도

unique_ptr은 복사 생성자가 삭제되어 있으므로 컴파일러가 error: use of deleted function 'std::unique_ptr<T>::unique_ptr(const std::unique_ptr<T>&)' 같은 에러를 냅니다.

// ❌ 잘못된 코드
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = ptr1;  // 컴파일 에러!
// ✅ 이동 사용
std::unique_ptr<int> ptr3 = std::move(ptr1);

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

get()은 소유권 없는 포인터를 돌려줍니다. 여기에 delete를 하면 unique_ptr이 파괴될 때 한 번 더 해제되어 이중 해제로 크래시가 납니다. 소유권을 정말 넘겨야 한다면 포인터를 돌려주고 자신은 비워지는 release()를 씁니다.

같은 raw 포인터로 shared_ptr 두 개 만들기

raw 포인터로 shared_ptr을 만들 때마다 별도의 제어 블록이 생기므로, 같은 포인터로 두 번 만들면 둘 다 객체를 삭제합니다. make_shared를 쓰거나, shared_ptr을 한 번만 만든 뒤 복사로 공유합니다.

weak_ptr을 lock() 없이 역참조

weak_ptr에는 operator*가 없어 컴파일 에러가 납니다. if (auto locked = weak.lock()) { /* locked 사용 */ } 형태로 씁니다.

Valgrind는 조용한데 메모리가 늘어남

순환 참조된 객체가 살아 있는 소유자에게서 여전히 닿을 수 있거나, 프로세스가 정상 종료되지 않아 Memcheck가 보고할 기회가 없는 경우입니다. 한쪽을 weak_ptr로 바꿔 순환을 끊습니다. 예: class B { std::weak_ptr<A> a_; };

make_shared 대신 new + shared_ptr

std::shared_ptr<Widget>(new Widget(1, 2, 3))는 객체와 제어 블록을 따로 두 번 할당합니다. auto p = std::make_shared<Widget>(1, 2, 3);은 한 번에 할당하고 new가 코드에 드러나지 않습니다.

make_*를 권하는 이유는 할당 횟수만이 아닙니다. C++14까지는 process(std::shared_ptr<Widget>(new Widget), compute()); 같은 호출에서 컴파일러가 new Widget → compute() → shared_ptr 생성 순서로 평가할 수 있었고, 이때 compute()가 예외를 던지면 아직 아무도 소유하지 않은 Widget이 누수됐습니다. C++17부터는 한 인자의 평가가 다른 인자의 평가와 끼어들지 않도록 규칙이 바뀌어 이 경로는 막혔지만, make_unique/make_shared는 표준 버전과 상관없이 안전합니다.

예외가 하나 있습니다. make_shared는 객체와 제어 블록을 한 덩어리로 할당하기 때문에, 마지막 shared_ptr이 사라져 객체의 소멸자가 불린 뒤에도 weak_ptr이 하나라도 남아 있으면 그 덩어리 전체가 반환되지 않습니다. 약한 참조 카운트를 담은 제어 블록이 살아 있어야 하기 때문입니다. 객체가 큰 버퍼를 직접 품고 있고 캐시처럼 weak_ptr이 오래 남는 구조라면 메모리가 “해제됐는데도 줄지 않는” 것처럼 보입니다. 이런 경우에는 std::shared_ptr<T>(new T(...))로 따로 할당해 객체 메모리가 먼저 반환되게 하거나, 큰 버퍼를 std::vector 멤버처럼 객체 밖의 별도 힙 영역에 둡니다. 커스텀 삭제자가 필요할 때도 make_shared는 쓸 수 없습니다. 할당 전략 자체를 바꾸려면 std::allocate_shared로 메모리 풀과 연결할 수 있습니다.


다음 글

스마트 포인터의 기반이 되는 RAII 패턴을 익히면 메모리뿐 아니라 파일, 락, 소켓 같은 모든 자원을 같은 방식으로 관리할 수 있습니다.


같이 보면 좋은 글


참고 자료