C++ unique_ptr 고급 완벽 가이드 | 커스텀 삭제자·배열
이 글의 핵심
unique_ptr 기초는 알겠는데, C API 연동·배열·Pimpl 패턴·이동 시맨틱스는 어떻게 쓰나요? 커스텀 삭제자, 배열 지원, Pimpl 구현, 이동 의미론, 자주 하는 실수, 프로덕션 패턴까지. 문제 시나리오로 시작하는 unique_ptr 고급 실전 가이드.
들어가며: unique_ptr 기초는 알겠는데, 실전에서는?
”C API의 malloc/free를 C++에서 어떻게 안전하게 쓰나요?”
unique_ptr 기초를 익혔다면 make_unique, std::move, get() 정도는 알고 있을 겁니다. 그런데 실제 프로젝트에서는 C 라이브러리 연동(malloc/free, FILE*, HANDLE), Pimpl 패턴으로 구현 세부사항 숨기기, 동적 배열 관리, 이동 의미론을 활용한 팩토리 패턴 등이 필요합니다. 이 글에서는 unique_ptr의 고급 기능을 문제 시나리오부터 완전한 예제, 자주 하는 실수, 프로덕션 패턴까지 다룹니다.
비유하면: unique_ptr 기초는 “열쇠를 지갑에 넣어 두면 나갈 때 자동으로 문이 잠기는 것”이라면, 고급 기능은 “다양한 종류의 문(파일, 소켓, C API 버퍼)에 맞는 자동 잠금 장치를 설치하는 것”입니다.
이 글을 읽으면:
- 커스텀 삭제자로 C API·파일 핸들·소켓 등을 RAII로 안전하게 관리할 수 있습니다.
- unique_ptr로 동적 배열을 다루고, std::vector와의 선택 기준을 알 수 있습니다.
- Pimpl 패턴으로 ABI 안정성을 확보하는 방법을 익힐 수 있습니다.
- 이동 의미론과 unique_ptr을 조합한 팩토리·컨테이너 패턴을 적용할 수 있습니다.
- 자주 하는 실수와 프로덕션 체크리스트를 활용할 수 있습니다.
목차
- 문제 시나리오
- 커스텀 삭제자 완전 가이드
- unique_ptr과 배열
- Pimpl 패턴과 unique_ptr
- 이동 의미론과 unique_ptr
- 완전한 고급 예제
- 자주 발생하는 에러와 해결법
- 모범 사례와 선택 가이드
- 프로덕션 패턴
- 성능·체크리스트
1. 문제 시나리오
시나리오 1: “C API가 malloc으로 할당한 버퍼를 반환하는데, free를 언제 호출해야 할지 모르겠어요”
"libpng, libjpeg 같은 C 라이브러리가 malloc으로 버퍼를 반환해요."
"예외가 나거나 early return이 있으면 free를 깜빡하기 쉽습니다."
상황: char* buf = (char*)malloc(1024);로 받은 버퍼를 C++ 코드에서 사용할 때, 여러 경로(예외, early return)에서 free를 일일이 호출하기 어렵습니다. 한 곳이라도 빠지면 메모리 누수가 발생합니다.
해결 포인트: unique_ptr에 커스텀 삭제자로 free를 지정하면 RAII로 스코프를 벗어날 때 자동 해제됩니다.
시나리오 2: “FILE*를 열었는데 fclose를 어디서 호출해야 할지 헷갈려요”
"파일을 열고 파싱하다가 예외가 나면 fclose가 호출되지 않아요."
"여러 함수에서 파일을 넘겨다니다 보니 누가 닫을지 모르겠어요."
상황: FILE* fp = fopen("data.txt", "r");로 연 파일을 여러 함수에 전달할 때, “마지막으로 사용하는 쪽”이 fclose를 해야 하는데 그 시점을 추적하기 어렵습니다.
해결 포인트: unique_ptr<FILE, FileDeleter>로 감싸면 소유권이 명확해지고, 스코프를 벗어날 때 자동으로 fclose가 호출됩니다.
시나리오 3: “헤더를 수정하면 모든 사용자가 다시 컴파일해야 해요”
"Widget 클래스의 private 멤버를 바꿨는데, 이걸 include하는 100개 파일이 전부 재컴파일돼요."
"빌드 시간이 너무 길어요."
상황: 헤더에 구현 세부사항(private 멤버, 의존성)이 노출되면, 구현 변경 시마다 해당 헤더를 include하는 모든 코드가 재컴파일됩니다.
해결 포인트: Pimpl 패턴으로 구현을 unique_ptr<Impl>만 두고, .cpp 파일에 숨기면 헤더 변경 없이 구현만 수정할 수 있어 빌드 시간이 크게 줄어듭니다.
시나리오 4: “new[]로 할당한 배열을 unique_ptr로 관리하고 싶어요”
"동적 배열이 필요한데, shared_ptr은 배열 지원이 제한적이에요."
"unique_ptr<int[]>로 배열을 만들 수 있다고 들었는데, 어떻게 쓰나요?"
상황: new int[n]으로 할당한 배열을 unique_ptr로 관리하고 싶을 때, unique_ptr<T[]> 특수화와 make_unique<T[]>(n) 사용법을 알아야 합니다.
해결 포인트: std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);으로 배열을 생성하고, arr[i]로 접근합니다. std::vector가 대부분의 경우 더 나은 선택이지만, C API 연동이나 고정 크기 배열이 필요할 때 유용합니다.
시나리오 5: “팩토리 함수가 new로 만든 객체를 반환하는데, 이동이 제대로 되는지 확인하고 싶어요”
"createWidget()이 new Widget()을 반환하는데, 호출자가 delete를 잊으면 누수돼요."
"unique_ptr로 반환하면 이동이 되는지, RVO가 적용되는지 궁금해요."
상황: 팩토리 함수가 힙에 할당한 객체를 반환할 때, 이동 의미론과 RVO가 어떻게 적용되는지 이해해야 합니다.
해결 포인트: std::unique_ptr<Widget> createWidget()으로 반환하면 소유권이 명확해지고, RVO 또는 이동으로 복사 없이 전달됩니다. 호출자가 받은 unique_ptr이 스코프를 벗어날 때 자동 해제됩니다.
2. 커스텀 삭제자 완전 가이드
커스텀 삭제자란?
unique_ptr의 기본 삭제자는 delete를 호출합니다. 하지만 C API의 free, fclose, CloseHandle 등 다른 해제 함수가 필요한 리소스는 커스텀 삭제자를 지정해야 합니다.
flowchart LR
subgraph Default["기본 삭제자"]
D1["unique_ptrT"]
D2["delete ptr"]
end
subgraph Custom["커스텀 삭제자"]
C1["unique_ptrT, Deleter"]
C2["Deleter(ptr)"]
end
D1 --> D2
C1 --> C2
C API: malloc/free
#include <memory>
#include <cstdlib>
#include <iostream>
int main() {
// ✅ 권장: decltype(&std::free)로 삭제자 타입 지정
auto buf = std::unique_ptr<char, decltype(&std::free)>(
static_cast<char*>(std::malloc(1024)),
&std::free
);
if (!buf) {
throw std::bad_alloc();
}
// 버퍼 사용
buf[0] = 'A';
buf[1] = '\0';
std::cout << buf.get() << std::endl;
return 0;
} // 소멸 시 free 자동 호출
코드 설명:
decltype(&std::free):free함수 포인터의 타입을 지정합니다.void (*)(void*)형태입니다.- 두 번째 인자
&std::free: 실제 호출할 삭제 함수를 전달합니다. static_cast<char*>:malloc은void*를 반환하므로char*로 변환합니다.- 스코프를 벗어나면 소멸자가
free(buf.get())를 호출합니다.
람다를 이용한 삭제자 (상태 있는 삭제자)
#include <memory>
#include <iostream>
int main() {
auto deleter = {
std::cout << "Custom delete: " << *p << std::endl;
delete p;
};
std::unique_ptr<int, decltype(deleter)> ptr(new int(42), deleter);
return 0;
} // "Custom delete: 42" 출력 후 delete 호출
주의: 람다를 삭제자로 쓰면 unique_ptr의 크기가 증가할 수 있습니다(람다 캡처에 따라). 상태 없는 함수 포인터는 8바이트 추가, 상태 있는 람다는 더 커질 수 있습니다.
FILE* 래퍼 (파일 핸들)
#include <memory>
#include <cstdio>
#include <stdexcept>
#include <string>
struct FileDeleter {
void operator()(FILE* fp) const {
if (fp) {
std::fclose(fp);
}
}
};
using FileHandle = std::unique_ptr<FILE, FileDeleter>;
FileHandle openFile(const char* path, const char* mode) {
FILE* fp = std::fopen(path, mode);
if (!fp) {
throw std::runtime_error(std::string("Cannot open: ") + path);
}
return FileHandle(fp);
}
int main() {
auto file = openFile("data.txt", "r");
if (file) {
char buf[256];
while (std::fgets(buf, sizeof(buf), file.get())) {
// 처리...
}
}
return 0;
} // fclose 자동 호출
코드 설명:
FileDeleter:operator()를 정의한 함수 객체입니다.fp가 null이 아닐 때만fclose를 호출합니다.using FileHandle: 타입 별칭으로 가독성을 높입니다.openFile:fopen으로 연 파일을FileHandle로 감싸 반환합니다. 예외 시에도 RAII로 fclose가 호출됩니다.
Windows HANDLE (예시)
#ifdef _WIN32
#include <memory>
#include <windows.h>
struct HandleDeleter {
void operator()(HANDLE h) const {
if (h != nullptr && h != INVALID_HANDLE_VALUE) {
CloseHandle(h);
}
}
};
using UniqueHandle = std::unique_ptr<void, HandleDeleter>;
UniqueHandle openMutex(const wchar_t* name) {
HANDLE h = OpenMutexW(MUTEX_ALL_ACCESS, FALSE, name);
if (!h) {
throw std::runtime_error("OpenMutex failed");
}
return UniqueHandle(h);
}
#endif
삭제자 타입과 unique_ptr 크기
| 삭제자 종류 | unique_ptr 크기 (64비트) | 비고 |
|---|---|---|
| 기본 (delete) | 8 bytes | raw 포인터와 동일 |
| 함수 포인터 | 16 bytes | 포인터 + 삭제자 |
| 상태 없는 람다 | 8 bytes | 빈 클래스 최적화 |
| 상태 있는 람다 | 16+ bytes | 캡처 크기에 따라 증가 |
3. unique_ptr과 배열
unique_ptr<T[]> 특수화
unique_ptr은 T[] 배열 타입에 대한 특수화가 있습니다. 배열의 경우 delete[]를 호출해야 하므로, unique_ptr<T[]>는 delete[]를 사용합니다.
flowchart TB
subgraph Single["unique_ptrT"]
S1["delete ptr"]
end
subgraph Array[""unique_ptrT("]>"]
A1[""delete("] ptr"]
end
배열 생성과 접근
#include <memory>
#include <iostream>
int main() {
// C++14: make_unique<T[]>(size)
std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);
arr[0] = 10;
arr[99] = 20;
for (int i = 0; i < 100; ++i) {
arr[i] = i;
}
std::cout << arr[0] << " " << arr[99] << std::endl; // 0 99
return 0;
} // delete[] 자동 호출
주의: unique_ptr<T[]>는 operator*(단일 객체 역참조)를 제공하지 않습니다. arr[i]로만 접근합니다.
배열 vs std::vector 선택 기준
| 상황 | 권장 | 이유 |
|---|---|---|
| 크기가 가변 | std::vector | resize, push_back 등 편의 기능 |
| C API에 raw 포인터 전달 | std::vector 또는 unique_ptr<T[]> | vec.data() 또는 arr.get() |
| 고정 크기, 스택에 두기 어려움 | unique_ptr<T[]> 또는 std::array | 단순함 |
| 다형성 배열 (기반 클래스 포인터 배열) | std::vector<std::unique_ptr<Base>> | unique_ptr<T[]>는 다형성에 부적합 |
C API 연동: 고정 크기 버퍼
#include <memory>
#include <cstring>
#include <iostream>
void processWithBuffer(size_t size) {
auto buf = std::make_unique<char[]>(size);
std::memset(buf.get(), 0, size);
// C API에 전달
some_c_function(buf.get(), size);
// 사용...
} // delete[] 자동 호출
초기화된 배열 (C++20)
C++20에서는 std::make_unique<int[]>(10)이 0으로 초기화됩니다. C++14/17에서는 초기화가 보장되지 않을 수 있으므로, 명시적으로 초기화하려면:
// C++14/17: 수동 초기화
auto arr = std::make_unique<int[]>(100);
std::fill(arr.get(), arr.get() + 100, 0);
// 또는 std::vector 사용 (자동 0 초기화)
std::vector<int> vec(100, 0);
4. Pimpl 패턴과 unique_ptr
Pimpl이란?
Pimpl(Pointer to Implementation)은 구현 세부사항을 불투명 포인터로 숨겨, 헤더 변경 없이 구현만 수정할 수 있게 하는 패턴입니다. unique_ptr과 결합하면 ABI 안정성과 빌드 시간 단축을 얻을 수 있습니다.
flowchart TB
subgraph Header["widget.h"]
W["class Widget"]
P["unique_ptrImpl pImpl_"]
end
subgraph Impl["widget.cpp"]
I["class Widget Impl"]
D["구현 세부사항"]
end
W --> P
P -.->|포인터| I
I --> D
기본 Pimpl 구현
// widget.h
#pragma once
#include <memory>
class Widget {
public:
Widget();
~Widget();
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
// 복사는 명시적으로 구현 (Impl 복제 필요)
Widget(const Widget&);
Widget& operator=(const Widget&);
void doSomething();
int getValue() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include <vector>
#include <string>
// Impl 정의: 헤더에 노출되지 않음
class Widget::Impl {
public:
std::vector<int> data;
std::string name;
void doSomethingInternal() {
// 복잡한 구현...
}
int getValueInternal() const {
return 42;
}
};
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
// 소멸자는 .cpp에 반드시 정의 (Impl이 완전 타입이어야 함)
Widget::~Widget() = default;
Widget::Widget(const Widget& other) : pImpl_(std::make_unique<Impl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
pImpl_ = std::make_unique<Impl>(*other.pImpl_);
}
return *this;
}
void Widget::doSomething() {
pImpl_->doSomethingInternal();
}
int Widget::getValue() const {
return pImpl_->getValueInternal();
}
Pimpl의 핵심: 소멸자 정의 위치
반드시 소멸자를 .cpp 파일에 정의해야 합니다. 헤더에 ~Widget() = default;만 두면, 컴파일러가 unique_ptr<Impl>의 소멸자를 인스턴스화할 때 Impl이 완전 타입(complete type)이어야 하는데, 헤더에서는 Impl이 전방 선언만 되어 있어 불완전 타입이므로 컴파일 에러가 발생합니다.
// ❌ 잘못된 예: 헤더에 소멸자 default
class Widget {
// ...
~Widget() = default; // Impl이 불완전 타입일 때 unique_ptr 소멸자 인스턴스화 실패!
};
// ✅ 올바른 예: .cpp에 소멸자 정의
// widget.cpp
Widget::~Widget() = default; // 이 시점에 Impl은 완전 타입
이동 생성자/대입과 Pimpl
unique_ptr은 이동 가능하므로, Widget의 이동 생성자/대입은 = default로 두면 됩니다. Impl이 완전 타입일 필요는 없습니다(이동 시 포인터만 바꾸므로).
5. 이동 의미론과 unique_ptr
unique_ptr은 “이동 전용” 타입
unique_ptr은 복사가 삭제되어 있고, 이동만 가능합니다. 이는 “독점 소유권”을 타입 시스템으로 강제하는 설계입니다.
sequenceDiagram
participant A as ptr1
participant B as ptr2
participant H as Heap
A->>H: 소유
Note over A,B: std::move(ptr1)
A->>B: 소유권 이전
B->>H: 소유
Note over A: nullptr
이동 시맨틱스 기본
#include <memory>
#include <cassert>
int main() {
auto ptr1 = std::make_unique<int>(42);
// ❌ 복사 불가
// auto ptr2 = ptr1; // 컴파일 에러
// ✅ 이동
auto ptr2 = std::move(ptr1);
assert(!ptr1); // ptr1은 nullptr
assert(ptr2 && *ptr2 == 42);
// 이동 대입
auto ptr3 = std::make_unique<int>(100);
ptr2 = std::move(ptr3); // ptr2가 가리키던 42는 delete, ptr3의 100 소유권 이전
assert(!ptr3);
assert(ptr2 && *ptr2 == 100);
return 0;
}
함수 인자: 소유권 이전
#include <memory>
#include <iostream>
void takeOwnership(std::unique_ptr<int> ptr) {
std::cout << *ptr << std::endl;
// 함수 종료 시 ptr 소멸 → delete
}
int main() {
auto ptr = std::make_unique<int>(42);
takeOwnership(std::move(ptr)); // 소유권 이전
// ptr은 nullptr, 사용 불가
return 0;
}
함수 반환: RVO와 이동
#include <memory>
std::unique_ptr<int> createValue() {
return std::make_unique<int>(42);
// RVO 또는 이동으로 반환 (복사 없음)
}
std::unique_ptr<int> createConditionally(bool flag) {
if (flag) {
return std::make_unique<int>(1);
}
return std::make_unique<int>(2);
// 두 경로 모두 이동
}
int main() {
auto p1 = createValue();
auto p2 = createConditionally(true);
return 0;
}
컨테이너에 unique_ptr 저장
#include <memory>
#include <vector>
#include <iostream>
struct Shape {
virtual void draw() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw() const override { std::cout << "Circle\n"; }
};
struct Rectangle : Shape {
void draw() const override { std::cout << "Rectangle\n"; }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Rectangle>());
// 또는
shapes.emplace_back(std::make_unique<Circle>());
for (const auto& s : shapes) {
s->draw();
}
return 0;
}
이동과 vector: push_back(std::move(ptr))로 이동하여 넣습니다. emplace_back은 인자를 전달해 컨테이너 내부에서 직접 생성할 수 있습니다.
6. 완전한 고급 예제
예제 1: C API 래퍼 (libpng 스타일)
#include <memory>
#include <cstdlib>
#include <cstring>
#include <stdexcept>
struct FreeDeleter {
void operator()(void* p) const {
std::free(p);
}
};
using UniqueBuffer = std::unique_ptr<char, FreeDeleter>;
UniqueBuffer allocateBuffer(size_t size) {
void* p = std::malloc(size);
if (!p) {
throw std::bad_alloc();
}
return UniqueBuffer(static_cast<char*>(p));
}
int main() {
auto buf = allocateBuffer(4096);
std::memset(buf.get(), 0, 4096);
// 사용...
return 0;
}
예제 2: Pimpl + 팩토리
// document.h
#pragma once
#include <memory>
#include <string>
class Document {
public:
static std::unique_ptr<Document> create(const std::string& path);
~Document();
void save();
void load();
private:
class Impl;
std::unique_ptr<Impl> pImpl_;
Document(std::unique_ptr<Impl> impl);
};
// document.cpp
#include "document.h"
#include <fstream>
#include <iostream>
class Document::Impl {
public:
std::string path;
std::string content;
void saveInternal() {
std::ofstream f(path);
f << content;
}
void loadInternal() {
std::ifstream f(path);
content.assign(std::istreambuf_iterator<char>(f), {});
}
};
std::unique_ptr<Document> Document::create(const std::string& path) {
auto impl = std::make_unique<Impl>();
impl->path = path;
impl->loadInternal();
return std::unique_ptr<Document>(new Document(std::move(impl)));
}
Document::Document(std::unique_ptr<Impl> impl) : pImpl_(std::move(impl)) {}
Document::~Document() = default;
void Document::save() {
pImpl_->saveInternal();
}
void Document::load() {
pImpl_->loadInternal();
}
예제 3: 배열 + 커스텀 삭제자 (알려진 크기)
#include <memory>
#include <iostream>
struct ArrayDeleter {
size_t count;
ArrayDeleter(size_t n) : count(n) {}
void operator()(int* p) const {
delete[] p;
}
};
int main() {
std::unique_ptr<int, ArrayDeleter> arr(new int[10], ArrayDeleter(10));
for (int i = 0; i < 10; ++i) {
arr.get()[i] = i;
}
return 0;
}
참고: unique_ptr<int[]>를 쓰면 ArrayDeleter 없이 delete[]가 자동 호출됩니다. 커스텀 삭제자가 필요한 경우(예: mmap/munmap)에만 위 패턴을 사용합니다.
예제 4: 다형성 + unique_ptr 이동
#include <memory>
#include <vector>
#include <iostream>
struct Animal {
virtual void speak() const = 0;
virtual ~Animal() = default;
};
struct Dog : Animal {
void speak() const override { std::cout << "Woof\n"; }
};
struct Cat : Animal {
void speak() const override { std::cout << "Meow\n"; }
};
std::unique_ptr<Animal> createAnimal(const std::string& type) {
if (type == "dog") return std::make_unique<Dog>();
if (type == "cat") return std::make_unique<Cat>();
return nullptr;
}
int main() {
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(createAnimal("dog"));
zoo.push_back(createAnimal("cat"));
for (const auto& a : zoo) {
a->speak();
}
return 0;
}
7. 자주 발생하는 에러와 해결법
에러 1: get()으로 얻은 포인터에 delete 적용
증상: 이중 해제(double-free)로 크래시.
// ❌ 잘못된 코드
auto ptr = std::make_unique<int>(42);
delete ptr.get(); // 이중 해제! unique_ptr 소멸 시 또 delete
해결법: get()은 non-owning 참조만 반환합니다. delete 금지. C API에 넘길 때만 사용.
// ✅ 올바른 코드
auto ptr = std::make_unique<int>(42);
some_c_api(ptr.get()); // 읽기/쓰기만, 소유권은 ptr에 유지
에러 2: Pimpl 소멸자를 헤더에 default로 두기
증상: error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'
// ❌ 잘못된 코드 (widget.h)
class Widget {
class Impl;
std::unique_ptr<Impl> pImpl_;
public:
~Widget() = default; // Impl이 불완전 타입 → 에러
};
해결법: 소멸자를 .cpp에 정의합니다.
// ✅ 올바른 코드 (widget.cpp)
Widget::~Widget() = default;
에러 3: unique_ptr<T[]>에 operator* 사용
증상: unique_ptr<int[]>에는 operator*가 없습니다.
// ❌ 잘못된 코드
auto arr = std::make_unique<int[]>(10);
int x = *arr; // 컴파일 에러
해결법: arr[i] 또는 arr.get()[i]를 사용합니다.
// ✅ 올바른 코드
int x = arr[0];
에러 4: 이동 후 원본 사용
증상: nullptr 역참조 또는 정의되지 않은 동작.
// ❌ 잘못된 코드
auto ptr1 = std::make_unique<int>(42);
auto ptr2 = std::move(ptr1);
std::cout << *ptr1 << std::endl; // ptr1은 nullptr!
해결법: 이동 후 원본은 사용하지 않습니다. 필요하면 이동 전에 복사본을 만들거나, shared_ptr을 고려합니다.
에러 5: 커스텀 삭제자 없이 malloc 버퍼를 unique_ptr에 넣기
증상: delete가 호출되는데, malloc으로 할당했으므로 free를 써야 함 → 정의되지 않은 동작.
// ❌ 잘못된 코드
std::unique_ptr<char> buf(static_cast<char*>(std::malloc(100)));
// 소멸 시 delete 호출 → 잘못됨! free여야 함
해결법: 커스텀 삭제자로 free를 지정합니다.
// ✅ 올바른 코드
auto buf = std::unique_ptr<char, decltype(&std::free)>(
static_cast<char*>(std::malloc(100)), &std::free);
에러 6: unique_ptr을 복사로 함수에 전달
증상: 컴파일 에러.
// ❌ 잘못된 코드
void take(std::unique_ptr<int> p) {}
auto ptr = std::make_unique<int>(42);
take(ptr); // 복사 불가!
해결법: std::move(ptr)로 전달합니다.
// ✅ 올바른 코드
take(std::move(ptr));
에러 7: 배열이 아닌 unique_ptr에 delete[] 사용
증상: new[]로 할당한 배열을 unique_ptr<T>에 넣으면 delete만 호출됨 → 정의되지 않은 동작.
// ❌ 잘못된 코드
std::unique_ptr<int> arr(new int[10]); // delete 호출됨, delete[] 아님!
해결법: unique_ptr<int[]>를 사용합니다.
// ✅ 올바른 코드
std::unique_ptr<int[]> arr(new int[10]);
// 또는
auto arr = std::make_unique<int[]>(10);
8. 모범 사례와 선택 가이드
선택 플로우차트
flowchart TD
A[리소스 타입은?] --> B[힙 객체 new/delete]
A --> C[C API malloc/free 등]
A --> D[파일/핸들 등]
B --> E[단일 객체?]
E -->|Yes| F[unique_ptr T]
E -->|No| G[배열]
G --> H["unique_ptr T 또는 vector"]
C --> I[커스텀 삭제자 + free]
D --> J[커스텀 삭제자 + fclose/CloseHandle 등]
F --> K[make_unique 사용]
H --> L["make_unique T 또는 vector"]
모범 사례 요약
| 규칙 | 설명 |
|---|---|
| make_unique 사용 | new 직접 사용 지양 |
| C API는 커스텀 삭제자 | malloc → free, fopen → fclose 등 |
| Pimpl 소멸자는 .cpp에 | Impl이 완전 타입이어야 함 |
| 배열은 unique_ptr<T[]> | delete[] 자동 호출 |
| get()에 delete 금지 | non-owning 참조만 |
| 소유권 이전은 std::move | 복사 불가 |
unique_ptr vs vector (배열)
// 고정 크기, C API 연동
auto buf = std::make_unique<char[]>(1024);
c_api_read(buf.get(), 1024);
// 가변 크기, 일반적인 경우
std::vector<int> vec;
vec.resize(100);
unique_ptr vs raw 포인터 (소유권 표현)
// ✅ 소유권 있음: unique_ptr
class Owner {
std::unique_ptr<Resource> resource_;
};
// ✅ 소유권 없음 (참조만): raw 포인터 또는 참조
void process(const Resource* r);
void process(const Resource& r);
9. 프로덕션 패턴
패턴 1: 팩토리에서 unique_ptr 반환
class WidgetFactory {
public:
static std::unique_ptr<Widget> create(const std::string& type) {
if (type == "A") return std::make_unique<WidgetA>();
if (type == "B") return std::make_unique<WidgetB>();
return nullptr;
}
};
패턴 2: Pimpl + ABI 안정성
헤더에 unique_ptr<Impl>만 두고 구현을 .cpp에 숨기면, 라이브러리 구현을 바꿔도 바이너리 호환성을 유지할 수 있습니다. 사용자 코드 재컴파일 없이 .so/.dll만 교체 가능합니다.
패턴 3: C API 래퍼 클래스
class CBufferGuard {
std::unique_ptr<char, decltype(&std::free)> buf_;
public:
explicit CBufferGuard(size_t size)
: buf_(static_cast<char*>(std::malloc(size)), &std::free) {
if (!buf_) throw std::bad_alloc();
}
char* get() { return buf_.get(); }
const char* get() const { return buf_.get(); }
};
패턴 4: 리소스 핸들 (RAII)
class FileGuard {
std::unique_ptr<FILE, FileDeleter> file_;
public:
explicit FileGuard(const char* path)
: file_(std::fopen(path, "r")) {
if (!file_) throw std::runtime_error("Cannot open file");
}
FILE* get() { return file_.get(); }
};
패턴 5: 옵셔널 소유 (nullable)
class Parser {
std::unique_ptr<Cache> cache_; // 필요 시에만 생성
public:
void enableCache() {
cache_ = std::make_unique<Cache>();
}
void parse() {
if (cache_) {
cache_->lookup(/* ... */);
}
}
};
패턴 6: 다형성 컨테이너 + 팩토리
std::vector<std::unique_ptr<Handler>> handlers;
handlers.push_back(HandlerFactory::create("http"));
handlers.push_back(HandlerFactory::create("websocket"));
for (const auto& h : handlers) {
h->handle(request);
}
10. 성능·체크리스트
크기 및 오버헤드
| 구성 | unique_ptr 크기 (64비트) |
|---|---|
| 기본 (delete) | 8 bytes |
| 함수 포인터 삭제자 | 16 bytes |
| 상태 없는 람다 삭제자 | 8 bytes (빈 클래스 최적화) |
make_unique vs new
// ✅ 권장: 예외 안전, 한 줄
auto p = std::make_unique<Widget>(a, b);
// ⚠️ 구식: 예외 시 누수 가능 (C++17 이전)
std::unique_ptr<Widget> p(new Widget(a, b));
프로덕션 체크리스트
- C API 연동 시 커스텀 삭제자 사용 (malloc→free, fopen→fclose)
- Pimpl 소멸자는 반드시 .cpp에 정의
- 배열은
unique_ptr<T[]>또는std::vector사용 -
get()으로 얻은 포인터에 delete 금지 - 소유권 이전 시
std::move사용 - 기본은
make_unique,make_unique<T[]>(n)사용
마무리
핵심 요약
✅ 커스텀 삭제자: C API(malloc/free, FILE*), Windows HANDLE 등 RAII로 안전하게 관리
✅ 배열: unique_ptr<T[]> 또는 std::vector
✅ Pimpl: 구현 숨김, ABI 안정성, 소멸자는 .cpp에 정의
✅ 이동 의미론: std::move로 소유권 이전, 팩토리 반환에 활용
실무 규칙
- C API는 반드시 커스텀 삭제자
- Pimpl 소멸자는 .cpp에
- get()에 delete 금지
- 배열은 unique_ptr<T[]> 또는 vector
다음 글
shared_ptr과 weak_ptr, 순환 참조 해결은 C++ 스마트 포인터와 순환 참조 해결법을 참고하세요.
자주 묻는 질문 (FAQ)
Q. unique_ptr과 shared_ptr 중 뭘 써야 할까요?
A. 기본은 unique_ptr입니다. 여러 곳에서 소유권을 공유해야 할 때만 shared_ptr을 사용하세요. unique_ptr은 오버헤드가 없고, 이동만으로 명확한 소유권 전달이 가능합니다.
Q. Pimpl에서 소멸자를 왜 .cpp에 두어야 하나요?
A. unique_ptr<Impl>의 소멸자가 Impl을 delete할 때, Impl이 완전 타입(complete type)이어야 합니다. 헤더에서는 Impl이 전방 선언만 되어 있어 불완전 타입이므로, 소멸자를 .cpp에 두어 그 시점에 Impl이 정의된 상태로 만들어야 합니다.
Q. 배열에 unique_ptr과 vector 중 뭘 쓰나요?
A. 가변 크기, resize, push_back 등이 필요하면 vector를 쓰세요. 고정 크기이고 C API에 raw 포인터를 넘겨야 하면 unique_ptr<T[]>가 적합합니다.
Q. 선행으로 읽으면 좋은 글은?
A. C++ 스마트 포인터 기초, 이동 의미론, RAII를 먼저 읽으면 좋습니다.
참고 자료
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
- C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
- C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]
이 글에서 다루는 키워드 (관련 검색어)
C++, unique_ptr, 스마트포인터, 커스텀삭제자, Pimpl, 이동의미론, 메모리관리, RAII, 모던C++, C_API연동 등으로 검색하시면 이 글이 도움이 됩니다.
관련 글
- C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
- C++ Google Test | gtest 설치부터 TEST·EXPECT_EQ
- C++ Google Mock |
- C++ shared_ptr 고급 완벽 가이드 | enable_shared_from_this·aliasing
- C++ 이동 의미론 완벽 가이드 | rvalue 참조·std::move