Devin.KR

C++ · 기본

C++ 객체와 자원 관리

C++ 스마트 포인터 - unique_ptr shared_ptr weak_ptr 와 순환 참조 (C++ 객체와 자원 관리 5장)

소유권을 타입으로 표현하는 세 가지 스마트 포인터를 텍스처 예제로 익힌다. 삭제자 지정, shared_ptr 순환 참조 누수와 weak_ptr 해결을 다룬다.

개발자 · 원고 갱신

이 장에서 배우는 것

원시 포인터 Texture*를 받은 함수는 그 포인터로 무엇을 해야 하는지 알 수 없다. 다 쓰고 delete 해야 하는가, 절대 하면 안 되는가? 다른 곳에서 이미 지웠을 수도 있는가? C에서는 이 약속을 주석과 문서로 전한다. 모던 C++에서는 소유권을 타입으로 표현한다.

  • std::unique_ptr — 주인이 하나. 복사 불가, 이동으로 주인을 넘긴다.
  • std::shared_ptr — 주인이 여럿. 마지막 주인이 사라질 때 해제한다.
  • std::weak_ptr — 주인이 아니라 지켜보는 쪽. 대상이 살아 있는지 확인하고 잠시 빌린다.
  • 삭제자를 바꿔 FILE* 같은 C 자원도 unique_ptr에 맡기는 방법과, shared_ptr 순환 참조 누수를 본다.

문제 상황

게임 엔진의 텍스처 관리자를 생각해 보자. 디스크에서 불러온 텍스처는 GPU로 올리는 함수에 넘겨지고, 렌더러와 미니맵이 같은 하늘 텍스처를 함께 쓰며, 디버그 창은 텍스처가 아직 살아 있으면 이름을 보여 주고 싶어 한다. 전부 Texture*로 주고받으면 누가 delete 할지 정하는 규칙이 코드 밖(사람 머릿속)에만 있다. 결과는 둘 중 하나다. 아무도 지우지 않거나(누수), 두 곳에서 지우거나(이중 해제). 디버그 창처럼 "지켜보기만 하는" 쪽은 이미 지워진 텍스처의 이름을 읽다가 엉뚱한 값을 보여 준다.

완성 코드

smart.cpp로 저장한다. 텍스처는 불러올 때와 해제될 때 출력을 남긴다.

#include <cstdio>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Texture {
    std::string name;
    explicit Texture(std::string n) : name(std::move(n)) { std::cout << "  [로드] " << name << '\n'; }
    ~Texture() { std::cout << "  [해제] " << name << '\n'; }
};

std::unique_ptr<Texture> load(const std::string& name) {
    return std::make_unique<Texture>(name);
}

void upload_to_gpu(std::unique_ptr<Texture> tex) {
    std::cout << "  GPU 로 올림: " << tex->name << '\n';
}

void inspect(const Texture& tex) {
    std::cout << "  빌려서 보기: " << tex.name << '\n';
}

struct FileCloser {
    void operator()(std::FILE* f) const {
        std::fclose(f);
        std::cout << "  [닫음] 파일\n";
    }
};

int main() {
    std::cout << "1) unique_ptr: 주인은 하나\n";
    auto grass = load("grass.png");
    inspect(*grass);
    upload_to_gpu(std::move(grass));
    std::cout << "  grass 는 이제 " << (grass ? "소유 중" : "비어 있음") << '\n';

    std::cout << "2) shared_ptr: 여럿이 함께 소유\n";
    auto sky = std::make_shared<Texture>("sky.png");
    {
        auto for_renderer = sky;
        auto for_minimap = sky;
        std::cout << "  use_count = " << sky.use_count() << '\n';
    }
    std::cout << "  use_count = " << sky.use_count() << '\n';

    std::cout << "3) weak_ptr: 소유하지 않고 지켜보기\n";
    std::weak_ptr<Texture> watcher = sky;
    if (auto locked = watcher.lock()) std::cout << "  아직 살아 있음: " << locked->name << '\n';
    sky.reset();
    std::cout << "  expired = " << std::boolalpha << watcher.expired() << '\n';

    std::cout << "4) 삭제자를 바꾼 unique_ptr\n";
    {
        std::unique_ptr<std::FILE, FileCloser> log(std::fopen("smart.log", "w"));
        if (log) std::fputs("hello\n", log.get());
    }
    std::cout << "main 끝\n";
}

