회사 발표용 화면을 제작하고 남은 자투리 시간에 팀 내에서 재미로 쓸 “커피쿠폰 수령자 추첨기”를 만들던 중이었습니다. 가볍게 장난처럼 시작한 핀볼 2D 물리 경주 시스템(chaos-drop)이었는데, 만들다 보니 욕심이 생겨 생각보다 꽤 진지하게 판이 커져 버렸습니다.

처음에는 그냥 돌아만 가게 만들자고 생각해서 하나의 HTML 파일 안에 모든 스크립트와 스타일을 밀어 넣었습니다. 코드가 3,000줄을 넘어가니 어디를 건드려야 할지 모르는 지경에 이르렀습니다. 유지보수가 불가능해지기 전에 결국 파일을 완전히 분해하고 역할별 모듈 구조로 재구축했습니다.

그 과정에서 마주친 의외의 물리 법칙 문제들과 모바일 뷰포트 스케일링, 그리고 45구 추첨 모드에서의 성능 최적화 과정을 공유합니다.


1. 게이밍 모니터의 배신: 주사율 독립적 루프 구현

가장 어이없으면서도 확실하게 겪은 물리 버그입니다. 60Hz 일반 사무용 노트북에서는 우아하게 떨어지던 구슬들이 144Hz, 240Hz 게이밍 모니터에서 구동해 보니 거의 빛의 속도로 폭주했습니다. 눈으로 쫓을 수 없을 만큼 빠르게 연산이 끝나버린 겁니다.

원인: 프레임에 묶여 있던 물리 연산

기존 코드는 requestAnimationFrame이 돌 때마다 구슬의 가속도와 충돌 판정을 정직하게 1회씩 갱신했습니다.

function animate() {
  updatePhysics(); // 프레임당 1회 물리 연산
  render();
  requestAnimationFrame(animate);
}

requestAnimationFrame은 모니터의 주사율에 맞춰 호출됩니다. 즉, 60Hz 모니터에서는 초당 60회 물리 연산이 돌지만, 240Hz 모니터에서는 초당 240회 연산이 돌면서 물리 시간이 4배 빠르게 흘러간 셈입니다.

해결: 60FPS 프레임 록 장치

물리 상수를 시간 델타(dt)에 맞춰 수식화하는 것도 방법이지만, 브라우저 스레드 점유나 충돌 판정 정밀도를 고려해 고해상도 타이머 기반의 60FPS 제약기를 도입하기로 했습니다.

let lastTime = performance.now();
const fpsInterval = 1000 / 60; // 16.67ms

function animate(currentTime) {
  requestAnimationFrame(animate);
  
  const elapsed = currentTime - lastTime;
  if (elapsed >= fpsInterval) {
    // 프레임 오차를 보정하며 시간 갱신
    lastTime = currentTime - (elapsed % fpsInterval);
    
    updatePhysics();
    render();
  }
}

elapsed % fpsInterval을 빼주어 미세하게 남는 밀리초 오차를 보정해 주니, 주사율에 상관없이 게이밍 모니터에서도 60FPS 속도로 차분하게 굴러가기 시작했습니다.


2. 동적 줌 시 발생하는 화면 흔들림 디버깅

구슬들의 밀집도에 따라 카메라가 알아서 확대/축소되도록 동적 줌 기능을 붙였는데, 줌 비율이 바뀔 때마다 전체 보드판이 좌우로 수십 픽셀씩 미친 듯이 떨리는 현상이 있었습니다. 눈에 피로감이 엄청났습니다.

원인: 비대칭 렌더링 영역과 줌 피벗의 불일치

코드를 확인해 보니 ctx.scale을 먹일 때 줌의 중심 좌표가 단순히 브라우저 창의 가로 절반(window.innerWidth / 2)으로 잡혀 있었습니다.

문제는 이 게임의 레이아웃이었습니다. 좌측에는 410px 짜리 고정 제어 패널이 있고, 핀볼 보드판은 오른쪽 나머지 영역에 치우쳐 렌더링되고 있었습니다.

  • 화면의 중심점과 실제 핀볼판의 가상 좌표 중심점이 비대칭으로 어긋나 있는 상태였습니다.
  • 이 상태에서 배율이 바뀌니 비대칭 횡이동 스케일이 적용되면서 판 전체가 좌우로 요동쳤습니다.

해결: 실제 좌표 기반 정밀 피벗 설정

줌의 중심점을 실제 보드판 렌더링 영역의 정중앙인 GAME_X_OFFSET + (GAME_VWIDTH / 2)로 바꿨습니다.

// Before
const pivotX = window.innerWidth / 2;

// After
const pivotX = GAME_X_OFFSET + (GAME_VWIDTH / 2);

피벗을 보정해 주니 배율이 아무리 동적으로 변해도 제자리에서 부드럽게 줌 인/아웃이 일어났습니다. 사소해 보이지만 좌표계를 다룰 때 설계 분리가 꽤 중요한가 봅니다.


