본문으로 건너뛰기
Previous
Next
C++ 스마트 포인터 | unique_ptr/shared_ptr '메모리 안전' 가이드

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

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

이 글의 핵심

스마트 포인터 기본(소유·참조 카운트)에 더해, 제어 블록 레이아웃, make_shared와 직접 생성, weak_ptr lock의 원자성, 커스텀 삭제자 타입 소거, 실무 패턴까지 정리합니다.

🎯 이 글을 읽으면 (읽는 시간: 약 32분)

TL;DR: C++ 메모리 관리의 핵심, 스마트 포인터를 완벽하게 마스터합니다. unique_ptr과 shared_ptr로 메모리 누수와 댕글링 포인터 문제를 해결하는 방법을 배웁니다.

이 글을 읽으면:

  • ✅ unique_ptr (독점 소유) vs shared_ptr (공유 소유) 완벽 이해
  • ✅ RAII 원칙과 자동 메모리 관리 마스터
  • ✅ make_unique/make_shared 사용법 및 예외 안전성 습득

실무 활용:

  • 🔥 메모리 누수 방지 (자동 해제)
  • 🔥 소유권 명확화 (unique vs shared)
  • 🔥 안전한 리소스 관리 (RAII)

난이도: 고급(심화 포함) | C++11/14 필수 | 실습 코드: 12개+


들어가며

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

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

// ❌ 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 실패

심화: 제어 블록, make_shared, weak_ptr lock, 삭제자 타입 소거, 실무 패턴

이 절에서는 표준 문서만으로는 드러나지 않는 구현 관점의 직관프로덕션에서의 트레이드오프를 다룹니다. 문장마다 구현체마다 미세한 차이가 있을 수 있으나, libstdc++/libc++/MSVC 계열에서 공통적으로 성립하는 모델을 기준으로 설명합니다.

제어 블록(control block) 레이아웃과 오버헤드

std::shared_ptr가 관리하는 힙 객체는, 사용자가 할당한 관리 대상 객체(managed object) 와, 메타데이터를 담는 제어 블록으로 나뉩니다. 제어 블록에는 일반적으로 강한 참조 카운트(strong count)·약한 참조 카운트(weak count)·삭제자(deleter)·할당자(allocator) 등이 저장됩니다. weak_ptr는 강한 카운트를 올리지 않지만 제어 블록은 공유하므로, 마지막 shared_ptr가 사라져도 weak_ptr가 남아 있으면 제어 블록은 유지됩니다.

sizeof(std::shared_ptr)는 구현에 따라 다르지만, 흔히 원시 포인터 두 개 분량(관리 객체 포인터 + 제어 블록 포인터)입니다. 반면 std::unique_ptr는 삭제자가 상태 없는 기본 타입이면 포인터 한 개에 가깝습니다. 따라서 “스마트 포인터를 값으로 복사·이동하는 빈도”가 높은 경로에서는 shared_ptr 복사가 원자적 연산과 캐시 일관성 비용을 동반한다는 점을 염두에 두어야 합니다.

할당 관점에서 보면, std::shared_ptr<T>(new T(...)) 형태는 객체용 메모리제어 블록용 메모리를 각각 할당하는 경우가 많아, 최소 두 번의 힙 할당이 발생할 수 있습니다. 반대로 std::make_shared는 한 번의 연속 구역에 객체와 제어 블록을 함께 올리는 경우가 많아 할당 횟수 감소캐시 지역성 이점이 있습니다. 다만 마지막 shared_ptr가 사라진 뒤에도 weak_ptr만 남는 상황에서는, make_shared로 묶인 배치 때문에 제어 블록이 차지하는 영역이 객체 본문과 함께 예약되어 있어, 구현에 따라 “객체는 이미 파괴되었는데도 메모리가 덜 반환되는 것처럼 보이는” 현상을 관측할 수 있습니다. 이는 버그가 아니라 수명·할당 전략의 트레이드오프입니다.

std::make_sharedshared_ptr 직접 생성·allocate_shared

std::make_shared는 (1) 예외 안전성: newshared_ptr 생성 사이에 예외가 끼어들며 생길 수 있는 누수 가능성을 줄이고, (2) 할당 횟수·지역성 측면에서 유리한 경우가 많습니다. 팀 코딩 규약에서 “가능하면 make_shared/make_unique”가 기본이 되는 이유입니다.

반면 std::shared_ptr<T>(new T(...), deleter) 나 커스텀 할당자가 필요한 경우에는 직접 생성이 자연스럽습니다. 예를 들어 delete가 아닌 fclose·CloseHandle·COM Release비표준 해제 루틴을 반드시 거쳐야 할 때는 삭제자를 넘기는 shared_ptr 패턴이 흔합니다. 할당 전략까지 제어하려면 std::allocate_shared를 사용해 메모리 풀·전역 할당자와 연동할 수 있습니다.

요약하면, 기본값은 make_shared, 삭제자·할당자가 요구사항을 지배하면 shared_ptr 생성자 또는 allocate_shared로 가져가면 됩니다.

weak_ptr::lock()의 원자성과 TOCTOU

weak_ptr는 강한 참조 카운트를 올리지 않으므로, 관리 객체가 소멸하는 타이밍과 관찰자 코드 사이에 레이스 컨디션이 생길 수 있습니다. lock()은 “객체가 아직 살아 있으면 강한 참조를 하나 만들고 shared_ptr를 돌려주고, 이미 파괴되었으면 빈 shared_ptr를 돌려준다”는 의미에 가깝고, 구현에서는 제어 블록의 강한 카운트를 원자적으로 증가시키는 경로로 연결됩니다.

