가비지 컬렉터가 있는 언어에서 넘어오면 가장 크게 바뀌는 감각이 이거예요. C#은 “아직 누가 쓰고 있으면 안 지운다”를 런타임이 알아서 보장하지만, C++은 객체를 언제 지울지 직접 정해야 해요. 스마트 포인터는 그 결정을 타입으로 적는 도구예요.
소유권은 “누가 지우나”라는 질문
new로 만든 객체는 누군가 delete를 불러야 사라져요. 책임자가 없으면 누수가 나고, 책임자가 둘이면 두 번 지워서 죽어요. 스마트 포인터는 이 책임을 변수에 붙여서, 변수가 사라질 때 소멸자가 대신 지우게 해요.
로봇 코드에서 소유 대상은 대체로 셋이에요. 하드웨어 핸들(시리얼 포트, 소켓), 큰 데이터(포인트클라우드, 이미지 프레임), 노드가 들고 있는 구성 요소(드라이버 객체, 구독·타이머)요. 각각에 맞는 도구가 정해져 있어요.
| 소유 형태 | 도구 | 코드에서 만나는 자리 |
|---|---|---|
| 한 명이 소유 | unique_ptr<T> | 노드가 드라이버를 소유, 팩토리 반환 |
| 여럿이 공유, 마지막이 지움 | shared_ptr<T> | 노드·메시지·구독 |
| 소유 없이 지켜보기 | weak_ptr<T> | 순환 참조 끊기 |
| 소유 없음, 빌려 씀 | const T& | 함수 인자 대부분 |
읽는 규칙은 단순해요. 시그니처에 unique_ptr이 보이면 소유권이 넘어가고, shared_ptr이면 수명을 같이 잡고, 참조면 빌려 쓸 뿐 지우지 않아요.
이동한 원본은 비어 있어요
unique_ptr은 복사가 금지되고 이동만 돼요. 그래서 소유자가 항상 정확히 한 명이라는 걸 컴파일러가 보장해 줘요.
auto drv = open_driver(3); // drv가 소유자
auto other = std::move(drv); // 소유권 이동 — 이제 other가 소유
// drv->id ← 여기서 쓰면 널 접근이동한 뒤의 원본은 널이에요. 컴파일은 되지만 역참조하면 죽어요. 참조를 복사하면 둘 다 유효한 언어에서 오면 여기서 한 번 걸리는데, “소유권을 넘겼으면 손을 뗀다”로 기억하면 돼요.
shared_ptr은 반대로 참조 카운트로 공유를 표현해요. 복사할 때마다 1 늘고 소멸할 때마다 1 줄어서, 0이 되는 순간 지워져요. 미들웨어가 메시지를 잠깐 잡고 콜백도 잡는 것처럼 누가 먼저 놓을지 정할 수 없는 관계에 쓰는 도구예요.
다만 남용하면 대가가 있어요. 카운트 증감이 원자 연산이라 복사마다 비용이 붙고, 무엇보다 누가 소유자인지를 타입이 말해 주지 않게 돼요. 소유자가 분명히 한 명이면 unique_ptr, 잠깐 빌려 쓰는 인자는 const T&가 먼저예요.
C 핸들도 같은 방식으로 감쌉니다
로봇 코드는 C API를 자주 만나요. 시리얼 포트의 open()/close(), 소켓, 서드파티 SDK 핸들처럼 “다 쓰면 전용 해제 함수를 불러야 하는” 자원들이요. unique_ptr은 지울 때 부를 함수를 바꿀 수 있어서 이런 자원도 감쌀 수 있어요.
struct FileCloser {
void operator()(std::FILE* f) const { if (f) std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;이 패턴이 중요한 이유는 예외와 조기 반환 때문이에요. close()를 손으로 부르는 코드는 중간에 예외가 나면 그 줄을 건너뛰는데, 해제 함수를 타입에 맡기면 어떤 경로로 함수를 나가도 정리가 보장돼요. 포트를 열어 둔 채 죽는 프로그램, 다시 실행하면 “port busy”가 뜨는 증상의 뿌리가 대부분 여기예요.
순환 참조를 끊는 weak_ptr
shared_ptr끼리 서로를 잡으면 카운트가 영원히 0이 안 돼요. 관리자가 작업자를 잡고 작업자도 관리자를 잡는 구조요. 누수인데 크래시가 안 나서 오래 숨는 종류예요. 한쪽, 보통 자식에서 부모로 가는 방향을 weak_ptr로 바꾸면 고리가 끊겨요.
struct Manager {
std::vector<std::shared_ptr<Worker>> workers; // 부모 → 자식: 소유
};
struct Worker {
std::weak_ptr<Manager> boss; // 자식 → 부모: 지켜보기만
};쓸 때는 lock()으로 승격해요. 살아 있으면 유효한 shared_ptr을 돌려주고 이미 지워졌으면 널을 돌려주니, “아직 살아 있으면 쓰고 죽었으면 건너뛴다”가 코드로 표현돼요.
콜백은 등록한 시점이 아니라 나중에 실행돼요
수명 사고가 가장 자주 나는 자리는 여기예요. 타이머 콜백, 구독 콜백, 비동기 결과 콜백 전부 등록과 실행 사이에 시간이 있어요.
std::function<void()> make_bad_callback() {
int offset = 42; // 지역 변수
return [&offset]() { // 참조로 캡처
std::cout << offset; // 실행 시점엔 이미 죽어 있음
};
} // ← 여기서 offset 소멸참조 캡처는 변수의 수명을 연장하지 않아요. 클로저가 캡처한 변수를 런타임이 살려 주는 언어와 달리, C++ 람다의 참조 캡처는 그 자리 주소를 기억할 뿐이라 원본이 죽으면 해제된 메모리를 가리켜요.
증상이 고약한 이유가 여기 있어요. 죽은 메모리에 우연히 옛 값이 남아 있으면 한동안 잘 돌다가, 최적화를 켜거나 타이밍이 바뀌는 날 갑자기 쓰레기 값이 나와요. 재현이 안 되는 버그의 단골 자리예요.
규칙은 하나예요. 콜백이 등록 지점보다 오래 산다면 참조 캡처를 쓰지 않아요. 작은 값은 값 캡처로 복사하고, 큰 객체는 [data = std::move(data)]로 소유권을 람다 안으로 옮기고, 공유 객체는 shared_ptr을 값 캡처해서 수명을 같이 잡아요.
멤버 함수 안에서 this를 캡처할 때도 같은 문제예요. this는 raw 포인터라 객체가 먼저 죽으면 똑같이 댕글링이거든요. 노드 코드에서 this 캡처가 널리 쓰이는 건, 타이머와 구독을 노드 멤버로 들고 있어서 “콜백이 살아 있는 동안 노드도 반드시 살아 있다”가 구조로 보장되기 때문이에요. 그 보장이 없는 자리, 예를 들어 떼어낸 스레드나 외부에 넘긴 콜백이라면 shared_from_this()를 값 캡처해서 수명을 붙잡는 편이 안전해요.
소유 지도를 그려 보면
새 코드를 읽을 때 객체마다 “소유자가 누구인가”를 한 줄로 답해 보면 포인터 선택은 자동으로 따라와요. 노드가 드라이버를 소유하고, 실행기와 사용자가 노드를 공유하고, 콜백은 노드가 살아 있는 동안만 돌고, 함수 인자는 빌려 쓸 뿐이라는 식으로요.
같은 질문이 메모리 밖에서도 반복돼요. 시리얼 포트와 카메라처럼 한 번에 한 주인만 가질 수 있는 자원에서도 소유자를 정하지 않아 생기는 사고가 동시성 버그의 원인은 자원 소유권 — 카메라는 메인, 시리얼은 스레드에 있어요. 대상이 메모리냐 장치냐만 다르고 질문은 같아요.
여러 스레드가 같은 데이터를 만질 때 이 질문이 어떻게 이어지는지는 C++ 동시성 — 센서 수신과 제어 루프에 정리했어요.