본문 바로가기

메모리 및 CPU 사용량 최적화

에셋 로딩과 객체 수명·Tick·블루프린트 연산의 메모리 및 CPU 비용을 측정해 프레임과 안정성을 개선합니다.

GPU만 최적화해서는 충분하지 않습니다. 메모리(memory)와 CPU는 게임의 안정성과 프레임 유지에 바로 이어지는 자원이라, 메모리가 부족하면 게임이 강제로 종료될 위험이 커지고, CPU가 과부하되면 프레임이 떨어지고 입력이 늦게 반응합니다. 그래서 메모리와 CPU도 함께 측정하고, 실제로 병목인 축부터 조정해야 합니다.

이 글에서는 메모리 쪽에서 에셋의 메모리, 로딩과 언로딩, 가비지 컬렉션, 인스턴싱을 정리하고, CPU 쪽에서 블루프린트 로직과 Tick, 물리, AI, 그리기 호출을 줄이는 방법을 살펴봅니다. Tick을 타이머로 바꿀 때 호출 횟수가 얼마나 줄고 그 대가로 응답이 얼마나 늦어지는지도 계산해 봅니다. 마지막으로 메모리와 CPU를 측정하는 도구와 비교할 항목을 정리합니다.


메모리와 CPU를 최적화해야 하는 이유

메모리와 CPU의 최적화는 사용량이 높은 항목을 찾고, 로딩과 업데이트의 비용을 나눠 줄이는 과정으로, GPU 최적화만큼 게임의 안정성과 성능에 큰 영향을 줍니다.

먼저 안정성입니다. 메모리가 부족해지는 OOM(Out Of Memory)은 게임을 강제로 종료시킬 수 있으므로, 메모리 예산과 로딩 중에 순간적으로 치솟는 최댓값을 관리해 그 위험을 줄입니다. 다음은 프레임입니다. CPU는 게임 로직과 물리, AI, 애니메이션 같은 계산을 맡으므로, 프레임의 주요 CPU 작업이 예산을 넘으면 프레임이 떨어집니다. 이때 CPU 전체의 사용률이 낮아도 스레드 하나가 병목일 수 있다는 점을 기억합니다.

그 밖의 이점도 있습니다. 필요 없는 메모리 사용을 줄이면 로딩 시간이 줄고, 쓰지 않거나 중복된 에셋을 없애면 최종 빌드의 크기도 줄어듭니다. 또 게임이 다른 앱과 함께 실행되거나 백그라운드에 있을 때 메모리를 적게 쓸수록 시스템 전체가 안정되는데, 특히 모바일에서 중요합니다.


메모리 최적화

메모리, 즉 RAM과 VRAM은 실행하는 동안 필요한 에셋과 데이터가 올라와 있는 공간입니다. 모든 에셋이 동시에 올라와 있는 것은 아니므로, 무엇을 언제 올리고 내릴지를 효율적으로 관리해야 합니다.

에셋의 메모리

에셋과 레벨 최적화 기법에서 다룬 텍스처의 해상도(Max Texture Size)와 압축 설정은 VRAM 사용량에 가장 큰 영향을 줍니다. 필요 이상으로 큰 텍스처를 쓰지 않고, 노멀 맵에는 Normalmap을, 색 텍스처에는 용도에 맞는 Default를 쓰되, 플랫폼마다 실제로 쓰이는 압축 형식을 확인합니다.

메시도 폴리곤이 많을수록 더 많은 버텍스 데이터를 메모리에 저장합니다. 여기서 하나 구분할 것이 있습니다. LOD는 주로 그릴 삼각형의 수를 줄이는 장치이고, 여러 LOD의 데이터 자체는 오히려 메모리를 더 씁니다. 실제로 메모리에 올라와 있는 양이 줄었는지는 스트리밍과 언로딩으로 확인해야 합니다.

