C++에서는 스마트 포인터라는 아주 편리한 기능을 지원해준다.

스마트 포인터는 일반적인 포인터 변수를 사용해 동적으로 할당받은 메모리 주소를 참조할 경우 발생할 수 있는 문제점들을 보완하기 위해 추가된 기능이다. 스마트 포인터에 대해 알아보기 전에, 일반적인 포인터 사용의 문제점을 알아보자.

 

일반적인 포인터의 문제점

동적으로 할당받은 메모리 주소를 참조할 때, 일반적인 포인터를 사용하면 발생할 수 있는 문제점은 댕글링 포인터와 메모리 누수가 있다.

 

1. 댕글링 포인터

두 포인터 변수 APtr과 BPtr이 같은 주소를 가리키고 있다고 해보자.

이 때, APtr이 참조하고 있는 메모리 영역을 할당 해제해버린다면?

 

두 포인터 변수가 가리키는 주소는 더 이상 유효하지 않은 주소가 된다. APtr의 경우, 본인이 해제를 하였으니 nullptr을 변수에 대입하여 오류를 방지할 수 있지만, BPtr은 주소가 유효하지 않음을 알지 못한다.

 

이로 인해, BPtr이 메모리 영역에 참조를 시도하면 세그멘테이션 오류가 발생하게 된다.

 

2. 메모리 누수

 

malloc, new 를 이용해 동적으로 메모리를 할당받게 되면, 사용이 끝난 뒤 반드시 메모리 할당을 해제해주어야 한다.

하지만, 메모리를 해제하지 않고 포인터 변수가 지역을 벗어나 소멸하거나 다른 주소를 가리키게 될 경우 할당 받은 메모리는 절대 해제할 수 없게 된다. 즉, 동적으로 할당받은 메모리에 대한 철저한 감시, 추적이 요구되며, 이를 소홀히 할 경우 메모리 누수가 발생할 수 있다. 아주 중요한 일이지만 동시에 프로그래머 입장에선 상당히 불편한 일이라고 할 수 있다.

 

스마트 포인터

스마트 포인터는 위의 두 문제를 해결하기 위해 만들어진 기능이다.

 

메모리의 해제를 프로그래머가 직접하지 않아도 되기 때문에, 높은 안정성과 편의성을 보장하고 있으며 사용법을 잘 숙지하였다면 메모리의 누수 또한 발생하지 않는다.

 

스마트 포인터는 std::shared_ptr, std::weak_ptr, std::unique_ptr의 총 3가지 종류가 있다.

먼저, 각 특징을 알아보기 전에 전체적인 구조와 동작 원리를 알아보자.

 

스마트 포인터의 동작 원리

스마트 포인터를 사용할 경우 std::make_shared, std::make_unique를 통해 메모리를 동적으로 할당할 수 있다.

std::shared_ptr<int> SharedPtr1 = std::make_shared<int>();
std::shared_ptr<int> SharedPtr2(std::make_shared<int>());

std::unique_ptr<double> UniquePtr = std::make_unique<double>();
std::unique_ptr<double> UniquePtr(std::make_unique<double>());

make_shared로 메모리를 동적으로 할당하게 되면, 참조 테이블이라는 것이 생성된다.

참조 테이블은 해당 메모리 영역과 관련된 여러 정보를 담고 있다.

 

대표적으로는 strong reference count, weak reference count를 담고 있다. 참조 테이블은 메모리 영역당 1개가 생성되며, 같은 메모리 영역을 가리키는 스마트 포인터는 모두 하나의 참조 테이블을 공유하게 된다.

 

어떠한 스마트 포인터가 새롭게 메모리 영역을 가리키게 되면, strong refence count가 1이 증가하며, 메모리 영역을 가리키지 않게 되면 strong reference count가 1이 감소하게 된다.

 

strong refence count가 0이 되는 순간, 해당 메모리는 할당이 해제된다.

//SRC = Strong Reference Count

