스마트 포인터
현대 C++의 또 다른 중요한 기능이자, C++에서 가장 흔히 발생하는 문제 중 하나인 메모리 관리를 혁신적으로 개선해주는 스마트 포인터(Smart Pointers) 에 대해 알아보겠습니다.
스마트 포인터는 C++11에서 도입되었으며, 전통적인 원시 포인터(new
, delete
)가 가지고 있던 메모리 누수(memory leak)와 댕글링 포인터(dangling pointer) 문제를 안전하고 효율적으로 해결해주는 도구입니다.
스마트 포인터는 RAII(Resource Acquisition Is Initialization) 원칙을 기반으로 하여, 자원(여기서는 동적 할당된 메모리)의 생명 주기를 자동으로 관리해줍니다.
스마트 포인터의 등장 배경
C++에서 new
와 delete
를 사용하여 동적으로 메모리를 할당하고 해제하는 것은 강력하지만, 동시에 매우 위험한 작업입니다.
원시 포인터 사용 시의 문제점
- 메모리 누수 (Memory Leak):
new
로 할당한 메모리를delete
로 해제하는 것을 잊거나, 예외 발생 등으로delete
문이 실행되지 않으면 메모리 누수가 발생합니다. 이는 프로그램의 성능 저하 및 궁극적인 작동 불능으로 이어질 수 있습니다.메모리 누수 예시 void func() { int* ptr = new int[10]; // 만약 여기서 예외 발생 시, delete ptr; 문이 실행되지 않아 메모리 누수 // ... delete[] ptr; }
- 댕글링/와일드 포인터 (Dangling/Wild Pointer): 이미 해제된 메모리를 가리키는 포인터입니다. 이러한 포인터를 사용하면 정의되지 않은 동작(Undefined Behavior)이 발생하여 프로그램 충돌이나 데이터 손상으로 이어질 수 있습니다.
댕글링 포인터 예시 int* ptr = new int; delete ptr; *ptr = 10; // 댕글링 포인터 사용, 위험!
- 이중 해제 (Double Free): 동일한 메모리 영역을 두 번 이상
delete
하는 경우입니다. 이 또한 정의되지 않은 동작을 유발합니다.이중 해제 예시 int* ptr = new int; delete ptr; delete ptr; // 이중 해제, 위험!
스마트 포인터는 이러한 문제점을 해결하기 위해 설계된 클래스 템플릿입니다.
스마트 포인터는 포인터처럼 동작하면서도, 자신이 가리키는 메모리(자원)의 소유권을 관리하여 해당 메모리가 더 이상 필요 없을 때 자동으로 해제해줍니다.
즉, RAII 원칙을 포인터에 적용한 것입니다.
std::unique_ptr
(단독 소유권 포인터)
std::unique_ptr
는 동적으로 할당된 객체에 대한 단독 소유권(exclusive ownership) 을 가집니다.
즉, 한 번에 하나의 unique_ptr
만이 특정 메모리 블록을 소유할 수 있습니다.
unique_ptr
가 소멸될 때 (스코프를 벗어나거나 reset()
등을 호출할 때), 소유하고 있던 메모리를 자동으로 delete
합니다.
특징
- 복사 불가능(Non-copyable):
unique_ptr
는 복사 생성자나 복사 대입 연산자가 없습니다. 소유권을 복사할 수 없습니다. - 이동 가능(Move-only):
std::move
를 사용하여 소유권을 다른unique_ptr
로 이전할 수 있습니다. 이전된unique_ptr
는nullptr
이 됩니다. - 경량(Lightweight): 원시 포인터와 거의 동일한 성능을 제공합니다. 추가적인 오버헤드가 거의 없습니다.
생성 및 사용
- C++11:
new
를 사용하여 직접 생성합니다. - C++14 이후:
std::make_unique<T>()
함수를 사용하는 것이 권장됩니다. 이는 예외 안전성을 높이고 코드 가독성을 개선합니다.
#include <iostream>
#include <memory> // unique_ptr, make_unique
#include <string>
class MyClass {
public:
std::string name;
MyClass(const std::string& n) : name(n) {
std::cout << "MyClass '" << name << "' created.\n";
}
~MyClass() {
std::cout << "MyClass '" << name << "' destroyed.\n";
}
void doSomething() {
std::cout << "MyClass '" << name << "' doing something.\n";
}
};
int main() {
std::cout << "--- Unique Ptr 1 (Simple) ---\n";
{
// MyClass 객체를 unique_ptr로 생성
std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>("Object1");
ptr1->doSomething();
// 스코프를 벗어나면 ptr1이 자동으로 소멸되고 Object1도 파괴됨
} // ~MyClass() 호출됨
std::cout << "\n--- Unique Ptr 2 (Move Semantics) ---\n";
std::unique_ptr<MyClass> ptrA = std::make_unique<MyClass>("ObjectA");
ptrA->doSomething();
std::unique_ptr<MyClass> ptrB;
// ptrB = ptrA; // 컴파일 오류! 복사 불가능
ptrB = std::move(ptrA); // 소유권 이전 (ptrA는 nullptr이 됨)
if (ptrA) { // ptrA가 유효한지 확인
std::cout << "ptrA is valid.\n";
} else {
std::cout << "ptrA is nullptr.\n"; // 출력됨
}
if (ptrB) {
ptrB->doSomething(); // 출력: MyClass 'ObjectA' doing something.
}
// main 함수 종료 시 ptrB가 소멸되고 ObjectA 파괴됨
// (ptrA는 이미 nullptr이므로 아무것도 파괴하지 않음)
std::cout << "\n--- Unique Ptr 3 (Reset) ---\n";
std::unique_ptr<MyClass> ptrC = std::make_unique<MyClass>("ObjectC");
ptrC->doSomething();
ptrC.reset(); // ptrC가 소유하던 ObjectC가 즉시 파괴됨 (ObjectC destroyed.)
if (!ptrC) {
std::cout << "ptrC is now nullptr.\n";
}
std::cout << "\n--- Unique Ptr 4 (Release - 원시 포인터 반환) ---\n";
std::unique_ptr<MyClass> ptrD = std::make_unique<MyClass>("ObjectD");
MyClass* rawPtr = ptrD.release(); // 소유권 포기 및 원시 포인터 반환. ptrD는 nullptr이 됨.
// ObjectD는 더 이상 자동으로 해제되지 않음!
rawPtr->doSomething();
delete rawPtr; // 개발자가 수동으로 해제해야 함! (매우 중요!)
// ~MyClass() 호출됨
std::cout << "End of main.\n";
return 0;
} // 이곳에서 ptrA, ptrB 등 스코프 벗어나는 unique_ptr들이 소멸됨.
std::shared_ptr
(공유 소유권 포인터)
std::shared_ptr
는 여러 개의 스마트 포인터가 동일한 동적 할당 객체에 대한 공유 소유권(shared ownership) 을 가질 수 있도록 합니다.
shared_ptr
는 해당 객체를 가리키는 shared_ptr
의 개수(참조 횟수, reference count)를 내부적으로 추적합니다.
참조 횟수가 0이 되면, 즉 해당 객체를 더 이상 아무 shared_ptr
도 참조하지 않을 때, 객체가 자동으로 delete
됩니다.
특징
- 복사 가능(Copyable):
shared_ptr
를 복사하면 참조 횟수가 증가합니다. - 이동 가능(Move-only):
std::move
를 사용하면 소유권이 이전되면서 참조 횟수는 변경되지 않습니다. - 참조 횟수 관리: 내부적으로 참조 횟수를 관리하는 제어 블록(control block)이 있어
unique_ptr
보다 약간의 오버헤드가 있습니다.
생성 및 사용
- C++11:
new
를 사용하여 직접 생성합니다. - C++11 이후:
std::make_shared<T>()
함수를 사용하는 것이 강력히 권장됩니다.make_shared
는 객체와 제어 블록을 하나의 메모리 블록에 할당하여 효율성과 예외 안전성을 높입니다.
#include <iostream>
#include <memory> // shared_ptr, make_shared
#include <string>
class Resource {
public:
std::string id;
Resource(const std::string& i) : id(i) {
std::cout << "Resource '" << id << "' created.\n";
}
~Resource() {
std::cout << "Resource '" << id << "' destroyed.\n";
}
void use() {
std::cout << "Using Resource '" << id << "'.\n";
}
};
void process(std::shared_ptr<Resource> res) { // shared_ptr를 값으로 전달 (참조 횟수 증가)
std::cout << "Inside process function. Resource count: " << res.use_count() << "\n";
res->use();
} // res가 스코프를 벗어나면 참조 횟수 감소
int main() {
std::cout << "--- Shared Ptr 1 (Basic) ---\n";
{
std::shared_ptr<Resource> r1 = std::make_shared<Resource>("SharedRes1"); // count = 1
std::cout << "r1 count: " << r1.use_count() << "\n"; // 출력: 1
std::shared_ptr<Resource> r2 = r1; // 복사! count = 2
std::cout << "r1 count: " << r1.use_count() << ", r2 count: " << r2.use_count() << "\n"; // 출력: 2, 2
process(r1); // r1이 값으로 전달되어 임시 shared_ptr이 생성됨. count = 3 (함수 내에서)
std::cout << "Back in main. r1 count: " << r1.use_count() << "\n"; // 출력: 2
} // r1, r2 스코프 벗어남. r2 소멸 (count=1), r1 소멸 (count=0). Resource 'SharedRes1' destroyed.
std::cout << "\n--- Shared Ptr 2 (Custom Deleter) ---\n";
// 배열이나 특수 해제 로직이 필요할 때 사용
std::shared_ptr<int[]> arr_ptr(new int[5], std::default_delete<int[]>());
// 또는 람다를 이용한 커스텀 삭제자
std::shared_ptr<int> custom_delete_ptr(new int(10), [](int* p){
std::cout << "Custom deleter called for " << *p << std::endl;
delete p;
});
std::cout << "End of main.\n";
return 0;
}
std::weak_ptr
(약한 참조 포인터)
std::weak_ptr
는 std::shared_ptr
의 상호 참조(circular reference) 문제를 해결하기 위해 사용됩니다.
weak_ptr
는 shared_ptr
가 관리하는 객체를 참조하지만, 참조 횟수(reference count)를 증가시키지 않습니다.
따라서 weak_ptr
는 객체의 소유권에 영향을 주지 않습니다.
특징
weak_ptr
자체로는 객체에 직접 접근할 수 없습니다. 객체에 접근하려면lock()
멤버 함수를 통해shared_ptr
로 변환해야 합니다.lock()
을 호출했을 때 객체가 이미 소멸되었다면nullptr
을 가리키는shared_ptr
가 반환됩니다.- 상호 참조 문제 해결:
shared_ptr
A가shared_ptr
B를 가리키고, B가 다시 A를 가리키는 경우, 참조 횟수가 항상 1 이상이 되어 객체가 영원히 소멸되지 않는 문제가 발생할 수 있습니다. 이때 한쪽을weak_ptr
로 만들면 문제가 해결됩니다.
#include <iostream>
#include <memory> // shared_ptr, weak_ptr
class B; // 전방 선언
class A {
public:
std::shared_ptr<B> b_ptr; // B에 대한 shared_ptr
A() { std::cout << "A created.\n"; }
~A() { std::cout << "A destroyed.\n"; }
};
class B {
public:
std::weak_ptr<A> a_ptr; // A에 대한 weak_ptr (중요!)
B() { std::cout << "B created.\n"; }
~B() { std::cout << "B destroyed.\n"; }
void checkA() {
if (auto sharedA = a_ptr.lock()) { // weak_ptr를 shared_ptr로 변환 시도
std::cout << "A is still alive.\n";
} else {
std::cout << "A is no longer alive.\n";
}
}
};
int main() {
std::cout << "--- Circular Reference Problem (No weak_ptr) --- (주석 처리됨)\n";
/*
// 만약 B::a_ptr이 shared_ptr<A>였다면:
{
std::shared_ptr<A> a = std::make_shared<A>(); // a_count = 1
std::shared_ptr<B> b = std::make_shared<B>(); // b_count = 1
a->b_ptr = b; // b_count = 2
b->a_ptr = a; // a_count = 2 (여기서 문제 발생!)
// 스코프를 벗어나도 a와 b의 count가 1이 되어 소멸되지 않음!
} // A destroyed, B destroyed가 출력되지 않음 -> 메모리 누수!
*/
std::cout << "\n--- Circular Reference Solved with weak_ptr ---\n";
{
std::shared_ptr<A> a = std::make_shared<A>(); // A created. a_count = 1
std::shared_ptr<B> b = std::make_shared<B>(); // B created. b_count = 1
a->b_ptr = b; // b_count = 2
b->a_ptr = a; // a_count는 변하지 않음 (weak_ptr이므로)
std::cout << "a count: " << a.use_count() << ", b count: " << b.use_count() << "\n"; // 출력: a count: 1, b count: 2
b->checkA(); // A is still alive.
} // 스코프 벗어남
// b가 먼저 소멸: b_ptr (shared_ptr<Resource>)의 참조 횟수 감소 (1 -> 0). B destroyed.
// a가 소멸: a_ptr (weak_ptr<A>)는 참조 횟수에 영향 없음. A destroyed.
// 결과: B destroyed. A destroyed. (순서는 다를 수 있음)
std::cout << "End of main.\n";
return 0;
}
원시 포인터와 스마트 포인터의 전환
get()
: 스마트 포인터가 관리하는 원시 포인터를 반환합니다. 이 원시 포인터는 스마트 포인터가 소멸되기 전까지만 유효합니다. 반환된 원시 포인터를delete
하면 안 됩니다.reset()
: 스마트 포인터가 현재 소유하고 있는 객체를 해제하고, 새로운 객체의 소유권을 갖거나nullptr
을 가리키게 합니다.release()
:unique_ptr
에만 있는 함수로, 소유권을 포기하고 관리하던 원시 포인터를 반환합니다. 반환된 원시 포인터는 반드시delete
로 수동 해제해야 합니다.
스마트 포인터 사용 권장 사항
- 기본은
std::unique_ptr
: 단독 소유권이 필요한 경우,std::unique_ptr
를 기본으로 사용합니다. 이는 가장 경량이고 효율적인 스마트 포인터입니다. - 공유 소유권은
std::shared_ptr
: 여러 객체가 동일한 자원을 공유해야 할 때만std::shared_ptr
를 사용합니다. std::make_unique
/std::make_shared
사용: 항상new
대신make_unique
또는make_shared
를 사용하여 스마트 포인터를 생성합니다. 이는 예외 안전성을 높이고 성능을 개선합니다.- 상호 참조는
std::weak_ptr
:shared_ptr
간의 상호 참조가 발생하여 메모리 누수가 예상될 때std::weak_ptr
를 사용하여 문제를 해결합니다. - 원시 포인터 노출 최소화:
get()
을 통해 원시 포인터를 얻는 것은 피할 수 없을 때만 사용하고,delete
를 호출하지 않도록 극도로 주의해야 합니다. - 가능하면
const
사용:const
객체를 가리키는 스마트 포인터를 선언하여 변경 불가능성을 명시하는 것이 좋습니다.
이번 장에서는 현대 C++의 핵심 기능인 스마트 포인터에 대해 상세히 학습했습니다.
전통적인 원시 포인터의 문제점(메모리 누수, 댕글링 포인터, 이중 해제)을 이해하고, 스마트 포인터가 RAII 원칙을 통해 이 문제들을 어떻게 해결하는지 알아보았습니다.
특히,
std::unique_ptr
: 단독 소유권과 효율성으로 가장 우선적으로 고려되는 스마트 포인터std::shared_ptr
: 공유 소유권을 통해 여러 소유자가 하나의 자원을 관리할 수 있게 하는 포인터std::weak_ptr
:shared_ptr
의 상호 참조 문제를 해결하고, 객체의 존재 여부를 안전하게 확인하는 포인터
각 스마트 포인터의 특징, 사용법, 그리고 std::make_unique
및 std::make_shared
의 중요성을 깊이 있게 다루었습니다.
스마트 포인터는 현대 C++에서 안전하고 견고한 프로그램을 작성하는 데 필수적인 도구입니다.
이를 통해 개발자는 복잡한 메모리 관리로부터 자유로워지고, 비즈니스 로직에 더 집중할 수 있게 됩니다.