C++ Custom Deleters | "커스텀 삭제자" 가이드

C++ Custom Deleters | "커스텀 삭제자" 가이드

이 글의 핵심

커스텀 삭제자로 C API·할당 방식을 스마트 포인터에 맞출 때의 선택지(함수 포인터, 람다, 함수 객체)와 unique_ptr·shared_ptr의 차이, RAII·성능까지 다룹니다.

커스텀 삭제자란?

스마트 포인터의 삭제 동작 커스터마이징

auto deleter = [](int* p) {
    std::cout << "삭제: " << *p << std::endl;
    delete p;
};

std::unique_ptr<int, decltype(deleter)> ptr(new int(10), deleter);

커스텀 삭제자가 필요한 경우

표준 delete/delete[]가 아닌 정리(cleanup) 가 필요할 때 커스텀 삭제자를 둡니다.

  • C API/플랫폼 핸들: FILE*(fclose), 소켓 디스크립터(close / closesocket), HANDLE, DIR* 등.
  • 할당 방식 불일치: malloc/free, 커스텀 풀에서 반납, 커스텀 정렬된 할당 등.
  • 배열: shared_ptr<T>new T[n]을 쥐고 있을 때 delete[]를 써야 하면(구형 코드) 삭제자에서 delete[] 호출. 가능하면 std::unique_ptr<T[]> / std::vector / C++17 shared_ptr<T[]>를 우선 검토합니다.
  • 관찰·디버깅: 삭제 시 로깅, 프로파일링 훅을 넣을 때.
  • 서드파티 객체: “이 포인터는 라이브러리 X의 release로만 해제” 같은 계약이 있을 때.

스마트 포인터는 소유권 + 스코프 끝에서의 정리를 한 덩어리로 묶는 도구이고, 커스텀 삭제자는 그 “스코프 끝”에 무엇을 호출할지를 바꾸는 부분입니다. 이는 아래 “RAII와의 관계” 절과 직결됩니다.

함수 포인터 vs 람다 vs 함수 객체

방식특징unique_ptr에서shared_ptr에서
함수 포인터상태 없음, 타입이 void(*)(T*)로 고정삭제자 크기가 포인터 한 칸제어 블록에 저장, 타입은 여전히 shared_ptr<T>
무상태 람다decltype(lambda)고유 타입(각 람다마다 다름)unique_ptr<T, Del>Del이 달라 서로 다른 unique_ptr 타입삭제자는 런타임에 제어 블록에 저장, 같은 shared_ptr<T> 로 취급 가능
캡처 있는 람다캡처 크기만큼 삭제자 객체가 커짐unique_ptr 객체 크기 증가(빈 베이스 최적화가 안 될 수 있음)제어 블록에 복사·이동
함수 객체(구조체)operator()에 템플릿·상태·noexcept 명시 용이팀 컨벤션으로 재사용하기 좋음동일

실무 가이드: 팀에서 재사용하는 이름 있는 삭제자struct FileCloser { void operator()(FILE*) const; }처럼 함수 객체로 두면, 테스트·문서화·단일 정의가 쉽습니다. 한 줄짜리로 끝나면 무상태 람다도 흔합니다. 함수 포인터는 C API와 직접 연결하거나 바이너리 크기를 고정하고 싶을 때 쓰입니다.

unique_ptr vs shared_ptr에서의 차이 (삭제자 관점)

  • std::unique_ptr<T, D>: 삭제자 타입 D가 템플릿 인자이므로, D가 다르면 unique_ptr 타입 자체가 다릅니다. ptr1 = ptr2 같은 대입은 같은 T와 같은 D일 때만 됩니다.
  • std::shared_ptr<T>: 삭제자는 포인터 타입 T*와 함께 제어 블록에 타입 소거(type-erased) 되어 저장됩니다. 그래서 서로 다른 람다/삭제자로 만든 shared_ptr<int>끼리는 같은 정적 타입으로 취급되며 대입·비교가 가능합니다(단, 관리 대상이 유효한 전제 하에서).

성능: unique_ptr의 무상태 삭제자는 종종 추가 저장 공간 없이 컴파일러 최적화를 받습니다. shared_ptr은 삭제자 호출이 간접 호출일 수 있어, 초고빈도 경로에서는 프로파일링해 보는 것이 좋습니다.

RAII와의 관계

RAII는 “자원 획득이 초기화이고, 소멸자에서 정리”입니다. C API를 쓰면 소멸자가 fclose 같은 함수일 뿐이라, 스마트 포인터에 커스텀 삭제자를 두면 unique_ptr<FILE, FileCloser> 형태로 동일한 스코프 규칙을 C++ 객체에 맞출 수 있습니다. 예외가 나도 스택 풀기로 삭제자가 호출되는 점이 일반 RAII 클래스와 같습니다. shared_ptr소유권 공유가 필요할 때만 쓰고, 단일 소유면 unique_ptr이 삭제자 타입까지 컴파일 타임에 드러나 의도가 더 명확합니다.