grass 가 가진 텍스처의 소유권이 std::move 로 upload_to_gpu 의 매개변수에 넘어가고, 함수가 끝날 때 매개변수가 소멸하며 텍스처를 해제한다. grass 는 비어 있게 된다.

줄별 해설

std::make_unique<Texture>(name)

힙에 Texture를 만들고 그것을 소유하는 unique_ptr을 돌려준다. new를 직접 쓰지 않는다. 함수 반환 타입이 std::unique_ptr<Texture>이므로 선언만 봐도 "호출한 쪽이 이 텍스처의 주인이 된다"는 것을 알 수 있다.

void inspect(const Texture& tex) — 빌려 쓰기

소유권과 상관없이 잠깐 보기만 하는 함수는 스마트 포인터를 받지 않는다. 그냥 참조(또는 널이 가능하면 원시 포인터)를 받는다. 호출할 때 *grass로 가리키는 객체를 넘긴다. 원시 포인터나 참조는 "나는 주인이 아니다"라는 뜻으로 읽는 것이 모던 C++의 관례다.

void upload_to_gpu(std::unique_ptr<Texture> tex) — 소유권 받기

값으로 unique_ptr을 받는 함수는 "소유권을 나에게 넘겨라"는 뜻이다. unique_ptr은 복사가 삭제되어 있어서 upload_to_gpu(grass)는 컴파일되지 않고, std::move(grass)로 명시해야 한다. 함수가 끝나면 매개변수 tex가 소멸하며 텍스처를 해제한다. 출력에서 [해제] grass.png가 "GPU 로 올림" 바로 뒤에 나오는 이유다. 넘긴 뒤 grass는 비어 있다(nullptr).

std::make_shareduse_count

shared_ptr은 객체 옆에 참조 횟수를 세는 제어 블록을 둔다. 복사할 때마다 횟수가 1 늘고, 복사본이 사라질 때마다 1 준다. 0이 되는 순간 객체를 해제한다. make_shared는 객체와 제어 블록을 한 번의 할당으로 만든다. 블록 안에서 복사본 두 개를 만들었더니 3, 블록을 나오자 다시 1이 되었다.

shared_ptr 세 개가 제어 블록 하나를 공유해 use_count 가 3 이다. weak_ptr 는 횟수를 늘리지 않고 제어 블록만 본다. use_count 가 0 이 되는 순간 텍스처가 해제된다.

std::weak_ptrlock()

weak_ptr은 참조 횟수를 늘리지 않는다. 대상을 쓰려면 lock()으로 잠시 shared_ptr을 얻는다. 대상이 이미 해제되었으면 빈 shared_ptr이 나오므로 if로 확인할 수 있다. sky.reset()으로 마지막 주인을 놓자 텍스처가 해제되고 expired()true가 되었다. 원시 포인터로는 이 "아직 살아 있는가?"를 확인할 방법이 없다.

std::unique_ptr<std::FILE, FileCloser> — 삭제자 바꾸기

unique_ptr의 두 번째 템플릿 인자는 해제할 때 부를 함수 객체다. 기본은 delete지만, 여기서는 fclose를 부르도록 바꿨다. 3장에서 File 클래스를 직접 만들었는데, 사실 이 한 줄로도 같은 RAII를 얻을 수 있다. C 라이브러리가 돌려주는 핸들(SDL_Window*, 소켓, 장치 핸들)을 감쌀 때 자주 쓰는 방법이다.

실제 실행 결과

$ clang++ -std=c++20 -Wall -Wextra smart.cpp -o smart && ./smart
1) unique_ptr: 주인은 하나
  [로드] grass.png
  빌려서 보기: grass.png
  GPU 로 올림: grass.png
  [해제] grass.png
  grass 는 이제 비어 있음
2) shared_ptr: 여럿이 함께 소유
  [로드] sky.png
  use_count = 3
  use_count = 1
3) weak_ptr: 소유하지 않고 지켜보기
  아직 살아 있음: sky.png
  [해제] sky.png
  expired = true
4) 삭제자를 바꾼 unique_ptr
  [닫음] 파일
main 끝

