C++ 관찰 포인터: 소유하지 않는 참조를 원시 포인터·weak_ptr로 표현하는 기준

이 글의 핵심

코드에 원시 포인터가 보이면 이것이 소유하는 포인터인지 관찰만 하는 포인터인지 알 수 없어 delete 누락이나 이중 해제가 생기기 쉽습니다. 소유는 스마트 포인터로, 관찰은 원시 포인터나 참조로 표현하는 규칙을 기준으로, 관찰 대상의 수명이 더 짧을 때 weak_ptr로 바꿔야 하는 경우와 nullptr 체크 누락, 컨테이너에 담긴 포인터의 소유권이 불분명해지는 문제를 이벤트 시스템 예제로 정리했습니다.

들어가며

관찰 포인터(Observer Pointer)는 소유권 없이 객체를 참조만 하는 포인터입니다. 스마트 포인터가 소유권을 관리하는 반면, 관찰 포인터는 객체의 수명에 관여하지 않고 단순히 관찰만 합니다. C++ 코드베이스가 unique_ptr과 shared_ptr로 소유권을 명확히 관리하기 시작하면서, 오히려 “소유권이 전혀 필요 없는” 나머지 포인터들의 역할이 더 뚜렷해졌습니다. 부모 객체에 대한 역참조, 콜백에 넘기는 this, 컨테이너 안의 특정 원소를 가리키는 임시 참조 같은 경우가 대표적인데, 이런 곳에 실수로 소유권 있는 스마트 포인터를 쓰면 오히려 소유권 그래프가 꼬이거나 순환 참조가 생길 수 있습니다.


표준에 없는 개념을 코드에 드러내는 방법

관찰 포인터는 표준 라이브러리에 아직 정식으로 채택되지 않은 개념(제안된 std::observer_ptr는 Library Fundamentals TS v2에 std::experimental::observer_ptr로만 들어갔고 정식 표준에는 포함되지 않았습니다)이라, 실제로는 그냥 raw pointer를 “이건 소유하지 않는다”는 의도로 사용하는 경우가 대부분입니다. C++ Core Guidelines도 같은 입장입니다. 규칙 R.3은 “원시 포인터(T*)는 비소유”라고 정하고, 소유는 전부 스마트 포인터로 표현하라고 권합니다. 이 규칙이 코드베이스 전체에서 지켜지면 T*를 볼 때마다 “delete하지 않는다”고 읽을 수 있으므로, 별도 타입 없이도 의도가 전달됩니다. 문제는 오래된 코드나 C API와 섞여 이 규칙이 깨진 곳입니다. 문제는 raw pointer만 봐서는 그것이 소유권을 가진 포인터인지 단순 관찰용인지 코드만으로 구분할 수 없다는 점입니다. 이 글에서는 관찰 포인터가 실제로 어떤 상황에서 쓰이는지, 그리고 소유 객체보다 관찰 포인터가 더 오래 살아남아 댕글링이 발생하는 전형적인 실수를 어떻게 피하는지 코드로 짚어봅니다.

소유 포인터와 관찰 포인터의 구분

delete 책임은 누구에게 있는가

핵심은 “누가 이 객체를 delete할 책임을 지는가”라는 질문입니다. unique_ptr<Widget>인 owner는 그 책임을 명확히 지고 있고, 스코프를 벗어나면 자동으로 Widget을 소멸시킵니다. 반면 owner.get()으로 얻은 raw pointer observer는 Widget을 가리킬 뿐 어떤 책임도 지지 않습니다. 아래 코드에서 observer->use()는 owner가 아직 살아있는 동안에는 안전하지만, 만약 owner가 먼저 소멸된 뒤에 observer를 사용하면 이미 해제된 메모리에 접근하는 댕글링 포인터 문제가 발생합니다.

#include <memory>
#include <iostream>

class Widget {
public:
    Widget() {
        std::cout << "Widget 생성" << std::endl;
    }
    
