- Published on
[Dev Log] i9 프로세서도 박살 내는 뱀파이어 서바이버의 진실: Update의 배신과 글로벌 버스
- Authors
- Name
- Logan Kim
1. 뱀파이어 서바이버가 하이엔드 PC를 꿇렸던 진짜 이유
인디 게임의 신화, **'뱀파이어 서바이버'**의 초기 버전을 기억하는가? 화면을 뒤덮는 수만 마리의 몬스터와 보석들 사이에서, 최신형 i9 프로세서와 RTX xx80시리즈를 장착한 하이엔드 PC조차 프레임이 10점대로 곤두박질치는 기현상이 벌어졌다.
게이머들은 "그래픽도 도트 쪼가리인데 왜 렉이 걸리냐"며 의아해했지만, 시스템 아키텍트의 눈에 원인은 명백했다. 초기 뱀파이어 서바이버는 자바스크립트(Electron + Phaser) 기반으로 짜여 있었고, 수만 개의 객체가 단일 스레드(Single Thread) 위에서 각자의 생명주기를 가진 객체가 지멋대로 뛰어놀다가 같이 뒤지는것이다. (게임 성공 이후, 금융치료가 이어지자 그들은 유니티를 써서 이것을 해결했다.)
물론 완전히 동일선상의 비교는 아니지만, 유니티에서도 이와 유사한 패턴은 유니티에도 있다. 그건 바로 유니티가 제공하는 가장 친절하고 위험한 도구인 Update()다.
2. Update()의 배신: C++과 C#의 철수찾기
유니티의 심장(엔진 코어)은 C++로, 우리가 작성하는 스크립트는 C#으로 돌아간다. 10,000마리의 몬스터 프리팹에 각각 Enemy.cs를 붙여놓고 그 안에 빈 Update() 함수라도 열어둔다면 시스템 밑바닥에서는 어떤 일이 벌어질까?
이 작업을 쉬운말로 철수찾기라고 휴먼 틀딱체를 써서 명명하겠다. 남에집 문 앞에서 철수를 찾는건 어려운 작업은 아니다. 그런데 이 경우에는 C++과 C#이라는 두 언어의 사이에서 철수있냐? 를 매번 만번씩 물어보는것이다. 그런데 이 update함수는 CPU성능에 따라서 초당 수백번이상도 호출된다.
3. 주니어의 방식: 각자도생 (The Naive Approach)
수천 개의 탄막이나 몬스터가 각각의 스크립트를 달고 아래와 같이 움직인다고 가정해 보자.
// 흔한 주니어의 탄막 스크립트
void Update() {
transform.Translate(Vector3.forward * speed * Time.deltaTime);
}
작성하기엔 편하다. 하지만 객체가 10,000개가 되는 순간, 유니티는 10,000번의 Update() 콜백을 발생시키며 CPU 캐시 메모리를 오링낸다. 구조를 통제하지 못하고 프레임워크의 편의성에 끌려다닌 대가다.
4. 아키텍트의 해결책: 글로벌 업데이트 버스 (Global Update Bus)
주니어가 아키텍트로 진화하려면 좀 더 본질에 접근할 필요가 있다. 해결책은 개별 객체의 Update()를 전부 지워버리고, 오직 단 하나의 중앙 관리자(Manager)만 Update()를 가지도록 아키텍처를 개편하는 것이다.
// 1. 객체는 스폰될 때 자신을 중앙 버스에 등록(Register)만 한다.
public class Enemy : MonoBehaviour {
void Start() { GlobalUpdateBus.Register(this); }
void OnDestroy() { GlobalUpdateBus.Unregister(this); }
// Update가 아닌 일반 Public 메서드
public void Tick(float deltaTime) {
transform.Translate(Vector3.forward * speed * deltaTime);
}
}
// 2. 단 하나의 Update만 존재하는 글로벌 버스
public class GlobalUpdateBus : MonoBehaviour {
private List<Enemy> activeEnemies = new List<Enemy>(10000);
void Update() {
// C++ -> C# 호출은 단 1번 발생.
// 이후는 C# 내부에서 배열을 순회하므로 오버헤드가 극적으로 소멸함.
for(int i = 0; i < activeEnemies.Count; i++) {
activeEnemies[i].Tick(Time.deltaTime);
}
}
}
10,000번의 철수찾기가 단 1번으로 줄어들었다. 데이터는 순차적인 리스트(List)에 담겨 연속적으로 처리되므로 CPU 캐시 히트율(Cache Hit Rate)은 비약적으로 상승한다. 이것이 메모리 포인터 낭비를 막고 프레임을 방어하는 가장 기본적이고 강력한 설계다.
단순하지만 이런 작업에서 50프레임나올게, 60프레임으로 오르고 스팀덱에서 12와트쓸거, 11.5와트로 줄어든다. 이런 작은 요소를 개선하는 부분에서 흔히말하는 최적화가 달성되는것이다.
요새 컴퓨터 좋은데 대충하면 안되냐 라고 해서 적당히해서 스팀에 출시하면 당신이 만든 게임도 대충 아무도 찾지않는 심해에서 파란색 거꾸로 따봉과 함께 헤엄치게 될 것이다.