언리얼 엔진/개념 정리

개념 정리 - C++ (메모리 관리)

journey46110 2026. 7. 22. 19:27

I. C++의 메모리 관리

1) 메모리의 종류

프로그램이 사용하는 메모리는 크게 스택(Stack)힙(Heap) 으로 나누어짐

구분 스택 (Stack) 힙 (Heap)
관리 컴파일러가 자동으로 관리해줌 프로그래머가 직접 관리해야 함
속도 빠름 느림
크기 작음
생명주기 블록({}) 종료 시 자동 해제 delete 할 때까지 유지

 

 

2) 스택 (Stack)

① 함수가 실행될 때 메모리의 모습

void Func()
{
    int a = 10;
}


Stack
┌────────┐
│ a = 10 │
└────────┘

 

② 함수가 종료될 때(블록{}을 벗어날 때) 메모리의 모습

Stack
(비어 있음)

함수가 끝나는 순간 자동으로 메모리에서 사라짐

 

③ 특징

장점 - 빠름, 자동으로 관리됨, 메모리 누수가 없음

단점 - 크기가 작음, 함수가 끝나면 사라짐

 

 

3) 힙 (Heap)

직접 메모리를 빌리는 공간

Player* player = new Warrior(...);

Stack
player
 │
 ▼
Heap
Warrior 객체

함수가 종료되어도 객체가 살아있음

delete player;

자동으로 없어지지 않고, 직접 delete를 해줘야 함

 

힙을 사용하는 이유

① 크기를 실행 중에 결정할 수 있음

cin >> size;
int* arr = new int[size];

사용자가 입력한 크기만큼 배열을 만들 수 있음

표준 C++에서는 int arr[size]; 불가능

 

② 함수가 끝나도 객체를 살아있게 할 수 있음

Player* CreatePlayer()
{
    return new Warrior(...);
}

함수가 끝나도 Warrior 객체가 살아있음

 

③ 다형성

Player* player1 = new Warrior();
Player* player2 = new Magician();
Player* player3 = new Archer();

가상 함수를 이용해 override를 할 때 힙을 자주 이용함

 

 

4) 메모리 누수 (Memory Leak)

Player* player = new Warrior(...);

//delete player;

delete를 안 했기에 메모리에 객체가 계속 남아 있음

객체가 계속 쌓이면 메모리가 부족해짐

 

 

5) Dangling Pointer

메모리를 지웠는데, 포인터가 그대로 남아 있는 경우

Player* player = new Warrior();
delete player;

player->attack();

없어진 메모리를 가리키는 경우

delete player;
player = nullptr;

보통 이렇게 같이 씀

 

 

6) Double Delete

이미 지운 메모리를 또 지우는 경우

delete player;
delete player;

이 경우도 위험함

 

 

7) 스마트 포인터

힙을 쓸 때 직접 new와 delete를 쓰면 실수하기 쉬움

스마트 포인터는 자동으로 delete를 해줌

 

① unique_ptr

메모리를 소유하는 소유자가 딱 한 개

객체 하나를 관리할 때 자주 사용함

unique_ptr
      │
      ▼
    Warrior

unique_ptr<Player> p1 = make_unique<Warrior>();
unique_ptr<Player> p2 = p1; //복사 안 됨, 컴파일 에러 발생

 

move를 사용해서 소유권 이전만 가능, 이때 소유권이 없어진 쪽은 nullptr이 됨

unique_ptr<Player> p2 = move(p1);

p1
nullptr

p2
 │
 ▼
Warrior

 

② shared_ptr

여러 포인터가 한 메모리를 같이 소유함

P1
 \
  \
   Warrior
  /
 /
P2

shared_ptr<Player> p1 = make_shared<Warrior>();
cout << "p1의 레퍼런스 카운트: " << p1.use_count() << endl; //출력 1

shared_ptr<Player> p2 = p1;
cout << "p2 생성 후 레퍼런스 카운트: " << p1.use_count() << endl; //출력 2

p2.reset();
cout << "p2 해제 후 레퍼런스 카운트: " << p1.use_count() << endl; //출력 1

return 0;
}

