타겟 생성 로직 리펙토링 - 누적 프레임 루프

Ethan·2025년 10월 10일

동작은 멀쩡했다. 대신 코드가 나를 괴롭혔다.
FPS Aim Test를 만들면서 타겟 스폰은 ‘그냥 된다’고 생각했다. 실제 플레이에서도 수십 ms 차이를 체감하기는 어렵다. 하지만 개발 완료 후 코드를 다시 볼 때 setInterval을 사실상 1회용으로 쓰고 즉시 초기화하는 구조가 눈에 밟혔고, 나중에 이걸 리팩토링 해봐야겠다고 생각했다.

그리고 리팩토링에 착수하여 코드를 살펴보던 지금.
“렌더는 requestAnimationFrame(이하 rAF)로 돌리는데, 스폰은 타이머 스케줄러라니?”
개발할 땐 몰랐는데, 설계 관점에서 렌더 루프와 시간 제어가 분리되어 있는 게 이상했다. 이 작은 이질감을 시작으로 리팩터링을 시작했고, 그 과정에서 스톨 시 버스트 생성, 첫 스폰 시점의 미세 오차, 누적 시간 보정 부재 같은 잠재 이슈를 확인하고 정리했다.

1. 문제 인식 - "체감 문제" 가 아닌 "설계 불일치"

플레이 도중 불편함을 느낀 적은 없었다. 오히려 의도대로 동작하는 게 뿌듯했지만, 코드를 다시 읽으며 아래 설계적 이슈를 파악했다.

  • 타이머(setInterval)를 사실상 1회용으로 사용
    setInterval을 걸고 한 번 실행되면 바로 cleanup 후 새 interval을 다시 거는 방식이었다. “반복 작업”을 위한 API를 '재귀 setTimeout'처럼 쓰고 있었다.

  • 렌더 루프(rAF)와 시간 제어 루프의 분리
    캔버스 렌더는 rAF(브라우저 렌더링 주기)인데, 스폰 타이밍은 타이머 큐(스케줄러)에 맡긴 구조였다. 둘은 동기화 보장이 없다.

  • 스톨/백그라운드에서의 잠재 위험
    탭 비활성, GC, 일시 정지 등으로 타이머가 클램핑/밀림될 수 있고, 복귀 시 스폰 이벤트가 버스트(한 프레임 다발 생성) 될 가능성이 있었다. 지금으로선 사실 pointerLock이 풀리면 게임이 바로 중단되어 확인할 바가 없었는데, 어떻게 발생할 지 모르니 구조적으로는 리스크라고 생각했다.


2. 원인 분석 - "타이머 기반 구조"의 한계

setInterval이 불안했던 이유를 정리하자면 단순히 문법이나 효율성 때문이 아니었다.
가장 근본적인 문제는 렌더 루프와 타이머 루프가 서로 다른 기준으로 시간을 흘려보내고 있었다는 점이다.

브라우저에는 두 가지 주요 루프가 존재한다.

루프 종류실행 기준주 사용 목적동작 타이밍
렌더 루프 (requestAnimationFrame)브라우저의 프레임 주기(대개 16.6ms)화면 렌더링, 애니메이션다음 프레임 직전에 실행
타이머 루프 (setInterval / setTimeout)내부 타이머 스케줄러예약된 시간 후 콜백 실행브라우저 렌더링과 비동기

setInterval은 이 타이머 큐에 의해 실행되기 때문에,
렌더 루프(rAF)와 항상 약간의 시간 차(16ms ± α) 가 발생한다.
결국 화면은 rAF에 맞춰 렌더링되는데, 스폰 로직은 다른 박자로 움직이게 되는 셈이다.

이 차이는 체감상 느껴지지 않더라도,

  • 탭이 비활성화될 때,
  • 브라우저가 부하로 인해 프레임을 건너뛰었을 때,
  • 혹은 백그라운드로 내려갔을 때

타이머 큐가 늦게 실행되거나, 누락된 콜백을 한꺼번에 실행하면서
‘스톨 이후 타겟이 한 번에 여러 개 생기는’ 버스트 현상이 일어날 수 있다는 사실도 이번에 알게 되었다.


3. 해결 방향 - 렌더 루프에 맞춘 누적 타이머 구조

이 문제를 해결하기 위해 골머리를 썩다가, 끝내 AI를 통해 아이디어를 얻어, 타이머 기반 로직을 모두 제거하고 requestAnimationFrame 루프 안에서 시간 누적(accumulation) 을 직접 계산하도록 바꿨다.

다시 말해,
“언제 타겟을 생성할지”를 타이머에게 맡기지 않고,
매 프레임마다 내가 직접 판단하는 구조로 재설계한 것이다.

이 방식에서는

  1. 매 프레임마다 performance.now()로 경과 시간을 측정하고,
  2. accumMs에 더해서 누적 시간(total delta)을 관리하고,
  3. 누적 시간이 생성 간격(intervalMs) 이상이 되면 타겟을 1개 생성한 뒤,
  4. accumMs에서 그만큼을 빼준다.
const spawnerTick = useCallback(() => {
  if (!targetManagerRef.current || spawnerStartTimeRef.current == null) return;

  const now = performance.now();
  const last = lastTsRef.current ?? now;
  const dt = Math.min(now - last, 100); // 스톨 방지 (100ms 이상 절삭)
  lastTsRef.current = now;
  accumMsRef.current += dt;

  const intervalMs = computeSpawnInterval(spawnerStartTimeRef.current);

  while (accumMsRef.current >= intervalMs) {
    accumMsRef.current -= intervalMs;
    const spawned = targetManagerRef.current.createTarget();
    if (spawned) setTargets(targetManagerRef.current.getTargets());
  }

  rafIdRef.current = requestAnimationFrame(spawnerTick);
}, [computeSpawnInterval]);

이 구조의 핵심은 타이머 스케줄러에 의지하지 않고, 내가 시간을 통제한다는 점이다.
더이상 타이머 스케줄러에 영향받지 않고, 렌더 루프와 타겟 생성 루프가 같은 시간축에서 움직이게 되었다.


4. 결과 - 더 안정적이고 일관된 스폰 타이밍

리팩토링 후 타겟 생성 로직은 훨씬 안정적이고 일관된 타이밍으로 동작했다.

이전에는 setInterval 기반의 타이머 큐가 브라우저 렌더링 루프와 분리되어
미세한 오차(스톨·타이머 밀림)가 누적될 여지가 있었지만,
이제는 requestAnimationFrame 루프 안에서 시간 누적을 직접 제어하기 때문에
렌더 주기와 스폰 로직이 완전히 동기화되었다.

리팩토링 과정에서 생성 간격을 감소 관련 일부 함수도 분리하고 훅에 포함시키는 등 유지보수성 및 가독성 측면에서도 소소하게 향상되었다.


5. 정리

리팩토링을 진행하면서 가장 크게 느낀 점은 다음 한 문장으로 요약된다.

“잘 작동하는 코드와 설계가 올바른 코드는 다르다.”

타겟 스폰은 처음부터 완벽히 동작했지만,

렌더링 루프와 시간 제어 루프의 분리라는 미묘한 설계 불일치는

언젠가 치명적인 버그로 이어질 수 있었다.

결국 이번 리팩토링은 단순한 최적화가 아니라

게임 루프의 구조적 일관성을 되찾는 작업이었다.

이 경험은 앞으로 어떤 기능을 구현하든,

시간을 누가 통제하고 있는가”라는 질문을 가장 먼저 던지게 만들었다.

profile
"Actions speak louder than words"

0개의 댓글