오늘은 C++ 단골 문제, 스마트 포인터에 대해 다뤄 보려한다.
<스마트 포인터 왜 쓰는가?>
일단 이 스마트 포인터라는 것이 왜 쓰이는가에 대해 알아야 한다.
RAII ( Resource Acquisition Is Initialization ) 직역하자면, 리소스 획득은 초기화다.
=> Object 와 Resource의 Life Cycle을 일치 시킨다. 라는 개념으로
간단히 정리하자면 Object -> 동적으로 객체 생성 후 delete를 해주지 않아.
Resource-> Heap Memory에 쌓이는 Memory Leak(메모리 누수) 현상을 원천적으로
차단 해줄 수 있기 때문이다.
< 왜 C++에만 있는가? >
이를 알기 위해선, 각 언어별 메모리 관리 방법에 대해서 알아야한다. C#, JAVA등 상기 언급한 언어들의
경우, GC(Garbage Collection)에 의해 내부적으로 동적으로 할당된 데이터가 해체되는데
이 원리에 대해선 추후 설명하도록 하고, 그럼 C++에만 존재하는 이유에 대해 생각해보자면,
C++의 경우 Pointer를 사용하여 주소로 접근 하는 방법이 있지만 C#과 Java의 경우, 참조 형태로
접근하는 구조적 방식에 차이가 있기 때문이다.
<스마트 포인터가 그래서 무엇인가?>
문제는 개발자가 직접적으로 메모리를 관리를 할 수 있다는 것은 어떻게 보면 장점이 될 수 있지만,
메모리 관리를 제대로 하지 않아 메모리 누수 현상이 불가피 할 수 있다. 그리고 이러한 현상은
협업을 하다보면, 누구나 실수를 범할 수 있는 문제이고 이를 해결하고자 몇 가지 원리로
Smart Pointer라는 개념이 등장하게 된다.
<Memory Leak (메모리 누수)>
우선, 어떤 상황에서 이 메모리 누수가 발생하는지를 일반적인 상황을 예로 들어 설명해보고자
한다.
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
class Cat {
public:
Cat(int age):_age(age)
{
cout << "cat constructor" << '\n';
}
~Cat()
{
cout << "cat destructor" << '\n';
}
private:
int _age;
};
int main() {
Cat * ptr = new Cat{3};
return 0;
}
Cat이라는 Class를 선언하고, main에서 동적으로 Cat을 생성하고, 그 주소를 ptr에 담았다.
그림으로 좀 더 자세히 보자면 아래 와 같다.

main이 실행되면 메모리에 Stack 공간이 할당되고, 그 안에 ptr 변수가 담기게 된다.
이 때, Ptr은 동적으로 생성하여 Heap Memory에 할당된, 고양이를 가르키고 있다.
그대로 main 함수가 종료되면, 할당된 Stack은 사라지고 그 안에 있던 모든 변수 또한 사라지게
된다.
단, 이 상황에서 Heap에 할당된 Cat은 지워지지 않기 때문에.
프로세스가 끝나기 전 까지 메모리 누수가 발생한다.
이렇게 포인터를 사용하게 되면 반드시 동적으로 객체를 생성해주면 delete를 통해 그 객체가
사용이 끝나면, 해체 해주어야 한다. 하지만 협업을 하다보면, 또는 작업을 하다보면.
delete를 까먹는 경우가 발생하고, 또 지금은 단순히 생성자와 소멸자만 있지만
Cat안에 Friend라고 또 포인터 멤버 변수가 있다고 가정한다면 소멸할 때, 그 Friend도
해제 해주어야 한다.
<Unique Pointer>
번거롭고 실수도 많아질 수 있는 이 작업을 Smart Pointer를 통해 개선할 수 있다.
Smart Pointer에는 2가지 Type이 존재하는데 그중 Unique Pointer를 먼저 살펴보고자 한다.
간단하게 이 녀석의 특징은 스택이 종료되면, 알아서 Heap에 저장된 객체도 삭제해준다.
앞서 말하였듯이, Smart Pointer는 일반 Pointer의 Memory Leak 현상을 완화해주는 목적이
있다보니 어떻게 보면 당연하다.
그 외에도 여러가지 특징들이 있는데.
일단 녀석은 스코프({}) 중심의 라이플 사이클을 가지고 있다. 이 부분에 대해선 코드를 설명할 때
다시 언급해보도록 하겠다.
또 하나의 특징으로는 그 이름과 걸맞게 Exclusive Owner Ship 특징을 가지는데 간단히,
해당 포인터가 Heap에 할당된 어느 오브젝트를 가르키면 다른 포인터들은 그 오브젝트를
가르킬 수 없다.
즉, 유니크 포인터로 가르키고 있는 대상이 있다면 그 대상은 다른 포인터가 가르킬 수 없다.
물론, 이를 std move 를 통해 유니크 포인터에서 다른 유니크 포인터로 넘겨줄 수 있지만
그 부분은 차후 r-value(오른값 참조)를 설명할 때 다루도록 하겠다.
여하튼 이제 코드를 보자.
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
class Cat {
public:
Cat(){
cout << "cat constructor" << '\n';
}
Cat(int age):_age(age)
{
cout << "cat constructor" << '\n';
}
~Cat()
{
cout << "cat destructor" << '\n';
}
private:
int _age;
};
int main() {
//vector<Cat> cats(5);
cout << " 시작 " << '\n';
{
unique_ptr<Cat> cat = make_unique<Cat>(3);
}
cout << " 끝 " << '\n';
return 0;
}

