소유권 모델 먼저 정리
| 타입 | 소유권 의미 | 대표 용도 |
|---|
std::unique_ptr<T> | 단독 소유(복사 불가, 이동만 가능) | 명확한 단일 owner |
std::shared_ptr<T> | 공동 소유(참조 카운트) | 생명주기를 여러 객체가 공유 |
std::weak_ptr<T> | 비소유 관찰자 | 순환 참조 방지, 약한 참조 |
핵심 원칙
- 가능한 한
unique_ptr를 기본값으로 사용합니다.
- 공유 소유권이 정말 필요한 경우에만
shared_ptr을 선택합니다.
weak_ptr는 "살아 있으면 쓰고, 없으면 포기"하는 안전한 관찰자입니다.
수동 레퍼런스 카운트가 위험한 이유
전형적 패턴
class RefCountable {
public:
void AddRef() { ++refCount_; }
void ReleaseRef() {
if (--refCount_ == 0) delete this;
}
private:
int refCount_ = 1;
};
멀티스레드 타이밍 버그
- 포인터를 전달한 뒤
AddRef()를 호출하기 전에 다른 스레드가 ReleaseRef()를 호출할 수 있습니다.
- 이 경우 객체가 삭제된 후 접근하는 use-after-free가 발생합니다.
refCount_를 atomic으로 바꿔도 생명주기 hand-off 타이밍 문제는 남습니다.
shared_ptr의 본질: 제어 블록
구조
shared_ptr은 객체와 별도로 제어 블록(control block) 을 둡니다.
- 제어 블록에는 strong count(
shared_ptr 수), weak count(weak_ptr 수), deleter 정보가 들어갑니다.
- 복사 시 strong count가 원자적으로 증가하고, 마지막 strong count가 0이 되면 객체가 파괴됩니다.
스레드 안전 경계 (매우 중요)
- 안전한 것: 서로 다른
shared_ptr 인스턴스가 같은 객체를 참조할 때 count 갱신
- 안전하지 않은 것: 객체
T 내부 상태를 여러 스레드가 동시에 변경
- 즉,
shared_ptr은 "수명 관리"를 안전하게 할 뿐, 객체 데이터 동기화까지 자동으로 해주지는 않습니다.
생성/소멸 실무 규칙
make_shared 우선
auto p = std::make_shared<Player>(id, name);
- 일반적으로 예외 안전성과 성능(할당 횟수 감소) 측면에서 유리합니다.
- 커스텀 deleter나 특수 생성 정책이 필요할 때만
shared_ptr(new T)를 신중히 사용합니다.
unique_ptr에서 shared_ptr로 승격
std::unique_ptr<Player> up = std::make_unique<Player>();
std::shared_ptr<Player> sp = std::move(up);
- 소유권이 단일에서 공유로 바뀌는 지점을 코드로 명확히 표현할 수 있습니다.
전달 방식 선택 가이드
| 함수 인자 | 의미 | 사용 기준 |
|---|
std::shared_ptr<T> 값 전달 | 소유권 공유(수명 연장) | 비동기 작업에 보관 필요 |
const std::shared_ptr<T>& | 참조만, count 증가 없음 | 호출 중 잠깐 접근 |
T* / T& | 소유권 없음 | 수명 보장이 외부에서 명확할 때 |
std::weak_ptr<T> | 살아 있으면 사용 | 콜백/관찰자 패턴 |
실무 팁
- 비동기 큐/스레드 풀에 작업을 넘길 때는 값 전달
shared_ptr가 흔히 안전합니다.
- 동기 함수 내부 즉시 사용이라면
const shared_ptr& 또는 T&가 더 가볍습니다.
weak_ptr와 순환 참조
왜 필요한가
shared_ptr끼리 서로를 참조하면 strong count가 0이 되지 않아 메모리 누수가 납니다.
struct B;
struct A { std::shared_ptr<B> b; };
struct B { std::weak_ptr<A> a; };
안전한 사용법
if (auto sp = wp.lock()) {
sp->DoWork();
} else {
}
lock() 결과를 즉시 검사해 수명 경쟁(race)을 안전하게 처리합니다.
shared_ptr(this) 금지와 enable_shared_from_this
잘못된 코드
std::shared_ptr<Knight> p(this);
- 이미 다른 제어 블록이 존재하면 제어 블록이 2개 생겨 이중 삭제가 발생할 수 있습니다.
올바른 패턴
class Knight : public std::enable_shared_from_this<Knight> {
public:
std::shared_ptr<Knight> GetPtr() {
return shared_from_this();
}
};
추가 주의사항
- 객체가
shared_ptr로 소유되기 전 생성자에서 shared_from_this()를 호출하면 예외가 날 수 있습니다.
- 소멸자 근처에서 자기 자신을 다시 공유하려는 패턴도 피해야 합니다.
공유 포인터 자체를 공유 변수로 쓸 때
문제
- 여러 스레드가 같은
shared_ptr 변수를 동시에 읽고/바꾸면 경쟁이 생길 수 있습니다.
해법
- mutex로 보호하거나,
- C++20 이상에서
std::atomic<std::shared_ptr<T>>를 사용해 원자적 load/store/exchange를 적용합니다.
오해 금지
- "shared_ptr은 스레드 안전"이라는 말은 보통 제어 블록 count 갱신에 대한 이야기입니다.
- shared_ptr 변수 자체와 객체 내부 상태까지 자동으로 안전해지는 것은 아닙니다.
강의 시 유의사항
강조 포인트
- 스마트 포인터는 메모리 해제 자동화 도구이면서, 동시에 소유권 설계 도구입니다.
shared_ptr의 안전 범위를 정확히 구분해야 멀티스레드 버그를 줄일 수 있습니다.
- 순환 참조와
shared_ptr(this)는 실무에서 반복되는 대표 실수입니다.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
shared_ptr이면 동시성 버그가 사라진다 | 수명 관리는 도움되지만 객체 상태 동기화는 별도 필요 |
atomic<int> refCount면 수동 관리도 안전하다 | hand-off 타이밍 문제는 여전히 위험 |
shared_from_this()는 어디서나 호출 가능 | shared ownership 확립 전/소멸 경로에서는 위험 |
체크 질문 (스스로 답해보기)
shared_ptr이 보장하는 스레드 안전 범위는 정확히 어디까지인가?
- 왜
shared_ptr(this)가 제어 블록 2개 문제를 만들 수 있는가?
- 콜백/비동기 코드에서
weak_ptr + lock() 패턴이 필요한 이유는?