weak_ptr은 왜 필요할까? shared_ptr 순환 참조 문제 이해하기

//
//

🚀 들어가며

이전 글에서는 shared_ptr의 내부 구조를 살펴보았습니다.

shared_ptr은 객체의 생명주기를 관리하기 위해 Control Block을 사용하고,
Control Block 안에는 strong count와 weak count 같은 정보가 저장됩니다.

shared_ptr은 매우 편리한 도구입니다.

객체의 소멸 시점을 정확하게 알 수 없을 때,
참조 카운트를 기반으로 객체의 생명주기를 자동으로 관리해줍니다.

하지만 shared_ptr만 사용한다고 해서 모든 메모리 문제가 해결되는 것은 아닙니다.

오히려 shared_ptr을 잘못 사용하면 객체가 영원히 해제되지 않는 문제가 발생할 수 있습니다.

그 대표적인 문제가 바로

👉 순환 참조(Circular Reference)입니다.

이번 글에서는 weak_ptr이 왜 필요한지,
그리고 shared_ptr의 순환 참조 문제를 어떻게 해결하는지 자세히 살펴보겠습니다.


🧠 shared_ptr의 기본 동작 다시 보기

shared_ptr은 참조 카운팅 기반으로 동작합니다.

간단하게 정리하면 다음과 같습니다.

  • shared_ptr이 객체를 소유하면 strong count가 증가합니다.
  • shared_ptr이 복사되면 strong count가 증가합니다.
  • shared_ptr이 소멸되면 strong count가 감소합니다.
  • strong count가 0이 되면 객체가 소멸됩니다.

예를 들어 아래 코드를 보겠습니다.

std::shared_ptr<Actor> actor1 = std::make_shared<Actor>();

std::shared_ptr<Actor> actor2 = actor1;

이 경우 actor1과 actor2는 같은 Actor 객체를 공유합니다.

actor1 ─┐
        ├── Actor 객체
actor2 ─┘

이때 strong count는 2가 됩니다.

strong count = 2

actor2가 사라지면 strong count는 1이 됩니다.

actor1까지 사라지면 strong count는 0이 되고,
그 순간 Actor 객체가 소멸됩니다.

strong count == 0
→ Actor 객체 소멸

여기까지만 보면 shared_ptr은 매우 안정적인 도구처럼 보입니다.

하지만 문제는 객체들이 서로 shared_ptr로 참조하는 경우에 발생합니다.


//

⚠️ 순환 참조란?

shared_ptr만 사용해 서로를 참조하면 다음과 같은 순환 참조 구조가 발생할 수 있습니다.

C++ weak_ptr과 shared_ptr 순환 참조 문제 및 해결 방법 설명 다이어그램
shared_ptr끼리 서로 참조하면 순환 참조 문제가 발생할 수 있으며, weak_ptr을 사용해 이를 해결할 수 있습니다.

이 구조에서는 객체들이 서로의 생명주기를 붙잡고 있기 때문에 strong count가 0이 되지 않을 수 있습니다.

순환 참조는 객체들이 서로를 참조하면서,
참조 카운트가 0이 되지 않는 상황을 의미합니다.

아래 예제를 보겠습니다.

#include <iostream>
#include <memory>

class Weapon;

class Player
{
public:
    Player()
    {
        std::cout << "Player 생성\n";
    }

    ~Player()
    {
        std::cout << "Player 소멸\n";
    }

public:
    std::shared_ptr<Weapon> weapon;
};

class Weapon
{
public:
    Weapon()
    {
        std::cout << "Weapon 생성\n";
    }

    ~Weapon()
    {
        std::cout << "Weapon 소멸\n";
    }

public:
    std::shared_ptr<Player> owner;
};

이제 다음과 같이 객체를 생성해보겠습니다.

int main()
{
    std::shared_ptr<Player> player = std::make_shared<Player>();
    std::shared_ptr<Weapon> weapon = std::make_shared<Weapon>();

    player->weapon = weapon;
    weapon->owner = player;

    return 0;
}

구조를 그림처럼 표현하면 다음과 같습니다.

player(shared_ptr) ──> Player
                       │
                       ▼
                     Weapon
                       ▲
                       │
weapon(shared_ptr) ────┘

조금 더 객체 관계 중심으로 보면 다음과 같습니다.

Player → Weapon
Weapon → Player

Player는 Weapon을 shared_ptr로 소유합니다.

Weapon도 Player를 shared_ptr로 소유합니다.

즉, 서로가 서로의 생명주기를 붙잡고 있는 상태가 됩니다.


🧩 왜 객체가 해제되지 않을까?

이 상황에서 main 함수가 끝나면 지역 변수인 player와 weapon은 사라집니다.

겉으로 보면 이제 Player와 Weapon 객체가 모두 사라져야 할 것 같습니다.

하지만 실제로는 그렇지 않습니다.

먼저 Player 객체의 참조 관계를 생각해보겠습니다.

main의 player 변수 → Player
Weapon의 owner     → Player

Player를 가리키는 shared_ptr이 2개입니다.

따라서 Player의 strong count는 2입니다.

Weapon도 마찬가지입니다.

