C++ malloc vs new vs make_unique: 메모리 할당 방식별 안전성 비교

이 글의 핵심

malloc은 크기만큼 원시 메모리를 주고, new는 그 위에 생성자를 호출하며, make_unique는 여기에 해제 책임까지 객체에 묶습니다. 세 방식의 실패 처리 차이(nullptr·bad_alloc·nothrow), 예외가 날 때 누수가 생기는 경로, 그리고 세 방식의 속도가 사실상 같은 이유와 잘못된 벤치마크의 함정을 정리합니다.

들어가며

C++에서 메모리 할당은 malloc, new, make_unique 세 가지 방법이 있습니다. 각각 생성자 호출, 타입 안전성, 자동 해제 등에서 차이가 있습니다. 비유로 말씀드리면, malloc/free는 토지만 임대, new/delete는 건물을 짓고 철거, make_unique는 관리 회사에 맡겨 계약 종료 시 자동 철거에 가깝습니다. 현대 C++에서는 가능하면 RAII가 되는 경로를 택하시는 것이 좋습니다.

세 방식을 층으로 보면 이해가 쉽습니다. 가장 아래에 malloc이 있고, new 표현식은 operator new(보통 내부에서 malloc)로 메모리를 받은 뒤 그 위에 생성자를 호출합니다. make_unique는 new로 만든 포인터를 unique_ptr에 담아 해제 책임을 객체의 수명에 묶습니다. 위층으로 갈수록 “메모리를 얻는다”는 일은 같고, 사람이 기억해야 할 규칙(생성자 호출, 해제 함수 짝, 예외 경로의 해제)이 하나씩 자동화된다고 보면 됩니다.


malloc vs new vs make_unique 차이

비교표

항목mallocnewmake_unique
생성자 호출❌✅✅
타입 안전성❌ (캐스팅 필요)✅✅
예외 처리nullptr 반환bad_alloc 던짐bad_alloc 던짐
해제 방법freedelete자동
배열 할당malloc(n * sizeof(T))new T[n]make_unique<T[]>(n)
RAII❌❌✅
예외 안전❌❌✅
C++11 이후C 호환레거시✅ 권장

실전 구현

malloc: C 스타일

#include <cstdlib>
#include <iostream>
int main() {
    // malloc: 생성자 호출 안 됨
    int* p = (int*)malloc(sizeof(int));
    
    if (p == nullptr) {
        std::cerr << "할당 실패" << std::endl;
        return 1;
    }
    
    *p = 42;
    std::cout << *p << std::endl;
    
    free(p);
    
    return 0;
}

C에서는 void*가 다른 포인터로 암시적 변환되므로 (int*) 캐스트 없이도 되지만, C++에서는 캐스트가 없으면 invalid conversion from 'void*' to 'int*' 에러가 납니다. 이 캐스트가 비교표의 “타입 안전성 ❌“의 의미입니다. 캐스트가 있으면 컴파일러는 크기가 맞는지 확인하지 않으므로, (double*)malloc(sizeof(int))처럼 잘못 써도 조용히 컴파일되고 쓰는 순간 버퍼를 넘어섭니다. int처럼 생성자가 없는 타입은 이렇게 써도 동작하지만, std::string이나 std::vector를 멤버로 가진 클래스를 malloc한 메모리에서 바로 쓰면 초기화되지 않은 내부 포인터를 따라가 크래시가 납니다. 이런 타입에 malloc을 써야 하는 특수한 경우(커스텀 할당자, 메모리 풀)라면 new (p) T(args...) 형태의 placement new로 생성자를 직접 호출하고, 해제 전에 p->~T()로 소멸자를 부른 뒤 free해야 합니다.


new: C++ 스타일

#include <iostream>
class MyClass {
private:
    int x_;
    
public:
    MyClass(int x) : x_(x) {
        std::cout << "생성자: " << x_ << std::endl;
    }
    
    ~MyClass() {
        std::cout << "소멸자: " << x_ << std::endl;
    }
    
