스마트 포인터

200원짜리개발자·2023년 9월 18일
1

C++

목록 보기
38/39
post-thumbnail

이번시간에는 스마트 포인터를 알아볼 것이다.
스마트 포인터는 그 자체로도 많이 사용이 되지만 면접에서는 스마트 포인터를 안물어보는 경우가 없을 정도로 많이 물어본다고 한다.

그래서 중요하다!

스마트 포인터를 왜 사용하는지에 대해서 알기위해서는 우리가 예전에 사용했던 쌩포인터 방식이 무엇이 문제 였는지를 알아야 한다.

그럼 알아보자

쌩 포인터의 문제점

여기 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_ptrunique_ptr은 1%정도이다.

shared_ptr

우리가 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을 복사하는 순간에 기억하는 애가 늘어난 것이기에 참조카운트를 증가시킨다.
그럼 언젠가 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까지 사용할 수 있게 되면 상황이 달라진다.

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을 알아야지 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

이름에서 나오듯이 딱 하나만 있어야 하는 데이터를 관리하게 된다. ( 매니저 클래스 같은 것 )

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만이라도 잘 기억하자.

profile
고3, 프론트엔드

0개의 댓글