본문 바로가기

블로그 / 기술

10인 배틀로얄 네트워크 동기화 — 스냅샷, 예측, 그리고 내 캐릭터 떨림

Salmonholic 온라인 매치에서 15Hz 스냅샷을 받아 60fps로 그리는 방법과, 예측 보정 버그로 생긴 내 캐릭터 떨림을 측정해 고친 과정을 기록했다.

Salmonholic ·· 6분 읽기

온라인 매치에서 흰 고양이가 연어 떼 사이를 원을 그리며 이동하는 Salmonholic 화면

Salmonholic의 온라인 매치는 서버가 초당 15번 상태를 보내고, 브라우저는 초당 60번 이상 화면을 그린다. 그 사이의 빈칸을 어떻게 메우느냐가 넷코드의 전부였다. 이 글은 Godot 웹 클라이언트에서 스냅샷을 받아 그리는 구조를 정리하고, 출시 직전에 발견한 “내 캐릭터가 미세하게 떨린다”는 문제를 어떻게 재고 고쳤는지 적은 기록이다.

무엇을 보내고 무엇을 보내지 않나

서버는 매 틱(66.67ms)마다 스냅샷 하나를 모든 플레이어에게 보낸다. 바이너리 형식이고 리틀엔디언이다. Godot의 PackedByteArray.decode_* 함수가 리틀엔디언만 읽기 때문이다. 플레이어 한 명의 행은 17바이트다.

필드 크기 용도
id, flags 2B 누구인지, 생존·봇·피격 상태
x, y 4B 위치
face, hp, level 3B 바라보는 방향, 체력, 레벨
weapons 4B 보유 무기 비트
inSeq 2B 서버가 마지막으로 처리한 내 입력 번호
xp 2B 경험치

값이 바뀐 행만 보내는 델타 방식이고, 15틱마다 전체를 담은 키프레임을 한 번 보낸다. 10명 키프레임이 196바이트, 클라이언트당 초당 약 2.3KB다. 화면을 채우는 연어 떼는 아예 보내지 않는다. 각 클라이언트가 서버에서 받은 시드로 자기 주변 몬스터를 직접 돌리고, 처치 수만 보고한다. 서버 구조는 Durable Objects 룸 서버 글에 따로 적었다.

이벤트(사망, 레벨업, 자기장 변화 등)는 종류 1B + 길이 1B + 내용 형식으로 스냅샷 꼬리에 붙는다. 모르는 종류는 길이만큼 건너뛴다. 이 규칙 덕에 서버에 새 이벤트를 추가해도 옛 클라이언트가 깨지지 않는다. 반대로 길이가 틀리면 전부 무너진다. 초기에 서버가 12바이트짜리 사망 이벤트에 길이 11을 적는 버그가 있었는데, 그 뒤에 오는 이벤트가 전부 1바이트씩 밀려 한 경기에 정체불명 이벤트가 18~25건씩 나왔다. 코덱은 이후 서버 인코딩과 Godot 디코딩을 왕복시키는 테스트 73개와 실서버 캡처 프레임을 바이트 단위로 재인코딩하는 검사로 묶었다.

남의 캐릭터는 100ms 늦게, 내 캐릭터는 1틱 빠르게

다른 9명은 보간으로 그린다. 받은 스냅샷 두 장 사이를 100ms 늦은 시점 기준으로 선형 보간한다. 스냅샷이 늦게 오면 최대 150ms까지만 앞으로 외삽하고 멈춘다.

여기서 작은 함정이 있었다. 처음에는 서버 틱을 공칭값 15Hz로 계산했는데, 로컬 서버는 실제로 14.5Hz 정도로 돌았다. 그 차이가 쌓여 로컬 테스트에서 프레임의 7.5%가 보간이 아닌 외삽으로 그려졌다. 도착 간격으로 실제 틱 속도를 재서 시계를 맞추자 외삽 비율은 0.13%로 떨어졌다.

내 캐릭터는 반대로 예측한다. 입력을 보낼 때마다 서버와 같은 이동 공식으로 정확히 1틱만큼 먼저 움직인다. 스냅샷이 오면 inSeq를 보고 서버가 이미 처리한 입력은 버리고, 남은 입력을 서버 위치에서 다시 재생한다. 예측과 서버의 차이는 0.12초에 걸쳐 흡수하고, 80픽셀을 넘으면 그냥 순간이동시킨다. 왕복 262390ms인 먼 서버에서도 보정 오차는 평균 1.85.1픽셀, 순간이동은 0회였다.

숫자는 괜찮은데 화면은 떨렸다

통계로는 문제가 없었다. 그런데 서울 서버에서 직접 해 보면 내 고양이가 멈추지 않고 미세하게 떨렸다. 순간이동 횟수는 0이었다. 그 통계는 80픽셀 이상 오차만 세기 때문에 몇 픽셀짜리 떨림은 잡지 못했다.

