C++ 스마트 포인터 | unique_ptr/shared_ptr "메모리 안전" 가이드

C++ 스마트 포인터 | unique_ptr/shared_ptr "메모리 안전" 가이드

이 글의 핵심

C++ 스마트 포인터에 대한 실전 가이드입니다. unique_ptr/shared_ptr 등을 예제와 함께 상세히 설명합니다.

들어가며

스마트 포인터는 RAII 원칙으로 자동 메모리 관리를 제공하는 포인터 래퍼입니다. raw 포인터의 메모리 누수, 댕글링 포인터 문제를 해결합니다.

// ❌ raw 포인터 (위험)
int* ptr = new int(10);  // 힙에 메모리 할당
// ... 사용 ...
delete ptr;  // 수동으로 메모리 해제 (깜빡하면 메모리 누수!)
// 문제점:
// 1. delete 깜빡하면 메모리 누수
// 2. 예외 발생 시 delete 실행 안될 수 있음
// 3. 이중 delete 시 크래시
// 4. delete 후 사용 시 UB (댕글링 포인터)

// ✅ 스마트 포인터 (안전)
// std::make_unique: unique_ptr 생성 헬퍼 함수
// RAII 원칙: 객체 생성 시 자원 획득, 소멸 시 자동 해제
std::unique_ptr<int> ptr = std::make_unique<int>(10);
// 스코프 벗어나면 자동으로 delete됨!
// 예외 발생해도 안전하게 메모리 해제

1. unique_ptr - 독점 소유

기본 사용

#include <memory>
#include <iostream>

int main() {
    // 생성
    std::unique_ptr<int> ptr = std::make_unique<int>(10);
    
    // 사용
    std::cout << *ptr << std::endl;  // 10
    *ptr = 20;
    std::cout << *ptr << std::endl;  // 20
    
    // nullptr 체크
    if (ptr) {
        std::cout << "유효함" << std::endl;
    }
    
    // 배열
    std::unique_ptr<int[]> arr = std::make_unique<int[]>(5);
    arr[0] = 1;
    arr[1] = 2;
    std::cout << arr[0] << ", " << arr[1] << std::endl;  // 1, 2
    
    return 0;
}  // 자동 delete

이동 (복사 불가)

#include <memory>
#include <iostream>

// unique_ptr을 값으로 받음: 소유권 이전
// 함수가 끝나면 자동으로 메모리 해제
void process(std::unique_ptr<int> ptr) {
    std::cout << "값: " << *ptr << std::endl;
}  // ptr 소멸 → 메모리 해제

int main() {
    std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
    
    // ❌ 복사 불가: unique_ptr은 독점 소유권
    // std::unique_ptr<int> ptr2 = ptr1;  // 컴파일 에러
    // 복사 생성자가 delete되어 있음
    
    // ✅ 이동: std::move로 소유권 이전
    // ptr1의 소유권이 ptr2로 완전히 이동
    // 이동 후 ptr1은 nullptr이 됨 (더 이상 소유하지 않음)
    std::unique_ptr<int> ptr2 = std::move(ptr1);
    
    // ptr1 확인: nullptr인지 체크
    if (!ptr1) {
        std::cout << "ptr1은 nullptr" << std::endl;
    }
    // ptr2 확인: 유효한지 체크
    if (ptr2) {
        std::cout << "ptr2는 유효: " << *ptr2 << std::endl;
    }
    
    // 함수에 전달: 소유권 이전
    // std::move(ptr2): ptr2의 소유권을 process 함수로 이전
    // 함수 호출 후 ptr2는 nullptr
    process(std::move(ptr2));
    
    if (!ptr2) {
        std::cout << "ptr2도 nullptr" << std::endl;
    }
    
    return 0;
}

출력:

ptr1은 nullptr
ptr2는 유효: 10
값: 10
ptr2도 nullptr

2. shared_ptr - 공유 소유

기본 사용

#include <memory>
#include <iostream>

