shared_ptr 심화: enable_shared_from_this와 aliasing 생성자 활용

비동기 콜백에서 this를 쓰려면 enable_shared_from_this를 상속하고 shared_from_this()로 얻은 포인터를 콜백에 넘기면, 콜백이 끝날 때까지 객체가 살아 있습니다.

들어가며

shared_ptr 기초를 익힌 뒤 실제 코드에서는 this를 shared_ptr로 만들어 비동기 콜백에 넘기거나, shared_ptr로 관리되는 부모 객체의 멤버를 shared_ptr로 돌려주거나, 여러 스레드가 같은 shared_ptr 변수를 바꾸는 상황을 자주 만납니다. 이때 잘못된 패턴을 쓰면 이중 해제(double-free)나 use-after-free로 크래시가 납니다.

이 글은 그런 상황마다 쓰는 도구를 하나씩 다룹니다. enable_shared_from_this는 이미 shared_ptr로 관리 중인 객체에서 같은 제어 블록을 공유하는 shared_ptr을 얻게 해 주고, aliasing 생성자는 멤버를 가리키면서 부모의 수명을 공유하는 shared_ptr을 만듭니다. 커스텀 삭제자는 delete가 아닌 방법으로 해제해야 하는 자원을 관리하고, std::atomic<std::shared_ptr<T>>는 여러 스레드가 같은 변수를 교체할 때 필요합니다. 마지막으로 자주 만나는 에러를 모아 원인과 해결법을 정리합니다.


enable_shared_from_this로 this를 안전하게 공유하기

왜 shared_ptr(this)가 위험한가

std::enable_shared_from_this<T>를 상속하면, 이미 shared_ptr로 관리 중인 객체에서 shared_from_this()를 통해 동일한 제어 블록을 공유하는 shared_ptr을 얻을 수 있습니다. 반대로 shared_ptr<T>(this)처럼 새 shared_ptr을 만들면 새 제어 블록이 생깁니다.

flowchart TB
    subgraph bad["shared_ptr(this) 사용"]
        B1["shared_ptr A"] --> B2["제어 블록 1"]
        B3["shared_ptr B = shared_ptr(this)"] --> B4["제어 블록 2"]
        B2 --> B5[객체]
        B4 --> B5
        B5 -.->|각각 독립적으로 delete 시도 → 이중 해제| B5
    end
#include <memory>
// 위험한 코드: 이중 해제
class BadService {
public:
    std::shared_ptr<BadService> getShared() {
        return std::shared_ptr<BadService>(this);  // 새 제어 블록
    }
};
int main() {
    auto p1 = std::make_shared<BadService>();
    auto p2 = p1->getShared();  // p1과 p2는 서로 다른 제어 블록
    // p2 소멸 시 객체 파괴 → p1 소멸 시 같은 객체를 또 파괴 → 정의되지 않은 동작
    return 0;
}

p1과 p2는 같은 객체를 가리키지만 참조 카운트를 따로 셉니다. 먼저 p2의 카운트가 0이 되면 객체를 delete하는데, 이 객체는 make_shared가 제어 블록과 함께 한 번에 할당한 메모리의 일부라 delete할 수 있는 포인터조차 아닙니다. 이어 p1이 같은 객체를 다시 파괴하려 하므로, 보통 힙 손상이나 double free 진단과 함께 크래시가 납니다.

enable_shared_from_this 사용법

#include <memory>
#include <iostream>
class GoodService : public std::enable_shared_from_this<GoodService> {
public:
    std::shared_ptr<GoodService> getShared() {
        return shared_from_this();  // 동일 제어 블록 공유
    }
    void doWork() { std::cout << "Working\n"; }
};
int main() {
    auto p1 = std::make_shared<GoodService>();
    auto p2 = p1->getShared();  // p1과 p2는 같은 제어 블록
    std::cout << "use_count: " << p1.use_count() << std::endl;  // 2
    return 0;  // p2, p1 순으로 소멸 → 한 번만 파괴
}

