C++에서 메모리 누수는 오랫동안 개발자를 괴롭혀 온 핵심 문제입니다. 특히 예외 상황에서 수동으로 관리하는 동적 메모리는 해제 로직이 실행되지 않아 누수가 발생하기 쉽습니다. 스마트 포인터는 RAII(Resource Acquisition Is Initialization) 사상을 대표적으로 구현한 것으로, 객체의 생명주기를 통해 자원을 자동으로 관리하여 이 문제를 완전히 해결합니다. 이 글에서는 사용 사례부터 시작하여 스마트 포인터의 설계 원리, 표준 라이브러리 구현 세부 사항을 심층 분석하고, 직접 실행 가능한 사용자 정의 구현 코드를 함께 제공합니다.
1. 왜 스마트 포인터가 필요한가? — 예외 상황에서의 메모리 누수 문제
먼저 전형적인 안티 패턴을 살펴보겠습니다. 코드에 예외가 발생할 때 수동으로 메모리를 해제하는 로직이 실패하여 메모리 누수가 발생할 수 있습니다.
double Divide(int a, int b) {
if (b == 0)
throw "Divide by zero condition!"; // 0으로 나누기 예외 발생
return (double)a / (double)b;
}
void Func() {
int* arr1 = new int[10];
int* arr2 = new int[10]; // 여기서 예외 발생 시 arr1을 해제할 수 없음
try {
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (...) {
delete[] arr1;
delete[] arr2;
throw;
}
delete[] arr1;
delete[] arr2;
}
위 코드에는 두 가지 치명적인 문제가 있습니다.
arr2의new연산이 예외를 던지면, 이미 할당된arr1의 메모리를 해제할 수 없습니다.- 예외 처리 로직이 중복되며, 여러
new를 사용할 때마다try-catch를 중첩해야 합니다.
스마트 포인터는 이러한 문제를 완벽하게 해결합니다. 자원 관리를 객체의 생명주기에 바인딩하여 정상 실행이든 예외 종료이든 객체가 소멸될 때 자원이 자동으로 해제됩니다.
2. 스마트 포인터의 핵심 설계 사상: RAII
RAII는 스마트 포인터의 핵심으로, 그 로직은 다음과 같습니다.
- 자원 획득: 스마트 포인터 객체가 생성될 때 동적 메모리, 파일 핸들 등 자원을 획득합니다.
- 자원 보유: 스마트 포인터 객체의 생명주기 동안 자원이 유효하며 유일하게 관리됩니다.
- 자원 해제: 스마트 포인터 객체가 소멸될 때 보유한 자원을 자동으로 해제합니다.
스마트 포인터가 일반 포인터처럼 사용되도록 operator*, operator-> 등을 오버로딩하여 포인터의 동작을 모방합니다.
3. C++ 표준 라이브러리 스마트 포인터 상세
C++ 표준 라이브러리(<memory>)는 4가지 스마트 포인터를 제공하며, 각각 다른 시나리오에 적합합니다. 주요 특성 비교는 다음과 같습니다.
| 스마트 포인터 | 핵심 특성 | 적용 시나리오 | 주의사항 |
|---|---|---|---|
| auto_ptr | 복사 시 자원 관리권 이전 | 사용 중단(C++11 이후 권장되지 않음) | 복사된 객체가 비어 있어 크래시 유발 |
| unique_ptr | 독점 소유권, 복사 불가 | 자원 공유 불필요 시 | 이동 의미론(std::move) 지원 |
| shared_ptr | 공유 소유권, 복사 가능 | 여러 객체가 자원 공유 필요 시 | 참조 카운트 기반, 순환 참조 주의 |
| weak_ptr | 소유권 없음, 관찰만 | shared_ptr 순환 참조 해결 | 직접 접근 불가, lock()으로 변환 필요 |
3.1 사용자 정의 스마트 포인터 구현 (상세 주석 포함)
다음은 auto_ptr, unique_ptr, shared_ptr, weak_ptr을 포함한 완전한 사용자 정의 구현 코드입니다. 모든 핵심 로직에 주석이 추가되어 있습니다.
1. auto_ptr 구현
#pragma once
#include <iostream>
using namespace std;
namespace my {
template<class T>
class auto_ptr {
public:
// 생성자: 자원 획득 (RAII 첫 단계)
auto_ptr(T* ptr) : m_ptr(ptr) {}
// 복사 생성자: 자원 관리권 이전 (auto_ptr의 핵심 결함)
auto_ptr(auto_ptr<T>& sp) {
m_ptr = sp.m_ptr;
sp.m_ptr = nullptr; // 원본 객체를 비움 -> "허상 포인터"
}
// 대입 연산자: 현재 자원 해제 후 대상 자원 이전
auto_ptr<T>& operator=(auto_ptr<T>& ap) {
if (this != &ap) {
if (m_ptr) delete m_ptr; // 현재 자원 해제 (배열 미지원 주의)
m_ptr = ap.m_ptr;
ap.m_ptr = nullptr;
}
return *this;
}
// 소멸자: 자동 자원 해제 (RAII 세 번째 단계)
~auto_ptr() {
if (m_ptr) {
cout << "delete: " << m_ptr << endl;
delete m_ptr;
}
}
T& operator*() { return *m_ptr; }
T* operator->() { return m_ptr; }
private:
T* m_ptr; // 관리하는 자원을 가리킴
};
}
2. unique_ptr 구현
#pragma once
#include <iostream>
using namespace std;
namespace my {
template<class T>
class unique_ptr {
public:
explicit unique_ptr(T* ptr = nullptr) : m_ptr(ptr) {}
// 복사 생성자와 대입 연산자를 삭제하여 복사 금지
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
// 이동 생성자: 자원 소유권 이전
unique_ptr(unique_ptr&& sp) noexcept : m_ptr(sp.m_ptr) {
sp.m_ptr = nullptr;
}
// 이동 대입 연산자
unique_ptr& operator=(unique_ptr&& sp) noexcept {
if (this != &sp) {
delete m_ptr; // 현재 자원 해제
m_ptr = sp.m_ptr;
sp.m_ptr = nullptr;
}
return *this;
}
~unique_ptr() {
if (m_ptr) {
cout << "delete: " << m_ptr << endl;
delete m_ptr;
}
}
T& operator*() { return *m_ptr; }
T* operator->() { return m_ptr; }
private:
T* m_ptr;
};
}
3. shared_ptr 구현
#pragma once
#include <iostream>
#include <functional>
using namespace std;
namespace my {
template<class T>
class shared_ptr {
public:
// 자원 해제 핵심 로직: 참조 카운트가 0이 되면 자원 파괴
void cleanup() {
if (m_refCount && --(*m_refCount) == 0) {
m_deleter(m_ptr); // 사용자 정의 삭제자 호출
delete m_refCount; // 참조 카운트 메모리 해제
m_ptr = nullptr;
m_refCount = nullptr;
}
}
explicit shared_ptr(T* ptr = nullptr)
: m_ptr(ptr), m_refCount(ptr ? new int(1) : nullptr) {}
// 삭제자를 지원하는 생성자
template<class D>
explicit shared_ptr(T* ptr, D del)
: m_ptr(ptr), m_refCount(ptr ? new int(1) : nullptr), m_deleter(del) {}
// 복사 생성자: 참조 카운트 증가
shared_ptr(const shared_ptr& sp)
: m_ptr(sp.m_ptr), m_refCount(sp.m_refCount), m_deleter(sp.m_deleter) {
if (m_refCount) ++(*m_refCount);
}
// 이동 생성자: 자원 소유권 이전, 참조 카운트 불변
shared_ptr(shared_ptr&& sp) noexcept
: m_ptr(sp.m_ptr), m_refCount(sp.m_refCount), m_deleter(move(sp.m_deleter)) {
sp.m_ptr = nullptr;
sp.m_refCount = nullptr;
}
// 복사 대입 연산자
shared_ptr& operator=(const shared_ptr& sp) {
if (this != &sp) {
cleanup(); // 현재 자원 해제 (가능한 파괴)
m_ptr = sp.m_ptr;
m_refCount = sp.m_refCount;
m_deleter = sp.m_deleter;
if (m_refCount) ++(*m_refCount);
}
return *this;
}
// 이동 대입 연산자
shared_ptr& operator=(shared_ptr&& sp) noexcept {
if (this != &sp) {
cleanup();
m_ptr = sp.m_ptr;
m_refCount = sp.m_refCount;
m_deleter = move(sp.m_deleter);
sp.m_ptr = nullptr;
sp.m_refCount = nullptr;
}
return *this;
}
~shared_ptr() { cleanup(); }
T* get() const { return m_ptr; }
int use_count() const { return m_refCount ? *m_refCount : 0; }
T& operator*() { return *m_ptr; }
T* operator->() { return m_ptr; }
private:
T* m_ptr;
int* m_refCount; // 힙에 할당된 참조 카운트 (여러 객체 공유 가능)
// 기본 삭제자: 사용자 정의 삭제 로직 지원 (배열, 파일 핸들 등)
function<void(T*)> m_deleter = [](T* ptr) { delete ptr; };
};
}
4. weak_ptr 구현
#pragma once
#include "shared_ptr.h" // shared_ptr에 의존
template<class T>
class weak_ptr {
public:
weak_ptr() = default;
// shared_ptr로부터 생성: 참조 카운트 증가하지 않음
weak_ptr(const shared_ptr<T>& sp) : m_ptr(sp.get()) {}
weak_ptr<T>& operator=(const shared_ptr<T>& sp) {
m_ptr = sp.get();
return *this;
}
bool expired() const { return m_ptr == nullptr; }
// 자원에 안전하게 접근하기 위해 shared_ptr로 변환
shared_ptr<T> lock() const {
return shared_ptr<T>(m_ptr);
}
private:
T* m_ptr = nullptr; // 소유권 없이 관찰만 함
};
3.2 핵심 기능 실전 예제
1. 사용자 정의 삭제자 (배열/파일 핸들 처리)
shared_ptr와 unique_ptr는 기본 delete로 처리할 수 없는 시나리오(배열, 파일 핸들 등)를 위해 사용자 정의 삭제자를 지원합니다.
// 배열 삭제자 (함수 객체)
class DeleteArray {
public:
template<class T>
void operator()(T* ptr) const { delete[] ptr; }
};
// 파일 핸들 삭제자
struct FileCloser {
void operator()(FILE* ptr) const {
cout << "fclose: " << ptr << endl;
fclose(ptr);
}
};
// 사용 예시
int main() {
// 배열 관리 (함수 객체 삭제자)
my::shared_ptr<int> sp1(new int[10], DeleteArray{});
// 파일 핸들 관리 (람다 삭제자)
my::shared_ptr<FILE> sp2(fopen("test.txt", "r"), [](FILE* p) { fclose(p); });
return 0;
}
2. shared_ptr 순환 참조 해결
두 shared_ptr가 서로를 참조하면 참조 카운트가 0이 되지 않아 메모리 누수가 발생합니다. weak_ptr은 참조 카운트를 증가시키지 않으므로 이 문제를 완벽히 해결합니다.
struct ListNode {
int data;
my::weak_ptr<ListNode> next; // shared_ptr 대신 weak_ptr 사용
my::weak_ptr<ListNode> prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main() {
my::shared_ptr<ListNode> n1(new ListNode);
my::shared_ptr<ListNode> n2(new ListNode);
cout << n1.use_count() << endl; // 1
cout << n2.use_count() << endl; // 1
n1->next = n2; // weak_ptr 대입, n2의 참조 카운트 변화 없음
n2->prev = n1; // weak_ptr 대입, n1의 참조 카운트 변화 없음
cout << n1.use_count() << endl; // 1
cout << n2.use_count() << endl; // 1
// 소멸 시 참조 카운트가 0이 되어 정상적으로 자원 해제
return 0;
}