    int getValue() const { return x_; }
};
int main() {
    // new: 생성자 호출
    MyClass* p = new MyClass(42);
    std::cout << p->getValue() << std::endl;
    delete p;  // 소멸자 호출
    
    return 0;
}

출력:

생성자: 42
42
소멸자: 42

make_unique: 현대 C++ (C++14)

#include <iostream>
#include <memory>
class MyClass {
private:
    int x_;
    
public:
    MyClass(int x) : x_(x) {
        std::cout << "생성자: " << x_ << std::endl;
    }
    
    ~MyClass() {
        std::cout << "소멸자: " << x_ << std::endl;
    }
    
    int getValue() const { return x_; }
};
int main() {
    // make_unique: 생성자 호출 + 자동 해제
    {
        auto p = std::make_unique<MyClass>(42);
        std::cout << p->getValue() << std::endl;
    }  // 자동으로 소멸자 호출
    
    std::cout << "블록 종료 후" << std::endl;
    
    return 0;
}

출력:

생성자: 42
42
소멸자: 42
블록 종료 후

배열 할당

#include <iostream>
#include <memory>
int main() {
    // malloc: 배열
    int* arr1 = (int*)malloc(10 * sizeof(int));
    for (int i = 0; i < 10; ++i) {
        arr1[i] = i;
    }
    free(arr1);
    
    // new: 배열
    int* arr2 = new int[10];
    for (int i = 0; i < 10; ++i) {
        arr2[i] = i;
    }
    delete[] arr2;  // delete[]
    
    // make_unique: 배열
    auto arr3 = std::make_unique<int[]>(10);
    for (int i = 0; i < 10; ++i) {
        arr3[i] = i;
    }
    // 자동 해제
    
    return 0;
}

세 줄은 비슷해 보이지만 초기화 동작이 다릅니다. malloc과 new int[10]은 원소를 초기화하지 않아 쓰레기 값이 들어 있고, make_unique<int[]>(10)은 new int[10]()처럼 값 초기화해서 0으로 채웁니다. 대부분은 이 차이가 안전 쪽으로 작용하지만, 수 MB짜리 버퍼를 할당하자마자 파일 내용으로 덮어쓰는 코드라면 0으로 채우는 비용이 그대로 낭비됩니다. C++20의 std::make_unique_for_overwrite<int[]>(n)은 이 초기화를 생략하는 버전입니다. 또 unique_ptr<int[]>는 크기를 기억하지 않으므로 길이를 따로 들고 다녀야 하는데, 크기가 필요하고 늘어날 수도 있는 배열이라면 대개 std::vector<int>가 더 맞는 선택입니다.


예외 안전성

#include <iostream>
#include <memory>
void process(int* data, int size) {
    if (size <= 0) {
        throw std::invalid_argument("size must be positive");
    }
    
    for (int i = 0; i < size; ++i) {
        data[i] = i;
    }
}
int main() {
    // ❌ new: 예외 발생 시 메모리 누수
    int* p1 = new int[10];
    try {
        process(p1, -1);  // 예외 발생
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
        delete[] p1;  // 수동 해제 필요
    }
    
    // ✅ make_unique: 예외 발생 시 자동 해제
    try {
        auto p2 = std::make_unique<int[]>(10);
        process(p2.get(), -1);  // 예외 발생
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
        // 자동 해제
    }
    
    return 0;
}

new 쪽 코드는 catch 블록에서 해제하므로 이 예제에서는 누수가 없지만, 예외가 잡히지 않는 경로(다른 예외 타입, 예외 없이 중간에 return하는 분기)가 하나만 추가돼도 delete[]가 실행되지 않습니다. 해제 코드를 모든 탈출 경로에 복사해야 한다는 점이 수동 관리의 근본적인 약점이고, unique_ptr은 스택 되감기(stack unwinding) 중에 소멸자가 반드시 호출된다는 언어 규칙을 이용해 이 문제를 구조적으로 없앱니다.

