Smart Pointer

Date:     Updated:

카테고리:

태그:

모두의 코드 씹어먹는 c++ 자료를 보고 정리한 내용입니다.


c++ 이후에 나온 많은 언어(Java 등등)들은 대부분 가비지 컬렉터 (Garbage Collector - GC) 라 불리는 자원 청소기가 기본적으로 내장되어 있다. 이 가비지 컬렉터의 역할은 프로그램 상에서 더 이상 쓰이지 않는 자원을 자동으로 해제해 주는 것이다. 따라서 프로그래머들이 코드를 작성할 때 자원을 해제하는 일에 대해 크게 싱경쓰지 않아도 된다. 하지만 c++의 경우 한 번 획득한 자원은, 직접 해제하지 않는 이상 프로그램이 종료되기 전 까지 영원히 남아있게 된다.

class A {
  int *data;

 public:
  A() { data = new int[100]; }

  ~A() { delete[] data; }
};

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

int main() {
  do_something();
}

위 코드와 같이 do_something 안에서 delete를 하지 않는다면, 생성된 객체를 가리키던 pa 포인터는 메모리에서 사라지게 된다. 따라서 Heap 어딘가에 클래스 A 객체가 그대로 남아있고, 그 객체를 가리키는 포인터는 메모리 상에 존재하지 않는 것이다. 따라서 그 객체는 영원히 해제되지 못한 채 힙에서 자리만 차지하며 메모리 누수를 발생시키는 것이다.

언뜻 생각하기에 자원 해제를 꼼꼼히 하지 않은 프로그래머 책임 아님? 이라 할 수 있으나 프로그램의 크기가 커지고 복잡해지면 자원을 해제하는 위치가 애매해지는 경우가 많아진다. 특히 예외처리가 포함 되어있을 경우가 그렇다.


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;
  }
}

위와 같이, thrower()로 발생된 예외로 인해, delete pa; 가 실행 되지 않고 넘어가버리기 때문이다. 이런 경우 예외는 정상적으로 처리되었지만, 메모리 누수는 피하지 못했다.

Resource Acquisition Is Initialization - RAII

c++ 창시자인 비야네 스트로스트룹은 c++에서 자원을 관리하는 방법으로 흔히 RAII라 불리는 디자인 패턴을 제안했다. 이는 스택에 할당한 객체를 통해 자원 관리를 수행하는 것이다.

지난 강좌에서 예외가 발생해 함수를 빠져나가더라도, 그 함수의 스택에 정의되어있는 모든 객체들은 빠짐 없이 소멸자를 호출하는데, 이를 Stack Unwiding 이라 한다고 했다. 물론 예외가 발생하지 않아도, 함수가 종료될 때 당연히 소멸자들이 호출된다.

위 코드에서 pa는 객체가 아니기 때문에 소멸자가 호출되지 않고, 객체를 가리키고 있는 포인터 값 메모리만 해제된다. 그렇다면 그 대신에, pa를 일반적인 포인터가 아닌 포인터 객체로 만들어 소멸자가 호출될 때 자신이 가리키는 데이터도 같이 delete하면 어떨까? 그렇게 된다면 자원 관리를 스택의 객체를 통해 수행할 수 있다.

이렇게 똑똑하게 작동하는 포인터 객체를 스마트 포인터(Smart Pointer)라고 한다.

[!tip] c++ 11 dlwjsdp, auto_ptr이란게 잠시 등장했지만, 문제가 너무 많아 지금은 사용하지 않는다.

객체의 유일한 소유권 - unique_ptr

c++에서 메모리를 잘못 관라하였을 때 발생하는 문제점은 크게 두 가지 종류이다.

첫 번째는 앞서 이야기한 메모리를 사용한 후 해제하지 않는 경우이다. 이를 메모리 누수(memory leak)이라고 부른다. 간단한 프로그램의 경우 크게 문제될 일이 없지만, 서버 처럼 장시간 작동하는 프로그램의 경우 시간이 지남에 따라 점점 사용하는 누수되는 메모리의 양이 늘어난다. 결과적으로 시스템 메모리가 부족해져 서버가 죽어버리는 문제가 발생할 수 있다. 다행히 해당 문제는 위에서 이야기한 RAII 패턴을 사용해 해결할 수 있다.