오디오는 사운드 웨이브 에셋을 더블클릭해 디테일 패널의 압축 설정을 조정합니다. 대상 플랫폼이 지원하는 코덱과 압축 품질, 스트리밍 설정을 비교하는데, 소리의 길이만 보고 형식을 정하지 말고 디코딩의 CPU 비용과 메모리, 재생이 시작되기까지의 지연, 음질을 함께 확인합니다. 애니메이션은 애니메이션 시퀀스 에셋의 Compression 항목에서, 쓰는 버전의 본 압축과 커브 압축 설정을 비교해 파일 크기와 메모리, 동작의 오차를 확인합니다. Compress None 같은 압축하지 않는 선택이 용량을 줄인다고 생각하지 않습니다.

로딩과 언로딩

레벨 스트리밍은 레벨 전체를 한 번에 불러오는 대신, 플레이어가 특정 영역에 들어오면 그 부분만 불러오고 벗어나면 내려 메모리 사용량을 관리합니다. 넓은 월드에서 불러오는 범위를 제한하는 데 쓸모가 있으며, 월드 파티션을 쓰는지에 따라 관리 방식이 달라집니다. 예전 방식의 서브 레벨은 Window 메뉴의 Levels에서 관리하는데, Always Loaded로 둔 레벨은 계속 올라와 있으므로, 영역마다 내려야 한다면 알맞은 스트리밍 방식을 고릅니다. 월드 파티션 월드는 셀과 스트리밍 소스로 관리합니다.

비동기 에셋 로딩(asynchronous asset loading)은 실행하는 동안 필요한 메시나 텍스처를 뒤에서 불러와, 한 번에 불러오느라 게임 스레드가 멈추는 일을 줄입니다. 로딩 화면 없이 끊김 없이 플레이하게 만들 때 중요합니다. 블루프린트에서는 에셋 대신 에셋의 경로를 담는 소프트 오브젝트 참조나 소프트 클래스 참조와 Async Load Asset, Async Load Class Asset 노드로 만들고, 불러오기가 끝난 뒤 결과가 유효한지 확인합니다. 에셋이 준비될 때까지 기다리는 동기 노드인 Load Asset Blocking과는 구분합니다. 소프트 참조가 에셋을 직접 가리키는 강한 참조(하드 참조)와 어떻게 다른지는 에셋 레퍼런싱의 기초에서 다룹니다. 다만 비동기로 불러와도 객체를 초기화하고 마무리하는 처리 때문에 순간적인 부담이 남을 수 있습니다.

가비지 컬렉션

가비지 컬렉션(garbage collection)은 UObject를 상속한 객체 가운데 더 이상 어디에서도 참조되지 않는 것을 찾아 정리합니다. UObject는 액터와 컴포넌트, 에셋처럼 언리얼 엔진이 관리하는 대부분의 객체가 공통으로 상속하는 부모 클래스입니다. 무엇을 기준으로 참조를 따지는지는 메모리 관리와 최적화 기법에서 자세히 다룹니다. 강한 참조로 이어진 객체는 회수되지 않으므로, 화면에 보이지 않는다는 이유만으로 에셋이 메모리에서 사라지지는 않습니다. Project Settings의 Engine에 있는 Garbage Collection에서 Time Between Purging Pending Kill Objects 같은 설정을 확인할 수 있습니다. 이 값은 제거 대기 중인 객체의 참조를 정리하는 간격이므로, 가비지 컬렉션의 모든 동작을 이 하나의 주기로 설명하지는 않습니다. 레벨을 바꾸는 것처럼 특정한 때에 Collect Garbage 노드로 가비지 컬렉션을 직접 실행할 수도 있지만, 그 순간 CPU 부담이 튈 수 있습니다.

더 깊이: 가비지 컬렉션이 프레임을 멈추게 하는 이유

