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++17shared_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_ptr을 int 자체에 특화하기보다, 아래 “대안”처럼 타입만 다른 래퍼를 쓰기도 합니다).
#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에서는 closesocket과 SOCKET 타입을 사용합니다.
삭제자 타입
// 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_ptr과 shared_ptr의 삭제자 차이는?
A: unique_ptr<T,D> 는 D가 타입에 포함되어 서로 다른 D면 다른 unique_ptr 타입입니다. shared_ptrshared_ptr<T>로 동일하게 다룰 수 있습니다.
Q3: 배열은?
A: std::make_unique<T[]>(n)는 C++14, std::make_shared<T[]>(n)는 C++20입니다. 그 전에는 shared_ptr에 delete[] 삭제자를 붙이거나 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:
- Scott Meyers, Effective Modern C++ (Item 18–22 근방)
- cppreference —
std::unique_ptr - cppreference —
std::shared_ptr
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- 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 |
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「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 | ‘커스텀 삭제자’ 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 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 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, custom-deleters, smart-pointer, RAII, resource 등으로 검색하시면 이 글이 도움이 됩니다.