메모리
메모리는 한정된 자원이므로 관리를 해야한다.
스택 메모리
일반 함수의 인자, 변수들은 대부분 스택 메모리 공간을 차지한다.
스택 메모리의 특징
컴파일 시 메모리가 할당된다.(실행 전에 크기가 정해져야 함.)
변수의 생존 주기가 끝나면 선언 시 할당되었던 메모리가 자동으로 회수된다. 따라서 사용자가 직접 메모리를 해제할 필요가 없다.
변수의 생존 주기를 벗어나면 자동으로 해제되므로 메모리를 더 길거나 유연하게 관리하기 어렵다.(장점이자 단점)
일반적으로 할당 가능한 스택 메모리의 크기가 제한적이다.
변수의 생존 주기란 선언된 라인을 기준으로 가장 가까운 마침 괄호 "}"이다.

힙 메모리(동적 메모리)
메모리의 전체 크기가 힙 메모리 > 스택 메모리 이다. 즉, 힙에서는 되는데 스택에서는 안되는 경우가 있다.
힙 메모리의 특징
런타임중 메모리를 동적으로 할당할 수 있다.(실행 중 크기를 정해줄 수 있음)
동적 할당 시 new 연산자를 사용하고, 해제 시 delete 연산자를 사용한다.
스택과 달리 자동으로 해제되지 않으므로 메모리 누수 등의 위험이 있다.(Memory Leak 메모리 누수)
동적 할당된 객체(또는 변수)의 생존 주기는 사용자가 delete로 해제할 때 까지 유지된다.
new 연산자는 주소값을 반환하기 때문에 포인터로 받아야 한다.
// 변수 하나의 영역에 대한 동적 할당
#include <iostream>
using namespace std;
void func1() {
int* ptr = new int(10); // 힙 메모리에 정수 10 할당 new 연산자 사용
cout << "Value: " << *ptr << endl;
delete ptr; // 메모리 해제
}
int main() {
func1();
return 0;
}
// 배열의 동적 할당과 해제
#include <iostream>
using namespace std;
void func2() {
int* arr = new int[5]; // 힙 메모리에 정수 배열 5개 할당
for (int i = 0; i < 5; ++i) {
arr[i] = i * 10;
cout << "arr[" << i << "] = " << arr[i] << endl;
}
delete[] arr; // 배열 메모리 해제
}
int main() {
func2();
return 0;
}
배열을 동적 할당하고 해제할 땐 new와 delete에 [ ] 를 사용해줘야 한다.
// 사용자 입력 기반으로 배열 크기를 동적 할당
#include <iostream>
using namespace std;
void createDynamicArray()
{
int size;
cout << "Enter the size of the array: ";
cin >> size; // 배열 크기를 사용자로부터 입력받음
if (size > 0)
{
int* arr = new int[size]; // 입력받은 크기만큼 동적 배열 생성
for (int i = 0; i < size; ++i)
{
arr[i] = i * 2; // 배열 초기화
cout << "arr[" << i << "] = " << arr[i] << endl;
}
delete[] arr; // 동적으로 할당한 배열 메모리 해제
}
else
{
cout << "Invalid size!" << endl;
}
}
int main()
{
createDynamicArray();
return 0;
}
사용자의 입력을 받아 배열의 크기를 정할 수 있다.
댕글링 포인터(Dangling Pointer)
포인터는 메모리가 해제되었는지 여부를 자동으로 알 수 없다.
따라서 이미 해제된 메모리의 주소를 계속 가지고 있는 포인터를 댕글링 포인터라고한다.

#include <iostream>
using namespace std;
void func5() {
int* ptr = new int(40); // 힙 메모리에 정수 40 할당
int* ptr2 = ptr; // ptr2에 ptr 주소값 대입
cout << "ptr adress = " << ptr << endl; //ptr의 주소값 출력
cout << "ptr2 adress = " << ptr2 << endl; //ptr2의 주소값 출력
cout << *ptr << endl; //ptr의 값 출력
delete ptr; // ptr만 해제
cout << *ptr2 << endl; // ptr 해제 후 ptr2의 값 출력
}
int main() {
func5();
return 0;
}