int main() {
    // std::make_shared: shared_ptr 생성 (권장)
    // 제어 블록(참조 카운트)과 객체를 한 번에 할당 (효율적)
    std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
    // use_count(): 현재 참조 카운트 확인
    std::cout << "참조 카운트: " << ptr1.use_count() << std::endl;  // 1
    
    {
        // shared_ptr은 복사 가능: 참조 카운트 증가
        // ptr1과 ptr2는 같은 메모리를 가리킴
        std::shared_ptr<int> ptr2 = ptr1;  // 복사 가능
        // 참조 카운트 2: ptr1, ptr2 두 개가 같은 객체 소유
        std::cout << "참조 카운트: " << ptr1.use_count() << std::endl;  // 2
        std::cout << "ptr1: " << *ptr1 << std::endl;  // 10
        std::cout << "ptr2: " << *ptr2 << std::endl;  // 10
    }  // ptr2 소멸 → 참조 카운트 감소 (2 → 1)
       // 아직 ptr1이 살아있어서 메모리는 해제 안됨
    
    std::cout << "참조 카운트: " << ptr1.use_count() << std::endl;  // 1
    
    return 0;
}  // ptr1 소멸 → 참조 카운트 0 → 메모리 해제

출력:

참조 카운트: 1
참조 카운트: 2
ptr1: 10
ptr2: 10
참조 카운트: 1

참조 카운팅

#include <memory>
#include <iostream>
#include <vector>

class Resource {
public:
    Resource(int id) : id_(id) {
        std::cout << "Resource " << id_ << " 생성" << std::endl;
    }
    
    ~Resource() {
        std::cout << "Resource " << id_ << " 소멸" << std::endl;
    }
    
    int getId() const { return id_; }
    
private:
    int id_;
};

int main() {
    std::vector<std::shared_ptr<Resource>> resources;
    
    {
        auto r1 = std::make_shared<Resource>(1);
        resources.push_back(r1);
        resources.push_back(r1);
        resources.push_back(r1);
        
        std::cout << "참조 카운트: " << r1.use_count() << std::endl;  // 4
    }  // r1 소멸해도 resources에 남아있음
    
    std::cout << "벡터 크기: " << resources.size() << std::endl;  // 3
    std::cout << "참조 카운트: " << resources[0].use_count() << std::endl;  // 3
    
    resources.clear();  // 모든 참조 제거, Resource 소멸
    
    return 0;
}

출력:

Resource 1 생성
참조 카운트: 4
벡터 크기: 3
참조 카운트: 3
Resource 1 소멸

3. weak_ptr - 순환 참조 방지

순환 참조 문제

#include <memory>
#include <iostream>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A 소멸" << std::endl; }
};

class B {
public:
    std::shared_ptr<A> a_ptr;  // 순환 참조!
    ~B() { std::cout << "B 소멸" << std::endl; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        
        a->b_ptr = b;
        b->a_ptr = a;  // 순환 참조
        
        std::cout << "a 참조 카운트: " << a.use_count() << std::endl;  // 2
        std::cout << "b 참조 카운트: " << b.use_count() << std::endl;  // 2
    }  // a, b 소멸해도 메모리 해제 안됨!
    
    std::cout << "블록 종료" << std::endl;
    
    return 0;
}

출력:

a 참조 카운트: 2
b 참조 카운트: 2
블록 종료

문제: A, B 소멸자가 호출되지 않음 (메모리 누수)

weak_ptr로 해결

#include <memory>
#include <iostream>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A 소멸" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> a_ptr;  // weak_ptr 사용
    ~B() { std::cout << "B 소멸" << std::endl; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        
        a->b_ptr = b;
        b->a_ptr = a;  // weak_ptr은 참조 카운트 증가 안함
        
        std::cout << "a 참조 카운트: " << a.use_count() << std::endl;  // 1
        std::cout << "b 참조 카운트: " << b.use_count() << std::endl;  // 2
    }  // A, B 모두 정상 소멸
    
    std::cout << "블록 종료" << std::endl;
    
    return 0;
}

출력:

a 참조 카운트: 1
b 참조 카운트: 2
B 소멸
A 소멸
블록 종료

weak_ptr 사용

#include <memory>
#include <iostream>

int main() {
    // weak_ptr: 참조 카운트를 증가시키지 않는 약한 참조
    std::weak_ptr<int> weak;
    
    {
        // shared_ptr 생성
        std::shared_ptr<int> shared = std::make_shared<int>(42);
        // weak_ptr에 할당: 참조 카운트 증가 안함
        // shared의 수명에 영향을 주지 않음
        weak = shared;
        
        // 참조 카운트: 여전히 1 (weak_ptr은 카운트 안함)
        std::cout << "shared 참조 카운트: " << shared.use_count() << std::endl;  // 1
        
        // weak_ptr 사용: lock()으로 shared_ptr 얻기
        // lock(): 객체가 살아있으면 shared_ptr 반환, 아니면 nullptr
        if (auto locked = weak.lock()) {
            // locked: 임시 shared_ptr (참조 카운트 증가)
            std::cout << "값: " << *locked << std::endl;  // 42
            // 참조 카운트 2: shared + locked
            std::cout << "참조 카운트: " << locked.use_count() << std::endl;  // 2
        }  // locked 소멸 → 참조 카운트 1로 복귀
    }  // shared 소멸 → 참조 카운트 0 → 메모리 해제
    
    // weak_ptr 만료 확인
    // expired(): 참조하던 객체가 소멸되었는지 확인
    if (weak.expired()) {
        std::cout << "weak_ptr 만료됨" << std::endl;
    }
    
    // 만료된 weak_ptr에서 lock() 시도
    if (auto locked = weak.lock()) {
        std::cout << "값: " << *locked << std::endl;
    } else {
        // 객체가 이미 소멸되어 lock 실패
        std::cout << "lock 실패" << std::endl;
    }
    
    return 0;
}