유니크 포인터를 사용할려면 memory를 include 해줘야 한다.
이후 사용법은 위 코드와 같이 unique_ptr<T> Variable = make_unique<T>(Parameter) 형태로
만들수 있다.
실행 결과를 보면 아까 위에서 언급한 스코프 중심의 라이프 사이클을 가지고 있다고 설명하였는데.
위 코드를 살펴보면 출력 부분에서 시작 이후 스코프 안에 유니크 포인터가 있다보니.
끝이 출력되기전, 알아서 소멸자를 호출하는 것을 볼 수 있다.
<언제쓰면 좋을까?>
아까 Memory Leak이 발생되는 원인 중 하나로 클래스 멤버변수에서 또 다른 Pointer가 있는 경우를
예로 들었다. 즉 해당 클래스가 소멸 될 때 해당 멤버변수를 delete를 해주지 않으면 안되기 때문에
이 때 Unique_Pointer를 사용해서 애초에 멤버변수를 담고 있는 클래스가 소멸하면 그 안에 속한
멤버변수 포인터들도 자동으로 해체 되는게 좋다고 볼 수 있다.
<Shared Pointer>
이 친구의 경우 딱 2가지만 기억하면 된다. 그 2가지도 그리 어려운 내용은 아니다.
일단 이 친구 또한, 유니크 포인터와 마찬가지로 이름에 그 특징을 나타내고 있다.
Shared 공유하다. 뭐 그런의미다 보니, 유니크 포인터와는 상반되게 하나의 오브젝트를
여러 Shared Pointer가 가르킬 수 있다는 의미로 해석할 수 있다.
엇? 그럼 기존의 Pointer와 다를게 없지 않느냐 라고 할 수 있겠다만. 앞서 설명하였듯
Smart Pointer는 모두 RAII 디자인 패턴을 따르고 있기 때문에 그 쓰임이 끝나면 알아서
Heap Memory에서 해체되어야 한다.
그럼 여러곳에서 Shared Pointer를 통해 가르키고 있는데 어떻게 알아서 해체가 될 수 있는지
에 대해 궁금할 것이다. 그리고 이 원리를 알아야만 Shared Pointer를 사용할 때, 주의해야할
문제 사항을 알 수 있다.
<원리가 무엇인가?>
일단 이 친구의 원리는 Reference Count를 통해 RAII 패턴을 따르고 있는데. 이 Count는
Shared Pointer가 해당 오브젝트를 가르킬 때마다 Count를 1씩 증가해주는 단순한 내용이다.
그러다 함수가 끝나 Stack에서 Pointer가 사라지면 그 Count가 1씩 다시 빠져나가며 최종적으로
가르키는 Pointer가 모두 사라지면 Heap에 할당된 그 객체 또한 소멸 시키는 원리라 볼 수 있다.
< Circluar Reference 일명 원형참조를 주의하자 >
간단한 원리로 동작하다보니, 이 친구를 사용할 때 한 가지는 꼭 주의해줘야 한다 바로 원형참조
라는 현상인데. 코드를 먼저 살펴보자
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
class Cat {
public:
Cat(){
cout << "cat constructor" << '\n';
}
~Cat()
{
cout << "cat destructor" << '\n';
}
shared_ptr<Cat> Mfriend;
private:
int _age;
};
int main() {
shared_ptr<Cat> pKitty = make_shared<Cat>();
shared_ptr<Cat> pNabbiy = make_shared<Cat>();
pKitty->Mfriend = pNabbiy;
pNabbiy->Mfriend = pKitty;
return 0;
}
간단하게 각각 Shared Pointer로 선언한, Kitty랑 Nabbiy가 있다. 그리고 이 친구들의 경우
friend라는 공유 멤버변수를 갖고 있다.
이 때, 함수가 종료되어 pNabbiy가 사라지게 되어도 kitty의 멤버변수인, friend 포인터가
Nabbiy를 가르키고 있기 때문에, 함수가 종료되어도 Refence Count가 1이 계속 유지되며
최종적으로 둘다 사라지지 않고, Heap에 남아 서로를 가르키게 된다.
그림을 통해 진행과정을 살펴보자.

위와 같이 main 함수가 실행되고 나면, Stack에 있는 각각의 Ptr은 Heap에 있는 Object를 가르킬 것이다.
또, 멤버 포인터로 선언된 Friend를 통해 또 한 번, 서로를 가르키게 된다 이 때 각각의 레퍼런스 카운터는
그림과 같이 2라고 볼 수 있다.

문제는 main 함수가 종료되고 나서 Stack에 있는 Ptr이 지워졌을 경우. 각각 레퍼런스 카운터는 1씩 감소하지만
Heap에선 아직 멤버 포인터로 서로를 가르키고 있기 때문에 레퍼런스 카운터가 프로세스가 끝날 때 까지 1로 유지 될 것이다.
이것을 원형참조라고 하며, Shared Pointer를 사용할 땐 이 부분을 주의하여야 한다.
코드 출처 - https://www.youtube.com/watch?v=SQYPN8FVCAI
'프로그래밍-기본기 > C++' 카테고리의 다른 글
| (쓰레드) Spin Lock (0) | 2023.09.25 |
|---|---|
| (쓰레드) Mutex (0) | 2023.09.08 |
| (쓰레드) Atomic (0) | 2023.09.06 |