증분 가비지 컬렉션 문서에 따르면 언리얼 엔진은 UObject의 메모리를 표시 후 정리(mark-and-sweep) 방식의 가비지 컬렉터로 관리합니다. 이 가운데 어떤 객체가 아직 참조되고 있는지를 찾는 단계를 도달 가능성 분석(reachability analysis)이라고 부르는데, 엔진은 이 단계를 한 프레임 안에 끝내며 그동안 게임플레이를 포함한 UObject 처리를 잠시 멈춥니다. 그래서 훑어볼 객체가 많을수록 멈추는 시간이 길어지고, 그것이 화면의 끊김으로 나타날 수 있습니다. 문서는 이를 피하는 방법으로 UObject 수의 예산을 빠듯하게 관리하기, 객체를 풀(pool)에 두고 다시 쓰기, 일반 게임플레이 중에는 가비지 컬렉션을 끄기를 들면서, 이런 방법이 코드를 복잡하게 만든다는 점도 함께 짚습니다.

이 문제를 줄이려고 엔진은 도달 가능성 분석을 여러 프레임에 나누어 하는 증분 방식을 제공합니다. DefaultEngine.ini의 [ConsoleVariables]에 gc.AllowIncrementalReachability=1과 프레임마다 쓸 시간 한도를 적어 켜는데, 5.8 문서에서도 아직 실험적(Experimental) 기능입니다. 이 방식은 C++의 UPROPERTY가 원시 포인터 대신 모두 TObjectPtr를 써야 안전하고, 완전히 스레드 안전하지는 않아서 문서는 데디케이티드 서버처럼 단일 스레드로 동작하는 빌드에서만 켜라고 권합니다. 또 액터 수명 주기 문서에 따르면 엔진은 함께 사라질 객체를 클러스터로 묶어 한꺼번에 해제해 수집 시간을 줄이며, 프로젝트 설정의 Garbage Collection 항목에서 이 클러스터 생성을 끌 수도 있지만 대부분의 프로젝트에서는 수집이 오히려 덜 효율적이 됩니다.

인스턴싱

인스턴싱(instancing)은 같은 메시를 여러 번 반복해 배치할 때, 메시와 재질의 데이터를 함께 써서 컴포넌트와 렌더링 제출의 비용을 줄이는 기법입니다. 다만 섹션과 렌더링 단계, LOD에 따라 실제 그리기 호출은 여러 개일 수 있습니다. 나무, 풀, 돌, 똑같은 건물 부품처럼 반복해서 쓰는 스태틱 메시에 씁니다.

인스턴싱은 계층형 인스턴스드 스태틱 메시 컴포넌트(HISM)나 인스턴스드 스태틱 메시 컴포넌트(ISM)로 만듭니다. 이 컴포넌트들은 함께 쓰는 메시 말고도 인스턴스마다의 트랜스폼과 커스텀 데이터, 컬링 같은 관리 데이터를 저장합니다. ISM과 HISM의 LOD와 컬링의 차이, 그리고 나나이트를 쓰는지에 따라 효과를 비교합니다.


CPU 최적화

CPU는 게임 로직과 주요 업데이트를 맡으므로, 비효율적인 로직은 프레임이 떨어지는 주요 원인이 됩니다. CPU 비용은 대개 같은 일을 너무 자주 하거나, 한 번에 너무 많은 대상을 다루는 데서 생깁니다. 그래서 아래에서는 먼저 블루프린트와 C++ 로직에서 실행 횟수를 줄이는 방법을 보고, Tick을 타이머로 바꿨을 때 호출 수가 얼마나 줄어드는지 계산한 뒤, 물리와 AI, 그리기 호출을 차례로 살펴봅니다.

블루프린트와 C++ 로직

가장 먼저 볼 것은 Event Tick입니다. Tick은 매 프레임 호출되므로, 여기에 복잡한 로직을 넣으면 CPU 부담이 크게 늘어납니다. 대신 Set Timer by Event로 0.1초에서 0.5초 간격으로 실행하거나, 플레이어의 입력이나 충돌처럼 특정 이벤트가 생길 때만 로직을 실행합니다. 물체의 상태 확인, UI 갱신, AI 행동 갱신 같은 로직이 그 대상입니다.

