본문으로 건너뛰기
Previous
Next
C++ make_unique & make_shared | '스마트 포인터 생성' 가이드

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

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

이 글의 핵심

std::make_unique·make_shared로 스마트 포인터를 만드는 방법, new와의 차이, make_shared의 단일 할당·캐시 지역성, 예외 안전성, make_를 피해야 하는 경우(커스텀 삭제자 등), make_unique<T[]>, 팩토리 패턴까지 정리합니다.

make_unique & make_shared란?

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

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

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

일상 비유로 이해하기: 메모리를 아파트 건물로 생각해보세요. 스택은 엘리베이터 같아서 빠르지만 공간이 제한적입니다. 힙은 창고처럼 넓지만 물건을 찾는 데 시간이 걸립니다. 포인터는 “3층 302호”처럼 주소를 가리키는 메모지라고 보면 됩니다.

예외 안전성 심화

위의 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 배열 생성이 표준화되었습니다.

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

// 동적 배열 — 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)

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

// ❌ 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 + 제어 블록 함께 할당

메모리 레이아웃:

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

// 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: 기본 사용

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

// 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 성능

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

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

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

자주 발생하는 문제

문제 1: 커스텀 삭제자

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

// ❌ 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: 초기화 리스트

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

// ❌ 중괄호 초기화
// 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: 메모리 해제 타이밍

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

// 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());

권장사항

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

// ✅ 기본적으로 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부터 배열 지원

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

// 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는 객체와 제어 블록을 함께 할당합니다.

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

// 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: 소괄호 사용이 필요합니다. 중괄호는 직접 사용할 수 없습니다.

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

// ❌ 중괄호: 직접 사용 불가
// 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이 남아있으면 객체는 소멸되지만 메모리는 해제되지 않습니다.

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

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/C++ 예제 코드입니다.

// 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++ make_unique & make_shared | ‘스마트 포인터 생성’ 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(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++ make_unique & make_shared | ‘스마트 포인터 생성’ 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  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 순서를 권장합니다.


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

C++, make_unique, make_shared, smart-pointer, C++14 등으로 검색하시면 이 글이 도움이 됩니다.