enable_shared_from_this는 객체 안에 weak_ptr 하나를 숨겨 두고, shared_ptr이 객체를 처음 소유하는 순간(make_shared나 shared_ptr<T>(new T)) 그 weak_ptr을 채웁니다. shared_from_this()는 이 weak_ptr을 shared_ptr로 바꿔 돌려줄 뿐이므로, 같은 제어 블록을 공유하고 참조 카운트가 올바르게 늘어납니다.

여기서 세 가지 조건이 나옵니다. 첫째, 객체가 반드시 shared_ptr로 생성되어 있어야 합니다. 스택에 둔 객체나 new로만 만든 객체에서 shared_from_this()를 부르면 숨은 weak_ptr이 비어 있어 C++17부터는 std::bad_weak_ptr 예외가 납니다(C++17 이전에는 정의되지 않은 동작이었습니다). 둘째, 생성자와 소멸자 안에서는 부를 수 없습니다. 생성자가 실행되는 동안에는 아직 shared_ptr이 객체를 소유하지 않기 때문입니다. 셋째, enable_shared_from_this는 public으로 상속해야 합니다. private이나 protected로 상속하면 shared_ptr 생성자가 기반 클래스를 찾지 못해 weak_ptr이 채워지지 않고, 결국 bad_weak_ptr이 납니다.

비동기 콜백에서의 사용

#include <memory>
#include <functional>
#include <iostream>
#include <thread>
#include <chrono>
class AsyncWorker : public std::enable_shared_from_this<AsyncWorker> {
public:
    void startAsync(std::function<void()> onComplete) {
        auto self = shared_from_this();  // 콜백이 끝날 때까지 수명 연장
        std::thread([self, onComplete]() {
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
            self->doWork();  // self가 살아 있으므로 안전
            if (onComplete) onComplete();
        }).detach();
    }
private:
    void doWork() { std::cout << "Async work done\n"; }
};
int main() {
    auto worker = std::make_shared<AsyncWorker>();
    worker->startAsync([]() { std::cout << "Callback\n"; });
    worker.reset();  // main이 소유를 놓아도 스레드의 self가 객체를 유지
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    return 0;
}

실행 결과는 다음과 같습니다.

Async work done
Callback

람다가 self를 값으로 들고 있으므로, main에서 worker를 놓아도 스레드가 끝날 때까지 객체가 유지됩니다. this를 직접 캡처했다면 worker.reset() 시점에 객체가 파괴되어 스레드가 해제된 메모리를 쓰게 됩니다. 예제의 마지막 sleep_for는 detach된 스레드가 출력을 마치기 전에 프로세스가 끝나지 않게 하려는 것일 뿐이며, 실제 코드에서는 detach 대신 종료 시점을 관리할 수 있는 구조(스레드 풀, std::jthread 등)를 쓰는 편이 낫습니다.

self로 수명을 연장하는 방식에는 대가도 있습니다. 콜백이 완료되지 않거나 큐에 영원히 남으면 객체도 영원히 살아남습니다. “객체가 이미 없으면 콜백을 건너뛰면 된다”는 상황이라면 다음 절의 weak_from_this()가 더 맞습니다.

weak_from_this() (C++17)

class MyClass : public std::enable_shared_from_this<MyClass> {
public:
    std::weak_ptr<MyClass> getWeak() {
        return weak_from_this();  // C++17, 소유하지 않는 참조
    }
};

weak_from_this()는 예외를 던지지 않으며, 객체가 shared_ptr로 관리되지 않는 경우에는 빈 weak_ptr을 돌려줍니다. 콜백에 weak_ptr을 넘기고 실행 시점에 lock()해 보는 방식은 순환 참조를 피하고 “있으면 쓰고 없으면 무시”하는 동작을 만들 때 씁니다.


aliasing 생성자로 멤버 수명을 부모에 묶기

aliasing 생성자란

aliasing 생성자는 shared_ptr<T>(r, ptr) 형태로, r이 소유권(수명)을 제공하고 ptr이 실제로 가리킬 대상이 됩니다. 즉 “r이 관리하는 객체의 수명에 묶여 있는 ptr”을 가리키는 shared_ptr을 만듭니다.