ptr을 해제 후 ptr2의 값을 출력했더니 쓰레기 값이 나왔다.
// delete를 중복으로 할 경우
#include <iostream>
using namespace std;
void func4() {
int* ptr = new int(30); // 힙 메모리에 정수 30 할당
cout << "Value: " << *ptr << endl;
delete ptr; // 첫 번째 해제
// delete ptr; // 두 번째 해제 (코드 활성화 시 에러 발생)
}
int main() {
func4();
return 0;
}
스마트 포인터
힙 메모리는 여러 가지 장점을 제공하지만 메모리를 직접 관리해야 한다는 부담이 있다.
댕글링 포인터가 발생하지 않도록 자동으로 관리해 주기 위해 C++에서는 스마트 포인터를 제공한다.
스마트 포인터의 핵심 원리는 new / delete를 사용하지 않는 자동 메모리 관리 이다.
1. unique_ptr은 객체에 대한 단일 소유권을 관리한다.
객체의 소유권을 명확히 하고 소유권 이전을 통해 효율적인 자원 관리가 가능하다.
아래 그림처럼 move를 통해 소유권을 이동하는 식으로 관리된다.
소유권을 이동할 순 있는데 두 개의 포인터가 동시에 하나를 소유할 수 없다.

2. shared_ptr은 레퍼런스 카운터를 관리한다.
레퍼런스 카운트란 현재 객체를 참조하는 포인터의 개수를 카운팅 하는 것이다.
레퍼런스 카운트가 0이 되면 객체는 자동으로 메모리 해제가 된다.

3. weak_ptr은 객체의 소유권을 공유하지 않는다.
다른 스마트 포인터와 다르게 레퍼런스 카운트를 증가시키지 않는 약한 참조를 한다.
shared_ptr은 유용하지만 순환참조가 발생할 수 있다.
순환 참조란, 두 개 이상의 객체가 서로를 shared_ptr로 가리켜 참조하는 상황을 말한다.
순환 참조는 메모리 누수를 유발할 수 있다.
서로 순환하고 있는 shared_ptr중 하나를 weak_ptr로 대체하면 순환 고리가 끊어지게 되므로 문제를 해결할 수 있다.
shared_ptr은 관찰과 소유를 하는 반면, weak_ptr은 관찰만 한다고 표현 한다.

unique_ptr
객체 소유권을 관리하는 스마트 포인터입니다. 즉, 단 하나의 포인터만 객체를 소유할 수 있습니다.
소유권의 개념만 있기 때문에 복사 혹은 대입이 불가능합니다. 이걸 시도하는 순간 컴파일 에러가 발생합니다.
// 기본적인 유니크포인터 사용법
#include <iostream>
#include <memory> // unique_ptr 사용하기 위해 포함
using namespace std;
int main()
{
// unique_ptr 생성
unique_ptr<int> ptr1 = make_unique<int>(10); // new가 아닌 make
// unique_ptr이 관리하는 값 출력
cout << "ptr1의 값: " << *ptr1 << endl;
// unique_ptr은 복사가 불가능
// unique_ptr<int> ptr2 = ptr1; // 컴파일 에러 발생!
// 범위를 벗어나면 메모리 자동 해제
return 0;
}
복사가 불가능하며 move를 사용해서 소유권 이전만 가능하다.
//move로 유니크포인터 소유권 이전
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// unique_ptr 생성
unique_ptr<int> ptr1 = make_unique<int>(20);
// 소유권 이동 (move 사용)
unique_ptr<int> ptr2 = move(ptr1);
if (!ptr1)
{
cout << "ptr1은 이제 비어 있습니다." << endl;
}
cout << "ptr2의 값: " << *ptr2 << endl;
return 0;
}

// 일반 클래스에서 유니크포인터 사용하는 방법
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
public:
MyClass(int val) : value(val) // 객체가 생성될 때 호출되는 생성자
{
cout << "MyClass 생성: " << value << endl;
}
~MyClass() // 객체가 소멸될 때 호출되는 소멸자
{
cout << "MyClass 소멸: " << value << endl;
}
void display() const
{
cout << "값: " << value << endl;
}
private:
int value;
};
int main()
{
// unique_ptr로 MyClass 객체 관리
unique_ptr<MyClass> myObject = make_unique<MyClass>(42);
// MyClass 멤버 함수 호출
myObject->display();
// 소유권 이동
unique_ptr<MyClass> newOwner = move(myObject);
if (!myObject) // myObject가 비어있다면 출력
{
cout << "myObject는 이제 비어 있습니다." << endl;
}
// 소유권 이전된 포인터로 멤버 함수 호출
newOwner->display();
// 범위를 벗어나면 newOwner가 관리하는 메모리 자동 해제
return 0;
}

