본문으로 건너뛰기 C++ RAII & Smart Pointers | '스마트 포인터' 가이드

C++ RAII & Smart Pointers | '스마트 포인터' 가이드

C++ RAII & Smart Pointers | '스마트 포인터' 가이드

이 글의 핵심

C++ RAII & Smart Pointers - "스마트 포인터" 가이드. C++ RAII & Smart Pointers의 RAII와 스마트 포인터란?, unique_ptr, shared_ptr를 실전 코드와 함께 설명합니다.

RAII와 스마트 포인터란?

RAII (Resource Acquisition Is Initialization)자원 획득은 초기화라는 C++ 핵심 원칙으로, 객체 생성 시 자원을 획득하고 소멸 시 자동 해제합니다. 스마트 포인터는 RAII를 구현한 대표적인 예입니다.

C에서는 malloc/freefopen/fclose처럼 자원을 획득하는 함수와 해제하는 함수가 분리되어 있어, 그 사이 어딘가에서 return이나 예외로 흐름이 빠져나가면 해제 코드가 실행되지 않고 자원이 새 나갑니다. RAII는 이 문제를 “자원의 생명주기를 객체의 생명주기에 묶는” 방식으로 해결합니다. 객체가 생성될 때 자원을 획득하고, 객체가 스코프를 벗어나 소멸자가 호출될 때 자동으로 자원을 해제하도록 설계하면, 정상 종료든 예외로 인한 스택 되감기든 상관없이 항상 해제가 보장됩니다. std::unique_ptr, std::shared_ptr 같은 스마트 포인터는 바로 이 RAII 원칙을 동적 할당 메모리에 적용한 것입니다.

// ❌ 수동 관리
int* ptr = new int(10);
// ... 예외 발생 시 누수
delete ptr;

// ✅ 스마트 포인터
auto ptr = std::make_unique<int>(10);
// 자동 소멸

왜 필요한가?:

  • 자동 해제: 소멸자에서 자동 정리
  • 예외 안전: 예외 발생 시에도 안전
  • 메모리 누수 방지: delete 누락 방지
  • 소유권 명확: 누가 자원을 소유하는지 명확

예외 안전성 측면에서 RAII의 위력이 가장 잘 드러납니다. 아래 코드에서 수동 관리 버전은 error 조건에서 예외를 던지면 그 위에 있는 delete ptr 줄이 실행되지 못하고 건너뛰어져 메모리가 그대로 새어 나갑니다. 반면 스마트 포인터 버전은 예외가 스택을 되감으며 전파되는 과정에서 ptr이라는 지역 변수의 소멸자가 자동으로 호출되기 때문에, 어떤 경로로 함수를 빠져나가든 자원이 항상 해제된다는 것을 컴파일러가 보장해줍니다.

// ❌ 수동 관리: 예외 시 누수
void func() {
    int* ptr = new int(10);
    
    if (error) {
        throw std::runtime_error("에러");  // 누수!
    }
    
    delete ptr;
}

// ✅ RAII: 예외 안전
void func() {
    auto ptr = std::make_unique<int>(10);
    
    if (error) {
        throw std::runtime_error("에러");  // 자동 해제
    }
}

RAII 동작 원리:

아래 다이어그램은 예외가 발생했을 때와 정상적으로 실행이 끝났을 때 모두, 결국 “소멸자 호출 → 자원 해제”라는 동일한 경로로 수렴한다는 것을 보여줍니다. C++ 런타임은 예외가 스택을 되감는(unwinding) 과정에서 그 스택 프레임에 있던 모든 지역 객체의 소멸자를 순서대로 호출해주기 때문에, 개발자가 각 예외 지점마다 수동으로 정리 코드를 작성할 필요가 없습니다.

flowchart LR
    A[객체 생성] --> B[자원 획득]
    B --> C[사용]
    C --> D[예외 발생?]
    D -->|Yes| E[스택 되감기]
    D -->|No| F[정상 종료]
    E --> G[소멸자 호출]
    F --> G
    G --> H[자원 해제]

스마트 포인터 종류:

타입소유권복사이동사용 시나리오
unique_ptr독점단일 소유자
shared_ptr공유여러 소유자
weak_ptr없음순환 참조 방지

세 스마트 포인터는 소유권 모델이 근본적으로 다릅니다. unique_ptr은 자원을 오직 하나의 포인터만 소유할 수 있도록 강제하기 때문에 복사 생성자와 복사 대입 연산자가 아예 삭제되어 있고, 소유권을 넘기려면 반드시 std::move로 명시적으로 이동해야 합니다. shared_ptr은 참조 카운트를 이용해 여러 포인터가 하나의 자원을 동시에 소유할 수 있게 하며, 마지막 shared_ptr이 소멸할 때 비로소 자원이 해제됩니다. weak_ptr은 참조 카운트에 영향을 주지 않으면서 shared_ptr이 관리하는 자원을 “관찰”만 하는 용도로, 자원이 아직 살아있는지는 lock()을 호출해 확인해야 합니다.

// unique_ptr: 독점 소유권
std::unique_ptr<int> u = std::make_unique<int>(10);
// auto u2 = u;  // 에러: 복사 불가
auto u2 = std::move(u);  // OK: 이동

// shared_ptr: 공유 소유권
std::shared_ptr<int> s = std::make_shared<int>(10);
auto s2 = s;  // OK: 복사 (참조 카운트 증가)

// weak_ptr: 관찰만
std::weak_ptr<int> w = s;
if (auto ptr = w.lock()) {  // 사용 시 lock
    std::cout << *ptr << '\n';
}

unique_ptr

unique_ptr은 오버헤드가 사실상 없는(raw pointer와 크기가 같은) 스마트 포인터로, 자원의 소유자가 명확히 하나로 정해지는 대부분의 상황에서 기본 선택지가 되어야 합니다. 복사는 컴파일 타임에 막혀 있으므로 실수로 같은 자원을 두 곳에서 소유하려는 시도는 애초에 컴파일이 되지 않고, 소유권을 다른 곳으로 넘기고 싶을 때만 std::move로 명시적인 이동을 수행합니다. 이동이 끝난 원본 ptr은 더 이상 자원을 가리키지 않는 nullptr 상태가 됩니다.

#include <memory>

// 독점 소유권
std::unique_ptr<int> ptr = std::make_unique<int>(10);

// 이동만 가능
auto ptr2 = std::move(ptr);  // ptr은 nullptr

shared_ptr

shared_ptr은 내부적으로 원자적 정수로 관리되는 참조 카운트를 두어, 자원을 가리키는 shared_ptr 인스턴스가 몇 개나 남아있는지 추적합니다. 복사할 때마다 카운트가 증가하고, shared_ptr이 스코프를 벗어나 소멸할 때마다 카운트가 감소하며, 카운트가 0이 되는 순간 실제 자원이 해제됩니다. 이 원자적 연산 때문에 shared_ptrunique_ptr보다 약간의 런타임 오버헤드가 있지만, 여러 곳에서 자원의 수명을 함께 책임져야 하는 상황(캐시, 그래프 자료구조 등)에서는 이 비용을 감수할 가치가 있습니다.

// 공유 소유권
std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
std::shared_ptr<int> ptr2 = ptr1;  // 참조 카운트 증가

// 마지막 shared_ptr 소멸 시 자원 해제

실전 예시

아래 네 가지 예시는 기본 사용법, 참조 카운트 동작, 컨테이너 통합, 다형성 팩토리 패턴이라는 스마트 포인터의 핵심 활용 상황을 보여줍니다.

예시 1: unique_ptr 기본

func 함수가 끝나는 시점에 ptr이라는 지역 변수가 스코프를 벗어나면서 Resource의 소멸자가 자동으로 호출됩니다. delete를 명시적으로 호출하는 코드가 어디에도 없다는 점에 주목해야 합니다. 이것이 RAII의 핵심으로, 자원 해제 시점을 프로그래머가 일일이 챙기는 대신 언어의 스코프 규칙에 맡기는 것입니다.

class Resource {
public:
    Resource() { std::cout << "생성" << std::endl; }
    ~Resource() { std::cout << "소멸" << std::endl; }
    