출력:

shared 참조 카운트: 1
값: 42
참조 카운트: 2
weak_ptr 만료됨
lock 실패

4. 실전 예제

예제 1: 리소스 관리

#include <memory>
#include <iostream>
#include <fstream>

class FileHandler {
private:
    std::unique_ptr<std::ofstream> file;
    std::string filename;
    
public:
    FileHandler(const std::string& filename) : filename(filename) {
        file = std::make_unique<std::ofstream>(filename);
        if (!file->is_open()) {
            throw std::runtime_error("파일 열기 실패: " + filename);
        }
        std::cout << "파일 열림: " << filename << std::endl;
    }
    
    ~FileHandler() {
        if (file && file->is_open()) {
            file->close();
            std::cout << "파일 닫힘: " << filename << std::endl;
        }
    }
    
    void write(const std::string& data) {
        if (file && file->is_open()) {
            *file << data << std::endl;
        }
    }
};

int main() {
    try {
        FileHandler handler("output.txt");
        handler.write("Hello");
        handler.write("World");
        // 예외 발생해도 자동으로 파일 닫힘
    } catch (const std::exception& e) {
        std::cerr << "에러: " << e.what() << std::endl;
    }
    
    return 0;
}

예제 2: 팩토리 패턴

#include <memory>
#include <iostream>
#include <string>

class Animal {
public:
    virtual void speak() = 0;
    virtual ~Animal() {}
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "멍멍!" << std::endl;
    }
    ~Dog() {
        std::cout << "Dog 소멸" << std::endl;
    }
};

class Cat : public Animal {
public:
    void speak() override {
        std::cout << "야옹!" << std::endl;
    }
    ~Cat() {
        std::cout << "Cat 소멸" << std::endl;
    }
};

std::unique_ptr<Animal> createAnimal(const std::string& type) {
    if (type == "dog") {
        return std::make_unique<Dog>();
    } else if (type == "cat") {
        return std::make_unique<Cat>();
    }
    return nullptr;
}

int main() {
    auto animal1 = createAnimal("dog");
    if (animal1) {
        animal1->speak();
    }
    
    auto animal2 = createAnimal("cat");
    if (animal2) {
        animal2->speak();
    }
    
    auto animal3 = createAnimal("bird");
    if (!animal3) {
        std::cout << "알 수 없는 동물" << std::endl;
    }
    
    return 0;
}

출력:

멍멍!
야옹!
알 수 없는 동물
Cat 소멸
Dog 소멸

예제 3: 캐시 시스템 (shared_ptr)

#include <memory>
#include <unordered_map>
#include <iostream>
#include <string>

class Resource {
private:
    std::string name;
    
public:
    Resource(std::string n) : name(n) {
        std::cout << "리소스 로드: " << name << std::endl;
    }
    
    ~Resource() {
        std::cout << "리소스 언로드: " << name << std::endl;
    }
    
    void use() {
        std::cout << name << " 사용 중" << std::endl;
    }
    
    std::string getName() const { return name; }
};

class ResourceCache {
private:
    std::unordered_map<std::string, std::shared_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> getResource(const std::string& name) {
        if (cache.find(name) == cache.end()) {
            cache[name] = std::make_shared<Resource>(name);
        }
        return cache[name];
    }
    
    void printCacheSize() {
        std::cout << "캐시 크기: " << cache.size() << std::endl;
    }
    
    void clear() {
        cache.clear();
        std::cout << "캐시 비움" << std::endl;
    }
};