std::shared_ptr<int> A = std::make_shared<int>(); //SRC = 1
std::shared_ptr<int> B = A; //SRC = 2
B = nullptr; //SRC = 1;
A = nullptr; //A = 0; -> 메모리 할당 해제

해당 메모리를 가리키고 있는 변수가 모두 사라져야만 메모리 할당이 해제되기 때문에 댕글링 포인터의 문제가 발생하지 않는다. 또한, 프로그래머의 실수로 메모리 누수가 발생할 확률도 매우 줄어든다고 할 수 있다.

 

의도적으로 참조를 끊고 싶다면, 위의 코드처럼 nullptr을 삽입해도 되고, 다른 대상을 가리켜도 된다. 지역을 벗어나면서 변수가 소멸할 때에도 알아서 참조가 끊어지기 때문에 상황에 맞게 사용하면 된다.

 

그렇다면, weak reference count는 뭘까? 이걸 알기 전에, std::shared_ptr과 std::weak_ptr에 대해 먼저 알아보자.

 

std::shared_ptr

std::shared_ptr은 말 그대로 하나의 메모리 주소를 공유하여 사용하는 포인터이다.

하나의 메모리 영역을 둘 이상의 개체가 참조하는 것이 허용되는 스마트 포인터이다.

 

std::shared_ptr은 위에서 말한 것처럼 참조 테이블을 통해, 메모리 주소를 가리키는 개체의 존재 여부를 파악하며 모든 개체가 참조를 끊게 되면 메모리 영역을 해제하게 된다. 

 

일반적인 상황에서 이는 매우 안전하고 편리한 방법이지만, 한 가지 상황에서 문제가 발생한다.

바로, 두 개의 shared_ptr이 서로를 가리키는 경우이다.

 

이렇게, 두 스마트 포인터가 서로를 가리키고 있다고 해보자.

이 때, 프로그램이 종료된다고 한다면 두 객체를 소멸하려고 할 것이다.

 

객체 A가 소멸되기 위해선, 객체 B의 shared_ptr이 A를 가리키지 않도록 해야 한다. 즉, 객체 B가 소멸해야 한다.

객체 B를 소멸하려고 보니, 객체 B는 반대로 객체 A가 소멸되어야만 소멸할 수 있다.

 

즉, 서로가 서로를 참조하는 상황으로 인해, 두 객체가 모두 소멸할 수 없는 상황이 된 것이다.

이러한 상황을 순환참조라고 한다. std::shared_ptr을 일반적으로 사용할 경우 순환 참조가 발생하여 두 객체 모두 소멸하지 못해 메모리 누수로 이어지게 된다.

 

std::shared_ptr에서 발생할 수 있는 순환참조 문제를 해결하기 위해 사용되는 것이 std::weak_ptr이다.

 

std::weak_ptr

std::weak_ptr은 std::shared_ptr과 거의 동일하지만, 한 가지 크게 다른 점이 있다.

std::shared_ptr은 strong reference count를 증가/감소시키는 반면, std::weak_ptr은 weak reference count를 증가/감소시키며 동작한다.

 

 위에서 말했듯이, 참조 테이블에는 strong reference count와 weak reference count 두 개의 참조 카운트 변수가 있다.

strong refenrece count는 메모리의 해제를 위해 사용되는 반면, weak reference count는 메모리 해제에 관여되지 않는다.

weak reference count가 0이든 10이든 100이든 strong reference count가 0이면 메모리가 해제되고, 1 이상이면 메모리가 해제되지 않는다.

 

즉, 위의 순환참조 상황에서, std::weak_ptr을 사용하면 순환참조 문제에서 벗어날 수 있다.

 

그렇다면, strong reference count가 0이 되면, 스마트 포인터 관련 모든 메모리가 해제될까?

아니다. 동적으로 할당한 메모리는 해제하지만, 참조 테이블은 사라지지 않는다.

