본문 바로가기

스마트 포인터

unique_ptr·shared_ptr·weak_ptr의 소유권과 해제 시점을 비교하고 RAII로 누수와 순환 참조를 예방합니다.

스마트 포인터(Smart Pointers)는 동적 객체의 소유권과 해제 시점을 타입으로 표현하는 도구입니다.

이 절에서는 unique_ptr, shared_ptr, weak_ptr의 책임과 사용 기준을 다룹니다.

스마트 포인터는 C++11에서 도입되었으며, 전통적인 원시 포인터(new, delete)가 가지고 있던 메모리 누수(memory leak)와 댕글링 포인터(dangling pointer) 문제를 안전하고 효율적으로 해결해주는 도구입니다.

스마트 포인터는 RAII(Resource Acquisition Is Initialization) 원칙을 기반으로 하여, 자원(여기서는 동적 할당된 메모리)의 생명 주기를 자동으로 관리해줍니다.


스마트 포인터의 등장 배경

C++에서 new와 delete를 사용하여 동적으로 메모리를 할당하고 해제하는 것은 강력하지만, 동시에 매우 위험한 작업입니다.

원시 포인터 사용 시의 문제점

메모리 누수 (Memory Leak): new로 할당한 메모리를 delete로 해제하는 것을 잊거나, 예외 발생 등으로 delete 문이 실행되지 않으면 메모리 누수가 발생합니다.

이는 프로그램의 성능 저하 및 궁극적인 작동 불능으로 이어질 수 있습니다.

메모리 누수 예시
void func() {
    int* ptr = new int[10];
    // 만약 여기서 예외 발생 시, delete[] ptr; 문이 실행되지 않아 메모리 누수
    // ...
    delete[] ptr;
}

댕글링 포인터 (Dangling Pointer): 이미 해제된 메모리를 가리키는 포인터입니다.

이러한 포인터를 사용하면 정의되지 않은 동작(Undefined Behavior)이 발생하여 프로그램 충돌이나 데이터 손상으로 이어질 수 있습니다.

댕글링 포인터 예시
int* ptr = new int;
delete ptr;
*ptr = 10; // 댕글링 포인터 사용, 위험!

이중 해제 (Double Free): 동일한 메모리 영역을 두 번 이상 delete하는 경우입니다.

이 또한 정의되지 않은 동작을 유발합니다.

이중 해제 예시
int* ptr = new int;
delete ptr;
delete ptr; // 이중 해제, 위험!

스마트 포인터는 이러한 문제점을 해결하기 위해 설계된 클래스 템플릿입니다.

스마트 포인터는 포인터처럼 동작하면서도, 자신이 가리키는 메모리(자원)의 소유권을 관리하여 해당 메모리가 더 이상 필요 없을 때 자동으로 해제해줍니다.

즉, RAII 원칙을 포인터에 적용한 것입니다.


std::unique_ptr (단독 소유권 포인터)

std::unique_ptr는 동적으로 할당된 객체에 대한 단독 소유권(exclusive ownership)을 가집니다.

즉, 한 번에 하나의 unique_ptr만이 특정 메모리 블록을 소유할 수 있습니다.

unique_ptr가 소멸되거나 reset() 등을 호출해 소유권을 놓을 때, 소유하고 있던 메모리를 자동으로 delete합니다.

특징
  • 복사 불가능(Non-copyable): unique_ptr는 복사 생성자나 복사 대입 연산자가 없습니다. 소유권을 복사할 수 없습니다.
  • 이동 가능(Move-only): std::move를 사용하여 소유권을 다른 unique_ptr로 이전할 수 있습니다. 소유권을 넘긴 unique_ptr는 nullptr이 됩니다.
  • 경량(Lightweight): 원시 포인터와 거의 동일한 성능을 제공합니다. 추가적인 오버헤드가 거의 없습니다.
생성 및 사용
  • C++11: new를 사용하여 직접 생성합니다.
  • C++14 이후: std::make_unique<T>() 함수를 사용하는 것이 권장됩니다. 이는 예외 안전성을 높이고 코드 가독성을 개선합니다.
std::unique_ptr 기본 사용법
#include <iostream>
#include <utility>
#include <memory> // unique_ptr, make_unique
#include <string>

