C++ Smart Pointer

윤형·2025년 12월 18일

C++ Game Server

목록 보기
3/10
post-thumbnail

Smart Pointer

스마트 포인터는 delete를 자동으로 해주는 보다 똑똑한 포인터를 의미한다. smart_ptr을 사용하면 new/delete를 직접 사용하지 않아도 동적 메모리를 안전하게 관리할 수 있다. (고맙다...)

  • smart_ptr은 실제로는 클래스고 내부에 포인터를 가지고 있다.
  • 스코프를 벗어나면 자동으로 delete또는 delete[]를 호출한다. (RAII)
  • RAII은 "객체가 만들어질때 자원을 잡고, 객체가 파괴될 때 자원을 자동으로 반납하자."라는 객체 관리 방법이다.
#include <iostream>

class A {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

void thrower() {
  // 예외를 발생시킴!
  throw 1;
}

void do_something() {
  A *pa = new A();
  thrower();

  // 발생된 예외로 인해 delete pa 가 호출되지 않는다!
  delete pa;
}

int main() {
  try {
    do_something();
  } catch (int i) {
    std::cout << "예외 발생!" << std::endl;
    // 예외 처리
  }
}

이런식으로 delete를 통해 메모리를 해제하려고 시도하지만 중간에 예외가 발생하면 바로 메모리 누수로 이어지게 된다. 따라서 스마트 포인터를 이용해서 관리하는 것이 정신건강에 이롭다. 참고로 auto_ptr 이란것도 있는데 이거는 여러 문제들이 발견되어 사용하면 안된다.

Smart Pointer에는 크게 3가지가 존재한다.
1. unique_ptr
2. shared_ptr
3. weak_ptr
이제 이 3가지에 대해서 하나씩 알아보자

unique_ptr

오직 한 포인터만 그 객체를 소유한다. 복사 금지, 이동은 가능.

Data* data = new Data();
Date* data2 = data;

// data 의 입장 : 사용 다 했으니 소멸시켜야지.
delete data;

// ...

// data2 의 입장 : 나도 사용 다 했으니 소멸시켜야지
delete data2;

일단 이렇게 이미 해제된 메모리를 다시 해제하려고 시도하는 것을 double free라고 부르고 이는 프로그램을 죽게 만드는 치명적인 버그다.

이렇게 되는 이유는 객체의 소유권이 명확하지 않아서 그렇다. 그래서 이는 소유권을 부여해주는 unique_ptr을 통해 해결할 수 있다.

#include <iostream>
#include <memory>

class A {
  int *data;

 public:
  A() {
    std::cout << "자원을 획득함!" << std::endl;
    data = new int[100];
  }

  void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

void do_something() {
  std::unique_ptr<A> pa(new A());
  pa->some();
}

int main() { do_something(); }
자원을 획득함!
일반 포인터와 동일하게 사용가능!
자원을 해제함!
  • unique_ptr를 정의하기 위해 템플릿 인자로 포인터가 가르킬 클래스를 전달한다.
  • std::unique_ptr<A> pb = pa;만약에 이런식으로 unique_ptr을 복사하려 한다면 당연히 오류를 발생한다.
void do_something() {
  std::unique_ptr<A> pa(new A());
  std::cout << "pa : ";
  pa->some();

  // pb 에 소유권을 이전.
  std::unique_ptr<A> pb = std::move(pa);
  std::cout << "pb : ";
  pb->some();
}
  • 하지만 이렇게 move를 통해 소유권을 이전하는 것은 가능하다.
  • 소유권 이전을 한 후에 pa.get()을 해 주소값을 확인하면 0이 나온다. 즉 소유권을 이전한 후에는 비워져 있기 때문에 접근하지 않도록 해야한다.

unique_ptr 인자

// 올바르지 않은 전달 방식
void do_something(std::unique_ptr<A>& ptr) { ptr->do_sth(3); }

int main() {
  std::unique_ptr<A> pa(new A());
  do_something(pa);
}
  • 이런식으로 레퍼런스에 전달하면 어떻게 될까?
  • 동작은 문제 없이 되겠지만 ptr은 더이상 유일한 소유권을 의미하지 않게 된다.
  • 따라서 이렇게 사용하게 되면 원칙상 문제다. (오류는 나지 않지만.)
  • 그러면 어떻게 인자로 넘겨야 할까?
void do_something(A* ptr) { ptr->do_sth(3); }

int main() {
  std::unique_ptr<A> pa(new A());
  do_something(pa.get());
}
  • 바로 원래의 포인터 주소값을 그대로 넘기는 것이다.
  • 이러면 소유권을 또 가지는 개념보다는 함수에 객체에 접근할 권리를 주는 개념이다.

std::make_unique