    void use() { std::cout << "사용" << std::endl; }
};

void func() {
    auto ptr = std::make_unique<Resource>();
    ptr->use();
    // 함수 종료 시 자동 소멸
}

int main() {
    func();
    // "생성" -> "사용" -> "소멸"
}

예시 2: shared_ptr 참조 카운트

use_count()는 현재 해당 자원을 가리키고 있는 shared_ptr이 몇 개인지 실시간으로 알려주는 디버깅용 메서드입니다. 이 예제는 내부 스코프에서 ptr2를 만들 때 카운트가 2로 늘어났다가, 그 스코프를 벗어나 ptr2가 소멸하는 순간 다시 1로 줄어드는 것을 보여줍니다. 이렇게 참조 카운트가 각 shared_ptr의 생명주기와 정확히 동기화된다는 점이 shared_ptr이 안전하게 공유 소유권을 관리할 수 있는 근거입니다.

void func() {
    auto ptr1 = std::make_shared<int>(10);
    std::cout << "카운트: " << ptr1.use_count() << std::endl;  // 1
    
    {
        auto ptr2 = ptr1;
        std::cout << "카운트: " << ptr1.use_count() << std::endl;  // 2
    }
    
    std::cout << "카운트: " << ptr1.use_count() << std::endl;  // 1
}

예시 3: 컨테이너

여러 개의 동적 할당 객체를 관리해야 할 때, 원시 포인터 벡터(vector<Widget*>) 대신 스마트 포인터 벡터를 쓰면 각 원소의 수명 관리를 컨테이너 자체에 맡길 수 있습니다. vector<unique_ptr<Widget>>가 소멸할 때는 벡터가 각 원소(unique_ptr)를 순서대로 소멸시키고, 그 각각이 다시 자신이 가리키는 Widget을 해제하므로, 별도의 반복문으로 delete를 호출할 필요가 전혀 없습니다.

std::vector<std::unique_ptr<Widget>> widgets;

widgets.push_back(std::make_unique<Widget>(1));
widgets.push_back(std::make_unique<Widget>(2));

// 벡터 소멸 시 모든 Widget 자동 소멸

예시 4: 팩토리 패턴

다형성을 활용한 팩토리 함수는 전통적으로 원시 포인터(Shape*)를 반환했지만, 그러면 호출자가 반환된 포인터를 언제 delete해야 하는지 함수 시그니처만으로는 알 수 없었습니다. std::unique_ptr<Shape>를 반환 타입으로 쓰면 “이 함수가 반환하는 객체의 소유권은 호출자에게 넘어간다”는 계약이 타입 자체에 명시되고, 기반 클래스 포인터로 파생 클래스 객체를 가리키는 다형성도 그대로 활용할 수 있습니다. Shape의 소멸자가 virtual로 선언되어 있어야 unique_ptr<Shape>가 소멸할 때 실제 파생 클래스(Circle)의 소멸자까지 올바르게 호출된다는 점도 중요합니다.

class Shape {
public:
    virtual void draw() = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Circle" << std::endl;
    }
};

std::unique_ptr<Shape> createShape(const std::string& type) {
    if (type == "circle") {
        return std::make_unique<Circle>();
    }
    return nullptr;
}

weak_ptr

weak_ptr은 자원을 소유하지 않고 오직 “이 자원이 아직 살아있는지”만 확인할 수 있는 특수한 스마트 포인터입니다. 참조 카운트를 증가시키지 않기 때문에 weak_ptr이 아무리 많이 존재해도 자원의 수명에는 영향을 주지 않으며, 실제로 값에 접근하려면 반드시 lock()을 호출해야 합니다. lock()은 자원이 아직 살아있으면 유효한 shared_ptr을, 이미 해제되었으면 빈 shared_ptr(nullptr과 비교하면 true)을 반환하므로, 항상 반환값을 확인한 뒤에 역참조해야 안전합니다.

auto shared = std::make_shared<int>(10);
std::weak_ptr<int> weak = shared;

// 사용 시 lock
if (auto ptr = weak.lock()) {
    std::cout << *ptr << std::endl;
}