class MyClass {
public:
    std::string name;
    MyClass(const std::string& n) : name(n) {
        std::cout << "MyClass '" << name << "' created.\n";
    }
    ~MyClass() {
        std::cout << "MyClass '" << name << "' destroyed.\n";
    }
    void doSomething() {
        std::cout << "MyClass '" << name << "' doing something.\n";
    }
};

int main() {
    std::cout << "--- Unique Ptr 1 (Simple) ---\n";
    {
        // MyClass 객체를 unique_ptr로 생성
        std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>("Object1");
        ptr1->doSomething();
        // 스코프를 벗어나면 ptr1이 자동으로 소멸되고 Object1도 파괴됨
    } // ~MyClass() 호출됨

    std::cout << "\n--- Unique Ptr 2 (Move Semantics) ---\n";
    std::unique_ptr<MyClass> ptrA = std::make_unique<MyClass>("ObjectA");
    ptrA->doSomething();

    std::unique_ptr<MyClass> ptrB;
    // ptrB = ptrA; // 컴파일 오류! 복사 불가능
    ptrB = std::move(ptrA); // 소유권 이전 (ptrA는 nullptr이 됨)

    if (ptrA) { // ptrA가 유효한지 확인
        std::cout << "ptrA is valid.\n";
    } else {
        std::cout << "ptrA is nullptr.\n"; // 출력됨
    }

    if (ptrB) {
        ptrB->doSomething(); // 출력: MyClass 'ObjectA' doing something.
    }
    // main 함수 종료 시 ptrB가 소멸되고 ObjectA 파괴됨
    // (ptrA는 이미 nullptr이므로 아무것도 파괴하지 않음)

    std::cout << "\n--- Unique Ptr 3 (Reset) ---\n";
    std::unique_ptr<MyClass> ptrC = std::make_unique<MyClass>("ObjectC");
    ptrC->doSomething();
    ptrC.reset(); // ptrC가 소유하던 ObjectC가 즉시 파괴됨 (ObjectC destroyed.)
    if (!ptrC) {
        std::cout << "ptrC is now nullptr.\n";
    }

    std::cout << "\n--- Unique Ptr 4 (Release - 원시 포인터 반환) ---\n";
    std::unique_ptr<MyClass> ptrD = std::make_unique<MyClass>("ObjectD");
    MyClass* rawPtr = ptrD.release(); // 소유권 포기 및 원시 포인터 반환. ptrD는 nullptr이 됨.
                                      // ObjectD는 더 이상 자동으로 해제되지 않음!
    rawPtr->doSomething();
    delete rawPtr; // 개발자가 수동으로 해제해야 함! (매우 중요!)
    // ~MyClass() 호출됨

    std::cout << "End of main.\n";
    return 0;
} // 이곳에서 ptrA, ptrB 등 스코프 벗어나는 unique_ptr들이 소멸됨.

std::shared_ptr (공유 소유권 포인터)

std::shared_ptr는 여러 개의 스마트 포인터가 동일한 동적 할당 객체에 대한 공유 소유권(shared ownership)을 가질 수 있도록 합니다.

shared_ptr는 해당 객체를 가리키는 shared_ptr의 개수(참조 횟수, reference count)를 내부적으로 추적합니다.

참조 횟수가 0이 되면, 즉 해당 객체를 더 이상 아무 shared_ptr도 참조하지 않을 때, 객체가 자동으로 delete됩니다.

특징
  • 복사 가능(Copyable): shared_ptr를 복사하면 참조 횟수가 증가합니다.
  • 이동 가능(Movable): std::move를 인자로 이동 생성하거나 빈 대상에 이동 대입하면 기존 소유권을 넘기며 강한 소유자를 추가하지 않습니다. 이미 소유 중인 대상에 이동 대입하면 대상의 이전 소유권을 먼저 놓으므로 참조 횟수가 줄거나 이전 객체가 파괴될 수 있습니다.
  • 참조 횟수 관리: 내부적으로 참조 횟수를 관리하는 제어 블록(control block)이 있어 unique_ptr보다 약간의 오버헤드가 있습니다.
생성 및 사용
  • C++11: new를 사용하여 직접 생성합니다.
  • C++11 이후: std::make_shared<T>() 함수를 사용하는 것이 강력히 권장됩니다. make_shared는 객체와 제어 블록을 하나의 메모리 블록에 할당하여 효율성과 예외 안전성을 높입니다.