    ~Widget() {
        std::cout << "Widget 소멸" << std::endl;
    }
    
    void use() {
        std::cout << "Widget 사용" << std::endl;
    }
};

int main() {
    // 소유권 있음
    std::unique_ptr<Widget> owner = std::make_unique<Widget>();
    
    // 소유권 없음 (관찰만)
    Widget* observer = owner.get();
    
    observer->use();  // OK
    
    // owner가 소멸되면 observer는 댕글링 포인터
    
    return 0;
}

핵심 개념:

  • 소유 포인터: 객체 수명 관리 (unique_ptr, shared_ptr)
  • 관찰 포인터: 객체 참조만, 수명 관리 안 함 (raw pointer)

관찰에는 포인터 말고 참조라는 선택지도 있습니다. 대상이 반드시 존재하고 도중에 다른 대상으로 바뀔 일이 없다면 Widget&가 더 정확한 표현입니다. 널일 수 없다는 사실이 타입에 드러나 널 체크가 필요 없어지기 때문입니다. 포인터는 “없을 수도 있다”(검색 실패의 nullptr), “나중에 다른 대상을 가리킬 수 있다”, “컨테이너에 담아야 한다”(참조는 벡터에 넣을 수 없음) 중 하나에 해당할 때 씁니다. 이 구분을 지키면 포인터를 받는 함수에서만 널 체크를 하면 되므로 방어 코드가 코드 전체에 흩어지지 않습니다. 널이 아님을 보장하는 포인터가 필요하면 Guidelines Support Library의 gsl::not_null<T*>를 쓰는 방법도 있습니다.


부모 참조·콜백·컨테이너 원소 조회

아래 세 가지 패턴은 트리 구조의 역참조, 이벤트 콜백, 컨테이너 안 특정 원소 접근이라는 관찰 포인터의 대표적인 활용 상황을 보여줍니다.

자식이 부모를 가리키는 트리

트리나 계층 구조에서는 부모가 자식들을 unique_ptr로 소유하는 것이 자연스럽지만, 자식이 자신의 부모를 다시 가리켜야 할 때 만약 자식도 부모를 shared_ptr로 소유해버리면 부모↔자식 사이에 순환 참조가 생겨 메모리가 절대 해제되지 않습니다. 자식 쪽의 부모 참조는 소유가 아니라 단순 역참조이므로, Parent*라는 관찰 포인터로 표현하는 것이 정확한 소유권 모델입니다. addChild에서 child->setParent(this)를 호출할 때 넘기는 this 역시, Parent 자신이 그 자식들에 의해 소유되는 것이 아니므로 자연스럽게 관찰 포인터로 취급됩니다.

#include <memory>
#include <vector>
#include <iostream>

class Parent;

class Child {
    Parent* parent;  // 관찰 포인터 (부모 참조)
    
public:
    Child() : parent(nullptr) {}
    
    void setParent(Parent* p) {
        parent = p;
    }
    
    void notifyParent() {
        if (parent) {
            std::cout << "부모에게 알림" << std::endl;
        }
    }
};

class Parent {
    std::vector<std::unique_ptr<Child>> children;  // 소유 포인터
    
public:
    void addChild(std::unique_ptr<Child> child) {
        child->setParent(this);  // this는 관찰 포인터
        children.push_back(std::move(child));
    }
    
    size_t childCount() const {
        return children.size();
    }
};

int main() {
    Parent parent;
    
    auto child1 = std::make_unique<Child>();
    auto child2 = std::make_unique<Child>();
    
    parent.addChild(std::move(child1));
    parent.addChild(std::move(child2));
    
    std::cout << "자식 수: " << parent.childCount() << std::endl;
    
    return 0;
}

