C++ make_unique·make_shared: new보다 나은 이유와 make 함수를 쓰면 안 되는 경우

이 글의 핵심

C++17 이전에는 func(unique_ptr<int>(new int(10)), compute()) 같은 호출에서 인자 평가 순서 때문에 누수가 날 수 있었고, make 함수는 생성과 소유권 이전을 한 경로로 묶어 이 틈을 없앱니다. make_unique<T[]>(n)로 배열을 만드는 법, 중괄호 초기화 리스트를 넘길 수 없는 제약, 팩토리 메서드와 공유 캐시 패턴도 함께 다룹니다.

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_unique·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)가 유리할 수 있습니다.

이 현상이 생기는 구조를 조금 더 풀어 보면 이렇습니다. 강한 참조 카운트가 0이 되는 순간 T의 소멸자는 즉시 호출됩니다. 그러니 파일 핸들이나 소켓 같은 자원은 제때 닫힙니다. 남는 것은 T가 차지하던 바이트뿐입니다. 제어 블록은 약한 참조 카운트까지 0이 되어야 해제되는데, make_shared는 객체와 제어 블록이 한 블록이라 그 블록 전체를 함께 붙잡고 있게 됩니다. 그래서 sizeof(T)가 수십 바이트인 일반적인 객체에서는 신경 쓸 필요가 없고, 내부에 큰 고정 배열을 품은 객체를 캐시에 weak_ptr로 대량 보관하는 경우처럼 “큰 sizeof × 오래 사는 weak_ptr × 많은 개수”가 겹칠 때만 문제가 됩니다. std::vector 멤버처럼 데이터가 별도 힙에 있는 경우는 소멸자에서 이미 해제되므로 해당되지 않습니다.

make_shared가 무시하는 것도 하나 있습니다. 클래스에 전용 operator new/operator delete를 정의해 메모리 풀에서 할당하도록 만들어 두었다면, make_shared는 제어 블록과 합친 크기를 표준 할당자로 한 번에 할당하므로 클래스 전용 operator new가 호출되지 않습니다. 풀 할당이 꼭 필요하다면 std::allocate_shared에 풀 기반 할당자를 넘기는 것이 올바른 방법입니다.

예외 안전성 심화

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

C++17부터는 함수 호출의 인자 표현식에 대해 더 엄격한 순서 규칙이 생겼습니다. 인자들 사이의 순서는 여전히 정해져 있지 않지만, 한 인자의 평가가 끝나기 전에 다른 인자의 평가가 끼어들 수 없게 되었습니다(indeterminately sequenced). 그래서 new int(10) → compute() → unique_ptr 생성처럼 섞이는 순서가 금지되어 위 예의 누수는 C++17에서 사라졌습니다. 그럼에도 여전히 make_가 의도를 분명히 하고 실수 여지를 줄인다는 점에서 권장 패턴으로 남습니다. 코드베이스가 C++14로 빌드될 수도 있고, 리뷰어가 new를 볼 때마다 “짝이 되는 소유권 이전이 확실한가”를 확인하는 비용도 사라지기 때문입니다. 강한 예외 안전을 요구하는 코드에서는 “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)은 내부적으로 new int[10]()를 호출하므로 값 초기화되어 모든 원소가 0입니다. 큰 버퍼를 곧바로 덮어쓸 예정이라 0으로 채우는 비용이 아깝다면, C++20의 std::make_unique_for_overwrite<int[]>(n)를 쓰면 기본 초기화(쓰레기 값)로 할당만 합니다.
  • 배열 버전은 크기만 받으므로 원소마다 다른 인자로 생성할 수 없습니다. 이 경우와 크기가 바뀌는 경우에는 std::vector<T>가 맞습니다.
  • 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
}

위 make_shape처럼 파생 클래스의 생성자가 공개되어 있으면 make_unique<Circle>()를 그대로 쓸 수 있습니다. 반면 아래 Database::connect처럼 생성자를 private으로 막고 정적 팩토리만 열어 둔 설계에서는, 멤버 함수 안에서 호출하더라도 make_unique가 생성자에 접근하지 못합니다. 실제 생성자 호출은 std::make_unique 템플릿 내부에서 일어나고, 그 함수는 클래스의 friend가 아니기 때문입니다.

장점 상세

예외 안전성

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 이전에는 함수 인자의 평가 순서가 정의되지 않았습니다. new와 unique_ptr 생성 사이에 예외가 발생하면 메모리 누수가 발생할 수 있습니다.

성능 (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가 더 적음

간결함

// ❌ 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) {
        // make_unique<Widget>(id)는 private 생성자 때문에 컴파일 에러
        return std::unique_ptr<Widget>(new Widget(id));
    }
    
private:
    explicit Widget(int id) {}  // private 생성자
};

