뚝뚝은 용기와 물줄기를 고르고, 버튼을 눌러 물을 받다가 넘치기 직전에 멈추는 타이밍 게임이다. 규칙은 단순하지만 화면에서는 매 순간 수위가 올라가야 하고, 용량을 넘는 순간 바로 게임이 끝나야 한다. 이 글은 이 "실시간"을 React 안에서 어떻게 구현했는지, 그리고 그 과정에서 만난 함정을 정리한다.

뚝뚝 게임 화면. 800ml 그릇에 구름 물줄기로 물을 받는 중이고 현재 수위가 401.7ml(50.2%)로 표시된다

게임의 물리: 용량과 유량

게임 데이터는 두 표가 전부다.

const vessels = {
  soju: { name: "소주컵", capacity: 50 },
  cup:  { name: "컵",     capacity: 180 },
  mug:  { name: "머그컵", capacity: 300 },
  beer: { name: "맥주컵", capacity: 500 },
  bowl: { name: "그릇",   capacity: 800 },
};
const sources = {
  tear:     { name: "눈물",     rate: 14 },  // ml/초
  purifier: { name: "정수기",   rate: 42 },
  faucet:   { name: "수도꼭지", rate: 62 },
  kettle:   { name: "주전자",   rate: 85 },
  cloud:    { name: "구름",     rate: 100 },
};

물이 흐르는 동안 매 순간 "유량 × 흐른 시간"만큼 양이 늘어난다. 양이 용량을 넘으면 넘침이다. 소주컵에 구름을 고르면 0.5초, 그릇에 눈물을 고르면 57초 만에 가득 찬다. 이 단순한 공식을 화면 갱신과 정확히 맞추는 것이 구현의 전부다.

setInterval이 아니라 requestAnimationFrame

수위처럼 눈에 보이는 값을 계속 바꿀 때는 setInterval보다 requestAnimationFrame(rAF)이 맞다. rAF는 브라우저가 다음 화면을 그리기 직전에 콜백을 호출해 준다. 화면 주사율(보통 60Hz, 요즘은 120Hz도 많다)에 맞춰 호출되므로 불필요한 계산이 없고, 탭이 보이지 않으면 호출을 멈춰 배터리도 아낀다.

다만 rAF는 "몇 ms마다"가 정해져 있지 않다. 그래서 프레임마다 일정량을 더하면 안 되고, 직전 프레임 이후 흐른 시간을 재서 그만큼 더해야 한다. 그렇지 않으면 120Hz 화면에서는 물이 두 배 빨리 찬다.

실제 루프 코드

const [amount, setAmount] = useState(0);
const [isPouring, setIsPouring] = useState(false);
const amountRef = useRef(0);                 // 루프 안에서 읽고 쓰는 실제 값
const lastFrameRef = useRef<number | null>(null); // 직전 프레임 시각

useEffect(() => {
  if (!isPouring || phase !== "play") return;
  let frame = 0;
  const tick = (time: number) => {
    const previous = lastFrameRef.current ?? time;
    // 직전 프레임 이후 흐른 시간. 단, 40ms를 넘지 않게 자른다.
    const elapsed = Math.min(40, time - previous) / 1000;
    lastFrameRef.current = time;

    const next = amountRef.current + source.rate * elapsed;
    amountRef.current = next;
    setAmount(next);

    if (next > vessel.capacity) {
      setIsPouring(false);
      setPhase("overflow");
      lastFrameRef.current = null;
      return; // 루프 종료
    }
    frame = requestAnimationFrame(tick);
  };
  frame = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(frame);
}, [isPouring, phase, source.rate, vessel.capacity]);

짧은 코드지만 결정이 여러 개 들어 있다. 하나씩 보자.

1. 경과 시간을 40ms로 자른다

Math.min(40, time - previous)가 이 루프에서 가장 중요한 한 줄이다. rAF는 탭이 보이지 않으면 멈춘다. 물을 받는 도중에 다른 탭을 봤다가 10초 뒤에 돌아오면, 돌아온 첫 프레임의 경과 시간은 10,000ms가 된다. 이걸 그대로 쓰면 수도꼭지 기준 620ml가 한 프레임에 들어가 돌아오자마자 넘침이 된다. 사용자는 아무것도 하지 않았는데 졌다고 느낀다.