왜냐하면, weak_ptr로 주소를 참조하고 있는 객체의 동작이 안전하게 완료되는 것을 보장하기 위해, weak reference count가 1 이상이면, 참조 테이블은 사라지지 않는다. weak reference count가 0이 되는 순간 참조 테이블이 소멸하며, 사용하던 스마트 포인터 관련 메모리가 모두 해제된다.

 

 weak_ptr을 사용하기 위해선, 2개의 기능을 알아야 한다.

lock과 expired이다.

 

 위에서 말했듯이, weak reference count는 할당된 메모리 해제에 관여하지 않는다. 즉, 현재 weak_ptr로 참조하고 있는 메모리 영역이 이미 해제된 곳일 가능성도 있다는 말이다.

 

이를 확인하기 위한 함수가 expired이다. 가리키는 메모리 주소가 유효하다면 false를 반환하고, 유효하지 않다면 true를 반환한다. 이를 이용해, weak_ptr을 안전하게 사용할 수 있다.

 

lock은 weak_ptr에서 참조중인 주소의 데이터를 직접적으로 사용하기 위해 호출하는 함수이다.

 

weak_ptr은 그 자체로는 원시 포인터에 접근할 수 없다. 왜냐하면, weak_ptr은 스마트 포인터의 생명 주기에 관여하지 않는다. 그럼에도 불구하고 shared_ptr처럼 마음껏 주소를 사용하고 변경할 수 있다면, 스마트 포인터를 사용하는 이유가 사라지게 된다. 그렇기 때문에, weak_ptr은 그 자체로 주소에 접근할 수 없고, lock을 이용해서 접근해야 한다.

lock은 해당 주소에 접근할 수 있는 shared_ptr을 반환해주는 함수이다. 이를 사용하여, weak_ptr이 참조중인 메모리 영역의 데이터를 사용할 수 있게 된다.

class A
{
public:
    int a = 0;
};

int main()
{
    std::shared_ptr<A> SharedPtr = std::make_shared<A>();
    std::weak_ptr<A> WeakPtr = SharedPtr;

    if (WeakPtr.expired() == false)
    {
        WeakPtr.lock()->a = 5;
    }
}

위와 같이 사용할 수 있다. expired를 이용해 주소의 유효성을 판별한 뒤, lock을 통해 메모리 주소에 접근한다.

 

std::unique_ptr

std::unique_ptr은 메모리 영역을 단 하나의 포인터 변수만 가리킬 수 있도록 하고 싶을 때 사용하는 스마트 포인터이다.

shared_ptr이나 weak_ptr은 하나의 메모리 영역을 여러개의 포인터로 가리킬 수 있었다.

하지만, unique_ptr은 메모리 영역을 단 하나의 포인터만 가리킬 수 있다.

 

스마트 포인터의 장단점

위에서 말했듯이, 스마트 포인터의 장점은 안정성에 있다. 프로그래머가 직접적으로 delete를 호출해주지 않아도 되기 때문에 실수로 인한 메모리 누수를 방지할 수 있으며 이로 인해 안정성과 편의성이 매우 높아진다.

 

반면, delete를 직접 호출하지 않기 떄문에 메모리 해제 시기를 프로그래머가 정확히 파악하기 힘들다는 단점이 있다. 이로 인해, 만약 메모리 누수가 발생한다면, 정확히 어디에서 어떻게 발생하고 있는지 파악하기가 매우 힘들어진다. 또한, 상속 관계에서 스마트 포인터를 사용하는 것이 여러가지 불편한 점이 있는데, 이 부분은 추후 별도의 게시물을 작성할 예정이다.

 

 

'C++ > C++' 카테고리의 다른 글

C++ - if constexpr  (1) 2024.10.05
C++ - Concept, Requires 키워드  (3) 2024.09.11
C++ - Callable 과 std::function (함수 객체, 람다 함수)  (1) 2024.06.11
C++ - Move Semantic (std::move, std::forward)  (1) 2024.06.09
C++ - L-Value vs R-Value  (1) 2024.06.09

+ Recent posts