본문으로 건너뛰기
Previous
Next
C++ Custom Deleters | '커스텀 삭제자' 가이드

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

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

이 글의 핵심

커스텀 삭제자가 필요한 경우, 함수 포인터·람다·함수 객체 비교, 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*와 함께 제어 블록에 타입 소거(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 는 삭제자가 제어 블록에 저장되고 정적 타입은 보통 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++ Custom Deleters | ‘커스텀 삭제자’ 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(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++ Custom Deleters | ‘커스텀 삭제자’ 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  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++, custom-deleters, smart-pointer, RAII, resource 등으로 검색하시면 이 글이 도움이 됩니다.