C++ make_unique & make_shared | "스마트 포인터 생성" 가이드

C++ make_unique & make_shared | "스마트 포인터 생성" 가이드

이 글의 핵심

make_unique·make_shared와 new의 차이, make_shared 단일 할당·weak_ptr과의 메모리 트레이드오프, 예외 안전성, make_를 피하는 경우, make_unique<T[]>, 팩토리 패턴을 정리합니다.

make_unique & make_shared란?

std::make_unique (C++14)와 std::make_shared (C++11)는 스마트 포인터를 안전하고 효율적으로 생성하는 함수입니다. new를 직접 사용하는 것보다 예외 안전하고, make_shared는 성능도 더 좋습니다.

// ❌ new 사용
auto ptr1 = std::unique_ptr<int>(new int(10));
auto ptr2 = std::shared_ptr<int>(new int(10));

// ✅ make 함수
auto ptr1 = std::make_unique<int>(10);
auto ptr2 = std::make_shared<int>(10);

왜 필요한가?:

  • 예외 안전: 메모리 누수 방지
  • 성능: make_shared는 할당 1번 (new는 2번)
  • 간결함: 타입을 한 번만 작성
  • 명확함: 의도가 명확

make_unique와 new의 차이

std::make_unique<T>(args...)는 구현상 new T(...)와 동등한 생성을 한 번에 감싼 것이지만, 호출부에서는 raw 포인터가 드러나지 않습니다.

관점new + 스마트 포인터 생성자make_unique / make_shared
표현T를 여러 번 쓰거나 new 결과가 중간에 남음타입은 한 번, 곧바로 스마트 포인터
예외 안전 (구 규칙)여러 전체 표현식이 섞이면 순서 이슈단일 함수 호출로 생성 경로가 단순
커스텀 삭제자생성자에 직접 넘기기 쉬움표준 make_unique / make_shared삭제자 인자 없음
private 생성자같은 클래스의 new는 가능make_shared는 비프렌드 외부에서 호출 불가인 경우가 있음

즉, **“기본 delete로 충분한 동적 객체”**에는 make_가 기본값이고, delete가 아닌 정리(파일 닫기, delete[], free 등)나 접근 제어가 막힌 생성에서는 new(또는 allocate_shared 등) 경로가 남습니다.

make_shared의 메모리 최적화 (심화)

std::shared_ptr관리되는 객체제어 블록(강한 참조·약한 참조 카운트, 커스텀 삭제자/할당자 정보 등)을 둘 다 추적해야 합니다. shared_ptr<T>(new T) 형태는 흔히 객체용 메모리제어 블록용 메모리각각 할당합니다. 반면 make_shared<T>(args...)는 구현에 따라 한 번의 연속된 할당에 객체와 제어 블록을 함께 둘 수 있어, 다음이 기대됩니다.

  • 할당 횟수 감소: 힙 트래킹·락 경합이 있는 환경에서 체감될 수 있음.
  • 지역성: 객체와 제어 블록이 인접하면 캐시 친화적일 수 있음.
  • 오버헤드: 두 번의 operator new 호출을 한 번으로 줄이는 효과.

다만 객체가 매우 크고 weak_ptr이 오래 살아 남는 경우, make_shared로 묶인 덩어리 때문에 객체 본문이 쓸모없어진 뒤에도 메모리가 통째로 유지되는 현상이 생길 수 있습니다(아래 “메모리 해제 타이밍”과 FAQ 참고). 그런 프로파일이면 shared_ptr<T>(new T)가 유리할 수 있습니다.

예외 안전성 심화

위의 func(std::unique_ptr<int>(new int(10)), compute()) 예는 C++17 이전에는 인자 평가 순서가 제한적으로만 보장되어, new까지 실행된 뒤 compute()에서 예외가 나면 unique_ptr이 만들어지지 않아 누수가 날 수 있었습니다. make_unique 한 번의 호출로 객체 생성과 스마트 포인터 포장이 한 경로로 묶이면 이런 “중간에 raw 소유권이 남는 창”이 줄어듭니다.

C++17부터는 함수 호출의 인자 표현식에 대해 더 엄격한 순서 규칙이 생겼지만, 여전히 make_가 의도를 분명히 하고 실수 여지를 줄인다는 점에서 권장 패턴으로 남습니다. 강한 예외 안전을 요구하는 코드에서는 “raw new 결과를 지역 변수에 담은 뒤 unique_ptr로 이전”처럼 단계를 나누는 것도 한 방법입니다.

언제 make_unique / make_shared를 쓰지 말아야 하나