자주 발생하는 문제

문제 1: 순환 참조

shared_ptr이 서로를 참조 카운트로 붙잡고 있으면, 프로그램이 그 객체들을 더 이상 사용하지 않게 되어도 참조 카운트가 절대 0이 되지 않아 메모리가 영원히 해제되지 않는 순환 참조 문제가 생깁니다. 이중 연결 리스트처럼 nextprev가 서로를 가리키는 구조가 대표적인 예입니다. 양방향 관계 중 한쪽(보통 “역방향” 관계, 여기서는 prev)을 weak_ptr로 바꾸면, 그쪽은 참조 카운트를 증가시키지 않으므로 순환 고리가 끊어져 정상적으로 메모리가 해제됩니다.

// ❌ 순환 참조
class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // 순환 참조
};

// ✅ weak_ptr 사용
class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // 순환 방지
};

문제 2: this 포인터

클래스 내부 메서드에서 자기 자신(this)을 shared_ptr로 감싸 반환하고 싶을 때, std::shared_ptr<Bad>(this)처럼 직접 새 shared_ptr을 만들면 매우 위험합니다. 이 객체가 이미 다른 shared_ptr에 의해 관리되고 있다면, 하나의 객체를 서로 다른(연결되지 않은) 두 제어 블록이 각각 관리하게 되어 결국 같은 메모리가 두 번 해제되는 이중 해제(double free) 버그가 발생합니다. std::enable_shared_from_this를 상속하면 shared_from_this()가 기존 제어 블록을 공유하는 올바른 shared_ptr을 반환해주므로, 이런 클래스를 설계할 때는 이 패턴을 사용하는 것이 안전합니다.

// ❌ this를 shared_ptr로
class Bad {
public:
    std::shared_ptr<Bad> getPtr() {
        return std::shared_ptr<Bad>(this);  // 위험!
    }
};

// ✅ enable_shared_from_this
class Good : public std::enable_shared_from_this<Good> {
public:
    std::shared_ptr<Good> getPtr() {
        return shared_from_this();
    }
};

문제 3: 배열

new로 배열을 할당했다면 반드시 delete[]로 해제해야 하는데, 일반 unique_ptr<T>는 내부적으로 delete(단일 객체용)만 호출하도록 되어 있어 배열을 잘못 해제하는 정의되지 않은 동작을 일으킵니다. 표준 라이브러리는 이 문제를 위해 unique_ptr<T[]>라는 부분 특수화를 제공하는데, 이 버전은 소멸 시 자동으로 delete[]를 호출하고 operator[]로 배열처럼 인덱스 접근도 지원합니다. C++14부터는 make_unique<T[]>(n)으로 이 특수화 버전을 더 간결하게 만들 수 있습니다.

// ❌ 배열 삭제 문제
std::unique_ptr<int> ptr(new int[10]);  // delete 호출

// ✅ 배열 특수화
std::unique_ptr<int[]> ptr(new int[10]);  // delete[] 호출

// ✅ make_unique (C++14)
auto ptr = std::make_unique<int[]>(10);

문제 4: 커스텀 삭제자

스마트 포인터가 관리할 수 있는 대상이 new로 할당한 메모리뿐인 것은 아닙니다. FILE*, 소켓 핸들, OS 리소스처럼 전용 해제 함수(fclose 등)가 필요한 자원도 커스텀 삭제자를 지정하면 동일한 RAII 패턴으로 관리할 수 있습니다. unique_ptr의 두 번째 템플릿 인자로 삭제자의 타입(대개 람다의 decltype)을 지정하고, 생성자에 실제 삭제자 인스턴스를 넘기면 소멸 시 기본 delete 대신 그 삭제자가 호출됩니다.

// FILE* 관리
auto deleter = [](FILE* f) {
    if (f) fclose(f);
};

std::unique_ptr<FILE, decltype(deleter)> file(
    fopen("file.txt", "r"), deleter
);

성능 비교

