unreal 5기

250912 언리얼엔진 본캠프 29일차 스마트 포인터

parkjinnam 2025. 9. 12. 16:24

스마트 포인터(Smart Pointer)

 우리는 지난 시간에 RAII에 대해서 공부했었다. RAII는 코드 습관을 말할 수도 있지만 C++에서는 RAII를 자체적인 기능으로서 구현한 기능들이 존재한다. 그 대표적인 예시가 스마트 포인터(Smart Pointer)이다. 스마트 포인터는 3가지로 구성되어 있는데

유니크 포인터(Unique Pointer), 셰어드 포인터(Shared Pointer), 위크 포인터(Weak Pointer)이다. 스마트 포인터는 기본적으로는 기존의 포인터와 동일한 동작을 수행하지만 해당 포인터를 소유한 객체가 소멸하면 자동으로 할당받은 메모리를 해제하여 메모리 누수를 방지한다. 이러한 특징 때문에 스마트 포인터를 사용하면 프로그래머가 직접 메모리를 관리하는 수고를 덜어준다. 또한 실수로 인한 메모리 누수를 미연에 방지해 주기에 C++에서 가능하면 스마트 포인터를 사용하는 것이 권장된다. 그렇다면 이러한 스마트 포인터에 속한 각 포인터의 특징에 대해서 알아보자.

 

유니크 포인터(Unique Pointer)

 

유니크 포인터는 이름을 보면 알 수 있지만 '유일한' 포인터이다. 즉 반드시 하나의 객체만이 가지고 있을 수 있는 포인터이며 동일한 메모리를 가리키는 두 개의 unique_ptr 인스턴스가 동시에 존재할 수 없다. 예컨대

std::unique_ptr<int> ptr1(new int (5));

std::unique_ptr<int> ptr2=ptr1;

 

이런 식으로 이미 존재하는 유니크 포인터 ptr1에 유니크 포인터 ptr2를 만들어 ptr1을 지정하려 하면 컴파일러는 에러를 일으킨다. 만약 ptr1의 소유권을 ptr2로 옮기고 싶다면 std::move 함수를 이용해야 한다.

using namespace std;

unique_ptr<int> ptr1(new int (5);

unique_ptr<int> ptr2 = move(ptr1);

 

이렇게 되면 ptr1은 이제 null이 되고 ptr2가 ptr1이 원래 가리키던 메모리를 가리키게 된다. 이러한 특성 때문에 유니크 포인터는 자원의 유일한 소유자임을 보장하며 메모리 관리에 있어서 더욱 안전해지게 된다. 더군다나 객체가 소멸하면 유니크 포인터도 자동으로 소멸하게 된다.

 

셰어드 포인터(Shared Pointer)

 

std::shared_ptr는 유니크 포인터와는 다르게 여러 객체가 동일한 리소스를 공유할 수 있게 해 준다. 즉 여러 개의 셰어드 포인터 인스턴스가 동일한 메모리를 가리킬 수 있음을 의미한다. 셰어드 포인터는 내부적으로 레퍼런스 카운팅을 수행하며 메모리를 가리키는 셰어드 포인터 인스턴스 수를 추적한다.

usning namespace std;

shared_ptr<int> ptr1(new int(5));

shared_ptr<Int> ptr2=ptr1;

 

아까의 unique_ptr과는 다르게 ptr1과 ptr2 모두 동일한 메모리를 가리키며 레퍼런스 카운터는 2가 된다. 이 레퍼런스 카운터가 0이 되면 shared_ptr은 즉시 메모리를 해제한다. 분명 shared_ptr은 동일한 메모리를 여러 객체가 안전하게 공유하게 해주는 장점이 있지만 지나치게 사용한다면 불필요한 레퍼런스 카운팅에 의해 성능이 저하될 수 있다. 또한 shared_ptr은 순환참조를 일으킬 수 있는데 이를 해결해 주는 것이 weak_ptr이다.

 

위크 포인터(Weak Pointer)

std::weak_ptr은 std::shared_ptr의 중요한 동반자이다. 스마트 포인터를 사용하면서 발생할 수 있는 순환참조에 대처하는데 도움을 준다. 순환참조는 두 객체가 서로를 참조하고 둘 다 shared_ptr을 사용하여 참조를 유지하는 경우에 발생한다. 이 경우 앞서 설명한 레퍼런스 카운터가 절대 0이 되지 않아 메모리가 해제되지 않고 누수가 발생한다.

using namespace std;

struct B;
struct A
{
	shared_ptr<B> b_ptr;
};

struct B
{
	shared_ptr<A> a_ptr;
};

shared_ptr<A> a(new A());

shared_ptr<B> b(new B());

a->b_ptr = b;

b->a_ptr = a; // 순환 참조 발생

 

위와 같은 코드의 경우 A와 B는 서로를 멤버로 갖고 있는 상황이다. 그 상황에서 a는 A를 가리키는, b는 B를 가리키는 shared_ptr이다. 이 상황에서 각 A와 B는 레퍼런스 카운트 1이 되게 된다. 이제 a가 가리키는 A의 b_ptr에 b를 대입했고 b가 가리키는 B의 a_ptr에 a를 대입했다. 이로 인해 A와 B는 각각 참조 카운트가 2가 된다.(A의 경우 b와 b->a_ptr로 인해) 이로 인해 다음과 같은 순환 참조가 발생한다.

a (shared_ptr) -> A 객체 -> b_ptr( shared_ptr) -> B 객체 -> a_ptr(shared_ptr) -> A 객체

 

이 처럼 A와 B가 서로를 shared_ptr로 소유하고 있는 상황이 발생하여 레퍼런스 카운트가 절대 0이 되지 않으면서 메모리 누수가 발생하게 된다. 이 상황에서 weak_ptr을 이용해서 다음과 같이 코드를 작성해 주면 순환참조가 발생하지 않는다.

using namespace std;

struct B;
struct A
{
	shared_ptr<B> b_ptr;
};

struct B
{
	weak_ptr<A> a_ptr;
};

shared_ptr<A> a(new A());

shared_ptr<B> b(new B());

a->b_ptr = b;

b->a_ptr = a;

 

이 상황에서는 B는 shared_ptr이 아닌 weak_ptr을 사용하여 A를 참조하기에 순환 참조가 발생하지 않는다. A가 파괴되면 weak_ptr 또한 nullptr로 설정되어 순환참조를 방지하게 된다.

또한 weak_ptr은 lock() 함수를 이용하여 shared_ptr로 변환할 수 있다. 이 함수는 해당 객체가 여전히 존재하는 경우에만 shared_ptr을 반환하므로 메모리를 안전하게 접근할 수 있다.