실무에서 위험한 패턴은 if (!w.expired()) { auto sp = w.lock(); }처럼 검사와 사용을 나누는 것입니다. expired()가 거짓이었더라도 그 직후 다른 스레드가 마지막 shared_ptr를 버리면 객체는 사라질 수 있어, TOCTOU(time-of-check-time-of-use) 문제가 됩니다. 안전한 형태는 다음과 같습니다.

#include <memory>
#include <iostream>

void safe_use(std::weak_ptr<int> w) {
    if (std::shared_ptr<int> sp = w.lock()) {
        std::cout << *sp << '\n';
    } else {
        std::cout << "이미 소멸됨\n";
    }
}

항상 if (auto sp = weak.lock()) { ... } 한 문장으로 강한 참조를 확보한 뒤에만 객체를 읽거나 멤버를 호출해야 합니다.

커스텀 삭제자와 타입 소거(type erasure)

std::shared_ptr의 삭제자는 타입 시스템상 shared_ptr<T>T에만 노출되고, 실제 삭제 로직은 제어 블록 안에 타입 소거된 형태로 저장됩니다. 그 결과 런타임에 올바른 해제 함수를 호출할 수 있지만, 같은 T라도 삭제자·할당 방식이 다른 shared_ptr끼리는 서로 대입·비교가 제한되는 등(구현·버전에 따름) 미묘한 제약이 생길 수 있습니다.

반면 std::unique_ptr<T, Deleter>에서 Deleter템플릿 인자이므로, 상태를 캡처한 람다 삭제자는 unique_ptr 객체 자체의 크기에 반영될 수 있습니다. 빈 삭제자는 빈 기저 클래스 최적화(EBO)로 비용이 줄어들 수 있지만, 캡처가 있으면 그만큼 무거워집니다. “삭제자가 객체 크기에 들어가도 되는지”는 unique_ptr를 멤버로 둘 때 레이아웃에 직접 영향을 줍니다.

C 스타일 핸들을 예로 들면 다음과 같습니다.

#include <cstdio>
#include <memory>

std::shared_ptr<FILE> open_file(const char* path) {
    return std::shared_ptr<FILE>(std::fopen(path, "rb"), [](FILE* f) {
        if (f) std::fclose(f);
    });
}

shared_ptr는 삭제자가 제어 블록에 들어가므로, 위와 같이 관리 포인터 타입(FILE*)과 실제 리소스 의미를 분리해 표현하기 쉽습니다.

프로덕션에서의 스마트 포인터 패턴

  1. 소유권의 기본값은 unique_ptr: 팩토리·컨테이너가 반환하는 “단일 소유” 리소스는 unique_ptr로 두고, 정말로 여러 경로가 같은 객체 수명을 공유할 때만 shared_ptr로 승격합니다. 불필요한 shared_ptr 남발은 원자적 참조 카운트 갱신 비용을 곳곳에 퍼뜨립니다.
  2. shared_ptr는 공유가 명확할 때만: 캐시, 비동기 콜백 큐, DAG/그래프 노드 등 수명이 본질적으로 공유되는 경우에 한정하는 것이 좋습니다.
  3. weak_ptr는 순환 참조뿐 아니라 옵저버·캐시에도 쓰입니다. lock()에 실패하면 객체가 이미 사라진 것으로 처리하면 됩니다.
  4. std::enable_shared_from_this: 객체가 자기 자신을 shared_ptr로 넘겨야 할 때(예: 비동기 작업에 shared_from_this()). 전제는 이미 shared_ptr로 관리되는 객체 안에서만 호출해야 하며, 스택에만 있는 임시 객체에는 사용할 수 없습니다.
  5. 핫 루프에서 shared_ptr 복사 줄이기: 반복문 안에서 매번 shared_ptr를 복사하기보다, 필요한 최소 범위에서만 지역 shared_ptr를 잡거나, 불변 참조는 const T&/T*로 전달합니다.
  6. Pimpl 관용구: std::unique_ptr<Impl>로 구현 세부를 숨기고 헤더 의존성을 줄입니다. 이동만 정의하면 되는 전형적인 패턴입니다.
#include <memory>
#include <future>

struct Worker : std::enable_shared_from_this<Worker> {
    std::future<void> post_async() {
        return std::async([self = shared_from_this()] {
            (void)self; // 실제로는 멤버 사용
        });
    }
};

// 반드시 shared_ptr로 감싼 뒤에만 shared_from_this() 사용
inline void run() {
    auto w = std::make_shared<Worker>();
    auto f = w->post_async();
    f.wait();
}

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: 순환 참조 방지, lock()원자적으로 강한 참조 확보(TOCTOU 방지)
  4. make_unique/make_shared: 예외 안전성·할당·지역성 이점(allocate_shared·직접 생성은 삭제자/할당 요구 시)
  5. 커스텀 삭제자: shared_ptr는 제어 블록에 타입 소거·unique_ptr는 삭제자가 타입에 포함
  6. 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++ 스마트 포인터 | unique_ptr/shared_ptr ‘메모리 안전’ 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 스마트 포인터 | unique_ptr/shared_ptr ‘메모리 안전’ 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. unique_ptr·shared_ptr·weak_ptr 심화: 제어 블록·오버헤드, make_shared 비교, weak_ptr lock, 커스텀 삭제자 타입 소거, 프로덕션 패턴. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


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

C++, 스마트포인터, unique_ptr, shared_ptr, weak_ptr, 메모리관리 등으로 검색하시면 이 글이 도움이 됩니다.