template< class Y >
shared_ptr( const shared_ptr<Y>& r, element_type* ptr ) noexcept;

r은 제어 블록을 공유할 shared_ptr이고, ptr은 역참조할 때 쓸 포인터입니다. 표준은 ptr이 무엇이든 허용하지만, 의미가 있으려면 보통 r이 관리하는 객체의 일부(멤버나 배열 요소)여야 합니다.

flowchart LR
    subgraph aliasing[aliasing shared_ptr]
        A1["shared_ptr Container"] --> A2["제어 블록"]
        A2 --> A3["Container 객체"]
        A4["shared_ptr Bar (aliasing)"] --> A2
        A4 -.->|가리킴| A5["Bar (멤버)"]
        A3 --> A5
    end
#include <memory>
#include <iostream>
struct Bar {
    int value = 42;
};
struct Container {
    Bar member;
};
int main() {
    auto container = std::make_shared<Container>();
    container->member.value = 100;
    // aliasing: container의 수명에 member가 묶임
    std::shared_ptr<Bar> barPtr(container, &container->member);
    std::cout << barPtr->value << std::endl;  // 100
    std::cout << "use_count: " << container.use_count() << std::endl;  // 2
    container.reset();  // container를 놓아도 barPtr이 Container를 유지
    std::cout << barPtr->value << std::endl;  // 100
    barPtr.reset();    // 이제 Container와 그 멤버 Bar가 함께 파괴
    return 0;
}

barPtr은 container의 제어 블록을 공유하면서 역참조할 때는 &container->member를 돌려줍니다. 두 포인터가 같은 참조 카운트를 쓰므로 container를 먼저 reset해도 barPtr이 남아 있는 한 Container 객체는 파괴되지 않고, 그 멤버인 Bar에도 안전하게 접근할 수 있습니다.

멤버 shared_ptr을 돌려주는 함수에서는 이렇게 씁니다. 클래스가 enable_shared_from_this를 상속해야 한다는 점에 주의하세요.

struct Container : std::enable_shared_from_this<Container> {
    Bar member;
    std::shared_ptr<Bar> getMember() {
        return std::shared_ptr<Bar>(shared_from_this(), &member);
    }
};

// 외부에서 이미 shared_ptr<Container>를 가지고 있다면
std::shared_ptr<Bar> getBar(const std::shared_ptr<Container>& c) {
    return std::shared_ptr<Bar>(c, &c->member);
}

aliasing과 enable_shared_from_this

aliasing 생성자는 가리키는 객체의 enable_shared_from_this를 초기화하지 않습니다. 따라서 Bar가 enable_shared_from_this<Bar>를 상속하더라도, aliasing으로만 만든 shared_ptr<Bar>가 있는 상태에서 Bar의 shared_from_this()를 호출하면 std::bad_weak_ptr이 납니다. aliasing은 수명만 공유할 뿐입니다.

aliasing은 부모 객체의 멤버를 돌려줄 때 외에도, 배열 전체를 소유하는 shared_ptr로 특정 원소를 가리키는 shared_ptr을 만들거나(std::shared_ptr<int>(arr, &arr[i])), Pimpl 클래스의 내부 구현 객체를 바깥 객체 수명에 묶어 노출할 때 쓰입니다. 새 제어 블록을 만들지 않고 기존 제어 블록의 카운트만 늘리므로 추가 할당도 없습니다.


FILE*, 배열, 뮤텍스를 위한 커스텀 삭제자

shared_ptr은 기본적으로 참조 카운트가 0이 될 때 delete ptr을 호출합니다. FILE*, new[]로 만든 배열, OS 핸들, 소켓처럼 다른 방식으로 해제해야 하는 자원은 커스텀 삭제자를 지정해야 합니다. 삭제자는 T*를 받아 자원을 해제하는 호출 가능 객체(함수, 람다, 함수 객체)입니다.

FILE* 관리 (fclose)

