Published on

[Dev Log] FFI 최적화 1: 잘못된 코루틴 사용으로 옅보이는 디아블로2 추억팔이

Authors
  • Name
    Logan Kim
    Twitter

최적화(Optimization) 라는 단어는 현장에서 너무나도 쉽게 오용된다. 수많은 게임 개발자들이 유니티(Unity)의 코루틴(Coroutine)을 비동기 멀티스레딩의 마법처럼 여기며, 무거운 수학 연산을 메인 스레드에 '할부'로 떠넘기는 치명적인 실수를 범하고 있다. 겉보기엔 작동하는 것처럼 보일지 몰라도, 시스템의 밑바닥에서는 단 하나의 CPU 코어가 피를 토하며 죽어가고 있다.

이 글은 낭만적인 코드에 갇힌 주니어들의 환상을 데이터로 박살 내기 위한 벤치마크 리포트다. 렌더링 노이즈를 완벽히 통제한 5,000개의 부동소수점 연산 테스트를 통해, 코루틴이 어떻게 게임의 프레임을 학살하는지 적나라하게 해부한다. 그리고 이를 구원할 데이터 중심 설계(DOD)와 C++ 네이티브(FFI)의 압도적인 퍼포먼스를 증명해 보일 것이다.

1. 고대유물 펜티엄 컴퓨터와 카우방, 그리고 탭(Tab)키

2000년대 초반, 펜티엄 PC로 디아블로 2를 즐겨본 세대라면 잊을 수 없는 기억이 하나 있다. 수백 마리의 소가 우글거리는 카우방 한가운데서 무심코 미니맵을 켜기 위해 'Tab' 키를 누르는 순간이다.

화면은 그대로 멈춰버렸고, 하드디스크가 비명을 지르며, 운이 나쁘면 기분나쁜 빨간글씨로 당신은 죽었습니다 메시지를 보며 경험치와 골드를 꼻아박는다. 우리는 이를 단순한 '렉'이라고 불렀지만, 아키텍트의 시선에서 이것은 명백한 '상태 동기화와 메모리 병목의 참사'다. 수백 개의 객체 좌표와 상태를 읽어 들여 UI에 맵핑하는 뼈를 깎는 연산이 메인 스레드의 목을 졸랐던 것이다.

CPU는 구조적으로 이런 연산에 취약하다. 게임 속 좌표는 딱 떨어지는 정수가 아니며, 이를 계산하기 위해서는 CPU 벤치마킹에 흔히 활용되는 무거운 부동소수점 연산(Floating-point Arithmetic)을 수행해야만 한다. 특히 유니티(Unity) 같은 현대 3D 게임 엔진은 이 소수점 연산을 펜티엄 시절과는 비교도 할 수 없을 만큼 방대하게 사용한다.

디아블로 2가 출시되고 수십 년이 지난 지금, 하드웨어는 비약적으로 발전했다. 하지만 안타깝게도 게임 엔진을 다루는 현대 주니어 개발자들의 아키텍처는 여전히 펜티엄 시절의 참사를 반복하고 있다. 그 중심에는 유니티가 제공하는 재앙의 근원인 코루틴(Coroutine)이 있다.

2. 코루틴은 멀티스레딩이 아니다.

많은 초보 개발자들이 코루틴을 비동기 멀티스레딩으로 착각한다. 무거운 연산이 있으면 무지성으로 IEnumerator에 집어넣고 yield return을 던진다.

까놓고 말하면, 코루틴은 마법이 아니다. 메인 스레드의 연산 시간을 잘게 쪼개어 다음 프레임으로 미루는 '할부 결제'에 불과하다. 객체가 10개, 100개일 때는 이 할부금이 티가 나지 않지만, 10,000개의 객체가 각자의 코루틴을 들고 메인 스레드에 일시불 청구서를 들이미는 순간 파산(프레임 드랍)은 확정된다.

이를 증명하기 위해, 렌더링 부하(Draw Call)를 완전히 제거한 순수 CPU 논리 연산 벤치마크 환경을 구축했다.