std::shared_ptr 기본 사용법
#include <iostream>
#include <memory> // shared_ptr, make_shared
#include <string>

class Resource {
public:
    std::string id;
    Resource(const std::string& i) : id(i) {
        std::cout << "Resource '" << id << "' created.\n";
    }
    ~Resource() {
        std::cout << "Resource '" << id << "' destroyed.\n";
    }
    void use() {
        std::cout << "Using Resource '" << id << "'.\n";
    }
};

void process(std::shared_ptr<Resource> res) { // shared_ptr를 값으로 전달 (참조 횟수 증가)
    std::cout << "Inside process function. Resource count: " << res.use_count() << "\n";
    res->use();
} // res가 스코프를 벗어나면 참조 횟수 감소

int main() {
    std::cout << "--- Shared Ptr 1 (Basic) ---\n";
    {
        std::shared_ptr<Resource> r1 = std::make_shared<Resource>("SharedRes1"); // count = 1
        std::cout << "r1 count: " << r1.use_count() << "\n"; // 출력: 1

        std::shared_ptr<Resource> r2 = r1; // 복사! count = 2
        std::cout << "r1 count: " << r1.use_count() << ", r2 count: " << r2.use_count() << "\n"; // 출력: 2, 2

        process(r1); // r1이 값으로 전달되어 임시 shared_ptr이 생성됨. count = 3 (함수 내에서)
        std::cout << "Back in main. r1 count: " << r1.use_count() << "\n"; // 출력: 2
    } // r1, r2 스코프 벗어남. r2 소멸 (count=1), r1 소멸 (count=0). Resource 'SharedRes1' destroyed.

    std::cout << "\n--- Shared Ptr 2 (Custom Deleter) ---\n";
    // 배열이나 특수 해제 로직이 필요할 때 사용
    std::shared_ptr<int[]> arr_ptr(new int[5], std::default_delete<int[]>());
    // 또는 람다를 이용한 커스텀 삭제자
    std::shared_ptr<int> custom_delete_ptr(new int(10), [](int* p){
        std::cout << "Custom deleter called for " << *p << std::endl;
        delete p;
    });

    std::cout << "End of main.\n";
    return 0;
}

std::weak_ptr (약한 참조 포인터)

C++ · OWNERSHIP CYCLE

역방향 weak 관계로 강한 소유권 순환을 끊는다

약한 관계는 강한 참조 수를 늘리지 않는다. 문제 그림은 외부 owner 해제 후, 해결 그림은 본문의 a·b가 살아 있는 시점이다.

같은 시점 안의 강한 소유권과 약한 관찰문제는 외부 소유자 해제 뒤 A와B의 강한 수가 각각1남는다. 해결은 본문a,b가 살아 있는 동안 A1,B2이고 B에서A로 약한 관찰이다. 두 비교는 서로 다른 시점이며 본문 종료순서를caption으로 보존한다.문제 · 외부 a·b를 해제한 뒤shared · 소유shared · 소유Ashared_ptr<B>강한 참조 수 1Bshared_ptr<A>강한 참조 수 1해결 · 본문 a·b가 살아 있는 동안shared · 소유weak · 비소유Ashared_ptr<B>강한 참조 수 1Bweak_ptr<A>강한 참조 수 2
같은 시점 안의 강한 소유권과 약한 관찰문제는 외부 소유자 해제 뒤 A와B의 강한 수가 각각1남는다. 해결은 본문a,b가 살아 있는 동안 A1,B2이고 B에서A로 약한 관찰이다. 두 비교는 서로 다른 시점이며 본문 종료순서를caption으로 보존한다.문제 · 외부 owner 해제소유sharedA강한 참조 수 1B강한 참조 수 1해결 · 본문 a·b 생존소유weakA강한 참조 수 1B강한 참조 수 2

보조 설명 · 코드와 수명 조건

강한 순환 · 외부 owner 해제 뒤

A는 B를, B는 A를 shared_ptr로 소유한다. A·B의 강한 참조 수가 각각 1로 남아 소멸되지 않는다.

weak 역방향 · 본문 scope 안

A의 shared_ptr<B>는 B를 소유하고 B의 weak_ptr<A>는 A를 관찰한다. 지역 a, b가 살아 있을 때 강한 참조 수는 A=1, B=2이다.