make_unique가 등장한 이유 중 하나는 C++14 이전의 미묘한 누수 경로였습니다. f(std::unique_ptr<T>(new T), g());에서 컴파일러가 new T → g() → unique_ptr 생성 순서로 평가하면, g()가 예외를 던질 때 new T로 만든 객체를 아무도 소유하지 않아 누수가 납니다. C++17부터는 함수 인자 각각의 평가가 섞이지 않도록 규칙이 바뀌어 이 경로는 막혔지만, make_unique<T>()를 쓰면 표준 버전과 무관하게 new가 코드에 드러나지 않아 리뷰에서 “짝이 맞는 delete가 어디 있나”를 찾을 필요 자체가 사라집니다.


고급 활용

커스텀 삭제자

#include <iostream>
#include <memory>
void customDeleter(int* ptr) {
    std::cout << "커스텀 삭제자 호출" << std::endl;
    delete ptr;
}
int main() {
    std::unique_ptr<int, decltype(&customDeleter)> p(new int(42), customDeleter);
    
    std::cout << *p << std::endl;
    
    return 0;
}

C 라이브러리 래핑

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <memory>
extern "C" {
    struct CData {
        int id;
        char name[100];
    };
    
    CData* create_data(int id, const char* name) {
        CData* data = (CData*)malloc(sizeof(CData));
        data->id = id;
        strncpy(data->name, name, 99);
        data->name[99] = '\0';
        return data;
    }
    
    void destroy_data(CData* data) {
        free(data);
    }
}
std::unique_ptr<CData, decltype(&destroy_data)> wrap_data(int id, const char* name) {
    CData* data = create_data(id, name);
    return {data, destroy_data};
}
int main() {
    auto data = wrap_data(1, "test");
    std::cout << data->id << ", " << data->name << std::endl;
    // 자동으로 destroy_data 호출
    
    return 0;
}

삭제자를 함수 포인터(decltype(&destroy_data))로 지정하면 unique_ptr이 포인터 두 개 크기가 됩니다. 객체 포인터 외에 삭제자 함수 포인터도 저장해야 하기 때문입니다. struct DataDeleter { void operator()(CData* p) const { destroy_data(p); } };처럼 상태 없는 함수 객체를 쓰면 빈 기반 최적화로 삭제자가 공간을 차지하지 않아 sizeof가 일반 포인터와 같아지고, 생성할 때 삭제자를 매번 넘길 필요도 없습니다. 비슷한 이유로 decltype(&free)처럼 표준 라이브러리 함수의 주소를 직접 쓰는 방식도 C++20부터는 “주소를 취할 수 있다고 지정되지 않은 표준 함수”라서 엄밀히는 보장되지 않으므로, [](void* p) { std::free(p); }를 감싼 함수 객체가 더 이식성이 좋습니다. 이 예제의 create_data는 malloc 실패를 확인하지 않고 data->id에 쓰는데, 실제 C 라이브러리를 감쌀 때는 nullptr 반환을 확인한 뒤 unique_ptr에 넣어야 합니다(nullptr을 담은 unique_ptr은 삭제자를 호출하지 않으므로 해제 쪽은 안전합니다).


팩토리 패턴

#include <iostream>
#include <memory>
#include <string>
class Shape {
public:
    virtual ~Shape() = default;
    virtual void draw() const = 0;
};
class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "원 그리기" << std::endl;
    }
};
class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "사각형 그리기" << std::endl;
    }
};
std::unique_ptr<Shape> createShape(const std::string& type) {
    if (type == "circle") {
        return std::make_unique<Circle>();
    } else if (type == "rectangle") {
        return std::make_unique<Rectangle>();
    }
    return nullptr;
}
int main() {
    auto shape = createShape("circle");
    if (shape) {
        shape->draw();
    }
    
    return 0;
}

성능 비교

벤치마크