두 번째로 발생 가능한 문제는 이미 해제된 메모리를 다시 참조하는 경우이다.

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

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

// ...

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

예를 들어 위 코드에서는 이미 소멸된 객체를 다시 소멸시키려고 한다. 이를 double free 버그라 하는데 이런 경우 메모리 오류가 나면서 프로그램이 죽어버린다.

위와 같은 문제가 발생한 이유는 객체의 소유권이 명확하지 않기 때문이다. 만약 어떤 포인터에 객체의 유일한 소유권을 부여하여, 이 포인터 말고는 객체를 소멸시킬 수 없다! 라고 한다면, 위와 같은 문제를 방지할 수 있을 것이다.

c++에서는 이렇게 특정 객체에 유일한 소유권을 부여하는 포인터 객체를 unique_ptr 이라 한다.

class A {
	int *data;
	
public:
	A() { data = new int[100]; }
	
	void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }
	
	~A() { delete[] data; }
};

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

int main() {
  do_something();
}

unique_ptr-> 연산자를 오버로드해서 마치 포인터를 다루는 것과 같이 사용할 수 있다. 또한 이 unique_ptr 덕분에 RAII 패턴을 사용할 수 있다. 즉, pa는 스택에 정의된 객체이기 때문에 do_something 함수가 종료될 때 자동으로 소멸자가 호출된다.

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

  // pb 도 객체를 가리키게 할 수 있을까?
  std::unique_ptr<A> pb = pa;
}

[!error] ‘std::unique_ptr<A,std::default_delete<_Ty>>::unique_ptr(const std::unique_ptr<_Ty,std::default_delete<_Ty>> &)': attempting to reference a deleted function

만약 unique_ptr을 복사하려고 하면 위와 같이 삭제된 함수를 참조한다는 오류가 뜬다.

삭제된 함수

class A {
 public:
  A(int a){};
  A(const A& a) = delete;
};

int main() {
  A a(3);  // 가능
  A b(a);  // 불가능 (복사 생성자는 삭제됨)
}

` = delete; 키워드는 c++ 11`에 추가된 기능으로, 사용을 원치 않는 함수를 명시적으로 삭제시키는 방법이다.

unique_ptr은 특정 객체의 소유권을 유일하게 보존하기 위해 복사 생성자를 명시적으로 삭제했다.

unique_ptr 소유권 이전

unique_ptr은 복사 생성자가 정의되어 있지 않지만, 이동 생성자는 정의되어 있다(생성자 & 대입 연산자). 즉, 객체의 유일한 소유권을 다른 포인터에게 이전할 수 있다는 것이다.

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

  // pb 에 소유권을 이전.
  std::unique_ptr<A> pb = std::move(pa);
  pb->some();
}

std::move를 이용해 이동 생성자를 호출하면 pbnew A로 생성된 객체의 소유권을 갖게 되고, pa는 아무것도 가리키고 있지 않게 된다. 실제로 pa.get()을 통해 pa가 가리키고 있는 실제 주소값을 확인해보면 0 (nullptr)이 찍힌다. 따라서 소유권을 이전시키고 난 이후에는 기존의 unique_ptr에 접근하지 않도록 조심해야 한다.

[!warning] 소유권이 이전된 unique_ptr을 댕글링 포인터(dangling pointer)라고 하며 이를 재 참조할 시에 런타임 오류가 발생한다. 따라서 소유권 이전은, 댕글링 포인터를 절대 다시 참조하지 않겠다는 확신 하에 이동해야 한다.

unique_ptr 함수 인자로 전달

앞서 unique_ptr은 복사 생성자가 없다고 했다. 만약 어떠한 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);
}

위 코드와 같이 unique_ptr의 레퍼런스를 사용하는 것은 유일한 소유권이라는 의미를 망각한 채 단순히 포인터의 Wrapper로 사용하는 것에 불과하다. 즉, 코드가 작동은 하지만 우리의 의도대로 작동하지 않을 것이다.

그렇다면 unique_ptr을 올바르게 전달하는 방법은 무엇일까? 이는 생각보다 단순한데, unique_ptrget함수를 이용해 원래의 포인터 주소값을 전달해주면 된다.