3. 벤치마크 설계: 부동소수점 학살

유니티 엔진의 렌더링 껍데기를 모두 벗겨내고, 오직 메모리 데이터와 수학 연산만을 수행하는 '헤드리스(Headless) 벤치마크'를 작성했다.

  • 테스트 환경: 최대 5000개의 더미 논리 객체를 점진적으로 생성

  • 연산 부하: 각 객체는 매 틱(Tick)마다 A* 경로 탐색에 준하는 무거운 부동소수점 연산(Sin, Cos, Lerp)을 수천 번 반복하여 목적지 좌표를 계산

  • 시각적 검증: 연산이 완료된 궤적 중 무작위 100개를 추출하여 Scene 뷰에 붉은색/녹색 선(Debug.Line)으로 시각화

테스트 기준: 렌더링 부하가 섞이면 CPU의 순수한 수학 연산 병목을 정확히 측정할 수 없다. 진정한 시스템 프로파일링은 모든 외부 노이즈를 통제한 상태에서 이루어져야 한다.

4. 벤치마크 실행: 극명하게 갈린 두 세계

유니티 기본 환경에 테스트 UI를 구현하고 두 가지 케이스를 비교했다. GUI 상에서 +1000 Coroutine Jobs 혹은 +1000 FFI Jobs 버튼을 클릭하여 동일한 연산 태스크를 점진적으로 쏟아부어 보았다.

[Case 1: 무지성 코루틴]

5천개는 시도도 못하고, 2천개 내외에서 프레임이 떡락해서 플레이를 제대로 찍기도 어렵다.

[Case 2: C++ 네이티브 멀티쓰레딩 (FFI)]

1천개의 태스크를 추가해도 마이크로 프레임 드랍조차 없다. 영상 길이상 편집되었으나, 최대 5천개까지 작업을 스택업(Stack-up) 하더라도 약간의 프레임 저하만 있을 뿐 지극히 쾌적한 환경을 방어해 낸다.

이 결과에서 보다시피, 동일한 목표를 가진 작업이라도 시스템을 설계하는 방식에 따라 결과는 천차만별이다. 하드웨어는 분명 짐승처럼 강력해졌지만, 구현 방식이 펜티엄 2 시대에 머물러 아무 생각 없이 코드를 짜거나 AI에게 무작정 구현을 맡긴다면 십중팔구 첫 번째 케이스(코루틴)의 지옥도를 건네받게 될 것이다.

이런 식의 개발을 기업에 비유하자면 메인 스레드를 담당하는 CPU 에이스 직원 하나만 죽을때까지 쥐어짜는 행위다. 이때 나머지 CPU 코어들은 개좆소 상사가 되서 존나 빠이팅하라고 필사적으로 바쁜척을 한다. 이 때문에 작업 관리자에서 보이는 전체 CPU 사용량, 즉 기업은 다 바쁜거같은데 정작 생산성은 밑바닥에 꽂히는 지극히 현실적인 패턴이 나온다.

이게 PC일때는 티가 많이 안나지만 스팀덱과 같은 UMPC장치에서는 자원이 부족하다보니 유독 도드라지게 나타나게 된다.

5. 다음편 예고, 과다포장 방지를 위한 마샬링 적용 기법

이처럼 일류기업으로 가려면 에이스를 학살하지않고 전체적으로 일을 분배해서 굴러가야 한다는 지극히 현실적인 결론에 도달했다.

그렇다면 단순히 C++ 플러그인을 만들어서 연산을 넘기기만 하면 모든 것이 일류기업으로 갈 수 있을까? 안타깝게도 어설픈 C++ FFI(Foreign Function Interface) 연동은 코루틴보다 더 끔찍한 '가비지 컬렉터(GC) 스파이크'라는 두 번째 공포를 불러온다.

다음 글에서는 C++ 연산을 도입하고도 메모리 마샬링(Marshalling) 비용 때문에 메인 스레드가 다시 한번 박살 나는 개좆소들의 안티 패턴을 해부해 보겠다.