🚀 들어가며
이번 글에서는 shared_ptr의 내부 구조 즉, 동작 매커니즘을 살펴보고자 합니다.
이전 글에서는 C++ 스마트 포인터의 기본 개념과 함께 unique_ptr, shared_ptr, weak_ptr의 사용 기준을 살펴봤습니다.
특히 shared_ptr을 설명할 때 중요한 문장이 있었습니다.
- “객체의 소멸 시점을 명확히 알 수 없을 때 shared_ptr을 사용할 수 있습니다.”
그런데 여기에서 한 단계 더 들어가면 자연스럽게 이런 질문이 생깁니다.
- shared_ptr은 어떻게 참조 카운트를 관리할까?
- 왜 shared_ptr은 unique_ptr보다 무겁다고 할까?
- make_shared를 권장하는 이유는 무엇일까?
- weak_ptr은 어떻게 객체가 살아있는지 확인할 수 있을까?
이 질문에 답하려면 shared_ptr의 내부 구조를 이해해야 합니다.
이번 글에서는 shared_ptr의 핵심 구조인 Control Block을 중심으로 shared_ptr이 실제로 어떻게 동작하는지 살펴보겠습니다.
🧠 shared_ptr은 포인터 하나만 들고 있지 않습니다
겉으로 보면 shared_ptr은 일반 포인터처럼 사용할 수 있습니다.
std::shared_ptr<Actor> actor = std::make_shared<Actor>(); actor->Tick();
사용하는 입장에서는 Actor 객체를 가리키는 포인터처럼 보입니다.
하지만 shared_ptr은 단순히 객체 주소 하나만 저장하지 않습니다.
대략적으로 보면 shared_ptr은 내부적으로 두 가지 정보를 가지고 있습니다.
- 실제 객체를 가리키는 포인터
- Control Block을 가리키는 포인터
즉, 단순화하면 이런 구조입니다.
shared_ptr ├── 객체 포인터 └── Control Block 포인터
여기서 중요한 것은 Control Block입니다.
shared_ptr의 핵심 정보는 대부분 Control Block에 들어 있습니다.
🎯 Control Block이란?
shared_ptr의 내부 구조를 단순화해서 보면 다음과 같은 형태입니다.