shared_ptr
하나의 객체를 여러 개의 포인터가 함께 참조할 수 있는 스마트 포인터 이다. 내부적으로 레퍼런스 카운터 관리를 한다.
use_count() 메서드 활용하여 현재 객체를 참조하는 포인터의 수를 확인할 수 있으며 reset() 메서드로 소유 중인 객체를 해제하거나 다른 객체로 변경할 수 있다.
// 기본적은 쉐어드포인터 사용법
#include <iostream>
#include <memory> // shared_ptr 사용하기 위해 포함
using namespace std;
int main()
{
// shared_ptr 생성
shared_ptr<int> ptr1 = make_shared<int>(10);
// ptr1의 참조 카운트 출력
cout << "ptr1의 참조 카운트: " << ptr1.use_count() << endl; // 출력: 1
// ptr2가 ptr1과 리소스를 공유
shared_ptr<int> ptr2 = ptr1;
cout << "ptr2 생성 후 참조 카운트: " << ptr1.use_count() << endl; // 출력: 2
// ptr2가 범위를 벗어나면 참조 카운트 감소
ptr2.reset();
cout << "ptr2 해제 후 참조 카운트: " << ptr1.use_count() << endl; // 출력: 1
// 범위를 벗어나면 ptr1도 자동 해제
return 0;
}
// 일반 클래스에서 쉐어드포인터 사용법
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
public:
MyClass(int val) : value(val)
{
cout << "MyClass 생성: " << value << endl; // 출력: MyClass 생성: 42
}
~MyClass()
{
cout << "MyClass 소멸: " << value << endl; // 출력: MyClass 소멸: 42
}
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; // 출력: 2
obj2->display(); // 출력: 값: 42
// obj2를 해제해도 obj1이 객체를 유지
obj2.reset();
cout << "obj2 해제 후 obj1의 참조 카운트: " << obj1.use_count() << endl; // 출력: 1
return 0;
}

weak_ptr
레퍼런스 카운트를 증가시키지 않는 약한 참조방식으로 동작하는 스마트 포인터 이다. 따라서 lock() 호출 후 반환된 shared_ptr이 유효한지 확인 후에 사용해야 한다.
// lock() 함수로 유효성을 확인하고 weak_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; // A클래스 weak_ptr로 참조, 변수명은 a_ptr
void useA()
{
if (auto a_shared = a_ptr.lock()) // a_ptr.lock()의 반환값을 a_shared가 받음. {
a_shared->say_hello(); // a_shared가 null이 아니면 say_hello() 호출
}
else
{
std::cout << "A is no longer available.\n";
}
}
};
int main() {
std::shared_ptr<B> b = std::make_shared<B>(); // B클래스 shared_ptr로 참조 변수명 b
{
std::shared_ptr<A> a = std::make_shared<A>(); // A클래스 shared_ptr로 참조 변수명 a
b->a_ptr = a; // b클래스의 weak_ptr에 shared_ptr를 넣어서 유효하게 만듬?
b->useA(); // A가 유효하므로 Hello 출력
} // A는 scope을 벗어나며 소멸됨
b->useA(); // A는 이미 소멸되었기 때문에 메시지 출력
}
shared_ptr은 순환 참조 문제가 발생할 수 있는데 발생시 순환 고리중 하나를 week_ptr로 변경하면 이 문제를 해결할 수 있다.
//shared_ptr을 사용해서 순환참조가 발생하는 예시
#include <iostream>
#include <memory>
class B; // Forward declaration
class A
{
public:
std::shared_ptr<B> b_ptr; // 클래스 A에는 B타입 쉐어드포인터가 있음
~A() { std::cout << "A destroyed\n"; }
};
class B
{
public:
std::shared_ptr<A> a_ptr; // 클래스 B에는 A타입 쉐어드포인터가 있음
~B() { std::cout << "B destroyed\n"; }
};
int main()
{
auto a = std::make_shared<A>(); // a에 A타입 쉐어드포인터
auto b = std::make_shared<B>(); // b에 B타입 쉐어드포인터
a->b_ptr = b; // 클래스 A의 b_ptr에는 클래스 B 를 소유
b->a_ptr = a; // 클래스 B의 a_ptr에는 클래스 A 를 소유
// main 함수가 끝나도 A와 B는 서로 참조 중이라 메모리 해제가 안 됨
return 0;
}

