TECH NOTE · AUTOMATION OPS
앱이 멈췄다 깨어나면 밀린 시간이 한 틱에 몰려 순간이동이 납니다. dt 상한을 두고 나머지를 버리는 이유와 값을 정하는 기준입니다.
창을 가렸다 다시 열면 캐릭터가 날아가 있다
바탕화면 캐릭터가 가끔 옆으로 순간이동했다. 걷다가 갑자기 200픽셀쯤 건너뛰고, 벽을 오르는 중이면 위로 튀어 올랐다.
한동안 이동 로직을 의심했다. 목적지 계산이 틀렸거나, 지형 판정이 잘못됐거나.
아니었다. 로그를 붙이니 전부 같은 조건에서 났다.
stall 0.31s → walk +52px
stall 1.84s → walk +197px
stall 4.70s → climb +280px
멈춰 있던 시간과 튄 거리가 비례했다.
밀린 시간이 한 번에 적분된다
물리 계산은 보통 이렇게 쓴다. 마지막 틱 이후 실제로 흐른 시간을 재서 그만큼 움직인다.
const now = performance.now();
const dtSec = Math.max(0, (now - lastLocomotionAt) / 1000);
lastLocomotionAt = now;
const next = position + speed * dtSec;
이게 정상 동작에서는 맞다. 틱이 조금 늦어도 이동 거리가 보정되니 속도가 일정하게 보인다.
문제는 메인 스레드가 아주 오래 멈출 때다. 창이 다른 창에 가려지거나, macOS의 App Nap이 걸리거나, 미션 컨트롤 전환이 일어나면 렌더 루프가 통째로 선다.
실측으로 0.3초에서 4.7초까지 멈췄다. 깨어난 첫 틱의 dtSec이 4.7이다. 초속 60픽셀로 걷고 있었으면 그 한 틱에 282픽셀을 간다.
낙하산을 펴고 있었다면 160픽셀을 한 프레임에 떨어진다. 사람 눈에는 순간이동이다.
상한을 두고 나머지는 버린다
고치는 방법은 한 줄이다.
const MAX_TICK_SEC = 0.1;
const dtSec = Math.min(
MAX_TICK_SEC,
Math.max(0, (now - lastLocomotionAt) / 1000),
);
lastLocomotionAt = now;
한 틱이 아무리 늦어도 물리적으로는 100ms만 흐른 것으로 친다. 나머지 4.6초는 그냥 버린다.
버린다는 게 핵심이다. 밀린 시간을 어딘가 저장했다가 나중에 갚으면 결국 같은 순간이동이 난다. 조금씩 나눠 갚아도 캐릭터가 부자연스럽게 빨라진다.
멈춰 있는 동안은 캐릭터도 멈춰 있었던 것으로 취급하는 편이 자연스럽다. 사용자는 그 시간에 화면을 안 보고 있었다.
상한값은 어떻게 정하나
두 조건 사이에서 고른다.
아래쪽 한계 — 정상 틱 간격보다 넉넉히 커야 한다. 33ms 주기인데 상한을 40ms로 두면, 조금만 늦어도 매번 시간이 깎여 캐릭터가 느려진다.
위쪽 한계 — 한 틱의 이동량이 눈에 띄면 안 된다. 초속 60픽셀에 상한 0.1초면 최대 6픽셀이다. 그 정도는 안 보인다.
33ms 주기에 100ms 상한이면 정상 틱의 세 배까지 허용한다. 지연이 세 배를 넘으면 그건 이미 정상이 아니다.
느려지는 쪽과 튀는 쪽 중에 느려지는 쪽을 고른 것이다. 이 판단은 무엇을 만드느냐에 따라 달라진다. 물리 시뮬레이션의 정확도가 중요하면 상한을 두는 대신 고정 스텝을 여러 번 도는 방식을 쓴다.
// 고정 스텝 방식 — 정확하지만 멈춤 후 한 번에 많이 돈다
let acc = elapsed;
while (acc >= FIXED_STEP) {
step(FIXED_STEP);
acc -= FIXED_STEP;
}
이 방식은 4.7초가 밀리면 141번을 연속으로 돈다. 그동안 화면이 또 멈춘다. 그래서 이쪽도 결국 반복 횟수 상한이 필요하다.
진단이 먼저였다
이 버그를 잡은 건 코드를 다시 읽어서가 아니다. 큰 점프가 날 때 값을 찍는 로그를 먼저 붙였다.
if (Math.abs(next - position) > JUMP_WARN_PX) {
console.warn(`feet jump dt=${dtSec.toFixed(2)}s dx=${(next - position).toFixed(0)}px`);
}
한 줄인데 이게 결정적이었다. dt와 이동 거리가 같이 찍히니 둘의 비례 관계가 바로 보였다. 이동 로직을 아무리 읽어도 안 나왔을 것이다.
재현이 어려운 버그는 재현을 포기하고 증거를 남기는 쪽이 빠르다. 조건이 갖춰졌을 때만 찍히게 해 두면 로그가 넘치지도 않는다.
어디서 또 나오나
dt 기반 계산을 하는 곳이면 전부 같은 위험이 있다.
- 게임 루프, 애니메이션
- 진행률 추정(남은 시간 계산)
- 속도 기반 스크롤 관성
- 물리 엔진을 쓰는 모든 것
브라우저에서는 백그라운드 탭이 가장 흔한 원인이다. 탭을 숨기면 requestAnimationFrame이 멈추고 setInterval은 1초 이상으로 늘어난다. 다시 열면 밀린 시간이 한 번에 온다.
document.visibilityState로 감지해 복귀 시점에 lastTime을 다시 잡는 방법도 있지만, 상한 한 줄이 더 단순하고 원인을 안 가려도 된다.
정리
- 실제 경과 시간으로 물리를 적분하면, 앱이 멈췄다 깨어날 때 밀린 시간이 한 틱에 몰린다
- 실측으로 0.3~4.7초 정지가 났고 이동량이 정확히 비례했다(최대 280픽셀)
- 한 틱의
dt에 상한을 두고 나머지는 버린다. 저장했다 갚으면 같은 문제가 난다 - 상한값은 정상 틱 간격의 두세 배. 그보다 작으면 평소에 느려진다
- 정확도가 중요하면 고정 스텝 반복을 쓰되, 반복 횟수에도 상한이 필요하다
- 재현이 어려우면 조건부 로그로 값을 먼저 남긴다
원인을 알고 나면 한 줄이지만, dt를 의심하기 전까지는 이동 로직만 들여다보게 된다.