#include <chrono>
#include <iostream>
#include <memory>
void benchMalloc() {
    for (int i = 0; i < 1000000; ++i) {
        int* p = (int*)malloc(sizeof(int));
        *p = i;
        free(p);
    }
}
void benchNew() {
    for (int i = 0; i < 1000000; ++i) {
        int* p = new int(i);
        delete p;
    }
}
void benchMakeUnique() {
    for (int i = 0; i < 1000000; ++i) {
        auto p = std::make_unique<int>(i);
    }
}
int main() {
    auto start1 = std::chrono::high_resolution_clock::now();
    benchMalloc();
    auto end1 = std::chrono::high_resolution_clock::now();
    auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
    
    auto start2 = std::chrono::high_resolution_clock::now();
    benchNew();
    auto end2 = std::chrono::high_resolution_clock::now();
    auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
    
    auto start3 = std::chrono::high_resolution_clock::now();
    benchMakeUnique();
    auto end3 = std::chrono::high_resolution_clock::now();
    auto time3 = std::chrono::duration_cast<std::chrono::milliseconds>(end3 - start3).count();
    
    std::cout << "malloc/free: " << time1 << "ms" << std::endl;
    std::cout << "new/delete: " << time2 << "ms" << std::endl;
    std::cout << "make_unique: " << time3 << "ms" << std::endl;
    
    return 0;
}

결과를 읽는 법: 할당 결과가 밖으로 새어 나가도록 측정 코드를 고쳐서 재면 세 방식은 측정 오차 수준에서 같은 시간을 보이는 것이 일반적이고, 위 코드를 그대로 -O2로 빌드하면 일부 루프가 0ms에 가깝게 나올 수 있습니다. 절대 시간은 표준 라이브러리 구현(glibc, MSVC CRT, MinGW), 할당 크기, 스레드 수에 따라 크게 달라지므로 직접 환경에서 재 보는 것이 가장 정확합니다.

세 방식이 같은 시간을 보이는 이유는 구조에 있습니다. libstdc++의 operator new는 내부에서 malloc을 부르고, make_unique는 new를 호출한 뒤 포인터를 감쌀 뿐이라 차이가 날 이유가 없습니다.

마지막 줄이 이 벤치마크의 함정입니다. C++14부터 컴파일러는 결과가 관찰되지 않는 new/delete 쌍을 통째로 없앨 수 있고, GCC는 malloc/free 쌍에도 같은 최적화를 합니다. 위 코드처럼 할당한 값을 루프 안에서만 쓰고 버리면 실제로는 할당을 한 번도 하지 않은 시간을 재게 되어, 0ms나 1ms 미만 같은 비현실적인 값이 나옵니다. 제대로 재려면 포인터가 컴파일러 밖으로 “새어 나가게” 해야 합니다. GCC/Clang에서는 루프마다 asm volatile("" :: "r"(p) : "memory");로 포인터를 사용한 것처럼 표시하는 방법이 있고, Google Benchmark를 쓴다면 benchmark::DoNotOptimize(p)가 같은 역할을 합니다. 할당 비용이 정말 문제라면 할당 방식 선택보다 할당 횟수를 줄이는 것(reserve, 객체 풀, 아레나)이 훨씬 큰 차이를 만듭니다.


실무 사례

사례 1: 리소스 관리 - RAII

#include <fstream>
#include <iostream>
#include <memory>
#include <string>
class FileReader {
private:
    std::unique_ptr<std::ifstream> file_;
    
public:
    FileReader(const std::string& filename) {
        file_ = std::make_unique<std::ifstream>(filename);
        
        if (!file_->is_open()) {
            throw std::runtime_error("파일 열기 실패");
        }
    }
    
    std::string readLine() {
        std::string line;
        std::getline(*file_, line);
        return line;
    }
};
int main() {
    try {
        FileReader reader("data.txt");
        std::cout << reader.readLine() << std::endl;
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
    }
    // 자동으로 파일 닫힘
    
    return 0;
}

사례 2: 게임 엔진 - 엔티티 관리

#include <iostream>
#include <memory>
#include <unordered_map>
#include <string>
class Entity {
private:
    int id_;
    std::string name_;
    
public:
    Entity(int id, const std::string& name) : id_(id), name_(name) {
        std::cout << "Entity 생성: " << name_ << std::endl;
    }
    
    ~Entity() {
        std::cout << "Entity 소멸: " << name_ << std::endl;
    }
    