다음은 대표적으로 new(또는 allocate_shared) + 생성자 쪽이 맞는 경우입니다.

  1. 커스텀 삭제자가 필요할 때 (delete가 아닌 정리). make_unique는 삭제자를 받지 않습니다.
  2. **std::allocate_shared**나 커스텀 할당자로 제어 블록·객체 배치를 정밀하게 잡아야 할 때.
  3. shared_ptr의 생성이 클래스 내부 전용이어야 할 때(enable_shared_from_this와 함께 쓰는 패턴 등) — 설계에 따라 make_shared를 밖에서 부르지 않습니다.
  4. make_shared로 인한 메모리 지연 해제가 문제일 때(큰 객체 + 오래 사는 weak_ptr).
  5. 배열: make_unique<T[]>(n)는 가능하지만, shared_ptr의 배열은 C++20 std::make_shared<T[]>(n) 이전에는 관례적으로 커스텀 삭제자나 shared_ptr 특수화를 썼습니다.

자세한 커스텀 삭제자 내용은 C++ Custom Deleters 가이드를 참고하세요.

배열: make_unique<T[]>(n) 심화

C++14부터 std::make_unique<T[]>(n)길이 n의 동적 배열을 만들고, 삭제는 **delete[]**에 맞춰집니다. 주의할 점은 다음과 같습니다.

  • 요소별 생성자 호출이 필요하면 std::vector<T>가 더 단순한 경우가 많습니다.
  • make_unique<int[]>(10)기본 초기화 규칙을 따릅니다(타입에 따라 초기화되지 않은 값이 있을 수 있음). 값을 0으로 두려면 std::vector나 루프 초기화를 고려하세요.
  • C++20에서는 std::make_shared<T[]>(n)shared_ptr 배열 생성이 표준화되었습니다.
// 동적 배열 — delete[]와 짝을 맞춤
auto p = std::make_unique<std::string[]>(4);
p[0] = "a";

// C++20: shared 배열
auto s = std::make_shared<double[]>(100);

실전 팩토리 패턴 (보강)

팩토리는 구체 타입을 숨기고 인터페이스만 노출할 때 unique_ptr/shared_ptr와 잘 맞습니다.

  • 추상 베이스 + unique_ptr<Base> 반환: 소유권을 호출자에게 넘기며, 구현 파일에서만 파생 클래스를 new하거나 make_unique합니다.
  • 실패 가능한 생성: std::optional<std::unique_ptr<T>> 또는 expected 스타일로 “생성 실패”를 명시적으로 표현합니다.
  • 공유 캐시: 동일 키에 대해 shared_ptr을 재사용하려면 make_shared로 한 번 만들고 맵에 넣는 패턴이 흔합니다(아래 “공유 캐시” 예시 참고).
struct Shape { virtual ~Shape() = default; };
struct Circle : Shape {};

inline std::unique_ptr<Shape> make_shape(const std::string& kind) {
    if (kind == "circle") {
        return std::make_unique<Circle>();
    }
    return nullptr; // 또는 optional
}

Database::connect 예시처럼 private 생성자 + 정적 멤버에서만 make_unique<Impl>을 호출하면, 호출자는 구체 DB 타입을 몰라도 됩니다.

장점 상세

1. 예외 안전성

void func(std::unique_ptr<int> ptr, int value) {
    // ...
}

int compute() {
    throw std::runtime_error("에러");
}

// ❌ new: 예외 시 누수 가능
func(std::unique_ptr<int>(new int(10)), compute());
// 실행 순서가 보장되지 않음:
// 1. new int(10)
// 2. compute() - 예외 발생!
// 3. unique_ptr 생성 (실행 안됨)
// → 메모리 누수

// ✅ make_unique: 안전
func(std::make_unique<int>(10), compute());
// make_unique는 원자적으로 실행
// → 메모리 누수 없음

이유: C++17 이전에는 함수 인자의 평가 순서가 정의되지 않았습니다. newunique_ptr 생성 사이에 예외가 발생하면 메모리 누수가 발생할 수 있습니다.

2. 성능 (shared_ptr)

// ❌ new: 할당 2번
auto ptr1 = std::shared_ptr<int>(new int(10));
// 1. int 할당 (4바이트)
// 2. 제어 블록 할당 (참조 카운트 등)

// ✅ make_shared: 할당 1번
auto ptr2 = std::make_shared<int>(10);
// int + 제어 블록 함께 할당

메모리 레이아웃:

// new 사용:
// [int] (힙 영역 1)
// [제어 블록] (힙 영역 2)

// make_shared:
// [int | 제어 블록] (힙 영역 1)

