C++ shared_ptr의 Control Block은 어디에 생성될까?

//
//

🚀 들어가며

이전 글들에서는 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을 하나의 메모리 블록에 함께 배치할 수 있습니다.

C++ shared_ptr의 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가
서로 어떻게 연결되는지 훨씬 자연스럽게 이해할 수 있습니다.

스마트 포인터를 잘 사용한다는 것은 단순히 문법을 아는 것이 아니라,
객체의 관계와 생명주기를 설계할 수 있다는 뜻입니다.

👉 게임 엔진 구조를 더 깊이 이해하고 싶다면

아래 강의를 통해 직접 구현해보는 것을 추천드립니다.

C++로 만드는 게임 엔진 프레임워크 강의

👉 C++로 만드는 게임 엔진 프레임워크 강의 바로가기

//
   

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다