RicoCheesethe studio log · v2.0
Live · KRRead posts
목록으로
뉴스PUBLISHED · 2026년 8월 9일·12 MIN READ

백엔드 개발자가 브라우저 게임을 만들었더니, 시스템 요구사항이 '내 모니터'였다

백엔드 엔지니어 Giorgi Kobaidze가 주말 챌린지로 만든 3D 브라우저 게임에서 프레임레이트 의존 버그를 발견하고 델타 타임으로 고친 과정. 선형 연산·지수 감쇠·lerp 보간 세 가지 패턴을 각각 다르게 다뤄야 하는 이유를 코드로 정리했다.

#javascript#frontend#webdev#programming#tech
Backend Engineer (Me) Ships a Browser Game With One Unintentional System Requirement: My Monitor

개요 #

게임은 잘 돌아갔다. 개발자 본인 컴퓨터에서만.

백엔드 경력이 긴 풀스택 엔지니어 Giorgi Kobaidze는 DEV 커뮤니티의 주말 챌린지에 참가해 이틀 만에 3D 브라우저 게임을 완성했다. 신스웨이브 분위기의 끝없는 고속도로를 달리면서 자기 DEV 글들이 네온 광고판으로 스쳐 지나가는 드라이빙 게임이다. 여러 번 테스트했고 문제가 없었다. 가속감도, 조향감도, 브레이크 감각도 만족스러웠다.

그런데 가족에게 링크를 보냈더니 답장이 왔다. "그래픽은 좋은데 차가 거의 안 움직여. 원래 이런 거야?"

원인은 코드가 아니라 모니터였다. 물리 계산이 프레임 단위로 작성돼 있었고, 개발자의 모니터는 165Hz, 가족의 모니터는 120Hz였다. 그의 표현대로, 모니터가 몰래 시스템 의존성이 되어 있었다.

게임 루프가 문제의 출발점 #

이 게임은 Three.js 위에서 돌아간다. 서버도 빌드 스텝도 없이 브라우저에서만 실행되고, DEV 공개 API로 글과 통계를 가져온다.

구조 자체는 교과서적인 게임 루프다. 매 반복마다 입력을 읽고, 세계를 갱신하고, 화면을 그린다. 브라우저에서 이 역할을 맡는 표준 도구가 requestAnimationFrame이다.

untitled
js
function animate() {
  requestAnimationFrame(animate); // "다음 프레임에 다시 불러줘"

  updatePhysics();
  updateCamera();
  renderScene();
}

animate(); // 루프 시작

플립북을 떠올리면 쉽다. animate() 호출 한 번이 한 장이다. 넘기는 속도가 빠를수록 움직임이 부드러워진다. 60Hz 모니터에서는 1초에 60장, 165Hz에서는 165장이 넘어간다. 바로 이 차이가 함정이었다.

차량 물리는 고등학교 수준의 뉴턴 역학에 가깝다. 스로틀이 속도를 올리고, 속도가 위치를 옮기고, 조향이 각도를 돌리고, 마찰이 속도를 깎고, 카메라가 뒤에서 부드럽게 따라온다. 단순했기 때문에 버그를 놓치기도 쉬웠다.

흐릿하게 찍힌 화면 위의 코드 라인들 Photo by Nemuel Sereti on Pexels

45Hz 차이가 만든 슬로모션 #

문제의 코드는 이렇게 생겼다.

untitled
js
function animate() {
  requestAnimationFrame(animate);

  carState.speed += acceleration;
  car.position.x += Math.sin(carState.angle) * carState.speed;
  car.position.z -= Math.cos(carState.angle) * carState.speed;
}

모든 물리 값이 초 단위가 아니라 프레임 단위였다. 하드웨어마다 실제 속도가 이렇게 갈렸다.