이 구조가 안전한 이유는 수명 순서가 구조적으로 보장되기 때문입니다. 자식은 부모의 children 벡터 안에서만 존재하므로 부모보다 먼저 소멸하거나 같이 소멸하고, 부모 포인터가 자식보다 오래 남는 일이 없습니다. 이 보장이 깨지는 순간이 두 가지 있습니다. 첫째, Parent가 복사되거나 이동될 때입니다. 위 Parent는 unique_ptr 멤버 때문에 복사는 안 되지만 이동은 되고, 이동하면 자식들은 새 Parent 객체로 옮겨 가는데 각 자식의 parent 포인터는 여전히 이동 전의 옛 주소를 가리킵니다. 이동 생성자에서 자식들의 부모 포인터를 다시 설정하거나, 이동 자체를 = delete로 막아야 합니다. 둘째, 자식을 다른 부모로 옮기는 releaseChild 같은 기능을 추가할 때 부모 포인터를 갱신하지 않는 경우입니다. 트리 구조를 직접 만들 때 제가 가장 자주 본 버그가 이 “이동 후 옛 부모를 가리키는” 형태였습니다.

콜백에 자기 자신을 넘기는 버튼

버튼 같은 UI 컴포넌트가 클릭 이벤트를 처리할 때, 콜백 함수 안에 자기 자신(this)을 넘겨줘야 호출하는 쪽에서 “어떤 버튼이 클릭되었는지” 알 수 있습니다. 이때 Button은 콜백을 호출하는 시점에 이미 스스로 살아있는 상태이므로, 콜백에 넘기는 this는 그저 “지금 이 순간의 나 자신을 가리켜라”는 관찰용 정보이지 소유권 이전이 아닙니다. std::function으로 저장된 핸들러는 Button 객체보다 오래 살아남아 this를 나중에 사용할 수도 있으므로, 콜백이 버튼보다 더 오래 유지될 수 있는 구조라면 이 this 포인터의 유효성도 함께 고려해야 합니다.

#include <iostream>
#include <functional>

class Button {
public:
    using ClickHandler = std::function<void(Button*)>;
    
    void setOnClick(ClickHandler handler) {
        onClick = handler;
    }
    
    void click() {
        if (onClick) {
            onClick(this);  // this는 관찰 포인터
        }
    }
    
private:
    ClickHandler onClick;
};

int main() {
    Button btn;
    
    btn.setOnClick([](Button*) {
        std::cout << "버튼 클릭됨" << std::endl;
    });
    
    btn.click();
    
    return 0;
}

콜백에서 더 위험한 관찰 포인터는 Button* 인자보다 람다 캡처입니다. btn.setOnClick([this] { updateUi(); })처럼 다른 객체(예: 대화상자)의 this를 캡처해 버튼에 등록하면, 대화상자가 먼저 소멸해도 버튼은 그 람다를 계속 들고 있고, 다음 클릭에서 해제된 객체의 멤버 함수가 호출됩니다. 캡처된 this는 코드상에 포인터로 보이지 않아 리뷰에서 놓치기 쉽습니다. 수명이 엇갈릴 수 있다면 대화상자를 shared_ptr로 관리하고 [weak = weak_from_this()] { if (auto self = weak.lock()) self->updateUi(); }처럼 약한 참조를 캡처하거나, 대화상자 소멸자에서 콜백 등록을 해제해야 합니다.

컨테이너가 소유한 원소를 빌려주기

Container는 자신이 담고 있는 모든 Item을 unique_ptr로 소유하고 있지만, 특정 id에 해당하는 항목을 찾아 호출자에게 돌려줄 때는 소유권까지 넘길 필요가 없습니다. find가 소유권 있는 스마트 포인터를 반환하면 컨테이너의 소유권 모델이 애매해지고 실수로 이중 소유가 생길 위험이 있으므로, item.get()으로 얻은 관찰 포인터를 반환해 “이 항목은 여전히 컨테이너가 소유하고 있으니 함부로 해제하지 말라”는 의도를 명확히 전달합니다. const 오버로드를 함께 제공하면 const Container에서도 안전하게 항목을 조회할 수 있습니다.