특히 shared_ptr(new T)와 make_shared는 메모리 할당 구조에서 차이가 발생할 수 있습니다.
Control Block은 shared_ptr이 객체의 생명주기를 관리하기 위해 사용하는 별도의 관리 정보입니다.
Control Block에는 보통 다음과 같은 정보가 들어 있습니다.
- strong reference count
- weak reference count
- deleter
- allocator 정보
단순하게 표현하면 다음과 같습니다.
Control Block ├── strong count ├── weak count ├── deleter └── allocator
각각의 역할을 하나씩 살펴보겠습니다.
🧩 strong reference count
shared_ptr이 관리하는 가장 중요한 값은 strong reference count입니다.
이 값은 해당 객체를 실제로 소유하고 있는 shared_ptr의 개수를 의미합니다.
예를 들어 아래 코드를 보겠습니다.
std::shared_ptr<Actor> actor1 = std::make_shared<Actor>(); std::shared_ptr<Actor> actor2 = actor1;
이 경우 actor1과 actor2는 같은 Actor 객체를 공유합니다.
actor1 ─┐
├── Actor 객체
actor2 ─┘
이때 strong reference count는 2가 됩니다.
strong count = 2
actor2가 사라지면 count는 1이 됩니다.
actor1까지 사라지면 count는 0이 됩니다.
그리고 이 순간 Actor 객체가 소멸됩니다.
strong count == 0 → 관리 중인 객체 소멸
즉, shared_ptr이 객체를 삭제하는 기준은 “더 이상 사용하지 않는다”는 추상적인 판단이 아니라,
strong reference count가 0이 되었는가입니다.
🧩 weak reference count
weak_ptr은 객체를 소유하지 않습니다.
따라서 weak_ptr이 늘어나도 strong reference count는 증가하지 않습니다.
std::shared_ptr<Actor> actor = std::make_shared<Actor>(); std::weak_ptr<Actor> weakActor = actor;
이 경우 strong count는 여전히 1입니다.
하지만 weak_ptr도 Control Block을 참조해야 합니다.
왜냐하면 weak_ptr은 나중에 lock()을 호출했을 때 객체가 아직 살아 있는지 확인해야 하기 때문입니다.
if (std::shared_ptr<Actor> lockedActor = weakActor.lock())
{
lockedActor->Tick();
}
weak_ptr 입장에서 객체가 살아 있는지 확인하려면 어디선가 그 정보를 확인해야 합니다.
그 정보가 바로 Control Block에 있습니다.
따라서 weak_ptr은 객체 자체를 소유하지는 않지만, Control Block은 참조합니다.
weak_ptr └── Control Block 참조
이 구조 때문에 weak_ptr은 객체의 생명주기에는 영향을 주지 않지만,
객체가 살아 있는지 여부는 확인할 수 있습니다.
🎯 객체는 사라졌는데 Control Block은 남을 수 있습니다
여기서 중요한 점이 하나 있습니다.
strong count가 0이 되면 객체는 소멸됩니다.
하지만 weak_ptr이 아직 남아 있다면 Control Block은 바로 사라지지 않습니다.
왜냐하면 weak_ptr이 여전히 Control Block을 통해 객체의 생존 여부를 확인해야 하기 때문입니다.
흐름을 보면 다음과 같습니다.
strong count == 0 → 객체 소멸 weak count != 0 → Control Block은 유지
weak_ptr까지 모두 사라지면 그때 Control Block도 해제됩니다.
strong count == 0 weak count == 0 → Control Block 해제
이 부분을 이해하면 weak_ptr이 왜 “객체를 직접 참조하지 않고 Control Block을 참조한다”고 설명하는지 이해할 수 있습니다.
⚠️ shared_ptr은 왜 unique_ptr보다 무거울까?
unique_ptr은 구조가 매우 단순합니다.
대부분의 경우 unique_ptr은 원시 포인터 하나 정도의 비용만 가집니다.
unique_ptr └── 객체 포인터
반면 shared_ptr은 객체 포인터 외에도 Control Block을 관리해야 합니다.
shared_ptr ├── 객체 포인터 └── Control Block 포인터
또한 shared_ptr이 복사되거나 사라질 때마다 참조 카운트를 증가/감소시켜야 합니다.
std::shared_ptr<Actor> actor2 = actor1; // 참조 카운트 증가
shared_ptr 복사 → strong count 증가 shared_ptr 소멸 → strong count 감소
이 참조 카운트 연산은 멀티스레드 환경에서도 안전해야 하므로 보통 원자적(Atomic) 연산이 사용됩니다.
그래서 shared_ptr은 unique_ptr보다 비용이 큽니다.
정리하면 shared_ptr의 비용은 다음과 같습니다.
- Control Block을 위한 추가 메모리
- 참조 카운트 증가/감소 비용
- 멀티스레드 안전성을 위한 원자적 연산 비용
그래서 shared_ptr은 편리하다고 아무 곳에나 사용하는 도구가 아닙니다.
필요할 때만 사용해야 합니다.
🎯 make_shared를 권장하는 이유
shared_ptr을 생성하는 방식은 크게 두 가지가 있습니다.
std::shared_ptr<Actor> actor(new Actor());
그리고
std::shared_ptr<Actor> actor = std::make_shared<Actor>();
현재는 일반적으로 std::make_shared 사용이 권장됩니다.
그 이유는 Control Block과 관련이 있습니다.
🧩 shared_ptr(new T) 방식
아래와 같이 작성하면,
std::shared_ptr<Actor> actor(new Actor());
보통 객체와 Control Block이 별도로 할당됩니다.
메모리 할당 1: Actor 객체 메모리 할당 2: Control Block
즉, 메모리 할당이 두 번 발생할 수 있습니다.
구조적으로 보면 다음과 같습니다.
shared_ptr ├── Actor 객체 --------------> 별도 메모리 └── Control Block -----------> 별도 메모리
🧠 왜 메모리 할당이 두 번 발생할까?
shared_ptr은 단순히 객체만 관리하는 것이 아닙니다.
앞에서 설명했듯이 shared_ptr은 객체의 생명주기를 관리하기 위해 별도의 Control Block을 함께 사용합니다.
따라서 아래 코드가 실행되면,
std::shared_ptr<Actor> actor(new Actor());
실제로는 내부적으로 두 개의 서로 다른 메모리 영역이 필요할 수 있습니다.
1. Actor 객체 메모리 2. Control Block 메모리
먼저 new Actor()가 실행되면서 Actor 객체가 힙 메모리에 생성됩니다.
new Actor()
이 시점에서는 단순히 Actor 객체 하나만 생성된 상태입니다.
구조적으로 보면 다음과 같습니다.
힙 메모리 └── Actor 객체
그 다음 shared_ptr 생성자가 호출됩니다.
std::shared_ptr<Actor> actor(...)
이때 shared_ptr은 단순히 객체 주소만 저장하는 것이 아니라,
객체의 참조 카운트를 관리하기 위한 Control Block도 생성해야 합니다.
따라서 내부적으로 추가 메모리 할당이 발생할 수 있습니다.
힙 메모리 ├── Actor 객체 └── Control Block
즉,
new Actor() → 객체 메모리 할당 shared_ptr 생성 → Control Block 메모리 할당
이런 흐름으로 동작할 수 있습니다.
🎯 왜 이게 중요할까?
단순히 “메모리 할당이 한 번 더 발생한다” 수준의 문제가 아닙니다.
메모리 할당은 생각보다 비용이 큰 작업입니다.
운영체제 수준 allocator와 연결되며,
멀티스레드 환경에서는 synchronization 비용까지 발생할 수 있습니다.
특히 게임 엔진처럼 프레임 단위로 수많은 객체가 생성/삭제되는 환경에서는
이런 작은 비용들이 누적될 수 있습니다.
또한 객체와 Control Block이 서로 다른 위치에 존재한다는 것도 중요합니다.
예를 들어 메모리 구조가 다음과 같다고 가정해보겠습니다.
0x1000 → Actor 객체 0x5000 → Control Block
shared_ptr을 복사하거나 destroy할 때마다 CPU는:
- 객체 메모리 접근
- Control Block 접근
을 각각 수행하게 됩니다.
이 과정에서 캐시 지역성(cache locality) 측면에서도 손해가 발생할 수 있습니다.
🧠 make_shared가 메모리 지역성(Locality)에 유리한 이유
반면 make_shared는 보통 객체와 Control Block을 하나의 메모리 블록에 함께 생성합니다.
[ Control Block | Actor 객체 ]
즉, 메모리 구조가 훨씬 밀집된 형태가 됩니다.
이 경우:
- 메모리 할당 횟수 감소
- 메모리 단편화 감소 가능성
- cache locality 개선 가능성
등의 장점을 얻을 수 있습니다.
특히 CPU cache 관점에서는 꽤 중요한 차이가 될 수 있습니다.
객체와 Control Block이 가까운 메모리에 존재하면
shared_ptr 관련 작업 시 cache miss 가능성을 줄일 수 있기 때문입니다.
⚠️ 하지만 구현 세부사항은 표준이 강제하지 않습니다
여기서 중요한 점이 하나 있습니다.
C++ 표준이 “반드시 두 번 할당해야 한다” 또는 “반드시 한 번만 할당해야 한다”고 강제하는 것은 아닙니다.
즉,
shared_ptr(new T) → 보통 두 번 할당 make_shared → 보통 한 번 할당
이라는 것은 대부분의 구현체(libstdc++, libc++, MSVC STL 등)에서 일반적으로 사용하는 방식입니다.
하지만 구현 세부사항은 라이브러리 구현체에 따라 달라질 수 있습니다.
따라서 이 부분은 “일반적인 구현 방식”으로 이해하는 것이 좋습니다.
🧩 make_shared 방식
반면 make_shared를 사용하면 객체와 Control Block을 하나의 메모리 블록에 함께 할당할 수 있습니다.
std::shared_ptr<Actor> actor = std::make_shared<Actor>();
이 경우 일반적으로 할당은 한 번만 발생합니다.
메모리 할당 1: Control Block + Actor 객체
구조적으로 보면 다음과 같습니다.
shared_ptr └── [ Control Block + Actor 객체 ]
이 방식은 다음 장점을 가집니다.
- 메모리 할당 횟수가 줄어듭니다.
- 메모리 지역성이 좋아질 수 있습니다.
- 예외 안전성 측면에서도 유리합니다.
그래서 특별한 이유가 없다면 shared_ptr을 만들 때는 make_shared를 사용하는 것이 좋습니다.
⚠️ make_shared가 항상 좋은 것은 아닙니다
그렇다고 make_shared가 항상 정답인 것은 아닙니다.
객체와 Control Block이 하나의 메모리 블록에 함께 있기 때문에 생기는 특성이 있습니다.
strong count가 0이 되면 객체의 소멸자는 호출됩니다.
하지만 weak_ptr이 남아 있다면 Control Block은 유지되어야 합니다.
그런데 make_shared를 사용하면 Control Block과 객체 메모리가 같은 블록에 존재합니다.
따라서 객체는 소멸되었지만, 해당 메모리 블록 자체는 weak_ptr이 모두 사라질 때까지 유지될 수 있습니다.
make_shared 사용 → Control Block + 객체가 같은 메모리 블록 strong count == 0 → 객체 소멸자 호출 weak count != 0 → 메모리 블록은 유지될 수 있음
일반적인 작은 객체에서는 큰 문제가 되지 않습니다.
하지만 객체가 매우 크고, weak_ptr이 오래 남아 있는 구조라면 메모리 반환 시점이 늦어질 수 있습니다.
이런 경우에는 make_shared 대신 shared_ptr(new T) 방식이 더 적절할 수도 있습니다.
즉, make_shared는 대부분의 경우 좋은 선택이지만, 내부 구조를 이해하고 예외 상황도 알고 있어야 합니다.
🧠 shared_ptr 복사는 값 복사처럼 보이지만 의미가 다릅니다
shared_ptr은 복사할 수 있습니다.
std::shared_ptr<Actor> actor1 = std::make_shared<Actor>(); std::shared_ptr<Actor> actor2 = actor1;
겉으로 보면 단순한 값 복사처럼 보입니다.
하지만 실제로는 객체가 복사되는 것이 아닙니다.
Actor 객체는 하나만 존재합니다.
복사되는 것은 shared_ptr 객체이며, 내부적으로 같은 Control Block을 공유하게 됩니다.
actor1 ─┐
├── Control Block ─── Actor 객체
actor2 ─┘
따라서 shared_ptr을 함수 인자로 넘길 때도 주의가 필요합니다.
🎯 함수 인자로 shared_ptr을 넘길 때
아래 코드를 보겠습니다.
void Process(std::shared_ptr<Actor> actor)
{
actor->Tick();
}
이렇게 값으로 shared_ptr을 넘기면 참조 카운트가 증가합니다.
함수가 끝나면 다시 감소합니다.
함수 호출 → strong count 증가 함수 종료 → strong count 감소
객체의 생명주기를 함수 안에서도 연장해야 한다면 괜찮습니다.
하지만 단순히 사용만 하는 경우라면 굳이 shared_ptr을 값으로 받을 필요가 없습니다.
이 경우에는 참조로 받는 편이 더 자연스럽습니다.
void Process(const std::shared_ptr<Actor>& actor)
{
actor->Tick();
}
더 나아가, 함수가 객체의 생명주기와 관계없이 단순히 Actor만 사용한다면 raw pointer나 reference를 받는 것도 가능합니다.
void Process(Actor& actor)
{
actor.Tick();
}
이 차이는 중요합니다.
shared_ptr을 사용한다는 것은 단순히 객체를 가리키겠다는 뜻이 아니라,
객체의 생명주기에 관여하겠다는 의미이기 때문입니다.
🧩 weak_ptr과 Control Block의 관계
weak_ptr은 shared_ptr이 관리하는 객체를 직접 소유하지 않습니다.
하지만 weak_ptr은 Control Block을 알고 있습니다.
그래야 객체가 살아 있는지 확인할 수 있기 때문입니다.
std::weak_ptr<Actor> weakActor = actor;
if (std::shared_ptr<Actor> locked = weakActor.lock())
{
locked->Tick();
}
lock()은 내부적으로 strong count를 확인합니다.
객체가 아직 살아 있다면 shared_ptr을 만들어 반환합니다.
객체가 이미 소멸되었다면 빈 shared_ptr을 반환합니다.
weak_ptr.lock() ├── 객체 살아 있음 → shared_ptr 반환 └── 객체 소멸됨 → 빈 shared_ptr 반환
이 구조 덕분에 weak_ptr은 안전한 약한 참조로 사용할 수 있습니다.
🎯 정리
shared_ptr의 내부 구조를 이해하면 왜 조심해서 사용해야 하는지 자연스럽게 알 수 있습니다.
- shared_ptr은 객체 포인터와 Control Block을 함께 사용합니다.
- Control Block은 strong count, weak count, deleter 등을 관리합니다.
- strong count가 0이 되면 객체가 소멸됩니다.
- weak count가 남아 있으면 Control Block은 유지될 수 있습니다.
- make_shared는 객체와 Control Block을 함께 할당할 수 있어 효율적입니다.
- 하지만 큰 객체와 오래 남는 weak_ptr 구조에서는 메모리 반환이 늦어질 수 있습니다.
- shared_ptr 복사는 객체 복사가 아니라 소유권 공유입니다.
🧠 마무리
shared_ptr은 편리한 도구입니다.
하지만 내부에는 생각보다 많은 일이 일어납니다.
단순히 “자동으로 메모리를 관리해준다” 정도로 이해하면 shared_ptr을 너무 쉽게 사용하게 됩니다.
하지만 Control Block, 참조 카운트, weak_ptr과의 관계를 이해하면 shared_ptr이 왜 무겁고, 왜 조심해서 사용해야 하는지 알 수 있습니다.
결국 중요한 질문은 다시 여기로 돌아옵니다.
- “이 객체의 소멸 시점을 누가 결정할 수 있는가?”
이 질문에 명확하게 답할 수 있다면 unique_ptr을 먼저 고려하는 것이 좋습니다.
반대로 여러 객체가 생명주기를 함께 공유해야 하고, 마지막 사용자를 알 수 없다면 shared_ptr이 적절할 수 있습니다.
그리고 단순히 관찰만 하면 되는 경우에는 weak_ptr을 사용해야 합니다.
즉, 스마트 포인터를 잘 사용한다는 것은 문법을 아는 것에서 한 발 더 나아가
객체의 생명주기를 설계할 수 있다는 뜻입니다.