메모리 구조와 Unreal의 GC

mingu Lee·2025년 11월 19일

CS

목록 보기
3/21

1. 메모리 구조


우리가 MyGame.exe를 실행하면, 운영체제는 이 프로그램을 위해 프로세스라는 독립된 실행 환경을 구축한다.

OS는 이 프로세스에게 가상 메모리를 할당하는데, 이 가상 메모리는 크게 4개의 영역으로 나뉜다.

1-1. Code (Text) 영역


우리가 작성한 C++ 코드가 컴파일되어 CPU가 직접 읽을 수 있는 기계어로 번역된 데이터가 저장된다.

프로그램 시작 시 로드되어, 프로그램이 종료될 때까지 유지된다.

핵심 특징


Code 영역의 핵심 특징은 읽기 전용/실행 전용이라는 것이다.

CPU는 이곳의 명령어를 읽고 실행할 수만 있다.

만약 실행 중에 프로그램이 이 COde 영역의 내용을 수정하려 시도하면 OS는 이를 심각한 보안 위협으로 간주하고 프로세스를 즉시 강제 종료시킨다.

또한, 프로그램이 실행되는 도중에 이 영역의 크기나 내용이 변하지 않는다.

1-2. Static (Data/BSS) 영역


전역 변수와 정적 변수가 저장되는 영역이며, 프로세스의 수명과 동일하다.

핵심 특징


Static 영역은 두 부분으로 나뉜다.

1. Data 영역

  • 초기값이 0이 아닌 전역/정적 변수가 저장
  • .exe 파일 자체에 이 초기값이 저장되어 있다가 프로그램 로드 시 메모리에 그대로 복사

2. BSS 영역

  • 초기값이 없거나 0으로 초기화된 전역/정적 변수가 저장
  • .exe 파일에는 '이만큼의 공간이 필요하다'는 정보만 기록 (파일 크기 절약)
  • 프로그램 로드 시 OS가 이 영역 전체를 0으로 자동 초기화

전역 변수는 모든 곳에서 접근 가능해 편리하지만, 데이터가 언제 어디서 변경되었는지 추적하기가 극도로 어려워져 디버깅이 힘들어진다.

Unreal에서는 원시 C++ 전역 변수 대신 UGameInstanceUGameSingleton을 사용하도록 강력히 권장한다.

이 객체들은 힙(Heap)에 생성되며, 게임의 수명 내내 존재하고 전역적인 데이터 저장소 역할을 한다.

// 1. Data 영역 (초기값이 0이 아님)
int G_TotalScore = 100;
const char* G_GameName = "MyUnrealGame"; // (문자열 리터럴도 특수 영역에 저장됨)

// 2. BSS 영역 (초기값 없거나 0 -> OS가 0으로 채워줌)
bool G_IsGameRunning; // = 0 (false)
float G_DamageModifiers[100]; // 모든 요소가 0.0f로 초기화됨

void MyFunction()
{
    // 이 변수는 스택이 아닌 Static 영역에 저장.
    // 이 코드는 프로그램 실행 중 최초 1회만 실행.
    static int FunctionCallCount = 0; 
}

1-3. Stack 영역


함수 호출 시 생성되는 지역 변수, 함수에 전달되는 매개 변수, 함수가 끝나고 돌아갈 복귀 주소가 저장된다.

함수가 시작될 때 생성되고, 함수가 return 할 때 즉시 사라진다.

핵심 특징


1. LIFO

  • 마지막에 들어온 것이 가장 먼저 나감
  • main()A()를 호출하고, A()B()를 호출하면, 스택에는 main->A->B 순서로 스택 프레임이 쌓임
  • 종료는 역순

2. 매우 빠름

  • 메모리를 할당하고 해제하는 과정이 단순히 스택 포인터가 레지스터의 주소값을 더하고 빼는 단일 CPU 명령어로 이루어짐
  • 힙처럼 어디에 공간이 있는지 검색할 필요가 전혀 없음

3. Stack Overflow

  • 스택 영역은 크기가 상대적으로 작음
  • 재귀 호출이 너무 깊어지거나 스택에 너무 큰 배열을 선언하면, 할당된 스택 영역을 초과하여 Stack Overflow가 발생하여 프로그램이 즉시 다운

스택은 빠르기 때문에 가능한 한 스택을 활용하는 것이 좋다.

하지만 FTransform, FHitResult, TArray 같은 큰 구조체나 컨테이너를 함수에 값으로 전달하면 모든 데이터가 스택에 통째로 복사된다.

따라서 매개변수를 참조형으로 선언하면 원본의 주소만 스택에 복사되므로 효율적이다.

1-4. Heap 영역


프로그래머가 코드 실행 중에 동적으로 할당하는 모든 데이터이다.