프레임레이트초당 갱신 횟수초당 실효 속도
165 fps (개발자 환경)1650.42 × 165 = 69.3 ✅ 의도한 감각
120 fps1200.42 × 120 = 50.4 🙂 의도의 73%
60 fps600.42 × 60 = 25.2 😐 의도의 36%
30 fps300.42 × 30 = 12.6 😩 의도의 18%

게임 전체가 165fps에 맞춰 튜닝돼 있었던 셈이다. 다른 사람들은 사실상 다른 게임을 하고 있었다.

해법: 델타 타임 #

프레임 사이에 실제로 흐른 시간을 재서 물리 갱신에 곱해주면 된다. 이게 **델타 타임(delta time)**이다.

untitled
js
const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const delta = clock.getDelta(); // 지난 프레임 이후 실제 경과 초
  const dt = delta * 60;          // 정규화: 60fps 기준에서 1.0

  carState.speed += acceleration * dt;
  car.position.x += Math.sin(carState.angle) * carState.speed * dt;
  car.position.z -= Math.cos(carState.angle) * carState.speed * dt;
}

clock.getDelta()는 경과 시간을 초로 반환한다. 60fps에서 약 0.0167초, 165fps에서 약 0.006초, 30fps에서 약 0.033초다. 여기에 60을 곱해 정규화하면 165fps에서 dt ≈ 0.36, 60fps에서 dt ≈ 1.0, 30fps에서 dt ≈ 2.0이 된다. 프레임당 이동량은 달라지지만 초당 이동량은 speed × 60으로 고정된다.

그런데 × dt로 다 해결되지 않는다 #

단순 곱셈이 통하는 건 선형 연산뿐이다. 이번 수정에서는 성격이 다른 패턴이 세 가지 나왔는데, 손보는 방식이 저마다 달랐다.

패턴 1 — 선형 연산 #

프레임마다 더하거나 빼는 값이라면 dt를 곱하면 끝이다.

untitled
js
// ❌ 프레임레이트 의존
carState.speed += CAR.acceleration * throttle;
carState.speed -= CAR.brakeForce * brake;
car.position.x += Math.sin(carState.angle) * carState.speed;
carState.angle += carState.steer * carState.speed * CAR.turnSpeed;

// ✅ 프레임레이트 독립
carState.speed += CAR.acceleration * throttle * dt;
carState.speed -= CAR.brakeForce * brake * dt;
car.position.x += Math.sin(carState.angle) * carState.speed * dt;
carState.angle += carState.steer * carState.speed * CAR.turnSpeed * dt;

패턴 2 — 지수 감쇠: Math.pow(factor, dt) #

비포장 도로에서 차가 느려지는 처리는 프레임마다 배수를 곱하는 방식이었다.

untitled
js
// ❌ 프레임레이트 의존
carState.speed *= 0.97; // 프레임당 3% 항력

165fps에서는 초당 165번, 30fps에서는 30번만 3%가 깎인다. 하드웨어에 따라 오프로드 감각이 완전히 달라진다.

순진하게 carState.speed *= 0.97 * dt로 고치면 틀린다. 배수는 선형으로 스케일할 수 없다. dt = 20.97 * 2 = 1.94가 되어 차가 감속은커녕 가속한다.

올바른 수정은 지수를 건드리는 것이다.

untitled
js
// ✅ 프레임레이트 독립
carState.speed *= Math.pow(0.97, dt);

반복 곱셈은 지수 감쇠이기 때문이다. 1초 동안 벌어지는 일을 비교해 보면 이유가 보인다.

60fps(dt = 1, 60프레임):

untitled
plaintext
1초 후 speed = speed × 0.97^60

30fps(dt = 2, 30프레임):

untitled
plaintext
1초 후 speed = speed × (0.97^2)^30 = speed × 0.97^60 ✅ 동일

Math.pow(factor, dt)는 배수 자체가 아니라 지수를 스케일한다. 그래서 실제 시간을 기준으로 보면 결과가 언제나 같다.

조향 보간에도 같은 트릭이 들어갔다.