  • make_unique는 unique_ptr을 쉽게 만들 수 있게 해주는 함수이다.
class Foo {
  int a, b;

 public:
  Foo(int a, int b) : a(a), b(b) { std::cout << "생성자 호출!" << std::endl; }
  void print() { std::cout << "a : " << a << ", b : " << b << std::endl; }
  ~Foo() { std::cout << "소멸자 호출!" << std::endl; }
};

int main() {
  auto ptr = std::make_unique<Foo>(3, 5);
  ptr->print();
}
  • make_unique는 템플릿 인자로 전달된 클래스의 생성자에 완벽한 전달을 수행한다.

STL 컨테이너

먼저 STL(Standard Template Library) 컨테이너란 데이터를 넣어두는 그릇 클래스를 의미한다.

  • vector, list, map, queue같은 것들이 전부 STL 컨테이너다.
int main() {
  std::vector<std::unique_ptr<A>> vec;
  std::unique_ptr<A> pa(new A(1));

  vec.push_back(pa);
}
  • 자 그러면 이제 vector STL 컨테이너에 한번 push_back을 해보자 물론 잘 동작하겠지?
  • 아니다 바로 오류를 발생시킬 것이다.
  • 그 이유는 unique_ptr은 복사 생성자가 없기 때문이다.
  • push_back 함수는 전달된 인자를 복사해서 집어 넣기 때문에 문제가 발생한다.
int main() {
  std::vector<std::unique_ptr<A>> vec;
  std::unique_ptr<A> pa(new A(1));

  vec.push_back(std::move(pa));  // 잘 실행됨
}
  • 따라서 pa를 vector안으로 이동시켜야 한다. (복사 X)
int main() {
  std::vector<std::unique_ptr<A>> vec;

  // vec.push_back(std::unique_ptr<A>(new A(1))); 과 동일
  vec.emplace_back(new A(1));

  vec.back()->some();
}
  • 참고로 emplace_back을 이용하면 생성자 인자를 받아서 컨테이너 안에서 직접 생성하는 것이기 때문에 잘 동작한다.
  • emplace_back은 전달된 인자를 완벽히 전달해 vector 맨 뒤에 unique_ptr<A>객체를 생성한다.

shared_ptr

unique_ptr는 항상 딱 하나의 객체가 소유하는 포인터 였다면 shared_ptr는 여거 객체가 같이 공유할 수 있는 포인터이다.

std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.

// 반면에 unique_ptr 의 경우
std::unique_ptr<A> p1(new A());
std::unique_ptr<A> p2(p1);  // 컴파일 오류!
  • p1과 p2의 경우 동일한 객체를 가르키지만 unique경우에는 오류를 발생시킨다.
  • shared_ptr이 몇개를 가르키는지 알아야 하기 때문에 참조 개수(reference count)가 0이 되어야 객체를 메모리에서 해제할 수 있다.
  • 참고로 count개수는 .use_count()를 통해서 확인할 수 있다.
std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.
std::shared_ptr<A> p3(p2);

std::cout << p1.use_count();  // 3
std::cout << p2.use_count();  // 3
std::cout << p3.use_count(); // 3
  • 여기서 p3를 p2로 만들게 되면 p2와 p3의 count값은 3으로 된다.

shared_ptr 생성 시 주의 사항

