🚀 들어가며
이전 글들에서는 C++ 스마트 포인터의 기본 개념과 함께,
shared_ptr, weak_ptr, enable_shared_from_this의 동작 원리를 살펴보았습니다.
그 과정에서 계속 등장한 핵심 개념이 하나 있습니다.
👉 바로 Control Block 입니다.
shared_ptr은 객체의 생명주기 관리를 위해 Control Block을 사용합니다.
Control Block에는 strong count, weak count 같은 참조 카운트 정보가 저장됩니다.
그런데 여기서 자연스럽게 이런 질문이 생깁니다.
- Control Block은 도대체 어디에 생성될까?
- 전역 어딘가에 있는 걸까?
- 싱글톤처럼 하나만 존재하는 걸까?
- shared_ptr 객체 내부에 들어 있는 걸까?
이번 글에서는 이 질문을 중심으로,
shared_ptr의 Control Block이 어디에 생성되고,
shared_ptr가 이를 어떻게 공유하는지 간단한 구현 코드와 함께 정리해보겠습니다.
🧠 shared_ptr은 포인터 하나만 가지고 있지 않습니다
먼저 중요한 점부터 정리해야 합니다.
shared_ptr은 단순히 객체 포인터 하나만 들고 있는 클래스가 아닙니다.
개념적으로 보면 shared_ptr은 보통 아래와 같은 정보를 가지고 있습니다.
shared_ptr ├── 객체를 가리키는 포인터 └── Control Block을 가리키는 포인터
즉, shared_ptr 자체 안에 참조 카운트 값이 직접 들어 있는 것이 아닙니다.
참조 카운트는 별도의 Control Block에 저장되고,
여러 shared_ptr이 같은 Control Block을 함께 바라보는 구조입니다.
개념적으로 표현하면 다음과 같습니다.
template<typename T>
class SharedPtr
{
private:
T* objectPtr;
ControlBlock* controlBlock;
};
물론 실제 표준 라이브러리 구현은 이보다 훨씬 복잡합니다.
하지만 개념을 이해하기에는 이 정도 구조로 보는 것이 좋습니다.
🎯 Control Block은 싱글톤이 아닙니다
Control Block을 처음 접하면 이런 오해를 할 수 있습니다.
"참조 카운트를 관리하는 공간이니까 어딘가 전역 관리자 같은 곳에 저장되는 걸까?"
하지만 그렇지 않습니다.
Control Block은 싱글톤이 아닙니다.
전역에 하나만 존재하는 것도 아닙니다.
객체마다, 더 정확히 말하면 shared ownership 그룹마다 하나씩 생성됩니다.
예를 들어 아래 코드가 있다고 해보겠습니다.
std::shared_ptr<Actor> actorA = std::make_shared<Actor>(); std::shared_ptr<Actor> actorB = std::make_shared<Actor>();
이 경우 Actor 객체가 2개 생성됩니다.
그리고 각각의 객체를 관리하기 위한 Control Block도 따로 생성됩니다.
actorA
└── Control Block A
└── Actor A
actorB
└── Control Block B
└── Actor B
Control Block은 객체 생명주기를 관리하기 위한 메타데이터입니다.
객체 하나를 여러 shared_ptr이 공유하면 같은 Control Block을 함께 사용합니다.
하지만 서로 다른 객체라면 서로 다른 Control Block을 사용합니다.
🧩 shared_ptr을 복사하면 Control Block도 복사될까?
아래 코드를 보겠습니다.
std::shared_ptr<Actor> actor1 = std::make_shared<Actor>(); std::shared_ptr<Actor> actor2 = actor1;
이 경우 Actor 객체가 하나 더 생성되는 것이 아닙니다.
Control Block이 하나 더 생성되는 것도 아닙니다.
actor1과 actor2는 같은 객체와 같은 Control Block을 공유합니다.
actor1 ─┐
├── Control Block
actor2 ─┘ │
▼
Actor 객체
이때 발생하는 과정은 다음과 같습니다.
actor2가 actor1을 복사 ↓ 같은 objectPtr 복사 ↓ 같은 controlBlock 포인터 복사 ↓ strong count 증가
즉, shared_ptr 복사는 객체 복사가 아닙니다.
Control Block을 공유하고,
그 안의 strong count를 증가시키는 작업입니다.
🔧 간단한 SharedPtr 구현으로 이해하기
이제 아주 단순화된 shared_ptr를 직접 구현해보겠습니다.
실제 shared_ptr는 훨씬 더 복잡하지만,
Control Block이 어디에 있고 어떻게 공유되는지 이해하기 위한 목적입니다.
먼저 Control Block을 만들어보겠습니다.
template<typename T>
class ControlBlock
{
public:
ControlBlock(T* ptr)
: objectPtr(ptr), strongCount(1)
{
}
public:
T* objectPtr;
int strongCount;
};
Control Block은 객체 포인터와 참조 카운트를 가지고 있습니다.
이제 SharedPtr 클래스를 만들어보겠습니다.
template<typename T>
class SharedPtr
{
public:
explicit SharedPtr(T* ptr)
{
controlBlock = new ControlBlock<T>(ptr);
}
SharedPtr(const SharedPtr& other)
{
controlBlock = other.controlBlock;
++controlBlock->strongCount;
}
~SharedPtr()
{
--controlBlock->strongCount;
if (controlBlock->strongCount == 0)
{
delete controlBlock->objectPtr;
delete controlBlock;
}
}
T* operator->()
{
return controlBlock->objectPtr;
}
private:
ControlBlock<T>* controlBlock = nullptr;
};
이 코드는 매우 단순화된 예제이지만, shared_ptr의 핵심 구조를 잘 보여줍니다.
중요한 부분은 생성자입니다.
explicit SharedPtr(T* ptr)
{
controlBlock = new ControlBlock<T>(ptr);
}
여기서 Control Block이 생성됩니다.
즉
SharedPtr 객체 생성 ↓ Control Block 생성 ↓ SharedPtr이 Control Block을 가리킴
이런 구조가 됩니다.
🧠 이 구현에서 Control Block은 어디에 있을까?
위 구현을 보면 Control Block은 아래 코드에서 생성됩니다.
new ControlBlock<T>(ptr)
즉, Control Block은 힙 메모리에 생성됩니다.
전역 변수도 아니고,
싱글톤도 아니고,
shared_ptr 객체 내부에 직접 들어 있는 것도 아닙니다.
shared_ptr 객체는 Control Block을 직접 소유하는 것이 아니라,
Control Block의 주소를 들고 있습니다.
SharedPtr 객체
└── ControlBlock* controlBlock
Heap
└── ControlBlock
├── objectPtr
└── strongCount
이 구조가 shared_ptr를 이해하는 데 매우 중요합니다.
⚠️ shared_ptr(new T) 방식에서는 보통 두 번 할당됩니다
아래 코드를 보겠습니다.
std::shared_ptr<Actor> actor(new Actor());
이 방식은 보통 다음과 같은 흐름으로 동작합니다.
new Actor() → Actor 객체 메모리 할당 std::shared_ptr 생성 → Control Block 메모리 할당
즉 메모리 할당이 두 번 발생할 수 있습니다.
구조적으로 보면 다음과 같습니다.
Heap ├── Actor 객체 └── Control Block
객체와 Control Block이 서로 다른 위치에 존재할 수 있습니다.
물론 실제 구현은 표준 라이브러리마다 다를 수 있습니다.
하지만 일반적으로 shared_ptr(new T) 방식은 객체와 Control Block이 별도로 할당되는 구조로 이해하면 됩니다.
🚀 make_shared는 무엇이 다를까?
지금까지 설명한 내용을 구조적으로 정리해보면 다음과 같습니다.
shared_ptr는 단순히 객체 주소만 관리하는 것이 아니라,
객체의 생명주기를 관리하는 Control Block을 중심으로 동작합니다.
그리고 make_shared는 객체와 Control Block을 하나의 메모리 블록에 함께 배치할 수 있습니다.

