개발하는 리프터 꽃게맨입니다.

[C++] 스마트 포인터 - 1 (unique_ptr, unique_ptr 설계) 본문

언어/C, C++

[C++] 스마트 포인터 - 1 (unique_ptr, unique_ptr 설계)

파워꽃게맨 2024. 1. 17. 00:35

[ 목차 ]

     

    1. 동적 메모리

    C++에서 동적 메모리는 new로 할당하고 delete로 해제합니다.

    new는 객체를 할당하고 초기화하여 그 객체에 대한 포인터를 반환하고

    delete는 소멸하며 할당한 메모리를 해제하죠.

     

    C++ 스타일의 동적 메모리는 Java, C#에 비해 빠르지만 큰 단점이 있는데,

    메모리의 할당과 해제를 모두 프로그래머에게 맡긴다는 것입니다.

    말로만 들어도 위험하죠?

     

    객체를 생성했으면, 반드시 해제해 줘야 문제가 생기지 않는다는 건데,

    이게 말처럼 쉽지 않습니다.

     

     

    위와 같은 코드가 있다고 가정합니다.

    함수 func에서 MyClass 메모리를 할당하고, 나중에 해제하려도 시도는 하고 있지만,

    중간에 return을 만나서 함수가 종료되어 버리면, 그대로 메모리 누수가 일어나는 거서입니다.

     

    이렇게 메모리를 제때 해제하지 않으면, 메모리 누출이 생기게 되는 것이죠.

     

    문제 상황은 이거뿐이 아닙니다.

    다음 코드를 또 살펴봅시다.

     

     

    간단한 클래스 2개를 설정했습니다.

    Player는 Pet 객체를 가질 수 있고,

    Pet은 주인인 Player 객체를 가질 수 있습니다.

     

     

    만약 이런 코드를 실행하면 어떻게 될까요?

     

     

    네, 매우 잘 동작합니다!

    정상적인 코드죠? 그러면 이렇게 해봅시다.

     

     

    만약 pet을 삭제해준다면, 과연 어떻게 될까요?

    pet이 죽었습니다.

    그러면 player는 'DoYouHavePet?' 이라는 질문에 어떻게 대답할까요?

     

     

    놀랍게도, 가지고 있다고 말합니다.

     

    사실 이건 매우 심각한 오류에요.

    Player에서 여전히 참조하고 있는 메모리를 해제하면, Player는 유효하지 않은 메모리를 가리킵니다.

    아마 그 메모리에는 예상치 못한 쓰레기 값이 들어가게 될 거예요.

     

    출력으로는 "Yes!"가 나왔지만, 이는 올바르지 않은 결과입니다.

     

    자 그러면 기존 포인터 (이하 원시 포인터)의 단점을 2가지로 생각해 볼 수 있겠네요.

     

    1) 메모리의 할당과 해제 관리가 어렵다. - 메모리 누수 문제

    2) 해제된 메모리를 참조할 수 있다. -  "use-after-free" 문제

     

    2. 직접 구현해 보는 자동 메모리 관리

    많은 언어가 '포인터'라는 개념을 포기하면서, 안정성을 많이 챙기려고 했습니다.

    대표적으로 java가 있겠죠.

     

    그러나 여전히 속도를 중시하는 프로그램의 경우 C/C++ 언어를 사용할 수밖에 없었습니다.

    그래서 아무래도 수동으로 관리하면 메모리 관리에 있어서 실수할 여지가 많으니까,

    많은 프로그래머들은 자동으로 메모리를 관리하고자 여러 기법을 시돕니다.

     

    아래 시연할 코드는 제 딴에 열심히 자동으로 메모리 관리를 해보겠다고 만든 코드입니다.

    비효율적인 코드니 참고만 해주시길 바랍니다.

     

    1) 메모리 누수 문제

    메모리 누수 문제를 해결하기 위해 사용할 기법은 'RAII 디자인 패턴'입니다.

    https://powerclabman.tistory.com/16 ( RAII에 대한 정보는 이 포스팅을 확인해 주시길 바랍니다. )

     

    간단히 말하자면 RAII란,

    자원의 할당은 클래스의 생성자에서, 자원의 해제는 클래스의 소멸자에서 담당하는 것입니다.

     

    스택에 저장된 지역 변수의 경우 사용 범위를 넘어가면 자동으로 해제되게 됩니다.

    이런 지역 변수의 특징을 이용하면, 자동으로 메모리가 해제되게 할 수 있습니다.

     

    바로 이렇게 템플릿 Wrapper 클래스를 만들어 주면 자동으로 메모리를 관리할 수 있습니다.

    func() 함수 부분을 보시면, MyClass 포인터를 받아주는 Wrapper 객체를 만들어서 메모리를 할당해주고 있습니다.

    Wrapper <MyClass> mc 자체는 지역 변수이기 때문에 '생명 주기'를 벗어나면 자동으로 소멸자가 해제됩니다.

    그러므로, 중간에 return으로 인해 함수를 이탈한다 해도 자동으로 소멸자가 호출되기 때문에

    이때, _ptr의 메모리를 해제해 주면 자동으로 메모리 관리가 된다는 것이죠.

     

     

    func 함수에서 굳이 delete를 명시적으로 호출해주지 않아도

    프로세스 메모리가 고정된 채로 유지되는 거 보이시나요?

     

    이것이 RAII 디자인 패턴을 이용해서 자동으로 메모리를 관리하는 기법 중 하나입니다.

     

    2) "use-after-free" 문제

    그러나 앞서 설명했던 Wrapper 클래스는 "use-after-free" 문제를 해결하지 못합니다.

    그러면 해제된 메모리에 대한 참조를 막기위해 어떤 묘수를 부릴 수 있을까요?

     

    1) 기본적으로 메모리를 해제하지 않고, 주기적으로 메모리를 확인하여 사용되지 않은 메모리를 해제한다. (가비지 컬렉션)

    2) 참조하는 포인터의 수를 기록하고 참조 포인터 수가 0이되면 메모리를 해제한다. (참조 카운팅)

     

    1) 번 방법이 Java, C#에서 사용하는 '가비지 컬랙션' 이라는 기법입니다.

    '가비지 컬렉션'은 여기서 다룰 주제가 아니므로, 간략한 작동 논리만 접은글에 서술하겠습니다.

    더보기

    가비지 컬렉션이란 메모리 누수를 막으려는 시도입니다.

    가비지 컬렉션은 메모리를 확인해서 사용되지 않는 메모리의 할당을 해제합니다.

    가비지 컬렉션은 주기적으로 동작하거나, 여유 메모리가 많이 남지 않았을 때 실행됩니다.

     

    전역변수, 스택, 레지스터 등 사용가능한 메모리를 쭉 보면서 어떤 개체로 힙 메모리에 접근할 수 있는지 탐색합니다.

    만약, 메모리가 할당된 힙 중에서 어떤 참조로도 접근할 수 없다고 한다면 '가비지'로 간주하여 메모리를 해제합니다.

     

    가비지 컬렉션의 단점은 사용되지 않는 메모리를 즉시 정리하지 않는다는 것과 가비지 컬렉션 동작시 프로그램이 잠시 느려질 수 있다는 것 입니다.

     

    빠른 속도 하나로 아직까지 생명을 연장하고 있는 C++ 입장에선, 굳이 느린 가비지 컬렉션은 사용할 필요없죠.

    그래서 C++는 '참조 카운팅' 방법을 가용합니다.

     

    그러면 '참조 카운팅' 기법을 구현하는 Wrapper 클래스를 만들어봅시다.

     

    '참조 카운팅'을 지원하는 매우 간단한 형태의 Wrapper 클래스

     

    먼저 포인터 변수를 관리하는 wrapper 클래스입니다. (이하 SPtr 클래스)

    '참조 카운팅' 기법에서는 SPtr 끼리 '참조 횟수'를 공유해야 합니다.

    그래서 '참조 횟수' 또한 공유되는 메모리를 사용할 수 밖에 없죠.

    그래서 RefCount 라는 '참조 카운팅'을 위한 클래스를 하나 더 만들었습니.

     

    참조 카운팅 클래스

     

    그냥 공유된 메모리에서 참조 카운팅을 관리하기 위해 만들어낸 클래스입니다.

    Set를 통해 _cnt를 증가시키거나, Release 를 통해 _cnt를 감소시키며, _cnt 가 0 이 될 경우

    스스로를 소멸시킵니다.

    멀티 쓰레드 환경에서 사용은 고려하지 않았습니다. 만약 멀티 쓰레드를 고려한다면 lock 처리도 해야하고 _cnt도 atomic 처리를 해주면 좋겠죠.

    내장되어 있는 스마트 포인터는 멀티 쓰레드 환경을 고려하여 atomic을 사용했기 때문에 속도가 조금 느립니다.

     

    아까 RAII 디자인 패턴과 겹쳐서 조합해서 생각해봤을때,

    소멸자에서 Release를 호출하면 적당하겠죠?

     

     

    이런 느낌입니다.

    이해가 가시나요..?

     

    SPtr 변수들 자체는 스택에 저장된 메모리라 자동으로 할당/해제가 됩니다.

    이 점을 이용해서, RefCount를 관리하고

    RefCount가 0이 되면, _ptr이 가리키는 메모리를 해제해주는 것이죠.

     

    SPtr의 소멸자에서 '참조 카운트'를 관리하는 Release 함수를 호출하고 있기에,

    대부분의 상황에서는 메모리 관리가 보장됩니다.

     

    그러나 프로그래머가 잘못 사용했을 때 메모리 누수는 막을 수 없습니다.

    아래 설명할 스마트 포인터도 멍청한 메모리 사용에 대해서는 어찌할 도리가 없습니다.

    예를 들어, 스마트 포인터와 원시 포인터를 동시에 사용하면, 참조 카운팅이 되지 않으니

    이런 포인터 클래스가 의미없게 되는 것이지요.

    이런 경우 똑같이 문제 상황이 발생할 것 입니다.

     

    여러가지 기능을 조금 넣어서 만든 '메모리 자동 관리 SPtr 클래스' 코드는 아래에 첨부하겠습니다.

    (참고로 해당 코드는 스마트 포인터 중 shared_ptr 의 역설계 코드입니다.)

     

    더보기
    #include <iostream>
    #include <cassert>
    using namespace std;
    
    class RefCount
    {
    public:
    	RefCount() : _cnt(0) {};
    	~RefCount() = default;
    
    	void Set() { _cnt += 1; }
    
    	int Release()
    	{
    		assert(_cnt > 0);
    
    		_cnt -= 1;
    
    		if (_cnt == 0)
    		{
    			delete this;
    			return 0;
    		}
    
    		return _cnt;
    	}
    
    	int GetCount() const { return _cnt; }
    
    private:
    	int _cnt;
    };
    
    template <typename T>
    class SPtr
    {
    public:
    	//기본 생성, 소멸, 복사대입, 복사생성, 이동대입, 이동생성자 정의
    
    	SPtr() : _ptr(nullptr), _refCnt(nullptr) {}
    	~SPtr() { Release(); }
    
    	SPtr(T* ptr) : _ptr(ptr), _refCnt(new RefCount) { SetRefCnt(); }
    	SPtr(const SPtr& other) : _ptr(other._ptr), _refCnt(other._refCnt) { SetRefCnt(); }
    	SPtr(T&& ptr) : _ptr(ptr), _refCnt(new RefCount) { ptr = nullptr; SetRefCnt(); }
    	SPtr(SPtr&& other) noexcept : _ptr(other._ptr), _refCnt(other._refCnt) { other._ptr = nullptr; other._refCnt = nullptr; }
    
    	SPtr& operator=(const SPtr& other)
    	{
    		if (_ptr == other._ptr)
    			return *this;
    
    		if (_ptr)
    			Release();
    
    		_ptr = other._ptr;
    		_refCnt = other._refCnt;
    
    		SetRefCnt();
    
    		return *this;
    	}
    
    	SPtr& operator=(SPtr&& other) noexcept
    	{
    		if (this == other)
    			return *this;
    
    		if (_ptr)
    			Release();
    
    		_ptr = other._ptr;
    		_refCnt = other._refCnt;
    
    		other._ptr = nullptr;
    		other._refCnt = nullptr;
    
    		return *this;
    	}
    
    
    public:
    	//연산자 오버로딩 및 외부 접근 함수
    	T& operator*() { return *_ptr; }
    	const T& operator*() const { return *_ptr; }
    	T* operator->() { return _ptr; }
    	const T* operator->() const { return _ptr; }
    
    	bool operator==(const SPtr& other)  const { return _ptr == other._ptr; }
    	bool operator!=(const SPtr& other)  const { return _ptr != other._ptr; }
    
    	bool isNull() const { return _ptr == nullptr; }
    	T* Get() const { return _ptr; }
    
    	int use_count() const { return _refCnt ? _refCnt->GetCount() : 0; }
    	bool unique() const { return use_count() == 1; }
    
    	void reset() { Release(); }
    
    	void reset(const T* ptr) {
    		Release();
    		_ptr = ptr;
    		_refCnt = new RefCount;
    		SetRefCnt();
    	}
    
    private:
    	void Release()
    	{
    		if (_ptr)
    		{
    			if (_refCnt->Release() == 0)
    			{
    				delete _ptr;
    				_ptr = nullptr;
    				_refCnt = nullptr;
    			}
    		}
    	}
    
    	void SetRefCnt()
    	{
    		if (_refCnt)
    			_refCnt->Set();
    	}
    
    private:
    	T* _ptr;
    	RefCount* _refCnt;
    };

     

    3. 스마트 포인터의 등장

    앞서 설명한 내용이 사실 std에서 제공하는 스마트 포인터에 대한 대부분의 설명이었습니다.

    '스마트 포인터'는 메모리 관리의 어려움으로 인해 발생하는 흔한 문제 상황인

    memory_leak,  use_after_free 에 대한 해결책인 셈이죠

     

    그리고 해결하기위한 방법은 '참조 카운팅'이었습니다. 

    가비지 컬렉션보다 훨씬 빠르게 동작하기 때문에, 참조 카운팅을 채택한 것이죠.

     

    앞서 설명했듯이 스마트 포인터 내부 소멸자에서 '원시 포인터'와 '참조 카운팅'에 대한 관리를 해주기 때문에

    포인터를 마치 지역변수를 사용하듯이 사용할 수 있는 것이죠.

     

    이제 머릿속에서 메모리에 대한 생각을 지우시면 됩니다.

    어짜피 자기들이 알아서 다 메모리를 해제하고 관리해주거든요!

     

    대신 '스마트 포인터'를 사용하기로 맘먹었으면 절대로 원시 포인터를 사용하면 안됩니다.

     

    자 그래서

    스마트 포인터는 뭐가 있고, 어떤 기능을 하고, 어떻게 어떤 상황에서 이용하는지 알아보겠습니다.

    그리고 그 내부 구조를 살펴보기 위해서 역설계 코드를 작성해보겠습니다.

     

    1) 스마트 포인터의 종류

    먼저 스마트 포인터는 <memory> 헤더에 정의되어 있고,

    그 종류에는 3가지가 있습니다.

     

    간단하게 개요만 살펴보고 이 포스팅에서는 unique_ptr 만 다루도록 하겠습니다.

     

    (1) unique_ptr

    단, 하나의 참조만 허용하는 스마트 포인터입니다.

    즉 어떤 한 메모리에 대해서 2개 이상의 참조가 발생하는 것을 막아줍니다.

     

    (2) shared_ptr

    여러 개의 참조를 허용하는 스마트 포인터입니다.

    앞서 직접 구현했던 포인터가 이 shared_ptr 의 설계 방식을 모방한 것입니다.

    특정 개체가 추가될 때마다 '참조 횟수'는 1씩 증가하며, '참조 횟수'가 0이 되면

    메모리가 해제됩니다.

     

    (3) weak_ptr

    shared_ptr 에서 발생하는 '순환 참조 문제'를 해결하기 위한 스마트 포인터입니다.

    자세한 것은 다음 포스팅에서 다루겠습니다.

    4. unique_ptr 

    아마 포인터를 가장 안전하게 사용하고 싶을 때 사용하는 것이 이 unique_ptr 입니다.

    자동으로 메모리가 해제되기에 memory_leak 문제를 해결할 수 있고,

    2개 이상의 참조를 허용하지 않기 때문에 use_after_free 문제가 발생하지 않습니다.

     

     

    참조가 허용되지 않는 모습입니다.

     

    1) 생성 방법

     

    2) 메모리가 정말 안전하게 해제될까?

     

    프로세스 메모리 부분이 상수 그래프죠?

    만약 메모리 관리가 안되면 선형 그래프 꼴 일 겁니다.

     

    3) 사용 예시

     

    선언 방법만 조금 생소할 뿐, 그냥 포인터와 동일하게 사용할 수 있습니다.

    어렵지 않죠?

     

    4) unique_ptr 에서 지원하는 함수 - get()

     

    unique_ptr 가 가지고 있는 원시 포인터를 리턴합니다.

    원시 포인터가 없으면 nullptr 을 리턴하죠.

     

    앞서 제가 원시 포인터와 스마트 포인터를 동시에 사용하는 것이 매우 위험하다고 했습니다.

    이것은 스마트 포인터 설계 목적에 어긋나는 행동인 것이죠.

    그래서 get은 엥간하면 사용하지 않고, 사용하면 안되는 함수입니다.

     

    c 스타일 라이브러리의 경우 부득이하게 원시 포인터를 넘겨줘야하는 경우가 있으므로 그 때만 사용됩니다.

     

    5) unique_ptr 에서 지원하는 함수 - reset()

     

    reset() 함수는 unique_ptr 이 가지고 있는 포인터를 비웁니다. nullptr로 만드는 것이죠.

    이 과정에서 가지고 있던 메모리는 해제됩니다.

    리턴 데이터는 void 입니다.

     

    reset 의 인수로 원시포인터를 넣어주면, 가지고 있던 포인터를 비우고 새로운 메모리를 할당합니다.

    당연하게도 다른 유니크 포인터를 넣을 수는 없습니다.

     

    6) unique_ptr 에서 지원하는 함수 - release()

     

    unique_ptr 의 포인터를 nullptr 로 바꾸고 들어있던 객체를 리턴합니다.

    get()의 경우 내부 포인터는 안비우는데, 이 함수는 내부 포인터를 비우고 객체를 리턴합니다.

     

    이것도 좀 위험한 게 더이상 메모리를 스마트 포인터가 관리해주지 않기 때문에 memory_leak이 더 쉽게 발생할 수 있습니다.

     

    그래서 get() 이든 release() 를 사용할 땐, 매우 조심스럽게 사용해야 합니다.

     

    7) unique_ptr 의 데이터를 어떻게 하면 안전하게 옮길 수 있을까?

    아무래도 기본적인 대입 연산자로는 데이터를 옮길 수 없습니다.

    대입 연산자는 값을 복사하기에, 복사와 동시에 참조가 2개로 되어버리기 때문이죠.

     

    아마도 이때까지 배운 것을 생각해보면,

    이런 식으로 release() 로 원시 포인터를 빼준 다음에 myDog2로 reset을 통해 전달해준다면,

    안전하게 이동하게 할 수 있을 겁니다.

    쓰는 방식이 조금 더러우니까 define 문법을 통해서 옮길 수 있죠.

     

    그런데, 사실 더 좋은 방법이 있습니다.

    바로 이동 연산입니다.

     

    https://powerclabman.tistory.com/47 (이동에 대한 개념은 이 링크를 참고해주세요.)

     

    이동에 대해서 잠시 복습해보자면,

    변수 v1에서 변수 v2로 값을 이동한다 했을때

    v2가 v1의 내용을 통채로 훔친다. 라는 느낌이 강합니다.

     

    즉, 동시에 2개의 참조가 발생할 수 없다는 것이죠.

    unique_ptr 내부에는 '이동 대입 연산자'와 '이동 생성자'가 정의되어 있기 때문에

    옮기고자 하는 값을 '오른값'으로 바꿔서 이동시켜주면 됩니다.

     

     

    어떤가요? 정말쉽지 않나요?

    앞선 포스팅에서 설명했던 오른값이 unique_ptr에서는 매우 유용하게 쓰입니다.

     

    8) 매개변수로 전달하기

    일단, 매개변수로 unique_ ptr 를 전달할 때 복사는 절대 사용할 수 없습니다.

     

     

    이런 call by value 형식의 전달 방법은 개체를 복사하려고 시도하기 때문에

    참조가 동시에 2개가 만들어지게 됩니다.

     

    사용할 수 있는 방법은

    참조, 오른값 참조겠죠?

     

    이것은 필요에 따라 결정해서 사용하시면 되겠습니다.

    5. unique_ptr 역설계

    앞서 설계한 SPtr 코드를 이용하면 간단하게 구현할 수 있습니다.

    RefCount는 필요없으니까 날려주시고,

    복사가 일어나면 안되니까 복사와 관련된 함수는 다 없애면 됩니다.

     

    더보기
    template <typename T>
    class UPtr
    {
    public:
    	//기본 생성, 소멸, 복사대입, 복사생성, 이동대입, 이동생성자 정의
    
    	UPtr() : _ptr(nullptr) {}
    	~UPtr() { Release(); }
    
    	UPtr(T* ptr) : _ptr(ptr) {  }
    	UPtr(const UPtr& other) = delete;
    	UPtr(T&& ptr) : _ptr(ptr) { ptr = nullptr;  }
    	UPtr(UPtr&& other) noexcept : _ptr(other._ptr) { other._ptr = nullptr;  }
    
    	UPtr& operator=(const UPtr& other) = delete;
    
    	UPtr& operator=(UPtr&& other) noexcept
    	{
    		if (this == &other)
    			return *this;
    
    		if (_ptr)
    			Release();
    
    		_ptr = other._ptr;
    
    		other._ptr = nullptr;
    
    		return *this;
    	}
    
    
    public:
    	//연산자 오버로딩 및 외부 접근 함수
    	T& operator*() { return *_ptr; }
    	const T& operator*() const { return *_ptr; }
    	T* operator->() { return _ptr; }
    	const T* operator->() const { return _ptr; }
    
    	bool operator==(const UPtr& other)  const { return _ptr == other._ptr; }
    	bool operator!=(const UPtr& other)  const { return _ptr != other._ptr; }
    
    	bool isNull() const { return _ptr == nullptr; }
    	T* Get() const { return _ptr; }
    
    public:
    	//unique_ptr 에서 지원하는 함수
    	T* get() const { return _ptr; }
    
    	void reset()
    	{
    		Release();
    		_ptr = nullptr;
    	}
    
    	void reset(const T* ptr)
    	{
    		Release();
    		_ptr = ptr;
    	}
    
    	T* release()
    	{
    		T* rPtr = new T;
    		::swap(rPtr, _ptr);
    		Release();
    
    		return rPtr;
    	}
    
    private:
    	void Release()
    	{
    		if (_ptr)
    		{
    			delete _ptr;
    			_ptr = nullptr;
    		}	
    	}
    
    private:
    	T* _ptr;
    };

    최대한 유사하게 설계해보았습니다.

    충분한 Q&A는 하지 않았기에 오류가 있을 수 있습니다.

    6. 마치며

    shared_ptr 와 weak_ptr 를 따로 뺀 이유는 그건 그것대로 설명할 게 많고

    둘이 보통 쌍으로 사용되기 때문입니다.

     

    다음 포스팅에서 뵙겠습니다.