게임 개발에서 스택과 힙을 아는 게 중요할까요?

개발을 처음 공부하시는 분들은 스택과 힙을 아는 게 그렇게 중요할까?
그냥 적절하게 변수를 생성하고, 생성한 변수를 다루는 함수를 잘 작성해서 기능을 구현하면 되는 게 아닐까?
정도로 생각하실 지도 모르겠습니다.
게임 개발을 공부하다 보면 메모리 관련 이야기를 자주 듣게 됩니다.
아래 나열된 키워드를 한 번쯤은 들어보셨을 겁니다.
- allocator
- memory pool
- cache locality
- fragmentation
- ECS
- linear allocator
저도 처음 공부할 때는 스택, 힙, 메모리 파편화, CPU 캐싱 등 많은 키워드들을 접하고 공부도 했지만,
제대로 이해하거나 그 필요성을 느끼지 못했던 것 같습니다.
그저 new를 사용하면 delete를 잘 사용해서 메모리 누수가 없게 해야되는구나 정도로만 받아들였던 것 같습니다.
이런 키워드들을 접하면 “그냥 메모리를 조금 더 빠르게 사용하기 위한 이야기 아닐까?”싶기도 합니다.
그런데 대부분의 게임 엔진들은 메모리를 효율적으로 사용하기 위해서 자체적으로 메모리를 직접 관리합니다.
그리고 최근 CPU는 연산 자체보다 메모리를 접근하는 비용이 훨씬 더 비쌉니다.
예전에는 CPU의 연산 속도 자체가 중요해서 CPU 연산량을 줄이는 최적화를 많이 진행했지만,
최근에는 CPU가 메모리에 접근하는데 드는 시간이 더 큰 문제가 되는 경우가 많습니다.
이런 관점에서 Stack과 Heap을 다시 살펴보겠습니다.
스택(Stack)은 왜 빠를까?
스택은 보통 자동 메모리로 불립니다.
함수를 호출할 때 파라미터와 지역 변수가 할당되는 공간이 스택입니다.
예를 들어 아래와 같이 Foo 함수 내부에 value라는 지역 변수를 선언하면, 이 변수는 스택에 저장됩니다.
void Foo()
{
int value = 10;
}
그리고 Foo 함수의 실행이 종료되면 스택 메모리에 할당되었던 value는 자동으로 정리됩니다.
여기까지는 대부분 알고 알고 있을 겁니다.
그런데 여기에서 중요한 건 스택이 왜 빠를까?입니다.
스택과 힙이 사용하는 물리적 메모리 공간은 같습니다. 즉, 스택도 힙도 모두 램(Ram)의 특정 공간을 사용합니다.
물리적인 공간을 나눠서 사용하는데 왜 스택은 빠르고 힙은 느릴까요?
스택은 기본적으로 구조가 굉장히 단순합니다.
메모리가 위로(또는 아래로) 쌓입니다.
Push Push Push Pop Pop
스택은 후입선출(Last In First Out, LIFO) 구조입니다.
그래서 메모리를 할당할 때 복잡한 탐색이 거의 필요하지 않습니다.
보통은 스택 포인터(Stack Pointer) 하나만 이동시키면 할당이 끝납니다.
의사 코드로 작성해본다면 아래 코드와 같습니다.
rsp -= size
따라서 메모리 관리 비용 자체가 굉장히 작다고 할 수 있습니다.
그래서 스택을 사용하는 함수 호출은 생각보다 매우 빠르게 동작합니다.
스택은 생각보다 제한적이다
지금까지 스택의 좋은 점을 살펴봤습니다.
스택은 구조가 단순하고, 관리 비용이 저렴하기 때문에 동작이 빠르다는 점을 배웠습니다.
이렇게 좋은 스택을 많이 사용할 수 있다면 참 좋겠지만, 문제는 스택 메모리의 크기가 제한적이라는 점입니다.
예를 들어, 아래 코드와 같이 함수 내부에서 지역 변수를 할당할 때 큰 메모리 공간을 할당하려고 하면 스택이 가득차 넘치는 Stack Overflow 오류가 발생할 수 있습니다.
void Foo()
{
int hugeArray[10000000];
}
스택은 보통 크기가 작게 제한되어 있기 때문입니다.
게임을 개발할 때 재귀 호출이나 지역 변수로 배열을 너무 크게 할당하면, Stack Overflow가 발생할 수 있습니다.
힙(Heap)은 왜 필요한가?
스택과 달리 힙은 런타입(실행중)에 동적으로 사용하는 메모리 영역을 말합니다.
예를 들어, 아래 코드와 같이 new 키워드를 사용해서 메모리를 할당하면 메모리 영역 중에서 힙 공간에 할당됩니다.
int* value = new int(10);
STL에서 많이 사용하는 std::vector도 내부에서 데이터를 관리할 때 힙을 사용합니다.
std::vector<int> values;
그리고 게임 엔진에서 사용되는 대부분의 게임 객체 대부분은 힙에 생성됩니다.
- GameObject
- Actor
- Component
- Animation Data
- Texture Resource
같은 오브젝트를 예로 들 수 있습니다.
게임을 제작하다 보면 실행 중에 동적으로 생성되고 제거되는 데이터가 굉장히 많기 때문에 힙을 자주 사용하게 됩니다.
예를 들면, FPS 게임에서 총알을 발사하거나, 특정 효과를 위해 파티클 이펙트를 생성하거나, 몬스터가 스폰(Spawn)되거나, UI를 생성하거나
등등의 작업은 실행 중에 동적으로 메모리를 할당해야 합니다.
이런 상황에서는 힙 메모리가 필요합니다.
힙 할당(Heap Allocation)은 왜 느릴까?
앞서 스택은 구조와 메모리 관리가 단순해 동작이 빠르다고 살펴봤습니다.
하지만, 스택은 사용할 수 있는 공간이 제한적이라는 단점이 있었습니다.
그리고 스택 메모리를 사용하려면 사전에 사용하려는 크기가 예측이 가능해야 합니다.
그런데 게임을 제작하다 보면 실행 전에 정확히 어느 정도의 메모리를 사용해야 할지 예상되지 않는 경우가 많습니다.
그래서 힙을 사용해야 하는데, 문제는 힙 할당(Heap Allocation)은 생각보다 비싸다는 점입니다.
왜냐하면 힙은 스택처럼 단순히 포인터 하나만 움직여서 메모리 관리를 할 수 있는 단순한 구조가 아니기 때문입니다.
힙 할당을 하면 내부적으로 여러 처리가 이루어집니다.
- 빈 공간 탐색
- 메모리 분할
- 할당 처리
- 파편화 관리
등 여러 작업들을 수행해야 합니다.
이처럼 힙은 메모리 관리 자체가 훨씬 복잡합니다.
특히, 게임처럼 메모리의 할당/해제가 반복되는 환경에서는 문제가 더 커질 수 있습니다.
탄약을 발사하는 기능을 구현하는 상황을 생각해보겠습니다.
탄약을 발사하면 탄약을 생성하고, 탄약이 화면을 벗어나거나 다른 물체에 부딪히면 탄약을 제거하도록 구현했다고 가정해보겠습니다.
이 경우에 탄약을 연속해서 발사하면 생성, 해제, 생성, 해제를 반복할 것입니다.
이 과정이 반복되면 메모리가 점점 조각나기 시작합니다.
이렇게 메모리가 조각나는 현상을 메모리 파편화(Memory Fragmemntation)이라고 부릅니다.
메모라 파편화가 심해지면, 다음 메모리를 할당할 때 사용 가능한 연속적인 메모리를 검색하는데 드는 비용이 커질 수 있어서 메모리 접근 효율이 떨어집니다.
또한, 메모리 사용 공간이 연속적이지 않은 힙의 특성 때문에 CPU 캐시 효율도 떨어집니다.
게임 엔진은 allocator를 직접 만든다
게임 엔진들은 대부분 자체적인 힙 할당기(Allocator)를 만들어 사용합니다.
앞서 설명한 이유로 힙 할당을 사용하면 성능 문제가 쉽게 발생할 수 있기 때문입니다.
그래서 게임 엔진들은 아래와 같은 메모리 관리자를 직접 구현해 사용합니다.
- Memory Pool
- Linear Allocator
- Arena Allocator
- Frame Allocator
예를 들어, 앞서 설명한 탄약처럼 수명이 짧고 반복 생성되는 객체는 new/delete를 반복해 할당/해제를 반복하는 것보다
미리 큰 메모리를 확보해둔 다음, 필요한 경우에 미리 확보해둔 메모리를 재사용하는 방식이 훨씬 더 효율적입니다.
그래서 게임 엔진에서는 allocator가 굉장히 중요한 주제입니다.
진짜 중요한 건 캐시(Cache)다
스택과 힙 메모리 동작의 특징과 게임 엔진에서 왜 힙을 사용할 수밖에 없는지, 그리고 힙을 사용할 때의 주의사항을 살펴봤습니다.
그런데 최근 게임 개발에서는 스택/힙 자체보다 캐시 지역성(Cache Locality)이 더 중요합니다.
앞서 설명했듯이, 최근 CPU는 연산 속도 자체보다 메모리를 접근하는 속도 때문에 성능이 좌우되기 때문입니다.
아래 코드에서는 std::vecctor에 Transform 타입을 사용했습니다.
std::vector에 저장되는 요소의 타입이 std::vector가 배치되는 메모리가 바로 저장되도록 선언했습니다.
이 경우에는 여러 Transform 데이터가 연속적으로 배치가 되기 때문에 CPU 캐시 효율이 굉장히 좋습니다.
std::vector<Transform>
반면, 같은 std::vector를 사용하더라도 아래 코드와 같이 Transform* 즉, 포인터형 타입을 사용하면 해당 데이터에 접근을 하기 위해 저장된 주소로 이동해야 합니다.
이 경우에는 바로 옆에 있는 데이터를 접근하기 위해서 전혀 다른 메모리 공간으로 이동해야 합니다.
std::vector<Transform*>
이처럼 포인터를 따라다니는 구조는 캐시 미스(Cache Miss)가 발생할 확률이 높습니다.
다시 말하면, 캐시 지역성이 좋지 않고, 캐시 효율이 낮습니다.
최근 게임 개발에서 자주 등장하는 키워드들이 있습니다.
- ECS: Entity Component System
- DOD: Data Oriented Design
- GPU Driven Rendering
등이 대표적인 키워드들입니다.
이 키워드들은 모두 캐시 친화적인 구조가 중요하다는 점을 강조하고 있습니다.
예전 게임 엔진들은 최적화를 위해서 어떻게 하면 CPU 연산량을 줄일 수 있을까?를 고민하며 발전했습니다.
(대표적으로 전설적인 게임 개발자 존 카멕이 퀘이크를 개발할 때 사용했던 Fast Inverse Square Root 기법이 있습니다. 아주 유명하고, 재미있는 기법이니 공부해보셔도 좋을 것 같습니다.)
그런데 최근 게임 엔진은 단순히 “빠른 코드”보다도 “메모리를 어떻게 배치할 것인가”를 더 중요하게 다루는 경우가 많아졌습니다.
마무리
지금까지 스택과 힙, 그리고 캐시 효율이 왜 중요한 지에 대해 살펴봤습니다.
스택과 힙은 단순히 면접을 위한 CS 개념으로 끝나지 않습니다.
최근의 최적화가 잘되어 있는 게임 및 게임 엔진을 살펴보면, 결국 꼬리표처럼 메모리 구조에 대한 언급이 지속적으로 등장합니다.
아래와 같은 키워드들이 대표적이라고 할 수 있습니다.
- allocator
- cache locality
- memory pool
- ECS
- rendering architecture
이 키워드들이 공통적으로 강조하는 건 결국, 메모리 접근 비용이 중요한 문제라는 것입니다.
다음 글에서는 왜 Cache Miss가 실제 게임 성능에 큰 영향을 주는지,
그리고 왜 최근 엔진들이 데이터 기반 설계(Data Oriented Design)를 중요하게 보는지에 대해 조금 더 자세히 정리해보겠습니다.