TECH 으로 돌아가기
TECH HACKER NEWS 오늘 10분 읽기 26 READS

56k 모뎀 시대의 RuneScape는 어떻게 바이트를 아꼈나

56k 모뎀 시대의 RuneScape는 어떻게 바이트를 아꼈나
SOURCE IMAGE · HACKER NEWS

2004년의 온라인 게임 환경은 지금과 전혀 달랐다. 초당 5킬로바이트 남짓의 56k 다이얼업 모뎀, 그것도 누군가 집 전화를 들면 연결이 끊기는 회선 위에서 3D 세계가 돌아갔다. 브라우저 안에서 한 서버에 최대 수천 명이 접속하고, 화면에는 수십 명이 동시에 움직였다. Jagex의 RuneScape는 이 조건에서 실제로 작동했다. 한 개발자가 2004년 RuneScape 2 클라이언트의 디컴파일 결과를 따라가며, '북쪽으로 한 칸 클릭'이라는 단순한 동작이 어떻게 서버를 거쳐 다른 플레이어의 화면까지 전달되는지 바이트 단위로 추적한 글이 이 설계의 핵심을 잘 보여준다. 결론부터 말하면, 이 게임의 네트워킹은 바이트 낭비를 극도로 거부하는 집요한 절약의 산물이다.

오직 오피코드만 암호화한다

로그인 핸드셰이크가 끝나면 게임 패킷이 오가기 전에 얇은 암호화 계층 하나가 세워진다. 흥미로운 점은 이것이 패킷 전체가 아니라 각 패킷 맨 앞의 '오피코드' 한 바이트만 암호화한다는 것이다. 오피코드는 이 패킷이 어떤 종류인지 알려주는 작은 정수이며, ISAAC이라는 스트림 암호로 보호된다. 클라이언트→서버, 서버→클라이언트 두 방향에 각각 별도의 스트림이 쓰이고, 양쪽은 네 개의 정수로 이뤄진 공유 키에서 시드를 만든다. 이 중 둘은 클라이언트가 생성하고 둘은 서버가 핸드셰이크 과정에서 넘겨준다. 서버→클라이언트 스트림은 같은 시드의 각 워드에 50을 더해 두 방향이 같은 키스트림을 공유하지 않도록 한다.

본문(body)을 통째로 암호화하지 않은 이유는 명확하다. 오피코드는 나머지 바이트를 어떻게 읽어야 하는지, 그리고 한 패킷이 어디서 끝나고 다음 패킷이 어디서 시작되는지를 결정하는 열쇠다. 이 한 바이트가 없으면 본문은 그저 해석 불가능한 바이트 덩어리가 된다. 제3자가 패킷을 파싱하는 것을 막는 가장 값싼 방어책이 바로 이 한 바이트 암호화였던 셈이다.

한 걸음은 7바이트, 경로는 꼭짓점만

북쪽으로 한 칸을 클릭하면, 네트워킹이 시작되기 전에 클라이언트는 로컬 충돌 맵을 이용한 너비 우선 탐색으로 경로를 만든다. 그리고 서버가 읽을 패킷을 작성한다. 패킷은 오피코드와, 본문 길이를 담은 1바이트로 시작한다. 이 길이 바이트는 경로의 크기에 따라 패킷 바이트 수가 달라지기 때문에 필요하다. 몸통의 길이는 '4 + 2 × (경로 길이 - 1) + 1'로 계산된다. 첫 웨이포인트의 절대 좌표는 x, z를 각각 2바이트 short로 담고, 이후 웨이포인트는 첫 지점 대비 델타를 축당 1바이트 부호 있는 정수로 보낸다. 한 번의 클릭은 멀어야 정해진 거리까지만 닿으므로 -128에서 127 범위에 충분히 들어간다.

여기서 절약의 두 가지 결정이 드러난다. 첫째, 각 웨이포인트를 절대 좌표(4바이트) 대신 델타(2바이트)로 보내 웨이포인트당 50%를 아낀다. 둘째, 경로의 모든 타일이 아니라 꺾이는 꼭짓점만 전송한다. 직선으로 열 칸을 걸어도 목적지 하나만 보내면 된다. 서버는 출발점을 이미 알고 있으니 직선을 스스로 걸으며 자신의 충돌 맵으로 검증한다. 마지막 1바이트는 Ctrl 키 상태다. 초기 버전에서 이 키는 '달리기 강제'였고, 이후 업데이트에서 현재 이동 모드를 반전시키는 방식으로 바뀌었다. 결국 한 걸음 북쪽 이동은 오피코드와 길이 표시를 포함해 7바이트로 끝난다.

600ms 사이클과 '변화 없음은 1비트'