위 그림처럼 shared_ptr의 핵심은 객체 자체보다도
Control Block을 여러 포인터가 함께 공유한다는 점입니다.
또한 weak_ptr와 enable_shared_from_this 역시
같은 Control Block을 기반으로 동작한다는 점이 중요합니다.
이 구조를 이해하면:
- 왜 shared_ptr(this)가 위험한지
- 왜 weak_ptr이 필요한지
- 왜 make_shared가 권장되는지
를 훨씬 자연스럽게 이해할 수 있게 됩니다.
앞서 살펴본 shared_ptr(new T) 코드와 비교해 아래 코드는 다르게 동작할 수 있습니다.
std::shared_ptr<Actor> actor = std::make_shared<Actor>();
make_shared는 보통 객체와 Control Block을 하나의 메모리 블록에 함께 생성합니다.
개념적으로는 다음과 같습니다.
Heap └── [ Control Block | Actor 객체 ]
즉 메모리 할당이 한 번으로 줄어들 수 있습니다.
이 구조는 여러 장점이 있습니다.
- 메모리 할당 횟수 감소
- 메모리 단편화 감소 가능성
- cache locality 개선 가능성
그래서 특별한 이유가 없다면 shared_ptr을 만들 때는 make_shared를 사용하는 것이 권장됩니다.
🧩 make_shared를 단순하게 구현해보면?
실제 make_shared 구현은 표준 라이브러리마다 다르고,
allocator, 예외 안정성, 타입 정렬, weak_ptr 관리 등 훨씬 복잡한 요소가 포함됩니다.
하지만 핵심 아이디어만 단순화하면 아래와 같이 생각해 볼 수 있습니다.
먼저 잘못된 단순 구현부터 보겠습니다.
template<typename T, typename... Args>
SharedPtr<T> MakeShared(Args&&... args)
{
T* object = new T(std::forward<Args>(args)...);
return SharedPtr<T>(object);
}
이 코드는 겉보기에는 make_shared처럼 보입니다.
하지만 실제로는 진짜 make_shared의 장점을 살리지 못합니다.
왜냐하면 내부적으로 결국 다음과 같은 일이 발생하기 때문입니다.
new T(...) → 객체 메모리 할당 1번 SharedPtr<T>(object) → Control Block 메모리 할당 1번
즉 이 구현은 객체와 Control Block을 따로 생성합니다.
구조적으로 보면 다음과 같습니다.
Heap ├── T 객체 └── Control Block
따라서 이 코드는 make_shared의 형태를 흉내 낸 것일 뿐,
make_shared의 핵심 구조를 구현한 것은 아닙니다.
🧠 make_shared의 핵심은 하나의 메모리 블록입니다
make_shared의 핵심은 단순히 new를 감싸는 것이 아닙니다.
👉 객체와 Control Block을 하나의 메모리 블록에 함께 배치하는 것입니다.
이를 단순화해서 구현하려면 Control Block 안에 객체를 직접 저장하는 구조를 생각해볼 수 있습니다.
예를 들어 아래와 같은 Control Block을 만들 수 있습니다.
template<typename T>
class MakeSharedControlBlock
{
public:
template<typename... Args>
MakeSharedControlBlock(Args&&... args)
: strongCount(1)
, weakCount(0)
, object(std::forward<Args>(args)...)
{
}
public:
int strongCount;
int weakCount;
T object;
};
이 구조에서는 Control Block 내부에 T 객체가 직접 들어 있습니다.
즉 메모리 구조는 다음과 비슷합니다.
MakeSharedControlBlock<T> ├── strongCount ├── weakCount └── T object
이제 이 Control Block을 한 번만 new로 생성하면,
Control Block과 객체가 하나의 메모리 블록 안에 함께 생성됩니다.
template<typename T, typename... Args>
SharedPtr<T> MakeShared(Args&&... args)
{
auto* block = new MakeSharedControlBlock<T>(
std::forward<Args>(args)...
);
return SharedPtr<T>(block, &block->object);
}
이 코드는 개념적으로 다음 흐름을 가집니다.
new MakeSharedControlBlock<T>(...) ↓ 하나의 메모리 블록 할당 ↓ 그 안에 strongCount 생성 ↓ 그 안에 weakCount 생성 ↓ 그 안에 T object 생성
즉 구조적으로는 아래와 비슷합니다.
Heap
└── MakeSharedControlBlock<T>
├── strongCount
├── weakCount
└── T object
이것이 make_shared가 객체와 Control Block을 함께 배치한다는 의미입니다.
🔧 SharedPtr 쪽도 구조가 조금 달라져야 합니다
위 예제가 동작하려면 SharedPtr도 단순히 T*만 받는 구조로는 부족합니다.
왜냐하면 이제 객체와 Control Block이 분리된 경우도 있고,
하나의 블록 안에 함께 들어 있는 경우도 있기 때문입니다.
그래서 개념적으로 SharedPtr은 아래와 같은 정보를 가질 수 있습니다.
template<typename T>
class SharedPtr
{
public:
SharedPtr(MakeSharedControlBlock<T>* block, T* ptr)
: controlBlock(block)
, objectPtr(ptr)
{
}
private:
MakeSharedControlBlock<T>* controlBlock;
T* objectPtr;
};
이 구조에서 objectPtr은 실제 T 객체를 가리키고,
controlBlock은 참조 카운트를 관리하는 Control Block을 가리킵니다.
SharedPtr ├── objectPtr -----> T object └── controlBlock --> MakeSharedControlBlock<T>
중요한 점은 objectPtr과 controlBlock이 가리키는 주소가 다르더라도,
실제로는 같은 메모리 블록 내부를 가리킬 수 있다는 점입니다.
🎯 shared_ptr(new T)와 make_shared의 차이
이제 두 방식을 비교해보겠습니다.
먼저 shared_ptr(new T) 방식입니다.
std::shared_ptr<Actor> actor(new Actor());
개념적으로는 다음과 같습니다.
Heap ├── Actor 객체 └── Control Block
객체와 Control Block이 별도로 할당될 수 있습니다.
반면 make_shared 방식은 다음과 같습니다.
std::shared_ptr<Actor> actor = std::make_shared<Actor>();
개념적으로는 다음과 같습니다.
Heap
└── MakeShared Control Block
├── strong count
├── weak count
└── Actor 객체
즉 make_shared는 객체와 Control Block을 하나의 메모리 블록에 함께 배치할 수 있습니다.
이 차이 때문에 make_shared는 다음과 같은 장점을 가질 수 있습니다.
- 메모리 할당 횟수를 줄일 수 있습니다.
- 객체와 Control Block의 메모리 지역성이 좋아질 수 있습니다.
- 메모리 단편화를 줄이는 데 도움이 될 수 있습니다.
⚠️ 단순화된 예제라는 점은 기억해야 합니다
위 코드는 실제 표준 라이브러리 구현이 아닙니다.
실제 shared_ptr와 make_shared는 다음과 같은 요소까지 고려합니다.
- 타입 정렬(alignment)
- allocator
- deleter
- 예외 안정성
- weak_ptr와 Control Block 수명 관리
- 배열 타입 지원
따라서 위 코드는 실제 구현이라기보다,
make_shared가 왜 객체와 Control Block을 하나의 메모리 블록에 배치할 수 있는지 이해하기 위한 개념적인 코드입니다.
하지만 핵심은 분명합니다.
shared_ptr(new T) → 객체와 Control Block이 따로 생성될 수 있음 make_shared → 객체와 Control Block을 하나의 블록에 함께 생성할 수 있음
이 차이를 이해하면,
왜 일반적으로 make_shared 사용을 권장하는지 훨씬 자연스럽게 이해할 수 있습니다.
🔍 weak_ptr도 Control Block을 공유합니다
이제 weak_ptr을 다시 생각해보겠습니다.
weak_ptr은 객체를 소유하지 않습니다.
하지만 객체가 살아 있는지 확인할 수 있습니다.
이게 가능한 이유도 Control Block 때문입니다.
shared_ptr
└── Control Block
├── strong count
└── weak count
weak_ptr
└── 같은 Control Block 참조
weak_ptr은 객체를 직접 소유하지 않지만,
Control Block을 통해 strong count가 0인지 확인할 수 있습니다.
그래서 lock()을 호출하면:
weak_ptr.lock() ↓ Control Block 확인 ↓ strong count가 0보다 크면 shared_ptr 생성 ↓ strong count가 0이면 빈 shared_ptr 반환
이 구조가 가능합니다.
🧠 enable_shared_from_this와도 연결됩니다
enable_shared_from_this도 결국 Control Block과 연결됩니다.
이전 글에서 설명했듯이,
enable_shared_from_this는 내부적으로 weak_ptr을 사용합니다.
개념적으로는 다음과 같습니다.
template<typename T>
class enable_shared_from_this
{
protected:
std::weak_ptr<T> weak_this;
};
객체가 shared_ptr로 관리되기 시작하면,
shared_ptr은 객체 내부의 weak_this에 Control Block 정보를 연결합니다.
그 뒤 shared_from_this()를 호출하면:
weak_this.lock() ↓ 기존 Control Block을 공유하는 shared_ptr 반환
이 됩니다.
즉:
weak_ptr enable_shared_from_this shared_from_this()
이 모든 개념은 결국 Control Block을 중심으로 연결되어 있습니다.
🎮 게임 개발 관점에서 왜 중요할까?
게임 개발에서는 객체 생명주기 관리가 매우 중요합니다.
예를 들어:
- Actor
- Component
- Resource
- Texture
- Sound
- Animation Clip
이러한 객체들은 여러 시스템에서 참조될 수 있습니다.
이때 shared_ptr을 사용하면 생명주기를 편리하게 공유할 수 있습니다.
하지만 Control Block 구조를 모르면 다음과 같은 실수를 하기 쉽습니다.
- shared_ptr(this)를 만들어 Control Block을 중복 생성함
- shared_ptr(new T)와 make_shared의 차이를 모름
- weak_ptr이 왜 Control Block을 계속 참조하는지 이해하지 못함
- shared_ptr 복사가 단순 포인터 복사보다 무거운 이유를 놓침
즉 shared_ptr을 제대로 이해하려면, 단순히 사용법만 알아서는 부족합니다.
👉 Control Block이 어디에 존재하고 어떻게 공유되는지 이해해야 합니다.
🧠 마무리
이번 글에서는 shared_ptr의 Control Block이 어디에 생성되는지 살펴보았습니다.
정리하면 다음과 같습니다.
- Control Block은 싱글톤이 아닙니다.
- Control Block은 전역에 하나만 존재하지 않습니다.
- shared ownership 그룹마다 하나의 Control Block이 생성됩니다.
- shared_ptr는 객체 포인터와 Control Block 포인터를 함께 관리합니다.
- shared_ptr를 복사하면 객체가 복사되는 것이 아니라 Control Block을 공유합니다.
- shared_ptr(new T)는 객체와 Control Block이 별도로 할당될 수 있습니다.
- make_shared는 객체와 Control Block을 하나의 메모리 블록에 함께 생성할 수 있습니다.
- weak_ptr과 enable_shared_from_this도 Control Block을 중심으로 동작합니다.
결국 shared_ptr의 핵심은 이것입니다.
-> shared_ptr는 객체 주소만 공유하는 것이 아니라, 객체의 생명주기를 관리하는 Control Block을 공유한다.
이 구조를 이해하면 shared_ptr, weak_ptr, enable_shared_from_this가
서로 어떻게 연결되는지 훨씬 자연스럽게 이해할 수 있습니다.
스마트 포인터를 잘 사용한다는 것은 단순히 문법을 아는 것이 아니라,
객체의 관계와 생명주기를 설계할 수 있다는 뜻입니다.