void do_something(A* ptr) { ptr->do_sth(3); }

int main() {
  std::unique_ptr<A> pa(new A());
  do_something(pa.get());
}

unique_ptr 생성하기

auto ptr = std::make_unique<Foo>(3, 5);

c++ 14부터 unique_ptr을 간단히 만들 수 있는 std::make_unique 함수를 제공한다. 또한 make_unique 함수는 아예 템플릿 인자로 전달된 클래스 생성자에 인자들을 직접 완벽한 전달을 통해 전달한다. 따라서 불필요한 임시 객체의 생성, 복사 과정을 생략할 수 있고, 코드도 간결해진다.

unique_ptr을 STL에 집어 넣기

int main() {
  std::vector<std::unique_ptr<A>> vec;
  std::unique_ptr<A> pa(new A(1));

  vec.push_back(pa);  // error
}

vectorpush_back 함수는 전달된 인자를 복사해서 집어 넣기 때문에 복사 생성자가 제거된 unique_ptr을 집어 넣으면 에러가 발생한다.

이를 방지하기 위해서는 명시적으로 pavector 안으로 이동 시켜주어야 한다.

vec.push_back(std::move(pa));  // 잘 실행됨

이때 재미있게도, emplace_back 함수를 이용하면 vector 안에 unique_ptr을 직접 생성하면서 집어 넣을 수도 있다. 즉 불필요한 과정을 생략할 수 있다는 것이다. emplace_back 함수는 전달된 인자를 완벽한 전달을 통해 직접 unique_ptr<A> 의 생성자에 전달한다.

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

shared_ptr

여러 객체에서 하나의 자원을 사용하는 경우가 존재한다. 이런 경우 해당 자원을 사용하는 모든 객체들이 소멸되어야 자원을 해제할 수 있는데, 어떤 객체가 먼저 소멸되는지 알 수 없기 때문에 자원을 해제시키는 타이밍 역시 알기 힘들다.

따라서 이런 경우, 특정 자원을 몇 개의 객체에서 가리키는지를 추적하며, 그 수가 0이 되는 순간 자원을 해제시켜주는 좀 더 스마트한 포인터가 필요하다.

이러한 방식을 정확히 수행하는 포인터가 바로 shared_ptr 이다. 유일하게 객체를 소유하는 unique_ptr 과는 다르게, shared_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);  // 컴파일 오류!

Pasted image 20240409225305

여기서 퀴즈 하나! 말했다시피 개개의 shared_ptr들은 레퍼런스 카운트가 몇인지 알고 이어야만 한다. 이 경우 어떻게 하면 같은 객체를 가리키는 shared_ptr끼리 동기화 시킬 수 있을까?

[1] shared_ptr 내부에 레퍼런스 카운트 저장하기

std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);
std::shared_ptr<A> p3(p2);

smart1 1

p3 를 선언할 때 여차저차 해서 p2의 레퍼런스 카운트를 증가시킬 수 있다 해도, p1에 저장되어 있는 레퍼런스 카운트는 건드릴 수 없다. 즉, 개개의 shared_ptr 이 서로 독립적으로 존재하기 때문에 내부에서 관리할 수가 없다.

[!fail]

[2] 별도의 제어 블록 관리

smart2

따라서 이와 같은 문제를 방지하기 위해 처음으로 자원을 가리키는 shared_ptr제어 블록(control block) 을 동적으로 할당한 후, shared_ptr들이 이 제어 블록의 정보를 공유하는 방식으로 구현된다.

make_shared 를 통한 생성

std::shared_ptr<A> p1(new A());

앞서 shared_ptr을 위와 같이 생성하였으나, 사실 이는 바람직한 생성 방법은 아니다. 왜냐하면 shared_ptr을 생성하기 위해서는 일단 A를 생성하기 위한 동적 할당이 한 번 일어나고, 그 다음 shared_ptr의 제어블록 을 위한 동적 할당이 한 번 일어나야 한다.

알다시피 동적 할당은 상당히 비싼 연산이기 때문에, 어차피 이미 정해진 사이즈로 두 번 할당할 것을 합친 크기로 한 번 할당하는 것이 훨씬 효율적이다.

std::shared_ptr<A> p1 = std::make_shared<A>();

