스마트 포인터가 필요한 이유
C++에서 동적 메모리를 직접 관리하다 보면 예외 발생 시 delete를 놓쳐 메모리 누수가 생길 수 있습니다. 특히 복잡한 제어 흐름이나 예외 처리 과정에서는 자원 해제를 보장하기 어렵습니다. 이를 해결하기 위해 '자원 획득은 초기화다(Resource Acquisition Is Initialization, RAII)'라는 개념을 기반으로 한 스마트 포인터가 등장했습니다.
RAII: 자원 관리의 핵심 원칙
RAII는 객체의 수명 주기를 스택 기반 지역 변수에 의존함으로써 자원을 안전하게 관리하는 기법입니다. 생성자에서 자원을 할당하고, 소멸자에서 반드시 해제함으로써 예외 상황에서도 자원 누수를 방지할 수 있습니다.
template<typename T>
class StackPtr {
private:
T* data;
public:
explicit StackPtr(T* ptr) : data(ptr) {}
~StackPtr() {
if (data) {
std::cout << "자동 해제: " << data << std::endl;
delete data;
}
}
// 복사 금지를 위한 삭제 선언
StackPtr(const StackPtr&) = delete;
StackPtr& operator=(const StackPtr&) = delete;
};
포인터처럼 사용하기: 연산자 오버로딩
스마트 포인터는 일반 포인터와 유사하게 동작해야 하므로 역참조(*)와 멤버 접근(->) 연산자를 오버로딩해야 합니다.
T& operator*() const { return *data; }
T* operator->() const { return data; }
이를 통해 다음과 같이 자연스럽게 사용할 수 있습니다:
StackPtr<int> num(new int(42));
std::cout << *num; // 값 접근
복사와 공유 전략의 진화
1. auto_ptr (비권장)
오래된 C++ 표준에서 사용되던 방식으로, 복사 시 소유권을 이전합니다. 하지만 직관적이지 않아 현재는 폐기되었습니다.
2. unique_ptr: 독점 소유
하나의 스마트 포인터만이 자원을 소유하며, 복사는 불가능하지만 이동은 가능합니다. 기본적으로 모든 복사 생성자와 대입 연산자를 삭제합니다.
template<typename T>
class UniquePtr {
T* resource;
public:
UniquePtr(T* ptr = nullptr) : resource(ptr) {}
~UniquePtr() { delete resource; }
// 복사 금지
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
// 이동 허용
UniquePtr(UniquePtr&& other) noexcept : resource(other.resource) {
other.resource = nullptr;
}
UniquePtr& operator=(UniquePtr&& other) noexcept {
if (this != &other) {
delete resource;
resource = other.resource;
other.resource = nullptr;
}
return *this;
}
T* operator->() const { return resource; }
T& operator*() const { return *resource; }
};
3. shared_ptr: 참조 카운팅 기반 공유
여러 포인터가 같은 자원을 공유할 수 있으며, 내부적으로 참조 횟수를 세어 마지막 소유자가 사라질 때 자원을 해제합니다. 멀티스레드 환경에서는 원자성 보장을 위해 뮤텍스를 사용합니다.
template<typename T>
class SharedPtr {
T* obj;
int* refCount;
std::mutex* mtx;
void incRef() {
mtx->lock();
++(*refCount);
mtx->unlock();
}
void decRef() {
bool shouldDelete = false;
mtx->lock();
if (--(*refCount) == 0) shouldDelete = true;
mtx->unlock();
if (shouldDelete) {
delete obj;
delete refCount;
delete mtx;
}
}
public:
explicit SharedPtr(T* ptr = nullptr)
: obj(ptr), refCount(new int(1)), mtx(new std::mutex) {}
SharedPtr(const SharedPtr& other)
: obj(other.obj), refCount(other.refCount), mtx(other.mtx) {
incRef();
}
SharedPtr& operator=(const SharedPtr& other) {
if (obj != other.obj) {
decRef();
obj = other.obj;
refCount = other.refCount;
mtx = other.mtx;
incRef();
}
return *this;
}
~SharedPtr() { decRef(); }
T* operator->() const { return obj; }
T& operator*() const { return *obj; }
int use_count() const { return *refCount; }
};
순환 참조 문제와 해결책: weak_ptr
shared_ptr는 양방향 연결 구조(예: 이중 연결 리스트)에서 순환 참조로 인해 메모리 누수가 발생할 수 있습니다. 두 노드가 서로를 가리키면 참조 카운트가 0이 되지 않아 소멸되지 않습니다.
struct Node {
SharedPtr<Node> prev;
SharedPtr<Node> next;
}; // 순환 참조 → 메모리 누수!
이 문제를 해결하기 위해 소유권 없이 참조만 하는 weak_ptr를 사용합니다. 이는 참조 카운트를 증가시키지 않으므로 순환을 끊을 수 있습니다.
template<typename T>
class WeakPtr {
T* weakRef;
public:
WeakPtr() : weakRef(nullptr) {}
WeakPtr(const SharedPtr<T>& sp) : weakRef(sp.get()) {}
T* get() const { return weakRef; }
T& operator*() const { return *weakRef; }
T* operator->() const { return weakRef; }
};
// 수정된 노드 정의
struct Node {
WeakPtr<Node> prev;
SharedPtr<Node> next;
}; // 참조 카운트 문제 해결
커스텀 삭제기: 다양한 자원 해제 방식 지원
힙 메모리 외에도 파일 핸들, 배열, 소켓 등 다양한 자원을 관리해야 할 수 있습니다. 이를 위해 삭제 로직을 사용자가 지정할 수 있는 커스텀 삭제기를 제공합니다.
template<typename T, typename Deleter = std::function<void(T*)>>
class SharedPtrWithDeleter {
T* resource;
int* count;
Deleter del;
public:
template<typename D>
SharedPtrWithDeleter(T* ptr, D d)
: resource(ptr), count(new int(1)), del(d) {}
~SharedPtrWithDeleter() {
if (--(*count) == 0) {
del(resource);
delete count;
}
}
// 나머지 구현은 동일...
};
실제 사용 예시:
// 배열 해제
SharedPtrWithDeleter<int[]> arr(new int[100], [](int* p) {
delete[] p;
});
// 파일 닫기
SharedPtrWithDeleter<FILE> file(fopen("data.txt", "r"), [](FILE* f) {
if (f) fclose(f);
});
// 네트워크 소켓 (가정)
SharedPtrWithDeleter<SOCKET> sock(socket(AF_INET, SOCK_STREAM, 0), closesocket);