make_unique<Widget>(id)로 쓰면 GCC는 error: 'Widget::Widget(int)' is private within this context를 내는데, 에러 위치가 <bits/unique_ptr.h> 안쪽으로 표시되어 처음 보면 원인을 찾기 어렵습니다. 팩토리 멤버 함수 안에서 new를 쓰는 것은 누수 창이 없는 단일 표현식이므로 안전합니다.

자주 발생하는 문제

문제 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());
    }
};

new로 돌아가면 make_shared의 단일 할당 이점을 잃습니다. 그 이점까지 지키고 싶다면 패스키(passkey) 관용구를 씁니다. 생성자는 public으로 두되, 클래스 밖에서는 만들 수 없는 private 태그 타입을 인자로 요구하는 방식입니다.

class Widget {
    struct Key { explicit Key() = default; };  // 외부에서 생성 불가
public:
    explicit Widget(Key) {}                    // public이지만 Key 없이는 호출 불가
    static std::shared_ptr<Widget> create() {
        return std::make_shared<Widget>(Key{}); // 단일 할당 유지
    }
};

make_shared 안에서 생성자를 호출하는 것은 문제없고(생성자가 public), 외부 코드는 Widget::Key에 접근할 수 없어 직접 생성을 막을 수 있습니다. enable_shared_from_this를 쓰는 클래스처럼 “반드시 shared_ptr로만 존재해야 하는” 타입에서 자주 쓰는 패턴입니다.

문제 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) {
        std::unique_ptr<Database> db(new Database());  // private 생성자라 make_unique 불가
        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) {
        std::unique_ptr<FileHandle> handle(new FileHandle());  // private 생성자
        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");
// 자동으로 파일 닫힘

fopen 실패 시 예외를 던져도 handle이 이미 unique_ptr에 담겨 있으므로 FileHandle 객체는 누수 없이 소멸합니다. 다만 이 경우 FILE*만 감싸는 것이 목적이라면 클래스를 따로 만들기보다 std::unique_ptr<FILE, decltype(&std::fclose)>처럼 삭제자를 지정하는 편이 더 간단합니다. 복사 금지, 이동 지원, 소멸 시 닫기가 전부 공짜로 따라옵니다.

패턴 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");  // 같은 객체 공유

이 캐시는 shared_ptr을 맵에 들고 있으므로 한 번 만든 Data가 캐시가 살아 있는 동안 절대 해제되지 않습니다. 사용하는 쪽이 모두 놓으면 자동으로 사라지게 하려면 맵 값을 std::weak_ptr<Data>로 바꾸고 lock()이 nullptr을 돌려줄 때 다시 만드는 방식이 흔합니다. 그런데 바로 이 구조가 앞에서 설명한 make_shared의 지연 해제 조건(오래 사는 weak_ptr)과 정확히 일치합니다. Data의 sizeof가 크다면 이 경우만큼은 shared_ptr<Data>(new Data(key))로 분리 할당하거나, 만료된 항목을 주기적으로 맵에서 지워 제어 블록까지 풀어 주는 것이 좋습니다. 여러 스레드에서 get을 부른다면 맵 접근에 뮤텍스도 필요합니다. shared_ptr의 참조 카운트는 원자적이지만 std::map 자체는 그렇지 않습니다.

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_unique·make_shared 모두 private 생성자 접근 불가 (패스키 관용구로 우회 가능)
  • 메모리 해제 타이밍: make_shared는 weak_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 + 제어 블록 함께 할당

실제 시간 차이는 할당기(glibc malloc, jemalloc, mimalloc 등)와 스레드 경합 정도에 따라 크게 달라지므로, 정확한 수치가 필요하다면 대상 환경에서 직접 측정해야 합니다. 할당 횟수가 절반이 된다는 사실 자체는 구현과 무관하게 성립합니다.

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_unique와 std::make_unique가 충돌합니다

A: C++11 시절 코드에 위의 전역 make_unique를 두고 C++14 이상으로 올리면, using namespace std;가 있거나 인자가 std 타입일 때 ADL로 std::make_unique도 후보에 올라 call of overloaded 'make_unique<...>(...)' is ambiguous 에러가 납니다. 자체 구현은 mylib::make_unique처럼 별도 네임스페이스에 두고 호출할 때 네임스페이스를 명시하거나, C++14로 올린 뒤 삭제하는 것이 가장 깔끔합니다. 이 내용은 Scott Meyers의 “Effective Modern C++” Item 21에서도 make 함수의 장단점과 함께 자세히 다룹니다.

관련 글: unique_ptr, shared_ptr, weak_ptr.

make_unique와 make_shared는 스마트 포인터를 안전하고 효율적으로 생성하는 함수입니다.


같이 보면 좋은 글