  • shared_ptr은 인자로 주소값이 전달이 되면 자기가 해당 객체를 처음으로 소유하는 것 마냥 행동하게 된다.
A* a = new A();
std::shared_ptr<A> pa1(a);
std::shared_ptr<A> pa2(a);
  • 만약에 pa2가 pa1으로 shared_ptr을 하는게 아니라 똑같이 a에 대해서 shared_ptr을 하게 된다면
  • 이렇게 서로 자기가 처음인 shared 된 것처럼 다른 control block의 존재를 모르고 서로 count를 1로 하게 된다.
  • 이전에서는 p2가 p1을 참조하게 되면서 control block의 존재를 알고 count를 올리게 되었다. -> 이게 진짜 shared된 상태.
  • 이 상태에서 p1이 소멸하면서 count가 0이 되어서 A가 같이 지워진다면 p2는 계속 참조중이기 때문에 오류가 발생하게 된다.
  • 이런 상황을 방지하기 위해서는 shared_ptr의 주소를 통해 생성하는 것을 피해야 한다.

make_shared 사용법 및 주의사항

make_shared은 shared_ptr을 쉽게 만들 수 있게 해주는 함수이다.

std::shared_ptr<A> p1 = std::make_shared<A>();
  • 이때 중요하게 알아야 하는데 바로 make_shared를 사용하지 않고 그냥 shared_ptr로 만들게 되면
[객체][control block] 이런 형태로 분리되어서 메모리 공간에 할당이 된다.

하지만 make_shared를 사용하게 되면
[객체 | control block] 이런 형태로 단일 메모리 블록에 할당하게 된다.
  • 따라서 여러 control block이 생기는 문제를 어느정도 해결해 줄 수 있다.
// ✅ 올바른 사용법
auto pa1 = std::make_shared<A>();  // [A | control block] #1 생성
auto pa2 = pa1;                    // pa1과 control block 공유 (ref=2)

// 또는 복사
std::shared_ptr<A> pa2 = pa1;      // 같은 control block 공유
  • 이렇게 사용하면 같은 메모리 공간을 참조하면서 control block을 공유하게 될 것이다.
  • 하지만...!
A* a = new A(); 

auto pa1 = std::make_shared<A>(a);
auto pa2 = std::make_shared<A>(a); 
  • 마찬가지로 이런식으로 나눠서 코드를 사용하게 되면 결국 같은 객체에 대해서 다른 메모리 공간을 할당해 별도의 control block을 관리해 ref count가 공유되지 않는 문제가 있게 된다.
  • 그렇다면 어떤방식으로 이러한 근본적인 문제를 해결할 수 있을까?
  • 정답은 그냥 Raw pointer를 생성해 넘겨주지 않게 신경쓰는 방법밖에 없다. 애초에 shared_ptr의 설계 원칙에 의한 문제기 때문에 이걸 감싸는 wrapper를 별로도 만들어주거나 신경쓰는 방법밖에 없다.
class A {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

  ~A() {
    std::cout << "소멸자 호출!" << std::endl;
    delete[] data;
  }

  std::shared_ptr<A> get_shared_ptr() { return std::shared_ptr<A>(this); }
};

int main() {
  std::shared_ptr<A> pa1 = std::make_shared<A>();
  std::shared_ptr<A> pa2 = pa1->get_shared_ptr();

  std::cout << pa1.use_count() << std::endl;
  std::cout << pa2.use_count() << std::endl;
}
  • 또한 이렇게 자기 자신을 shared_ptr로 넘기는 경우는 종종 일어나게 된다.
  • 이때 단순히 return으로 shared_ptr<A>를 하게 되면 pa1과 pa2는 같은 A를 가르키지만 control block이 마찬가지로 각각 생기게 된다. 이러면 위와 같은 문제가 똑같이 일어날 수 있다.
#include <iostream>
#include <memory>

class A : public std::enable_shared_from_this<A> {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

  ~A() {
    std::cout << "소멸자 호출!" << std::endl;
    delete[] data;
  }

  std::shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
};

int main() {
  std::shared_ptr<A> pa1 = std::make_shared<A>();
  std::shared_ptr<A> pa2 = pa1->get_shared_ptr();
  std::shared_ptr<A> pa3 = pa1->get_shared_ptr();
  std::shared_ptr<A> pa4 = pa3->get_shared_ptr();
  
  std::cout << pa1.use_count() << std::endl;
  std::cout << pa2.use_count() << std::endl;
  std::cout << pa3.use_count() << std::endl;
}
  • 이럴때는 enable_shared_from_this클래스를 상속받아서 해결할 수 있다.
  • 이 클래스에는 shared_from_this라는 멤버 함수를 정의하는데 이 함수는 이미 존재하는 control block을 이용해 shared_ptr을 생성한다.
  • 여기서 포인트는 shared_ptr이 먼저 정의가 되어있어야 한다. 따라서 pa1이 없이 pa2가 get_shared_ptr을 하면 오류가 나온다.
  • 이렇게 하면 여러 ref count block이 생기는 문제는 어느정도 신경을 쓰면 해결할 수 있다는 사실을 알게 되었다.

shared_ptr의 순환 참조 문제