반복문도 살펴봅니다. For Loop나 For Each Loop 안에서 너무 많은 계산을 하거나 복잡한 함수를 부르지 않게 하고, 큰 배열을 도는 반복은 필요할 때만 실행되도록 조건을 더합니다. IsValid로 유효하지 않은 오브젝트에 접근하는 것을 막고, Branch로 필요 없는 로직이 실행되지 않게 합니다.

참조는 저장해 두고 씁니다. Get Actor Of Class나 Get All Actors Of Class처럼 레벨에서 액터를 찾는 노드는 비용이 큽니다. 그래서 필요한 때에 한 번 찾아 참조를 저장해 두고, 액터가 생기거나 없어질 때와 레벨이 바뀔 때 다시 찾거나 유효한지 확인합니다. Print String이나 Draw Debug 같은 디버그 노드는 개발 중에는 쓸모가 있지만, 최종 빌드에서는 없애거나 꺼야 합니다.

Tick을 타이머로 바꾸는 효과 계산하기

상태를 확인하는 액터 200개가 60 FPS에서 매 Tick 한 번씩 같은 검사를 한다면, 초당 호출은 200 × 60 = 12,000회입니다. 이것을 0.2초 간격으로 바꾸면 액터마다 초당 5회, 전체로는 1,000회가 됩니다. 다만 이것은 프레임과 간격이 일정하다고 가정한 호출 횟수의 비교일 뿐, CPU 시간이 정확히 1/12로 준다는 뜻은 아닙니다. 같은 프레임에 타이머가 몰릴 수도 있고, 검사 하나하나의 비용도 다르기 때문입니다.

그리고 모든 Tick을 타이머로 바꿀 수 있는 것도 아닙니다. 인벤토리의 수량이 바뀔 때만 UI의 글자를 바꾸면 되는 경우라면, 수량이 바뀌는 이벤트에서 갱신을 부르는 편이 상태를 계속 살피는 것보다 분명합니다. 반대로 문이나 카메라의 부드러운 움직임은 매 프레임 변화가 필요할 수 있으므로, Tick을 무조건 타이머로 바꾸지 않습니다. 타이머로 바꿀 때는 얼마나 늦게 알아차려도 되는지, 대상 참조가 언제까지 유효한지, 반복 실행을 언제 멈출지를 먼저 정합니다.

물리, AI, 그리기 호출

물리 시뮬레이션은 CPU에 큰 부담을 주므로, 물리 시뮬레이션 성능 조정에서 다룬 것처럼 Simulate Physics를 켠 물체의 수를 제한하고, 단순 충돌과 잠들기를 활용하며, Max Physics Delta Time과 Max Substeps 같은 프로젝트 설정을 조정합니다.

AI가 병목으로 측정되었다면 갱신 빈도와 동작하는 범위를 확인합니다. 플레이어에게서 멀리 있는 AI는 행동 로직을 끄거나 아주 단순한 로직만 실행하게 하고, 비헤이비어 트리(Behavior Tree) 노드의 실행 빈도와 블랙보드(Blackboard) 조회 빈도를 줄입니다. 내비게이션 메시는 필요한 영역에만 만들고 복잡도를 줄입니다.

그리기 호출은 에셋과 레벨 최적화 기법에서 다룬 액터 병합과 인스턴싱으로 CPU의 준비 부담을 줄입니다. 엔진은 메시와 재질, 셰이더 바인딩이 호환되는 그리기를 합쳐 주기도 하지만, 가까이 있다는 이유만으로 저절로 합쳐지지는 않으므로 실제 그리기 수를 확인합니다. 작은 텍스처 여러 장을 큰 텍스처 하나(텍스처 아틀라스)에 묶어 쓰면 재질의 수가 줄어 그리기 호출도 아낄 수 있습니다.