    int getId() const { return id_; }
    const std::string& getName() const { return name_; }
};
class EntityManager {
private:
    std::unordered_map<int, std::unique_ptr<Entity>> entities_;
    int nextId_ = 0;
    
public:
    int createEntity(const std::string& name) {
        int id = nextId_++;
        entities_[id] = std::make_unique<Entity>(id, name);
        return id;
    }
    
    void destroyEntity(int id) {
        entities_.erase(id);  // 자동 소멸
    }
    
    Entity* getEntity(int id) {
        auto it = entities_.find(id);
        return it != entities_.end() ? it->second.get() : nullptr;
    }
};
int main() {
    EntityManager manager;
    
    int id1 = manager.createEntity("Player");
    int id2 = manager.createEntity("Enemy");
    
    manager.destroyEntity(id1);
    
    return 0;
}

출력:

Entity 생성: Player
Entity 생성: Enemy
Entity 소멸: Player
Entity 소멸: Enemy

사례 3: 네트워크 - 소켓 관리

#include <iostream>
#include <memory>
class Socket {
private:
    int fd_;
    
public:
    Socket(int fd) : fd_(fd) {
        std::cout << "Socket 생성: " << fd_ << std::endl;
    }
    
    ~Socket() {
        std::cout << "Socket 닫기: " << fd_ << std::endl;
        // close(fd_);
    }
    
    void send(const std::string& data) {
        std::cout << "전송: " << data << std::endl;
    }
};
std::unique_ptr<Socket> createSocket(int port) {
    // int fd = socket(...);
    int fd = 42;  // 예시
    return std::make_unique<Socket>(fd);
}
int main() {
    try {
        auto sock = createSocket(8080);
        sock->send("Hello");
        
        if (true) {  // 조건부 종료
            return 0;  // 자동으로 소켓 닫힘
        }
        
        sock->send("World");
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
    }
    
    return 0;
}

사례 4: 멀티스레드 - 스레드 안전 캐시

#include <iostream>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <string>
template<typename K, typename V>
class ThreadSafeCache {
private:
    std::unordered_map<K, std::unique_ptr<V>> cache_;
    mutable std::mutex mutex_;
    
public:
    void put(const K& key, std::unique_ptr<V> value) {
        std::lock_guard<std::mutex> lock(mutex_);
        cache_[key] = std::move(value);
    }
    
    V* get(const K& key) {
        std::lock_guard<std::mutex> lock(mutex_);
        auto it = cache_.find(key);
        return it != cache_.end() ? it->second.get() : nullptr;  // 주의: 아래 설명 참고
    }
    
    void remove(const K& key) {
        std::lock_guard<std::mutex> lock(mutex_);
        cache_.erase(key);  // 자동 소멸
    }
};
int main() {
    ThreadSafeCache<std::string, int> cache;
    
    cache.put("key1", std::make_unique<int>(42));
    
    if (int* val = cache.get("key1")) {
        std::cout << *val << std::endl;
    }
    
    cache.remove("key1");
    
    return 0;
}

이 캐시는 맵 자체는 뮤텍스로 보호하지만, get이 돌려준 원시 포인터는 락 밖으로 나갑니다. 한 스레드가 get("key1")로 포인터를 받아 쓰는 동안 다른 스레드가 remove("key1")이나 같은 키로 put을 하면 unique_ptr이 객체를 해제해 첫 스레드의 포인터가 댕글링이 됩니다. 단일 스레드 예제에서는 드러나지 않다가 부하가 걸린 운영 환경에서만 가끔 크래시가 나는 전형적인 경우입니다. 멀티스레드 캐시라면 값을 std::shared_ptr<V>로 저장하고 get이 shared_ptr 복사본을 돌려주게 해서, 캐시에서 지워져도 사용 중인 스레드가 있는 동안에는 객체가 살아 있게 만드는 것이 일반적인 해법입니다. “소유권이 하나”라는 unique_ptr의 전제가 여러 스레드가 동시에 쓰는 상황과 맞지 않는 대표적인 예입니다.