#include <memory>
#include <cstdio>
#include <iostream>
int main() {
    std::shared_ptr<FILE> file(
        std::fopen("test.txt", "w"),
        [](FILE* fp) {
            if (fp) {
                std::cout << "fclose 호출\n";
                std::fclose(fp);
            }
        }
    );
    if (file) {
        std::fprintf(file.get(), "Hello, shared_ptr!\n");
    }
    return 0;  // 마지막 소유자가 사라질 때 람다가 fclose 호출
}

첫 인자는 관리할 포인터, 두 번째 인자는 삭제자입니다. fopen이 실패하면 null을 관리하게 되는데, shared_ptr은 관리 포인터가 null이어도 삭제자를 호출하므로 람다 안에서 null을 검사했습니다.

배열 관리

#include <memory>
int main() {
    // C++17 이전: 삭제자로 delete[] 지정
    std::shared_ptr<int> arr1(new int[10], std::default_delete<int[]>());

    // C++17: 배열 타입을 쓰면 기본 삭제자가 delete[]이고 operator[]도 제공
    std::shared_ptr<int[]> arr2(new int[10]);
    arr2[0] = 1;

    // C++20: make_shared로 배열 생성
    auto arr3 = std::make_shared<int[]>(10);
    return 0;
}

std::shared_ptr<int>(new int[10])처럼 삭제자 없이 만들면 delete[] 대신 delete가 호출되어 정의되지 않은 동작이 됩니다. C++17부터는 shared_ptr<int[]>를 쓰면 이 실수가 생기지 않고, 배열에 대한 make_shared는 C++20부터 지원됩니다. 크기가 정해진 배열이라면 std::shared_ptr<std::vector<int>>나 std::shared_ptr<std::array<int, N>>가 더 다루기 쉽습니다.

뮤텍스 잠금 해제

#include <memory>
#include <mutex>
void processWithLock(std::mutex& mtx) {
    mtx.lock();  // 먼저 잠근 뒤에 해제 책임을 shared_ptr에 넘긴다
    std::shared_ptr<std::mutex> unlocker(&mtx, [](std::mutex* m) { m->unlock(); });
    // ... 작업 ...
}   // unlocker의 마지막 복사본이 사라질 때 unlock

잠그기 전에 삭제자를 가진 shared_ptr을 먼저 만들면, lock()이 예외를 던졌을 때 잠기지 않은 뮤텍스를 unlock하는 정의되지 않은 동작이 생기므로 순서가 중요합니다. 다만 이 패턴은 shared_ptr 생성 자체가 제어 블록 할당에 실패해 예외를 던질 수 있고(이때도 삭제자는 호출되어 unlock은 보장됩니다), 잠금을 여러 소유자가 공유해야 하는 특수한 경우가 아니면 쓸 이유가 없습니다. 보통은 std::lock_guard나 std::unique_lock이 맞습니다.

삭제자와 제어 블록

커스텀 삭제자는 제어 블록에 저장됩니다. 삭제자 타입은 shared_ptr<T>의 타입에 드러나지 않으므로 삭제자가 다른 shared_ptr끼리도 같은 타입으로 섞어 쓸 수 있습니다(unique_ptr은 삭제자가 타입의 일부라 이렇게 할 수 없습니다). 대신 make_shared는 삭제자를 받지 않으므로 객체와 제어 블록이 따로 할당되고, 할당이 두 번 일어납니다. 기본 delete로 충분한 객체라면 make_shared를 우선 씁니다.


shared_ptr에서 스레드 안전한 것과 아닌 것

flowchart TB
    subgraph safe[스레드 안전]
        S1["참조 카운트 증감"]
        S2["서로 다른 shared_ptr 객체를 각자 복사·대입·소멸"]
    end
    subgraph unsafe[스레드 안전하지 않음]
        U1["같은 shared_ptr 객체를 동시에 수정"]
        U2["가리키는 객체 자체에 대한 동시 접근"]
    end

참조 카운트의 증감은 원자적이라, 같은 객체를 가리키는 서로 다른 shared_ptr 복사본을 여러 스레드가 각자 복사하고 대입하고 소멸시키는 것은 안전합니다. 마지막 복사본이 사라질 때 객체가 정확히 한 번 파괴된다는 것도 보장됩니다.

