콘텐츠로 건너뛰기
Codex

타이머 콜백이 겹쳐 실행될 때 막는 법

TECH NOTE · AUTOMATION OPS

타이머 콜백이 겹쳐 실행되면 오래된 호출이 최신 상태를 덮어씁니다. 진행 중 플래그로 막는 법과 finally가 필수인 이유를 정리했습니다.

가끔 한 프레임만 엉뚱한 그림이 스친다

바탕화면 캐릭터가 걷는 중에 아주 가끔 이상한 자세가 한 번 번쩍였다. 재현이 안 됐다. 10분을 보고 있으면 한두 번 나오고, 녹화를 켜면 안 나왔다.

코드를 봐도 이상한 곳이 없었다. 상태를 계산하는 함수는 순수했고 테스트도 통과했다.

원인은 타이머였다. 33ms마다 도는 콜백이 이전 콜백이 끝나기를 안 기다리고 있었다.

locomotionTimer = window.setInterval(() => {
  void tickLocomotion();
}, 33);

tickLocomotionasync 함수다. 안에서 두 번 기다린다.

async function tickLocomotion() {
  const out = await invoke("locomotion_tick", { dtSec });  // 백엔드 왕복
  await appWindow.setPosition(next);                        // 창 이동
  state = out.state;
  frame = out.frame;
  facing = out.facing;
}

setInterval은 이걸 모른다. 33ms가 지나면 그냥 또 부른다.

왜 오래된 호출이 새 것을 덮어쓰나

평소에는 문제가 없다. 왕복이 5ms면 33ms 안에 끝난다.

문제는 어딘가 한 번 느려질 때다. 백엔드가 60ms 걸린 순간, 두 번째 호출이 이미 시작해 있다. 그리고 먼저 시작한 쪽이 나중에 끝날 수 있다.

t=0    호출 A 시작 ── invoke 대기 ──────────────────▶ 60ms에 반환
t=33   호출 B 시작 ── invoke 대기 ──▶ 45ms에 반환
                                      B가 state를 쓴다 (최신)
                                                     A가 state를 쓴다 (과거)

마지막에 쓴 게 A다. 60ms 전의 자세가 화면에 박힌다. 다음 틱에 곧바로 덮어써지니 한 프레임만 번쩍이고 사라진다.

재현이 안 되는 이유도 여기 있다. 지연이 겹쳐야 나오고, 녹화를 켜면 부하 특성이 달라진다.

고치는 건 세 줄이다

이미 진행 중이면 그냥 돌아간다.

let locomotionTickInFlight = false;

async function tickLocomotion() {
  if (locomotionTickInFlight) return;
  locomotionTickInFlight = true;
  try {
    const out = await invoke("locomotion_tick", { dtSec });
    await appWindow.setPosition(next);
    state = out.state;
  } catch (error) {
    label.textContent = `로코모션 오류: ${String(error)}`;
  } finally {
    locomotionTickInFlight = false;
  }
}

finally가 없으면 안 된다. 중간에 예외가 나면 플래그가 true로 남고, 그 뒤로 틱이 영원히 안 돈다. 캐릭터가 그 자리에 굳는다.

catch로 잡고 finally에서 푸는 게 아니라, catch가 없어도 finally는 반드시 있어야 한다. 예외를 위로 던질 생각이라면 더 그렇다.

건너뛴 틱은 어떻게 되나

가드를 붙이면 겹친 호출이 그냥 버려진다. 33ms짜리 틱 하나가 사라지는 셈이다.

물리 계산이 실제 경과 시간을 쓰고 있으면 저절로 보정된다.

const now = performance.now();
const dtSec = Math.max(0, (now - lastLocomotionAt) / 1000);
lastLocomotionAt = now;

한 틱을 건너뛰면 다음 틱의 dtSec이 그만큼 커지고, 이동량도 그만큼 커진다. 결과적으로 같은 거리를 간다.

고정 스텝(dt를 상수로 쓰는 방식)이면 이 보정이 안 된다. 건너뛴 만큼 실제로 덜 움직인다. 그 경우엔 버리지 말고 밀린 횟수를 세어 두었다가 따라잡아야 한다.

다른 방법들

가드 말고도 선택지가 있다. 무엇을 원하느냐로 갈린다.

방법 동작 맞는 경우
진행 중이면 버림 새 호출을 무시 주기적 틱. 다음 번에 또 오니까
큐에 쌓음 순서대로 전부 실행 하나도 빠지면 안 되는 작업
이전 것을 취소 최신만 살림 검색어 입력 같은 최신 우선
세대 번호로 판별 늦게 온 옛 결과를 버림 응답을 화면에 반영할 때

주기적으로 도는 틱은 첫 번째가 맞다. 어차피 33ms 뒤에 또 오기 때문이다.

`setTimeout` 재귀로 바꾸는 방법도 있다

아예 겹칠 수 없게 만드는 방법이다. 끝난 다음에 다음 예약을 건다.

async function loop() {
  await tickLocomotion();
  timer = window.setTimeout(loop, 33);
}

이러면 가드가 필요 없다. 대신 주기가 33ms가 아니라 “처리 시간 + 33ms”가 된다. 백엔드가 60ms 걸리면 실제 주기는 93ms다.

주기가 정확해야 하면 setInterval + 가드, 겹침이 절대 없어야 하면 setTimeout 재귀다. 둘 다 되는 방법은 없다.

어디서 또 나오나

같은 형태가 반복된다. async 콜백을 주기적으로 부르는 곳이면 전부다.

  • 폴링(setInterval로 API 호출)
  • 스크롤·리사이즈 핸들러 안의 비동기 작업
  • 파일 감시 콜백
  • 웹소켓 재연결 타이머

공통점은 호출자가 콜백의 완료를 안 기다린다는 것이다. setInterval도, addEventListener도, 감시자도 반환된 Promise를 버린다.

void tickLocomotion()이라고 적은 그 void가 신호다. “이 Promise를 안 쓰겠다”는 뜻이고, 그 말은 완료를 아무도 모른다는 뜻이다.

정리

  • setIntervalasync 콜백의 완료를 기다리지 않는다. 느려지면 겹친다
  • 겹치면 먼저 시작한 호출이 나중에 끝나 오래된 값이 최신 값을 덮어쓴다
  • 증상이 “가끔 한 프레임만 이상함”이라 재현이 어렵다
  • 진행 중 플래그 하나로 막되, finally에서 반드시 푼다. 안 그러면 영구 정지다
  • 버려진 틱은 dt를 실제 경과 시간으로 쓰면 저절로 보정된다
  • 겹침이 절대 안 되면 setTimeout 재귀. 대신 주기가 늘어난다

void를 적고 있다면 그 자리에 이 문제가 있는지 한 번 보는 게 좋다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다