C++ 스마트 포인터 완벽 분석: 원리, 구현 및 실제 사용 시 주의점

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

위 코드에는 두 가지 치명적인 문제가 있습니다.

  1. arr2new 연산이 예외를 던지면, 이미 할당된 arr1의 메모리를 해제할 수 없습니다.
  2. 예외 처리 로직이 중복되며, 여러 new를 사용할 때마다 try-catch를 중첩해야 합니다.

스마트 포인터는 이러한 문제를 완벽하게 해결합니다. 자원 관리를 객체의 생명주기에 바인딩하여 정상 실행이든 예외 종료이든 객체가 소멸될 때 자원이 자동으로 해제됩니다.

2. 스마트 포인터의 핵심 설계 사상: RAII

RAII는 스마트 포인터의 핵심으로, 그 로직은 다음과 같습니다.

  1. 자원 획득: 스마트 포인터 객체가 생성될 때 동적 메모리, 파일 핸들 등 자원을 획득합니다.
  2. 자원 보유: 스마트 포인터 객체의 생명주기 동안 자원이 유효하며 유일하게 관리됩니다.
  3. 자원 해제: 스마트 포인터 객체가 소멸될 때 보유한 자원을 자동으로 해제합니다.

스마트 포인터가 일반 포인터처럼 사용되도록 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_ptrunique_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;
}

태그: C++ 스마트 포인터 RAII shared_ptr unique_ptr

9월 12일 18:18에 게시됨