unique_ptr·shared_ptr·weak_ptr로 소유권 나누기
설정 패널·문서 아웃라인·위젯 팔레트처럼 트리 구조를 new/delete로 관리하면, 삭제 순서 하나만 틀려도 use-after-free나 이중 해제가 납니다. 이 글에서는 std::unique_ptr, std::shared_ptr, std::weak_ptr 의 역할을 나누고, 다형 노드와 순환 참조를 다루는 패턴을 정리합니다.
완성할 예제
다음 세 가지 역할을 코드로 구성합니다.
DocumentTree: shared_ptr로 다형 노드를 보관하고 팩토리로 생성
IconCache: unique_ptr로 단일 소유 리소스 관리
PreviewPane: weak_ptr로 본문을 관찰만 하고 순환을 끊기
읽고 나면 어디에 어떤 스마트 포인터를 둘지 기준으로 잡을 수 있습니다.
왜 raw 포인터 트리가 자주 깨지는가
전형적인 실패는 다음과 같습니다.
루트가 노드를 new로 만들고, 뷰 A·B가 같은 raw 포인터를 보관합니다.
한쪽이 delete하면 다른 쪽은 dangling이 됩니다.
부모와 자식이 서로를 shared_ptr로 들고 있으면 참조 카운트가 0이 되지 않아 누수됩니다.
관심사와 표준 도구의 대응은 대략 다음과 같습니다.
단일 소유 — std::unique_ptr
공유 소유·다형 팩토리 — std::shared_ptr + make_shared
순환 끊기·관찰 — std::weak_ptr + lock()
안전한 downcast — std::dynamic_pointer_cast
unique_ptr로 단일 소유 리소스 묶기
아이콘 아틀라스·설정 로더처럼 주인이 하나인 리소스는 unique_ptr이 적합합니다. 복사 대신 이동만 허용됩니다.
Cpp
#include <filesystem> #include <memory> struct TextureAtlas { explicit TextureAtlas(std::filesystem::path dir); }; class IconCache { public: void load(const std::filesystem::path& dir) { atlas_ = std::make_unique<TextureAtlas>(dir); } TextureAtlas* get() const { return atlas_.get(); } private: std::unique_ptr<TextureAtlas> atlas_; };
weak_ptr로 순환 참조 끊기
노드가 본문을 shared_ptr로 들고, 본문이 다시 노드를 shared_ptr로 들면 순환이 생깁니다. 한쪽을 weak_ptr로 두면 수명이 명확해집니다.
Cpp
class DocumentBody; class PreviewPane { public: void bind(std::shared_ptr<DocumentBody> doc) { body_ = std::move(doc); } std::size_t paragraphCount() const { if (auto locked = body_.lock()) { return locked->paragraphCount(); } return 0; } private: std::weak_ptr<DocumentBody> body_; };
lock()이 빈 shared_ptr을 주면 대상은 이미 파괴된 것입니다. UI 프리뷰처럼 “없어도 되는” 관찰자에 잘 맞습니다.
설계 체크리스트
단일 소유면 unique_ptr, 공유가 필요하면 shared_ptr을 씁니다.
부모↔자식처럼 순환이 가능하면 한쪽을 weak_ptr로 둡니다.
팩토리 결과는 make_shared로 만들고 alias 타입으로 API에 드러냅니다.
downcast는 dynamic_pointer_cast로 하고, 실패를 nullptr로 처리합니다.
get()으로 꺼낸 raw 포인터는 즉시 사용 범위로만 제한합니다.
컨테이너 순회 중 삭제는 ID나 weak_ptr로 대상을 잡고 분리합니다.
자주 발생하는 문제
메모리가 줄지 않음
부모와 자식이 서로를 shared_ptr로 들고 있는지 확인합니다. 역방향 링크를 weak_ptr로 바꾸면 참조 카운트가 정상적으로 내려갑니다.
dynamic_pointer_cast 결과가 항상 null
베이스에 virtual 소멸자·다형 함수가 없거나, 실제 타입이 기대한 파생형이 아닙니다. RTTI가 꺼진 빌드인지도 확인합니다.
순회 중 컨테이너가 깨짐
for 루프 안에서 erase와 함께 마지막 shared_ptr이 파괴되면 콜백이 같은 컨테이너를 다시 건드릴 수 있습니다. 삭제 목록을 모은 뒤 루프 밖에서 지우는 편이 안전합니다.
dangling raw 포인터
.get() 결과를 멤버에 저장했다가 소유 shared_ptr/unique_ptr이 먼저 사라진 경우입니다. 관찰이 길면 weak_ptr을 쓰세요.
다음 단계
소유권 그래프가 익숙해지면 다음으로 확장하기 좋습니다.
JSON 직렬화 시 shared_ptr 그래프를 ID 참조로 저장할지, 깊은 복사로 둘지 결정하기
unique_ptr custom deleter로 C API 포인터 감싸기 (이 시리즈의 extern "C" 글)
스레드 경계에서 shared_ptr 복사 비용과 수명 보장 다시 보기
cppreference의 shared_ptr, weak_ptr, unique_ptr 항목과 C++ Core Guidelines의 리소스 소유권 절을 함께 보면 API 세부 규칙을 놓치지 않습니다.