프로그래머가 요청할 때 생성되며, 명시적으로 해제하거나 GC가 수거할 때까지 절대 자동으로 사라지지 않는다.

핵심 특징


1. 유연성

  • 프로그램 실행 중에 필요한 만큼(OS가 허락하는 한) 큰 메모리를 할당받을 수 있음.
  • 스택과 달리 크기 범위가 훨씬 큼

2. 느린 속도

  • 스택과 달리 메모리 관리자가 힙 영역을 탐색하여 요청한 크기를 할당할 수 있는 빈 공간을 검색해야함
  • 해제 시에도 이 공간을 빈 공간 목록에 다시 추가하는 작업이 필요한데, 이 과정은 스택 포인터 이동보다 수천 배 느릴 수 있음

3. 단편화 (Fragmentation)

  • 할당과 해제를 반복하면 힙 영역이 잘게 쪼개진 빈 공간들고 가득 차게 됨
  • 예를 들어 총 여유 공간은 100MB인데, 연속된 50MB짜리 빈 공간이 없어서 할당에 실패할 수도 있음

4. 메모리 누수 (Memory Leak)

  • 힙에 할당한 객체를 사용한 뒤, 해제하는 것을 잊어버리면 발생

C++ 힙 관리의 2가지 치명적인 문제


1. 메모리 누수

new로 빌린 메모리를 delete로 반납하는 것을 잊는 경우이다.

// ----------------------------------------
// 순수 C++의 '메모리 누수' 예제
// ----------------------------------------
class MyData { public: int Value = 100; };

void CreateData_Leak()
{
    // 1. 'Ptr' 변수 자체는 *스택*에 생성
    // 2. 'Ptr'이 가리키는 *실제 데이터*는 *힙*에 생성 (주소: 0x1000A000)
    MyData* Ptr = new MyData(); 

    // 3. 'delete Ptr;'을 안 함
} 
// 4. 함수 리턴
  
// 5. *스택*에 있던 'Ptr' 변수는 사라짐
  
// 6. 결과:
//    아무도 'MyData' 객체(0x1000A000)의 주소를 모르므로,
//    이 메모리는 프로그램 종료 시까지 영원히 "누수"됨

2. 댕글링 / Stale 포인터 (Dangling Pointer)

메모리 누수보다 더 심각한 문제이다.

delete는 했지만, 다른 포인터가 여전히 그 삭제된 주소를 가리키고 있다가 접근할 때 발생한다.

MyData* Ptr_A = new MyData(); // A가 객체 생성 (0x1000A000)
MyData* Ptr_B = Ptr_A;      // B가 A와 동일한 객체를 가리킴

delete Ptr_A; // A가 객체를 삭제함
Ptr_A = nullptr; 

// Ptr_B는 Ptr_A가 객체를 삭제한 사실을 모릅니다.
// Ptr_B는 여전히 '삭제된' 주소 0x1000A000을 가리키고 있습니다. (Stale Pointer)

Ptr_B->Value = 300; // CRASH! (존재하지 않는 메모리에 접근)

Unreal의 해결책: GC와 UPROPERTY


Unreal Engine의 모든 UObject는 힙 영역에 생성된다.

Unreal은 위에서 언급된 메모리 누수Dangling Pointer 문제를 GCUPROPERTY라는 시스템으로 동시에 해결한다.

예를 들어 다음과 같은 코드가 있다고 가정해보자.

// (헤더 파일: AMyPlayer.h)
UCLASS()
class AMyPlayer : public APawn
{
    GENERATED_BODY()

    // GC에게 정보를 등록하는 키워드
    UPROPERTY() 
    TObjectPtr<AMyActor> MyActorPtr; // (TObjectPtr은 UE5의 안전한 포인터)
};

// (소스 파일: AMyPlayer.cpp)
void AMyPlayer::SpawnMyActor()
{
    // 2. 힙에 'AMyActor' 객체 생성 (주소: 0x2000B000)
    AMyActor* MySpawnedActor = GetWorld()->SpawnActor<AMyActor>();

    // 3. 'UPROPERTY' 멤버 변수에 주소(0x2000B000) 저장
    this->MyActorPtr = MySpawnedActor; 
}

1. 메모리 누수 문제 해결: 강한 참조 (Strong Reference)

UPROPERTY는 GC에게 '이 객체(AMyActor)는 AMyPlayer가 여전히 사용 중이니, '쓰레기가 아니다' 라고 알려주는 강한 참조 신호이다.

결과적으로 AMyPlayer 객체가 파괴되지 않는 한, GC는 MyActorPtr가 참조하는 AMyActor를 절대 쓰레기로 취급하지 않고 삭제하지 않는다.

즉, 개발자는 new만 하고 delete를 잊는 메모리 누수 걱정을 할 필요가 없다.

