실시간 2D 가상 오피스를 만들며 잡은 버그 3선 — 탭 포커스·Dead Reckoning·맵 격리
실시간 멀티플레이어 2D 가상 오피스(LinkSquare)를 만들고 있다. Phaser로 화면을 그리고, Colyseus로 서버 권위 상태를 동기화하고, LiveKit으로 근접 음성을 붙인 구조다. 기능을 붙이는 것보다 오히려 “왜 화면이 이렇게 보이지?” 하는 미묘한 버그를 잡는 데 시간이 더 들었다. 이 글은 그중 기억에 남는 세 가지를 기록으로 남긴다.
1. 탭을 다시 켰더니 캐릭터가 굼떠졌다
다른 탭을 보다가 게임 탭으로 돌아오면, 캐릭터가 약 2초 동안 눈에 띄게 느리게 움직이다가 서서히 정상 속도로 “램프업”되는 증상이 있었다. 이동 속도는 상수(PLAYER_SPEED = 160)로 고정돼 있어서 코드상으로는 느려질 이유가 없었다.
원인: Phaser TimeStep의 panic 보호 로직
브라우저는 백그라운드 탭에서 requestAnimationFrame을 멈춘다. 탭으로 돌아오면 Phaser의 TimeStep은 그동안 밀린 시간을 “패닉” 상황으로 간주하고, 복귀 직후 일정 프레임(panicMax, 기본 120프레임) 동안 프레임 간 delta를 목표값(_target)으로 캡한다. 큰 delta 점프로 물리가 폭주하는 걸 막으려는 보호 장치다.
문제는 이 구간에서 실제 렌더 fps가 목표보다 잠깐이라도 낮으면, delta가 실제보다 작게 캡되면서 “일정 속도” 물리 이동이 화면상 느리게 시작한다는 점이다. 속도 상수는 그대로인데 적분되는 시간이 줄어드니, 눈에는 느려 보이는 것이다.
해결
Phaser 설정에서 fps.panicMax를 0으로 두면 복귀 후 delta 캡이 사라진다(개별 스파이크를 걸러내는 outlier 체크는 그대로 유지된다). 복귀 직후에도 즉시 정상 속도가 유지된다.
// game/config.ts
export const phaserConfig = {
// ...
// 탭 포커스 복귀 후 캐릭터가 ~2초간 느려지는 문제 해결.
// TimeStep은 focus 시 panicMax(기본 120프레임) 동안 delta를 _target으로 캡한다.
// panicMax:0으로 복귀 후 delta 캡을 제거해 즉시 정상 속도 유지.
fps: {
panicMax: 0,
},
}
한 줄짜리 설정이지만, 원인을 프레임워크 내부(TimeStep._coolDown)까지 파고들기 전까지는 “왜 속도가 램프업되지?”를 계속 맴돌았다. 엔진이 “나를 도우려고” 넣은 보호 로직이 오히려 증상을 만든 사례였다.
2. 남의 캐릭터가 뚝뚝 끊긴다 — Dead Reckoning
서버는 각 플레이어 위치를 일정 주기로만 브로드캐스트한다. 내 캐릭터는 내 입력으로 매 프레임 매끄럽게 움직이지만, 다른 플레이어는 위치 패킷이 도착할 때마다 순간이동하듯 뚝뚝 끊겨 보였다.
접근: 위치 보간 + 속도 예측
두 가지를 합쳤다. 첫째, 도착한 서버 위치로 즉시 스냅하지 않고 lerp로 부드럽게 따라가게 했다. 둘째, 서버 패킷에 속도 벡터(vx, vy)를 함께 실어 보내, 다음 패킷이 오기 전까지의 시간을 “예측 추정(dead reckoning)”으로 메웠다.
// game/utils/interpolation.ts
// 서버 위치 + 속도벡터로 elapsed(ms) 이후 위치를 예측.
// elapsedMs는 0~maxMs로 클램프해 업데이트가 끊겨도 폭주하지 않게 한다.
export function predictPosition(
server: { x: number; y: number },
velocity: { vx: number; vy: number },
elapsedMs: number,
maxMs = 250
) {
const t = Math.min(Math.max(elapsedMs, 0), maxMs) / 1000
return { x: server.x + velocity.vx * t, y: server.y + velocity.vy * t }
}
핵심은 maxMs 클램프다. 패킷이 오래 끊기면 예측만으로 캐릭터가 벽을 뚫고 무한히 나아가버린다. 예측 구간을 상한으로 막아, 최악의 경우에도 “조금 더 가다 멈춤” 정도로 그치게 했다. 예측이 틀리면 다음 서버 위치가 lerp로 부드럽게 교정한다.
3. 다른 방 사람이 보이면 안 된다 — 맵 격리
로비·회의실·휴게실 등 여러 맵을 도입하면서, 다른 맵에 있는 플레이어가 내 화면과 참가자 목록에 섞여 보이는 문제가 생겼다. 심지어 근접 음성까지 맵을 넘어 들렸다.
원인은 “같은 룸에 접속했으니 모두 같은 공간”이라는 초기 가정이었다. 플레이어마다 현재 mapId를 상태로 두고, 렌더·참가자 목록·음성 대상 계산을 모두 “나와 같은 mapId”로 필터링하도록 바꿨다. 상태 하나를 추가하는 것보다, 그 상태를 참조해야 할 지점을 빠짐없이 찾는 게 진짜 작업이었다.
돌아보며
세 버그의 공통점은 “내 코드는 맞는데 화면이 틀린” 부류라는 것이다. 속도 상수는 그대로였고(1번), 서버 위치도 정확했고(2번), 룸 접속도 정상이었다(3번). 버그는 항상 내가 “당연하다”고 넘긴 계층 — 엔진의 프레임 관리, 네트워크 지연, 공간의 정의 — 에 숨어 있었다. 증상이 아니라 그 아래 계층을 의심하는 습관이 결국 시간을 아꼈다.