40ms는 25fps에 해당하는 값이다. 정상적인 상황에서는 프레임 간격이 16ms(60Hz)나 8ms(120Hz)라 이 제한에 걸리지 않는다. 기기가 잠깐 버벅여 프레임이 밀렸을 때나 탭이 숨겨졌다 돌아왔을 때만 잘린다. 그 대가로 "숨겨져 있던 동안 흐른 물"은 무시되는데, 게임에서는 이쪽이 훨씬 공정하다. 사용자가 보지 못한 시간은 게임 시간으로 치지 않는 것이다.

이 판단은 앞서 쓴 카운트다운 타이머 글과 정반대다. 타이머는 사용자가 보지 않는 동안에도 시간이 흘러야 하고, 게임은 보지 않는 동안 멈춰야 한다. "보지 않는 시간을 어떻게 취급할 것인가"는 기능의 성격에 따라 달라진다.

프레임 간격과 40ms 제한을 직접 확인해 보았다.

  • 환경: Chromium 156.0.8078.4(Playwright 1.64.1, headless), macOS 26.6.2 arm64(Apple M5 Pro), Node v22.22.2, 뚝뚝 운영 빌드(out/), 화면 주사율 60Hz, 2026-10-23 측정
  • 방법: 그릇(800ml)과 구름(100ml/s)을 고르고 4초 동안 물을 틀며 requestAnimationFrame 콜백이 받은 시각의 간격을 기록
CPU 측정 구간 프레임 수 간격 평균 중앙값 95번째 백분위 최대 40ms를 넘은 프레임
제한 없음 4,061ms 242 16.67ms 16.7ms 16.8ms 16.8ms 0개
6배 느리게 4,086ms 243 16.67ms 16.7ms 16.8ms 16.8ms 0개

CPU를 6배 느리게 해도 프레임 간격은 달라지지 않았다. 뚝뚝의 화면이 가벼워서 40ms 제한에 걸리는 일이 평소에는 없다는 뜻이다. 제한이 실제로 동작하는 순간을 보려고, 물을 트는 중에 메인 스레드를 500ms 동안 막고 그 직후 3프레임 안에 수위가 얼마나 올랐는지 네 번 측정했다.

시도 블로킹 직전 수위 직후 수위 증가분
1 60.0ml 67.3ml 7.3ml
2 100.7ml 108.0ml 7.3ml
3 141.3ml 148.7ml 7.4ml
4 182.0ml 189.3ml 7.3ml

구름의 유량이 100ml/s이므로 500ms 동안 흐른 물을 그대로 반영했다면 계산상 50ml가 한 번에 늘어야 하지만, 실제 증가분은 7ml대였다. 밀린 시간이 40ms(4ml)로 잘리고 나머지 몇 프레임의 정상 증가분이 더해진 값이다. 120Hz 화면과 실제 저사양 휴대폰에서의 프레임 간격은 측정하지 못했다.

2. 루프의 값은 ref, 화면은 상태

물의 양을 useState로만 관리하면 루프 안에서 최신 값을 읽기 어렵다. tick 함수는 이펙트가 시작될 때 만들어진 클로저라, 그 안에서 amount를 읽으면 이펙트 시작 시점의 값에 고정된다. 그렇다고 amount를 이펙트 의존성에 넣으면 매 프레임 이펙트가 정리되고 다시 시작된다.

그래서 실제 계산은 amountRef로, 화면 갱신은 setAmount로 나눴다. 루프는 ref를 읽고 쓰며 계산하고, 결과를 상태에 복사해 화면을 다시 그리게 한다. 이펙트는 물이 흐르기 시작하거나 멈출 때, 용기나 물줄기가 바뀔 때만 다시 시작된다.

3. 멈췄다 다시 누를 때 시간 기준을 초기화한다