untitled
js
// ❌ 프레임레이트 의존
carState.steer += (steerDir - carState.steer) * 0.1;

// ✅ 프레임레이트 독립
carState.steer += (steerDir - carState.steer) * (1 - Math.pow(0.9, dt));

패턴 3 — lerp 보간: 1 - Math.pow(1 - alpha, dt) #

가장 까다로운 쪽이다. 카메라는 lerp로 차를 부드럽게 따라간다.

untitled
js
// ❌ 프레임레이트 의존
camera.position.lerp(targetPos, 0.08);

lerp(target, alpha)는 남은 거리의 alpha 비율만큼 매 프레임 이동한다. 165fps에서는 초당 165번 잘게 따라붙어 매끄럽지만, 30fps에서는 30번뿐이라 눈에 띄게 처진다.

여기서도 alpha * dt는 오답이다. dt = 2alpha = 0.16이 되어 감각이 바뀌고, 값이 커지면 목표를 지나쳐 버린다.

untitled
js
// ✅ 프레임레이트 독립
camera.position.lerp(targetPos, 1 - Math.pow(1 - 0.2, dt));

직관은 이렇다. alpha 비율로 n번 lerp하면 남은 거리는 (1 - alpha)^n이다. dt=1n프레임 진행한 결과와 dt=2n/2프레임 진행한 결과를 같게 만들려면 (1 - alpha)dt 제곱하면 된다.

untitled
python
adjusted_alpha = 1 - (1 - alpha)^dt

숫자를 넣어 보면 dt = 1.0(60fps 기준)에서 1 - 0.8^1.0 = 0.200으로 원래 감각이 유지되고, dt = 0.36(165fps)에서는 1 - 0.8^0.36 ≈ 0.083, dt = 2.0(30fps)에서는 1 - 0.8^2.0 = 0.360이 나온다. 프레임당 걸음 크기는 달라지지만 추적 속도는 같다.

세 패턴 정리 #

상황프레임레이트 의존프레임레이트 독립
프레임당 덧셈·뺄셈value += ratevalue += rate * dt
프레임당 곱셈(감쇠)value *= factorvalue *= Math.pow(factor, dt)
목표를 향한 lerplerp(target, alpha)lerp(target, 1 - Math.pow(1-alpha, dt))

상수는 다시 튜닝해야 한다 #

물리를 60fps 기준으로 프레임레이트 독립으로 바꾸자, 기존 상수들의 감각이 어긋났다. 165fps에 암묵적으로 맞춰져 있었기 때문이다. dt = 1 기준으로 다시 잡은 값은 이렇다.

untitled
js
// 수정 전 (얼떨결에 165fps에 맞춰진 값)
maxSpeed:     0.42,
acceleration: 0.0006,
brakeForce:   0.0015,
friction:     0.00005,

// 수정 후 (60fps, dt = 1 기준)
maxSpeed:     1.155,
acceleration: 0.004538,
brakeForce:   0.011344,
friction:     0.000378,

기존 게임을 이렇게 옮긴다면 재튜닝 시간을 따로 잡아 두는 편이 좋다. 하드웨어 간 일관성은 확보되지만, 자기 모니터에서 익숙했던 감각은 달라질 수 있다.

남는 이야기 #

requestAnimationFrame으로 게임이나 애니메이션 루프를 만든다면 프레임레이트에 대해 아무것도 가정하지 말라는 것이 저자의 결론이다. 플레이어의 환경은 30fps 내장 그래픽부터 240Hz 게이밍 모니터까지 뻗어 있다.

수정 자체는 어렵지 않지만 "전부 dt 곱하기"로 끝나지도 않는다. 선형 연산, 지수 감쇠, lerp 보간은 각자 다른 처방이 필요하다. 세 가지를 다 맞추면 10년 된 노트북에서든 고주사율 게이밍 PC에서든 같은 감각이 나온다.

관련 링크는 아래에서 볼 수 있다.


이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.