위 코드는 스마트 포인터면 소멸 시 소멸자가 호출되어 문구가 출력되어야 하는데 현재 서로 순환 참조 중이라 메모리 해제가 되지 않아서 소멸자가 호출되지 않는 상황이다. 메모리 해제가 되지 않는다면 메모리 누수가 발생하고 있다는 것.
이를 shared_ptr중 하나를 weak_ptr로 변경하여 해결할 수 있다.
//shared_ptr중 하나를 weak_ptr로 변경하여 순환참조를 해결
#include <iostream>
#include <memory>
class B; // Forward declaration
class A
{
public:
std::shared_ptr<B> b_ptr; // 클래스 A에는 B타입 쉐어드포인터가 있음
~A() { std::cout << "A destroyed\n"; }
};
class B
{
public:
std::weak_ptr<A> a_ptr; // 클래스 B에는 A타입 쉐어드포인터가 있음
~B() { std::cout << "B destroyed\n"; }
};
int main()
{
auto a = std::make_shared<A>(); // a에 A타입 쉐어드포인터
auto b = std::make_shared<B>(); // b에 B타입 쉐어드포인터
a->b_ptr = b; // 클래스 A의 b_ptr에는 클래스 B 를 소유
b->a_ptr = a; // 클래스 B의 a_ptr에는 클래스 A 를 소유
// main 함수가 끝나도 A와 B는 서로 참조 중이라 메모리 해제가 안 됨
return 0;
}

얕은 복사와 깊은 복사
일반적으로 포인터나 동적으로 할당된 자원을 관리하는 객체는 메모리 안정성을 위해 깊은 복사를 사용하는 것이 바람직하다.
안전한 메모리 관리를 위해 깊은 복사를 사용하는 편이 좋은데 어떠한 차이가 있는지 알아보자.
얕은복사
얕은 복사란 클래스 내의 포인터 멤버를 복사할 때 포인터가 가리키는 데이터가 아닌 포인터가 저장하고 있는 주소값만 복사하는 것을 의미한다.
두 객체가 동일한 동적 메모리 영역을 가리키게 되어 원본 객체가 메모리를 해제하면 복사된 객체의 포인터는 해제된 메모리 영역을 가리키게 된다. 즉 댕글링 포인터가 발생 할 위험이 있다.

// 얕은 복사
#include <iostream>
using namespace std;
int main() {
// 포인터 A가 동적 메모리를 할당하고 값을 30으로 설정
int* A = new int(30);
// 포인터 B가 A가 가리키는 메모리를 공유
int* B = A;
cout << "A의 값: " << *A << endl; // 출력: 30
cout << "B의 값: " << *B << endl; // 출력: 30
// A가 동적 메모리를 해제
delete A;
// 이제 B는 Dangling Pointer(해제된 메모리를 가리키는 포인터)
// 이 시점에서 B를 통해 접근하면 Undefined Behavior 발생
cout << "B의 값 (dangling): " << *B << endl; // 위험: 정의되지 않은 동작
return 0;
}

깊은 복사
클래스의 포인터 멤버가 가리키는 동적 데이터를 새로 할당된 독립적인 메모리 영역에 복제하는 것을 의미한다.
원본 객체와 복사된 객체는 서로 독립적인 메모리 공간을 소유하므로 댕글링 포인터가 발생하지 않는다.

// 깊은 복사
#include <iostream>
using namespace std;
int main() {
// 포인터 A가 동적 메모리를 할당하고 값을 30으로 설정
int* A = new int(30);
// 포인터 B가 A가 가리키는 값을 복사 (깊은 복사)
int* B = new int(*A);
cout << "A의 값: " << *A << endl; // 출력: 30
cout << "B의 값: " << *B << endl; // 출력: 30
// A가 동적 메모리를 해제
delete A;
// B는 여전히 독립적으로 자신의 메모리를 관리
cout << "B의 값 (깊은 복사 후): " << *B << endl; // 출력: 30
// B의 메모리도 해제
delete B;
return 0;
}