메모리와 CPU 측정 도구

Stat Unit의 Game과 Draw 시간으로 CPU 병목을 먼저 확인합니다. Stat Engine으로 엔진 전체의 통계를 보고, 함수 하나하나의 비용은 그에 맞는 Stat 그룹과 언리얼 인사이트의 타이밍 정보로 따라갑니다. Stat Memory는 엔진 서브시스템의 메모리 통계를 보여 주며, GPU 리소스는 Stat RHI와 플랫폼 도구로 함께 확인합니다. 통계마다 무엇을 포함해 세는지가 다르다는 점을 구분합니다.

콘솔에 memreport -full을 입력하면 지금의 메모리 사용량을 자세히 정리한 보고서가 출력 로그나 파일로 만들어져, 어떤 에셋이 메모리를 가장 많이 차지하는지 확인할 수 있습니다.

언리얼 인사이트(Unreal Insights)는 언리얼 엔진 4.23부터 제공되는 프로파일링 도구로, CPU와 GPU, 메모리, 네트워크 같은 엔진의 활동을 자세하게 보여 줍니다. 필요한 트레이스 채널을 켜고 기록한 결과를 UnrealInsights.exe에서 엽니다. 메모리를 분석하려면 개발용(Development) 실행 파일을 시작할 때 -trace=default,memory를 지정해, 할당의 처음부터 기록해야 합니다. 에디터에서 잰 값과 패키징한 게임에서 잰 값은 구분합니다. 특정 프레임의 CPU 순간 부담이 어느 블루프린트 함수에서 생겼는지, 메모리가 어떻게 할당되는지를 자세히 볼 수 있습니다. 원격 장치에서는 지원되는 인사이트 트레이스나 플랫폼의 프로파일러로 수집하며, 세션 프런트엔드(Session Frontend)의 기능은 엔진 버전에 따라 다르므로 확인합니다.

되풀이해 플레이하며 비교할 것

증상마다 비교할 기록은 다음과 같습니다.

증상확인할 기록
메모리가 늘어남같은 이동 경로의 시작, 최댓값, 끝 시점의 값과, memreport, 할당과 해제의 추적
Game 축의 순간 부담Tick, AI, 물리, 생성과 파괴, 가비지 컬렉션의 호출 시간
Draw 축이 늘어남제출과 컴포넌트, 재질 섹션의 수와, 렌더 스레드의 대기
이동 중에 멈춤불러오기 요청과 완료 처리, 스트리밍 셀이 바뀌는 지점을 지나는 시간

한 가지만 바꾼 뒤 같은 구간에서 비교하고, 레벨을 바꿀 때와 오래 플레이할 때, 백그라운드에서 돌아올 때도 메모리가 회수되고 기능이 유지되는지 확인합니다.


정리

  • 메모리 부족은 게임을 종료시킬 수 있고 CPU 과부하는 프레임을 떨어뜨리므로, GPU와 함께 측정하며, CPU 사용률이 낮아도 스레드 하나가 병목일 수 있습니다.
  • 텍스처와 오디오, 애니메이션은 압축 설정으로 메모리를 줄이고, LOD는 그릴 삼각형을 줄일 뿐 메모리를 줄이지는 않습니다.
  • 레벨 스트리밍과 월드 파티션으로 불러오는 범위를 제한하고, 비동기 로딩으로 게임 스레드가 멈추는 일을 줄입니다.
  • 가비지 컬렉션은 참조되지 않는 객체만 회수하며, Collect Garbage를 직접 부르면 순간 부담이 생길 수 있습니다.
  • Tick을 타이머로 바꾸면 호출 횟수는 줄지만 CPU 시간이 같은 비율로 줄지는 않고, 응답이 늦어지는 만큼을 함께 정해야 합니다.
  • 메모리는 Stat Memory와 memreport -full, 언리얼 인사이트의 메모리 트레이스로, CPU는 Stat Unit과 인사이트의 타이밍으로 측정합니다.