트러블슈팅

문제 1: malloc-delete 혼용

증상: 크래시 또는 메모리 누수

// ❌ 미정의 동작
int* p1 = (int*)malloc(sizeof(int));
delete p1;  // ❌ malloc-delete 혼용
// ❌ 미정의 동작
int* p2 = new int(42);
free(p2);  // ❌ new-free 혼용
// ✅ 올바른 짝
int* p3 = (int*)malloc(sizeof(int));
free(p3);
int* p4 = new int(42);
delete p4;
auto p5 = std::make_unique<int>(42);
// 자동 해제

짝이 맞아야 하는 이유는 new/delete와 malloc/free가 같은 할당자를 쓴다는 보장이 없기 때문입니다. operator new는 프로그램이 교체할 수 있고(메모리 추적 도구나 tcmalloc 같은 할당자가 이렇게 끼어듭니다), Windows에서는 DLL마다 서로 다른 CRT 힙을 쓸 수 있어서 한쪽 모듈에서 할당한 메모리를 다른 모듈에서 해제하면 힙 손상이 납니다. int처럼 단순한 타입에서는 “우연히 동작”하는 경우가 많아 더 위험한데, 소멸자가 있는 클래스라면 free는 소멸자를 부르지 않아 내부 자원이 새고, malloc한 메모리를 delete하면 초기화되지 않은 객체의 소멸자가 실행됩니다. AddressSanitizer는 이 불일치를 alloc-dealloc-mismatch (malloc vs operator delete) 에러로 바로 잡아 주므로, 테스트 빌드에 켜 두는 것이 가장 확실한 방어입니다.


문제 1-1: 할당 실패는 세 가지로 다르게 드러난다

int* a = (int*)std::malloc(n * sizeof(int));   // 실패하면 nullptr — 확인하지 않으면 다음 줄에서 크래시
int* b = new int[n];                            // 실패하면 std::bad_alloc 예외
int* c = new (std::nothrow) int[n];             // 실패하면 nullptr (예외를 쓰지 않는 코드베이스용)
auto d = std::make_unique<int[]>(n);            // bad_alloc 예외, nothrow 버전 없음

malloc 결과를 확인하지 않는 코드는 흔하고, new (std::nothrow)를 쓰면서 결과를 확인하지 않으면 malloc과 똑같은 문제가 됩니다. 예외를 끈(-fno-exceptions) 임베디드·게임 코드에서는 bad_alloc을 잡을 수 없으므로 nothrow 버전과 명시적 검사가 필요하고, 그 외에는 new/make_unique가 실패를 무시할 수 없게 만들어 준다는 점이 장점입니다. 또 malloc(10)처럼 sizeof를 빼먹으면 10개가 아니라 10바이트(int 2.5개 분량)만 받아 버퍼 오버플로우가 나지만, new int[10]과 make_unique<int[]>(10)은 원소 개수로 받으므로 이 실수가 원천적으로 없습니다. Linux는 기본 설정에서 메모리를 실제로 쓸 때 할당하는 overcommit 정책이라, 큰 할당이 성공한 뒤 나중에 OOM killer로 종료되는 경우도 있어 “nullptr/예외가 안 났으니 메모리가 있다”고 단정할 수는 없습니다.

문제 2: new 후 예외 발생

증상: 메모리 누수

// ❌ 예외 발생 시 메모리 누수
void badFunction() {
    int* p = new int(42);
    
    // 예외 발생 가능
    if (true) {
        throw std::runtime_error("오류");
    }
    
    delete p;  // 실행 안 됨
}
// ✅ make_unique로 자동 해제
void goodFunction() {
    auto p = std::make_unique<int>(42);
    
    // 예외 발생 가능
    if (true) {
        throw std::runtime_error("오류");
    }
    
    // 예외 발생해도 자동 해제
}

문제 3: 배열 delete 누락

증상: 메모리 누수