내부적으로 Reference Count를 가짐

reset을 이용해 소유권을 해제할 수 있음

 

③ weak_ptr

서로를 shared_ptr로 참조하는 경우 레퍼런스 카운트가 0이 되지 않음, 이를 순환참조라고 함

A
↓
B
↑
A

이때, 한쪽을 weak_ptr으로 바꾸면 순환참조가 풀림

weak_ptr은 레퍼런스 카운트에 영향을 미치지 않아서, lock으로 유효성을 검사해야 함

#include <iostream>
#include <memory>
using namespace std;

class Monster {
public:
    void roar() {
        cout << "슬라임이 울부짖습니다!" << endl;
    }
};

class Stage {
public:
    weak_ptr<Monster> monster;

    void checkMonster() {
        if (auto currentMonster = monster.lock()) {
            currentMonster->roar();
        }
        else {
            cout << "몬스터가 이미 사라졌습니다." << endl;
        }
    }
};

int main() {
    shared_ptr<Stage> stage = make_shared<Stage>();

    {
        shared_ptr<Monster> slime = make_shared<Monster>();

        stage->monster = slime;

        stage->checkMonster();   // 몬스터 존재
    }

    // slime 소멸

    stage->checkMonster();       // 몬스터 없음
}

 

해설

shared_ptr<Monster> slime = make_shared<Monster>();
slime(shared_ptr)
      │
      ▼
   Monster
   
stage->monster = slime;
slime(shared_ptr)
      │
      ▼
   Monster
      ▲
      │
monster(weak_ptr)

auto currentMonster = monster.lock();
slime(shared_ptr)
      │
      ▼
   Monster
      ▲
      │
currentMonster(shared_ptr)

중괄호를 벗어나서 slime이 해제될 때, monster(weak_ptr)도 같이 해제됨

 

 

8) 얕은 복사와 깊은 복사

① 얕은 복사

포인터를 복사할 때, 값이 아니라 주소만 복사하는 것

int* a = new int(10);
int* b = a;

delete a;
cout << *b; // b -> dangling pointer

둘 다 같은 메모리를 가리킴
하나를 삭제하면, 나머지 하나는 dangling pointer가 됨

 

② 깊은 복사

포인터가 가리키는 메모리의 값을 새로운 메모리에 복제하는 것

int* a = new int(10);
int* b = new int(*a);

delete a;
cout << *b; //아무 문제 없음

 

 

II. 언리얼 엔진의 메모리 관리

1) 가비지 컬렉션 (Garbage Collection)

언리얼 엔진 내부에서는 new와 delete를 직접 쓰지 않음
대신에 Garbage Collection을 주로 사용함


작동 방식 - Mark & Sweep 알고리즘

주기적으로 실행됨

더 이상 프로그램에서 사용하지 않는다고 판단되는 객체를 메모리에서 제거함

 

① 루트셋 (Root Set)

루트셋에 포함된 객체를 식별

플레이 중에 항상 존재해야되는 객체를 포함함

 

② Mark 단계

루트셋의 객체에서 직간접적으로 참조할 수 있는 객체에 표시를 함

 

③ Sweep 단계

Mark 단계에서 표시되지 않은 객체를 메모리에서 제거함

 

 

2) 리플렉션 시스템

언리얼 엔진은 클래스 내부에 무엇이 들어있는 지 런타임 동안 모름

리플렉션 매크로를 사용하면 컴파일러의 수행 전에 사용자가 정의한 타입을 언리얼 엔진이 알게 해줌

 

자주 사용하는 매크로

매크로 역할
UCLASS() 클래스 등록
USTRUCT() 구조체 등록
UPROPERTY() 멤버 변수 등록
UFUNCTION() 함수 등록
GENERATED_BODY() 리플렉션 코드 삽입

 

예시

UCLASS(Blueprintable, BlueprintType)
class YOURPROJECT_API UPlayerData : public UObject
{
    GENERATED_BODY()

public:
    UPlayerData();

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Player")
    int32 Level = 1;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Player")
    float HP = 100.f;

    UFUNCTION(BlueprintCallable, Category = "Player")
    void LevelUp();
};