unique_ptr은 내부에 원시 포인터 하나(그리고 커스텀 삭제자를 쓰지 않는 한 추가 상태 없음)만 저장하므로 크기와 성능 모두 raw pointer와 사실상 동일합니다. 반면 shared_ptr은 자원을 가리키는 포인터 외에도 참조 카운트와 약한 참조 카운트를 관리하는 별도의 제어 블록을 가리키는 포인터를 하나 더 저장하기 때문에 크기가 두 배가 되고, 카운트를 증감시킬 때마다 스레드 안전을 위한 원자적 연산이 수반됩니다. 소유권이 단순하다면 unique_ptr을 기본으로 삼고, 정말 공유가 필요할 때만 shared_ptr로 전환하는 것이 성능 관점에서 합리적입니다.

// unique_ptr: 포인터 크기
sizeof(std::unique_ptr<int>);  // 8바이트

// shared_ptr: 포인터 + 제어 블록
sizeof(std::shared_ptr<int>);  // 16바이트

실무 패턴

아래 세 가지 패턴은 자원 팩토리, 참조 기반 캐시, 큐를 통한 소유권 이전이라는 스마트 포인터의 실무 활용 방식을 보여줍니다.

패턴 1: 팩토리 함수

createConnection처럼 자원을 생성해 반환하는 팩토리 함수는 unique_ptr을 반환 타입으로 두는 것이 관례입니다. 반환된 conn은 사용이 끝나고 스코프를 벗어나면 자동으로 연결이 종료되므로, 호출자는 연결을 언제 닫아야 할지 신경 쓸 필요 없이 함수를 호출하고 결과를 사용하기만 하면 됩니다. 이런 패턴은 데이터베이스 연결, 파일 핸들, 네트워크 소켓처럼 명시적으로 정리해야 하는 자원을 다루는 코드에서 특히 유용합니다.

class Connection {
public:
    Connection(const std::string& host) : host_(host) {
        std::cout << "연결: " << host_ << '\n';
    }
    
    ~Connection() {
        std::cout << "연결 종료: " << host_ << '\n';
    }
    
    void query(const std::string& sql) {
        std::cout << "쿼리: " << sql << '\n';
    }
    
private:
    std::string host_;
};

std::unique_ptr<Connection> createConnection(const std::string& host) {
    return std::make_unique<Connection>(host);
}

// 사용
auto conn = createConnection("localhost");
conn->query("SELECT * FROM users");
// 자동 연결 종료

패턴 2: 캐시 시스템

캐시가 항목을 shared_ptr로 강하게 붙잡고 있으면, 캐시에 들어간 순간부터 그 객체는 캐시가 살아있는 한 절대 해제되지 않아 메모리를 계속 점유하게 됩니다. 이 예제처럼 캐시 내부 저장소를 weak_ptr로 두면, 다른 곳에서 아무도 그 자원을 실제로 사용하고 있지 않을 때는 자연스럽게 해제되도록 두면서도, 누군가 여전히 사용 중이라면(lock()이 성공하면) 캐시 히트로 재사용할 수 있는 절충안을 만들 수 있습니다. 이런 구조는 “사용 중일 때만 캐시로서 의미가 있는” 리소스에 적합합니다.

#include <map>
#include <memory>
#include <string>

class Cache {
    std::map<std::string, std::weak_ptr<Resource>> cache_;
    
public:
    std::shared_ptr<Resource> get(const std::string& key) {
        // 캐시 확인
        if (auto it = cache_.find(key); it != cache_.end()) {
            if (auto ptr = it->second.lock()) {
                return ptr;  // 캐시 히트
            }
        }
        
        // 캐시 미스: 새로 생성
        auto resource = std::make_shared<Resource>(key);
        cache_[key] = resource;
        return resource;
    }
};

패턴 3: 소유권 전달

작업 큐처럼 객체의 소유권이 한 곳에서 다른 곳으로 명확하게 옮겨가야 하는 구조에서는 unique_ptr이 자연스럽게 들어맞습니다. addTask는 매개변수를 값으로 받는 대신 std::move로 소유권을 큐에 넘기고, getNextTask는 저장해둔 unique_ptr을 다시 std::move로 꺼내 호출자에게 넘깁니다. 이 과정에서 실제 Task 객체는 단 한 번도 복사되지 않고, 오직 소유권(내부 포인터)만 이동하기 때문에 큰 객체를 다루더라도 효율적입니다.