const togglePour = () => {
  lastFrameRef.current = null; // 다음 첫 프레임은 경과 시간 0으로 시작
  setIsPouring((value) => !value);
};

물을 멈췄다가 3초 뒤 다시 누르면, lastFrameRef에는 3초 전 마지막 프레임 시각이 남아 있다. 초기화하지 않으면 다시 누르는 순간 3초 치(물론 40ms로 잘리긴 하지만)의 물이 들어간다. 토글할 때마다 null로 만들면 첫 프레임은 previous = time이 되어 경과 시간 0으로 시작한다.

4. 넘침 판정은 루프 안에서 즉시

넘침 여부를 별도 이펙트에서 amount를 보고 판단할 수도 있다. 하지만 그러면 넘친 뒤 한 프레임이 더 그려지고, 다음 렌더링에서야 게임이 끝난다. 루프 안에서 계산 직후에 판정하고 바로 루프를 끝내면, 넘친 순간과 게임 종료가 같은 프레임에 일어난다.

화면 표시: 104%까지만

const percent = Math.min(104, (amount / vessel.capacity) * 100);
const score = Math.min(100, Math.round((amount / vessel.capacity) * 1000) / 10);

수위 그래픽은 104%까지 허용한다. 넘치는 순간 물이 용기 위로 살짝 올라오는 모습을 보여 주기 위해서다. 100%에서 딱 자르면 "넘쳤다"는 느낌이 약하다. 반대로 점수는 100을 넘지 않게 자르고, 소수점 한 자리까지 보여 준다. 99.4%와 99.8%의 차이가 이 게임의 재미이기 때문이다.

매 프레임 setState는 괜찮을까

rAF 루프에서 매 프레임 setAmount를 호출하면 초당 60~120번 React 렌더링이 일어난다. 걱정되는 부분이지만, 뚝뚝에서는 문제가 없었다. 다시 그려지는 것이 게임 화면 하나이고, 무거운 계산이 없기 때문이다. 만약 화면 구성이 복잡해져 프레임이 떨어진다면 두 가지 방법이 있다.

  1. 수위 요소만 별도 컴포넌트로 분리해서 렌더링 범위를 줄인다.
  2. 상태 대신 ref로 DOM 요소에 직접 style.transform을 바꾸고, React 상태는 게임이 끝날 때만 갱신한다.

2번은 가장 빠르지만 React의 선언적인 흐름에서 벗어나므로, 1번으로 부족할 때만 쓰는 것이 좋다. 작은 게임에서는 "일단 상태로 만들고, 측정해서 느리면 최적화"가 맞는 순서였다.

버튼은 누르고 있기가 아니라 토글

실제 수도꼭지처럼 버튼을 누르고 있는 동안만 물이 흐르게 하는 방식도 생각할 수 있다. 하지만 모바일에서 길게 누르면 텍스트 선택이나 컨텍스트 메뉴가 뜰 수 있고, 손가락이 버튼 밖으로 살짝 미끄러지면 pointerleave나 pointercancel로 입력이 끊기는 등 기기마다 다른 예외가 많다. 뚝뚝은 한 번 누르면 흐르고, 다시 누르면 멈추는 토글 방식을 쓴다. 반응 속도를 겨루는 게임에서 입력 방식이 불안정하면 실력이 아니라 기기 탓으로 지게 되기 때문에, 단순하고 예측 가능한 입력을 고르는 편이 낫다.

정리

  • 눈에 보이는 연속 변화는 requestAnimationFrame으로, 변화량은 경과 시간 기반으로 계산한다.
  • 경과 시간에 상한(뚝뚝은 40ms)을 두면 탭 전환이나 순간적인 버벅임 뒤의 큰 점프를 막을 수 있다.
  • 루프에서 읽고 쓰는 값은 ref로, 화면에 보일 값은 상태로 나누면 이펙트를 매 프레임 다시 만들지 않아도 된다.
  • 다시 시작할 때는 시간 기준을 초기화하고, 종료 판정은 루프 안에서 즉시 한다.