  • 사실 shared_ptr의 가장 큰 문제는 순환 참조 문제이다.
  • 만약에 shared_ptr들이 서로를 가르키는 상황이 생기면 절대로 참조 개수가 0이 될 수 없는 상황이 발생한다.
  • 객체 1이 파괴되기 위해서는 참조 개수가 0이 되어야 하기 때문에 객체2가 파괴되어야 하는데 객체 2가 파괴되기 위해서는 객체1이 파괴되어야 한다.(fuck?)
  • 이러한 순환참조 문제를 해결하기 위해 등장한 개념이 weak_ptr이다.

weak_ptr

weak_ptr은 shared_ptr가 관리하는 객체를 약하게 참조하는 포인터다.
shared_ptr를 구현하는 참조 카운트의 영향을 받지 않는다는 특징이 있다.

  • 객체를 소유하지 않아 control block을 의 use_count값을 증가시키지 않는다.
  • 대신 shared_ptr가 관리하는 객체가 아직 살아있는지를 관찰하는 observe 역할을 수행한다.
  • 할당받은 자원을 사용하기 위해서는 .lock()이나 .expired()를 통해 하면 된다.
  • shared_ptr을 타고 들어가면 내부적으로 _uses와 _weaks 멤버 변수가 존재하는데, 이게 바로 강한 참조와 약한 참조를 확인하고 있는 변수라고 보면 된다.
  • use카운트가 0이되면 메모리 해제가 되고, weak가 남아있는 상태에서 참조를 하게 되면 내부적으로 use카운트를 보고 0이면 nullptr을 반환하기 때문에 안전하게 사용할 수 있다.
  • 또한 ref카운트를 올리지 않기 때문에 이걸 이용하면 순환 참조를 피하면서 필요한 값을 가져올 수 있게 되는 것이다.
class Person
{
    //... 생략
    std::weak_ptr<Person> m_partner; 
    //... 생략
public:
    bool SetParter(std::shared_ptr<Person>& p)
    {
        if (!p)
            return false;

        this->m_partner = p;

        std::cout << m_name << " is now partnered with " << p->m_name << "\n";

        return true;
    }
    string getPartnerName() const {
        // lock()으로 안전하게 shared_ptr 변환 시도
        if (auto partner = m_partner.lock()) {
            return partner->m_name;  // 안전하게 접근
        }
        return "No partner";  // 파트너가 삭제된 경우
    }
}

{
    // woong과 soo라는 Person의 shared_ptr 객체를 생성합니다.
    auto pWoong = std::make_shared<Person>("woong");
    auto pSoo = std::make_shared<Person>("soo");

    // woong과 soo과 서로 파트너로 설정합니다. 
    // 내부적으로 서로가 서로를 참조하는 형태로 구현됩니다.
    pWoong->SetPartner(pSoo);
    pSoo->SetPartner(pWoong);
    
    // 안전하게 파트너 이름 출력
    std::cout << pWoong->getPartnerName() << std::endl;  // "soo"

    // soo가 먼저 삭제된 후
    pSoo.reset();
    std::cout << pWoong->getPartnerName() << std::endl;  // "No partner"
}
  • 이런식으로 사용하게 되면 shared_ptr → weak_ptr 단방향 참조를 하게 된다.
  • 외부에서 shared_ptr 소유권 유지하면서 내부는 weak로 순환을 끊는다.
  • 참고로 lock을 사용하면 새로운 shared_ptr을 생성해서 반환하게 된다. 따라서 ref count도 1증가한다.
  • 이 shared_ptr을 받아서 원하는 처리를 하면 되는 것이다.
  • 위 코드에서는 auto partner = m_partner.lock() 부분에서 ref count가 1늘어났지만 스코프를 벗어나면서 자동적으로 정리가 되므로 다시 1이 줄어드는 흐름으로 동작하고 있는 것이다.
  • 추가로 만약에 shared_ptr의 use count가 0이 되어서 메모리가 해제가 될 때, weak count가 0이 아니라면 control block부분은 해제를 안하고 남겨놓게 된다. 따라서 어떤 weak_ptr이 접근하면 그 때 이거 해제된거라고 안전하게 알려주고, weak_count까지 0이 되어야 그제서야 control block부분도 메모리에서 해제하게 된다.
  • 참고로 use_count는 0인데 weak는 살아있으면 "좀비 상태"라고 표현하기도 한다.
  • 트리구조를 한번 생각해보자.
  • 각 노드는 하나의 부모만을 가지고 자식은 여러개 가질 수 있다.
class Node {
  std::vector<std::shared_ptr<Node>> children;
  /* 어떤 타입이 와야할까? */ parent;

