문승현 튜터님 강의 - ‘메모리 할당’
GPU와 렌더링 파이프라인의 기초에 대하여 알아보자
문승현 튜터님의 Notion 자료를 바탕으로 강의를 들으며
수정 및 재작성한 블로깅용 글
1. 메모리 할당의 비용 (The Cost of Memory Allocation)
1.1 개요: 왜 게임 개발자는 ‘New’를 두려워해야 하는가?
게임 엔진, 특히 언리얼 엔진 환경에서 최적화의 제1원칙은
“런타임 중의 빈번한 동적 할당을 피하라”는 것입니다.
우리가 C++에서 new를 호출하거나 언리얼에서 SpawnActor를 수행할 때,
단순히 메모리 공간이 생기는 것으로 생각하기 쉽습니다.
하지만 그 이면에는 유저 모드와 커널 모드를 넘나드는
복잡한 ‘권한 요청’과 ‘자원 탐색’ 과정이 숨어 있습니다.
60FPS(프레임당 16.6ms)를 다투는 실시간 환경에서
이 비용은 곧바로 프레임 드랍(Hitch)으로 직결됩니다.
1.2 하드웨어와 OS의 협력: 동적 할당의 메커니즘
동적 할당은 단순히 변수를 선언하는 것과 달리,
운영체제의 자원 관리자에게 ‘땅’을 빌려오는 행위입니다.
-
- 시스템 콜(System Call) 발생
- 유저 모드에서 실행 중인 게임 루틴은 물리적인 RAM을 직접 조작할 권한이 없습니다.
따라서malloc()이나new를 호출하면, CPU는 실행 중인 코드를
멈추고 모드 비트를 전환(Mode Switch)하여 커널 모드로 진입합니다.
- 시스템 콜(System Call) 발생
-
- 커널의 자원 탐색
- 운영체제는 현재 프로세스에 할당된 힙(Heap) 영역을 뒤지며
요청한 크기에 딱 맞는 ‘연속된 빈 공간’이 있는지 확인합니다.
만약 적절한 공간이 없다면, OS는 페이지 테이블을 갱신하거나
커널 수준의 메모리 관리 알고리즘을 실행하여 새로운 영역을 확보해야 합니다.
- 커널의 자원 탐색
-
- 장부 기록(Management Overhead)
- 할당된 메모리의 주소, 크기, 할당 상태 등을 커널의 관리 장부에 기록하는 과정이 수반됩니다.
이는 단순 연산보다 훨씬 무거운 I/O 작업에 가깝습니다.
- 장부 기록(Management Overhead)
1.3 언리얼 엔진에서의 구체적 비용: SpawnActor와 NewObject
언리얼 엔진에서 객체를 생성하는 것은 일반적인 C++의 new보다 훨씬 더 무겁습니다.
이는 엔진이 제공하는 다양한 ‘편의 기능’들이
메모리 할당 시점에 함께 작동하기 때문입니다.
-
- CDO(Class Default Object) 복사
- 클래스의
기본 설정을 복제하여 인스턴스를 만듭니다.
- CDO(Class Default Object) 복사
-
- 컴포넌트 등록
- 액터에 붙은 각종 컴포넌트(Mesh, Collision, Audio 등)를
렌더링 스레드와 물리 스레드에 등록하는 과정이 수반됩니다.
- 컴포넌트 등록
-
- 가비지 컬렉션(GC) 등록
- 생성된 객체는 언리얼의 GC 시스템 감시망에 등록되어야 합니다.
객체가 많아질수록 GC가 검사해야 할 리스트가 길어져,
추후 전체 시스템의 성능 저하를 야기합니다.
- 가비지 컬렉션(GC) 등록
1.4 비유: 택배 주문과 편의점 구매
동적 할당의 비용을 이해하기 위해 실생활에 비유해 보겠습니다.
-
- 정적 할당(Stack)
- 내 주머니에 있는 지갑에서 돈을 꺼내는 것과 같습니다. 즉각적이고 비용이 거의 없습니다.
- 정적 할당(Stack)
-
- 동적 할당(Heap)
- 물건이 필요할 때마다 본사에 ‘택배 주문’을 하는 것과 같습니다.
- 주문서를 작성하고(System Call)
- 본사 직원이 창고에서 물건을 찾고(Kernel Search)
- 배송 장부에 기록한 뒤(Management)
- 집 앞까지 배송하는 과정(Memory Mapping)이 필요합니다.
총알 한 발을 쏠 때마다 택배 주문을 한다면,
총을 연사하는 순간 집 앞은 택배 상자와 배송 트럭으로 마비될 것입니다.
이것이 바로 게임에서 발생하는 ‘메모리 병목’입니다.
1.5 성능의 적: 문맥 교환(Context Switch)과 캐시 미스
동적 할당이 무서운 진짜 이유는 단순히 시간이 걸리기 때문만이 아닙니다.
-
- 모드 전환 오버헤드
- 앞서 배운 이중 동작 모드에 따라,
유저 모드에서 커널 모드로 전환될 때마다 CPU의 레지스터 상태를 저장하고 복구하는 비용이 발생합니다.
- 모드 변경에도 오버헤드 발생에 유의
- 모드 전환 오버헤드
-
- CPU 캐시 오염
- 커널 코드가 실행되면서 CPU 캐시(L1, L2)에 들어있던 게임 데이터들이 밀려나고
커널의 관리 데이터들이 그 자리를 차지합니다.
다시 게임 루프로 돌아왔을 때, CPU는 데이터를
다시 RAM에서 읽어와야 하는 캐시 미스(Cache Miss) 상황에 직면하게 됩니다.
- CPU 캐시 오염
1.6 요약 및 결론
동적 할당은 편리하지만, 운영체제와의 치열한 상호작용을 전제로 합니다.
- 예측 불가능성: OS가 메모리 지도를 뒤지는 시간은 매번 다를 수 있어 프레임 시간이 불규칙해집니다.
- 커널 의존성: 커널 모드 진입 자체가 시스템의 흐름을 끊는 ‘무거운 이벤트’입니다.
- 관리 부하: 빈번한 할당과 해제는 ‘메모리 단편화‘라는 더 큰 재앙을 불러옵니다.
결국, 최고의 최적화는 “게임 중에 OS에게 말을 걸지 않는 것”입니다.
이를 통해 하드웨어적/OS적 비용을 감소시키는 방식으로 최적화를 고민해야합니다.
- 다양한 최적화 기법은 결국 이러한 근본적인 이유에 기반함
2. 메모리 단편화 (Memory Fragmentation)
2.1 개요: 왜 여유 메모리가 있는데도 ‘Memory Out’이 발생하는가?
운영체제로부터 메모리를 빌려오고 반납하는 과정이 반복되면,
메모리 공간은 마치 ‘구멍 난 치즈’처럼 변하게 됩니다.
분명 전체 여유 공간의 합계는 충분함에도 불구하고,
정작 큰 데이터를 담으려고 하면 “공간이 부족하다”는 에러와 함께 게임이 강제 종료되곤 합니다.
이러한 현상을 메모리 단편화(Memory Fragmentation)라고 하며,
이는 장시간 실행되는 게임의 안정성을 해치는 가장 큰 적입니다.
2.2 내부 단편화 (Internal Fragmentation)
내부 단편화는 운영체제가 메모리를 할당할 때
‘효율적인 관리’를 위해 정해진 크기 단위(예: 4KB 페이지)로 할당하기 때문에 발생합니다.
-
현상: 프로그램이 100바이트만 요청했지만, OS의 최소 할당 단위가 1024바이트라면 나머지 924바이트는 사용되지 못한 채 낭비됩니다.
-
원인: 메모리 관리 장치(MMU)가 주소를 계산할 때, 너무 잘게 쪼개는 것보다 일정한 덩어리(Chunk) 단위로 관리하는 것이 속도 면에서 유리하기 때문입니다.
-
결과: 할당된 영역 ‘안쪽’에 쓰이지 않는 빈 공간이 생기며, 이는 전체적인 메모리 사용량을 야기한 것보다 부풀리게 만듭니다.
2.3 외부 단편화 (External Fragmentation)
게임 엔진에서 가장 치명적인 문제는 바로 외부 단편화입니다.
이는 다양한 크기의 객체들이 생성되고 소멸되는 과정에서 발생합니다.
-
현상: 메모리 곳곳에 할당된 블록 사이사이에 아주 작은 빈 공간들이 흩어져 있는 상태입니다.
-
- 원인
- 예를 들어, 10MB의 빈 공간이 있지만 1MB씩 10군데로 흩어져 있다면,
2MB 크기의 텍스처 데이터를 한 번에 올릴 수 없습니다.
(컴퓨터의 메모리 주소는 ‘연속적’이어야 하기 때문입니다.)
- 원인
-
- 언리얼 엔진의 사례
TArray의 크기가 커져서 메모리 재할당(Reallocation)이 일어날 때,
기존보다 더 큰 연속된 공간을 찾지 못하면 메모리가 충분해도 엔진은 크래시를 일으킵니다.
- 언리얼 엔진의 사례
2.4 비유: 주차장과 테트리스 (The Metaphor)
이 현상은 우리가 매일 겪는 ‘주차장’ 문제와 매우 흡사합니다.
1.할당과 해제
- 주차장에 경차, 승용차, 대형 버스가 수시로 들어오고 나갑니다.
2.단편화 발생
- 버스가 나간 자리에 경차가 주차되고, 승용차가 나간 자리에 또 다른 경차가 주차됩니다.
3.교착 상태
- 이제 거대한 관광버스(대형 에셋) 한 대가 들어오려고 합니다. 주차장 전체 면적을 합치면 버스 3대는 들어갈 공간이 남았지만,
차들 사이사이에 작은 공간만 있을 뿐 버스 한 대가 들어갈 ‘연속된 빈 칸’이 없습니다.
4. 결과
- 결국 버스는 주차를 포기하고 돌아가야 합니다. 이것이 게임의 ‘Out of Memory’ 크래시입니다.
2.5 언리얼 엔진의 대응: FMallocBinned
언리얼 엔진은 운영체제의 기본 할당자에게만 의존할 경우 발생하는 단편화를 방지하기 위해 Binned Allocator(구획화 할당자)를 독자적으로 운영합니다.
- 작동 원리: 메모리를 ‘크기별 보관함’으로 나눕니다. 8바이트 전용 구역, 16바이트 전용 구역 등으로 미리 땅을 선점해둡니다.
- 효과: 비슷한 크기의 객체끼리 모아서 관리하기 때문에, 작은 객체가 나간 자리에 큰 객체가 억지로 끼어들며 생기는 외부 단편화를 획기적으로 줄입니다.
- 한계: 그럼에도 불구하고 수만 개의 액터가 생성/삭제되는 런타임 환경에서는 완벽한 방어가 불가능합니다.
2.6 요약 및 결론
메모리 단편화는 시간이 흐를수록 악화됩니다.
일단 발생한 단편화를 다시 정리(Compaction)하는 것은 CPU에 엄청난 부하를 주는 작업입니다.
따라서 가장 현명한 방법은 “파편화가 일어날 기회 자체를 주지 않는 것”입니다.
최적화 역시 그런 방식으로 고려해야 합니다.
3. 최적화 기법 : 오브젝트 풀링 (Object Pooling)
3.1 개요: 버리지 않고 다시 쓰는 ‘자원 순환’의 기술
앞서 우리는 운영체제에 메모리를 요청하는 행위가 얼마나 비싼지(Step 1),
그리고 무분별한 할당과 해제가 어떤 재앙을 불러오는지(Step 2) 배웠습니다.
오브젝트 풀링(Object Pooling)은 이 모든 문제에 대한 가장 고전적이면서도 강력한 해답입니다.
핵심은 간단합니다. “필요할 때 만들지 말고, 미리 만들어둔 것을 꺼내 쓰자”는 것입니다.
3.2 작동 원리: 대여소(Library) 시스템
오브젝트 풀링은 메모리 관리의 주도권을 운영체제(OS)로부터 빼앗아
게임 엔진(User Mode)으로 가져오는 전략입니다.
- 1.준비(Warm-up)
- 게임이 시작되거나 레벨이 로드될 때,
예상되는 최대 개수(예: 총알 500개)의 객체를 미리 생성하여 리스트(Pool)에 담아둡니다.
이때 메모리 할당 비용을 한꺼번에 지불합니다. - 2.대여(Acquire)
- 새로운 객체가 필요하면
Spawn을 호출하는 대신,
풀에서 ‘비활성’ 상태인 객체를 찾아 ‘활성’ 상태로 바꾼 뒤 사용합니다. - 3.반납(Release)
- 객체의 수명이 다하면(예: 벽에 충돌)
Destroy를 호출하여 메모리를 해제하는 대신, 다시 ‘비활성’ 상태로 돌려 풀에 반납합니다.
3.3 CS적 관점에서의 이점: 왜 압도적으로 빠른가?
오브젝트 풀링이 성능을 높이는 이유는 단순히 “미리 만들어서”가 아닙니다.
하드웨어와 OS 레벨에서 일어나는 다음의 변화에 주목해야 합니다.
-
- 시스템 콜(System Call) 제로화
- 런타임 중에
new나malloc을 호출하지 않으므로,
유저 모드에서 커널 모드로의 전환(Mode Switch)이 발생하지 않습니다.
CPU는 오직 게임 로직에만 전념할 수 있습니다.
- 시스템 콜(System Call) 제로화
-
- 데이터 지역성(Data Locality) 향상
- 풀에 저장된 객체들은 메모리 상에서 비교적 가깝게 배치됩니다.
이는 CPU 캐시 적중률(Cache Hit Rate)을 높여,
메모리에서 데이터를 읽어오는 속도를 비약적으로 향상시킵니다.
- 데이터 지역성(Data Locality) 향상
-
- 가비지 컬렉션(GC) 부하 감소
- 언리얼 엔진 입장에서 객체의 개수가 일정하게 유지되므로,
GC가 매번 새로운 객체를 추적하거나 파괴된 객체를 정리하느라 스캔할 필요가 없어집니다.
- 가비지 컬렉션(GC) 부하 감소
3.4 언리얼 엔진에서의 실무 구현 (Step-by-Step)
언리얼 엔진에서 오브젝트 풀링을 구현할 때 반드시 거쳐야 하는 물리적인 단계들입니다.
1.Pooling Manager 작성
- 객체들을 담아둘
TArray와 이를 관리할 로직을 가진 Manager 액터를 생성합니다.
2.비활성화 로직(Deactivate) - 객체를 ‘삭제’하는 것이 아니기 때문에, 눈에 보이지 않고 물리 연산에 참여하지 않도록 처리해야 합니다.
SetActorHiddenInGame(true)SetActorEnableCollision(false)SetActorTickEnabled(false)
3. 초기화 로직(Reset)
- 풀에서 다시 꺼내 쓸 때, 이전의 상태(위치, 속도, 남은 시간 등)가 남아있지 않도록 완벽히 초기화해야 합니다.
이를 생략하면 총알이 이전 폭발 위치에서 갑자기 튀어나오는 등의 버그가 발생합니다.
3.5 비유: 뷔페의 접시 관리 (The Metaphor)
오브젝트 풀링은 뷔페 식당의 ‘접시 관리’와 똑같습니다.
-
최악의 운영:
손님이 올 때마다 공장에 전화를 걸어 새 접시를 주문하고(Spawn),
손님이 다 쓰면 접시를 깨뜨려 버리는(Destroy) 방식입니다.
공장장(OS)은 화를 낼 것이고, 식당 바닥은 파편(단편화)으로 가득 찰 것입니다. -
풀링 운영:
식당이 문을 열 때 충분한 양의 접시를 미리 사둡니다(Pre-allocation).
손님이 다 쓴 접시는 깨끗이 씻어서(Reset) 다시 쌓아둡니다(Release).
손님은 기다릴 필요가 없고, 식당은 항상 깨끗하게 유지됩니다.
3.6 결론: 예측 가능한 성능 (Predictability)
오브젝트 풀링의 진정한 가치는 ‘성능의 예측 가능성’에 있습니다.
프레임당 실행 시간이 들쭉날쭉한 게임은 플레이어에게 불쾌한 경험을 줍니다.
풀링을 사용하면 CPU 사용량이 일정하게 유지되므로, 하드웨어 자원을 극한까지 안정적으로 활용할 수 있습니다.
💡 핵심 정리
- 오브젝트 풀링은 메모리 할당 비용을 런타임에서 로딩 타임으로 전이시키는 기술이다.
- 시스템 콜과 캐시 미스를 줄여 CPU 효율을 극대화한다.
- 언리얼 엔진에서는
Destroy대신 상태값 변경(Hidden/Collision)을 통해 재사용을 구현한다.
- 게임성을 유지하며, 사용자에게 ‘쾌적함’을 제공하기 위해 ‘최적화’가 필요함
4. 언리얼 자체 할당자와 가비지 컬렉션 (Unreal Allocator & GC)
4.1 개요: 왜 언리얼은 OS의 할당자를 거부하는가?
현대 운영체제의 범용 메모리 할당자(Generic malloc)는
워드, 웹 브라우저, 게임 등 모든 프로그램에 대응해야 하는 ‘범용성’에 초점이 맞춰져 있습니다.
하지만 극한의 성능을 요구하는 언리얼 엔진 입장에서
OS의 할당 방식은 너무 느리고, 단편화에 취약합니다.
언리얼은 이를 해결하기 위해 OS로부터 거대한 영역을 미리 할당받은 뒤,
그 안에서 엔진만의 독자적인 ‘계급 체계’를 운영합니다.
4.2 FMallocBinned: 크기별 구획화 전략
언리얼 엔진의 핵심 할당 방식인 Binned Allocator는
앞서 배운 내부/외부 단편화 문제를 하드웨어 수준에서 최적화합니다.
- Bin(보관함)의 개념: 메모리를 특정 크기(예: 8, 16, 32… 32768 바이트)의 보관함으로 미리 나눕니다.
- 작동 원리: 객체가 20바이트를 요청하면, 16바이트 보관함에는 들어가지 않으므로 즉시 32바이트 보관함에서 한 칸을 내어줍니다.
- CS적 이점:
- 검색 속도: OS처럼 빈 공간을 찾기 위해 전체 메모리 지도를 뒤질 필요 없이, 해당 크기의 ‘보관함 리스트’만 확인하면 되므로 할당 속도가 O(1)에 가깝습니다.
- 외부 단편화 방지: 같은 크기의 객체끼리 모여 있기 때문에, 객체가 사라진 자리에 항상 같은 크기의 객체가 들어올 수 있어 ‘주차장 마비’ 현상이 발생하지 않습니다.
- 검색 속도: OS처럼 빈 공간을 찾기 위해 전체 메모리 지도를 뒤질 필요 없이, 해당 크기의 ‘보관함 리스트’만 확인하면 되므로 할당 속도가 O(1)에 가깝습니다.
4.3 가비지 컬렉션(GC)과 메모리의 관계
언리얼은 C++ 기반임에도 불구하고 자바처럼 자동 메모리 관리(GC)를 수행합니다.
이 과정은 OS의 스케줄링과 유사한 부하를 발생시킵니다.
-
- Mark and Sweep
- 엔진은 주기적으로 모든 객체를 전수 조사하며
“이 객체를 아직 쓰고 있는가?”를 확인합니다(Mark).
쓰지 않는다면 메모리에서 해제합니다(Sweep).
- Mark and Sweep
-
- 오버헤드 발생
- 객체(UObject)의 수가 많아질수록 GC가 조사해야 할 ‘가족 관계도’가 복잡해집니다.
이는 프레임 타임을 잡아먹는 ‘GC 스파이크’ 현상의 원인이 됩니다.
- 오버헤드 발생
-
- 풀링과의 시너지
- 오브젝트 풀링을 사용하면 객체를 파괴하지 않고 재사용하므로,
GC가 관리해야 할 대상의 총합이 일정하게 유지되어 시스템 안정성이 비약적으로 향상됩니다.
- 풀링과의 시너지
4.4 실무 최적화 팁: TArray의 예약(Reserve)
컨테이너의 동작 방식도 OS의 페이지 할당 원리와 맞닿아 있습니다.
- 현상:
TArray에 데이터를 계속Add하다 보면, 어느 순간 할당된 공간이 꽉 찹니다. 이때 엔진은 기존 공간의 1.5~2배에 달하는 새로운 연속된 공간을 찾아 모든 데이터를 복사하고 기존 공간을 지웁니다. - 비용: 이 ‘복사와 재할당’ 과정은 매우 무거운 작업입니다.
- 해결:
Reserve(예상_개수)를 미리 호출하십시오. 이는 OS에게 “이만큼의 땅은 확실히 쓸 테니 미리 비워둬”라고 선언하는 것과 같으며, 런타임 중 재할당으로 인한 프레임 드랍을 원천 봉쇄합니다.
4.5 결론: 하드웨어를 이해하는 개발자가 성능을 지배한다
지금까지 우리는 이중 동작 모드부터 시작하여 언리얼의 메모리 할당자까지 숨 가쁘게 달려왔습니다.
결국 운영체제(OS)는 공정한 관리자이지만, 실시간성이 생명인 게임 엔진에게는 그 공정함이 때로는 독이 됩니다.
우리는 운영체제가 자원을 관리하는 원리(시스템 콜, 단편화, 페이지 테이블)를 이해함으로써,
“운영체제가 개입할 틈을 주지 않는 최적화”를 이뤄낼 수 있습니다.
- 동적 할당을 최소화하여 커널 모드로의 전환을 막고,
- 오브젝트 풀링을 통해 메모리 지도를 깔끔하게 유지하며,
- 엔진의 할당 특성을 이용해 하드웨어 캐시 효율을 극대화하십시오.
이것이 단순한 ‘코더’를 넘어 시스템의 한계를 끌어내는 ‘엔지니어’로 나아가는 길입니다.
댓글남기기