연습 문제

1. 0.5초 간격으로 바꾸면

60 FPS에서 액터 120개의 상태를 매 Tick 확인하던 작업을 0.5초 간격으로 바꿉니다. 바꾸기 전과 후의 초당 호출 횟수는 각각 얼마일까요? 상태가 바뀐 뒤 0.1초 안에 반드시 표시해야 하는 UI에도 이 간격을 그대로 써도 될까요?

해설 보기

바꾸기 전에는 120 × 60 = 7,200회/초이고, 바꾼 뒤에는 120 ÷ 0.5 = 240회/초입니다.

하지만 상태가 검사 직후에 바뀌면 다음 검사까지 거의 0.5초를 기다릴 수 있으므로, 0.1초 안에 표시한다는 조건을 만족한다고 보장할 수 없습니다. 이런 UI는 상태가 바뀌는 이벤트에서 바로 표시하거나, 필요한 주기를 다시 정하고 실제 지연을 측정해야 합니다. 호출 횟수가 줄었는지와 동작의 조건을 만족하는지는 따로 확인할 일입니다.

2. 레벨을 바꿔도 줄지 않는 메모리

던전 레벨에서 마을 레벨로 옮겨 갔는데, 던전에서만 쓰던 큰 텍스처들이 메모리에서 사라지지 않습니다. 마을에는 그 텍스처가 보이지 않습니다. 무엇을 의심해야 할까요?

해설 보기

가비지 컬렉션은 어디에서도 참조되지 않는 객체만 회수하므로, 화면에 보이지 않는다는 이유만으로 텍스처가 사라지지는 않습니다. 게임 인스턴스나 마을에 남아 있는 블루프린트 같은 어딘가에서 그 텍스처나 그것을 쓰는 에셋을 강하게 참조하고 있을 수 있습니다.

memreport -full이나 언리얼 인사이트의 메모리 트레이스로 그 텍스처가 아직 메모리에 있는지와 누가 참조하는지를 확인하고, 필요하지 않은 강한 참조는 소프트 참조로 바꾸거나 레벨을 바꿀 때 참조를 비웁니다.

3. LOD를 늘렸는데 메모리가 늘었다

메모리를 줄이려고 모든 바위 메시에 LOD를 세 단계씩 더 만들었습니다. 그런데 메모리 사용량이 오히려 늘었습니다. 왜 그럴까요?

해설 보기

LOD는 멀리 있을 때 그릴 삼각형의 수를 줄이는 장치이고, 단계마다 메시 데이터가 따로 저장되므로 LOD를 더 만들면 데이터 자체는 오히려 늘어납니다. 그래서 메모리가 늘어난 것입니다.

LOD는 GPU의 그리기 비용을 줄이는 데는 효과가 있지만, 메모리를 줄이려는 목적이라면 텍스처의 해상도와 압축, 쓰지 않는 에셋의 정리, 스트리밍으로 메모리에 올라오는 범위를 줄이는 방법을 써야 합니다.

메모리 분석은 Epic의 메모리 인사이트 안내에서 확인할 수 있습니다.

이것으로 9장을 마칩니다. 9장에서는 Stat Unit과 ProfileGPU, 시각화 모드로 병목을 찾는 방법, 메시와 텍스처, 재질, 레벨 구조를 최적화하는 기법, 환경 설정마다의 비용과 예산, 품질 기준을 함께 놓고 판단하는 방법, 그리고 메모리와 CPU 사용량을 측정하며 줄이는 방법까지 성능 최적화를 다뤘습니다. 다음 장의 환경 설정의 배포 준비부터는 만든 환경을 배포할 수 있게 정리하는 데서 시작해, 패키징과 빌드 설정, 배포 뒤의 유지보수, 버전 관리와 패치, 사용자 피드백을 통한 개선, 크로스 플랫폼 배포까지 배포와 유지보수를 다룹니다.