본문 scope 종료 · A 다음 B

지역 b 해제: B 2→1. 지역 a 해제: A 1→0, A 소멸자 실행. A의 b_ptr 해제: B 1→0, B 소멸자 실행.

본문 scope 종료 순서는 지역 b → 지역 a → A 소멸자 → A의 b_ptr → B 소멸자이다. 접근 전 weak_ptr::lock() 결과가 비었는지 확인한다.

std::weak_ptr는 std::shared_ptr의 상호 참조(circular reference) 문제를 해결하기 위해 사용됩니다.

weak_ptr는 shared_ptr가 관리하는 객체를 참조하지만, 참조 횟수(reference count)를 증가시키지 않습니다.

따라서 weak_ptr는 객체의 소유권에 영향을 주지 않습니다.

특징
  • weak_ptr 자체로는 객체에 직접 접근할 수 없습니다. 객체에 접근하려면 lock() 멤버 함수를 통해 shared_ptr로 변환해야 합니다.
  • lock()을 호출했을 때 객체가 이미 소멸되었다면 nullptr을 가리키는 shared_ptr가 반환됩니다.
  • 상호 참조 문제 해결: shared_ptr A가 shared_ptr B를 가리키고, B가 다시 A를 가리키는 경우, 참조 횟수가 항상 1 이상이 되어 객체가 영원히 소멸되지 않는 문제가 발생할 수 있습니다. 이때 한쪽을 weak_ptr로 만들면 문제가 해결됩니다.
std::weak_ptr 사용 예시
#include <iostream>
#include <memory> // shared_ptr, weak_ptr

class B; // 전방 선언

class A {
public:
    std::shared_ptr<B> b_ptr; // B에 대한 shared_ptr
    A() { std::cout << "A created.\n"; }
    ~A() { std::cout << "A destroyed.\n"; }
};

class B {
public:
    std::weak_ptr<A> a_ptr; // A에 대한 weak_ptr (중요!)
    B() { std::cout << "B created.\n"; }
    ~B() { std::cout << "B destroyed.\n"; }
    void checkA() {
        if (auto sharedA = a_ptr.lock()) { // weak_ptr를 shared_ptr로 변환 시도
            std::cout << "A is still alive.\n";
        } else {
            std::cout << "A is no longer alive.\n";
        }
    }
};

int main() {
    std::cout << "--- Circular Reference Problem (No weak_ptr) --- (주석 처리됨)\n";
    /*
    // 만약 B::a_ptr이 shared_ptr<A>였다면:
    {
        std::shared_ptr<A> a = std::make_shared<A>(); // a_count = 1
        std::shared_ptr<B> b = std::make_shared<B>(); // b_count = 1

        a->b_ptr = b; // b_count = 2
        b->a_ptr = a; // a_count = 2 (여기서 문제 발생!)

        // 스코프를 벗어나도 a와 b의 count가 1이 되어 소멸되지 않음!
    } // A destroyed, B destroyed가 출력되지 않음 -> 메모리 누수!
    */

    std::cout << "\n--- Circular Reference Solved with weak_ptr ---\n";
    {
        std::shared_ptr<A> a = std::make_shared<A>(); // A created. a_count = 1
        std::shared_ptr<B> b = std::make_shared<B>(); // B created. b_count = 1

        a->b_ptr = b; // b_count = 2
        b->a_ptr = a; // a_count는 변하지 않음 (weak_ptr이므로)

        std::cout << "a count: " << a.use_count() << ", b count: " << b.use_count() << "\n"; // 출력: a count: 1, b count: 2
        b->checkA(); // A is still alive.

    } // 스코프 벗어남
    // 지역 b가 먼저 소멸: B 강한 참조 수 2 -> 1, B 객체는 아직 살아 있음.
    // 지역 a 소멸: A 강한 참조 수 1 -> 0, A 소멸자 본문이 먼저 실행됨.
    // A의 b_ptr 멤버 소멸: B 강한 참조 수 1 -> 0, B 소멸자 실행.
    // 이 예제의 출력 순서: A destroyed. 다음 B destroyed.
    std::cout << "End of main.\n";
    return 0;
}

따라 하기: weak_ptr 자체와 lock으로 얻은 소유자 구별하기