안전하지 않은 것은 두 가지입니다. 하나는 같은 shared_ptr 변수를 여러 스레드가 동시에 수정(reset, 대입 등)하거나, 한 스레드가 수정하는 동안 다른 스레드가 읽는 경우로, 이는 데이터 레이스입니다. 다른 하나는 가리키는 객체 자체입니다. shared_ptr은 수명만 관리할 뿐이므로, 여러 스레드가 *ptr을 동시에 수정한다면 그 동기화는 따로 해야 합니다.

안전한 패턴: 스레드마다 복사본

#include <memory>
#include <thread>
#include <vector>
#include <iostream>
void worker(std::shared_ptr<int> ptr) {
    // 각 스레드는 자기 복사본을 가짐
    std::cout << "value: " << *ptr << '\n';
}
int main() {
    auto ptr = std::make_shared<int>(42);
    std::vector<std::thread> threads;
    for (int i = 0; i < 3; ++i) {
        threads.emplace_back(worker, ptr);  // 값으로 전달 → 복사
    }
    for (auto& t : threads) t.join();
    return 0;
}

worker에 shared_ptr을 값으로 넘기면 각 스레드가 자기 복사본을 받습니다. 복사할 때 참조 카운트만 원자적으로 증가하고, 각 스레드는 자기 복사본만 다루므로 데이터 레이스가 없습니다. (여러 스레드가 std::cout에 동시에 쓰면 출력이 섞일 수는 있지만, 이는 shared_ptr과 무관한 문제입니다.)

위험한 패턴: 같은 shared_ptr 변수를 공유하며 수정

std::shared_ptr<int> ptr = std::make_shared<int>(42);
std::thread t1([&ptr]() { ptr = std::make_shared<int>(1); });  // 데이터 레이스
std::thread t2([&ptr]() { ptr.reset(); });                     // 데이터 레이스
t1.join(); t2.join();

두 스레드가 같은 ptr 변수를 참조로 캡처해 동시에 수정하므로, 내부의 포인터와 제어 블록 포인터가 어긋나게 섞일 수 있습니다. 각 스레드가 복사본만 쓰도록 바꾸거나, 정말로 같은 변수를 여러 스레드가 교체해야 한다면 std::atomic<std::shared_ptr<T>>를 씁니다.

atomic<shared_ptr> (C++20)

