C++ weak_ptr로 순환 참조 문제 해결하는 방법
📌 shared_ptr이 서로를 참조할 때 발생하는 메모리 누수, weak_ptr이 해결해줍니다!
스마트 포인터는 C++에서 메모리 관리의 복잡함을 줄여주는 아주 강력한 도구입니다.
특히 shared_ptr는 여러 객체가 같은 리소스를 공유할 수 있게 해주어 편리하지만, 서로를 참조하는 상황에서는 치명적인 메모리 누수를 일으킬 수 있습니다.
이런 문제를 해결하기 위해 weak_ptr이 존재하는데요.
오늘은 여러분이 스마트 포인터를 사용할 때 반드시 알아야 할 순환 참조 문제와 weak_ptr의 역할에 대해 자세히 알아보겠습니다.
이 글에서는 C++ 개발 중 자주 마주치는 메모리 해제 이슈인 순환 참조 문제를 중심으로,
그 원인과 해결책을 단계별로 정리해보겠습니다.
특히 shared_ptr과 weak_ptr의 차이, 실제 예시, 그리고 올바른 사용 패턴까지 함께 살펴볼 예정이니,
스마트 포인터를 제대로 이해하고 싶은 분들에게 많은 도움이 될 거예요.
📋 목차
🔗 shared_ptr의 편리함과 위험성
C++의 shared_ptr은 소유권을 공유할 수 있도록 설계된 스마트 포인터입니다.
하나의 객체를 여러 포인터가 참조할 수 있으며, 참조 카운트(reference count)가 0이 될 때 자동으로 메모리를 해제해주기 때문에 메모리 관리를 한결 수월하게 만들어줍니다.
예를 들어, 여러 클래스나 함수가 동일한 리소스를 공유할 때 직접 메모리 해제를 신경 쓰지 않아도 되어 코드가 깔끔해지고 안정성도 높아집니다.
이러한 shared_ptr의 자동 메모리 관리 기능은 복잡한 시스템에서도 널리 사용되는 이유이기도 하죠.
💎 핵심 포인트:
shared_ptr은 객체의 소유권을 여러 개가 공유할 수 있게 하며, 참조 카운트가 자동으로 관리됩니다.
하지만 shared_ptr은 서로가 서로를 참조하는 경우 문제가 발생할 수 있습니다.
이런 구조에서는 참조 카운트가 절대로 0이 되지 않기 때문에, 객체가 소멸되지 않고 메모리 누수(memory leak)가 발생하게 됩니다.
⚠️ 주의: shared_ptr을 양방향으로 사용할 경우 순환 참조가 발생해 메모리가 해제되지 않을 수 있습니다.
struct B;
struct A {
std::shared_ptr<B> ptrB;
};
struct B {
std::shared_ptr<A> ptrA;
};
위 예제처럼 A와 B가 서로를 shared_ptr로 참조하면, 어느 한 쪽도 먼저 파괴될 수 없게 되어 메모리 해제가 이루어지지 않습니다.
이 문제를 해결하기 위해 등장한 것이 바로 weak_ptr입니다.
🛠️ 순환 참조가 발생하는 구조
C++에서 순환 참조는 두 객체가 서로를 shared_ptr로 참조하면서 생기는 구조입니다.
이 구조에서는 두 객체 모두 참조 카운트를 갖고 있어서,
각 객체가 소멸되어야 할 시점에도 참조 카운트가 0이 되지 않아 메모리가 해제되지 않는 문제가 발생합니다.
다시 말해, 객체 A가 객체 B를 shared_ptr로 갖고 있고,
동시에 객체 B도 객체 A를 shared_ptr로 갖고 있다면,
어느 쪽도 소멸되지 않기 때문에 영원히 메모리에 남아 있는 좀비 객체가 생기게 되는 것이죠.
💡 TIP: 참조 카운트 기반의 메모리 관리를 사용할 때는 순환 참조 가능성을 항상 염두에 두는 것이 좋습니다.
📌 순환 참조의 실제 예시
#include <memory>
#include <iostream>
struct B; // 전방 선언
struct A {
std::shared_ptr<B> b;
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::shared_ptr<A> a;
~B() { std::cout << "B destroyed\n"; }
};
int main() {
std::shared_ptr<A> objA = std::make_shared<A>();
std::shared_ptr<B> objB = std::make_shared<B>();
objA->b = objB;
objB->a = objA;
return 0;
}
이 예제를 실행하면, “A destroyed” 또는 “B destroyed” 메시지가 출력되지 않습니다.
즉, 소멸자가 호출되지 않았다는 것은 메모리가 해제되지 않았다는 뜻이며,
이것이 바로 순환 참조로 인한 메모리 누수입니다.
⚙️ weak_ptr의 역할과 특징
weak_ptr은 C++11부터 도입된 스마트 포인터로, shared_ptr와 다르게 객체의 소유권을 갖지 않습니다.
즉, 참조는 하지만 참조 카운트를 증가시키지 않기 때문에, 순환 참조를 방지할 수 있습니다.
weak_ptr은 단독으로 객체를 관리하거나 접근할 수 없으며,
반드시 lock() 함수를 통해 shared_ptr로 일시적으로 변환한 후 사용해야 합니다.
이러한 구조 덕분에, 객체가 유효하지 않으면 shared_ptr을 얻을 수 없기 때문에 안전하게 사용할 수 있죠.
💎 핵심 포인트:
weak_ptr은 참조만 하고 소유권은 갖지 않기 때문에, 참조 카운트에는 영향을 주지 않습니다.
📌 weak_ptr 사용 방법
#include <memory>
#include <iostream>
int main() {
std::shared_ptr<int> sp = std::make_shared<int>(100);
std::weak_ptr<int> wp = sp;
if (auto spt = wp.lock()) {
std::cout << *spt << std::endl;
} else {
std::cout << "expired" << std::endl;
}
return 0;
}
위 코드에서 wp.lock()은 shared_ptr이 아직 살아 있다면 유효한 포인터를 반환하고,
그렇지 않다면 nullptr을 반환합니다.
이런 방식으로 객체의 생존 여부를 체크하면서 안전하게 사용할 수 있다는 점이 weak_ptr의 큰 장점입니다.
💡 TIP: weak_ptr은 circular dependency를 끊기 위한 용도로만 사용하고, 실제 객체 접근은 반드시 lock()을 통해 수행해야 합니다.
🔌 weak_ptr을 이용한 순환 참조 해결 예제
앞서 shared_ptr만 사용할 경우 순환 참조가 발생할 수 있다는 점을 살펴보았습니다.
이번에는 weak_ptr을 활용하여 이 문제를 어떻게 해결할 수 있는지 예제를 통해 확인해보겠습니다.
shared_ptr는 소유권을 가지므로 참조 카운트를 증가시키고, weak_ptr은 소유권이 없기 때문에 참조 카운트를 증가시키지 않습니다.
따라서 두 객체 중 한 쪽을 weak_ptr로 바꿔주면 메모리 누수 없이 객체가 정상적으로 소멸됩니다.
#include <memory>
#include <iostream>
struct B; // 전방 선언
struct A {
std::shared_ptr<B> b;
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::weak_ptr<A> a; // weak_ptr 사용
~B() { std::cout << "B destroyed\n"; }
};
int main() {
std::shared_ptr<A> objA = std::make_shared<A>();
std::shared_ptr<B> objB = std::make_shared<B>();
objA->b = objB;
objB->a = objA;
return 0;
}
위 코드를 실행하면, “A destroyed” 와 “B destroyed”가 정상적으로 출력됩니다.
즉, 두 객체가 순서에 상관없이 안전하게 메모리에서 해제되며 순환 참조 문제가 말끔히 해결된 것입니다.
💎 핵심 포인트:
순환 참조가 발생할 수 있는 구조에서는 반드시 한쪽은 weak_ptr로 선언해야 안전하게 메모리를 관리할 수 있습니다.
실제 프로젝트에서는 관계가 “소유”가 아닌 단순 참조인 경우,
또는 객체 수명에 영향 주지 않아야 하는 경우에도 weak_ptr을 사용하는 것이 바람직합니다.
💡 스마트 포인터 사용 시 주의할 점
shared_ptr과 weak_ptr은 매우 강력하고 편리하지만, 그만큼 올바르게 사용하는 것이 중요합니다.
잘못 사용하면 오히려 의도하지 않은 메모리 누수나 성능 저하를 일으킬 수 있기 때문이죠.
스마트 포인터를 사용할 때는 항상 객체의 소유 관계를 먼저 고려해야 합니다.
shared_ptr은 소유권을 공유하는 상황에서만 사용하고,
그 외에는 unique_ptr이나 weak_ptr을 상황에 맞게 조합하여 사용하는 것이 좋습니다.
- 🛠️shared_ptr은 반드시 소유 관계가 명확할 때만 사용하세요.
- ⚙️weak_ptr은 순환 참조가 우려되거나 참조만 필요할 때 사용하세요.
- 🔍shared_ptr 사용 후 참조 카운트를 디버깅하는 습관을 들이세요.
- 🚫raw pointer와 혼용하지 마세요. 예상치 못한 문제가 발생할 수 있습니다.
또한 불필요한 shared_ptr 복사를 줄이는 것도 좋은 습관입니다.
복사가 일어나면 참조 카운트가 증가하면서, 의도치 않은 객체 수명 연장으로 이어질 수 있기 때문입니다.
마지막으로 스마트 포인터는 어디까지나 메모리 관리를 돕는 도구일 뿐,
모든 상황을 자동으로 해결해주는 만능 도구는 아니라는 점을 기억하세요.
올바른 이해와 사용법이 동반되어야 진정한 효과를 얻을 수 있습니다.
❓ 자주 묻는 질문 (FAQ)
shared_ptr만 써도 메모리 누수를 피할 수 있지 않나요?
이런 경우 weak_ptr로 순환 구조를 끊어야 합니다.
weak_ptr도 객체를 소유하나요?
weak_ptr은 객체를 참조만 할 뿐 소유하지 않으며, 참조 카운트에도 영향을 주지 않습니다.
weak_ptr을 직접 접근해서 사용할 수 있나요?
lock() 함수는 무엇을 하나요?
순환 참조는 언제 주로 발생하나요?
특히 부모-자식 관계나 쌍방향 연결 구조에서 자주 발생합니다.
unique_ptr도 순환 참조 문제가 생기나요?
다만 이동(moving)과 관련한 주의는 필요합니다.
shared_ptr과 weak_ptr은 어떤 기준으로 선택하나요?
weak_ptr의 성능 오버헤드는 없나요?
✅ weak_ptr로 안전하게 메모리 누수 막는 법
C++에서 스마트 포인터는 개발자의 부담을 덜어주는 강력한 도구입니다.
특히 shared_ptr은 참조 카운트를 기반으로 자동으로 메모리를 해제해주는 기능을 제공해 많은 사랑을 받고 있죠.
하지만 shared_ptr 간에 순환 참조가 발생하면 오히려 메모리 누수라는 심각한 문제가 발생할 수 있습니다.
이때 필요한 것이 바로 weak_ptr입니다.
weak_ptr은 객체를 참조하되 소유권을 가지지 않아 참조 카운트를 증가시키지 않기 때문에,
순환 참조를 효과적으로 끊어낼 수 있습니다.
즉, 공유는 하되 관리하지 않는 방식으로 객체 수명 주기를 제어할 수 있는 매우 유용한 도구입니다.
이번 글을 통해 순환 참조의 개념부터 발생 원인, weak_ptr의 등장 배경과 사용법,
그리고 실제 예제를 통한 적용 방법까지 상세히 살펴보았습니다.
스마트 포인터를 사용할 때는 무조건 shared_ptr만 사용하는 것이 아니라,
관계의 방향성과 소유권을 고려한 설계가 매우 중요하다는 점도 다시 한번 강조합니다.
앞으로 C++ 프로젝트에서 메모리 누수 없는 안정적인 코드를 작성하고 싶다면,
weak_ptr을 적극적으로 활용해보세요!
🏷️ 관련 태그:shared_ptr, weak_ptr, C++ 스마트포인터, 순환참조, 메모리누수, C++ 메모리관리, 참조카운트, 소유권관리, C++ 예제, C++코딩팁