다음 C++17 파일은 한 스레드에서만 실행합니다. use_count()를 고정 출력으로 예측하는 동안 다른 스레드의 복사·reset은 없다고 가정합니다.

lock으로 확보한 강한 소유자의 수명
#include <iostream>
#include <memory>

int main() {
    auto owner = std::make_shared<int>(7);
    std::weak_ptr<int> observer = owner;
    auto held = observer.lock();
    std::cout << held.use_count() << '\n';
    owner.reset();
    std::cout << std::boolalpha << observer.expired() << ' ' << *held << '\n';
    held.reset();
    std::cout << observer.expired() << ' ' << static_cast<bool>(observer.lock()) << '\n';
}

출력은 첫 줄 2, 둘째 줄 false 7, 셋째 줄 true false입니다. observer는 강한 소유자 수를 늘리지 않지만, 성공한 lock()의 반환값을 저장한 held는 강한 소유자입니다. 따라서 원래 owner를 reset해도 held가 남아 있는 동안 객체는 살아 있습니다. expired()를 먼저 확인하고 나중에 원시 포인터를 사용하면 두 동작 사이의 수명 변화를 막지 못합니다. 접근할 때 얻은 lock() 결과를 확인하고 그 shared_ptr가 살아 있는 동안 사용합니다.

독립 연습

다른 소유자나 스레드가 없는 코드에서 auto p = std::make_shared<int>(7); auto q = p; std::weak_ptr<int> w = p;를 선언합니다. 이어 p = std::move(q);를 실행하면 p.use_count(), bool(q), w.expired()는 각각 무엇일까요? 다음으로 auto held = w.lock(); p.reset();를 실행한 뒤 객체가 살아 있는 이유도 설명하세요. 필요한 헤더는 memory, utility, iostream입니다.

이동 대상의 이전 소유권까지 세는 풀이

처음에는 p, q라는 두 강한 소유자가 있습니다. 이동 대입은 q의 소유권을 p로 넘기고 기존 p의 소유권을 놓으므로 마지막 강한 소유자 수는 1입니다. q는 비고 w는 만료되지 않아 결과는 1, false, false입니다. 그다음 성공한 lock이 held라는 소유자를 만들고 p를 reset한 뒤에도 그 소유자가 남습니다. held까지 놓을 때 강한 소유자 수가 0이 되어 정수 객체가 파괴됩니다. “이동은 항상 참조 수를 그대로 둔다”는 규칙 대신 어떤 소유자가 추가되고 사라지는지 추적해야 합니다.

이동 대입의 기존 대상 처리는 shared_ptr 대입 규칙에서 확인할 수 있습니다.


원시 포인터와 스마트 포인터의 전환

  • get(): 스마트 포인터가 관리하는 원시 포인터를 반환합니다. 이 원시 포인터의 유효성은 가리키는 객체 수명에 달립니다. reset으로 일찍 만료되거나 다른 shared_ptr 소유자에 의해 더 오래 유지될 수 있습니다. 반환된 원시 포인터를 delete하면 안 됩니다.
  • reset(): 이 스마트 포인터의 기존 소유권을 놓고, 새 객체를 소유하거나 nullptr을 가리키게 합니다. unique_ptr는 기존 객체가 있으면 삭제 함수(deleter)를 호출하지만, shared_ptr는 다른 강한 소유자가 남아 있으면 객체를 파괴하지 않습니다.
  • release(): unique_ptr에만 있는 함수로, 소유권을 포기하고 관리하던 원시 포인터를 반환합니다. 호출자는 원래 deleter에 맞는 해제 책임을 맡거나 다른 소유자에게 넘겨야 합니다. 배열·파일 등에는 일반 delete를 적용하면 안 됩니다.

스마트 포인터 사용 권장 사항

  • 기본은 std::unique_ptr: 단독 소유권이 필요한 경우, std::unique_ptr를 기본으로 사용합니다. 이는 가장 경량이고 효율적인 스마트 포인터입니다.
  • 공유 소유권은 std::shared_ptr: 여러 객체가 동일한 자원을 공유해야 할 때만 std::shared_ptr를 사용합니다.
  • std::make_unique / std::make_shared 사용: 커스텀 deleter 등 별도 요구가 없다면 new 대신 make_unique 또는 make_shared를 사용하여 스마트 포인터를 생성합니다. 이는 예외 안전성을 높이고 성능을 개선합니다.
  • 상호 참조는 std::weak_ptr: shared_ptr 간의 상호 참조가 발생하여 메모리 누수가 예상될 때 std::weak_ptr를 사용하여 문제를 해결합니다.
  • 원시 포인터 노출 최소화: get()을 통해 원시 포인터를 얻는 것은 피할 수 없을 때만 사용하고, delete를 호출하지 않도록 극도로 주의해야 합니다.
  • 가능하면 const 사용: const 객체를 가리키는 스마트 포인터를 선언하여 변경 불가능성을 명시하는 것이 좋습니다.

