메모리 관리와 최적화 기법
UObject GC와 일반 C++ 소유권을 구분하고 에셋 언로드·풀링·자료구조 선택으로 메모리를 절약합니다.
메모리가 부족하면 게임은 갑자기 종료되거나, 프레임이 크게 떨어지거나, 로딩이 길어집니다. 특히 콘솔과 모바일 기기는 쓸 수 있는 메모리가 정해져 있어서, PC에서는 문제없던 게임이 그 한도를 넘는 순간 바로 종료되기도 합니다. 그런데 언리얼에서는 객체를 직접 delete하지 않으므로, "이 객체는 언제 메모리에서 사라지는가"를 이해하지 못하면 다 쓴 에셋이 계속 메모리에 남아 있는 이유를 찾기 어렵습니다.
프로파일링 도구 사용법에서 성능을 측정하는 방법을 익혔으니, 이 글에서는 메모리를 다룹니다. 언리얼의 가비지 컬렉션이 무엇을 기준으로 객체를 회수하는지 살펴보고, 에셋 언로드, 텍스처와 메시, 오디오, 자료구조 같은 영역별 최적화 기법과 측정 도구, 측정할 때의 주의점을 정리합니다.
언리얼의 메모리 관리
언리얼 엔진은 자체적인 메모리 관리 시스템을 가지고 있으며, 그 중심에는 UObject 시스템과 가비지 컬렉션(GC)이 있습니다. 액터, 컴포넌트, 에셋 같은 대부분의 게임 객체는 UObject를 상속하고, 이 객체들은 언리얼의 가비지 컬렉터가 메모리에서 해제합니다.
가비지 컬렉션의 기준: 도달 가능성
GC는 루트에서 시작해 추적할 수 있는 참조를 따라갔을 때 그 객체에 도달할 수 있는지를 기준으로 판단합니다. 살아 있는 객체의 UPROPERTY 강한 참조는 그 대상을 유지합니다. 하지만 바깥에서 도달할 수 없는 순환 참조만으로는 객체가 계속 살아남지 않으며, 모든 C++ 포인터가 추적되는 것도 아닙니다.
TWeakObjectPtr는 약한 참조(weak reference)로, GC가 객체를 회수하는 것을 막지 않으면서 그 객체에 접근할 수 있게 해 줍니다. 객체가 파괴되거나 회수되면 약한 참조는 유효하지 않게 되므로, 쓸 때마다 IsValid()나 Get()의 결과를 확인합니다. 대상을 소유하지 않는 캐시나 관찰에 씁니다.
GC는 주기적으로 실행되거나, 레벨을 불러올 때처럼 특정 조건에서 강제로 실행될 수 있습니다. GC가 실행되는 동안 게임 스레드가 잠시 멈출 수 있으므로 화면이 순간적으로 멈추는 스톨(stall)이 생길 수 있습니다.
순환 참조보다 루트에서의 도달 가능성이 기준입니다
루트 R→A→B 도달 상태에서 R→A를 끊으면 A↔B 순환/약한 관찰만 남아 회수 대상이 되는 전이 과정이다. 별도의 C↔D와 즉시 메모리 반환이 보장되지 않는다는 구분을 유지한다.
UNREAL C++ · CH9-2
순환 참조보다 루트에서의 도달 가능성이 기준입니다
일반 UObject의 강한 참조 그래프를 단순화한 예시입니다. 루트에서 추적할 수 있는 경로가 있으면 유지되고, 서로 참조하는 두 객체만으로는 유지되지 않습니다.
1 · 루트에서 도달 가능
A ↔ B의 순환 외에 루트 → A 경로가 있습니다. 이 강한 참조 경로가 A와 B를 유지합니다.
2 · 루트에서 도달 불가
C ↔ D가 남아 있어도 루트에서 도달하지 못하면 GC 수집 대상입니다. 즉시 메모리가 해제된다는 뜻은 아닙니다.
선의 의미와 약한 참조
화살표는 GC가 추적하는 강한 참조의 방향입니다. 살아 있는 객체의 UPROPERTY 강한 참조가 대표적인 예이며 모든 일반 C++ 포인터가 추적되는 것은 아닙니다.
TWeakObjectPtr는 대상을 유지하는 경로가 아닙니다. 접근 전에 IsValid() 또는 Get()의 결과를 확인합니다.
Destroy가 지정된 Actor/Component의 수명과 GC 클러스터 등은 별도 정책입니다. 이 예시는 명시적 파괴 없이 일반 UObject의 도달 가능성만 비교하며 실제 측정한 객체 그래프가 아닙니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 화살표는 GC가 추적하는 강한 참조다. 루트→A↔B는 유지되며, A/B 순환만 남거나 C↔D만 있으면 수집 대상이다. 즉시 해제/Actor 파괴/약한 참조 관계는 이 그림에 추가하지 않았다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 루트 도달 루트 R의 강한 경로가 A/B에 도달한다. |
|
|
| 루트 간선 제거 R→A를 끊고 순환과 약한 관찰만 남긴다. |
|
|
| 회수 대상 도달 불가능한 A/B가 회수 대상이 될 수 있다. |
|
|
순환 참조보다 루트에서의 도달 가능성이 기준입니다
루트 R→A→B 도달 상태에서 R→A를 끊으면 A↔B 순환/약한 관찰만 남아 회수 대상이 되는 전이 과정이다. 별도의 C↔D와 즉시 메모리 반환이 보장되지 않는다는 구분을 유지한다.
UNREAL C++ · CH9-2
순환 참조보다 루트에서의 도달 가능성이 기준입니다
일반 UObject의 강한 참조 그래프를 단순화한 예시입니다. 루트에서 추적할 수 있는 경로가 있으면 유지되고, 서로 참조하는 두 객체만으로는 유지되지 않습니다.
1 · 루트에서 도달 가능
A ↔ B의 순환 외에 루트 → A 경로가 있습니다. 이 강한 참조 경로가 A와 B를 유지합니다.
2 · 루트에서 도달 불가
C ↔ D가 남아 있어도 루트에서 도달하지 못하면 GC 수집 대상입니다. 즉시 메모리가 해제된다는 뜻은 아닙니다.
선의 의미와 약한 참조
화살표는 GC가 추적하는 강한 참조의 방향입니다. 살아 있는 객체의 UPROPERTY 강한 참조가 대표적인 예이며 모든 일반 C++ 포인터가 추적되는 것은 아닙니다.
TWeakObjectPtr는 대상을 유지하는 경로가 아닙니다. 접근 전에 IsValid() 또는 Get()의 결과를 확인합니다.
Destroy가 지정된 Actor/Component의 수명과 GC 클러스터 등은 별도 정책입니다. 이 예시는 명시적 파괴 없이 일반 UObject의 도달 가능성만 비교하며 실제 측정한 객체 그래프가 아닙니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 화살표는 GC가 추적하는 강한 참조다. 루트→A↔B는 유지되며, A/B 순환만 남거나 C↔D만 있으면 수집 대상이다. 즉시 해제/Actor 파괴/약한 참조 관계는 이 그림에 추가하지 않았다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 루트 도달 루트 R의 강한 경로가 A/B에 도달한다. |
|
|
| 루트 간선 제거 R→A를 끊고 순환과 약한 관찰만 남긴다. |
|
|
| 회수 대상 도달 불가능한 A/B가 회수 대상이 될 수 있다. |
|
|
위 그림은 일반 UObject의 강한 참조 그래프를 단순하게 그려, 순환 참조가 아니라 루트에서의 도달 가능성이 기준이라는 점을 보여 줍니다. 첫 번째 경우에서는 루트 집합이 A를 참조하고 A와 B가 서로 참조합니다. A와 B 사이의 순환 외에도 루트에서 A로 가는 경로가 있으므로, 이 강한 참조 경로가 A와 B를 함께 유지합니다. 두 번째 경우에서는 C와 D가 서로 참조하지만 루트와 연결된 경로가 없으므로, 순환이 남아 있어도 GC의 회수 대상입니다. 그림의 설명처럼 회수 대상이 된다는 것이 즉시 메모리가 해제된다는 뜻은 아닙니다.
그림 아래쪽은 선의 의미를 정리합니다. 화살표는 GC가 추적하는 강한 참조의 방향이며, 살아 있는 객체의 UPROPERTY 강한 참조가 대표적인 예입니다. TWeakObjectPtr는 대상을 유지하는 경로가 아니므로 접근하기 전에 IsValid()나 Get()의 결과를 확인합니다. 그림은 명시적으로 파괴하지 않은 일반 UObject의 도달 가능성만 비교하며, Destroy를 부른 액터나 컴포넌트의 수명과 GC 클러스터 같은 것은 별도의 정책입니다. 실제로 측정한 객체 그래프도 아닙니다.
단계 그림은 첫 번째 경우가 바뀌는 과정을 따라갑니다. 일반 UObject A와 B가 강한 참조로 서로 연결되어 있고 루트 R에서 A로 유지 경로가 있으며, 바깥의 관찰자는 B를 TWeakObjectPtr로만 가지고 있다고 합시다. R에서 A로 가는 경로가 있는 동안에는 B도 R, A, B의 경로로 도달할 수 있습니다. 그런데 R에서 A로 가는 참조를 끊으면 A와 B 사이의 순환과 약한 관찰만 남고, 루트에서 도달할 길이 없어진 A와 B는 회수 대상이 됩니다. 여기서는 명시적인 Destroy, 클러스터, 별도의 로드 핸들은 빼고 참조 모델만 따집니다.
더 깊이: GC의 멈춤을 나누고 줄이는 엔진 설정
프로젝트 설정의 Garbage Collection 항목에는 GC가 일하는 방식을 바꾸는 설정이 모여 있습니다. Time Between Purging Pending Kill Objects는 파괴 대기 중인 객체에 대한 참조를 정리하는 사이에 기다릴 게임 시간(초)입니다. Allow Parallel GC를 켜면 GC가 여러 스레드를 쓰고, Incremental BeginDestroy Enabled를 켜면 엔진이 프레임마다 시간 한도를 두고 객체를 몇 개씩 나눠 파괴합니다. Multithreaded Destruction Enabled는 객체의 메모리 해제를 워커 스레드에서 하게 합니다.
앞의 그림에서 빼 두었던 GC 클러스터(cluster)도 여기서 정합니다. Create Garbage Collector UObject Clusters를 켜면 엔진은 GC 성능을 높이려고 객체들을 클러스터로 묶으려 하고, Asset Clustering Enabled와 Actor Clustering Enabled는 에셋 파일과 레벨이 GC용 액터 클러스터를 만들 수 있게 할지를 정합니다. Maximum Object Count Not Considered By GC와 Size of Permanent Object Pool처럼 쿡한 빌드에서만 동작한다고 설정 문서에 적힌 항목도 있습니다. 이런 항목은 에디터에서 바꿔 보고 효과를 판단할 수 없으므로, 패키지한 빌드에서 GC 시간을 다시 측정합니다.
일반 C++ 메모리
UObject가 아닌 메모리는 일반 C++의 규칙을 따릅니다. TArray, FString, std::vector 같은 일반 C++ 값은 소멸자와 RAII(자원 획득은 초기화)로 내부 저장소를 관리하므로, 매번 직접 new와 delete를 쓸 필요가 없습니다. 직접 할당한 버퍼나 외부 자원은 그 API에 맞는 소유권과 해제 규칙을 지켜야 하며, TSharedPtr, TUniquePtr 같은 스마트 포인터로 UObject가 아닌 메모리를 자동으로 관리할 수 있습니다. 다만 TSharedPtr끼리 서로 가리키는 순환은 참조 계수가 0이 되지 않아 해제되지 않으므로, GC의 도달 가능성 규칙과 섞어 생각하면 안 됩니다. 공유 소유와 순환 참조가 생기는 과정은 C++ 교재의 스마트 포인터에서 std::shared_ptr와 std::weak_ptr로 따라가 볼 수 있습니다.
필요 없는 에셋 내보내기
더 이상 필요 없는 객체는 루트에서 그 객체를 유지하는 강한 참조를 없애야 회수됩니다. 포인터를 nullptr로 바꾸거나, TArray에서 빼거나, 지역 변수가 스코프를 벗어나게 합니다. 약한 참조까지 모두 없애야 회수되는 것은 아닙니다.
해제는 각 시스템의 수명 API를 따릅니다. 액터는 Destroy()로, 스트리밍 레벨은 UnloadStreamLevel로 내리는 식입니다. 일반 에셋을 임의로 파괴 표시하는 것은 언로드 방법이 아니므로, 강한 참조나 활성 로드 핸들처럼 에셋을 붙잡고 있는 원인을 없애고 실제로 회수되었는지 측정합니다. CollectGarbage 같은 명시적 수집을 불러도 살아 있는 참조를 무시하고 에셋을 내리지는 않으며, 수집 비용이 프레임에 주는 영향부터 확인해야 합니다.
모든 에셋을 한꺼번에 불러오는 대신, 필요할 때만 불러오고 쓰지 않을 때 내리는 에셋 스트리밍을 씁니다. 레벨 스트리밍이나 월드 파티션으로 월드를 나눠 플레이어가 그 지역에 들어갈 때만 불러오고, UI 이미지, 사운드, 이펙트처럼 특정 시점에만 필요한 에셋은 FStreamableManager나 Async Load Asset 노드로 비동기 로드해 처음 로딩 시간을 줄입니다.
텍스처와 메시 줄이기
그래픽 에셋은 게임 메모리에서 가장 큰 비중을 차지할 수 있습니다. 필요 이상으로 해상도가 높은 텍스처는 줄이고, 텍스처 스트리밍(Texture Streaming) 설정으로 지금 필요한 밉맵(mipmap)만 불러오게 합니다. 텍스처 그룹(Texture Group) 설정으로 텍스처마다의 스트리밍 동작도 조절할 수 있습니다.
텍스처마다 알맞은 압축 형식을 씁니다. PC에서는 RGB나 RGBA에 BC1(DXT1)이나 BC3(DXT5)를, 노멀 맵에 BC5를 쓰는 식이고, 모바일 플랫폼에서는 ASTC나 ETC 같은 형식을 씁니다.
메시는 필요한 UV 채널만 씁니다. UV 채널이 하나 늘 때마다 메시마다 메모리가 더 듭니다. 거리에 따라 단순한 모델로 그리는 LOD(Level of Detail)는 렌더링 작업을 줄이지만, 여러 LOD가 함께 메모리에 남을 수 있으므로 LOD 전환만으로 상주 메모리가 줄었다고 보면 안 되고, LOD 스트리밍과 쿠킹에 포함되는 범위도 함께 확인합니다. 정점 색이나 탄젠트가 필요 없다면 빼서 메시 데이터를 줄이고, 스켈레탈 메시의 애니메이션 시퀀스에는 알맞은 압축 설정을 적용합니다.
오디오 줄이기
사운드는 가져온 파일의 확장자와 실행 중에 쓰이는 쿠킹 코덱을 구분하고, 대상 플랫폼이 지원하는 압축과 품질 설정을 고릅니다. 파일 크기뿐 아니라 디코딩 비용과 메모리를 함께 측정합니다. 배경 음악처럼 길고 메모리를 많이 차지하는 사운드는 한 번에 불러오지 말고 디스크에서 스트리밍해 재생하도록 설정하며, 필요 이상으로 높은 샘플 레이트는 낮춥니다.
컨테이너와 자료구조
TArray에 들어갈 원소 수를 대략 안다면 TArray::Reserve()로 미리 메모리를 잡아 둡니다. 원소가 늘 때마다 다시 할당하고 복사하는 비용과 메모리 파편화를 줄일 수 있습니다. TMap이나 TSet은 키의 해시 충돌이 많아지면 느려지므로, 알맞은 해시 함수를 쓰거나 키를 다듬습니다. 자주 쓰는 데이터를 캐싱해 같은 계산을 되풀이하지 않게 하고 메모리 접근 패턴을 좋게 만들 수도 있습니다.
총알이나 이펙트처럼 자주 만들어지고 사라지는 액터는 매번 SpawnActor와 DestroyActor를 부르는 대신, 미리 몇 개를 만들어 두고 다시 쓰는 오브젝트 풀링(object pooling)을 구현해 할당과 해제의 부담을 줄입니다.
메모리 파편화 막기
메모리 파편화(fragmentation)는 쓸 수 있는 메모리의 총량은 충분한데 연속된 큰 블록이 모자라 할당이 실패하거나 느려지는 현상입니다. 운영체제 교재의 연속 메모리 할당에서는 같은 현상을 외부 단편화라는 이름으로 예를 들어 설명합니다. 작은 객체를 자주 만들고 없애는 일을 줄이는 것이 먼저이며, 오브젝트 풀링이 여기에 도움이 됩니다. 필요한 메모리를 미리 할당해 두는 사전 할당(pre-allocation)도 좋은 방법입니다. 데이터 구조가 메모리 정렬(alignment) 규칙을 따르면 CPU가 효율적으로 접근할 수 있는데, 언리얼의 컨테이너는 대부분 이를 자동으로 처리합니다.
메모리를 측정하는 도구
MemReport는 등록된 통계, 객체, 에셋 보고를 모아 보통 Saved/Profiling/MemReports에 .memreport 파일로 남기며, 실제 저장 경로는 로그에서 확인합니다. MemReport -full은 보고 항목을 더 넣지만 모든 할당의 호출 스택을 기록하지는 않습니다.
메모리 인사이트는 -trace=default,memory처럼 프로세스가 시작될 때부터 메모리 채널을 켜고, 할당과 해제, 그 호출 스택을 분석합니다. 패키지에서 쓸 때는 Development 구성 같은 지원 조건을 확인합니다. 늘어난 할당이 누수인지, 의도한 캐시인지는 그 객체의 수명과 쓰임을 함께 보고 판단해야 합니다. stat memory는 현재 총 메모리 사용량과 주요 시스템별 사용량을 게임 화면에 간단히 보여 줍니다.
측정할 때의 주의점
개발 빌드에는 디버깅 정보와 프로파일링 코드가 들어 있어 메모리를 더 쓸 수 있습니다. 배포(Shipping) 구성에서는 일부 진단 기능이 빠지지만, 실제 메모리 차이는 콘텐츠, 최적화, 로드 조건을 맞춰 측정해야 알 수 있습니다.
에디터에서 플레이하며 측정하는 것도 조심해야 합니다. 에디터 자체가 쓰는 메모리와, 에디터가 열어 둔 에셋까지 함께 메모리에 있으므로 실제 게임보다 메모리를 훨씬 많이 씁니다. 그래서 메모리는 독립 실행형 게임(Standalone Game)이나 패키징한 빌드에서 측정합니다. 블루프린트도 메모리를 쓰므로, 지나치게 큰 블루프린트나 과도한 변수는 피하고 필요하면 C++로 옮기는 것을 검토합니다.
마지막으로 최적화 기법끼리도 서로 맞바꾸는 관계가 있다는 점을 기억합니다. 풀링, Reserve, 캐시는 되풀이되는 작업의 비용을 줄이는 대신 상주 메모리를 늘릴 수 있습니다. 바꾸기 전과 후의 메모리와 함께 로딩 지연, GC 스톨, 화면 품질을 비교해 어디까지 유지할지 정합니다.
정리
- 언리얼의
UObject는 GC가 회수하며, 기준은 루트에서 강한 참조를 따라 도달할 수 있는지입니다. 순환 참조만으로는 살아남지 않습니다. TWeakObjectPtr는 대상을 유지하지 않으므로 쓸 때마다IsValid()나Get()을 확인하고, 회수 대상이 되는 것과 메모리가 즉시 해제되는 것은 다릅니다.- 일반 C++ 메모리는 RAII와 스마트 포인터로 관리하며,
TSharedPtr의 소유 순환은 GC와 다른 규칙으로 해제되지 않습니다. - 에셋을 내리려면 붙잡고 있는 강한 참조와 로드 핸들을 없애고, 수명 API를 쓰며, 스트리밍과 비동기 로드로 필요할 때만 불러옵니다.
- 텍스처 해상도와 압축, 스트리밍, 메시의 UV와 LOD, 오디오의 압축과 스트리밍, 컨테이너의
Reserve와 풀링으로 메모리를 줄입니다. - 메모리는
MemReport, 메모리 인사이트,stat memory로 측정하되 에디터가 아니라 독립 실행형이나 패키징한 빌드에서 측정합니다. - 풀링,
Reserve, 캐시는 상주 메모리를 늘릴 수 있으므로 전후의 메모리와 로딩, 스톨, 품질을 함께 비교합니다.
연습 문제
1. 루트 경로를 끊으면
앞의 예에서 R에서 A로 가는 참조를 없앴고, 다른 강한 루트 경로는 없습니다. A와 B 사이의 순환과 B를 보는 약한 관찰자가 남았다는 이유로 A와 B가 계속 유지될까요? 참조를 끊은 바로 그 줄에서 메모리가 이미 반환되었다고 기록해도 될까요?
해설 보기
유지되지 않습니다. A와 B는 루트에서 도달할 수 없으므로 회수 대상이 됩니다. 약한 참조는 대상을 유지하는 경로가 아니고, 순환만으로는 살아남지 않습니다.
하지만 회수 대상이 되었다고 그 줄에서 메모리가 반환된 것은 아닙니다. 실제로 해제되는 시점은 GC의 실행과 수명 정책에 따라 따로 확인해야 합니다. 관찰자가 나중에 B에 다시 접근할 때는 IsValid()나 Get()의 결과를 확인합니다. 일반 C++ TSharedPtr의 소유 순환과는 다른 규칙이라는 점도 함께 기억합니다.
2. 강제로 GC를 불렀는데
다 쓴 텍스처를 메모리에서 내리려고 CollectGarbage를 불렀는데 메모리가 그대로입니다. 무엇을 확인해야 할까요?
해설 보기
그 텍스처를 붙잡고 있는 강한 참조나 활성 로드 핸들이 남아 있는지 확인합니다. 명시적인 GC도 살아 있는 참조를 무시하고 에셋을 내리지는 않으므로, 어떤 UPROPERTY나 컨테이너, 로드 핸들이 아직 그 텍스처를 가리키고 있다면 회수되지 않습니다. 참조 뷰어나 메모리 보고로 원인을 찾아 없앤 뒤 다시 측정합니다. 또 강제 GC는 그 자체로 프레임을 멈출 수 있으므로 비용도 함께 봅니다.
3. 에디터에서 잰 메모리
에디터에서 플레이하며 측정한 메모리가 목표보다 훨씬 커서 대규모 최적화를 계획했습니다. 계획을 세우기 전에 무엇을 먼저 해야 할까요?
해설 보기
독립 실행형 게임이나 패키징한 빌드에서 같은 장면을 다시 측정합니다. 에디터에서는 에디터 자체의 메모리와 에디터가 열어 둔 에셋까지 함께 메모리에 있으므로, 실제 게임보다 크게 나옵니다. 그 숫자를 기준으로 최적화를 계획하면 필요 없는 작업을 하게 될 수 있습니다. 빌드 구성과 로드 조건도 기록해 두고 비교합니다.
가비지 컬렉션의 동작은 Epic의 Unreal Object Handling 문서에서, 메모리 인사이트는 Memory Insights 문서에서 확인할 수 있습니다.
다음 글 렌더링 최적화에서는 드로 콜, 오버드로, 그림자, 후처리처럼 렌더링 비용을 줄이는 방법을 살펴봅니다.