3. 모바일 뷰포트 스케일링에 따른 병목 현상

모바일 화면 대응을 위해 브라우저 가로 폭에 비례하여 보드판 전체를 축소(BOARD_XSCALE)하도록 반응형 구조를 짰습니다. 그런데 모바일 기기로만 테스트하면 구슬들이 골인 지점 직전의 통로에 꽉 껴서 무한히 갇히는 병목 현상이 터졌습니다.

원인: 스케일링되지 않은 하드코딩 픽셀 크기

보드 너비 자체는 비율대로 축소되었는데, 골인 입구에 배치된 3개의 런치패드(발판) 픽셀 너비는 고정값으로 남아 있었습니다.

  • 보드는 줄어들었는데 발판 크기는 그대로 유지되다 보니, 그 사이 통로 간격이 구슬 지름(r=10)보다도 좁아진 겁니다.

해결: 통로 간격 최소치 보장 알고리즘

깔때기 출구의 실제 가용 폭을 계산한 뒤, 발판들이 최소 22px(구슬 지름의 2배 이상)의 간격을 보장하지 못할 경우 발판 자체의 가로 크기를 비율에 맞게 강제 축소하도록 자동 보정 로직을 넣었습니다.

// 출구 폭이 좁아지면 런치패드 크기도 유동적으로 축소하여 간격을 확보
if (availableWidth < requiredGap) {
  const scaleFactor = availableWidth / requiredGap;
  this.width *= scaleFactor;
}

포탈 반경 등 다른 물리 기믹들도 고정 크기에서 BOARD_XSCALE을 타도록 전수 정비하여 모바일 환경에서도 공이 갇히지 않게 해결했습니다.


4. 로또 모드(45구)를 위한 렌더링/CPU 최적화

이 시스템을 추첨용으로도 쓰다 보니 45개 구슬을 동시에 굴리는 모드(로또 모드)가 필요했습니다. 구슬 수가 늘어나니 저사양 컴퓨터에서 프레임 드랍과 오디오 찢어짐 현상이 발생하기 시작했습니다.

구슬 개수가 늘어날 때 병목을 유발하는 원인들을 차례대로 걷어냈습니다.

① 사운드 동시 발음 제한

구슬 45개가 핀과 범퍼에 부딪히며 초당 수백 번씩 오디오 플레이를 때리니 오디오 스레드 과부하로 소리가 찢어졌습니다.

  • 동일 충돌음 발생 시 최소 시간 차를 두고 실행하도록 막고, 동시에 울릴 수 있는 오디오 보이스 수를 제한하여 오디오 찢어짐을 해결했습니다.

② 완주 구슬의 물리 연산 배제 (O(N²) 제거)

구슬끼리의 충돌을 검사할 때 기본적으로 이중 루프를 돌며 O(N²) 계산을 합니다.

  • 이미 골인해서 결승선 안쪽에 안착한 구슬들은 물리 업데이트 및 구슬 간 충돌 검사 대상에서 제외시켰습니다. 달리는 중인 활성 구슬들끼리만 충돌 계산을 하도록 분리하여 연산량을 대폭 덜어냈습니다.

③ 완성된 구슬의 렌더링 이미지 캐싱

결승선에 골인한 구슬들은 위치 변화가 없습니다. 하지만 매 프레임마다 그라데이션(createRadialGradient) 효과를 호출하여 그리고 있었습니다.

  • 구슬이 멈춰 선 순간, 해당 그라데이션 렌더링 정보를 오프스크린 캔버스에 비트맵 데이터로 구운 뒤 재사용했습니다. CPU가 매번 그라데이션을 계산하지 않고 복사만 하도록 바꿔 렌더링 오버헤드를 줄였습니다.

느낀 점

업무에서는 항상 대규모 데이터 정합성이나 캐시 싱크 같은 무거운 시스템 구조만 고민했습니다. 그래서인지 이렇게 눈에 바로 보이는 캔버스 위에서 오직 ‘성능 최적화’ 하나에만 미쳐볼 수 있는 토이 프로젝트가 더 신선하고 재밌었습니다. 오버헤드 1ms라도 더 줄이려고 비트맵 캐싱을 적용하고, O(N²) 루프를 기어코 걷어내는 과정 자체가 순수한 개발의 즐거움이었습니다.

또 하나 신기한 경험은 피드백의 힘이었습니다. 혼자 만들어 둔 핀볼 레이싱 판을 주변 사람들이 직접 플레이해 보면서 진심으로 즐거워하더라고요. “꼴찌 선출 모드도 있으면 좋겠다”, “이 타이밍에 구슬이 굳으면 더 쫄깃할 것 같다” 같은 재치 있는 의견들이 쏟아졌고, 그걸 반영하며 피드백 루프를 돌아 기획을 발전시키는 재미도 쏠쏠했습니다.


관련 링크