class TaskQueue {
    std::vector<std::unique_ptr<Task>> tasks_;
    
public:
    void addTask(std::unique_ptr<Task> task) {
        tasks_.push_back(std::move(task));
    }
    
    std::unique_ptr<Task> getNextTask() {
        if (tasks_.empty()) {
            return nullptr;
        }
        
        auto task = std::move(tasks_.back());
        tasks_.pop_back();
        return task;
    }
};

// 사용
TaskQueue queue;
queue.addTask(std::make_unique<Task>("Task1"));

auto task = queue.getNextTask();  // 소유권 이동
task->execute();

FAQ

Q1: 언제 사용하나요?

A:

  • unique_ptr: 독점 소유권, 단일 소유자
  • shared_ptr: 공유 소유권, 여러 소유자
  • weak_ptr: 순환 참조 방지, 관찰만

C/C++ 예제 코드입니다.

// unique_ptr: 독점
std::unique_ptr<Widget> widget = std::make_unique<Widget>();

// shared_ptr: 공유
std::shared_ptr<Widget> shared = std::make_shared<Widget>();
auto shared2 = shared;  // 복사

// weak_ptr: 관찰
std::weak_ptr<Widget> weak = shared;

Q2: 성능은?

A:

  • unique_ptr: raw pointer와 동일 (오버헤드 없음)
  • shared_ptr: 참조 카운트 오버헤드 (원자적 연산)
// unique_ptr: 8바이트
sizeof(std::unique_ptr<int>);  // 8

// shared_ptr: 16바이트 (포인터 + 제어 블록)
sizeof(std::shared_ptr<int>);  // 16

Q3: 배열은 어떻게 사용하나요?

A: unique_ptr<T[]> 를 사용합니다.

C/C++ 예제 코드입니다.

// ❌ 배열 삭제 문제
std::unique_ptr<int> ptr(new int[10]);  // delete 호출 (잘못됨)

// ✅ 배열 특수화
std::unique_ptr<int[]> ptr(new int[10]);  // delete[] 호출

// ✅ make_unique (C++14)
auto ptr = std::make_unique<int[]>(10);

Q4: 순환 참조는 어떻게 해결하나요?

A: weak_ptr 로 해결합니다.

// ❌ 순환 참조
class Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // 순환 참조
};

// ✅ weak_ptr
class Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // 순환 방지
};

Q5: 언제 raw pointer를 사용하나요?

A:

  • 소유권 없음: 관찰만
  • 함수 매개변수: 임시 참조
  • 성능 중요: 핫 패스

process 함수의 구현 예제입니다.

// raw pointer: 소유권 없음
void process(Widget* widget) {
    widget->use();  // 관찰만
}

// unique_ptr: 소유권 있음
std::unique_ptr<Widget> owner = std::make_unique<Widget>();
process(owner.get());  // raw pointer 전달

Q6: make_unique vs new?

A: make_unique를 권장합니다.

C/C++ 예제 코드입니다.

// ❌ new: 예외 안전하지 않음
func(std::unique_ptr<Widget>(new Widget()), compute());
// compute()에서 예외 발생 시 누수 가능

// ✅ make_unique: 예외 안전
func(std::make_unique<Widget>(), compute());

Q7: 커스텀 삭제자는?

A: 람다나 함수 객체를 사용합니다.

// FILE* 관리
auto deleter = [](FILE* f) {
    if (f) fclose(f);
};

std::unique_ptr<FILE, decltype(deleter)> file(
    fopen("file.txt", "r"), deleter
);

Q8: 스마트 포인터 학습 리소스는?

A:

관련 글: unique_ptr, shared_ptr, weak_ptr.

한 줄 요약: RAII와 스마트 포인터는 자동 자원 관리로 메모리 누수를 방지하는 C++ 핵심 기법입니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, RAII, smart-pointers, unique_ptr, shared_ptr 등으로 검색하시면 이 글이 도움이 됩니다.