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 사용
auto ptr1 = std::unique_ptr<int>(new int(10));
auto ptr2 = std::shared_ptr<int>(new int(10));
// make 함수
auto ptr3 = std::make_unique<int>(10);
auto ptr4 = std::make_shared<int>(10);
new를 직접 쓰는 것과 비교하면 이점이 세 가지 있습니다. 타입 이름을 한 번만 쓰면 되고, 코드에 raw new가 드러나지 않아 “이 포인터의 소유권이 확실히 넘어갔는가”를 리뷰어가 따로 확인할 필요가 없습니다. C++14 이하에서는 함수 인자 평가 순서 때문에 생기는 누수도 막아 줍니다. 그리고 make_shared는 객체와 제어 블록을 한 번에 할당합니다. 반대로 make 함수를 쓸 수 없거나 쓰지 않는 편이 나은 경우도 있어서, 이 글에서는 그 경계를 함께 정리합니다.
make_unique와 new의 차이
std::make_unique<T>(args...)는 구현상 new T(std::forward<Args>(args)...)로 만든 포인터를 unique_ptr에 담아 돌려주는 것과 같지만, 호출부에서는 raw 포인터가 드러나지 않습니다.
| 관점 | new + 스마트 포인터 생성자 | make_unique / make_shared |
|---|---|---|
| 표현 | T를 두 번 쓰고 new 결과가 잠시 raw 포인터로 존재 | 타입은 한 번, 곧바로 스마트 포인터 |
| 예외 안전 (C++14 이하) | 다른 인자 평가가 끼어들면 누수 가능 | 함수 호출 하나로 생성과 포장이 끝남 |
| 커스텀 삭제자 | 생성자에 직접 넘기기 쉬움 | 표준 make_unique / make_shared는 삭제자 인자 없음 |
private 생성자 | 클래스 멤버 함수 안의 new는 가능 | 멤버 함수 안에서 불러도 호출 불가 (생성자를 부르는 주체가 라이브러리 함수이므로) |
즉, 기본 delete(배열이면 delete[])로 정리하면 되는 동적 객체에는 make 함수가 기본값이고, 파일 닫기나 free 같은 다른 정리가 필요하거나 접근 제어 때문에 라이브러리 함수가 생성자를 부를 수 없을 때는 new로 만든 포인터를 스마트 포인터 생성자에 넘기는 경로가 남습니다.
make_shared의 메모리 최적화
std::shared_ptr은 관리되는 객체와 제어 블록(강한 참조·약한 참조 카운트, 커스텀 삭제자/할당자 정보 등)을 둘 다 추적해야 합니다. shared_ptr<T>(new T) 형태는 객체용 메모리를 new로 이미 할당한 뒤 제어 블록을 따로 할당하므로 할당이 두 번 일어납니다. 반면 make_shared<T>(args...)는 주요 구현 모두 객체와 제어 블록을 한 번의 연속된 할당에 함께 둡니다.
// new 사용: [int] [제어 블록] ← 힙 할당 2번
auto ptr1 = std::shared_ptr<int>(new int(10));
// make_shared: [제어 블록 | int] ← 힙 할당 1번
auto ptr2 = std::make_shared<int>(10);
할당 횟수가 절반이 되면 할당기 호출 비용과 힙 메타데이터가 줄고, 객체와 참조 카운트가 가까이 있어 캐시 지역성에도 유리할 수 있습니다. 실제 시간 차이는 할당기(glibc malloc, jemalloc, mimalloc 등)와 스레드 경합 정도에 따라 크게 달라지므로, 정확한 수치가 필요하다면 대상 환경에서 직접 측정해야 합니다.
다만 객체가 매우 크고 weak_ptr이 오래 살아 남는 경우, make_shared로 묶인 덩어리 때문에 객체가 소멸한 뒤에도 메모리가 통째로 유지되는 현상이 생길 수 있습니다. 구조를 풀어 보면 이렇습니다. 강한 참조 카운트가 0이 되는 순간 T의 소멸자는 즉시 호출됩니다. 그러니 파일 핸들이나 소켓 같은 자원은 제때 닫힙니다. 남는 것은 T가 차지하던 바이트뿐입니다. 제어 블록은 약한 참조 카운트까지 0이 되어야 해제되는데, make_shared는 객체와 제어 블록이 한 블록이라 그 블록 전체를 함께 붙잡고 있게 됩니다.
auto ptr = std::make_shared<LargeObject>();
std::weak_ptr<LargeObject> weak = ptr;
ptr.reset(); // 소멸자는 호출되지만, weak가 살아 있는 동안 블록 전체가 유지됨
auto ptr2 = std::shared_ptr<LargeObject>(new LargeObject());
std::weak_ptr<LargeObject> weak2 = ptr2;
ptr2.reset(); // 객체 메모리는 즉시 해제되고, 작은 제어 블록만 남음
그래서 sizeof(T)가 수십 바이트인 일반적인 객체에서는 신경 쓸 필요가 없고, 내부에 큰 고정 배열을 품은 객체를 캐시에 weak_ptr로 대량 보관하는 경우처럼 큰 sizeof, 오래 사는 weak_ptr, 많은 개수가 겹칠 때만 문제가 됩니다. std::vector 멤버처럼 데이터가 별도 힙에 있는 경우는 소멸자에서 이미 해제되므로 해당되지 않습니다.
make_shared가 무시하는 것도 하나 있습니다. 클래스에 전용 operator new/operator delete를 정의해 메모리 풀에서 할당하도록 만들어 두었다면, make_shared는 제어 블록과 합친 크기를 표준 할당자로 한 번에 할당하므로 클래스 전용 operator new가 호출되지 않습니다. 풀 할당이 꼭 필요하다면 std::allocate_shared에 풀 기반 할당자를 넘기는 것이 올바른 방법입니다.
예외 안전성
void func(std::unique_ptr<int> ptr, int value);
int compute() {
throw std::runtime_error("에러");
}
// C++14 이하에서 누수 가능
func(std::unique_ptr<int>(new int(10)), compute());
// 허용되던 실행 순서:
// 1. new int(10)
// 2. compute() - 예외 발생!
// 3. unique_ptr 생성 (실행되지 않음) → int 누수
// 안전
func(std::make_unique<int>(10), compute());
C++14 이하에서는 함수 인자 안의 하위 표현식들이 서로 섞여 평가될 수 있었기 때문에, new int(10)이 실행된 뒤 unique_ptr이 만들어지기 전에 compute()가 끼어들어 예외를 던지면 할당된 int를 아무도 소유하지 않게 됩니다. make_unique는 함수 호출 하나이고, 다른 함수 호출과는 서로 끼어들 수 없으므로(indeterminately sequenced) make_unique가 끝나 unique_ptr이 만들어진 다음에야 compute()가 실행되거나, 아예 그 전에 실행됩니다. 어느 쪽이든 소유자가 없는 포인터가 생기는 순간이 없습니다.
C++17부터는 함수 인자 표현식에도 같은 규칙이 적용되었습니다. 인자들 사이의 순서는 여전히 정해져 있지 않지만, 한 인자의 평가가 끝나기 전에 다른 인자의 평가가 끼어들 수 없게 되어 위 예의 누수는 C++17에서 사라졌습니다. 그럼에도 make 함수는 의도를 분명히 하고 실수 여지를 줄인다는 점에서 권장 패턴으로 남습니다. 코드베이스가 C++14로 빌드될 수도 있고, 리뷰어가 new를 볼 때마다 짝이 되는 소유권 이전이 확실한지 확인하는 비용도 사라지기 때문입니다. make 함수를 쓸 수 없는 상황이라면 unique_ptr을 먼저 지역 변수로 만든 다음 함수에 std::move로 넘기는 식으로, raw 포인터가 존재하는 구간을 한 문장 안에 가두면 됩니다.
언제 make_unique / make_shared를 쓰지 말아야 하나
- 커스텀 삭제자가 필요할 때(
fclose,free등delete가 아닌 정리). 표준 make 함수는 삭제자를 받지 않습니다. - 생성자가
private이라 라이브러리 함수가 접근할 수 없을 때. 아래의 패스키 관용구로 우회할 수 있습니다. make_shared의 지연 해제가 문제일 때(큰 객체와 오래 사는weak_ptr).- 클래스 전용
operator new나 커스텀 할당자를 써야 할 때.shared_ptr라면std::allocate_shared가 대안입니다.
배열은 예외가 아닙니다. make_unique<T[]>(n)는 C++14부터 가능하고, shared_ptr도 C++17부터 shared_ptr<T[]>(new T[n])이 delete[]를 쓰도록 지원되며 C++20에서는 make_shared<T[]>(n)까지 표준화되었습니다.
자세한 커스텀 삭제자 내용은 C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법를 참고하세요.
배열: make_unique<T[]>(n)
C++14부터 std::make_unique<T[]>(n)는 길이 n의 동적 배열을 만들고, 삭제는 delete[]에 맞춰집니다. 주의할 점은 다음과 같습니다.
make_unique<int[]>(10)은 내부적으로new int[10]()를 호출하므로 값 초기화되어 모든 원소가 0입니다. 큰 버퍼를 곧바로 덮어쓸 예정이라 0으로 채우는 비용이 아깝다면, C++20의std::make_unique_for_overwrite<int[]>(n)를 쓰면 기본 초기화(쓰레기 값)로 할당만 합니다.- 배열 버전은 크기만 받으므로 원소마다 다른 인자로 생성할 수 없습니다. 이 경우와 크기가 바뀌는 경우에는
std::vector<T>가 맞습니다.
// 동적 배열 — delete[]와 짝을 맞춤
auto p = std::make_unique<std::string[]>(4);
p[0] = "a";
// C++20: shared 배열
auto s = std::make_shared<double[]>(100);
자주 발생하는 문제
커스텀 삭제자
// make_unique에는 삭제자를 지정하는 매개변수가 없음
// (두 번째 템플릿 인자는 생성자 인자 타입이지 삭제자 타입이 아님)
// 생성자에 포인터와 삭제자를 직접 넘김
auto closer = [](FILE* f) { if (f) std::fclose(f); };
auto file = std::unique_ptr<FILE, decltype(closer)>(std::fopen("file.txt", "r"), closer);
중괄호 초기화 리스트
// 컴파일 에러: 중괄호 목록에서는 Args를 추론할 수 없음
// 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}
);
make_unique는 인자를 Args&&...로 받는 템플릿이라, 타입이 없는 중괄호 목록 {1, 2, 3}에서는 Args를 추론하지 못합니다. 또 make 함수는 내부에서 소괄호로 생성하므로, std::make_unique<std::vector<int>>(10, 0)은 원소가 10, 0인 벡터가 아니라 0이 10개인 벡터가 됩니다.
private 생성자
class Widget {
Widget() {} // private
public:
static std::shared_ptr<Widget> create() {
// return std::make_shared<Widget>(); // 컴파일 에러
return std::shared_ptr<Widget>(new Widget()); // 가능
}
};
make_shared<Widget>()로 쓰면 GCC는 error: 'Widget::Widget()' is private within this context 같은 에러를 내는데, 에러 위치가 표준 라이브러리 헤더 안쪽으로 표시되어 처음 보면 원인을 찾기 어렵습니다. 팩토리 멤버 함수 안에서 new를 쓰는 것은 누수 창이 없는 단일 표현식이므로 안전합니다.
다만 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로만 존재해야 하는 타입에서 자주 쓰는 패턴입니다.
실무 패턴
팩토리 메서드
팩토리는 구체 타입을 숨기고 인터페이스만 노출할 때 unique_ptr와 잘 맞습니다. 추상 베이스의 unique_ptr<Base>를 돌려주면 소유권은 호출자에게 넘어가고, 파생 클래스는 구현 파일 안에서만 만들어집니다.
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; // 실패를 더 명시하려면 std::optional이나 std::expected(C++23)
}
make_shape처럼 파생 클래스의 생성자가 공개되어 있으면 make_unique<Circle>()를 그대로 쓸 수 있습니다. 반면 생성자를 private으로 막고 정적 팩토리만 열어 둔 설계에서는, 멤버 함수 안에서 호출하더라도 make_unique가 생성자에 접근하지 못합니다. 실제 생성자 호출은 std::make_unique 템플릿 내부에서 일어나고, 그 함수는 클래스의 friend가 아니기 때문입니다.
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");
리소스 관리
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_);
}
}
FileHandle(const FileHandle&) = delete; // 복사되면 fclose가 두 번 불림
FileHandle& operator=(const FileHandle&) = delete;
private:
FileHandle() : file_(nullptr) {}
};
auto file = FileHandle::open("data.txt"); // 스코프를 벗어나면 자동으로 닫힘
fopen 실패 시 예외를 던져도 handle이 이미 unique_ptr에 담겨 있으므로 FileHandle 객체는 누수 없이 소멸합니다. 다만 FILE*만 감싸는 것이 목적이라면 클래스를 따로 만들기보다 앞의 예처럼 std::unique_ptr<FILE, ...>에 삭제자를 지정하는 편이 더 간단합니다. 복사 금지, 이동 지원, 소멸 시 닫기가 전부 따라옵니다.
공유 캐시
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 자체는 그렇지 않습니다.
C++11에서 make_unique 직접 구현하기
make_unique는 C++14에 추가되었습니다. C++11 코드에서는 다음처럼 직접 만들어 쓰곤 했습니다.
// C++11용 단일 객체 버전 (배열 버전은 생략)
namespace mylib {
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
}
이런 자체 구현을 전역 네임스페이스에 두고 C++14 이상으로 올리면, using namespace std;가 있거나 인자가 std 타입일 때 ADL로 std::make_unique도 후보에 올라 call of overloaded 'make_unique<...>(...)' is ambiguous 에러가 납니다. 위처럼 별도 네임스페이스에 두고 호출할 때 mylib::make_unique로 명시하거나, C++14로 올린 뒤 삭제하는 것이 가장 깔끔합니다. make 함수의 장단점은 Scott Meyers의 “Effective Modern C++” Item 21에서도 자세히 다룹니다.