#include <atomic>
#include <chrono>
#include <iostream>
#include <memory>
#include <thread>
std::atomic<std::shared_ptr<int>> atomicPtr;
void writer() {
    for (int i = 0; i < 5; ++i) {
        atomicPtr.store(std::make_shared<int>(i));
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}
void reader() {
    for (int i = 0; i < 5; ++i) {
        auto local = atomicPtr.load();  // 현재 값의 복사본을 원자적으로 얻음
        if (local) {
            std::cout << "Reader: " << *local << std::endl;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(15));
    }
}
int main() {
    atomicPtr.store(std::make_shared<int>(0));
    std::thread t1(writer);
    std::thread t2(reader);
    t1.join();
    t2.join();
    return 0;
}

store는 새 shared_ptr로 원자적으로 교체하고, load는 그 시점의 값을 복사해 돌려줍니다. 읽는 쪽은 local 복사본만 쓰므로, 그 사이에 writer가 값을 바꿔도 local이 가리키는 객체는 살아 있습니다. exchange, compare_exchange_strong/weak도 지원됩니다.

#include <atomic>
#include <memory>
std::atomic<std::shared_ptr<int>> counterPtr;
void updateIfEqual(int expected, int newVal) {
    auto current = counterPtr.load();
    while (current && *current == expected) {
        auto desired = std::make_shared<int>(newVal);
        if (counterPtr.compare_exchange_strong(current, desired)) {
            break;  // 교체 성공
        }
        // 실패하면 current가 최신 값으로 갱신되어 조건을 다시 검사
    }
}

std::atomic<std::shared_ptr<T>>는 대부분의 구현에서 lock-free가 아니며 내부적으로 작은 락을 쓰기도 하고, 표준 라이브러리마다 지원 시점이 달라 사용하는 컴파일러와 라이브러리 버전에서 지원되는지 확인해야 합니다. C++20 이전에는 std::atomic_load(&ptr), std::atomic_store(&ptr, value) 같은 자유 함수를 썼는데, 이 함수들은 C++20에서 deprecated되었습니다.

// C++11~17 방식 (C++20에서 deprecated)
std::shared_ptr<int> ptr = std::make_shared<int>(42);
std::shared_ptr<int> loaded = std::atomic_load(&ptr);
std::atomic_store(&ptr, std::make_shared<int>(100));

자유 함수 방식은 같은 변수에 대한 모든 접근이 이 함수들을 거쳐야 안전하다는 약속에 의존하므로, 한 곳이라도 일반 대입을 하면 다시 데이터 레이스가 됩니다. 타입으로 강제하는 atomic<shared_ptr>가 나온 이유입니다.


비동기 타이머, 스레드 풀, weak_ptr 옵저버 예제

스레드 풀에 shared_ptr 작업 전달

#include <chrono>
#include <condition_variable>
#include <functional>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
class ThreadPool {
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
public:
    explicit ThreadPool(size_t n) {
        for (size_t i = 0; i < n; ++i) {
            workers_.emplace_back([this]() {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lk(mtx_);
                        cv_.wait(lk, [this] { return stop_ || !tasks_.empty(); });
                        if (stop_ && tasks_.empty()) return;
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();
                }
            });
        }
    }
    template<typename F>
    void submit(F&& f) {
        {
            std::lock_guard<std::mutex> lk(mtx_);
            tasks_.emplace(std::forward<F>(f));
        }
        cv_.notify_one();
    }
    ~ThreadPool() {
        { std::lock_guard<std::mutex> lk(mtx_); stop_ = true; }
        cv_.notify_all();
        for (auto& w : workers_) w.join();
    }
};
int main() {
    ThreadPool pool(2);
    auto config = std::make_shared<int>(42);
    pool.submit([config]() {
        std::cout << "Task: " << *config << std::endl;
    });
    return 0;  // 소멸자가 남은 작업을 모두 처리한 뒤 스레드를 join
}

작업 람다가 config를 값으로 캡처하므로, main이 config를 놓더라도 작업이 실행될 때까지 데이터가 살아 있습니다. 소멸자는 stop_을 세운 뒤 큐가 빌 때까지 작업을 처리하고 나서 스레드를 join하므로 별도의 sleep 없이도 작업이 실행됩니다.

enable_shared_from_this와 aliasing 조합

#include <memory>
#include <iostream>
struct Payload { int value = 0; };
class Handler : public std::enable_shared_from_this<Handler> {
    Payload payload_;
public:
    explicit Handler(int v) { payload_.value = v; }
    std::shared_ptr<Payload> getPayload() {
        return std::shared_ptr<Payload>(shared_from_this(), &payload_);
    }
};
int main() {
    auto handler = std::make_shared<Handler>(100);
    auto payload = handler->getPayload();
    std::cout << payload->value << std::endl;  // 100
    handler.reset();
    std::cout << payload->value << std::endl;  // 100 (payload가 Handler 수명을 유지)
    return 0;
}

weak_ptr 옵저버 패턴

발행자(Subject)가 구독자(Observer)를 vector<shared_ptr<Observer>>로 들고 있으면, 구독자를 쓰던 쪽이 모두 놓아도 발행자가 계속 소유하므로 구독자가 해제되지 않습니다. 반대로 raw 포인터로 들고 있으면 구독자가 먼저 파괴된 뒤 발행자가 호출할 때 use-after-free가 납니다. 발행자가 weak_ptr로 저장하면 소유하지 않으면서도 살아 있는지 확인할 수 있습니다.