작성된 패킷은 클라이언트의 송신 스트림에 담겨 약 20ms마다 네트워크로 비워진다. 반면 서버의 메인 루프는 약 600ms에 한 번 깨어나 모든 플레이어의 수신 버퍼를 비우고, 핸들러를 실행하고, 다음에 내보낼 갱신 정보를 조립한다. 사이클 직전에 도착한 패킷은 거의 즉시 처리되지만, 직후에 도착한 패킷은 600ms 가까이 기다린다. 이 600ms가 지연의 최소 단위를 결정하며, 20ms 플러시나 다른 오버헤드는 그 밑에서 헤엄친다. 그래서 이 설계에서 아낄 것은 시간이 아니라 바이트다. 절약할 지연 자체가 없기 때문이다.

핵심은 클라이언트가 자신이 볼 수 있는 모든 플레이어의 사본을 들고 있다는 점이다. 각 플레이어의 마지막 위치, 외형, 애니메이션, 채팅 상태를 추적하는 목록이 존재한다. 서버가 매 사이클 보내는 단일 복합 '플레이어 갱신 패킷'의 임무는 이 사본을 서버의 권위 있는 상태와 동기화하는 것이며, 이는 거의 언제나 클라이언트가 이미 아는 것에 대한 델타다. 이 패킷은 네 단계로 읽히는데, 앞의 세 단계는 바이트가 아니라 비트 단위로 읽는 비트 패킹이고, 네 번째 단계만 바이트 정렬이다. 이동과 등록은 빈번하고 작으므로 비트를, 장비 교체나 공격 동작이나 채팅 같은 드물고 풍부한 정보는 바이트를 쓴다.

이 구조의 백미는 '변화 없음'의 표현이다. 로컬 플레이어가 움직이지 않고 아무것도 바뀌지 않았다면, 그 존재 전체가 갱신 패킷에서 단 1비트로 표현된다. 한 걸음을 걸었다면 이동 여부 1비트, 유형 2비트, 방향 3비트, 추가 정보 유무 1비트로 7비트, 즉 1바이트도 안 된다. 서버와 클라이언트는 컴파일 시점에 프로토콜에 대한 동일한 이해를 공유하므로, 무엇이 기본값이고 무엇이 변화인지 굳이 설명할 필요가 없다. 40명이 가만히 서 있는 군중이라면 48비트, 곧 6바이트로 장면 전체가 정적임을 확인한다. 없음이라는 상태가 0비트 뒤에 숨어 있고, 세부 정보의 '부재' 자체가 곧 메시지다.

좌표는 볼 수 있는 만큼만

새 플레이어가 시야에 들어올 때의 인코딩도 같은 원리를 따른다. 11비트 플레이어 id(2047은 목록의 종료 표식으로 예약), 추가 정보 여부 1비트, 순간이동 여부 1비트, 그리고 위치 10비트다. 플레이어의 절대 세계 좌표는 축마다 수천 단위의 값이라 16비트씩 32비트가 필요하지만, 이 로직에는 전역 위치가 필요 없다. 화면에 그릴 수 있는 다른 플레이어는 기껏해야 약 15타일 떨어져 있고, 15는 부호 있는 5비트(-16~+15)에 깔끔히 들어간다. 좌표계를 로컬 플레이어 기준으로 다시 중심 잡고 보이는 범위로 잘라내면, 새로 보이는 플레이어의 위치는 32비트가 아니라 축당 5비트씩 10비트로 끝난다. 순간이동 분기에서는 약 104타일의 로드된 영역을 다루기에 7비트 두 개를 쓴다.

여기서 실무자가 가져갈 교훈은 분명하다. 필요할 수 있는 가장 작은 값의 집합을 먼저 규명하고, 딱 그만큼을 표현할 비트만 쓴다는 것이다. 이 원칙이 없었다면 '방향당 3비트'나 '유휴 플레이어당 1비트'도 모두 바이트 단위로 읽어야 했고, 유휴 플레이어 여덟 명이 1바이트가 아니라 8바이트를 잡아먹었을 것이다. 비트는 각 바이트를 위에서 아래로 채워 나가며, 로컬 플레이어 구간이 1비트뿐이면 다음 구간은 낭비되는 빈 공간 없이 곧장 두 번째 비트부터 읽는다.

물론 이 접근에는 한계와 맥락이 있다. 컴파일 시점에 프로토콜을 양쪽에 못 박아두는 방식은 대역폭을 극한으로 아끼는 대신 버전 간 호환성과 프로토콜 진화의 유연성을 희생한다. 또한 이 모든 절약은 600ms라는 넉넉한 사이클 시간이 지연 여유를 보장했기에 성립했다. 오늘날의 실시간 대전 게임처럼 밀리초 단위 반응이 승부를 가르는 환경이라면 같은 트레이드오프가 그대로 통하지는 않는다. 그럼에도 '기본값은 공짜, 비용은 실제로 바뀐 것에만'이라는 발상은 대역폭이 제약인 IoT 통신이나 모바일 동기화, 상태 델타 전송을 설계하는 현장에서 여전히 유효한 사고의 틀이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://jkm.dev/posts/how-2004-runescape-fit-a-multiplayer-r...
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...