[CS300 #290] 게임 엔진의 구조 — 루프, 씬, 컴포넌트
컴퓨터공학 300 주제 시리즈의 290번째 글이다. 전체 지도는 여기.
한 줄 요약
게임 엔진은 “입력 → 갱신 → 렌더링”을 초당 수십 번 반복하는 루프 위에, 장면을 구성하는 객체 모델(씬 그래프·컴포넌트)과 렌더링·물리·오디오 같은 하위 시스템을 얹은 실시간 소프트웨어 플랫폼이다.
왜 필요한가
게임 엔진은 게임 개발자만의 관심사가 아니다. 구조를 보면 컴퓨터공학의 여러 주제가 한 프로그램에 모여 있다.
- 정해진 시간 안에 한 프레임을 끝내야 하는 실시간 시스템
- 앞 글들의 변환 행렬과 렌더링 파이프라인
- 상속 대신 조합(composition)을 쓰는 설계
- 메모리 배치와 캐시를 고려한 데이터 지향 설계
- 시뮬레이션·디지털 트윈·데이터 시각화·XR 같은 비게임 분야의 실시간 3D
웹 서버가 “요청이 오면 처리”라면, 게임은 “아무 일이 없어도 매 프레임 세계를 갱신”한다. 이 차이가 구조를 결정한다.
핵심 개념
게임 루프
모든 엔진의 심장은 루프다.
while running:
process_input() # 키보드·패드·네트워크 입력 수집
update(dt) # 게임 상태를 dt 만큼 진행 (AI, 물리, 애니메이션)
render() # 현재 상태를 화면에 그림
60 FPS 라면 한 프레임에 쓸 수 있는 시간은 약 16.7ms 다. 이 예산 안에 입력·갱신·렌더링을 모두 끝내야 한다. 넘치면 프레임이 떨어지고, 사용자는 끊김으로 느낀다.
시간 간격(dt) 다루기
기계마다 프레임 시간이 다르다. 갱신을 “프레임당 1칸 이동”으로 짜면 빠른 기계에서 캐릭터가 빨리 움직인다. 해결책은 크게 세 가지다.
| 방식 | 방법 | 장단점 |
|---|---|---|
| 고정 프레임 | 프레임을 일정 속도로 맞추고 대기 | 단순. 느린 기계에서 게임 자체가 느려짐 |
| 가변 dt | 실제 걸린 시간만큼 update(dt) |
부드럽지만 물리 결과가 dt 에 따라 달라짐 |
| 고정 dt + 누적 | 실제 시간을 누적했다가 고정 간격으로 여러 번 갱신 | 결정적이고 안정적. 렌더링은 보간 |
물리 시뮬레이션은 시간 간격이 바뀌면 결과가 달라지고, 간격이 크면 물체가 벽을 뚫고 지나간다. 그래서 많은 엔진이 물리는 고정 간격으로, 렌더링은 가능한 빠르게 돈다. Godot 은 _process(delta)(프레임마다)와 _physics_process(delta)(고정 간격)를 분리하고, Unity 는 Update 와 FixedUpdate 를 분리한다.
장면과 객체 모델
게임 세계는 수많은 객체로 이뤄진다. 객체를 조직하는 방식이 엔진의 성격을 정한다.
씬 그래프(scene graph): 객체를 트리로 둔다. 자식의 변환은 부모 기준이다. 자동차 노드 아래 바퀴 노드를 두면 자동차가 움직일 때 바퀴도 따라온다. 세계 행렬은 루트부터 로컬 행렬을 곱해 내려가며 구한다. Godot 은 모든 것을 노드 트리로 구성한다.
컴포넌트 기반: “플레이어 extends 캐릭터 extends 움직이는것…” 같은 깊은 상속은 금방 엉킨다. 날 수 있는 적, 수영하는 적, 날면서 수영하는 적을 상속으로 표현하기 어렵다. 그래서 객체는 빈 그릇이고, 기능은 붙이는 부품(컴포넌트)으로 만든다. Unity 의 GameObject 에 붙이는 컴포넌트가 이 방식이다.
ECS(Entity-Component-System): 컴포넌트를 더 밀고 나간 형태다.
엔티티(Entity) : 그냥 id. 예) 42
컴포넌트(Component): 순수 데이터. 예) Position{x,y}, Velocity{vx,vy}
시스템(System) : 특정 컴포넌트 조합을 가진 예) MovementSystem: Position+Velocity 를 가진
모든 엔티티에 로직 적용 모든 엔티티의 위치를 갱신
같은 종류의 컴포넌트를 배열에 연속으로 담으면 시스템이 메모리를 순서대로 훑는다. CPU 캐시에 유리하다. 수만 개 객체를 다루는 경우 객체 지향 방식보다 빠를 수 있다.
엔진의 하위 시스템
+-------------------------------------------------------------+
| 게임 코드 / 스크립트 (GDScript, C#, C++, 블루프린트 ...) |
+-------------------------------------------------------------+
| 씬·객체 모델 | 애니메이션 | AI·내비게이션 | UI | 네트워크 |
+-------------------------------------------------------------+
| 렌더러 | 물리 | 오디오 | 입력 | 리소스 관리 | 파일·에셋 로딩 |
+-------------------------------------------------------------+
| 플랫폼 추상화: OS, 그래픽 API(Vulkan/Direct3D/Metal/WebGL) |
+-------------------------------------------------------------+
플랫폼 추상화 계층 덕분에 같은 게임 코드가 PC·콘솔·모바일에서 돈다. 에디터는 이 런타임 위에서 도는 또 하나의 애플리케이션이다.
에셋 파이프라인
원본 에셋(고해상도 텍스처, 3D 모델 원본)은 그대로 쓰지 않는다. 플랫폼별 압축 포맷으로 바꾸고, 메시를 최적화하고, 묶음으로 패키징한다. 소프트웨어의 빌드 파이프라인과 같은 역할이다. 대형 프로젝트에서는 코드 빌드보다 에셋 빌드가 오래 걸리기도 한다.
직접 해 보기
고정 dt + 누적 방식의 게임 루프와 아주 작은 ECS 를 만든다. 빠른 기계(120 FPS)와 느린 기계(30 FPS)에서 1초를 돌렸을 때 결과가 같은지 본다.
from dataclasses import dataclass
@dataclass
class Position:
x: float
@dataclass
class Velocity:
vx: float
# ECS: 엔티티는 id, 컴포넌트는 데이터, 시스템은 로직
positions = {1: Position(0.0), 2: Position(100.0)}
velocities = {1: Velocity(10.0)} # 엔티티 2 는 움직이지 않는다
def movement_system(dt):
for eid, vel in velocities.items():
if eid in positions:
positions[eid].x += vel.vx * dt
def run(frame_times, step=1 / 60):
acc, updates = 0.0, 0
for ft in frame_times: # ft: 한 프레임에 실제로 걸린 시간(초)
acc += ft # 1) 입력 처리 (생략)
while acc >= step: # 2) 고정 간격으로 시뮬레이션 갱신
movement_system(step)
acc -= step
updates += 1
alpha = acc / step # 3) 렌더링: 남은 비율로 보간 가능
return updates, alpha
for name, frames in [("빠른 기계", [1 / 120] * 120), ("느린 기계", [1 / 30] * 30)]:
positions[1].x = 0.0
updates, alpha = run(frames)
print(f"{name}: 갱신 {updates}회, 엔티티1 x={positions[1].x:.2f}, 엔티티2 x={positions[2].x}")
실행 결과다.
빠른 기계: 갱신 60회, 엔티티1 x=10.00, 엔티티2 x=100.0
느린 기계: 갱신 60회, 엔티티1 x=10.00, 엔티티2 x=100.0
빠른 기계는 프레임 2번에 갱신 1번, 느린 기계는 프레임 1번에 갱신 2번을 한다. 결과적으로 둘 다 1초에 정확히 60번 갱신해 같은 위치에 도달한다. 렌더링 횟수는 다르지만 게임 세계의 시간은 같다. 부동소수점 누적 오차 때문에 경계에서 갱신 횟수가 하나 어긋날 수도 있다. 실제 엔진은 정수 틱 카운터를 함께 쓰기도 한다.
velocities 에 없는 엔티티 2 는 시스템이 건드리지 않는다. “움직이는 능력”은 상속이 아니라 컴포넌트의 유무로 정해진다.
현업에서는
- 게임 서버도 같은 루프를 돈다. 서버는 렌더링 없이 고정 틱(예: 초당 수십 회)으로 상태를 갱신하고 클라이언트에 보낸다. 컨테이너로 띄운 게임 서버는 CPU 제한(CFS 쿼터)에 걸리면 틱이 밀린다. 쿠버네티스에서 게임 서버를 돌릴 때 CPU 요청·제한을 보수적으로 잡는 이유다.
- 프레임 시간 분석은 성능 프로파일링의 기본이다. “평균 FPS” 보다 가장 느린 프레임들(1% low)이 체감 끊김을 결정한다. 웹 서비스에서 평균 응답보다 p99 를 보는 것과 같은 이야기다.
- 엔진 기술은 게임 밖으로 퍼졌다. 건축 시각화, 자동차 HMI, 로봇 시뮬레이터, 가상 제작(영화)에서 실시간 엔진을 쓴다.
- 브라우저의
requestAnimationFrame도 게임 루프다. 화면 주사율에 맞춰 콜백을 부르고, 콜백에 타임스탬프를 넘긴다. 탭이 백그라운드로 가면 호출이 멈추거나 줄어든다.
확인 문제
- 60 FPS 의 프레임 예산은 몇 ms 인가?
- 물리를 가변 dt 로 갱신하면 생길 수 있는 문제 두 가지는?
- 깊은 상속 대신 컴포넌트 조합을 쓰는 이유는?
- ECS 가 캐시 효율에서 유리할 수 있는 이유는?
- 고정 dt 루프에서 렌더링 보간이 필요한 이유는?
풀이
- 1000 / 60 ≈ 16.7ms.
- 같은 입력이라도 기계·프레임마다 결과가 달라진다(비결정성). dt 가 크면 빠른 물체가 얇은 벽을 통과하는 터널링이 생긴다.
- 기능 조합이 상속 계층 하나로 표현되지 않고, 계층이 깊어질수록 변경이 어렵다. 컴포넌트는 기능을 자유롭게 붙였다 뗄 수 있다.
- 같은 종류 컴포넌트가 연속 배열에 있어 시스템이 순차 접근하므로 캐시 라인을 효율적으로 쓴다.
- 렌더링 시점이 갱신 시점과 어긋나므로, 그대로 그리면 움직임이 계단처럼 튄다. 직전·현재 상태를 남은 비율로 보간하면 부드럽다.
더 읽을거리 (References)
- R. Nystrom, Game Programming Patterns, Game Loop — 저자가 공개한 온라인판
- Godot Engine, Idle and Physics Processing
- Unity, Order of execution for event functions
- J. Gregory, Game Engine Architecture, 3rd ed., CRC Press, 2018.