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) + 생성자 쪽이 맞는 경우입니다.
- 커스텀 삭제자가 필요할 때 (
delete가 아닌 정리).make_unique는 삭제자를 받지 않습니다. - **
std::allocate_shared**나 커스텀 할당자로 제어 블록·객체 배치를 정밀하게 잡아야 할 때. shared_ptr의 생성이 클래스 내부 전용이어야 할 때(enable_shared_from_this와 함께 쓰는 패턴 등) — 설계에 따라make_shared를 밖에서 부르지 않습니다.make_shared로 인한 메모리 지연 해제가 문제일 때(큰 객체 + 오래 사는weak_ptr).- 배열:
make_unique<T[]>(n)는 가능하지만,shared_ptr의 배열은 C++20std::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 이전에는 함수 인자의 평가 순서가 정의되지 않았습니다. new와 unique_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_shared1번 vsnew2번 - 캐시 지역성:
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_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 + 제어 블록 함께 할당
벤치마크:
make_shared: ~50nsnew+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:
- “Effective Modern C++” by Scott Meyers (Item 21)
- “C++ Primer” by Stanley Lippman
- cppreference.com - make_unique
- cppreference.com - make_shared
관련 글: unique_ptr, shared_ptr, weak_ptr.
한 줄 요약: make_unique와 make_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 | 메모리 할당 완벽 비교