make_shared 함수는 A의 생성자 인자들을 받아 객체 A제어 블록을 한 번에 동적 할당하고, 만들어진 shared_ptr 을 리턴한다.

enable_shared_from_this

A* a = new A();
std::shared_ptr<A> pa1(a);
std::shared_ptr<A> pa2(a);

smart3

shared_ptr을 생성할 떄 주의할 점이 있다. 위 코드와 같이 인자로 주소값이 전달되면, 마치 자기가 해당 객체를 첫 번째로 소유하는 shared_ptr 인 것 마냥 행동한다. 다시 말해, 같은 객체를 가리키는 p1, p2 가 각각의 control block 을 갖게 된다는 말이다.

이러한 문제를 방지하려면 shared_ptr 을 주소값을 통해 생성하는 것을 지양해야 한다. 하지만 어쩔 수 없는 상황도 있는데, 바로 객체 내부에서 자기 자신을 가리키는 shared_ptr을 만들 때이다. (DX11 강의에서 고통받았던 부분..)

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;
}

[!result] 자원을 획득함! 1 1 소멸자 호출! 소멸자 호출! test(38479,0x10e0945c0) malloc: ** error for object 0x7fa1e0e02700: pointer being freed was not allocated test(38479,0x10e0945c0) malloc: ** set a breakpoint in malloc_error_break to debug [1] 38479 abort ./test

위 코드에서 get_shared_ptr 함수는 shared_ptr을 생성할 때, 이미 자기 자신을 가리키는 shared_ptr이 있다는 사실을 모른채 새로운 제어 블록을 생성한다. 따라서 get_shared_ptr 이 아직 객체를 가리키고 있음에도 p1이 해당 객체를 지워버려 double free 버그가 발생하게 된다.

이 문제는 enable_shared_from_this 를 통해 깔끔하게 해결할 수 있다.

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

enable_shared_from_this 클래스에는 shared_from_this 라는 멤버 함수를 정의하고 있는데, 이 함수는 이미 정의되어 있는 제어 블록을 사용해 shared_ptr을 생성한다.

이때 주의할 점은 기존 제어블록을 가지고 있는 shared_ptr 이 반드시 먼저 정의되어 있어야 한다. 그렇지 않으면 shared_from_this 는 있는 제어 블록을 확인해서 가져만 올 뿐, 없는 제어 블록을 만들지는 않기 때문에 오류가 발생하게 된다.

weak_ptr

앞서 shared_ptr은 레퍼런스 카운트가 0이 되면 가리키는 객체를 메모리에서 해제 시킨다고 했다. 그런데, 겍체를 더 이상 사용하는 않는데도 불구하고 참조 갯수가 절대 0 이 될 수 없는 경우가 있다. 바로 아래 그림처럼 서로가 서로를 참조하는 경우이다.

void set_other(std::shared_ptr<A> o) { other = o; }

int main() {
  std::shared_ptr<A> pa = std::make_shared<A>();
  std::shared_ptr<A> pb = std::make_shared<A>();

  pa->set_other(pb);
  pb->set_other(pa);
}

smart4

객체 1이 해제되기 위해서는 객체 1을 가리키고 있는 shared_ptr의 참조 갯수가 0이 되어야 한다. 즉, 객체 2가 소멸되어야 한다. 하지만 객체 2가 해제되기 위해서는 마찬가지로 객체 2를 가리키고 있는 shared_ptr의 참조 갯수가 이 되어야 하고, 그러기 위해서는 객체 1이 소멸되어야만 한다.

이 문제는 shared_ptr 자체에 내재되어 있는 구조적인 문제이기 때문에 shared_ptr만으로는 문제를 해결할 수 없다. 이러한 순환 잠조 문제를 해결하기 위해 등장한 것이 바로 weak_ptr이다.

smart5 1

예를 들어 위 그림과 같은 트리 구조를 지원하는 클래스를 만든다고 해보자. 즉, 한 개의 노드는 여러개의 자식 노드를 가질 수 있지만, 단 한 개의 부모 노드만을 가진다. 이러한 구조는 아래와 같이 구현될 것이다.

class Node {
  std::vector<std::shared_ptr<Node>> children;
  /* 어떤 타입이 와야할까? */ parent;

