[C++] 스마트 포인터 : unique_ptr, shared_ptr, weak_ptr

[C++] 스마트 포인터 : unique_ptr, shared_ptr, weak_ptr

개요

안녕하세요. 이번 글에서는 C++의 스마트 포인터(smart pointer)에 대해 알아보겠습니다. C++에서 new로 할당한 메모리는 delete로 직접 해제해야 하는데, 해제를 빠뜨리거나 중간에 예외가 발생하면 메모리 누수(memory leak)가 생깁니다. 스마트 포인터는 이런 수동 관리의 부담을 줄여 주는, 자신이 소멸될 때 가리키던 메모리를 자동으로 해제해 주는 객체입니다. C++11부터 <memory> 헤더에서 unique_ptr, shared_ptr, weak_ptr 세 가지를 제공합니다.

왜 필요한가 — 일반 포인터의 문제

일반 포인터는 아래처럼 할당한 메모리를 직접 해제해야 합니다. 그런데 delete를 빠뜨리거나, 해제 전에 return/예외로 함수를 빠져나가면 그대로 메모리 누수가 됩니다.

void func()
{
    int* p = new int(10);

    // ... 중간에 예외가 발생하거나 return 하면
    if (someError)
        return;      // delete를 못 하고 빠져나감 → 누수

    delete p;        // 매번 잊지 않고 해제해야 함
}

스마트 포인터를 쓰면 스코프를 벗어나는 순간 소멸자가 자동으로 호출되어 메모리가 해제되므로, delete를 직접 부를 필요가 없습니다. 이런 방식을 RAII(Resource Acquisition Is Initialization)라고 합니다.

한눈에 보기

종류소유 방식복사주 용도
unique_ptr단독 소유 (하나만)불가 (이동만 가능)기본 선택지
shared_ptr공유 소유 (참조 카운트)가능여러 곳에서 함께 소유
weak_ptr소유하지 않고 참조만가능shared_ptr 순환 참조 해결

unique_ptr — 단독 소유

하나의 객체를 단 하나의 포인터만 소유하도록 하는 스마트 포인터입니다. 소유권이 유일해야 하므로 복사는 불가능하고, std::move로 소유권을 넘기는 이동만 가능합니다. 생성은 std::make_unique(C++14)를 사용하는 것이 안전합니다.

#include <iostream>
#include <memory>

int main()
{
    // make_unique로 생성 (권장)
    std::unique_ptr<int> p1 = std::make_unique<int>(10);
    std::cout << *p1 << "\n";   // 10

    // std::unique_ptr<int> p2 = p1;   // 컴파일 에러: 복사 불가
    std::unique_ptr<int> p2 = std::move(p1); // 소유권 이동
    // 이제 p1은 nullptr, p2가 소유

    if (!p1)
        std::cout << "p1은 비어 있습니다\n";

    return 0;    // p2가 소멸되며 자동으로 delete
}

shared_ptr — 공유 소유

하나의 객체를 여러 포인터가 함께 소유할 수 있는 스마트 포인터입니다. 내부에 참조 카운트(reference count)를 두어, 복사될 때마다 카운트가 1 증가하고 소멸될 때마다 1 감소합니다. 카운트가 0이 되는 순간 객체가 해제됩니다. 생성은 std::make_shared를 권장합니다.

#include <iostream>
#include <memory>

int main()
{
    std::shared_ptr<int> p1 = std::make_shared<int>(10);
    std::cout << p1.use_count() << "\n"; // 1

    {
        std::shared_ptr<int> p2 = p1;    // 복사 → 함께 소유
        std::cout << p1.use_count() << "\n"; // 2
    }   // p2 소멸 → 카운트 1로 감소

    std::cout << p1.use_count() << "\n"; // 1
    return 0;    // p1 소멸 → 카운트 0 → 자동 해제
}

use_count로 현재 몇 곳에서 소유하고 있는지 확인할 수 있습니다.

weak_ptr — 순환 참조 끊기

shared_ptr끼리 서로를 가리키면 참조 카운트가 영원히 0이 되지 않아, 메모리가 해제되지 않는 순환 참조(circular reference) 문제가 생깁니다.

struct Node
{
    std::shared_ptr<Node> next;  // 서로 shared_ptr로 참조하면
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;   // a가 b를 소유
b->next = a;   // b가 a를 소유 → 카운트가 서로 1씩 남아 해제 안 됨

이때 한쪽을 weak_ptr로 바꾸면 됩니다. weak_ptr는 객체를 소유(참조 카운트 증가)하지 않고 그냥 관찰만 하므로, 서로를 가리켜도 카운트가 정상적으로 0이 되어 해제됩니다.

struct Node
{
    std::weak_ptr<Node> next;    // 한쪽을 weak_ptr로 → 순환 끊김
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;   // 이제 카운트가 서로 올라가지 않으므로 정상 해제됨

한 가지 더, weak_ptr는 소유를 하지 않기 때문에 *로 바로 접근할 수 없습니다. 실제로 쓸 때는 lock()을 호출해 shared_ptr를 얻는데, 대상이 아직 살아 있으면 유효한 shared_ptr를, 이미 해제됐으면 빈 값(nullptr)을 반환합니다. 즉 lock()은 “살아 있는지 확인 + 안전하게 접근”을 한 번에 해 줍니다.

std::weak_ptr<int> w;              // 소유하지 않는 관찰자

{
    auto sp = std::make_shared<int>(10); // sp가 소유 (카운트 1)
    w = sp;                              // w는 관찰만 (카운트 그대로 1)

    if (auto p = w.lock())               // sp가 살아 있음 → 유효한 shared_ptr
        std::cout << "살아 있음: " << *p << "\n";  // 여기 실행
}   // 스코프 끝 → sp 소멸 → 카운트 0 → 대상 해제

if (auto p = w.lock())                   // 대상이 이미 해제됨
    std::cout << "살아 있음\n";
else
    std::cout << "이미 해제됨\n";        // 이번엔 여기 실행

정리

  • unique_ptr — 소유자가 하나뿐일 때. 기본적으로 이것을 먼저 고려합니다.
  • shared_ptr — 여러 곳에서 같은 객체를 함께 소유해야 할 때.
  • weak_ptr — shared_ptr가 서로를 참조해 생기는 순환 참조를 끊을 때.

스마트 포인터를 쓰면 delete를 직접 호출하지 않아도 스코프를 벗어날 때 자동으로 해제되므로, 메모리 누수와 이중 해제 같은 실수를 크게 줄일 수 있습니다. 이상으로 C++ 스마트 포인터에 대해 간단히 알아보았습니다.

참고