#include <memory>
#include <vector>
#include <iostream>

class Item {
    int id;
    
public:
    Item(int i) : id(i) {}
    
    int getId() const { return id; }
};

class Container {
    std::vector<std::unique_ptr<Item>> items;
    
public:
    void add(std::unique_ptr<Item> item) {
        items.push_back(std::move(item));
    }
    
    // 소유권 유지, 관찰 포인터 반환
    Item* find(int id) {
        for (auto& item : items) {
            if (item->getId() == id) {
                return item.get();  // 관찰 포인터
            }
        }
        return nullptr;
    }
    
    // const 버전
    const Item* find(int id) const {
        for (const auto& item : items) {
            if (item->getId() == id) {
                return item.get();
            }
        }
        return nullptr;
    }
};

int main() {
    Container container;
    container.add(std::make_unique<Item>(1));
    container.add(std::make_unique<Item>(2));
    container.add(std::make_unique<Item>(3));
    
    Item* item = container.find(2);
    if (item) {
        std::cout << "찾음: " << item->getId() << std::endl;
    }
    
    return 0;
}

이 예제에서 find가 돌려준 포인터가 add를 여러 번 호출한 뒤에도 유효한 것은 원소를 unique_ptr로 간접 저장하기 때문입니다. 벡터가 재할당되면 unique_ptr 객체들은 새 버퍼로 이동하지만, 각각이 가리키는 Item은 힙의 원래 위치에 그대로 있습니다. 반대로 std::vector<Item>에 값으로 저장하고 &items[i]를 돌려주면, 다음 push_back에서 재할당이 일어나는 순간 모든 관찰 포인터가 무효화됩니다. 값 저장은 캐시 효율이 좋지만 외부에 포인터를 오래 넘겨야 한다면 unique_ptr 저장, std::deque(끝 삽입 시 원소 주소 유지), 또는 포인터 대신 id를 넘기는 방식 중 하나를 골라야 합니다. id를 넘기면 매번 조회 비용이 들지만, 원소가 삭제된 뒤 조회하면 nullptr를 받으므로 댕글링이 원천적으로 사라집니다.


댕글링·널 체크·소유권이 모호한 시그니처

관찰 대상이 먼저 소멸함

관찰 포인터에서 가장 흔하고 위험한 실수는 관찰 대상이 관찰 포인터보다 먼저 소멸하는 상황입니다. bad() 함수는 owner라는 지역 변수의 내부 포인터를 전역 변수 observer에 저장해두는데, 함수가 끝나는 순간 owner가 소멸되면서 observer는 이미 해제된 메모리를 가리키는 댕글링 포인터가 됩니다. 이후 observer를 역참조하면 정의되지 않은 동작(운 좋으면 크래시, 운 나쁘면 조용히 잘못된 값을 반환)이 발생합니다. good()처럼 관찰 포인터의 사용 범위를 소유 포인터의 수명 안으로 한정하면 이 문제를 피할 수 있습니다.

#include <memory>
#include <iostream>

Widget* observer;

void bad() {
    auto owner = std::make_unique<Widget>();
    observer = owner.get();
}  // owner 소멸 -> observer는 댕글링!

void good() {
    auto owner = std::make_unique<Widget>();
    Widget* localObserver = owner.get();
    localObserver->use();  // owner 수명 내에서 사용
}

int main() {
    bad();
    // observer->use();  // 정의되지 않은 동작!
    
    good();  // 안전
    
    return 0;
}

해결책: 관찰 포인터는 소유 포인터의 수명 내에서만 사용하세요.