재현을 위해 측정 도구를 새로 만들었다. 같은 실서버 패킷을 원래 버퍼와 수정할 버퍼에 동시에 넣고 120Hz로 표시 위치를 기록하는 프로브다. 이렇게 하면 시드나 경기 진행이 달라서 생기는 차이 없이 코드 차이만 볼 수 있다.

원인은 두 가지였다.

  1. 시계 하나를 두 용도로 썼다. 예측 버퍼의 한 변수가 “입력 틱의 외삽 시작 시각”과 “보정 오프셋이 줄어들기 시작한 시각”을 함께 맡고 있었다. 스냅샷이 도착하면 이 값이 초기화되면서 이미 화면에 그려진 틱 내부 이동분이 사라졌고, 입력을 보낼 때는 이미 줄어든 보정량이 다시 커졌다. 스냅샷마다 0.881픽셀씩 튀는 게 모델에서 그대로 재현됐다.
  2. 거의 수직으로 움직일 때 좌우가 뒤집혔다. 캐릭터 그림의 좌우는 매 프레임 cos(face) < 0으로 정했다. 위로 곧게 움직이면 수평 성분이 0 근처에서 부호만 바뀌어, 5.35초 동안 그림이 61번 뒤집혔다.

첫 번째는 보정 오프셋 전용 시계를 따로 두고, 입력이나 스냅샷을 적용하기 직전의 표시 위치 전체를 보존하도록 고쳤다. 두 번째는 수평 성분이 0.12를 넘을 때만 방향을 바꾸는 히스테리시스를 넣었다. 공격 방향과 시뮬레이션 값은 건드리지 않고 그림만 바꿨다.

수정 전후 떨림 측정값 막대그래프 같은 패킷을 두 버퍼에 동시에 넣어 비교했다. 입력·스냅샷 순간의 점프는 0이 됐다.

측정 수정 전 수정 후
한국 서버 프레임 잔차 RMS 2.022px 0.091px
한국 서버 이벤트 점프 RMS 5.944px 0px
오프라인 위쪽 이동 중 좌우 반전 61회 0회

60Hz와 120Hz 샘플에서 똑같이 재현됐기 때문에 모니터 주사율 문제가 아니라는 것도 확인했다. 이 수정은 2026-09-24 커밋 5af247a(0.11.0)에 들어갔다. 커밋 메시지에는 한국 서버 월드 좌표 RMS가 4.24.9픽셀에서 0.230.32픽셀로 줄었다고 적혀 있다.

끊겼다 돌아오는 경우

웹 게임은 연결이 자주 끊긴다. 탭이 백그라운드로 가거나 모바일 망이 바뀌면 소켓이 닫힌다. 서버는 입장할 때 재접속 토큰을 주고, 경기 중에 끊기면 클라이언트는 0.5초, 1초, 2초 간격으로 다시 붙는다. 15초 안에 돌아오면 같은 자리로 복귀하고, 그동안 내 고양이는 봇이 대신 조종한다. 복귀하면 서버가 로스터, 경기 상태, 카드 선택 정보를 다시 보내고 다음 틱에 키프레임을 준다.

온라인 매치에서 두 고양이가 맞붙은 장면 PvP 판정은 서버가 한다. 클라이언트의 무기 이펙트는 연출일 뿐이고 HP는 스냅샷 값을 따른다.

그래도 빈틈은 있었다. 죽어서 관전 중일 때 재접속하면 결과 화면으로 넘어가는 타이머가 멈췄고, 레벨업 카드를 고르던 중 온라인에서 죽으면 카드 창이 그대로 남았다. 둘 다 0.11.4(커밋 62a2f75)에서 고쳤다.

한 번 잰 값을 믿지 않기

마지막으로 배운 건 측정 한 번을 믿지 말라는 것이다. 접속 전에 서울 서버와 Cloudflare 서버 중 빠른 쪽을 고르는데, 초기 코드는 두 서버에 동시에 물어 먼저 대답한 쪽을 택했다. 서울이 1ms만 늦어도 먼 서버로 갈 수 있는 구조였다. 0.16.4(커밋 83283e9)에서 각 서버를 세 번씩 재서 중앙값을 쓰고, 서울 서버에 30ms 여유를 주도록 바꿨다. 넷코드의 다른 부분도 마찬가지였다. 떨림은 통계 하나로는 보이지 않았고, 두 버퍼를 나란히 놓고 나서야 보였다.

온라인 매치는 Salmonholic 소개 페이지에서 바로 들어갈 수 있다. 빈자리를 채우는 서버 봇 이야기는 봇과 매치메이킹 글에 이어서 적었다.

#넷코드#클라이언트 예측#보간#Godot#멀티플레이

함께 읽으면 좋은 글

전체 글 →