성능 고려사항 (삭제자 자체)

  • unique_ptr + 무상태 삭제자: 오버헤드가 거의 없고, T* 한 포인터만 들고 다니는 경우가 많습니다.
  • unique_ptr + 두꺼운 삭제자: 삭제자 객체가 unique_ptr 객체 크기를 키웁니다. 필요 이상으로 캡처하지 않도록 합니다.
  • shared_ptr: 참조 카운트 갱신과 제어 블록이 기본 비용입니다. 삭제자는 보통 제어 블록 안의 간접 호출 한 번입니다. 커스텀 삭제자만의 이유로 shared_ptr을 쓰기보다, 정말 공유가 필요한지 먼저 판단하는 편이 좋습니다.

make_shared와 할당 횟수는 커스텀 삭제자를 쓰는 shared_ptr 생성 시에는 make_shared를 못 쓰는 경우가 많아, 할당 2번이 되는 점도 비용에 포함해 생각합니다.

unique_ptr 삭제자

// 함수 포인터
void myDeleter(int* p) {
    std::cout << "삭제" << std::endl;
    delete p;
}

std::unique_ptr<int, decltype(&myDeleter)> ptr(new int(10), myDeleter);

// 람다
auto deleter = [](int* p) { delete p; };
std::unique_ptr<int, decltype(deleter)> ptr2(new int(20), deleter);

shared_ptr 삭제자

// shared_ptr: 타입에 포함 안됨
auto deleter = [](int* p) {
    std::cout << "삭제" << std::endl;
    delete p;
};

std::shared_ptr<int> ptr(new int(10), deleter);

실전 예시

예시 1: FILE* 관리

#include <cstdio>

auto fileDeleter = [](FILE* f) {
    if (f) {
        std::cout << "파일 닫기" << std::endl;
        std::fclose(f);
    }
};

std::unique_ptr<FILE, decltype(fileDeleter)> file(
    std::fopen("data.txt", "r"),
    fileDeleter
);

if (file) {
    char buffer[100];
    std::fgets(buffer, sizeof(buffer), file.get());
}

예시 1-보강: 파일 핸들 패턴 요약

  • 한 스코프에서만 쓴다: unique_ptr<FILE, Deleter>가 가장 가볍습니다.
  • 여러 스레드/컨테이너에서 공유: 드물게 shared_ptr<FILE> + 동일 삭제자를 쓸 수 있지만, 보통은 단일 소유가 명확합니다.
  • 바이너리 모드: fopen(..., "rb") 등 텍스트/바이너리 플래그를 실수하지 않도록 팩토리 함수 하나로 묶는 것이 좋습니다.

예시 2: 배열 삭제

// ❌ delete 사용 (배열은 delete[])
// std::unique_ptr<int> ptr(new int[10]);

// ✅ 배열 특수화
std::unique_ptr<int[]> ptr(new int[10]);

// ✅ 커스텀 삭제자
auto deleter = [](int* p) { delete[] p; };
std::unique_ptr<int, decltype(deleter)> ptr2(new int[10], deleter);

예시 3: C 라이브러리 자원

#include <cstdlib>

// malloc/free
auto deleter = [](int* p) {
    std::cout << "free" << std::endl;
    std::free(p);
};

std::unique_ptr<int, decltype(deleter)> ptr(
    static_cast<int*>(std::malloc(sizeof(int))),
    deleter
);

예시 4: 소켓 관리

POSIX 환경에서 fd를 int로 두는 경우가 많습니다. 아래는 힙에 int를 하나 두고 포인터로 삭제자에 넘기는 예시입니다(실무에서는 unique_ptrint 자체에 특화하기보다, 아래 “대안”처럼 타입만 다른 래퍼를 쓰기도 합니다).

#include <unistd.h>  // close

struct SocketDeleter {
    void operator()(int* sock) const {
        if (sock && *sock >= 0) {
            ::close(*sock);
            std::cout << "소켓 닫기" << std::endl;
        }
        delete sock;
    }
};

extern int createSocket();  // 예: socket(...)

std::unique_ptr<int, SocketDeleter> socket(new int(createSocket()));

대안: int fd만 있을 때 unique_ptr<int, FdCloser>std::default_delete<int> 대신 close를 호출하도록 FdCloser를 정의하되, “유효하지 않은 fd”-1로 표기하는 팀 규칙과 맞추면 예외 경로에서도 누수 없이 닫을 수 있습니다. Windows에서는 closesocketSOCKET 타입을 사용합니다.

삭제자 타입

// 1. 함수 포인터
void deleter(int* p) { delete p; }
std::unique_ptr<int, decltype(&deleter)> ptr(new int(10), deleter);

// 2. 람다
auto del = [](int* p) { delete p; };
std::unique_ptr<int, decltype(del)> ptr2(new int(10), del);