이 버그가 까다로운 이유는 대부분 즉시 크래시하지 않는다는 점입니다. 해제 직후의 메모리에는 옛 값이 그대로 남아 있는 경우가 많아서, observer->use()가 한동안 정상처럼 동작하다가 그 메모리가 다른 할당에 재사용된 뒤에야 엉뚱한 값이나 크래시로 드러납니다. 개발 단계에서는 -fsanitize=address로 빌드하면 해제된 메모리 접근 순간에 heap-use-after-free 보고서와 함께 할당·해제·사용 위치의 스택을 모두 보여 주므로, 관찰 포인터를 많이 쓰는 코드라면 테스트를 AddressSanitizer 빌드로도 돌리는 것을 권합니다.

관찰 포인터의 nullptr 체크 누락

관찰 포인터는 소유권이 없다는 특성상 “이 포인터가 항상 유효한 객체를 가리킨다”는 보장이 소유 포인터보다 훨씬 약합니다. 값이 아직 설정되지 않았거나(Child의 parent가 초기값 nullptr인 경우처럼), 관찰 대상이 다른 경로로 이미 제거되었을 가능성을 항상 열어두어야 합니다. 관찰 포인터를 매개변수로 받는 함수는 사용하기 전에 반드시 nullptr 여부를 확인하는 것을 기본 습관으로 삼아야 하며, 이 검사를 생략하면 널 포인터 역참조로 인한 크래시가 발생합니다. 다만 널 체크는 “아직 설정되지 않음”만 잡아낼 뿐, 이미 소멸한 객체를 가리키는 댕글링 포인터는 널이 아니므로 통과시킨다는 점을 분명히 알아 두어야 합니다. 널 체크가 수명 문제를 해결해 준다고 착각하면 앞 절의 버그를 그대로 안고 가게 됩니다. 그리고 호출자가 절대 널을 넘기지 않는 함수라면 널 체크 대신 매개변수를 참조로 바꾸는 편이 더 정직한 설계입니다.

#include <iostream>

void process(Widget* ptr) {
    // ❌ nullptr 체크 없음
    // ptr->use();  // ptr이 nullptr이면 크래시!
    
    // ✅ 항상 nullptr 체크
    if (!ptr) {
        std::cout << "널 포인터" << std::endl;
        return;
    }
    
    ptr->use();
}

Widget* 반환만으로는 소유권을 알 수 없다

가장 근본적인 문제는 raw pointer 하나만으로는 그것이 소유권을 넘기는 것인지, 단순히 관찰만 하라는 것인지 코드 시그니처만 봐서는 전혀 구분할 수 없다는 점입니다. Widget* createWidget()처럼 함수가 new로 만든 객체를 raw pointer로 반환하면, 호출자는 이 포인터를 자신이 delete해야 하는지 아니면 다른 누군가가 관리하는지 알 방법이 없습니다. 소유권을 넘기는 함수라면 unique_ptr을 반환 타입으로 명시하고, 이미 다른 곳(예: Manager 내부의 컨테이너)이 소유하고 있는 객체에 대한 임시 접근만 제공하려는 함수라면 raw pointer(관찰 포인터)를 반환해 의도를 명확히 구분해야 합니다.

#include <memory>

// ❌ raw pointer로 소유권 이전 (불명확)
Widget* createWidget() {
    return new Widget();  // 누가 delete?
}

// ✅ unique_ptr로 소유권 명확
std::unique_ptr<Widget> createWidget() {
    return std::make_unique<Widget>();
}

// ✅ 관찰 포인터 반환 (소유권 유지)
class Manager {
    std::vector<std::unique_ptr<Widget>> widgets;
    
public:
    Widget* getWidget(size_t index) {
        return widgets[index].get();  // 관찰만
    }
};

vector<Widget*>가 소유자인지 관찰자인지 모호함

