1. 메모리 개념
1) 스택 메모리
일반 변수들은 대부분 스택 메모리 공간을 차지한다. 스택 메모리는 변수의 생존 주기가 끝나면 선언 시 할당되었ㅎ던 메모리가 자동으로 회수된다. 즉, {} 이렇게 괄호를 닫으면 변수의 생존 주기를 정해주는 것과 같다.
그런데 스택 메모리는 일반적으로 할당 가능한 크기가 제한적이고, 스코프를 벗어나면 자동으로 해제되어 메모리를 유연하게 관리하기가 어렵다. 그래서 사용하는 것이 힙 메모리이다.
2) 힙 메모리
동적 할당 시 new 연산자를 사용, 해제 시 delete 연산자 사용한다.
다만 스택과 달리 자동으로 해제되지 않아 메모리 누수 등의 위험이 있을 수 있고 동적 할당된 객체의 생존 주기는 delete로 해제할 때 까지 유지된다.
3) Dangling Pointer
이미 해제된 메모리의 주소를 가지고 있는 포인터를 사용하는 경우에 이러한 포인터를 Dangling Pointer 라고 한다.
4) 메모리 누수(Memory Leak)
동적으로 할당한 메모리를 사용한 후 제대로 해제하지 않으면, 계속해서 사용하지 않는 메모리가 쌓이게 된다. 성능에 큰 문제가 생기니까 주의하자.
5) 스마트 포인터
C++가 제공하는 기능. 스마트 포인터는 new / delete를 사용하지 않는 자동 메모리 관리를 제공한다.
#include <memory> : 스마트 포인터를 사용할 때 include 한다.
unique_ptr : move를 통해 소유권을 이동하는 식으로 관리되고, 객체에 대한 단일 소유권을 관리한다. [ move() ]
shared_ptr : 레퍼런스 카운트가 0이 되면 객체는 자동으로 메모리 해제가 되어 메모리 누수 및 Dangling Pointer 문제를 효과적으로 방지할 수 있다. 레퍼런스 카운트란 현재 객체를 참조하는 포인터의 개수를 카운팅 하는 것이다. [ use_count(), reset() ]
weak_ptr : 레퍼런스 카운트를 증가시키지 않는 약한 참조를 한다. 객체의 소유권을 공유하지 않는다. [ lock() ]
스마트 포인터를 사용할 때는 복사 혹은 대입이 불가능하다. 소유권의 개념만 있기 때문이다. 복사가 불가능하기 때문에 move를 사용해서 소유권 이전만이 가능하다.
#include <iostream>
#include <memory>
using namespace std;
int main() {
unique_ptr<int> ptr1 = make_unique<int>(10);
cout << "ptr1의 값: " << *ptr1 << endl;
return 0;
}
unique_ptr<int> ptr1 = make_unique<int>(10); 의 방식으로 스마트 포인터를 생성한다.
복사가 불가능하다는 뜻은 unique_ptr<int> ptr2 = ptr1; 이렇게 쓸 수 없다는 뜻이다.
소유권 이전을 하고 싶다면 다음과 같이 사용하면 된다.
int main() {
unique_ptr<int> ptr1 = make_unique<int>(20);
unique_ptr<int> ptr2 = move(ptr1);
if (!ptr1) {
cout << "ptr1은 이제 비어 있습니다." << endl;
}
cout << "ptr2의 값: " << *ptr2 << endl;
return 0;
}
unique_ptr ptr2 = ptr1;
이렇게 안쓰고
unique_ptr<int> ptr2 = move(ptr1); 이렇게 쓰면 소유권이 이전된다. 즉, ptr1값이 ptr2로 이전되어 ptr2를 호출했을 때 값이 출력되게 된다.
shared_ptr은 유용하지만 순환참조가 발생할 수 있다. 두 개 이상의 객체가 서로를 shared_ptr로 가리켜 참조하는 상황을 말한다. 서로 참조하다보니 레퍼런스 카운트가 0이 되지 않아서 메모리 해제가 안된다. 이렇게 되면 장점은 못써먹고 메모리 누수를 유발할 수 있다. 그래서 이런 경우에 shared_ptr중 하나를 weak_ptr로 대체해서 약한 참조를 하는 것이다. 약한 참조를 하면 레퍼런스 카운트가 증가되지 않으니까(객체의 소유권을 공유하지 않으니까) 문제를 해결할 수 있다.
또한, shared_ptr은 하나의 객체를 여러 개의 포인터가 함께 참조할 수 있는 스마트 포인터이다. 내부적으로 레퍼런스 카운터를 관리한다. use_count() 메서드를 활용해서 현재 객체를 참조하는 포인터의 수를 확인할 수 있으며, reset() 메서드로 소유 중인 객체를 해제하거나 다른 객체로 변경할 수 있다.
#include <iostream>
#include <memory>
using namespace std;
class MyClass {
public:
MyClass(int val) : value(val) {
cout << "MyClass 생성: " << value << endl; // 출력: MyClass 생성:
}
~MyClass() {
cout << "MyClass 소멸: " << value << endl; // 출력: MyClass 소멸:
}
void display() const {
cout << "값: " << value << endl; // 출력: 값: 42
}
private:
int value;
};
int main() {
// shared_ptr로 MyClass 객체 관리
shared_ptr<MyClass> obj1 = make_shared<MyClass>(42);
// 참조 공유
shared_ptr<MyClass> obj2 = obj1;
cout << "obj1과 obj2의 참조 카운트: " << obj1.use_count() << endl; //
obj2->display(); // 출력: 값: 42
// obj2를 해제해도 obj1이 객체를 유지
obj2.reset();
cout << "obj2 해제 후 obj1의 참조 카운트: " << obj1.use_count() << end
return 0;
}
이렇게 MyClass 클래스를 생성한 뒤에 main() 함수에서 shared_ptr로 MyClass 객체를 참조해서 관리할 수 있다.
shared_ptr<MyClass> obj1 = make_shared<MyClass>(42); 형식으로 사용한다.
이후 obj2를 obj2.reset(); 을 사용해서 해제해도 obj1은 여전히 MyClass 객체를 참조하고 있는 것을 확인할 수 있었다.
weak_ptr은 레퍼런스 카운트를 증가시키지 않는 약한 참조방식이다. 반드시 lock() 호출 후 반환된 shared_ptr이 유효한지 확인 후에 사용해야 한다.
#include <iostream>
#include <memory>
class A {
public:
void say_hello() {
std::cout << "Hello from A\n";
}
};
class B {
public:
std::weak_ptr<A> a_ptr;
void useA() {
if (auto a_shared = a_ptr.lock()) { // 유효한지 확인
a_shared->say_hello();
} else {
std::cout << "A is no longer available.\n";
}
}
};
int main() {
std::shared_ptr<B> b = std::make_shared<B>();
{
std::shared_ptr<A> a = std::make_shared<A>();
b->a_ptr = a;
b->useA(); // A가 유효하므로 Hello 출력
} // A는 scope을 벗어나며 소멸됨
b->useA(); // A는 이미 소멸되었기 때문에 메시지 출력
}
lock() 함수 내부 객체 유효성을 확인하고 사용하는 것이다.
결과적으로 shared_ptr과 weak_ptr을 같이 사용해서 순환참조를 해결하면서 스마트 포인터를 사용해서 메모리 관리를 할 수 있다.
#include <iostream>
#include <memory>
class B; // Forward declaration
class A {
public:
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyed\n"; }
};
class B {
public:
std::weak_ptr<A> a_ptr; // weak_ptr로 변경
~B() { std::cout << "B destroyed\n"; }
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a; // weak_ptr로 참조
return 0;
}
2. 얕은 복사와 깊은 복사
일반적으로 포인터나 동적으로 할당된 자원을 관리하는 객체는 메모리 안정성을 위해 깊은 복사를 사용하는 것이 바람직하다.
얇은 복사란, 클래스 내의 포인터 멤버를 복사할 때 포인터가 가리키는 데이터가 아닌 포인터가 저장하고 있는 주소값만 복사하는 것을 의미한다. 예를 들어,
int main() {
// 포인터 A가 동적 메모리를 할당하고 값을 30으로 설정
int* A = new int(30);
// 포인터 B가 A가 가리키는 메모리를 공유
int* B = A;
이런식으로 주소값만 복사하는 경우에는 delete A;를 했을 때 B는 Dangling Pointer가 된다.
깊은 복사란, 클래스의 포인터 멤버가 가리키는 동적 데이터를 새로 할당된 독립적인 메모리 영역에 복제하는 것을 의미한다.
이렇게 하면 원본과 복사된 객체가 서로 독립적인 메모리 공간을 소유해서 dangling pointer가 발생할 염려가 없다.
int main() {
// 포인터 A가 동적 메모리를 할당하고 값을 30으로 설정
int* A = new int(30);
// 포인터 B가 A가 가리키는 값을 복사 (깊은 복사)
int* B = new int(*A);
여기에서는 new int(*A); 를 통해 새로 메모리를 할당해서 A가 가리키는 값을 복사했다. 나중에 delete A;를 해도 B는 여전히 독립적으로 자신의 메모리를 관리하기 때문에 dangling pointer가 발생하지 않는다.
이러한 자원 관리, 즉 메모리 관리 개념을 잘 숙지해놓고 코드 작성시 많이 유의해야 할 것 같다. 차후에는 언리얼 엔진의 메모리 관리 프로세스를 이해하는 과정도 필요하겠다.
C++언어를 활용해서 Text RPG를 만드는 실습을 진행중인데, 그냥 부딪히면서 배우는 게 확실히 체감도 잘되고 그만큼 흡수하는 속도도 빨라지는 것 같다. 다음에는 템플릿과 함수오버로딩에 대해 학습하고 실제 적용까지 해볼 예정이다.
'게임 프로그래밍 공부 > 게임 개발을 위한 C++' 카테고리의 다른 글
| [C++학습] 7. STL (0) | 2026.05.07 |
|---|---|
| [C++학습] 6. 함수 오버로딩, 템플릿 (0) | 2026.05.06 |
| [C++학습] 4. 객체지향 프로그래밍, 객체지향적 설계 (0) | 2026.05.04 |
| [C++학습] 3. Class 개념, 객체지향 프로그래밍 (0) | 2026.05.02 |
| [C++학습] 2. 포인터와 레퍼런스~클래스 (0) | 2026.05.01 |