#include <memory>
#include <vector>
#include <iostream>
class Observer {
    int id_;
public:
    explicit Observer(int id) : id_(id) {}
    void onNotify() {
        std::cout << "Observer " << id_ << " notified\n";
    }
};
class Subject {
    std::vector<std::weak_ptr<Observer>> observers_;
public:
    void attach(const std::shared_ptr<Observer>& obs) {
        observers_.push_back(obs);  // weak_ptr로 저장 (강한 참조 카운트 증가 없음)
    }
    void notify() {
        for (auto it = observers_.begin(); it != observers_.end(); ) {
            if (auto sp = it->lock()) {
                sp->onNotify();
                ++it;
            } else {
                it = observers_.erase(it);  // 이미 파괴된 구독자 제거
            }
        }
    }
};
int main() {
    Subject subject;
    auto obs1 = std::make_shared<Observer>(1);
    auto obs2 = std::make_shared<Observer>(2);
    subject.attach(obs1);
    subject.attach(obs2);
    subject.notify();  // Observer 1, 2 notified
    obs1.reset();      // obs1 파괴
    subject.notify();  // Observer 2만 notified
    return 0;
}

notify() 안에서 Observer의 멤버 함수를 호출하므로 Observer는 Subject보다 먼저 완전히 정의되어 있어야 합니다. lock()으로 얻은 sp는 호출이 끝날 때까지 구독자를 살려 두므로, 다른 스레드가 그 사이에 마지막 shared_ptr을 놓아도 안전합니다. 만료된 항목을 그때그때 지우지 않으면 목록이 계속 늘어나므로 위처럼 순회하면서 정리합니다. 단, 이 정리 작업은 observers_를 수정하므로 여러 스레드에서 attach와 notify를 부른다면 뮤텍스로 보호해야 합니다.

make_shared로 만든 객체는 weak_ptr이 하나라도 남아 있는 동안 메모리 블록 전체가 해제되지 않는다는 점도 알아 둘 만합니다. 객체의 소멸자는 강한 참조가 0이 될 때 호출되지만, 객체와 제어 블록이 한 번에 할당되어 있어 weak_ptr이 모두 사라질 때까지 그 메모리가 남습니다. 큰 객체를 오래 사는 weak_ptr로 관찰한다면 shared_ptr<T>(new T)로 따로 할당하는 편이 메모리를 빨리 돌려줍니다.


자주 만나는 에러

shared_ptr(this)로 이중 해제

증상은 프로그램 크래시(double free, 힙 손상)입니다. 앞에서 본 것처럼 shared_ptr<T>(this)가 새 제어 블록을 만들기 때문이며, enable_shared_from_this를 상속하고 shared_from_this()를 반환하도록 바꿉니다.

스택 객체에서 shared_from_this() 호출

class Service : public std::enable_shared_from_this<Service> {};
Service svc;                       // 스택에 생성
// auto p = svc.shared_from_this();  // C++17부터 std::bad_weak_ptr 예외

auto svc2 = std::make_shared<Service>();
auto p2 = svc2->shared_from_this();  // shared_ptr로 생성하면 정상 동작

enable_shared_from_this를 상속한 클래스는 항상 shared_ptr로 생성된다는 규칙을 생성자를 private으로 두고 팩토리 함수만 열어 강제할 수 있습니다.

class Service : public std::enable_shared_from_this<Service> {
    Service() = default;
public:
    static std::shared_ptr<Service> create() {
        // make_shared는 private 생성자에 접근할 수 없으므로 new를 쓴다
        return std::shared_ptr<Service>(new Service());
    }
};

생성자에서 shared_from_this() 호출

class Bad : public std::enable_shared_from_this<Bad> {
public:
    Bad() {
        // auto p = shared_from_this();  // 아직 shared_ptr이 없어 bad_weak_ptr
    }
};

class Good : public std::enable_shared_from_this<Good> {
public:
    void init() {
        auto p = shared_from_this();  // 생성이 끝난 뒤 호출
    }
};
auto obj = std::make_shared<Good>();
obj->init();

init()을 따로 부르는 방식은 호출을 잊을 위험이 있으므로, 위의 팩토리 함수 안에서 생성 직후 init()까지 호출하도록 묶어 두는 편이 안전합니다.