vector<Widget*>라는 선언만 봐서는 이 벡터가 각 Widget의 소유자인지, 아니면 다른 곳에서 소유하는 객체들을 단순히 모아둔 관찰용 목록인지 알 수 없습니다. 실제로 이 벡터가 소유자 역할을 해야 한다면 vector<unique_ptr<Widget>>로 선언해 소멸 시 자동으로 모든 원소가 해제되도록 만들어야 하고, 이미 다른 곳(예: 위 예제의 Manager)이 소유하는 객체들을 참조만 하려는 목적이라면 vector<Widget*>를 관찰용 목록으로 명확히 문서화(주석이나 변수명으로)해서 나중에 코드를 읽는 사람이 소유권을 착각하지 않도록 해야 합니다.

#include <vector>
#include <memory>

// ❌ raw pointer 컨테이너 (소유권 불명확)
std::vector<Widget*> widgets;
// 누가 delete? 메모리 누수 가능

// ✅ 소유 포인터 컨테이너
std::vector<std::unique_ptr<Widget>> owners;

// ✅ 관찰 포인터 컨테이너 (소유권은 다른 곳)
std::vector<Widget*> observers;

리스너를 소유하지 않는 이벤트 디스패처

관찰자 패턴(Observer Pattern)을 구현하는 이벤트 디스패처는 관찰 포인터가 등장하는 가장 전형적인 실무 사례입니다. EventDispatcher는 등록된 리스너들을 소유하지 않습니다. 리스너(Logger, Counter)는 main 함수의 지역 변수로 독립적으로 생성되고 소멸되며, 디스패처는 그저 이들의 주소만 vector<EventListener*>에 저장해두었다가 이벤트가 발생하면 순회하며 호출할 뿐입니다. 이런 구조 덕분에 하나의 리스너가 여러 디스패처에 등록되거나, 디스패처의 생명주기와 무관하게 리스너를 자유롭게 관리할 수 있습니다. 다만 이 설계는 리스너가 디스패처에 등록된 채로 먼저 소멸해버리면 댕글링 포인터가 남는다는 책임을 호출자에게 지우므로, 실무에서는 소멸자에서 자동으로 등록을 해제하는 안전장치를 추가하는 경우가 많습니다.

#include <memory>
#include <vector>
#include <iostream>
#include <algorithm>
#include <string>

class Event {
public:
    std::string type;
    
    Event(const std::string& t) : type(t) {}
};

class EventListener {
public:
    virtual ~EventListener() = default;
    virtual void onEvent(const Event& event) = 0;
};

class EventDispatcher {
    std::vector<EventListener*> listeners;  // 관찰 포인터
    
public:
    // 리스너 등록 (소유권 없음)
    void addListener(EventListener* listener) {
        if (listener) {
            listeners.push_back(listener);
        }
    }
    
    // 리스너 제거
    void removeListener(EventListener* listener) {
        listeners.erase(
            std::remove(listeners.begin(), listeners.end(), listener),
            listeners.end()
        );
    }
    
    // 이벤트 발송
    void dispatch(const Event& event) {
        for (EventListener* listener : listeners) {
            if (listener) {
                listener->onEvent(event);
            }
        }
    }
};

class Logger : public EventListener {
public:
    void onEvent(const Event& event) override {
        std::cout << "[LOG] 이벤트: " << event.type << std::endl;
    }
};

class Counter : public EventListener {
    int count = 0;
    
public:
    void onEvent(const Event& event) override {
        count++;
        std::cout << "[COUNT] 총 " << count << "개 이벤트" << std::endl;
    }
};

int main() {
    EventDispatcher dispatcher;
    
    // 리스너 생성 (소유권 유지)
    Logger logger;
    Counter counter;
    
    // 관찰 포인터로 등록
    dispatcher.addListener(&logger);
    dispatcher.addListener(&counter);
    
    // 이벤트 발송
    dispatcher.dispatch(Event{"UserLogin"});
    dispatcher.dispatch(Event{"DataSaved"});
    
    // 리스너 제거
    dispatcher.removeListener(&logger);
    
    dispatcher.dispatch(Event{"UserLogout"});
    
    return 0;
}