main의 weapon 변수 → Weapon
Player의 weapon    → Weapon

Weapon을 가리키는 shared_ptr도 2개입니다.

이제 main 함수가 끝나면서 지역 변수 player와 weapon이 사라집니다.

그러면 각각 strong count가 하나씩 줄어듭니다.

Player strong count : 2 → 1
Weapon strong count : 2 → 1

문제는 여기입니다.

둘 다 strong count가 0이 되지 않았습니다.

왜냐하면 Player 안의 weapon이 Weapon을 여전히 소유하고 있고,
Weapon 안의 owner가 Player를 여전히 소유하고 있기 때문입니다.

결과적으로 두 객체는 서로를 붙잡은 채로 남게 됩니다.

Player strong count = 1
Weapon strong count = 1

→ 둘 다 소멸되지 않음

이것이 순환 참조 문제입니다.


🔍 실행 결과로 확인하기

위 코드를 실행하면 생성자는 호출됩니다.

Player 생성
Weapon 생성

하지만 소멸자는 호출되지 않을 수 있습니다.

원래라면 main 함수가 끝날 때 아래 메시지도 출력되어야 합니다.

Player 소멸
Weapon 소멸

하지만 순환 참조가 발생하면 객체가 해제되지 않기 때문에 소멸자가 호출되지 않습니다.

이건 단순히 출력이 안 되는 문제가 아닙니다.

실제 프로그램에서는 메모리가 해제되지 않는다는 뜻입니다.

게임처럼 오랫동안 실행되는 프로그램에서는 이런 문제가 누적될 수 있습니다.


🎮 게임 개발에서 순환 참조가 생기기 쉬운 구조

순환 참조는 생각보다 자연스럽게 발생합니다.

특히 게임 개발에서는 객체들이 서로를 참조하는 구조가 많습니다.

예를 들어 다음과 같은 관계를 생각해볼 수 있습니다.

  • Player → Weapon
  • Weapon → Owner(Player)
  • Actor → Component
  • Component → Owner Actor
  • UI → Inventory
  • Inventory → UI Callback
  • Quest → NPC
  • NPC → Quest State

이런 구조는 전부 자연스러운 설계입니다.

문제는 이 관계를 모두 shared_ptr로 연결했을 때 발생합니다.

예를 들어 Actor와 Component 관계를 생각해보겠습니다.

Actor는 Component를 소유한다
Component는 자신의 Owner Actor를 알아야 한다

이때 Actor가 Component를 shared_ptr로 소유하는 것은 자연스러울 수 있습니다.

하지만 Component가 Owner Actor를 다시 shared_ptr로 소유하면 문제가 생길 수 있습니다.

Actor → Component : 소유 관계
Component → Actor : 참조 관계

이 둘은 같은 관계가 아닙니다.

Actor는 Component를 살려둘 책임이 있습니다.

하지만 Component는 Actor를 살려둘 책임이 없습니다.

Component는 단지 자신의 Owner가 누구인지 알고 싶을 뿐입니다.

이런 경우 Component가 Actor를 shared_ptr로 가지면 안 됩니다.

이때 필요한 것이 weak_ptr입니다.


🧩 weak_ptr은 무엇을 해결하는가?

weak_ptr은 shared_ptr이 관리하는 객체를 참조할 수 있지만,
객체를 소유하지는 않습니다.

즉, strong count를 증가시키지 않습니다.

이게 핵심입니다.

shared_ptr → 객체를 소유함
weak_ptr   → 객체를 관찰함

다시 Player와 Weapon 예제를 수정해보겠습니다.

class Weapon;

class Player
{
public:
    Player()
    {
        std::cout << "Player 생성\n";
    }

    ~Player()
    {
        std::cout << "Player 소멸\n";
    }

public:
    std::shared_ptr<Weapon> weapon;
};

class Weapon
{
public:
    Weapon()
    {
        std::cout << "Weapon 생성\n";
    }

    ~Weapon()
    {
        std::cout << "Weapon 소멸\n";
    }

public:
    std::weak_ptr<Player> owner;
};

이제 Weapon은 Player를 shared_ptr로 소유하지 않습니다.

대신 weak_ptr로 참조만 합니다.

구조는 다음과 같습니다.

Player --shared_ptr--> Weapon
Weapon --weak_ptr----> Player

이 경우 Weapon이 Player를 참조하더라도 Player의 strong count는 증가하지 않습니다.

따라서 main 함수가 끝나면 정상적으로 객체가 해제될 수 있습니다.


🔐 weak_ptr은 어떻게 안전하게 접근할까?

weak_ptr은 객체를 소유하지 않습니다.

따라서 weak_ptr이 가리키던 객체가 이미 소멸되었을 수도 있습니다.

그렇기 때문에 weak_ptr은 일반 포인터처럼 바로 사용할 수 없습니다.

아래와 같은 코드는 불가능합니다.

// 불가능
owner->Move();

weak_ptr을 사용하려면 lock()을 호출해야 합니다.

if (std::shared_ptr<Player> player = owner.lock())
{
    player->Move();
}

lock()은 객체가 아직 살아 있으면 shared_ptr을 반환합니다.

