소켓 예제에서 권위 서버까지: 멀티플레이 게임 네트워크를 다시 설계하기
echo·채팅 서버의 socket 흐름을 출발점으로 TCP 프레이밍, 부분 I/O, 동시성, 권위 서버, 예측과 보정, UDP 선택, 재접속과 부하 테스트까지 연결합니다.
이 글은 개인 학습 아카이브의 네트워크·UNIX 시스템 프로그래밍 강의 자료와 예제를 교차 검토해 독립적으로 다시 쓴 학습 노트입니다. 오래된 API, 부정확한 설명, 안전하지 않은 예제는 현재의 시스템 설계 원칙에 맞춰 명시적으로 바로잡았으며 원본 파일, 내부 경로, 개인 식별 정보는 공개하지 않습니다.
먼저 결론
간단한 멀티플레이 예제는 채팅 서버에 MOVE player x y 같은 문자열을 보내는 것만으로도 움직입니다. 하지만 운영 가능한 게임 서버가 되려면 네 가지 경계를 분리해야 합니다.
- 바이트 경계: TCP 스트림을 완전한 메시지로 조립합니다.
- 신뢰 경계: 클라이언트가 보낸 좌표가 아니라 입력을 검증하고 서버가 상태를 결정합니다.
- 시간 경계: tick, sequence, acknowledgement로 순서와 지연을 다룹니다.
- 용량 경계: 느린 클라이언트와 폭주 트래픽이 전체 방을 막지 못하게 큐와 작업량을 제한합니다.
socket API는 출발점입니다. 게임 네트워킹의 어려움은 connect, send, recv를 호출한 뒤부터 시작됩니다.
1. 가장 작은 연결 흐름
TCP 클라이언트와 서버의 기본 수명 주기는 다음과 같습니다.
client server
socket() socket()
| |
connect() --------------------------> bind() -> listen()
|
accept()
| |
send()/recv() <---------------------> recv()/send()
| |
close() close(connection)
|
accept() next client
여기서 listening socket은 새 연결을 받는 문이고, accept가 반환한 socket은 한 연결의 I/O 통로입니다. 둘을 같은 객체처럼 관리하면 종료 처리, 모니터링, 파일 디스크립터 제한을 이해하기 어렵습니다.
또한 accept가 성공했다는 사실은 사용자가 인증됐거나 게임 방에 입장했다는 뜻이 아닙니다. 연결, 인증, 로비, 방 참가, 플레이, 종료는 별도의 애플리케이션 상태입니다.
2. TCP에는 메시지 경계가 없다
가장 위험한 학습용 가정은 “한 번 send한 문자열을 한 번 recv하면 그대로 받는다”입니다. TCP는 신뢰할 수 있는 순서 있는 바이트 스트림이지 메시지 큐가 아닙니다.
- 한 메시지가 여러 번의
recv로 나뉠 수 있습니다. - 여러 메시지가 한 번의
recv에 합쳐질 수 있습니다. send와write는 요청한 길이보다 적게 처리할 수 있습니다.- 정상 종료, 재시도 가능한 오류, 치명적 오류를 구분해야 합니다.
따라서 길이 접두사나 고정 헤더처럼 명시적인 framing이 필요합니다.
frame header
+---------+---------+----------+---------------+
| version | type | sequence | payloadLength |
+---------+---------+----------+---------------+
| payload bytes ... |
+------------------------------------------------+
수신기는 먼저 고정 크기 헤더를 끝까지 모으고, payloadLength가 프로토콜 상한 안인지 검증한 뒤 정확히 그 길이만큼 본문을 모읍니다. 송신기도 전체 길이가 나갈 때까지 반복하되, 무한 재시도하지 않고 deadline과 취소 신호를 존중해야 합니다.
고정 128바이트를 매번 보내는 방식은 framing처럼 보이지만 해결책이 아닙니다. 작은 메시지에 대역폭을 낭비하고, 버퍼를 초기화하지 않으면 이전 메모리를 노출하며, 더 큰 메시지와 버전 확장도 다루지 못합니다.
3. 동시성 모델은 비용의 위치를 바꾼다
| 모델 | 장점 | 병목과 위험 | 적합한 출발점 |
|---|---|---|---|
| 순차 처리 | 가장 단순 | 한 클라이언트가 모두를 대기시킴 | echo 실습, 관리 도구 |
| 프로세스/연결 | 주소 공간 격리 | 프로세스·메모리·문맥 전환 비용 | 강한 격리가 필요한 소규모 서비스 |
| 스레드/연결 | 직관적인 blocking 코드 | 무제한 스레드, 공유 상태 경쟁 | 연결 수가 제한된 서비스 |
| readiness event loop | 적은 실행 단위로 많은 연결 | 상태 머신과 backpressure가 복잡 | 채팅 gateway, 많은 idle 연결 |
| 제한된 worker pool | 작업량과 CPU를 통제 | 큐 지연, 작업 분류 필요 | DB·압축·검증 같은 혼합 작업 |
핵심은 “어떤 API가 최신인가”가 아니라 연결 수, 활성 비율, 메시지 비용, 공유 상태, 격리 요구입니다. event loop에서도 무거운 JSON 파싱이나 DB 호출을 같은 루프에 넣으면 전체 연결이 멈춥니다. 스레드 모델도 수와 큐를 제한하면 충분히 좋은 선택이 될 수 있습니다.
4. 채팅 프로토콜을 게임 프로토콜로 바꿀 때
학습 예제의 문자열 명령은 protocol을 눈에 보이게 한다는 장점이 있습니다.
CONN player 10 20
MOVE player 11 20
DISC player
그러나 서버가 클라이언트의 새 좌표를 그대로 방송하면 클라이언트가 곧 권위자가 됩니다. 속도 제한, 충돌, 쿨다운을 건너뛴 좌표도 진실로 받아들이게 됩니다.
운영 설계에서는 클라이언트가 의도와 입력 순서를 보내고 서버가 시뮬레이션 결과를 결정하는 편이 안전합니다.
InputCommand {
sessionId
inputSequence
clientTick
buttons
aimDirection
}
StateSnapshot {
serverTick
lastProcessedInputSequence
entities[]
}
서버는 인증된 연결을 session에 매핑하고, 입력 빈도와 가능한 값을 검증하고, 정해진 tick에서 상태를 갱신합니다. 클라이언트가 보낸 닉네임이나 player ID만으로 소유권을 판단하면 안 됩니다.
5. 지연을 숨기되 서버 결과와 다시 맞춘다
매 입력마다 왕복 응답을 기다리면 조작이 지연만큼 늦어집니다. 그래서 실시간 게임은 보통 다음 기법을 조합합니다.
- client-side prediction: 내 입력을 로컬에서 먼저 적용합니다.
- server reconciliation: 서버 snapshot이 오면 서버가 처리한 마지막 input sequence를 확인하고, 아직 반영되지 않은 입력을 다시 적용합니다.
- interpolation: 다른 플레이어는 약간 과거의 snapshot 사이를 보간해 jitter를 줄입니다.
- lag compensation: 서버가 제한된 과거 상태를 참고해 판정을 보정하되, 악용 가능성과 최대 rewind 범위를 제한합니다.
prediction은 서버 권위를 포기하는 기술이 아닙니다. 화면 반응을 먼저 보여주고, 최종 판정은 서버 결과에 수렴시키는 기술입니다. 큰 보정이 반복되면 숨길 문제가 아니라 입력 tick, 시계 오차, 패킷 손실, 시뮬레이션 불일치를 관측해야 합니다.
6. TCP와 UDP는 게임 전체가 아니라 메시지별로 고른다
| 의미 | 필요한 성질 | 흔한 선택 |
|---|---|---|
| 로그인, 구매, 인벤토리 변경 | 신뢰성, 중복 방지, 감사 가능성 | 신뢰성 있는 요청/응답 |
| 로비와 채팅 | 순서, 전달 보장, moderate latency | TCP 계열 연결 |
| 플레이어 입력 | 최신성, 낮은 지연, sequence 검증 | UDP datagram 또는 지연을 통제한 신뢰 채널 |
| 빈번한 상태 snapshot | 오래된 패킷 폐기, 부분 손실 허용 | sequence가 있는 datagram |
| 라운드 결과 | 정확히 한 번처럼 보이는 처리 | idempotency가 있는 신뢰 채널 |
UDP를 쓰면 연결과 신뢰성 문제가 사라지는 것이 아닙니다. 애플리케이션이 session 식별, 순서, 중복, 손실, MTU, 재전송 여부, rate limit을 책임져야 합니다. 반대로 모든 실시간 메시지를 한 TCP 스트림에 넣으면 손실된 앞 바이트가 복구될 때 뒤의 최신 상태도 대기하는 head-of-line 문제가 생길 수 있습니다.
중요한 질문은 “TCP인가 UDP인가”보다 이 메시지는 늦게라도 반드시 필요한가, 아니면 새 값이 오면 폐기해도 되는가입니다.
7. 종료와 재접속도 프로토콜이다
정상 종료 명령만 기다리면 네트워크 단절, 앱 강제 종료, NAT 변화, 서버 재시작을 처리할 수 없습니다. session 상태를 명시적으로 둡니다.
CONNECTED -> AUTHENTICATED -> IN_ROOM -> PLAYING
| | | |
+-------------+-------------+------> DISCONNECTED
|
reconnect grace
|
RESUMED or EXPIRED
- heartbeat 하나만으로 생존을 단정하지 말고 마지막 입력·마지막 응답·소켓 오류를 함께 봅니다.
- 재접속 token은 짧은 수명, 회전, 재사용 방지와 session 소유권 검증이 필요합니다.
- 같은 command를 다시 받아도 보상·아이템이 두 번 지급되지 않도록 command ID와 idempotency 경계를 둡니다.
- 방에서 나간 player의 상태와 소유 객체를 언제 제거할지 grace 정책을 정합니다.
8. 느린 클라이언트가 서버를 멈추지 않게 한다
방송 서버가 모든 client에 blocking send를 순서대로 실행하면 한 명의 느린 수신자가 전체 방을 지연시킬 수 있습니다.
- 연결별 outbound queue에 byte와 message 상한을 둡니다.
- 새 snapshot이 있으면 보내지 못한 오래된 snapshot은 합치거나 버립니다.
- 결제·결과처럼 버릴 수 없는 event와 위치 snapshot을 같은 폐기 정책으로 다루지 않습니다.
- 방, 계정, IP, message type별 rate limit을 분리합니다.
- queue depth, dropped snapshot, send latency, disconnect reason을 metric으로 남깁니다.
backpressure는 단순히 “더 기다리는 것”이 아닙니다. 어떤 데이터를 지연시키고, 합치고, 버리고, 연결을 끊을지 정하는 제품 규칙입니다.
9. 프로토콜 입력은 적대적으로 검증한다
네트워크에서 온 길이와 문자열을 신뢰하지 않습니다.
- frame 길이 상한과 type별 schema를 먼저 검사합니다.
- 숫자 범위, enum, UTF-8, 문자열 길이를 검증합니다.
- 인증된 session의 player만 해당 player의 입력을 만들 수 있게 합니다.
- client tick이 과도하게 앞서거나 뒤처지면 격리하고 기록합니다.
- parse 실패와 권한 실패를 구분하되 내부 구조를 오류 메시지로 노출하지 않습니다.
- 압축 payload는 압축 해제 후 크기 상한도 둡니다.
sprintf, 무제한 문자열 읽기, NUL 문자를 경계로 삼는 오래된 예제를 그대로 복사하면 buffer overflow와 parser ambiguity가 생깁니다.
10. 성공 경로보다 경계에서 테스트한다
최소 테스트 행렬은 다음과 같습니다.
- 한 frame을 모든 byte 위치에서 나눠 전달합니다.
- 여러 frame을 한 번에 합쳐 전달합니다.
- partial write,
EINTR, timeout, half-close를 주입합니다. - datagram의 loss, reorder, duplicate, burst를 주입합니다.
- 한 client가 읽지 않을 때 다른 client의 tick 지연을 측정합니다.
- reconnect 직전과 직후 같은 command를 다시 보냅니다.
- 최대 길이, 초과 길이, 알 수 없는 version/type을 보냅니다.
- 서버와 클라이언트 시뮬레이션 결과를 같은 input trace로 비교합니다.
평균 응답 시간만 보지 말고 tick overrun, p95/p99 queue delay, 연결당 메모리, drop/reconcile 빈도, disconnect reason을 함께 봐야 합니다.
오래된 예제에서 바로잡은 점
- TCP의 한 번의
send와recv는 메시지 경계를 보장하지 않습니다. - client마다 무제한 process/thread를 만드는 방식은 동시성 상한과 격리 정책이 필요합니다.
- 고정 크기 buffer 전송은 framing과 같지 않으며 초기화되지 않은 byte를 노출할 수 있습니다.
- 클라이언트 좌표를 그대로 방송하는 구조는 서버 권위를 만들지 못합니다.
- UDP 수신 buffer 전체를 문자열로 바꾸고 trim하는 방식은 실제 datagram 길이와 문자 encoding을 잃습니다.
- 정상 종료 문자열만으로 disconnect를 감지할 수 없습니다.
마무리 체크리스트
- frame의 version, type, sequence, length가 있는가?
- partial I/O와 deadline을 처리하는가?
- connection identity와 authenticated player identity가 분리됐는가?
- 서버가 최종 게임 상태를 결정하는가?
- message별 신뢰성과 폐기 정책이 다른가?
- 재접속과 중복 command가 안전한가?
- 느린 client의 queue가 bounded인가?
- loss, reorder, fragmentation, overload를 자동 테스트하는가?
작은 채팅 서버는 좋은 출발점입니다. 다만 운영 가능한 게임 서버로 가는 핵심은 socket 호출을 늘리는 것이 아니라 경계, 권위, 시간, 용량을 명시하는 것입니다.