나중에 AMyPlayer가 파괴되거나 MyActor = nullptr이 되어 참조가 끊기면, GC가 알아서 AMyActor를 수거해간다.

2. Dangling Pointer 해결: 자동 Null-Setting

UPROPERTY의 진짜 핵심이다.

UPROPERTY가 있다면 GC에게 '만약 이 객체(AMyActor)가 삭제되면, 나(MyActorPtr)에게 즉시 알려줘' 라고 등록하는 '사망 통지' 예약과 같다.

GC가 AMyActor를 삭제하기로 결정하는 순간, 엔진 GC 코드가 MyActorPtr 변수의 메모리에 직접 접근하여 값을 nullptr로 강제 override 해버린다.

결과적으로 개발자는 if (MyActorPtr) 또는 if (IsValid(MyActorPtr))이라는 간단한 검사만으로 삭제된 객체에 접근하려는 시도를 100% 막을 수 있다.

자동 nullptr의 작동 원리 (엔진 내부)

  1. 컴파일 시 (UHT): UPROPERTY 매크로를 발견하면, UHT가 'AMyPlayer 클래스는 XXX 바이트 위치에 AMyActor를 가리키는 포인터가 있다'는 '명단'을 생성하여 엔진에 등록
  2. 런타임 시 (GC 실행): GC가 AMyActor(0x2000B000)를 쓰레기로 판단하고 삭제하기로 결정
  3. Null-Setting (핵심): GC는 객체를 즉시 삭제하지 않고, 1번에서 만든 명단을 전부 스캔
  • GC: 현재 삭제될 0x2000B000 주소를 가리키는 UPROPERTY가 누구누구지?
  • GC: AMyPlayer 객체의 MyActorPtr 변수가 가리키고 있네
  • 이후 GC가 MyActorPtr 변수의 메모리에 직접 접근하여 그 값을 nullptr로 강제 override
  • 마지막으로 AMyActor를 delete

2. Unreal GC


2-1. GC의 기본 전제: UObject


GC의 관리를 받는 모든 객체는 UObject를 상속받아야 한다.

  • new 키워드가 아닌, NewObject<T>()(UObject용) 또는 GetWorld()->SpawnActor<T>()(AActor용)를 사용해야함
  • 이 함수들은 객체를 힙에 생성함과 동시에 GC에게 관리 목록에 추가하라고 알림
  • GC는 이 객체들의 생사를 추적하기 시작함

2-2. GC의 핵심 원리: 도달 가능성 (Reachability)


GC는 '이 객체가 필요한가?'라는 어려운 질문에 대답하지 않는다.

대신 "이 객체에 '도달'할 수 있는가?"라는 간단한 질문에만 대답한다.

  • 살아있는 객체: 어떻게든 '도달'할 수 있는 객체
  • 쓰레기 객체: 절대 '도달'할 수 없는 객체

2-3. GC의 출발점: 루트 셋


루트 셋은 '절대로 GC의 대상이 되어서는 안 되는, 항상 살아있다고 보장된' 객체들의 최상위 목록이다.

GC는 항상 이 루트 셋에서부터 탐색을 시작한다.

  • 루트 셋의 예:
    • UGameInstance (게임이 켜져 있는 내내 존재)
    • UWorld (현재 로드된 레벨)
    • AddToRoot() 함수로 명시적으로 루트에 추가된 객체
    • 그 외 엔진이 관리하는 핵심 싱글톤 객체들

2-4. GC의 핵심 알고리즘: Mark and Sweep (표시 및 쓸기)


GC가 실행되면 (보통 일정 시간 간격으로) 다음 두 단계를 거친다.

1. Mark (표시)

  1. GC가 루트셋에 있는 모든 객체를 '살아있음(Reachable)'이라고 표시
  2. 이 객체들이 참조하는 모든 UPROPERTY 포인터를 따라감
  3. UPROPERTY를 건너 만나는 모든 UObject를 '살아있음'이라고 표시
  4. 이 과정을 재귀적으로 반복하여, 루트 셋에서 UPROPERTY를 통해 도달할 수 있는 모든 객체를 '살아있음'으로 표시

2. Sweep (쓸기)

  1. GC가 관리 목록을 쭉 살펴봄
  2. 각 개체가 1단계에서 Mark를 받았는지 확인
  3. 표시됨
  • 다음 GC를 위해 표시만 지움 (Unmark)
  1. 표시 안 됨
  • 루트 셋에서 도달할 수 없는 Garbage 임
  • 이 객체를 가리키는 모든 UPROPERTY 포인터를 찾아서, 그 값을 nullptr로 override (Null-Setting)
  • 이 객체의 소멸자를 호출
  • 이 객체가 사용하던 힙 메모리를 해제
profile
Github: https://github.com/dlalsrn

0개의 댓글