복사를 시도하면 컴파일러가 막는다.

#include <memory>

int main() {
    auto a = std::make_unique<int>(7);
    auto b = a;
}
$ clang++ -std=c++20 -Wall -Wextra unique_copy.cpp -o unique_copy
unique_copy.cpp:5:10: error: call to implicitly-deleted copy constructor of 'unique_ptr<int>'
    5 |     auto b = a;
      |          ^   ~
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:221:55: note: copy constructor is implicitly deleted because 'unique_ptr<int>' has a user-declared move constructor
  221 |   _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX23 unique_ptr(unique_ptr&& __u) _NOEXCEPT
      |                                                       ^
1 error generated.

오류 문구 "call to implicitly-deleted copy constructor"가 핵심이다. 4장에서 본 대로 unique_ptr은 이동 생성자를 직접 선언했기 때문에 복사 생성자가 삭제되었다. 소유권이 둘로 나뉘는 실수를 컴파일 단계에서 잡은 것이다.

shared_ptr 순환 참조

로봇 모델을 트리로 표현한다. 몸통이 팔을 자식으로 갖고, 팔은 부모인 몸통을 안다. 양쪽 다 shared_ptr로 들고 있으면 어떻게 될까?

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

struct Node {
    std::string name;
    std::shared_ptr<Node> parent;               // 문제: 자식이 부모를 소유한다
    std::vector<std::shared_ptr<Node>> children;
    explicit Node(std::string n) : name(std::move(n)) {}
    ~Node() { std::cout << "  [해제] " << name << '\n'; }
};

struct SafeNode {
    std::string name;
    std::weak_ptr<SafeNode> parent;             // 해결: 지켜보기만 한다
    std::vector<std::shared_ptr<SafeNode>> children;
    explicit SafeNode(std::string n) : name(std::move(n)) {}
    ~SafeNode() { std::cout << "  [해제] " << name << '\n'; }
};

int main() {
    std::cout << "shared_ptr 로 서로 가리킬 때\n";
    {
        auto body = std::make_shared<Node>("몸통");
        auto arm = std::make_shared<Node>("팔");
        body->children.push_back(arm);
        arm->parent = body;
        std::cout << "  몸통 use_count = " << body.use_count() << '\n';
    }
    std::cout << "  블록을 나왔다\n";

    std::cout << "부모를 weak_ptr 로 가리킬 때\n";
    {
        auto body = std::make_shared<SafeNode>("몸통");
        auto arm = std::make_shared<SafeNode>("팔");
        body->children.push_back(arm);
        arm->parent = body;
        std::cout << "  몸통 use_count = " << body.use_count() << '\n';
        if (auto p = arm->parent.lock()) std::cout << "  팔의 부모: " << p->name << '\n';
    }
    std::cout << "  블록을 나왔다\n";
}
$ clang++ -std=c++20 -Wall -Wextra cycle.cpp -o cycle && ./cycle
shared_ptr 로 서로 가리킬 때
  몸통 use_count = 2
  블록을 나왔다
부모를 weak_ptr 로 가리킬 때
  몸통 use_count = 1
  팔의 부모: 몸통
  [해제] 몸통
  [해제] 팔
  블록을 나왔다

몸통과 팔이 서로를 shared_ptr 로 붙잡으면 지역 변수가 사라져도 참조 횟수가 0 이 되지 않아 둘 다 누수된다. 부모 방향을 weak_ptr 로 바꾸면 소유가 한 방향이 되어 정상 해제된다.

첫 번째 블록에서는 [해제]가 하나도 찍히지 않았다. 블록 끝에서 지역 변수 bodyarm이 사라져도, 몸통은 팔이, 팔은 몸통이 붙잡고 있어서 참조 횟수가 0이 되지 않는다. 몸통의 use_count가 2인 것이 그 증거다(지역 변수 하나 + 팔의 parent). 두 객체는 프로그램이 끝날 때까지 누수된다. 두 번째 블록처럼 부모 방향을 weak_ptr로 바꾸면 소유는 몸통 → 팔 한 방향뿐이라 둘 다 정상적으로 해제된다.

표 5-1. 소유 방식에 따른 선택

