C++에서 new/delete 키워드를 사용해서 동적할당을 하면, Dangling Pointer나 Memory Leak등 자원 관리가 안되는 경우를 볼 수 있다.
이러한 자원 관리 문제를 해결하기 위해 포인터가 범위(Scope)를 벗어나면 할당한 메모리를 해제하도록 하는 것이 스마트 포인터이다.
스마트 포인터에는 '소유권(ownership)'이라는 키워드가 핵심이며, 이는 하나의 객체를 누가 소유를 할 것인지가 주요 내용이다.
스마트 포인터에는 3가지의 종류가 있다.
unique_ptr: 해당 포인터가 가리키는 메모리는 다른 포인터가 가리킬 수 없다.

shared_ptr: 해당 포인터가 가리키는 메모리는 다른 포인터와 공동 소유할 수 있다. (Shared Ownership)

weak_ptr: 다른 포인터와 달리 소유권을 가지고 있지 않고 관찰을 한다.

unique_ptr을 사용하는 예제는 다음과 같다.
#include <iostream>
#include <memory>
class Data {
public:
int value;
Data(int v) : value(v) { std::cout << "Data(" << value << ") 할당\n"; }
~Data() { std::cout << "Data(" << value << ") 해제\n"; }
};
int main() {
std::cout << "--- 1. unique_ptr ---\n";
// 단독 소유 (복사 불가)
std::unique_ptr<Data> uPtr = std::make_unique<Data>(10);
std::cout << "값: " << uPtr->value << "\n\n";
// 값을 복사는 못하지만 이동은 가능
// 예) std::unique_ptr<Data> uPtr = std::move(uPtr);
// 이때, uPtr은 nullptr이니 주의!
std::cout << "--- 2. shared_ptr ---\n";
// 공동 소유 (참조 횟수 카운팅)
std::shared_ptr<Data> sPtr1 = std::make_shared<Data>(20);
std::cout << "초기 참조 횟수: " << sPtr1.use_count() << "\n";
{
std::shared_ptr<Data> sPtr2 = sPtr1; // 소유권 공유
std::cout << "sPtr2 추가 후 참조 횟수: " << sPtr1.use_count() << "\n";
} // 블록이 끝나면 sPtr2가 소멸하며 카운트 감소
std::cout << "블록 종료 후 참조 횟수: " << sPtr1.use_count() << "\n\n";
std::cout << "--- 3. weak_ptr ---\n";
// 약한 참조 (참조 횟수 증가 안 함)
std::weak_ptr<Data> wPtr = sPtr1;
std::cout << "weak_ptr 추가 후 참조 횟수: " << sPtr1.use_count() << " (변화 없음)\n";
// weak_ptr는 직접 접근 불가. lock()으로 유효성 검사 후 임시 shared_ptr로 변환하여 사용(이때 잠시 참조 횟수가 증가, scope 끝나면 감소)
if (std::shared_ptr<Data> tempPtr = wPtr.lock()) {
std::cout << "접근 성공, 값: " << tempPtr->value << "\n";
}
std::cout << "\n--- 프로그램 종료 ---\n";
return 0;
}
결과
--- 1. unique_ptr --- Data(10) 할당 값: 10 --- 2. shared_ptr --- Data(20) 할당 초기 참조 횟수: 1 sPtr2 추가 후 참조 횟수: 2 블록 종료 후 참조 횟수: 1 --- 3. weak_ptr --- weak_ptr 추가 후 참조 횟수: 1 (변화 없음) 접근 성공, 값: 20 --- 프로그램 종료 --- Data(20) 해제 Data(10) 해제
A 객체의 shared_ptr이 B 객체를 가리키고, B 객체의 shared_ptr가 A 객체를 가리키면서 참조 카운트가 계속 유지되어 메모리 누수가 발생하는 현상이다.
이 부분은 단순히 한쪽 포인터를 weak_ptr로 지정하면서, shared_ptr이 사라지면 참조 카운트도 0이 되므로 자연스럽게 해결이 된다.
예시는 다음과 같다.
#include <iostream>
#include <memory>
class B; // 전방 선언
class A {
public:
std::shared_ptr<B> b_ptr;
A() { std::cout << "A 생성\n"; }
~A() { std::cout << "A 해제\n"; }
};
class B {
public:
// B도 A를 shared_ptr로 가지면 순환 참조가 발생하여 영원히 해제되지 않음
// std::shared_ptr<A> a_ptr;
// 한쪽을 weak_ptr로 만들어 참조 횟수를 올리지 않고 가리킴
std::weak_ptr<A> a_ptr;
B() { std::cout << "B 생성\n"; }
~B() { std::cout << "B 해제\n"; }
};
int main() {
std::cout << "--- 블록 시작 ---\n";
{
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b; // A가 B를 참조 (B의 카운트 증가)
b->a_ptr = a; // B가 A를 참조 (weak_ptr이므로 A의 카운트 증가 안 함!)
}
// 블록이 끝나는 순간:
// a가 소멸되면서 참조 횟수가 0이 되어 A 메모리 정상 해제.
// A가 해제되면서 내부에 있던 b_ptr도 사라져 B의 참조 횟수도 0이 됨. B 메모리 정상 해제.
std::cout << "--- 블록 종료 ---\n";
return 0;
}
정확히 말하자면, 스마트 포인터는 원시 포인터를 감싼 형태(Wrapper)이기 때문에 바로 대입을 하려고 하면 컴파일 에러가 난다.
이를 해결하려면, 스마트 포인터에서 get()을 통해 원시 포인터를 도출해내야 한다.
// 클래스 및 복사 생성자 부분은 생략
int main() {
std::unique_ptr<int> originSmartPtr = std::make_unique<int>(1024);
// DataContainer container = originSmartPtr; (x)
DataContainer container = originSmartPtr.get();
return 0;
}
shared_ptr은 참조 카운트라는 속성을 저장해야 하고 순환 참조를 일으킬 수 있으므로, 공동 소유를 해야하는 경우가 아니면 unique_ptr을 우선으로 사용하는 것이 좋다.
스마트 포인터 자체가 소유 키워드를 기반으로 탄생한 문법이기도 하고 1번 처럼 원시 포인터와 호환되지 않기 때문에 특정 목적이 있는 것이 아닌 이상, 값을 전달하는 것과 같은 단순한 작업은 스마트 포인터를 쓰지 않는 것이 좋다.
아래는 이에 대한 예시이다.
#include <iostream>
#include <memory>
#include <string>
class Widget {
public:
std::string name;
Widget(std::string n) : name(n) {}
void use() {
std::cout << "" << name << " 사용 중입니다!\n";
}
};
void bad_func(std::shared_ptr<Widget>& w) {
std::cout << "bad_func 호출 -> ";
w->use();
}
void good_func(Widget& w) {
std::cout << "good_func 호출 -> ";
w.use();
}
int main() {
Widget stack_widget("위젯1");
// bad_func(stack_widget); // 컴파일 에러! (shared_ptr가 아니기 때문)
good_func(stack_widget);
std::shared_ptr<Widget> shared_widget = std::make_shared<Widget>("위젯2");
bad_func(shared_widget); // 작동은 하지만, 함수 설계가 나쁨
good_func(*shared_widget);
return 0;
}
결과
good_func 호출 -> 위젯1 사용 중입니다! bad_func 호출 -> 위젯2 사용 중입니다! good_func 호출 -> 위젯2 사용 중입니다!
보통 대학 C++ 과정에서는 나가야 할 진도가 너무 많아서 보통 스마트 포인터까지 오는 경우가 드물다.
또한, C++을 이용해서 언리얼 엔진 코드를 작성하면 TSharedPtr과 같이 언리얼에서 구현한 스마트 포인터도 종종 보게 되므로 중요한 부분이기도 하다.
나도 스마트 포인터가 '메모리 자동 해제'를 해주는 것만 알고 있었지만, 이번 기회에 확실하게 되짚고 갈 수 있었던 것 같다.
참고 자료
- learncpp(Chapter 22)
https://www.learncpp.com/cpp-tutorial/stdunique_ptr/- C++ 핵심 가이드 라인(Resource)
https://cppkorea.org/CppCoreGuidelines/Resource/#rsmart