 public:
  Node(){};
  void AddChild(std::shared_ptr<Node> node) { children.push_back(node); }
};

이때 고민되는 것이 과연 parent 노트의 타입을 무엇으로 하느냐 이다.

  • 만약 일반 포인터(Node*) 로 하게 된다면, 메모리 해제를 제대로 하지 않을 경우, 혹은 예외가 발생한 경우 적절하게 자원을 해제하기 어렵다. 또한 이미 해제된 메모리를 계속 가리키고 있을 위험도 있다.

  • 그렇다고 shared_ptr로 하게 된다면 부모와 자식이 서로를 가리키기 때문에 앞서 본 순환 참조 문제가 발생한다.

weak_ptr은 일반 포인터와 shared_ptr 사이에 위치한 스마트 포인터로, 스마트 포인터 처럼 객체를 안전하게 잠조할 수 있게 해주지만, shared_ptr와는 다르게 참조 갯수를 늘리지는 않는다.

따라서 어떤 객체를 weak_ptr이 가리키고 있다 하더라도, 다른 shared_ptr들이 가리키고 있지 않다면 이미 메모리에서 소멸되었을 것이다.

이 떄문에 weak_ptr 자체로는 원래 객체를 참조할 수 없고, 반드시 shared_ptr로 변환해서 사용해야 한다. 만약 이미 소멸된 객체를 가리키고 있다면 빈 shared_ptr로 변환되고, 아닐 경우 해당 객체를 가리키는 shared_ptr로 변환된다.

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() {
    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();  // 접근 실패!
}

[!result] 자원을 획득함! 자원을 획득함! vec[0] ref count : 1 vec[1] ref count : 1 접근 : 자원 2 소멸자 호출! 이미 소멸됨 ㅠ 소멸자 호출!

먼저 weak_ptr을 정의하는 부분 보터 살펴보자

void set_other(std::weak_ptr<A> o) { other = o; }

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

weak_ptr은 생성자로 shared_ptr이나 다른 weak_ptr을 받는다. 또한 shared_ptr과는 다르게, 이미 제어 블록이 만들어진 객체만을 받을 수 있으므로 평범한 포인터 주소값으로는 weak_ptr을 생성할 수 없다.

그 다음으로 weak_ptrshared_ptr로 변환하는 과정을 살표보자.

void access_other() {
  std::shared_ptr<A> o = other.lock();
  if (o) {
    std::cout << "접근 : " << o->name() << std::endl;
  } else {
    std::cout << "이미 소멸됨 ㅠ" << std::endl;
  }
}

앞서 말했듯이 weak_ptr은 그 자체로는 원소를 참조할 수 없고, shraed_ptr로 변환해야 한다. 이 작업은 lock 함수를 통해 수행된다.

weak_ptr에 정의된 lock 함수는 만일 객체의 참조 갯수가 0이 아니라 살아있다면, 해당 객체를 가리키는 shared_ptr을 반환, 이미 해제된 객체라면 아무것도 가리키지 않는 shared_ptr을 반환한다.

std::shared_ptr<A> o = other.lock();
if (o) {
  std::cout << "접근 : " << o->name() << std::endl;
}

아무것도 가리키지 않는 shared_ptrif문 내에서 false로 형변환되므로 간단하게 확인할 수 있다.

앞서 제어 블록에는 몇개의 shared_ptr이 가리키고 있는지를 나타내는 차모 갯수가 있다고 했다. 그렇다면 참조 갯수가 0이 될 때 객체와 더불어 제어 블록까지 즉시 해제해야 할까?

아니다. 만약 shared_ptr이 가리키는 참조 갯수가 0개 이지만, 아직 weak_ptr이 남아있다고 해보자. 물론 이 상태에서는 이미 객체는 해제 되어있겠지만, 제어 블록마저 해재해 버린다면 weak_ptr은 해당 객체의 참조 갯수가 0이라는 사실을 알 수 없게 된다.

즉, 제어 블록을 메모리에서 해제하기 위해서는 이를 가리키는 weak_ptr 역시 0 개여야 한다. 따라서 제어 블록에는 참조 갯수와 더불어 약한 참조 갯수(weak count) 도 기록한다.



맨 위로 이동하기

Cpp 카테고리 내 다른 글 보러가기

댓글 남기기