성능 비교:

  • 할당 횟수: make_shared 1번 vs new 2번
  • 캐시 지역성: make_shared가 더 좋음 (연속된 메모리)
  • 할당 오버헤드: make_shared가 더 적음

3. 간결함

// ❌ new: 타입 2번
auto ptr = std::unique_ptr<VeryLongTypeName>(new VeryLongTypeName(args));

// ✅ make_unique: 타입 1번
auto ptr = std::make_unique<VeryLongTypeName>(args);

실전 예시

예시 1: 기본 사용

// unique_ptr
auto ptr1 = std::make_unique<int>(42);
auto ptr2 = std::make_unique<std::string>("Hello");
auto ptr3 = std::make_unique<std::vector<int>>(10, 0);

// shared_ptr
auto ptr4 = std::make_shared<int>(42);
auto ptr5 = std::make_shared<std::string>("Hello");

예시 2: 배열

// C++14: make_unique 배열
auto arr1 = std::make_unique<int[]>(10);

// C++20: make_shared 배열
auto arr2 = std::make_shared<int[]>(10);

// 초기화
for (int i = 0; i < 10; i++) {
    arr1[i] = i;
}

예시 3: 예외 안전성

void process(std::unique_ptr<Widget> w, int value) {
    // ...
}

int compute() {
    throw std::runtime_error("에러");
}

int main() {
    // ❌ 예외 시 누수 가능
    // process(std::unique_ptr<Widget>(new Widget()), compute());
    
    // ✅ 안전
    process(std::make_unique<Widget>(), compute());
}

예시 4: 팩토리

class Widget {
public:
    static std::unique_ptr<Widget> create(int id) {
        return std::make_unique<Widget>(id);
    }
    
private:
    Widget(int id) {}  // private 생성자
};

make_shared 성능

// ❌ 할당 2번
auto ptr = std::shared_ptr<int>(new int(10));
// 1. int 할당
// 2. 제어 블록 할당

// ✅ 할당 1번
auto ptr = std::make_shared<int>(10);
// int + 제어 블록 함께 할당

자주 발생하는 문제

문제 1: 커스텀 삭제자

// ❌ make_unique는 커스텀 삭제자 불가
auto deleter = [](int* p) { delete p; };
// auto ptr = std::make_unique<int, decltype(deleter)>(10, deleter);

// ✅ 생성자 사용
auto ptr = std::unique_ptr<int, decltype(deleter)>(
    new int(10), deleter
);

문제 2: 초기화 리스트

// ❌ 중괄호 초기화
// auto ptr = std::make_unique<std::vector<int>>({1, 2, 3});

// ✅ 소괄호
auto ptr = std::make_unique<std::vector<int>>(
    std::initializer_list<int>{1, 2, 3}
);

문제 3: private 생성자

class Widget {
    Widget() {}  // private
    
public:
    static std::shared_ptr<Widget> create() {
        // ❌ make_shared 불가
        // return std::make_shared<Widget>();
        
        // ✅ new 사용
        return std::shared_ptr<Widget>(new Widget());
    }
};

문제 4: 메모리 해제 타이밍

// make_shared: 제어 블록과 객체 함께 할당
auto ptr = std::make_shared<LargeObject>();
std::weak_ptr<LargeObject> weak = ptr;

ptr.reset();  // 객체 소멸하지만 메모리는 weak_ptr 때문에 유지

// ✅ new 사용 시 객체 메모리만 해제
auto ptr = std::shared_ptr<LargeObject>(new LargeObject());

권장사항

// ✅ 기본적으로 make 함수
auto ptr1 = std::make_unique<Widget>();
auto ptr2 = std::make_shared<Widget>();

// ❌ new 사용 (특별한 경우만)
// - 커스텀 삭제자
// - private 생성자
// - 메모리 해제 타이밍 제어

실무 패턴

패턴 1: 팩토리 메서드

class Database {
public:
    static std::unique_ptr<Database> connect(const std::string& url) {
        auto db = std::make_unique<Database>();
        db->connect_impl(url);
        return db;
    }
    
private:
    Database() = default;
    void connect_impl(const std::string& url) {
        // 연결 로직
    }
};

// 사용
auto db = Database::connect("postgres://localhost");

패턴 2: 리소스 관리

class FileHandle {
    FILE* file_;
    
public:
    static std::unique_ptr<FileHandle> open(const std::string& path) {
        auto handle = std::make_unique<FileHandle>();
        handle->file_ = fopen(path.c_str(), "r");
        if (!handle->file_) {
            throw std::runtime_error("파일 열기 실패");
        }
        return handle;
    }
    
