본문 바로가기

블로그 / 기술

두 언어가 같은 48바이트를 읽게: AvoidNow 스냅샷을 바이너리로 바꾼 기록

AvoidNow 멀티플레이의 JSON 프레임을 바이너리로 바꾼 과정을 정리했다. 플레이어 한 명을 48바이트에 담는 레이아웃, 6바이트 입력, 그리고 GDScript와 TypeScript가 같은 바이트를 읽는지 확인한 방법까지.

Salmonholic ·· 5분 읽기

왼쪽에 여러 줄짜리 JSON 스냅샷, 오른쪽에 색으로 구분된 48개의 바이트 칸이 놓인 비교 그림

AvoidNow는 최대 네 명이 같은 아레나에서 공룡 발을 피하는 게임이다. 서버가 판정을 쥐고 있어서, 각 클라이언트에게 “지금 모두가 어디 있고 어떤 상태인가”를 초당 여러 번 내려보낸다. 이 내려보내는 묶음을 스냅샷이라고 부른다. 처음에는 스냅샷을 JSON 텍스트로 보냈는데, 공개 전에 바이너리로 바꿨다. 이 글은 왜 바꿨고, 플레이어 한 명을 어떻게 48바이트에 욱여넣었고, 두 언어로 짠 인코더와 디코더가 같은 바이트를 읽는지 어떻게 확인했는지 적은 것이다.

밤새 만든 JSON은 일부러 남겨 뒀다

멀티플레이를 처음 붙이던 날, 프로토콜은 JSON 텍스트로 만들었다. 디버깅할 때 프레임을 그냥 눈으로 읽을 수 있어서 빨리 검증하기에 좋았다. 대신 트래픽이 컸다. 좌표 하나가 "p":[640.2,430.7]처럼 열몇 글자가 되고, 키 이름까지 매번 따라붙는다. 서버는 틱을 30Hz로 돌리고 스냅샷을 15Hz로 보내니, 이 묶음이 초당 열다섯 번 네 명치씩 나간다. 모바일 데이터로 하는 사람에게는 부담이다.

그래서 결정 문서에 “공개 전에 바이너리 코덱으로 교체”를 올려 두고, 9월 26일에 프로토콜 v1을 만들었다. 핵심은 트래픽의 대부분을 차지하는 두 가지, 스냅샷(서버→클라)과 입력(클라→서버)만 바이너리로 바꾼 것이다. 로비·이벤트·결과처럼 가끔 한 번 오가는 메시지는 JSON 텍스트 그대로 뒀다. 자주 오가는 것만 조이면 효과는 대부분 얻고, 읽기 어려워지는 비용은 적게 든다. 스냅샷은 플레이어 한 명당 48바이트로 JSON의 약 7분의 1이 됐고, 전체 송신량은 대략 4분의 1을 목표로 잡았다.

스냅샷 한 프레임의 구조

스냅샷 프레임은 머리말 8바이트로 시작한다. 프레임 종류 1바이트, 매치 시작 기준 시각 4바이트(float), 서버가 적용한 마지막 입력 번호(ack) 2바이트, 플레이어 수 1바이트다. 그 뒤에 플레이어가 인원수만큼 48바이트씩 이어지고, 다이브(추적형 장판) 개수 1바이트와 다이브 8바이트들이 붙는다.

스냅샷 프레임의 바이트 구조와 플레이어 48바이트 안의 필드 구성 프레임은 머리말 8바이트 + 인원수 × 48바이트 + 다이브로 끝난다. 아래는 플레이어 한 명의 48바이트가 어떤 필드로 나뉘는지다.

모든 수는 리틀 엔디언으로 쌓는다. 바이트 순서는 두 언어가 반드시 같게 정해 둬야 하는 약속이라, 코덱 파일 머리 주석에 레이아웃을 한 줄씩 적어 그걸 정답으로 삼았다.

48바이트 안에 무엇을 어떻게 넣었나

좌표를 float 4바이트로 보내면 정밀도가 남아돈다. 아레나는 1,280×720 픽셀이고 화면에서 0.1픽셀 차이는 보이지 않는다. 그래서 위치와 속도는 10을 곱해 정수로 반올림한 뒤 16비트로 보낸다. 0.1픽셀 단위까지 담기고, 한 값이 4바이트에서 2바이트로 준다. 바라보는 방향은 x·y 두 축 값이 -1~1 범위라 축마다 127을 곱해 1바이트씩, 둘이 2바이트에 들어간다. 이 방향은 연출용이라 거친 값이어도 괜찮다.