멤버 주소로 독립 shared_ptr 생성

auto container = std::make_shared<Container>();

// std::shared_ptr<Bar> bad(&container->member);
// container와 무관한 제어 블록이 생기고, 카운트가 0이 되면 멤버 주소를 delete

std::shared_ptr<Bar> barPtr(container, &container->member);  // aliasing 생성자

멤버 주소로 일반 생성자를 쓰면 new로 할당하지 않은 주소를 delete하게 되어 정의되지 않은 동작이 됩니다. 부모 수명에 묶인 멤버 포인터는 항상 aliasing 생성자로 만듭니다.

비동기 콜백에 this 직접 캡처

void startAsync() {
    // asyncCall([this]() { this->onComplete(); });  // 콜백 시점에 객체가 없을 수 있음
    auto self = shared_from_this();
    asyncCall([self]() {
        self->onComplete();
    });
}

Asio 같은 비동기 네트워크 라이브러리에서 세션 객체가 shared_from_this()를 핸들러에 캡처해 넘기는 것이 이 패턴의 대표적인 사례입니다. 핸들러가 남아 있는 동안 세션이 살아 있고, 더 이상 읽기·쓰기를 예약하지 않으면 마지막 핸들러가 끝나면서 세션이 자연스럽게 파괴됩니다.

weak_ptr lock() 결과 검사 누락

void callback(std::weak_ptr<Service> wp) {
    // wp.lock()->doWork();  // lock()이 빈 shared_ptr을 돌려주면 null 역참조
    if (auto sp = wp.lock()) {
        sp->doWork();
    }
}

expired()로 먼저 확인한 뒤 lock()하는 방식은 그 사이에 객체가 파괴될 수 있어 경쟁 상태가 생깁니다. lock() 결과를 바로 검사하는 것이 맞습니다.

커스텀 삭제자에서 예외 발생

// 위험: 삭제자에서 예외를 던지면 std::terminate
std::shared_ptr<Resource> r1(ptr1, [](Resource* p) {
    if (!p->close()) throw std::runtime_error("close failed");
});

// 삭제자에서는 예외를 밖으로 내보내지 않는다
std::shared_ptr<Resource> r2(ptr2, [](Resource* p) {
    if (p) {
        try {
            p->close();
        } catch (...) {
            std::cerr << "close failed\n";  // 로깅만 하고 전파하지 않음
        }
        delete p;
    }
});

삭제자는 shared_ptr의 소멸자나 reset() 안에서 호출되는데, 소멸자는 기본적으로 noexcept이므로 예외가 빠져나가면 std::terminate가 호출됩니다. 또 커스텀 삭제자를 지정하면 기본 delete가 호출되지 않으므로, new로 만든 객체라면 삭제자 안에서 직접 delete해야 합니다.


shared_ptr을 함수 인자로 받는 방법

C++ Core Guidelines(R.30~R.37)의 기준은 소유권과 관계가 있는지입니다. 함수가 객체를 사용하기만 하고 소유권에는 관여하지 않는다면 shared_ptr이 아니라 T&나 T*로 받습니다. 함수가 소유권을 나눠 가져야 한다면(멤버에 저장하거나 다른 스레드로 넘김) shared_ptr<T>를 값으로 받고, 호출자는 더 쓸 일이 없으면 std::move로 넘겨 카운트 증감을 줄입니다. const shared_ptr<T>&는 함수가 조건에 따라 복사본을 만들 수도 있고 아닐 수도 있을 때 씁니다.

enable_shared_from_this의 비용은 객체마다 숨은 weak_ptr 하나(보통 포인터 두 개 크기)가 추가되는 것이고, shared_from_this()는 weak_ptr을 shared_ptr로 바꾸면서 참조 카운트를 원자적으로 한 번 증가시킵니다. 대부분의 코드에서 문제가 되지 않지만, 핫 루프에서 반복 호출한다면 한 번 얻은 shared_ptr을 재사용하는 편이 낫습니다.


참고 자료


같이 보면 좋은 글