shared_ptr 내부 구조 이해하기 | Control Block, 참조 카운트, make_shared 차이

//
//

🚀 들어가며

이번 글에서는 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의 내부 구조를 단순화해서 보면 다음과 같은 형태입니다.

C++ shared_ptr 내부 구조와 Control Block 및 make_shared 메모리 구조 설명 다이어그램
shared_ptr은 객체 포인터뿐 아니라 참조 카운트를 관리하는 Control Block도 함께 관리합니다.

특히 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을 사용해야 합니다.

 

즉, 스마트 포인터를 잘 사용한다는 것은 문법을 아는 것에서 한 발 더 나아가
객체의 생명주기를 설계할 수 있다는 뜻입니다.

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

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

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

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

//
   

댓글 남기기

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