🚀 들어가며
이전 글에서는 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만 사용해 서로를 참조하면 다음과 같은 순환 참조 구조가 발생할 수 있습니다.

이 구조에서는 객체들이 서로의 생명주기를 붙잡고 있기 때문에 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을 고려해야 합니다.
스마트 포인터를 잘 사용한다는 것은 단순히 문법을 아는 것이 아니라,
객체의 관계와 생명주기를 설계할 수 있다는 뜻입니다.