// ❌ delete 사용 (배열은 delete[])
int* arr = new int[10];
delete arr;  // ❌ 미정의 동작 (누수·힙 손상·크래시 모두 가능)
// ✅ delete[] 사용
int* arr2 = new int[10];
delete[] arr2;
// ✅ make_unique 사용
auto arr3 = std::make_unique<int[]>(10);
// 자동 해제

delete arr가 단순한 누수로 끝나지 않는 이유는 배열 new의 구현 방식 때문입니다. 소멸자가 있는 타입의 배열을 new T[n]으로 만들면, 컴파일러는 보통 배열 앞에 원소 개수를 기록해 두고(array cookie) delete[]가 그 개수만큼 소멸자를 호출합니다. delete는 이 기록을 모르므로 첫 원소의 소멸자만 부르고, 할당자에게 잘못된 시작 주소를 넘겨 힙이 손상될 수 있습니다. unique_ptr<T[]>를 쓸 때도 템플릿 인자에 []를 빼먹고 unique_ptr<int>(new int[10])처럼 쓰면 같은 문제가 생기므로, 배열은 make_unique<T[]>나 vector로만 만드는 습관이 안전합니다.


문제 4: 함수 인자로 전달

증상: 소유권 혼란

// ❌ raw 포인터로 전달 (소유권 불명확)
void process(int* ptr) {
    // delete ptr?  // 누가 해제?
}
int* p = new int(42);
process(p);
delete p;  // 여기서 해제?
// ✅ unique_ptr로 전달 (소유권 이전)
void process2(std::unique_ptr<int> ptr) {
    // 자동 해제
}
auto p2 = std::make_unique<int>(42);
process2(std::move(p2));  // 소유권 이전
// p2는 nullptr
// ✅ raw 포인터로 전달 (소유권 유지)
void process3(int* ptr) {
    // 읽기만
}
auto p3 = std::make_unique<int>(42);
process3(p3.get());  // 소유권 유지

마무리

현대 C++에서는 make_unique를 사용하세요.

핵심 요약

  1. malloc vs new vs make_unique
    • malloc: 생성자 호출 안 함, free 필요
    • new: 생성자 호출, delete 필요
    • make_unique: 생성자 호출 + 자동 해제
  2. 선택 기준
    • 일반적인 경우: make_unique
    • 공유 소유권: make_shared
    • C 라이브러리 연동: malloc
    • 레거시 코드: new
  3. 예외 안전성
    • malloc/new: 예외 발생 시 메모리 누수
    • make_unique: 자동 해제로 안전
  4. 성능
    • 거의 차이 없음
    • 안전성이 더 중요

선택 가이드

상황권장이유
C++ 객체make_unique자동 해제
공유 소유권make_shared참조 카운팅
C 라이브러리mallocC 호환
레거시 코드new기존 코드 유지
일반적인 경우make_unique예외 안전

코드 예제 치트시트

// malloc
int* p1 = (int*)malloc(sizeof(int));
*p1 = 42;
free(p1);
// new
int* p2 = new int(42);
delete p2;
// make_unique
auto p3 = std::make_unique<int>(42);
// 자동 해제
// 배열
auto arr1 = std::make_unique<int[]>(10);
// 커스텀 삭제자
std::unique_ptr<int, decltype(&free)> p4((int*)malloc(sizeof(int)), free);

다음 단계

참고 자료

한 줄 정리: 현대 C++에서는 make_unique로 자동 해제와 예외 안전성을 보장하며, C 라이브러리 연동 시에만 malloc을 사용합니다.


자주 묻는 질문 (FAQ)

Q. C API가 malloc으로 준 메모리도 스마트 포인터로 관리할 수 있나요?

A. 됩니다. std::unique_ptr<char, decltype(&std::free)> p(c_api_strdup(...), &std::free);처럼 해제 함수를 삭제자로 지정하면, 예외나 조기 반환에서도 free가 호출됩니다. 중요한 것은 할당한 쪽의 해제 함수와 짝을 맞추는 것으로, malloc으로 받은 메모리를 기본 unique_ptr<char[]>(내부적으로 delete[])에 넣으면 미정의 동작입니다.


같이 보면 좋은 글