    ~FileHandle() {
        if (file_) {
            fclose(file_);
        }
    }
    
private:
    FileHandle() : file_(nullptr) {}
};

// 사용
auto file = FileHandle::open("data.txt");
// 자동으로 파일 닫힘

패턴 3: 공유 캐시

class Cache {
    std::map<std::string, std::shared_ptr<Data>> cache_;
    
public:
    std::shared_ptr<Data> get(const std::string& key) {
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            return it->second;  // 공유
        }
        
        // 새로 생성
        auto data = std::make_shared<Data>(key);
        cache_[key] = data;
        return data;
    }
};

// 사용
Cache cache;
auto data1 = cache.get("user:123");
auto data2 = cache.get("user:123");  // 같은 객체 공유

FAQ

Q1: make 함수의 장점은?

A:

  • 예외 안전성: 메모리 누수 방지
  • 성능: make_shared는 할당 1번
  • 간결함: 타입을 한 번만 작성
  • 명확함: 의도가 명확
// make_unique: 예외 안전
func(std::make_unique<int>(10), compute());

// make_shared: 성능
auto ptr = std::make_shared<int>(10);  // 할당 1번

Q2: 언제 new를 사용하나요?

A:

  • 커스텀 삭제자: make_unique는 커스텀 삭제자 불가
  • private 생성자: make_shared는 private 생성자 접근 불가
  • 메모리 해제 타이밍: make_sharedweak_ptr 때문에 메모리 해제 지연
// 커스텀 삭제자
auto deleter = [](FILE* f) { if (f) std::fclose(f); };
auto ptr = std::unique_ptr<FILE, decltype(deleter)>(
    std::fopen("file.txt", "r"), deleter
);

Q3: 배열은 어떻게 생성하나요?

A:

  • make_unique: C++14부터 배열 지원
  • make_shared: C++20부터 배열 지원
// make_unique: C++14
auto arr1 = std::make_unique<int[]>(10);
arr1[0] = 42;

// make_shared: C++20
auto arr2 = std::make_shared<int[]>(10);
arr2[0] = 42;

Q4: make_shared의 성능 차이는?

A: 할당 1번 vs 2번입니다. make_shared는 객체와 제어 블록을 함께 할당합니다.

// new: 할당 2번
auto ptr1 = std::shared_ptr<int>(new int(10));
// 1. int 할당
// 2. 제어 블록 할당

// make_shared: 할당 1번
auto ptr2 = std::make_shared<int>(10);
// int + 제어 블록 함께 할당

벤치마크:

  • make_shared: ~50ns
  • new + shared_ptr: ~100ns

Q5: 초기화 리스트는 어떻게 사용하나요?

A: 소괄호 사용이 필요합니다. 중괄호는 직접 사용할 수 없습니다.

// ❌ 중괄호: 직접 사용 불가
// auto ptr = std::make_unique<std::vector<int>>({1, 2, 3});

// ✅ 소괄호 + initializer_list
auto ptr = std::make_unique<std::vector<int>>(
    std::initializer_list<int>{1, 2, 3}
);

// ✅ 또는 임시 벡터
auto ptr2 = std::make_unique<std::vector<int>>(
    std::vector<int>{1, 2, 3}
);

Q6: make_shared의 단점은?

A: 메모리 해제 지연입니다. weak_ptr이 남아있으면 객체는 소멸되지만 메모리는 해제되지 않습니다.

auto ptr = std::make_shared<LargeObject>(1000000);
std::weak_ptr<LargeObject> weak = ptr;

ptr.reset();  // 객체 소멸, 하지만 메모리는 유지 (weak_ptr 때문)

// new 사용 시:
auto ptr2 = std::shared_ptr<LargeObject>(new LargeObject(1000000));
std::weak_ptr<LargeObject> weak2 = ptr2;

ptr2.reset();  // 객체 메모리 즉시 해제, 제어 블록만 유지

Q7: make_unique는 C++11에 없나요?

A: 없습니다. C++14에 추가되었습니다. C++11에서는 직접 구현할 수 있습니다.

// C++11: 직접 구현
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

Q8: make 함수 학습 리소스는?

A:

관련 글: unique_ptr, shared_ptr, weak_ptr.

한 줄 요약: make_uniquemake_shared는 스마트 포인터를 안전하고 효율적으로 생성하는 함수입니다.


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

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

  • C++ Custom Deleters | “커스텀 삭제자” 가이드
  • C++ weak_ptr | “약한 포인터” 가이드
  • C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr

관련 글

  • C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
  • C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
  • 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기
  • C++ Chrono Literals |
  • C++ malloc vs new vs make_unique | 메모리 할당 완벽 비교