이번시간에는 스마트 포인터를 알아볼 것이다.
스마트 포인터는 그 자체로도 많이 사용이 되지만 면접
에서는 스마트 포인터를 안물어보는 경우가 없을 정도로 많이 물어본다고 한다.
그래서 중요하다!
스마트 포인터를 왜 사용하는지에 대해서 알기위해서는 우리가 예전에 사용했던 쌩포인터 방식이 무엇이 문제 였는지를 알아야 한다.
그럼 알아보자
여기 knight라는 클래스가 있다.
class Knight
{
public:
public:
int _hp = 100;
int _damage = 10;
}
int main()
{
Knight* knight = new Knight();
}
이런식으로 동적할당을 knight를 생성시킬 수 있었다.
여기서 생성을하고 delete를 하지 않는다면 메모리 누수가 일어나서 양이 많아지면 프로그램이 터질 것이다. (귀찮고 타이밍 잡기 힘듬..)
스마트 포인터는 여기다가 여러 기법을 추가해서 정책에 따라 포인터를 관리해주는 것을 넣어주는 것이라고 볼 수 있다.
간단하게는 delete하는 것이 너무 번잡스럽고 힘들거나, 어떤 특정 조건에 delete를 해줘야 하는 상황에서 깔끔하게 처리하기 위해 해줄 수 있는 것이 있다. (스마트 포인터까지는 아니고 wrapper클래스이다)
template<typename T>
class Wrapper
{
public:
Wrapper(T* ptr) : _ptr(ptr) { }
~Wrapper()
{
if(_ptr)
delete _ptr
}
public:
T* _ptr;
}
생성될 때 포인터를 받아주고, 소멸될 때 포인터가 존재하면 _ptr을 삭제시켜주는 것이다.
Wrapper<Knight> w(new Knight());
이렇게 사용을 하게 된다면,
Wrapper클래스는 언젠가 소멸이 될 것이고 소멸자가 호출이 되면서 가지고 있던 Knight포인터를 소멸시킬 것이다.
서버에서도 lock을 관리할 때 많이 사용도된다.
스마트 포인터의 종류로는 3가지가 있다.
shared_ptr
weak_ptr
unique_ptr
사용 비중으로 친다면 shared_ptr
이 99% 이고 weak_ptr
과 unique_ptr
은 1%정도이다.
우리가 shared_ptr을 이해하기 위해서는 썡 포인터를 사용했을시 발생하는 문제를 알아야 한다. (위 문제와 다르다)
우리가 만약 게임에서 화살을 쏴서 목표물에게 날라가고 있다고 가정을 하였을 때,
그 목표물이 갑자기 delete되어버리면 큰 문제가 발생하게 될 것이다.
class Knight
{
public:
void Attack()
{
if(_target)
_target->_hp -= _damage;
}
public:
int _hp = 100;
int _damage = 10;
Knight* _target = nullptr;
}
int main()
{
Knight* k1 = new Knight();
Knight* k2 = new Knight();
k1->_target = k2;
k1->Attack();
}
여기까지는 괜찮은 상황이다.
하지만 갑자기 k2가 사라져 버린다면?
Knight* k1 = new Knight();
Knight* k2 = new Knight();
k1->_target = k2;
delete k2;
k1->Attack();
이러면 잘못된 메모리로 접근을 하게 되어서 메모리 오염이 일어나게 된다. (Use After Free)
(디버깅 모드 여서 다 밀림)
이런상황을 어떤식으로 해결할 것인가 하면 생각보다 힘들 것이다.
스마트 포인터를 사용하면 우회할 수 있지만, 안쓰더라도 머리를 잘 쓰면 우회할 수 있긴하다.
사실 포인터로는 저게 한계이고 다른 변수를 만들어서 찾을 수 있게 해주는 것이 가장 좋은 수 일 것이다.
class ObjectManager
{
Knight* GetObject(int id)
{
// id에 따라 return 시켜준다.
return ???;
}
unordered_map<int, Knight*> hm;
};
class Knight
{
public:
int targetId = 0;
//Knight* _target = nullptr;
};
이게 가장 최선의 수 일 것이다.
포인터를 사용해서는 날라갔는지 않갔는지 모르기에 방법이 없다.
그래서 모던 C++에서 넘어오면 썡포인터 사용을 지양해야한다. (언리얼도 소스코드를 확인해보면 스마트 포인터를 사용한다)
포인터를 여러군데에서 사용하면서 관리하다보면 생명주기가 꼬이기 때문에 문제가 된다.
그래서 위에서 말했던 것과 shared_ptr이 밀접한 관련이 있다.
결국 위에서 문제가 되었던 것은 누군가가 나를 참조하고 있는데 삭제를 해버렸던 것이 문제였다.
그래서 shared_ptr에서는 추가적으로 몇 명이 나를 참조(기억)하고 있는지 추적을 해준다.
그럼 한 번 간단하게 구현을 해보자
template<typename T>
class SharedPtr
{
public:
SharedPtr() {}
SharedPtr(T* ptr) : _ptr(ptr) { }
public:
T* _ptr;
int _refCount = 1; // 얼마만큼 참조하고 있는지 기억
}
int main()
{
SharedPtr<Knight> k1(new Knight());
SharedPtr<Knight> k2(new Kngiht());
SharedPtr<knight> k3;
k3 = k1;
}
이렇게 k3 = k1
을 하게 되면 k3가 k1의 포인터를 가진다는 말이 되기 때문에 k1과 k3 둘 다 동일한 Knight객체를 가지게 될 것이다. 이럴 때 RefCount를 늘려줘서 기억하고 있는다는 것을 알려야 한다. 하지만 매번 k1._refCount = 2
이런식으로 관리하기에는 힘들기 때문에 RefCount는 저런식으로 관리하지 않고 RefCount를 따로 빼서 관리하는 것이 일반적이다.
class RefCountBlock
{
public:
int _refCount = 1;
}
template<typename T>
class SharedPtr
{
public:
SharedPtr() {}
// 처음 생성하면 _block 만들어주기
SharedPtr(T* ptr) : _ptr(ptr)
{
if(ptr)
{
_block = new RefCountBlock();
cout << "RefCount: " + _block->_refCount;
}
}
// 복사 생성자
SharedPtr(const SharedPtr& other) : _ptr(other._ptr), _block(other._block)
{
if(_ptr)
{
_block->_refCount++;
}
}
// 복사 대입 연산자
void operator=(const SharedPtr& other)
{
_ptr = other._ptr;
_block = other._block;
if(_ptr)
_block->_refCount++;
}
public:
T* _ptr = nullptr;
RefCountBlock* _block = nullptr;
}
이런식으로 관리를 해줄 수 있다.
이제 복사 생성자와 복사 대입 연산자도 만들었기 때문에,
k3가 k1을 복사를 한다고 하면 k3가 k1의 포인터와 블록도 이전을 시켜주기 때문에 참조했다는 사실을 기억할 수 있다.
그래서 결국 이런식으로 관리를 하기 시작한다면 delete를 사용할 수 없게 된다. (C#에서 작업하듯이 하면 된다)
사용을 하다보면은 언젠가 아무도 사용하지 않게 되었을 때, 알아서 삭제가 된다.
shared_ptr이 언젠가 사용되지 않아 소멸이 된다고 하면,
~SharedPtr()
{
if(_ptr)
{
_block->_refCount--; // 하나 줄임
if(_block->_refCount == 0) // 0이면 delete
{
delete _ptr;
delete _block;
cout << "Delete Data" << endl;
}
}
}
이런식으로 refCount를 하나 감소시키고 0이라면 포인터와 블럭을 삭제시켜줄 수 있다.
전체적으로 정리를 해보면
이제부터 Knight라는 객체를 shared_ptr이라는 애가 관리를 하고,
몇 명이 포인터를 가리키고 있는지 추척을 하고 (복사가 일어날 때마다 count를 1씩 늘림)
그리고 언젠가 삭제가 되었을 때 포인터와 블럭을 날려준다.
이것이 Shared_ptr의 개념이다.
그래서 이제부터 Shared_ptr을 만들어서 사용하지 않고 C++에서 제공하는 shared_ptr
버전으로 사용해도 똑같이 동작할 것이다.
그럼 응용을 해서 target을 shared_ptr로 관리를 해보자.
class Knight
{
public:
void Attack()
{
if(_target)
_target->_hp -= _damage;
}
public:
int _hp = 100;
int _damage = 10;
shared_ptr<Knight> _target = nullptr;
}
int main()
{
shared_ptr<Kngiht> k1(new Knight());
shared_ptr<Knight> k2(new Knight());
}
그리고 shared_ptr을 사용하게 되면 모든 포인터를 shared_ptr로만 관리를 해야한다.
그럼이제 target을 사용해보면
// 포인터 사용하듯이 사용
k1->_target = k2;
k2에 refCount가 2로 늘어난다.
이렇게 되면 기억하고 있는 애들에 따라 메모리가 관리가 되고 있기 때문에 _target에서 기억을 하고 있는 순간에는 절대로 소멸이 되지 않는다고 보장할 수 있다.
하지만 이렇게 되면 우리가 원하는 시점에 소멸을 시킬 수 없다.
모든 포인터가 그 포인터를 잊어야지 소멸을할 수 있기에 우리는 C#처럼 작업을 해야 된다.
그럼 언젠가 모두 잊으면 소멸이 된다.
(영화 코코와 흡사)
장단점이 있긴하지만 스마트포인터를 사용하는 것이 합리적인 것이 이런 이유때문이다.
shared_ptr를 사용할 때 단점 중 하나가
void Test(shared_ptr<Knight> k)
{
}
int main()
{
Test(k1);
}
이러한 상황이 왔을 때 k1의 refCount가 증가할까?
복사해서 넘기기 때문에 증가하게 될 것이다.
그리고 끝날 때 감소될 것이다.
근데 여기서 증감을 하는 것이 어찌보면 연산이기 때문에 &로 받는 경우가 있다.
"사용했으면 무조건 refCount를 늘려야 하는 것 아니냐?"라고 할 수 있는데 Test라는 함수를 누군가가 호출을 하였다는 것은 k1이 refCount를 물고 있다는 것이기 때문에 안전하다고 할 수 있다. 그래서 의도적으로 참조값으로 넘기는 경우가 있다.
하지만, 뭐 Knight를 참조로 들고 있는다는 것은 말이 안된다. 이럴꺼면 스마트포인터를 사용하는 이유가 없다고 볼 수 있다.
그래서, 어지간해서는 shared_ptr로 관리를 하되, 함수에 넘길 때만 가끔 참조로 넘겨서 refCount가 증감하는 연산(비용)을 줄일 수 있다.
그 외에는 일반 포인터와 똑같이 사용이 가능하다.
shared_ptr은 ThreadSafe한가?
ThreadSafe를 보장하기 위해서는 refCount를 그냥 int(상호배타적이지 않음)로 선언하는 것이 아닌 ThreadSafe타입인 atomic<int> _refCount;
으로 바꿔줘야지 ThreadSafe한다.
참고로 shared_ptr은 refCount가 그냥 int로 되어있는 것이 아닌 atomic<\int>으로 되어 있기 때문에 ThreadSafe하다는 것을 보장할 수 있다. (그래서 속도가 좀 느리다. atomic타입은 덧셈이 좀 느림) (그래서 언리얼에서는 TShared_ptr이라는 것을 만들어서 사용한다. 표준 shared_ptr은 언리얼에서 사용하지 않음)
shared_ptr의 개념이 너무 어렵다 싶으면,
간단하게 생각해서 일반 포인터와 동일하게 사용할 수 있지만,
내부적으로 참조카운트라는 것을 추척해서 나를 기억하고 있는애가 몇 명인지 추적한다.
그리고 shared_ptr을 복사하는 순간에 기억하는 애가 늘어난 것이기에 참조카운트를 증가시킨다.
그럼 언젠가 shared_ptr이 다 소멸이 되어 참조카운트가 0이 될 때, 그제서야 가지고 있던 포인터가 소멸이 된다는 것을 알 수 있다.
이게 shared_ptr의 개념이라고 볼 수 있다.
shared_ptr의 또 다른 문제가 있다.
{
shared_ptr<Knight> k1(new Knight());
shared_ptr<Knight> k2(new Knight());
k1->_target = k2;
}
k1과 k2가 저 스코프 안에서만 사용한다고 해보자.
k1과 k2의 refCount가 처음에는 1일 것이고,
k1의 target이 k2를 가르키고 있기에 k2는 2가 될 것이다.
그리고 스코프의 범위를 나가게 된다면, 둘 다 자연스럽게 소멸이 될 것이다.
근데 여기서 문제가 될 수 있는 상황이 있는데
{
shared_ptr<Knight> k1(new Knight());
shared_ptr<Knight> k2(new Knight());
k1->_target = k2;
k2->_target = k1;
}
이런식으로 k1이 k2를 가지고 있지만 k2도 k1을 가지고 있기에 무한순환이 될 것이다.
한 쪽이 놔줘야지 소멸을 할 수 있는데, 객체가 소멸이 되려서 refCount가 0이 되어야 하는데 둘 다 refCount가 2이기 때문에 놔주지 않게 된다. (Dead Lock과 비슷함)
이렇게 되면 Memory Leak이 일어나게 된다.
shared_ptr만 사용하면 이 문제를 해결할 수 없다. 그러면 어떻게 해야하는가..
유저가 로그아웃을 했을 때 target을 nullptr로 밀어준다면 refCount를 감소시켜주게 된다.
그걸 이용해서 강제로 끊어줄 수 밖에 없다. 그래서 코드를 짤 때 조심하지 않으면 무조건 Memory Leak이 일어나게 된다. (편리하지만 사이클에 지옥에서 빠져나갈 수는 없다.)
하지만 우리가 weak_ptr까지 사용할 수 있게 되면 상황이 달라진다.
원래라면 refBlock에다가 참조카운트를 기록해서 나를 얼마만큼 기억을 하고 있는지 추적하고 있었다.
여기서 weak_ptr까지 사용을 한다면 refBlock에다가 weak_ptr의 개수도 기록을 한다. (이중으로 기록함)
그럼 weak_ptr은 어떤 특징이 있을까?
target을 weak_ptr로 바꾸면 shared_ptr이랑 유사하게 포인터이긴하지만, 생명주기(refCount)에 영향을 주지 않는다.
내가 아무리 weak_ptr로 상대방을 가지고 있다고 하더라도 refCount에 영향을 주지 않고 있기 때문에 깔끔하게 날라간다.
"그러면 쌩포인터와 다를께 무엇이냐?"라고 한다면 weak_ptr는
if(_target.expired() == false) // 값이 있는지 없는지 체크
{
shared_ptr<Knight> spr = _target.lock();
}
expired()를 이용해서 _target안에 값이 있는지 확인하고,
안날라갔다면, lock()이라는 기능을 이용해서 shared_ptr로 꺼내서 사용할 수 있다.
여기서 장단점이 갈린다는 것이 귀찮다..
하지만, 위에 사이클 문제를 해결할 수 있는 거의 유일한 수단이다. (임의적으로 끊지 않는 이상)
refCount가 0이 되면 shared_ptr객체는 날려버리지만 weak_ptr이 0이 아닌 이상 refBlock은 날리지 않고 남겨둔다.
남기는 이유는 expired를 하고 lock을 하는 부분이 refBlock으로 작동이 되기 때문이다.
그렇기에 shared_ptr에 의존적이다. (weak_ptr은 혼자 사용하는 것이 아니다)
결국 weak_ptr는 refBlock을 유지해주는 역활을 하고 이걸 이용해서 이 객체가 날라갔는지 판별을 하여 shared_ptr로 바꿔서 사용할 수 있게 된다. (언리얼에서도 Tshared_ptr과 Tweak_ptr이 있다)
설계 빡빡하게 하기 ( shared_ptr & weak_ptr 사용 ) vs 편안하게 사용하되 사이클 문제 감안하기 ( shared_ptr 사용 )
면접에서 스마트포인터에 대해 물어본다면 결국 shared_ptr을 알아야지 weak_ptr을 설명할 수 있기 때문에 shared_ptr를 이해하고 있는 것이 중요하다.
shared_ptr을 설명해보면
shared_ptr은 refCount라는걸 만들어서 참조 개수를 관리하여서 shared_ptr로 다른 객체를 복사할 때마다 (참조 횟수가 늘때 마다) refCount를 늘리고 0이 될 때에만 삭제하는 방식이다.
라고 설명할 수 있다.
여기서 weak_ptr
은 왜 나왔나요? 라는 질문이 나온다면
사이클이 방생하게 되면 shared_ptr끼리 서로 절대 놔주지 않는 문제가 발생하기 때문에 그걸 보안하기 위해서 weak_ptr가 등장했다. weak_ptr은 refCount에는 영향을 주지 않지만, weakRefCount라는 별개의 데이터로 관리를 하고 refBlock자체를 가지고 있기에 weak_ptr를 이용하여 어떤한 메모리가 날라갔는지 구별할 수 있다. 구별한 후에 안전하게 접근하거나 날라가면 버리거나 선택을 할 수 있다.
이름에서 나오듯이 딱 하나만 있어야 하는 데이터를 관리하게 된다. ( 매니저 클래스 같은 것 )
unique_ptr<Knihgt> k1(new Knight());
unique_ptr<Knight> k2 = k1; // 오류 발생
복사와 관련된 것을 다 막아놓았다.
shared_ptr는 복사를 하면 refCount가 증가하는 식이였다면 unique_ptr는 복사하는 것을 막아버린다.
직접 구현을 해보면
class UniquePtr
{
private:
void operator=(const UniquePtr&)
{
}
};
이런식이라고 볼 수 있다. ( 원본은 = delete로 막혀있다 ) ( 복사, 복사 생성자, 복사 연산자 다 막힘 )
그리고 이러한 특징을 제외하고는 일반 포인터와 똑같다.
하지만 단 한가지의 k1를 k2로 가져오는 방법이 있다.
바로 이동
이다.
이동은 나 자신을 포기하고 내 모든것을 가지고 가도 된다는 뜻이기에
unique_ptr<Knight> k2 = std::move(k1);
이런식으로 오른값 참조를 해서 넘겨준다면 이동을 시켜줄 수 있다.
하지만 일반적으로 이렇게 넘겨주는 일은 드물것이다.
이게 끝이다. 이제 매니저같은 유일 클래스를 만들어 사용할 때 사용을 해주면 된다.
여기까지보면 진짜 스마트포인터에서 shared_ptr의 사용빈도와 중요도가 99%이고 weak_ptr은 shared_ptr을 보조하는 역활이고 unique_ptr은 사실 존재만 있고 사용하는 경우가 별로 없다.
하지만 이 스마트포인터는 면접에 자주 나오기때문에 기억을 해줘야 한다.
솔직히 C#, 유니티에서는 GC가 언리얼에서는 유사GC?가 알아서 처리를 해주기에 스마트 포인터와는 조금 다르다고 볼 수 있다.
그리고 절대 스마트 포인터와 썡포인터는 같이 사용하면 안된다.
스마트 포인터는 여기서 끝이다.
면접때 대부분 shared_ptr위주로 질문이 나오기에 shared_ptr만이라도 잘 기억하자.