int main() {
    ResourceCache cache;
    
    {
        auto r1 = cache.getResource("texture1");
        auto r2 = cache.getResource("texture1");  // 같은 객체
        r1->use();
        
        std::cout << "r1 참조 카운트: " << r1.use_count() << std::endl;  // 3 (r1, r2, cache)
        std::cout << "r2 참조 카운트: " << r2.use_count() << std::endl;  // 3
    }  // r1, r2 소멸해도 캐시에 남아있음
    
    cache.printCacheSize();  // 1
    
    cache.clear();  // 캐시 비우면 리소스 언로드
    
    return 0;
}

출력:

리소스 로드: texture1
texture1 사용 중
r1 참조 카운트: 3
r2 참조 카운트: 3
캐시 크기: 1
캐시 비움
리소스 언로드: texture1

5. 자주 발생하는 문제

문제 1: make_unique/make_shared를 안 쓰는 경우

#include <memory>

void func(std::unique_ptr<int> p1, std::unique_ptr<int> p2) {
    // ...
}

int main() {
    // ❌ 위험한 코드: 예외 안전성 문제
    // func(std::unique_ptr<int>(new int(1)), std::unique_ptr<int>(new int(2)));
    // 평가 순서가 보장 안되어 누수 가능:
    // 1. new int(1)
    // 2. new int(2)
    // 3. unique_ptr 생성 (예외 발생 시 1, 2 누수)
    
    // ✅ 안전한 코드
    func(std::make_unique<int>(1), std::make_unique<int>(2));
    
    // ✅ 또는
    auto p1 = std::make_unique<int>(1);
    auto p2 = std::make_unique<int>(2);
    func(std::move(p1), std::move(p2));
    
    return 0;
}

문제 2: shared_ptr 순환 참조

#include <memory>
#include <iostream>

// ❌ 순환 참조
class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // 순환!
    int value;
    
    Node(int v) : value(v) {
        std::cout << "Node " << value << " 생성" << std::endl;
    }
    
    ~Node() {
        std::cout << "Node " << value << " 소멸" << std::endl;
    }
};

void testCircular() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);
    
    n1->next = n2;
    n2->prev = n1;  // 순환 참조! 소멸 안됨
    
    std::cout << "n1 참조 카운트: " << n1.use_count() << std::endl;  // 2
    std::cout << "n2 참조 카운트: " << n2.use_count() << std::endl;  // 2
}

// ✅ weak_ptr 사용
class NodeFixed {
public:
    std::shared_ptr<NodeFixed> next;
    std::weak_ptr<NodeFixed> prev;  // weak_ptr
    int value;
    
    NodeFixed(int v) : value(v) {
        std::cout << "NodeFixed " << value << " 생성" << std::endl;
    }
    
    ~NodeFixed() {
        std::cout << "NodeFixed " << value << " 소멸" << std::endl;
    }
};

void testFixed() {
    auto n1 = std::make_shared<NodeFixed>(1);
    auto n2 = std::make_shared<NodeFixed>(2);
    
    n1->next = n2;
    n2->prev = n1;  // weak_ptr은 참조 카운트 증가 안함
    
    std::cout << "n1 참조 카운트: " << n1.use_count() << std::endl;  // 1
    std::cout << "n2 참조 카운트: " << n2.use_count() << std::endl;  // 2
}

int main() {
    std::cout << "=== 순환 참조 테스트 ===" << std::endl;
    testCircular();
    std::cout << "함수 종료 (소멸자 호출 안됨!)" << std::endl;
    
    std::cout << "\n=== weak_ptr 테스트 ===" << std::endl;
    testFixed();
    std::cout << "함수 종료 (소멸자 호출됨)" << std::endl;
    
    return 0;
}

출력:

=== 순환 참조 테스트 ===
Node 1 생성
Node 2 생성
n1 참조 카운트: 2
n2 참조 카운트: 2
함수 종료 (소멸자 호출 안됨!)

=== weak_ptr 테스트 ===
NodeFixed 1 생성
NodeFixed 2 생성
n1 참조 카운트: 1
n2 참조 카운트: 2
NodeFixed 2 소멸
NodeFixed 1 소멸
함수 종료 (소멸자 호출됨)

문제 3: unique_ptr을 함수에 전달

#include <memory>
#include <iostream>

// 방법 1: 소유권 이전
void takeOwnership(std::unique_ptr<int> ptr) {
    std::cout << "소유권 이전: " << *ptr << std::endl;
}

// 방법 2: 참조로 전달 (소유권 유지)
void borrow(const std::unique_ptr<int>& ptr) {
    std::cout << "참조: " << *ptr << std::endl;
}

// 방법 3: raw 포인터로 전달 (소유권 없음)
void observe(int* ptr) {
    if (ptr) {
        std::cout << "관찰: " << *ptr << std::endl;
    }
}