언리얼엔진의 메모리 관리
언리얼 엔진은 객체들의 메모리 관리를 자동화하기 위해 가비지 컬렉션 시스템을 사용합니다.
1. 가비지 컬렉션은 마크 앤 스윕 알고리즘 방식으로 동작합니다.
이 알고리즘은 주기적으로 실행되며, 더 이상 프로그램에서 사용하지 않는다고 판단되는 UObject 들을 식별하여 메모리에서 제거를 합니다.
청소부가 집안(메모리)을 돌아다니며 필요한 물건에는 표시를 하고, 표시되지 않은 물건은 쓰레기로 간주하여 치우는 방식이라고 생각하면 쉽습니다.
아래와 같이 3단계로 진행이 된다.
- 루트셋에서 시작 :
- 먼저 루트셋에 포함된 객체들을 식별합니다. 이 객체들은 항상 살아있다고 간주되는 특별한 객체입니다.
- 예를 들어, 게임 엔진 자체, 플레이어 컨트롤러 등이 루트셋에 포함될 수 있습니다. 이는 가비지 컬렉션 대상이 아닙니다. 청소 시, 절대 버려서는 안되는 물건으로 비유할 수 있습니다.
- 마크 단계 - 도달 가능성 분석
- 루트셋 객체에서 시작해서 직간접적으로 참조하는 UObject를 마크 합니다. 이는 객체가 사용중임을 나타냅니다.
- 스윕 단계 - 메모리 회수
- 마크 단계가 완료되면 마크되지 않은 객체들이 차지하고 있던 메모리를 회수합니다. 이 과정에서 해당 객체의 소멸자가 호출되고 메모리가 반환됩니다.

2. UObject에는 가비지 컬렉션 동작 방식을 제어하는 다양한 플래그가 존재합니다.
이 플래그들은 가비지 컬렉션의 동작에 중요한 정보를 제공하며, GUObjectArray라는 전역 배열에 저장된 각 객체 정보의 일부로 관리됩니다.
- RF_RootSet
- 이 플래그가 설정된 객체는 루트셋의 일부로 관리됩니다. 즉 설정된 시점부터 가비지 컬렉션 대상이 아닙니다. AddToRoot() 함수를 통해 설정하고, RemoveFromRoot()함수를 통해 해제할 수 있습니다.
- RF_BeginDestroyed
- 객체의 BeginDestroy() 함수가 호출되었음을 나타냅니다.
- 해당 함수는 객체가 실제로 메모리에서 해제되기 전에 필요한 정리 작업을 수행하는 함수입니다.
- RF_FinishDestroyed
- 객체의 FinishDestoy() 함수가 호출되었음을 나타냅니다.
- 해당 함수는 객체 소멸의 마지막 단계로, 이 함수 호출 후 객체의 메모리가 완전히 해제됩니다.
=> 언리얼엔진의 C++에는 가비지 컬렉션이라는 기능을 사용하여 메모리 관리를 쉽게 할 수 있다.
언리얼엔진의 리플렉션 시스템
리플렉션은 UObject를 위한 운영체제와 같습니다.
언리얼 엔진 내부에서 동작하는 여러 모듈(가비지 컬렉터, 스크립트 시스템) 등은 모두 UObject 기반입니다.
하지만 사용자가 정의한 타입들의 경우 엔진에서 알지 못하므로, 이를 처리할 수 있도록 타입 정보를 공유해야 합니다.
이를 위한 작업이 리플렉션입니다.
리플렉션의 핵심은 UHT 코드 생성기입니다.
UHT는 C++ 컴파일러가 수행되기 되기 전에 동작합니다.
C++ 코드 내에서 메타 데이터를 얻고, 내부적으로 소스 코드를 생성합니다.
이 동작이 완료된 이후에 C++ 컴파일러가 수행됩니다.



=> 리플렉션 이라는 기능을 통해 C++에서 사용자가 정의한 객체를 언리얼 모듈에서 동일하게 동작하게 해서 자원 관리가 용이하게 하고 기존에 제공했던 기능을 활용할 수 있게 한다.
'C++ 공부' 카테고리의 다른 글
| C++ 공부 8일차(STL, 컨테이너, 벡터, 맵, 알고리즘, 반복자) (0) | 2025.08.20 |
|---|---|
| C++ 공부 7일차(함수 오버로딩, 템플릿) (0) | 2025.08.19 |
| C++ 4일차(상속, protected, 멤버 초기화 리스트, 다형성, 가상 함수(virtual), 순수 가상 함수) (0) | 2025.08.14 |
| C++ 공부 3일차(Class, 접근제어, getter, setter, 생성자, 헤더 파일, 소스 파일) (0) | 2025.08.13 |
| C++ 공부 3일차(포인터, 레퍼런스) (0) | 2025.08.13 |