상황쓸 것
주인이 명확히 하나(대부분)std::unique_ptr. 기본 선택
수명을 여러 곳이 함께 결정std::shared_ptr. 정말 공유할 때만
역방향 링크, 캐시, 관찰자std::weak_ptr
소유와 무관하게 잠시 사용T&, const T&, 널 가능하면 T*

표 5-2. 자주 쓰는 스마트 포인터 연산

연산하는 일주의
make_unique / make_shared만들고 소유new 대신 기본으로 쓴다
std::move(p)소유권 넘기기넘긴 뒤 p는 비어 있다
get()원시 포인터 빌리기저장하지 않는다
reset()지금 해제(또는 교체)shared는 횟수만 1 줄 수 있다
release()소유만 놓기(unique)해제하지 않는다
lock()weak에서 잠시 소유빈 결과인지 확인

실무에서 자주 틀리는 것

1. 같은 원시 포인터로 shared_ptr을 두 번 만든다

std::shared_ptr<int> a(raw); std::shared_ptr<int> b(raw);는 제어 블록을 두 개 만든다. 둘 다 참조 횟수가 1이라 서로의 존재를 모르고, 각자 0이 될 때 같은 메모리를 해제한다. 원시 포인터에서 스마트 포인터를 만드는 일은 한 번만, 가능하면 make_shared/make_unique로 처음부터 만든다.

2. 모든 곳에 shared_ptr을 쓴다

"편하니까" 전부 shared_ptr로 만들면 누가 주인인지 다시 흐려지고, 참조 횟수 증감이 원자적 연산이라 멀티스레드 환경에서 비용이 생긴다. 순환 참조 위험도 커진다. 먼저 unique_ptr로 설계하고, 공유가 꼭 필요한 곳만 바꾼다.

3. get()으로 꺼낸 포인터를 오래 들고 있는다

Texture* t = owner.get();은 빌린 것이다. owner가 사라지거나 reset() 되면 t는 매달린 포인터가 된다. get()의 결과는 그 줄이나 그 함수 안에서만 쓰고, 저장해 두지 않는다.

4. release()를 해제로 착각한다

release()는 소유권만 놓고 포인터를 돌려준다. 해제하지 않는다. 돌려받은 포인터를 delete하거나 다른 소유자에게 넘기지 않으면 누수다. 해제하려면 reset()을 쓴다.

연습 문제

  1. int* raw = new int(3); std::shared_ptr<int> a(raw); std::shared_ptr<int> b(raw); 다음 a.use_count()b.use_count()는 각각 몇이고, 이 코드는 어떻게 끝나는가?
  2. std::vector<std::unique_ptr<Texture>> v;에 텍스처 두 개를 넣고 v.erase(v.begin());를 했다. 첫 번째 텍스처는 언제 해제되는가? v 자체가 사라지면?
  3. auto u = std::make_unique<Texture>("x"); Texture* p = u.release(); 다음 텍스처의 소멸자는 불렸는가? 이 상태에서 해야 할 일은?

정답

  1. 둘 다 1이다. 제어 블록이 따로 두 개 생겼기 때문이다. 두 shared_ptr이 사라질 때 각자 같은 메모리를 해제하므로 이중 해제로 끝난다(4장 문제 상황과 같은 결과). 검증 프로그램에서는 첫 번째 shared_ptruse_count()가 1인 것까지만 확인하고, 이중 해제가 되는 두 번째 생성은 실행하지 않았다.
  2. erase하는 그 순간 해제된다. 원소인 unique_ptr이 소멸하면서 가리키던 텍스처를 지운다. v가 사라지면 남은 원소도 모두 해제된다. 원시 포인터 벡터였다면 erase 전에 직접 delete 해야 했다. 검증 프로그램에서 소멸자 호출 수로 두 시점을 확인했다.
  3. 불리지 않았다. u는 비었고 텍스처는 p만 가리킨다. delete p;를 하거나, std::unique_ptr<Texture> again(p);처럼 다시 소유자에게 맡겨야 한다. release()는 C API처럼 소유권을 받아 가는 함수에 넘길 때만 쓴다.

다음 장에서는 스마트 포인터보다 훨씬 자주 쓰는 소유 객체, 표준 컨테이너를 본다. 특히 std::vector가 메모리를 옮기는 순간 옛 참조가 어떻게 되는지가 핵심이다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.