스마트 포인터에 대해서
- 메모리 관리를 자동화하는 C++의 객체
- 일반적인 원시 포인터(
raw pointer
)와 달리 소멸자가 호출될 때 자동으로 동적 메모리를 해제 - C++에서는
std::unique_ptr
,std::shared_ptr
,std::weak_ptr
등의 스마트 포인터가 제공 - 스마트 포인터의 활용으로 메모리 누수(memory leak)를 방지하며, RAII(Resource Acquisition Is Initialization) 원칙을 따를 수 있음.
필요성과 사용 이유
C++의 new
와 delete
키워드. 동적 메모리 할당과 해제를 위한 키워드.
이는 두 가지 주요 문제가 발생할 수 있는 원인.
- 메모리 누수 (
delete
의 사용이 누락된 경우) - Dangling Pointer (해제된 메모리 공간에 대한 부적절한 접근)
스마트 포인터는 포인터가 가리키는 메모리를 자동으로 해제한다. (위의 문제를 방지)
그러나 스마트 포인터가 모든 메모리 관리 문제를 해결하지는 못한다. (순환참조 같은 문제가 존재)
작동원리
RAII원칙을 사용해 메모리를 자동으로 관리한다. 스마트 포인터 객체의 생성자에서 메모리 할당이, 소멸자에서 메모리 해제가 일어난다는 의미다.
RAII 원칙
: Resource Acquisition Is Initialization 원칙
객체의 수명이 그 객체가 소유한 자원의 수명과 동일하게 관리되는 것
std::unique_ptr
- C++ 11부터 제공되는 스마트 포인터.
- 단일 소유권 모델을 구현한다.
- 동시에 하나의
std::unique_ptr
만이 특정 객체(메모리 영역)를 소유할 수 있다는 의미. - 메모리 블록을 독점적으로 소유하고, 복사는 허용하지 않는다.
- 메모리 누수의 예방과, 자원의 안전한 관리에 도움을 준다.
- 동시에 하나의
- 비용이 거의 없으며, 일반 포인터와 거의 동일한 성능을 제공한다.
- 복사가 불가능한 대신, ‘이동’이라는 개념으로 포인터의 소유권을 이전할 수 있음.
std::move
소유권 이전
1
2
std::unique_ptr<int> ptr1 = std::make_unique<int>(5);
std::unique_ptr<int> ptr2 = std::move(ptr1);
위의 경우에, ptr1
은 ptr2
로 소유권을 이전했기에 ptr1
은 원본에 대한 소유권을 잃는다.
위의 특성으로 std::unique_ptr
은 자원의 유일한 소유자임을 보장해 메모리 관리를 예측가능하게 하며, 안전하게 만든다.
reset()
- 포인터가 가리키는 객체의 메모리 할당을 해제하는 메서드
Custom deleter
std::unique_ptr
가 메모리를 해제하는 방법을 사용자가 정의할 수 있는 방법
예외처리
std::unique_ptr
는 스코프를 벗어나는 순간 메모리를 자동으로 해제하기에, 예외가 발생하더라도 메모리 누수는 발생하지 않는다.
함수에 전달하고 반환하는 방법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <memory>
std::unique_ptr<int> CreateUniquePtr() {
// std::unique<int>를 직접 반환하며, 소유권이 이전됨.
// 함수가 끝난 후에도 메모리 누수는 발생하지 않는다.
return std::unique_ptr<int>(new int(5));
}
void UseUniquePtr(std::unique_ptr<int> ptr) {
// ptr은 이 함수 안에서만 유효함.
// 함수가 끝난 후에는 자동으로 메모리가 해제됨.
std::cout << "value is " << *ptr << '\n';
}
int main() {
std::unique_ptr<int> ptr = CreateUniquePtr();
UseUniquePtr(std::move(ptr));
}
std::unique_ptr
는 복사 생성자, 복사 대입 연산자가 없기에std::move
를 사용해 소유권을 이전해야 함.
std::shared_ptr
- 메모리에 대한 ‘공유’ 소유권을 제공하는 스마트 포인터.
- 여러
std::shared_ptr
인스턴스가 동일한 메모리를 가리킬 수 있다.
- 여러
- 내부적으로 레퍼런스 카운팅을 통해 동일한 메모리를 가리키는
std::shared_ptr
인스턴스의 수를 추적한다. 이를 통해 동일한 자원을 안전하게 공유할 수 있도록 한다.- 하나의 메모리 공간에 대한 레퍼런스 카운트가 0이 되면 메모리가 해제된다.
- 내부적으로 일반 포인터와 ‘제어 블록’을 갖고 있다.
- 이는 레퍼런스 카운트와 관련된 정보를 유지하는 부분.
- 불필요한 레퍼런스 카운팅은 성능 저하의 원인이 될 수 있다.
- 순환 참조 문제 야기 가능.
- 서로가 서로를 참조하는
std::shared_ptr
객체 쌍이 생기면, 서로의 참조 카운트가 0이 되지 않도록 막아주어 메모리 누수가 발생한다. - 이러한 상황을 막기 위해
std::weak_ptr
을 사용함
- 서로가 서로를 참조하는
기본 사용법
1
2
std::shared_ptr<int> p1(new int(5)); // 1번 방식
std::shared_ptr<int> p2 = std::make_shared<int>(5); // 2번 방식
- 두 방식 중에는 대체로 2번이 더 선호된다. 메모리 할당이 한번만 이루어져 더 나은 성능을 가지며 예외 안전성을 가지기 때문이다.
- 한 번의 메모리 할당으로 Control Block과 실제 객체를 같이 할당하기 때문에, 성능 측면에서 이점이 있다.
get()
- 관리하는 객체의 포인터를 반환하는 메서드
use_count()
std::shared_ptr
의 레퍼런스 카운트를 알아내기 위한 메서드.- 디버깅이나 학습 목적 외에는 사용을 권장하지 않음.
- 해당 메서드의 반환 값이 순간적인 상태를 반영하기 때문에 멀티 스레드 환경에서는 신뢰할 수 없기 때문.
Custom deleter
std::shared_ptr
가 메모리를 해제하는 방법을 사용자가 정의할 수 있는 방법
예외처리
std::shared_ptr
는 스코프를 벗어나는 순간 메모리를 자동으로 해제하기에, 예외가 발생하더라도 메모리 누수는 발생하지 않는다.
std::weak_ptr
- 소유권이 없는 스마트 포인터
std::shared_ptr
와 유사하지만, 레퍼런스 카운트에 영향을 주지 않는다.- 가리키는 객체의 수명에 영향을 주지 않는 ‘약한’ 참조를 제공한다는 점에서 차이가 있다.
- 대체로
std::shared_ptr
와 함께 사용되며,std::shared_ptr
의 순환 참조 회피에 사용된다.
- 대신
std::weak_ptr
이 가리키는 객체에 직접 접근할 수 없다.lock()
메서드를 활용해std::shared_ptr
로 변환한 후 접근하는 것은 가능하다.
기본 사용법
1
2
std::shared_ptr<int> sp(new int(5));
std::weak_ptr<int> wp(sp);
lock()
std::weak_ptr
의 원본 객체를 가리키는std::shared_ptr
을 반환하는 메서드.- 원본 객체가 이미 소멸된 상태라면, 비어있는
std::shared_ptr
를 반환. 이를 활용해 메모리에 안전하게 접근할 수 있음.
1 2 3 4 5 6 7
void printValue(const std::weak_ptr<int>& wp) { if (auto sp = wp.lock()) { std::cout << "Value: " << *sp << "\n"; } else { std::cout << "원본 객체 소멸, 접근 불가!\n"; } }
expired()
- 원본 객체가 이미 소멸되었는지 여부를 확인하는 메서드.
- 소멸되었다면
true
, 그렇지 않으면false
.
순환 참조 문제가 발생하는 상황과 대처방법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <memory>
struct Node {
std::shared_ptr<Node> next;
~Node() {
// 이 부분이 출력되지 않기에, 인스턴스가 소멸되지 않음을 알 수 있다.
std::cout << "Node 소멸\n";
}
};
void circularReference() {
std::shared_ptr<Node> a = std::make_shared<Node>();
std::shared_ptr<Node> b = std::make_shared<Node>();
a->next = b; // a가 b를 참조
b->next = a; // b가 a를 참조 (순환 참조 발생!)
std::cout << "a의 레퍼런스 카운트: " << a.use_count() << std::endl;
std::cout << "b의 레퍼런스 카운트: " << b.use_count() << std::endl;
}
int main() {
circularReference();
return 0;
}
// [결과]
// a의 레퍼런스 카운트: 2
// b의 레퍼런스 카운트: 2
위의 예제를 보면 circularReference
메서드가 끝나면서 std::shared_ptr<Node>
a
와 b
의 수명이 다한다. 그러나 Node
인스턴스의 std::shared_ptr<Node> next
가 서로를 참조해 레퍼런스 카운트가 0이 되지 않도록 막아준다. 그렇기에 메모리에 있는 실제 두 Node
인스턴스는 계속 메모리를 점유하고 있을 것이다. 메모리 누수가 발생하는 것이다.
이를 막기 위해 다음과 같이 std::weak_ptr
를 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <memory>
struct Node {
std::weak_ptr<Node> next;
~Node() {
std::cout << "Node 소멸\n";
}
};
void weakPtrExample() {
std::shared_ptr<Node> a = std::make_shared<Node>();
std::shared_ptr<Node> b = std::make_shared<Node>();
a->next = b; // weak_ptr이므로 참조 카운트 증가 X
b->next = a;
std::cout << "a의 레퍼런스 카운트: " << a.use_count() << std::endl;
std::cout << "b의 레퍼런스 카운트: " << b.use_count() << std::endl;
}
int main() {
weakPtrExample();
return 0;
}
// [결과]
// a의 레퍼런스 카운트: 1
// b의 레퍼런스 카운트: 1
// Node 소멸
// Node 소멸
위 예제에서는 Node
인스턴스의 next
를 std::shared_ptr<Node>
이 아닌 std::weak_ptr<Node>
를 사용했기에, 두 Node
인스턴스는 서로를 참조함에도 서로의 레퍼런스 카운트에 영향을 주지 않음을 알 수 있다. 그래서 이번에는 weakPtrExample
메서드가 끝날 때 두 Node
인스턴스가 정상적으로 잘 소멸될 수 있는 것이다.
사용자 정의 스마트 포인터
- 기본으로 제공되는
std::unique_ptr
,std::shared_ptr
,std::weak_ptr
등으로 충분하지 않은 경우에 사용할 수 있음. - 사용자 정의 스마트 포인터를 구현할 때 첫번째로 중요한 것은 소유권 정책을 구현하는 것이다.
- 메모리 누수, 데드락, 그 밖에도 다양한 문제가 발생하는 것을 막기 위해 연산자 오버로딩, 메모리 관리 등 복잡한 문제를 정확하게 처리해야하기에 가능하다면 표준 라이브러리의 스마트 포인터를 사용하는 것이 좋다.
기본적인 사용자 정의 스마트포인터 구현 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T>
class smart_ptr {
private:
T *ptr;
public:
explicit smart_ptr(T *p = nullptr) {
// 생성자에서 포인터 초기화
ptr = p;
}
~smart_ptr() {
// 소멸자에서 메모리 해제
delete ptr;
}
// 일반 포인터와 같은 방식으로 사용하기 위해 연산자 오버로딩
T &operator*() { return *ptr; }
T *operator->() { return ptr; }
};