WebSocket을 바닥부터 구현하기
프레임워크 없이 만드는 멀티플레이 게임
HTTP 서버를 만들고 정적 파일을 제공하는 데까지 성공했습니다. 이제 멀티플레이 게임을 만들려면 실시간 통신이 필요했습니다.
문제는 HTTP가 기본적으로 요청-응답 구조라는 것이었습니다. HTTP/1.1의 keep-alive로 TCP 연결을 재사용할 수는 있지만, 일반적인 요청-응답만으로는 서버가 원하는 시점에 "게임이 시작됐어요" 같은 메시지를 지속적으로 보낼 수 없었습니다.
Polling을 시도하다
처음에는 Polling 방식을 생각했습니다. 클라이언트가 0.5초마다 서버에 "혹시 업데이트 있어요?" 하고 물어보는 방식입니다.
4명이 동시에 접속해 0.5초마다 요청을 보내면 초당 8번의 요청이 발생합니다. 당시에는 주기적으로 요청을 보내는 방식이 비효율적이라고 생각했고, 구현도 복잡해 보였습니다.
다른 방법을 찾다가 실시간 양방향 통신에 WebSocket을 쓰기로 했습니다.
WebSocket을 직접 구현하기로
WebSocket은 한 번 연결되면 계속 연결을 유지하고 양방향으로 메시지를 주고받을 수 있습니다. Spring Boot에서 WebSocket을 사용해본 적은 있었지만 직접 구현해본 적은 없었습니다.
"이거다" 싶었지만, 프레임워크 없이 직접 구현해야 한다는 것을 깨닫고 막막해졌습니다. 프레임워크에서는 이미 구현된 기능을 사용하기만 하면 됐는데 지금은 그런 게 없었습니다.
여러 블로그를 읽다가 RFC 6455 문서를 참고해야 한다는 것을 알게 되었습니다. RFC 문서를 읽는 것은 이번이 처음이었습니다.
Handshake 구현하기
RFC 6455 문서를 열었는데 영어로 가득했지만, 다행히 한국어 블로그 글들이 많아서 참고할 수 있었습니다.
WebSocket 연결은 HTTP로 시작한다고 했습니다. 클라이언트가 "Upgrade: websocket" 헤더를 보내면 서버가 101 Switching Protocols 응답을 보내서 연결을 업그레이드합니다.
서버는 Sec-WebSocket-Key에 Magic String이라는 특정 문자열을 붙인 뒤 SHA-1 해싱과 Base64 인코딩을 거쳐 응답 값으로 돌려줘야 했습니다.
String key = request.getHeader("Sec-WebSocket-Key");
String magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
String concatenated = key + magic;
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] hash = digest.digest(concatenated.getBytes(StandardCharsets.UTF_8));
String accept = Base64.getEncoder().encodeToString(hash);
처음에는 "이게 왜 필요하지?" 싶어 RFC 문서와 여러 자료에서 이유를 찾아봤습니다.
왜 이런 과정이 필요할까?
단순히 Upgrade: websocket만 주고받으면 현재 요청에 대응하는 WebSocket 서버의 응답인지 확인하기 어렵습니다. RFC 6455의 opening handshake는 다음 두 가지를 확인하도록 설계됐습니다.
첫째, 양쪽이 WebSocket opening handshake를 이해하는지 확인
클라이언트는 요청마다 무작위 nonce인 Sec-WebSocket-Key를 보냅니다. 서버는 이 값에 RFC가 정한 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)를 이어 붙인 뒤 SHA-1과 Base64를 적용해 Sec-WebSocket-Accept를 만듭니다. 클라이언트가 같은 계산으로 응답을 검증하므로, 서버가 단순히 HTTP 101을 반환한 것이 아니라 현재 WebSocket 요청을 처리했다는 사실을 확인할 수 있습니다.
둘째, 오래된 응답의 재사용 방지
요청마다 Key가 달라지면 Accept 값도 달라집니다. 따라서 중간 프록시 등에 남아 있던 과거 handshake 응답을 현재 연결의 정상 응답으로 받아들이지 않습니다.
다만 이 challenge-response는 서버 인증이나 암호화가 아니며 중간자 공격을 막지 않습니다. Key, GUID, 계산 방식은 모두 통신 당사자에게 공개되어 있고, 여기서 SHA-1은 RFC가 정한 값 변환에 사용될 뿐입니다. 전송 내용의 기밀성·무결성과 서버 인증이 필요하면 인증서를 검증하는 TLS 기반의 wss://를 사용해야 합니다. 자세한 계산과 검증 절차는 RFC 6455 §4.2.2에서 확인할 수 있습니다.
코드를 작성하고 Handshake 응답을 보냈더니, 드디어 브라우저 콘솔에서 WebSocket 연결이 성공했다는 메시지가 나타났습니다.
이 과정을 이해하고 나니 handshake가 담당하는 프로토콜 검증과 TLS가 담당하는 보안의 경계를 구분할 수 있었습니다.
Frame 파싱, 고통스러웠던 순간
Handshake는 성공했지만 메시지를 읽을 수 없었습니다. JavaScript에서 ws.send('Hello')를 보냈는데 서버에서는 이상한 바이트만 찍혔습니다.
RFC 6455 문서의 "Data Framing" 섹션을 다시 봤습니다. 엄청난 그림이 나왔습니다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
비트 단위로 파싱해야 한다는 걸 이때 알았습니다.
RFC의 frame을 일반적으로 처리하려면 첫 번째 바이트에서 FIN과 opcode를 확인하고, 두 번째 바이트에서 MASK와 payload length를 분리해야 합니다. 당시 구현은 브라우저가 보내는 짧은 단일 텍스트 frame을 전제로 두 번째 바이트에 & 0x7F를 적용하는 수준부터 시작했습니다.
비트 연산을 해본 적이 거의 없어서 막막했지만, 블로그의 예제 코드를 참고하면서 하나씩 구현했습니다.
4바이트의 Masking Key를 읽고 Payload의 각 바이트와 XOR 연산을 해야 원래 메시지를 얻을 수 있었습니다. i % 4로 Masking Key를 순환하면서 사용해야 했습니다.
byte[] maskingKey = new byte[4];
in.read(maskingKey);
byte[] payload = new byte[payloadLength];
in.read(payload);
for (int i = 0; i < payloadLength; i++) {
payload[i] ^= maskingKey[i % 4];
}
String message = new String(payload, StandardCharsets.UTF_8);
이 코드는 학습 당시의 핵심 흐름을 보여주지만 범용 parser는 아닙니다. InputStream.read(byte[])가 배열을 항상 한 번에 채운다고 가정하고, FIN·opcode·MASK를 검증하지 않으며, payload 126/127의 확장 길이와 fragmentation도 처리하지 않습니다. 실제 프로젝트의 범위는 WebSocketFrame에서 확인할 수 있습니다.
3일 정도 고통을 받았던 것 같습니다. 이상한 문자가 나오거나 Exception이 발생하는 일이 반복되었고, 여러 블로그 글을 읽고 RFC 문서를 확인하며 디버깅했습니다.
그러다 어느 순간 서버 콘솔에 "Hello"가 찍혔습니다.
서버에서 클라이언트로 메시지 보내기
클라이언트에서 서버로 메시지를 받는 데 성공하고 나니, 이제 서버에서 클라이언트로 메시지를 보낼 차례였습니다.
서버가 클라이언트로 보내는 frame은 RFC 6455에 따라 masking하면 안 됩니다. 이 방향은 masking key가 없어서 조금 더 단순했지만, 당시 코드는 여전히 payload 125바이트 이하만 처리했습니다.
private void sendTextFrame(OutputStream out, String message) throws IOException {
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
out.write(0x81); // FIN=1, Opcode=1 (Text Frame)
out.write(payload.length); // Payload Length (125 이하만 처리)
out.write(payload);
out.flush();
}
브라우저에서 메시지를 받았습니다. 양방향 통신이 드디어 작동했습니다.
정확히는 RFC 6455의 opening handshake와 payload 125바이트 이하인 단일 텍스트 frame의 happy path를 학습 목적으로 구현한 결과입니다. 클라이언트→서버 방향은 MASK=1, 서버→클라이언트 방향은 MASK=0을 전제로 합니다. ping/pong/close 제어 frame, closing handshake, 확장 길이, 분할 메시지, strict UTF-8 검증까지 지원하는 완전한 WebSocket 구현은 아닙니다.
세션 관리와 브로드캐스트
여러 클라이언트를 동시에 관리하려고 각 WebSocket 연결을 세션으로 만들어 Map에 저장하기로 했습니다.
public class SessionManager {
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
public void add(String nickname, WebSocketSession session) {
sessions.put(nickname, session);
}
public void sendTo(String nickname, String message) {
WebSocketSession session = sessions.get(nickname);
if (session != null && session.isConnected()) {
session.send(message);
}
}
}
닉네임을 세션의 식별자로 사용했습니다. UUID를 써야 하나 고민했지만, 게임 특성상 닉네임이 고유하니 이를 키로 사용하기로 했습니다.
게임룸 격리 문제
세션 관리를 구현한 뒤 테스트하려고 게임룸 2개를 동시에 실행했더니 문제가 생겼습니다.
A 게임룸의 라운드 메시지가 B 게임룸 플레이어에게도 전송됐습니다. 모든 세션에 브로드캐스트하는 broadcast() 메서드를 썼기 때문이었습니다.
각 게임룸이 자신의 플레이어에게만 메시지를 보내도록 수정했습니다.
// GameRoom
private void broadcastToPlayers(String message) {
for (Player player : players.getPlayers()) {
String nickname = player.getNickname();
sessionManager.sendTo(nickname, message);
}
}
이제 각 게임룸이 독립적으로 작동했습니다.
비트 연산으로 Frame을 파싱하는 부분에서는 며칠 동안 진도를 나가지 못했습니다.
RFC 6455 문서와 여러 블로그 글을 참고했고, 특히 한국어 자료가 이해하는 데 도움이 됐습니다. opening handshake와 제한된 frame 처리 경로를 직접 구현하면서 프로토콜의 구조를 구체적으로 볼 수 있었고, 동시에 실제 라이브러리가 처리해야 할 예외와 제어 frame이 얼마나 많은지도 뒤늦게 확인했습니다.
실시간 양방향 통신을 구현한 뒤에는 멀티플레이 게임을 만들면서 멀티스레드 환경의 문제를 다뤄야 했습니다.