int main() {
    auto ptr = std::make_unique<int>(10);
    
    // ❌ 컴파일 에러
    // takeOwnership(ptr);  // 복사 불가
    
    // ✅ 소유권 이전
    // takeOwnership(std::move(ptr));  // ptr은 nullptr이 됨
    
    // ✅ 참조로 전달
    borrow(ptr);  // ptr 유지
    
    // ✅ raw 포인터로 전달
    observe(ptr.get());  // ptr 유지
    
    std::cout << "ptr 유효: " << (ptr ? "yes" : "no") << std::endl;
    
    return 0;
}

출력:

참조: 10
관찰: 10
ptr 유효: yes

6. 실전 예제: 리소스 관리자

#include <memory>
#include <vector>
#include <iostream>
#include <string>

class ResourceManager {
private:
    std::vector<std::unique_ptr<std::string>> resources_;
    
public:
    // 리소스 추가
    void add(std::unique_ptr<std::string> resource) {
        resources_.push_back(std::move(resource));
    }
    
    // 리소스 생성 및 추가
    void create(const std::string& value) {
        resources_.push_back(std::make_unique<std::string>(value));
    }
    
    // 리소스 가져오기 (소유권 이전)
    std::unique_ptr<std::string> take(size_t index) {
        if (index >= resources_.size()) return nullptr;
        
        auto resource = std::move(resources_[index]);
        resources_.erase(resources_.begin() + index);
        return resource;
    }
    
    // 리소스 개수
    size_t count() const {
        return resources_.size();
    }
    
    // 리소스 출력
    void print() const {
        std::cout << "Resources (" << resources_.size() << "):" << std::endl;
        for (size_t i = 0; i < resources_.size(); ++i) {
            if (resources_[i]) {
                std::cout << "  [" << i << "]: " << *resources_[i] << std::endl;
            } else {
                std::cout << "  [" << i << "]: (moved)" << std::endl;
            }
        }
    }
};

int main() {
    ResourceManager mgr;
    
    // 리소스 추가
    mgr.add(std::make_unique<std::string>("Resource 1"));
    mgr.create("Resource 2");
    mgr.create("Resource 3");
    
    std::cout << "초기 상태:" << std::endl;
    mgr.print();
    
    // 리소스 가져오기
    auto r = mgr.take(1);
    std::cout << "\n가져온 리소스: " << *r << std::endl;
    
    std::cout << "\n남은 리소스:" << std::endl;
    mgr.print();
    
    return 0;
}

출력:

초기 상태:
Resources (3):
  [0]: Resource 1
  [1]: Resource 2
  [2]: Resource 3

가져온 리소스: Resource 2

남은 리소스:
Resources (2):
  [0]: Resource 1
  [1]: Resource 3

정리

핵심 요약

  1. unique_ptr: 독점 소유, 복사 불가, 이동 가능
  2. shared_ptr: 공유 소유, 참조 카운팅
  3. weak_ptr: 순환 참조 방지, 참조 카운트 증가 안함
  4. make_unique/make_shared: 예외 안전성, 성능 이점
  5. RAII: 자동 메모리 관리

스마트 포인터 비교

특징unique_ptrshared_ptrweak_ptr
소유권독점공유없음
복사불가가능가능
이동가능가능가능
오버헤드없음참조 카운팅없음
용도기본 선택공유 필요순환 참조 방지
배열지원제한적-

실전 팁

선택 가이드:

  • 기본: unique_ptr
  • 공유 필요: shared_ptr
  • 순환 참조: weak_ptr
  • 배열: unique_ptr<T[]> 또는 vector

성능:

  • unique_ptr: raw 포인터와 동일
  • shared_ptr: 참조 카운팅 오버헤드 (약간)
  • make_shared: 메모리 할당 1번 (최적화)

주의사항:

  • make_unique/make_shared 사용
  • 순환 참조 주의
  • move 후 사용 금지
  • 댕글링 포인터 방지

다음 단계

  • C++ RAII
  • C++ Move Semantics
  • C++ weak_ptr

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

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

  • C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
  • C++ RAII & Smart Pointers | “스마트 포인터” 가이드
  • C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]

관련 글

  • C++ shared_ptr vs unique_ptr |
  • C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
  • C++ unique_ptr 고급 완벽 가이드 | 커스텀 삭제자·배열
  • C++ 순환 참조 | shared_ptr 메모리 누수
  • C++ RAII & Smart Pointers |