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 포인터 트리가 자주 깨지는가

전형적인 실패는 다음과 같습니다.

  1. 루트가 노드를 new로 만들고, 뷰 A·B가 같은 raw 포인터를 보관합니다.

  2. 한쪽이 delete하면 다른 쪽은 dangling이 됩니다.

  3. 부모와 자식이 서로를 shared_ptr로 들고 있으면 참조 카운트가 0이 되지 않아 누수됩니다.

관심사와 표준 도구의 대응은 대략 다음과 같습니다.

  1. 단일 소유 — std::unique_ptr

  2. 공유 소유·다형 팩토리 — std::shared_ptr + make_shared

  3. 순환 끊기·관찰 — std::weak_ptr + lock()

  4. 안전한 downcast — std::dynamic_pointer_cast

shared_ptr로 다형 트리 다루기

트리 노드처럼 여러 뷰가 같은 객체를 참조하고, 런타임에 파생형이 달라지는 곳은 shared_ptr이 맞습니다.

Cpp

#include <memory> #include <string> #include <vector> struct TreeNode { virtual ~TreeNode() = default; virtual std::string title() const = 0; }; struct FolderNode : TreeNode { explicit FolderNode(std::string t) : title_(std::move(t)) {} std::string title() const override { return title_; } private: std::string title_; }; struct TextBlockNode : TreeNode { explicit TextBlockNode(std::string t) : title_(std::move(t)) {} std::string title() const override { return title_; } private: std::string title_; }; using TreeNodeList = std::vector<std::shared_ptr<TreeNode>>; class DocumentTree { public: void setupPalette() { palette_.push_back(std::make_shared<FolderNode>("Widgets")); palette_.push_back(std::make_shared<TextBlockNode>("Label")); } std::shared_ptr<TreeNode> addTextBlock(std::string title) { auto node = std::make_shared<TextBlockNode>(std::move(title)); nodes_.push_back(node); return node; } std::shared_ptr<TextBlockNode> findTextBlock(const std::string& title) const { for (const auto& node : nodes_) { if (node->title() == title) { return std::dynamic_pointer_cast<TextBlockNode>(node); } } return nullptr; } private: TreeNodeList palette_; TreeNodeList nodes_; };

make_shared는 제어 블록과 객체를 한 번에 할당합니다. dynamic_pointer_cast는 실패 시 nullptr을 돌려주므로, 잘못된 파생형에 접근하는 사고를 줄입니다.

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 프리뷰처럼 “없어도 되는” 관찰자에 잘 맞습니다.

enable_shared_from_this

노드가 자기 자신을 콜백에 shared_ptr로 넘겨야 할 때는 std::enable_shared_from_this를 씁니다.

Cpp

struct TreeNode : std::enable_shared_from_this<TreeNode> { // ... void scheduleUpdate() { auto self = shared_from_this(); queueAsync([self] { self->refresh(); }); } };

설계 체크리스트

  1. 단일 소유면 unique_ptr, 공유가 필요하면 shared_ptr을 씁니다.

  2. 부모↔자식처럼 순환이 가능하면 한쪽을 weak_ptr로 둡니다.

  3. 팩토리 결과는 make_shared로 만들고 alias 타입으로 API에 드러냅니다.

  4. downcast는 dynamic_pointer_cast로 하고, 실패를 nullptr로 처리합니다.

  5. get()으로 꺼낸 raw 포인터는 즉시 사용 범위로만 제한합니다.

  6. 컨테이너 순회 중 삭제는 ID나 weak_ptr로 대상을 잡고 분리합니다.

자주 발생하는 문제

메모리가 줄지 않음

부모와 자식이 서로를 shared_ptr로 들고 있는지 확인합니다. 역방향 링크를 weak_ptr로 바꾸면 참조 카운트가 정상적으로 내려갑니다.

shared_from_this에서 예외

객체가 스택에 있거나, new로만 만들고 shared_ptr에 넣지 않은 상태입니다. 항상 make_shared로 생성한 뒤 사용하세요.

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 세부 규칙을 놓치지 않습니다.

On this page