랜선포차 서버 송신량을 4분의 1로: 한 틱에 프레임 하나, 바뀐 필드만 보내기
9월 27일 밤, 랜선포차 방 서버의 전송 형식을 프로토콜 3으로 바꿨다. 메시지를 틱마다 한 프레임으로 묶고 상태는 바뀐 필드만 보내서 사용자당 송신 75%, 서버 CPU는 16.7%에서 9.2%로 줄었다.

랜선포차는 폰 브라우저에서 최대 여섯 명이 한 방에 모여 미니게임을 하고, 진 사람이 우유 벌칙을 받는 우유 벌칙 파티 게임이다. 화면은 화상 통화처럼 여섯 칸으로 나뉘고, 서버는 방 상태가 바뀔 때마다 모든 사람에게 그 상태를 보낸다.
9월 27일 밤, 이 전송 방식을 통째로 바꿨다. 순서대로 부하 테스트 도구(ad77e7f, 21시 10분), 프레임 묶음과 상태 델타(1987103, 22시 3분), 인코더 CPU 줄이기(344233d, 22시 11분), 병합(cd3287b, 22시 43분)이다. 병합 커밋 제목에 적은 결과는 **사용자당 송신 −75%, 가상 접속자 1,200명에서 서버 CPU 16.7% → 9.2%**다. 그날 무엇을 바꿨는지 커밋 기록을 따라 정리한다.
먼저 잴 도구부터 만들었다
고치기 전에 지금 얼마나 쓰는지부터 알아야 했다. 그래서 첫 커밋은 최적화가 아니라 부하 테스트 도구였다. 사람처럼 움직이는 가상 클라이언트를 30, 60, 120, 240, 480명처럼 단계별로 늘리면서 방 서버 한 대가 어디까지 버티는지 본다.
가상 클라이언트는 실제 클라이언트 흉내를 낸다. 게스트로 들어와 5초마다 ping을 보내고, 차례가 오면 1~3초 뒤 게임을 고르고, 게임 중에는 그 게임에 맞는 입력을 무작위로 보낸다. 채팅과 리액션도 무작위 간격으로 섞는다. 판이 끝나면 몇 초 뒤 새 판에 들어가 인원을 유지한다.
재는 값은 ping 왕복 시간의 p50·p95·p99, 서버 CPU와 메모리, 초당 받은 메시지 수와 바이트다. 서버는 운영 서버와 비슷하게 CPU 1개, 메모리 256MB로 묶은 로컬 컨테이너에서 돌렸다. 도구 안에는 운영 주소를 넣으면 실행을 거부하는 줄도 넣었다. 실제 사용자가 있는 서버에 가짜 사람 수백 명을 밀어 넣는 실수는 한 번이면 충분히 큰 사고다.
무엇이 낭비였나
바꾸기 전 서버는 두 가지 방식으로 바이트를 버리고 있었다.
첫째, 메시지를 생기는 대로 하나씩 보냈다. 한 사람이 채팅을 치면 채팅 이벤트가 나가고, 곧이어 바뀐 방 상태가 따로 나갔다. 같은 순간에 리액션, 안내 문구, 상태가 겹치면 WebSocket 프레임이 여러 개로 쪼개져 나갔다.
둘째, 상태를 매번 통째로 보냈다. 방 상태에는 여섯 명의 이름, 캐릭터, 배탈 게이지, 우유 개수, 지금 게임의 화면 정보가 다 들어 있다. 그런데 한 번 바뀔 때 실제로 달라지는 것은 대개 한두 사람의 값 몇 개다. 나머지는 직전에 보낸 것과 똑같은데도 전부 다시 문자열로 만들어 보냈다.
바꾼 것 1: 한 틱에 프레임 하나
서버에 소켓마다 보낼 메시지를 모아 두는 상자를 만들었다. 이벤트, 채팅 기록, 안내 문구는 바로 보내지 않고 이 상자에 쌓는다. 그러다 방의 flush 틱이 오면 [이벤트들…, 상태] 순서로 배열 하나에 담아 프레임 하나로 보낸다. 배열 안 순서가 곧 처리 순서라서, 클라이언트는 이벤트를 먼저 처리하고 그 결과 상태를 받는다.
예외는 pong 하나다. 서버와 폰의 시계를 맞추는 용도라서 묶어서 늦게 보내면 측정값이 틀어진다. 그래서 ping이 오면 바로 답한다.
바꾼 것 2: 상태는 바뀐 필드만
소켓마다 “이 사람에게 마지막으로 보낸 상태”를 기억하는 인코더를 붙였다. 처음 접속했을 때, 재접속했을 때, 다시하기로 새 판을 열 때만 전체 상태를 보낸다. 그 뒤로는 바뀐 부분만 담은 델타를 보낸다. 델타는 세 갈래로 나뉜다.
| 부분 | 델타에 담는 것 | 통째로 보내는 경우 |
|---|---|---|
| 방의 최상위 필드 | 바뀐 필드만 | 없음 |
| 참가자 목록 | 바뀐 사람의 바뀐 필드만 | 자리 순서나 구성이 달라졌을 때 |
| 게임 화면 정보 | 바뀐 키와 없어진 키 | 다른 게임으로 넘어갔을 때 |
모든 상태 메시지에는 1씩 오르는 번호(seq)를 붙였다. 클라이언트는 받은 델타의 번호가 “마지막 번호 + 1”이 아니면 중간을 놓친 것으로 보고 그 델타를 버린 뒤 전체 상태를 다시 달라고 요청한다. 요청을 너무 자주 보내지 않게 2초 간격을 두었다.
export interface StateDelta {
type: "delta";
seq: number;
s?: Partial<RoomState>; // 바뀐 최상위 필드
p?: (FieldPatch & { seat: number })[]; // 사람별 바뀐 필드
g?: FieldPatch; // 같은 게임일 때 바뀐 키
}
화면 코드는 하나도 고치지 않았다. 클라이언트의 디코더가 델타를 받아 전체 상태로 되살리고, 매번 새 객체를 만들어 넘긴다. 미니게임 24종의 화면은 예전처럼 완성된 방 상태만 받는다. 전송 형식이 바뀌었으니 프로토콜 버전은 3으로 올렸다.
델타가 맞는지 어떻게 믿나
델타 방식의 위험은 어딘가에서 한 필드라도 빠지면 그 사람 화면만 조용히 틀어진다는 점이다. 그래서 같은 커밋에 시험을 따로 붙였다. 한 판을 처음부터 끝까지, 24개 게임 전부에서 돌리면서 모든 자리에 대해 “델타를 쌓아 되살린 상태”와 “서버가 그 자리에 보내려던 전체 상태”가 같은지 비교한다. 재접속, 다시 동기화 요청, 다시하기 때 전체 상태가 나가는지와 번호 틈 처리도 시험한다.
부하 테스트 도구와 네트워크 점검 도구도 새 형식을 읽도록 같이 고쳤다. 재는 도구가 옛 형식을 기대하면 숫자가 아니라 오류만 나온다.
두 번째 고비: 비교하는 데 CPU를 쓰고 있었다
첫 구현은 필드마다 JSON 문자열로 바꿔서 직전 값과 비교했다. 보내는 바이트는 줄었지만, 비교하려고 문자열을 만드는 일이 새로 생겼다. 8분 뒤 커밋(344233d)에서 이 비용을 줄였다.
- 숫자, 문자열, 참·거짓은 문자열로 바꾸지 않고 그대로 비교한다. 객체와 배열만 JSON으로 바꾼다.
- 여섯 자리가 같이 쓰는 참가자 목록과 공용 게임 화면은 flush마다 한 번만 문자열로 만들고, 그 결과를 자리끼리 나눠 쓴다.
- 비교할 때 임시 Map이나 객체 복사를 만들지 않는다.
이 커밋에 적은 측정은 서버 프로세스 안에서 한 판짜리 방 40개를 돌려 잰 값이다. 상태 인코딩에 173ms가 들었고, 예전에 상태를 통째로 문자열로 만들던 방식은 240ms였다. 비교 작업을 새로 얹었는데도 예전보다 빨라진 것이다. 같은 측정에서 상태로 나간 바이트는 123MB에서 22MB가 됐다.

정리하며
이날 작업에서 남은 교훈은 순서다. 재는 도구를 먼저 만들었기 때문에 “줄었다”가 아니라 “얼마나 줄었다”를 말할 수 있었다. 델타를 넣은 뒤 CPU가 오히려 늘 수 있다는 것도 측정을 다시 돌리고서야 확인할 수 있었다. 그리고 화면 코드를 건드리지 않고 전송 계층 안에서만 바꾼 덕분에, 24종 미니게임을 하나씩 다시 손볼 필요가 없었다.
랜선포차는 게임 소개 페이지에서 바로 해 볼 수 있다. 친구끼리 코드로 방을 여는 흐름은 빠른 매칭과 친구방 코드 글에, 다른 게임 서버를 부하 테스트한 이야기는 AvoidNow 부하 테스트 글에 정리해 두었다.