생존·다운·이탈·방패 같은 참/거짓 상태는 따로 바이트를 쓰지 않고 플래그 1바이트에 비트로 몰아 담는다. 생존이 0번 비트, 다운이 1번, 이탈이 2번, 방패가 3번이다. 남은 하트 수는 0~255를 담는 1바이트에 따로 둔다. 반면 무적 끝·대시 끝·대시 쿨 끝·신발 끝 같은 “언제까지”는 시각이라 float 그대로 보낸다. 이 값들이 48바이트 중 절반 가까이를 차지한다.

재미있는 건 필드를 다 더하면 47바이트라는 점이다. 그런데 슬롯은 48바이트로 고정해 뒀다. 한 바이트가 비는 셈인데, 숫자가 딱 떨어지는 48을 슬롯 크기로 잡아 두면 “플레이어 N명 = 48N바이트”로 계산이 단순해지고, 나중에 필드를 하나 더 넣을 여유도 생긴다. 이 48이라는 숫자는 코덱 상수 하나로 두고 양쪽이 같이 쓴다.

두 언어가 같은 바이트를 읽는지 확인하기

문제는 인코더와 디코더가 서로 다른 언어로 산다는 것이다. 서버는 TypeScript로 스냅샷을 만들고, 게임 클라이언트는 Godot에서 GDScript로 그 바이트를 푼다. 레이아웃을 한 글자라도 다르게 이해하면, 점수 자리에서 좌표를 읽어 캐릭터가 엉뚱한 곳으로 튄다.

snap_v1.hex를 색으로 구분한 바이트 격자와 풀어낸 값 서버가 만든 65바이트 고정 예시를 클라이언트가 풀어, 시각·ack·점수·다이브 번호가 예상값과 같은지 본다.

이걸 맞추려고 고정 예시 바이트 파일을 하나 뒀다. 특정한 값(시각 12.345, ack 65534, 점수 123456, 다이브 번호 42 같은 눈에 띄는 수)으로 채운 스냅샷을 16진수로 적어 저장소에 넣어 둔다. 클라이언트 쪽 테스트는 이 파일을 읽어 디코더로 풀고, ack이 65534인지, 첫 플레이어 점수가 123456인지, x가 640.2 근처인지, 다이브 번호가 42인지를 확인한다. 하나라도 어긋나면 레이아웃이 틀어졌다는 뜻이라 바로 걸린다. 양쪽을 고칠 때마다 이 예시 하나로 교차 검증이 되는 셈이다.

입력은 6바이트, 그리고 옛 클라이언트를 위한 길

클라이언트가 서버로 보내는 입력은 더 작다. 프레임 종류 1바이트, 입력 번호(seq) 2바이트, 좌우·상하 이동값을 127 곱해 각 1바이트, 대시 눌림 1바이트. 합쳐서 6바이트다.

입력 프레임 (6바이트)
  u8  종류
  u16 seq
  i8  mx * 127   (-1 ~ 1)
  i8  my * 127
  u8  대시(0/1)

seq와 ack은 16비트로 돌고 돈다. 65535 다음은 0이다. 서버는 입력을 틱마다 하나씩만 적용하고, 스냅샷의 ack에 “여기까지 적용했다”를 담아 돌려준다. 클라이언트는 그 이후 입력만 다시 예측에 쓴다.

갈아타는 방법도 조심했다. 클라이언트는 접속할 때 주소에 proto=1을 붙여 “나는 바이너리를 읽을 수 있다”고 알린다. 이 표시가 없으면 서버는 예전처럼 JSON 스냅샷을 보낸다. 덕분에 서버를 먼저 올려도 옛 빌드가 깨지지 않는다. 한국 서버와 해외 서버가 같은 계약을 구현하므로, 어느 쪽에 붙어도 동작은 같다.

규칙을 두 언어로 똑같이 짜는 이야기는 같은 게임 규칙을 두 언어로에 따로 적었다. 이번 글은 그 규칙이 주고받는 “봉투”를 줄인 기록이다. 이렇게 줄인 트래픽으로 작은 서버 한 대에 몇 명이 들어가는지는 $5짜리 서버 부하 테스트에서 쟀다. 직접 피해 보고 싶다면 AvoidNow 게임 소개에서 바로 할 수 있다.

#AvoidNow#네트워크#직렬화#바이너리 프로토콜#Godot#TypeScript

함께 읽으면 좋은 글

전체 글 →