 public:
  Node(){};
  void AddChild(std::shared_ptr<Node> node) { children.push_back(node); }
};
  • shared_ptr이 오면 앞서 확인한 순환 참조 문제가 발생할것이다.
  • 일반적인 Node* 포인터를 하게 되면 dangling이 발생할 위험이 있다.
    • (참조하는 parent가 해제되었는데도 참조하는 경우가 발생 가능.)
  • weak_ptr은 일반 포인터와 shared_ptr사이에 위치한 포인터로 객체를 안전하게 참조할 수 있지만 count를 늘리지 않는다. (구경만 함.)
  • 이때 weak_ptr이 있어도 그 객체를 가르키는 shared_ptr이 없으면 소멸이 된다.
    ⚠️따라서 weak_ptr자체로는 원래 객체를 참조할 수 없기 때문에 shared_ptr로 변환해서 사용해야 한다. 객체가 소멸된 상태면 빈 shared_ptr을 반환하고 아니라면 해당 객체를 가르키는 shared_ptr로 변환된다.
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class A {
  std::string s;
  std::weak_ptr<A> other;

 public:
  A(const std::string& s) : s(s) { std::cout << "자원을 획득함!" << std::endl; }

  ~A() { std::cout << "소멸자 호출!" << std::endl; }

  void set_other(std::weak_ptr<A> o) { other = o; }
  void access_other() {
    // weak_ptr 를 shared_ptr 로 변환 시도
    // 이게 변환인 이유는 other lock을 호출하면 mutex와 같은 락이 아니라 weak 상태를 shared 상태로 변환한다는 의미이다.
    std::shared_ptr<A> o = other.lock();
    if (o) {
      std::cout << "접근 : " << o->name() << std::endl;
    } else {
      std::cout << "이미 소멸됨" << std::endl;
    }
  }
  std::string name() { return s; }
};

int main() {
  std::vector<std::shared_ptr<A>> vec;
  vec.push_back(std::make_shared<A>("자원 1"));
  vec.push_back(std::make_shared<A>("자원 2"));

  vec[0]->set_other(vec[1]);
  vec[1]->set_other(vec[0]);

  // pa 와 pb 의 ref count 는 그대로다.
  std::cout << "vec[0] ref count : " << vec[0].use_count() << std::endl;
  std::cout << "vec[1] ref count : " << vec[1].use_count() << std::endl;

  // weak_ptr 로 해당 객체 접근하기
  vec[0]->access_other();

  // 벡터 마지막 원소 제거 (vec[1] 소멸)
  vec.pop_back();
  vec[0]->access_other();  // 접근 실패!
}
자원을 획득함!
자원을 획득함!
vec[0] ref count : 1
vec[1] ref count : 1
접근 : 자원 2
소멸자 호출!
이미 소멸됨
소멸자 호출!
  • set_other를 호출하면 weak_ptr로 인자를 받아서 이를 넣는다. 즉 weak_ptr는 weak_ptr또는 shared_ptr를 받는다는 것을 알 수 있다.
  • 여기서 other.lock()를 하면 weak_ptr을 잠시 shared_ptr 상태로 승급해서 고정한다는 의미에 가깝다. (mutex lock이랑은 다름)
  • access_other를 하면 잠시 shared로 변환해서 카운트가 2로 증가한 모습을 볼 수 있다.
  • vec[1]이 지워져도 순환참조 문제가 발생하지 않는 모습을 볼 수 있다.

참고 링크

씹어먹는 C++ 13 : 모두의 코드

profile
제가 관심있고 공부하고 싶은걸 정리하는 저만의 노트입니다.

0개의 댓글