이 예제에서 main이 끝날 때 counter와 logger는 dispatcher보다 먼저 소멸합니다(지역 변수는 선언의 역순으로 소멸). 여기서는 소멸 이후 dispatch를 부르지 않으니 문제가 없지만, 디스패처가 전역이거나 더 오래 사는 객체의 멤버라면 소멸한 리스너 주소가 벡터에 남습니다. dispatch 안의 if (listener) 검사는 이 경우를 전혀 막지 못합니다. 앞에서 말한 대로 댕글링 포인터는 널이 아니기 때문입니다.

실무에서 더 자주 터지는 문제는 dispatch 도중의 등록 변경입니다. 어떤 리스너가 onEvent 안에서 자기 자신을 removeListener로 제거하면(한 번만 받는 리스너가 흔히 이렇게 합니다), dispatch가 순회 중인 벡터에서 erase가 일어나 범위 기반 for문의 반복자가 무효화됩니다. addListener로 새 리스너를 추가해도 재할당 때문에 같은 일이 생깁니다. 증상은 리스너 하나를 건너뛰거나, 같은 리스너를 두 번 호출하거나, 크래시입니다. 흔한 해결책은 dispatch 시작 시 auto snapshot = listeners;로 복사본을 순회하거나, 순회 중에는 제거를 “표시”만 해 두었다가 순회가 끝난 뒤 한꺼번에 지우는 방식입니다. 복사본을 순회할 때는 그 사이 제거된 리스너가 한 번 더 호출될 수 있으므로, 제거와 소멸이 동시에 일어나는 구조라면 표시 후 정리 방식이 더 안전합니다.

소멸 시 자동 해제를 구현하는 가장 깔끔한 방법은 addListener가 구독 토큰 객체를 돌려주고, 토큰의 소멸자가 removeListener를 호출하게 하는 RAII 패턴입니다. 리스너 클래스가 토큰을 멤버로 들고 있으면 리스너가 소멸하는 순간 등록도 자동으로 풀립니다. 다만 이 경우 디스패처가 토큰보다 먼저 소멸하면 이번에는 토큰이 댕글링 디스패처를 가리키게 되므로, 디스패처 쪽 수명도 함께 설계해야 합니다. 관찰 관계는 결국 “누가 더 오래 사는가”를 한쪽 방향으로 정하는 문제입니다.


관찰 포인터 정리

핵심 요약

  1. 관찰 포인터: 소유권 없는 포인터
  2. 용도: 부모 참조, 콜백, 임시 접근
  3. 위험: 댕글링 포인터 (수명 관리 주의)
  4. nullptr 체크: 없을 수 있는 포인터에만 (널이 아니어도 댕글링일 수 있음), 항상 존재하면 참조로
  5. 소유권 명확화: unique_ptr/shared_ptr vs raw pointer
  6. 수명이 불확실하면: shared_ptr + weak_ptr::lock()으로 전환

포인터 타입별 소유권 비교

타입소유권수명 관리사용 시기
unique_ptr단독 소유자동명확한 소유권
shared_ptr공유 소유참조 카운트여러 소유자
raw pointer없음 (관찰)수동참조만
weak_ptr없음shared_ptr 관찰순환 참조 방지

이어서 볼 글


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 관찰 포인터가 가리키는 객체가 이미 소멸했는지 확인할 방법이 있나요?

A. raw 포인터나 observer_ptr은 대상의 수명 정보를 전혀 갖지 않으므로, 널 체크로는 이미 소멸한 객체를 가리키는 댕글링 상태를 구분할 수 없습니다. 수명이 불확실한 관계라면 소유하는 쪽을 std::shared_ptr로 두고 관찰하는 쪽은 std::weak_ptr로 들고 있다가 lock()으로 살아 있는지 확인해야 합니다. 관찰 포인터를 쓰려면 부모가 자식보다 오래 살거나, 이벤트 시스템에서 리스너가 소멸할 때 구독을 해제하는 식으로 수명 순서가 구조적으로 보장되어야 합니다.