Doom을 SQL로 돌린다고? 데이터베이스 안에서 60FPS로 돌아가는 SQLDoom 이야기

한 줄 요약
1993년작 Doom의 게임 로직과 렌더러를 전부 SQL로 구현한 프로젝트, SQLDoom이 등장했습니다. 데이터베이스가 프레임버퍼를 만들어 반환하고, Python 클라이언트는 입력 처리와 화면 표시만 담당합니다. 놀랍게도 노트북에서 320×200 해상도로 보통 60FPS(복잡한 장면에서는 35FPS)가 나옵니다.
전작 DOOMQL과 무엇이 다른가
같은 제작자의 전작 DOOMQL은 ASCII 화면을 30FPS로 렌더링했지만, 내부적으로는 레이캐스팅 방식이라 사실 Wolfenstein 3D에 더 가까웠습니다. 반면 진짜 Doom은 BSP 트리로 깊이 순서를 저렴하게 계산해 텍스처, 임의 각도의 벽, 서로 다른 바닥 높이를 구현합니다.
SQLDoom의 목표는 외형 흉내가 아니라 원작의 플레이 감각까지 재현하는 것이었습니다. 규칙은 이렇습니다.
- 렌더러는 SQL만 사용하고, 모든 픽셀의 정확한 RGB 값을 담은 테이블 또는 비트맵만 출력
- 게임 루프도 SQL 기반 (단, DB 내부 사용자 정의 함수는 허용)
- 외부 클라이언트는 입력 해석, 게임 틱 구동, 출력 비트맵 표시만 담당
구조: 로직과 렌더링의 분리
단일 Python 스크립트가 pygame으로 입력과 화면 표시를 처리하고, 초당 35회 게임 틱을 요청합니다. 게임 로직·상태·렌더러는 모두 데이터베이스 안에 있습니다.
여기서 재미있는 설계 선택이 나옵니다. 원작 Doom은 틱마다 정확히 한 프레임을 그려서 35FPS로 묶여 있었습니다. SQLDoom은 로직은 고정 35Hz, 렌더링은 클라이언트가 원할 때 분리해서 돌립니다.
- 렌더러는 게임 상태 테이블을 입력으로 받는 순수 함수로 구성
- 틱 사이의 카메라 위치를 보간해 렌더링 주기를 로직 주기와 분리
- 틱당 28.6ms 예산(원작 상수)은 지키면서, 화면은 최소 35FPS 이상을 목표
WAD 파일이 관계형 데이터와 잘 맞는 이유
Doom의 WAD 포맷은 의외로 관계형 구조로 옮기기 쉽습니다.
- 두
VERTEXES를LINEDEF가 연결하고 LINEDEF에는 두 개의SIDEDEF가 있고SIDEDEF는SECTOR의 경계를 이루며- 그
SECTOR안에THINGS가 존재할 수 있음
딱 봐도 외래 키로 이어지는 테이블 묶음이죠. WAD 전체를 DB로 변환하는 데 약 1,000줄의 Python이 쓰였고, 노트북에서 Doom 1 전체를 가져오는 데 약 18초가 걸립니다.
변환된 데이터는 평범한 SQL로 조회할 수 있습니다. 예를 들어 E1M1의 벽과 꼭짓점을 조인하고 generate_series()로 선분을 나누면, 위에서 내려다본 지도를 ASCII로 그리는 쿼리도 만들 수 있습니다.
게임 틱과 몬스터 AI를 SQL로
게임 루프가 처리하는 일은 생각보다 많습니다. 이동·회전·발사 입력은 물론이고 적의 이동과 공격, 아이템 획득, 투사체와 폭발 반경, 적 스프라이트와 애니메이션, 시점 흔들림, HUD까지 전부 들어갑니다.
절차적인 틱 실행 순서는 PL/pgSQL과 비슷한 CedarDB의 스크립트 언어 cedarscript로 구성했습니다. 대략 이런 순서입니다.
- 시계 갱신과 실행 계획 계산
- 사용자 입력, 특수 동작과 문, 이동, 사망 처리
- 상태가 바뀌면 계획 재계산
- 비밀 구역, 통과 시 발동하는 선, 아이템 획득, 무기 상태, 즉시 명중 판정과 피해
- 사운드 재생, 몬스터·구역 효과·오브젝트 물리
Python 드라이버는 그저 SELECT doom_run_game_tic(...)을 1/35초마다 호출할 뿐입니다.
몬스터 AI는 특히 영리합니다. 재귀 쿼리로 시야를 판정하고, 생존 여부·시야각·거리·공격 대기시간·애니메이션 진행 상태에 따라 상태 머신을 갱신합니다. 대기 상태에서 플레이어를 발견하면 추적하고, 공격 거리와 대기시간 조건을 만족하면 공격합니다. 체력이 최대 체력의 음수보다 더 낮고 해당 프레임이 존재하면 xdeath로 전환해, 과도한 피해를 입은 적이 터져 죽는 연출까지 재현합니다.
성능, 그리고 ECS와 SQL의 의외의 궁합
측정된 가장 느린 틱은 E4M1에서 활성 몬스터 46마리가 열리는 문으로 몰려오는 상황으로, 10.45ms(틱 예산의 약 37%)가 걸렸습니다. 활성 몬스터 6마리인 일반적인 틱은 평균 2.15ms, 약 8% 수준입니다.
코드량도 흥미롭습니다. 게임 로직이 SQL 약 5,900줄인데, 같은 기능의 원작 C 코드는 약 9,000줄입니다.
비결은 사고방식의 전환입니다. 적을 하나씩 순회하는 대신 UPDATE ... WHERE condition으로 대상 집합을 선언하고, 적용 방법과 병렬 처리는 데이터베이스가 결정하게 맡깁니다.
그러다 보니 게임 개발에서 널리 쓰이는 엔티티 컴포넌트 시스템(ECS) 이 관계형 구조에 자연스럽게 대응됩니다.
- 플레이어·몬스터·사물은 엔티티, 위치·스프라이트·능력치는 컴포넌트
- 몬스터 AI·플레이어 이동·피해 계산 같은 시스템은 특정 속성을 가진 엔티티 간 상호작용
- 각 컴포넌트는 하나의 테이블이 되고, 각 시스템은 엔티티를 키로 테이블을 조인하는
UPDATE/INSERT가 됨
ECS가 중시하는 데이터 지역성과 "특정 컴포넌트를 가진 엔티티 순회"는, SQL의 데이터 집약적 처리와 사실 같은 곳을 바라보고 있었던 셈입니다.
프레임 하나를 만드는 거대한 뷰
렌더러는 레벨 기하 정보, 게임 상태, 플레이어 위치를 받아 완전한 프레임버퍼를 반환하는 하나의 큰 뷰입니다. 처리 흐름은 이렇습니다.
- BSP 탐색 후 보이지 않는 구역 제거
- 벽을 화면 공간에 투영
- 벽 픽셀, 바닥과 천장, 스프라이트의 후보 픽셀 생성
- 가장 가까운 픽셀 선택 →
COLORMAP과 상태 표시줄 적용 → 프레임버퍼 완성
주석을 제외한 SQL 약 1,300줄, CTE 89개로 구성됩니다. 참고로 linux_doom 원본 렌더러는 약 3,300줄로 SQLDoom의 약 2.5배입니다.
최종 프레임은 64,000개의 (x, y, rgb) 행이고, string_agg로 화면 순서에 맞춰 합치면 단일 행 192,000바이트의 RGB 데이터가 됩니다.
하이라이트: 재귀 하강을 정렬 키로 바꾸다
개인적으로 가장 무릎을 치게 되는 부분입니다.
원작 Doom은 하드웨어 Z 버퍼가 없던 시절, 앞에서 뒤로 그리는 순서로 가림 관계를 처리했습니다. doom.wad에는 미리 계산된 BSP 트리가 들어 있고, 각 노드의 선이 지도를 둘로 나누며, 각 리프(하위 구역)는 내부 어디서든 모든 벽이 보이는 볼록한 형태입니다. 그리고 각 노드에서 카메라와 같은 쪽의 하위 트리가 반대쪽보다 앞에 있습니다.
문제는 이 재귀 하강이 SQL과 잘 맞지 않는다는 점. SQLDoom의 해법은 이렇습니다.
- 로드 시 모든 루트→하위 구역 경로를 미리 계산해 정렬 키로 사용
- 현재 시점에서 경로의 앞쪽 선택은
0, 뒤쪽 선택은1로 인코딩 - 이를
bigint에 담고SUM()과ORDER BY로 정렬 → 재귀 하강을 정렬로 대체
40비트를 사용하는데, 원작에서 가장 깊은 E4M8의 BSP 트리가 32단계이므로 원작 최대 맵의 256배 규모까지 커버합니다.
가시성 제거도 집합 연산으로 처리합니다. 각 노드에 저장된 자식들의 경계 상자가 시야 절두체 밖이면 해당 하위 트리를 제외하고, BOOL_AND(vc.keep)으로 조상 중 하나라도 가시성 조건을 통과하지 못한 하위 구역을 걸러냅니다. 이후 파이프라인은 bsp_seq와 조인해 보이는 하위 구역만 올바른 순서로 처리합니다.
벽·바닥·천장: 윈도 함수로 visplane 대체하기
Doom은 수직 벽과 지면에 평행한 천장만 쓰는 2.5D 게임이라, 완전한 3D 엔진보다 렌더링이 단순합니다. 벽을 앞에서 뒤로 그린 뒤 남은 영역을 바닥·천장으로 채우는 식이죠. 몬스터·통·아이템은 항상 플레이어를 향하는 평면 스프라이트지만, 벽과 겹칠 때는 가림 처리가 필요합니다.
벽은 비교적 수월합니다. 연속된 화면 열과 각 열의 연속된 픽셀 구간으로 표현되므로, generate_series()로 열과 행을 확장해 픽셀 후보를 생성하면 됩니다. 원작의 R_RenderSegLoop(열 순회)과 R_DrawColumn(픽셀 그리기) 두 루프를 대체하는 셈입니다. 벽 렌더링은 평균 1.7ms.
바닥과 천장이 진짜 난관입니다. 원작의 visplane 알고리듬은 ceilingclip/floorclip 배열에 각 열의 아직 채우지 않은 구간을 저장하고, 벽을 그릴 때마다 정해진 순서로 배열을 변경하며 화면을 채웁니다. 사실상 영역 채우기 알고리듬인데, 명령형 C로는 저렴하게 3D 같은 결과를 얻을 수 있지만 SQL로 직접 옮기기는 어렵습니다.
SQLDoom은 반복과 가변 상태 대신 패널 정렬 + 윈도 함수를 씁니다.
- 패널 = 화면의 한 열에 나타나는 벽의 일부
solid, 문 위의upper, 창문·난간 아래의lower처럼 직접 그리는 패널이 있고, 발코니 아래에서 문밖으로 나올 때처럼 다른 패널의 렌더링 경계만 결정하는 패널도 있음- 각 열의 패널을 가까운 순서로 정렬하고,
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING범위의MAX/MIN으로 앞선 패널들이 남긴 구간을 계산 - 이전 패널이 끝나는 지점과 다음 패널이 시작하는 지점 사이를 바닥과 천장으로 채움
가변 상태를 윈도 프레임으로 치환한 전형적인 사례입니다. 바닥·천장·하늘 렌더링은 보통 약 3ms.
원작이 피했던 비용: 픽셀별 깊이 판정
벽, visplane, 스프라이트 단계는 화면을 바로 그리지 않고 (x, y, depth, colour) 후보를 생성합니다. 그럴 수밖에 없는 이유가 있습니다.
- 원작의 고정소수점 연산을 그대로 구현하지 않아 벽·바닥·하늘 후보가 같은 위치에서 겹칠 수 있음
- 벽에 일부 가려진 스프라이트도 처리해야 함
- 원작처럼 정교한 순서 제어로 Z 버퍼링을 피하는 방식을 SQL로 재현하지 못함
그래서 모든 후보를 만든 뒤 최종 픽셀을 고릅니다. 이때 쓰는 트릭이 또 하나의 비트 패킹입니다. 후보의 깊이, 표면 우선순위, 출처 우선순위, 안정적인 동률 판정 값, 조명·팔레트 정보를 하나의 bigint 키에 묶습니다.
- 깊이를 상위 비트에 두어, 픽셀별
MIN(winner_key)한 번으로 가장 가까운 후보 선택 - 표면 우선순위는 벽 → 스프라이트 → 평면 순
- 색상 정보까지 키에 포함해 추가 조인을 회피
이런 최적화에도 깊이 판정은 평균 8.2ms로 프레임 전체의 3분의 1 이상을 차지하는 최대 비용 구간입니다. 전체 성능은 Ryzen 7 PRO 7840U 노트북에서 보통 약 60FPS, 매우 복잡한 장면에서 35FPS. 비용이 큰 곳은 ① 반복 알고리듬을 흉내 내는 visplane 처리, ② 원작이 피했던 깊이 판정, ③ 색상 맵 조회와 프레임버퍼 패킹 같은 픽셀별 작업입니다.
그래서, 데이터베이스가 진짜 유용한 지점은?
제작자도 솔직합니다. 데이터베이스 안에서 Doom을 렌더링하는 것 자체는 좋은 선택이 아닙니다. 하지만 게임 속성을 관계형으로 저장하는 건 이야기가 다릅니다.
샷건은 weapon_defs 테이블의 한 행입니다. 탄약 종류, 발사당 소모량, 산탄 수, 피해, 사거리를 그냥 SELECT로 조회할 수 있습니다. 예시 샷건은 탄약 1개를 쓰고 산탄 7개를 발사하며, 각 산탄의 피해는 3d5, 최대 사거리는 2048입니다. weapon_frames에는 대기·발사·섬광 상태의 프레임, 지속 틱 수, 공격 프레임 여부, 재발사 확인 여부가 들어 있습니다.
속성과 애니메이션이 전부 데이터이므로 실행 중 모딩이 가능합니다. 시연에서는 게임을 멈추지 않고 샷건을 "한 번에 산탄 500개를 더 넓게 발사"하도록 바꿔버렸습니다.
물론 JSON 파일에도 같은 데이터를 담을 수 있습니다. 다만 그 경우엔 수정 시점의 제약조건 검증이 없고, 변경을 적용하려면 별도 과정이 필요합니다. 반면 DB에서는 타입과 제약조건이 수정을 즉시 검증하고, 트랜잭션과 접근 제어를 그대로 활용해 최대 4인 데스매치까지 지원합니다. 공개 서버에서 첫 번째 에피소드를 플레이하거나, SQL 콘솔로 진행 중인 경기 상태를 조회할 수도 있습니다.
정리하며
SQLDoom은 "할 수 있으니까 했다"류의 프로젝트처럼 보이지만, 그 과정에서 꽤 진지한 교훈을 남깁니다.
- 재귀를 정렬 키로, 가변 상태를 윈도 함수로, 순회를 집합 연산으로 바꾸는 사고 전환
- ECS와 관계형 모델이 본질적으로 같은 곳을 바라본다는 발견
- 그리고 C 9,000줄의 로직이 SQL 5,900줄로 표현된다는, 선언형의 표현력
매일 UPDATE와 JOIN을 쓰는 개발자라면, 같은 도구로 1993년산 FPS가 60FPS로 돌아간다는 사실 하나만으로도 한 번 들여다볼 가치가 있습니다.