멈추지 않고 끈질기게
[C++] 스마트 포인터(Smart Pointer) 본문
※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.
※ 해당 포스팅은 C++ 17 버전을 기준으로 작성되었습니다.
※ 해당 포스팅은 하기 출처들을 참조하였습니다.
- https://learn.microsoft.com/ko-kr/cpp/standard-library/shared-ptr-class?view=msvc-170
- https://learn.microsoft.com/ko-kr/cpp/standard-library/unique-ptr-class?view=msvc-170
- https://learn.microsoft.com/ko-kr/cpp/standard-library/weak-ptr-class?view=msvc-170
1. 포인터 사용으로 발생할 수 있는 이슈들
포인터는 C++의 강력한 도구이지만, 그와 동시에 치명적인 이슈를 일으키는 원인이기도 합니다. 포인터 사용으로 발생할 수 있는 이슈들은 대표적으로 다음과 같습니다.
1) 메모리 누수
메모리를 할당하고 해제하지 않을 경우 힙에 해당 객체가 계속 남게 됩니다. 이 경우 해당 코드가 실행될 때마다 힙 메모리를 잡아먹고, 사용하지도 않는 메모리가 계속 할당되어 있는 메모리 누수 문제가 발생합니다. 보통 malloc - free, new - delete 쌍을 맞추면 된다고는 하지만, 코드가 복잡해질수록 포인터끼리 얽히기 쉬우므로 쉽지 않습니다.
2) 널 포인터(nullptr) 이슈
아무것도 가리키지 않고 있는 널포인터를 통해 무언가에 접근할 경우 발생하는 이슈입니다. 포인터 사용 시 널포인터 체크를 생활화함으로써 어느정도 예방할 수 있습니다.
3) 댕글링 포인터(Dangling Pointer) 이슈
포인터가 이미 할당이 해제된 메모리를 가리키고 있고, 이를 통해 접근할 경우 발생하는 이슈입니다. 발생하기도 쉽고 치명적인 이슈이므로 요주의입니다.
using namespace std;
class Player
{
public:
Player() : _level(0) {}
Player(int level) : _level(level) {}
~Player() {}
public:
int _level;
};
int main()
{
Player* p1 = new Player(10);
Player* p2 = p1;
delete p1; // from here, p2 is dangling pointer
cout << p2->_level << endl;
}
상기 예시에서는 Player 클래스의 인스턴스를 동적 할당하여 생성한 뒤 주소를 p1에 저장하였고, p2에 복사하였습니다. 여기서 delete p1으로 해당 객체를 해제한 순간, p2는 댕글링 포인터가 됩니다. 이상태에서 p2를 통해 _level 값을 출력해보면 예상치 못한 값이 나오는 것을 확인할 수 있습니다. 바로 예외가 발생하거나 에러가 뜨는 것이 아니라 예기치 못한 동작을 한다는 점에서 특히나 위험합니다.
이를 해결하기 위해 C++에서는 스마트 포인터라는 클래스 템플릿을 제공합니다. 기본적으로 포인터처럼 동작하지만, 사용이 끝난 포인터의 메모리를 자동으로 해제함으로서 위와 같은 이슈들을 예방해줍니다. C++ 11 이후로는 unique_ptr, shared_ptr, weak_ptr 이라는 3종류의 스마트 포인터를 제공하고 있습니다.
2. unique_ptr<T>
이름대로 포인터의 유니크함을 보장해주는 스마트 포인터입니다. 위의 댕글링 포인터 예시에서 알 수 있듯이, 포인터의 복사는 이슈를 발생시킬 확률이 높습니다. unique_ptr을 사용할 경우 일반적인 복사 생성자를 지원하지 않으며, 오직 move() 함수를 통한 전달만 가능합니다.
using namespace std;
class Player
{
public:
Player() : _level(0) {}
Player(int level) : _level(level) {}
~Player() {}
public:
int _level;
};
int main()
{
unique_ptr<Player> p1 = make_unique<Player>(10);
// unique_ptr<Player> p2 = p1; // compile error
unique_ptr<Player> p2 = move(p1);
if (p1 == nullptr)
cout << "p1 연결 해제" << endl;
}
유니크 포인터는 메인 함수의 2번째 줄과 같은 복사를 지원하지 않고, move(p1)을 통해 해당 주소값을 넘겨받는 것만 가능합니다. 이 때, p1은 p2에 주소값을 넘겨준 뒤 nullptr로 초기화됩니다. 사진 2에서 p1의 널포인터 체크 부분이 실행된 것을 볼 수 있습니다.
이처럼 유니크 포인터는 해당 객체를 가리키는 유일한 포인터임을 보장하고, 스택 영역을 벗어나는 순간 가리키는 객체의 할당을 자동으로 해제합니다. 일반 포인터라면 해당 객체를 가리키는 다른 포인터들이 존재할 수 있기 때문에 자동으로 해제하면 안되지만, 유니크 포인터는 스택 영역을 벗어나는 순간 해당 객체를 가리키는 포인터가 존재할 수 없게 되므로 자동으로 해제하는 데 문제가 없습니다. 따라서 유니크 포인터로 가리키는 객체는 delete 를 통해 수동으로 해제할 수 없습니다(다른 스마트 포인터들도 동일).
3. shared_ptr<T>
공유 포인터는 유니크 포인터와 달리 다른 공유 포인터와 같은 객체를 가리키는 것이 가능합니다. 대신 해당 객체를 가리키는 참조(레퍼런스)를 카운팅하고, 0이 되는 순간 자동으로 해제하는 역할을 합니다.
class Player
{
public:
Player() : _level(0) { cout << "Player 객체 생성" << endl; }
Player(int level) : _level(level) {}
~Player() { cout << "Player 객체 해제됨" << endl; }
public:
int _level;
};
void Test1();
void Test2(shared_ptr<Player> p);
void Test1()
{
shared_ptr<Player> p = make_shared<Player>();
cout << "현재 참조 수: " << p.use_count() << endl;
Test2(p);
cout << "현재 참조 수: " << p.use_count() << endl;
}
void Test2(shared_ptr<Player> p)
{
cout << "현재 참조 수: " << p.use_count() << endl;
}
int main()
{
Test1();
}
위 예시에서는 Test1()에서 Player 객체를 make_shared()를 통해 동적 할당하며 shared_ptr에 연결하고, Test2()의 매개변수로 사용하고 있습니다. 공유 포인터의 현재 레퍼런스 카운트는 use_count()를 통해 확인할 수 있습니다. Test1()에서 생성한 직후에는 1이며, Test2()에서는 p가 매개변수로 복사되어 사용되었으므로 2가 된 것을 볼 수 있습니다. Test2() 종료 후에는 복사된 포인터가 1이 되며, Test1() 종료 후 레퍼런스 카운트가 0이 되고, 자동으로 해제되어 소멸자가 호출된 것을 볼 수 있습니다.
공유 포인터는 위 예시처럼 함수의 매개변수로 사용하는 등 복사 작업도 가능하므로, 유니크 포인터에 비해 광범위하게 사용할 수 있습니다. 또한 레퍼런스 카운트가 0이 되는 순간 자동으로 메모리 할당을 해제하므로, 마찬가지로 개발자가 직접 delete 할 필요도 없습니다. 하나의 객체가 여러 곳에서 참조될 경우 할당 해제 타이밍을 판단하기가 굉장히 어려워지므로, 이 경우 공유 포인터를 사용하는 것이 바람직하겠습니다.
4. weak_ptr<T>
약한 포인터는 공유 포인터를 한번 더 래핑(wrapping)한 포인터 입니다. 약한 포인터가 필요한 이유를 설명하려면, 우선 공유 포인터에서 발생할 수 있는 문제에 대해 알아보아야 합니다.
using namespace std;
class Player
{
public:
Player() : _level(0) { cout << "Player 객체 생성" << endl; }
Player(int level) : _level(level) {}
~Player() { cout << "Player 객체 해제됨" << endl; }
// _target 설정 함수
void SetTarget(shared_ptr<Player> target) { _target = target; }
public:
int _level;
// shared_ptr<Player>을 참조하는 변수
shared_ptr<Player> _target;
};
int main()
{
shared_ptr<Player> p1 = make_shared<Player>();
shared_ptr<Player> p2 = make_shared<Player>();
// circular reference
p1->SetTarget(p2);
p2->SetTarget(p1);
cout << p1.use_count() << endl;
cout << p2.use_count() << endl;
}
위 예시들에서 사용하던 Player 클래스에 공유 포인터 _target을 추가하고, 타겟 세팅용 함수를 추가하였습니다. Player 객체를 동적 할당하면서 공유포인터 p1과 p2로 가리키고, 서로 타겟으로 저장하도록 했습니다. 실행해보면 p1, p2 모두 참조 카운트가 2로 나오는 것을 알 수 있습니다.
실행 결과에서 주목할 점은 소멸자가 실행되지 않았다는 것입니다. p1과 p2는 메인 함수의 종료와 함께 스택에서 제외되지만, 각각의 Player 객체에서 p1, p2를 참조하고 있기 때문에 참조 카운트가 1에서 멈추고 할당 해제가 실행되지 않는 것입니다. 이렇게 공유 포인터가 서로를 참조하여 할당 해제를 방해하는 상황을 순환 참조(circular reference) 라고 합니다. 순환 참조가 발생할 경우, 공유 포인터를 사용해도 메모리 누수가 발생할 수 있기 때문에 주의해야 합니다.
이러한 순환 참조 문제를 방지하기 위한 포인터가 바로 약한 포인터(weak_ptr) 입니다. 다음은 위 예시에서 타겟을 weak_ptr로 변경한 코드입니다. 참고로 weak_ptr은 복사 생성자로 shared_ptr을 받으므로, 공유 포인터 p1, p2를 그대로 매개변수로 사용할 수 있습니다.
using namespace std;
class Player
{
public:
Player() : _level(0) { cout << "Player 객체 생성" << endl; }
Player(int level) : _level(level) {}
~Player() { cout << "Player 객체 해제됨" << endl; }
void SetTarget(weak_ptr<Player> target) { _target = target; }
public:
int _level;
weak_ptr<Player> _target;
};
int main()
{
shared_ptr<Player> p1 = make_shared<Player>();
shared_ptr<Player> p2 = make_shared<Player>();
// circular reference
p1->SetTarget(p2);
p2->SetTarget(p1);
cout << p1.use_count() << endl;
cout << p2.use_count() << endl;
}
실행 결과를 보면 p1 및 p2의 참조 카운트가 1로 출력되는 것을 알 수 있습니다(약한 포인터도 use_count() 사용 가능). 약한 포인터는 참조 카운팅에 영향을 주지 않고, 대신 _Weaks라는 변수에 별도로 카운팅됩니다. 따라서 위 예시에서 메인 함수 종료 시 p1, p2가 스택 영역에서 제외되며 참조 카운트가 0이 되고, 각 Player 객체가 힙 메모리에서 해제됩니다. 이와 같이 약한 포인터를 잘 사용하면 순환 참조 문제를 해결할 수 있습니다.
다만 약한 포인터는 사용 시 조심해야 할 부분이 있습니다.
class Player
{
public:
Player() : _level(0) { cout << "Player 객체 생성" << endl; }
Player(int level) : _level(level) { cout << "Player 객체 생성(Lv." << level << ")" << endl; }
~Player() { cout << "Player 객체 해제됨" << endl; }
void SetTarget(weak_ptr<Player> target) { _target = target; }
public:
int _level;
weak_ptr<Player> _target;
};
int main()
{
weak_ptr<Player> p2;
{
shared_ptr<Player> p1 = make_shared<Player>(30);
p2 = p1;
// compile error
// cout << p2->_level << endl;
shared_ptr<Player> p3 = p2.lock();
if (p3 != nullptr)
cout << p3->_level << endl;
}
shared_ptr<Player> p4 = p2.lock();
if (p4 == nullptr)
cout << "p3 is nullptr" << endl;
}
약한 포인터는 참조 카운팅에 포함되지 않기 때문에 해당 포인터가 유효함을 보장하지 않습니다. 즉, 약한 포인터가 가리키는 객체가 이미 할당 해제되어 있을 위험성이 있습니다. 따라서 약한 포인터는 ->를 통한 직접적인 접근을 금지하며, lock() 함수를 통해 shrared_ptr 형태로 반환받아서 접근해야 합니다. 해당 객체가 할당 해제된 경우 nullptr을 반환하므로, 널 체크를 통해 이슈를 예방할 수 있습니다.
위 예시에서는 약한 포인터 p2를 미리 선언한 뒤, 블록 안에서 Player 객체를 동적 할당하며 공유포인터 p1으로 가리키게 하고 p2에 p1 값을 복사했습니다. p2.lock()을 통해 다시 공유 포인터를 받아 p3에 저장하고, 널포인터가 아니라면 레벨 값을 출력하도록 했습니다. 이후에 블록 바깥에서 다시 p2.lock()으로 공유 포인터를 받아 p4에 저장했으나, 여기서는 p1의 참조 카운트가 0이 되어 객체가 할당 해제되었으므로 nullptr가 저장됩니다.
실행 결과를 보면 블록 안에서 p3를 통해 레벨값을 출력했으며, 블록을 넘어가는 순간 할당이 해제되어 소멸자가 호출되었습니다. 이후 블록 바깥에서는 p4에 nullptr가 저장되었으므로, 조건문에 걸려 "p3 is nulltpr"을 출력하는 것을 확인할 수 있습니다.
5. 결론
C++의 포인터는 치명적인 이슈를 일으키는 원인으로 유명하나, 이를 해결하기 위한 대책 또한 꾸준히 강구되어왔습니다. 스마트 포인터는 그 노력의 대표적인 산물로, 적절하게 활용하여 메모리 관련 이슈를 크게 줄일 수 있을 것으로 보입니다. 단, 위에서 알아보았듯이 순환 참조 등의 이슈로 인해 공유 포인터를 사용한다고 해서 완전히 안전할 수는 없으며, 약한 포인터는 사용이 다소 번거로우므로 필요한 상황에만 쓰는 것이 좋아보입니다. 현재 상황에 가장 적합한 스마트 포인터를 구분하여 사용할 줄 아는 역량을 키우도록 해야겠습니다.
'C++' 카테고리의 다른 글
[C++] std::endl vs "\n" (2) | 2024.07.23 |
---|---|
[C++] 람다 표현식(lambda expression) (0) | 2023.12.05 |
[C++] 가상 함수(virtual function) (0) | 2023.03.22 |
[C++] 타입 변환 연산자(Type Casting Operator) (0) | 2023.03.19 |
[C++] 포인터(Pointer) (0) | 2023.03.08 |