객체가 이미 소멸되었다면 빈 shared_ptr을 반환합니다.

owner.lock()
 ├── 객체 살아 있음 → shared_ptr 반환
 └── 객체 소멸됨 → 빈 shared_ptr 반환

이 구조 덕분에 weak_ptr은 안전한 약한 참조로 사용할 수 있습니다.

객체가 살아 있는 동안에는 사용할 수 있고,
객체가 사라졌다면 접근하지 않으면 됩니다.


🧠 weak_ptr은 Control Block을 참조합니다

weak_ptr이 객체가 살아 있는지 확인할 수 있는 이유는 Control Block 때문입니다.

shared_ptr이 관리하는 객체에는 Control Block이 존재합니다.

Control Block에는 strong count와 weak count가 들어 있습니다.

Control Block
 ├── strong count
 └── weak count

weak_ptr은 객체 자체를 소유하지 않지만,
Control Block을 통해 객체의 생존 여부를 확인합니다.

흐름은 대략 다음과 같습니다.

weak_ptr.lock()
→ Control Block 확인
→ strong count 확인
→ 객체가 살아 있으면 shared_ptr 생성
→ 객체가 죽어 있으면 빈 shared_ptr 반환

여기서 중요한 점이 하나 있습니다.

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이 왜 shared_ptr과 함께 사용되는지 알 수 있습니다.

weak_ptr은 객체를 살려두지는 않지만,
객체가 살아 있는지 확인할 수 있는 안전한 관찰자 역할을 합니다.


🎯 weak_ptr을 언제 사용해야 할까?

weak_ptr은 다음과 같은 상황에서 사용합니다.

  • 순환 참조를 끊어야 할 때
  • 객체를 소유하지 않고 참조만 해야 할 때
  • Observer 패턴처럼 대상 객체를 관찰만 할 때
  • 캐시에서 객체가 살아 있는지 확인해야 할 때
  • 부모-자식 관계에서 자식이 부모를 참조해야 할 때

핵심은 단순합니다.

👉 객체를 사용할 수는 있지만, 살려둘 책임은 없을 때 weak_ptr을 사용합니다.


🧩 shared_ptr과 weak_ptr의 역할 구분

shared_ptr과 weak_ptr의 차이는 단순히 강한 참조와 약한 참조의 차이가 아닙니다.

더 중요한 차이는 책임입니다.

  • shared_ptr은 객체의 생명주기에 책임을 가집니다.
  • weak_ptr은 객체의 생명주기에 책임을 가지지 않습니다.

즉,

shared_ptr → 이 객체는 내가 살려둔다
weak_ptr   → 이 객체가 살아 있으면 사용하겠다

이 차이를 이해해야 합니다.

그래야 shared_ptr을 남발하지 않고,
객체 관계를 더 명확하게 설계할 수 있습니다.


🎮 게임 엔진 구조에서 생각해보기

게임 엔진 구조에서는 객체의 생명주기가 매우 중요합니다.

예를 들어 Level이 Actor를 관리한다고 생각해보겠습니다.

Level → Actor

이 경우 Level은 Actor를 소유합니다.

그렇다면 Actor가 Level을 다시 소유해야 할까요?

대부분의 경우 그렇지 않습니다.

Actor는 자신이 어느 Level에 속해 있는지 알아야 할 수는 있습니다.

하지만 Level을 살려둘 책임까지 가지는 것은 자연스럽지 않습니다.

이 경우 Actor가 Level을 참조해야 한다면 weak_ptr 또는 raw pointer 같은 비소유 참조를 고려할 수 있습니다.

중요한 것은 이것입니다.

소유 관계인지
참조 관계인지
먼저 구분해야 한다

이 구분 없이 모든 관계를 shared_ptr로 연결하면,
객체 생명주기가 복잡해지고 순환 참조 문제가 발생할 가능성이 높아집니다.


🧠 마무리

weak_ptr은 shared_ptr의 부속 기능처럼 보일 수 있습니다.

하지만 실제로는 객체 생명주기를 설계할 때 매우 중요한 역할을 합니다.

shared_ptr은 객체를 소유합니다.

weak_ptr은 객체를 소유하지 않고 관찰합니다.

이 차이를 이해하면 순환 참조 문제를 피할 수 있고,
객체 관계를 더 명확하게 설계할 수 있습니다.

정리하면 다음과 같습니다.

  • shared_ptr은 객체의 생명주기를 연장합니다.
  • weak_ptr은 객체의 생명주기를 연장하지 않습니다.
  • shared_ptr끼리 서로 참조하면 순환 참조가 발생할 수 있습니다.
  • 순환 참조를 끊기 위해 weak_ptr을 사용할 수 있습니다.
  • 소유하지 않고 사용만 하는 관계라면 weak_ptr을 고려할 수 있습니다.

결국 중요한 질문은 이것입니다.

  • “이 객체를 내가 살려둘 책임이 있는가?”

책임이 있다면 shared_ptr을 사용할 수 있습니다.

책임이 없다면 weak_ptr을 고려해야 합니다.

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

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

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

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

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

//
   

댓글 남기기

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