이번 장에서는 현대 C++의 핵심 기능인 스마트 포인터에 대해 상세히 학습했습니다.

전통적인 원시 포인터의 문제점(메모리 누수, 댕글링 포인터, 이중 해제)을 이해하고, 스마트 포인터가 RAII 원칙을 통해 이 문제들을 어떻게 해결하는지 알아보았습니다.

특히,

  • std::unique_ptr: 단독 소유권과 효율성으로 가장 우선적으로 고려되는 스마트 포인터
  • std::shared_ptr: 공유 소유권을 통해 여러 소유자가 하나의 자원을 관리할 수 있게 하는 포인터
  • std::weak_ptr: shared_ptr의 상호 참조 문제를 해결하고, 객체의 존재 여부를 안전하게 확인하는 포인터

각 스마트 포인터의 특징, 사용법, 그리고 std::make_unique 및 std::make_shared의 중요성을 깊이 있게 다루었습니다.

스마트 포인터는 현대 C++에서 안전하고 견고한 프로그램을 작성하는 데 필수적인 도구입니다.

이를 통해 개발자는 복잡한 메모리 관리로부터 자유로워지고, 비즈니스 로직에 더 집중할 수 있게 됩니다.

스마트 포인터는 포인터 문법보다 소유권을 표현하는 장치이므로, 먼저 객체를 파괴할 책임이 어디에 있는지 정해야 합니다.

unique_ptr, shared_ptr, weak_ptr의 차이도 new/delete 대체가 아니라 소유권 정책의 선택으로 봐야 합니다.

C++ · OWNERSHIP CHOICE

동적 수명과 소유권 책임을 기준으로 포인터를 고른다

값·컨테이너를 먼저 고려한다. 동적 객체에 단일 소유자가 있으면 unique_ptr, 실제 공유 수명이 필요하면 shared_ptr, 소유하지 않는 관찰에는 weak_ptr 또는 유효 범위가 명확한 참조를 사용한다.

소유·동적 수명·공유 수명에 따른 분기객체를소유하지않으면weak_ptr또는유효범위참조.소유하지만동적수명불필요면값컨테이너.동적단일소유unique_ptr,실제공유수명shared_ptr. 객체를소유하는가?아니요예동적 소유가필요한가?아니요예공유 수명이필요한가?아니요예weak_ptr / 참조비소유 관찰값 / 컨테이너가장 단순한 소유unique_ptr단일 소유shared_ptr공유 수명
소유·동적 수명·공유 수명에 따른 분기객체를소유하지않으면weak_ptr또는유효범위참조.소유하지만동적수명불필요면값컨테이너.동적단일소유unique_ptr,실제공유수명shared_ptr.객체를소유하는가?예아니요동적 소유가필요한가?예아니요공유 수명이필요한가?예아니요weak_ptr / 참조비소유 관찰값 / 컨테이너가장 단순한 소유unique_ptr단일 소유shared_ptr공유 수명

보조 설명 · 코드와 수명 조건

  1. 값을 우선

    동적 수명이 필요하지 않으면 값과 표준 컨테이너가 가장 안전합니다.

  2. 단일 소유

    unique_ptr는 복사할 수 없고 이동으로 소유권을 넘깁니다.

  3. 공유 소유

    shared_ptr는 복사·이동 가능하며 control block의 강한 참조 수를 공유합니다.

  4. 비소유 관찰

    shared_ptr가 관리하는 객체의 비소유 관찰에는 weak_ptr를 씁니다. 참조·원시 포인터는 대상의 유효 범위를 따로 보장합니다.

get()은 소유권을 넘기지 않고, release()는 unique_ptr의 deleter 책임을 호출자에게 함께 넘긴다.