// 3. 함수 객체
struct Deleter {
    void operator()(int* p) const { delete p; }
};
std::unique_ptr<int, Deleter> ptr3(new int(10));

자주 발생하는 문제

문제 1: unique_ptr 타입

// unique_ptr: 삭제자가 타입에 포함
auto del1 = [](int* p) { delete p; };
auto del2 = [](int* p) { delete p; };

std::unique_ptr<int, decltype(del1)> ptr1(new int(10), del1);
std::unique_ptr<int, decltype(del2)> ptr2(new int(20), del2);

// ptr1과 ptr2는 다른 타입

문제 2: shared_ptr 타입

// shared_ptr: 삭제자가 타입에 포함 안됨
auto del1 = [](int* p) { delete p; };
auto del2 = [](int* p) { delete p; };

std::shared_ptr<int> ptr1(new int(10), del1);
std::shared_ptr<int> ptr2(new int(20), del2);

// ptr1과 ptr2는 같은 타입
ptr1 = ptr2;  // OK

문제 3: 배열 삭제자

// ❌ delete 사용
std::shared_ptr<int> ptr(new int[10]);  // 위험

// ✅ 커스텀 삭제자
std::shared_ptr<int> ptr(new int[10], [](int* p) {
    delete[] p;
});

// ✅ C++17: shared_ptr<T[]>
std::shared_ptr<int[]> ptr(new int[10]);

문제 4: nullptr 삭제자

auto deleter = [](int* p) {
    if (p) {
        delete p;
    }
};

std::unique_ptr<int, decltype(deleter)> ptr(nullptr, deleter);

성능 비교 (스마트 포인터·삭제자)

커스텀 삭제자를 쓰는 shared_ptr 은 보통 std::make_shared를 쓸 수 없고, shared_ptr<T>(new T(...), deleter)처럼 객체 할당 + 제어 블록 할당이 분리되는 비용이 생깁니다. 이는 make_shared 최적화와 트레이드오프입니다.

// 커스텀 삭제자: make_shared 불가에 가까움(일반적으로 new + shared_ptr 생성자)
auto ptr1 = std::shared_ptr<Widget>(new Widget(), [](Widget* p) { delete p; });

// 기본 삭제자면 make_shared로 할당 1번에 가깝게
auto ptr2 = std::make_shared<Widget>();

unique_ptr: 무상태 커스텀 삭제자는 default_delete와 비슷한 수준의 오버헤드로 두는 경우가 많습니다. 상태(캡처)가 큰 람다unique_ptr 객체 자체가 커질 수 있으니, 자주 복사·이동되는 타입이 아닌지 확인합니다.

FAQ

Q1: make 함수 장점?

A: make_unique / make_shared는 기본 삭제 경로에서 예외 안전성, make_shared할당 최적화, 타입 반복 감소를 제공합니다. 커스텀 삭제자가 있으면 make_를 쓰지 못하는 경우가 많습니다.

Q2: unique_ptrshared_ptr의 삭제자 차이는?

A: unique_ptr<T,D>D타입에 포함되어 서로 다른 D다른 unique_ptr 타입입니다. shared_ptr<T> 는 삭제자가 제어 블록에 저장되고 정적 타입은 보통 shared_ptr<T>동일하게 다룰 수 있습니다.

Q3: 배열은?

A: std::make_unique<T[]>(n)는 C++14, std::make_shared<T[]>(n)는 C++20입니다. 그 전에는 shared_ptrdelete[] 삭제자를 붙이거나 shared_ptr<T[]>(C++17)를 검토합니다.

Q4: 성능 차이?

A: 기본 shared_ptr 생성에서는 make_shared단일 할당에 가깝습니다. 커스텀 삭제자가 붙은 shared_ptr은 흔히 new + 제어 블록 이중 할당이 되어, 위와 다른 비용 모델입니다.

Q5: 언제 new를 직접 쓰나요?

A: 커스텀 삭제자, make_shared로는 안 되는 접근 제어/팩토리, allocate_shared, make_shared의 메모리 지연 해제가 문제인 큰 객체 등입니다. make 가이드의 “언제 쓰지 말아야 하나” 절을 참고하세요.

Q6: 람다 대신 함수 객체를 쓰는 이유는?

A: 재사용·이름·단위 테스트·일관된 noexcept 명시가 쉽습니다. 팀 전체가 같은 struct IoCloser를 링크하면 리뷰와 추적이 편합니다.

Q7: 학습 리소스는?

A:


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ make_unique & make_shared | “스마트 포인터 생성” 가이드
  • C++ 복사/이동 생성자 | “Rule of Five” 가이드
  • C++ RAII & Smart Pointers | “스마트 포인터” 가이드

관련 글

  • C++ shared_ptr vs unique_ptr |
  • C++ malloc vs new vs make_unique | 메모리 할당 완벽 비교
  • C++ 복사/이동 생성자 |
  • C++ 메모리 누수 찾기 | Valgrind·ASan으로
  • C++ Exception Safety |