WebSocket을 바닥부터 구현하기
HTTP 서버를 만들고 정적 파일을 제공하는 데까지 성공했습니다. 이제 멀티플레이 게임을 만들려면 실시간 통신이 필요했습니다.
문제는 HTTP가 요청-응답 구조라는 것이었습니다. 클라이언트가 요청하면 서버가 응답하고 연결이 끊어지기 때문에, 서버에서 먼저 클라이언트에게 "게임이 시작됐어요" 같은 메시지를 보낼 방법이 없었습니다.
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 문서와 여러 자료를 읽으면서 이 과정이 왜 필요한지 이해할 수 있었습니다.
왜 이런 복잡한 과정이 필요할까?
단순히 "WebSocket으로 업그레이드해주세요"라고 요청하면 안 될까요? 이 질문에 대한 답은 보안과 프로토콜 검증에 있었습니다.
첫째, 일반 HTTP 서버의 오작동 방지
만약 클라이언트가 WebSocket을 지원하지 않는 일반 HTTP 서버에 잘못 연결하면 어떻게 될까요? 서버는 "Upgrade: websocket" 헤더를 이해하지 못하고 이상한 응답을 보낼 수 있습니다. 하지만 클라이언트 입장에서는 이게 진짜 WebSocket 응답인지, 아니면 우연히 비슷하게 생긴 HTTP 응답인지 구분할 방법이 없습니다.
Sec-WebSocket-Key와 그에 대한 정확한 해시값(Sec-WebSocket-Accept)을 주고받음으로써, 서버가 **"나는 WebSocket 프로토콜을 정확히 이해하고 있습니다"**라고 증명하는 것입니다.
둘째, 중간자 공격(Man-in-the-Middle) 방지
클라이언트가 보낸 랜덤한 Key 값과 표준 Magic String을 결합해서 해싱하면, 중간에 누군가 메시지를 가로채서 조작하려고 해도 정확한 Accept 값을 만들어낼 수 없습니다. Magic String(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)은 RFC 6455에 명시된 고정값이고, SHA-1 해싱은 역산이 불가능하기 때문입니다.
셋째, 캐싱 프록시 문제 해결
HTTP 프록시 서버들은 응답을 캐싱할 수 있는데, WebSocket 연결은 캐싱되면 안 됩니다. 매번 다른 Sec-WebSocket-Key를 생성하고 그에 맞는 Accept 값을 계산하도록 강제함으로써, 프록시가 이전 응답을 재사용할 수 없게 만듭니다.
코드를 작성하고 Handshake 응답을 보냈더니, 드디어 브라우저 콘솔에서 WebSocket 연결이 성공했다는 메시지가 나타났습니다.
이 과정을 이해하고 나니 RFC 문서에 정의된 표준이 얼마나 치밀하게 설계되었는지 느낄 수 있었습니다. 단순해 보이는 Handshake 하나에도 보안, 호환성, 안정성을 고려한 여러 장치들이 숨어있었습니다.
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| | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
비트 단위로 파싱해야 한다는 걸 이때 알았습니다.
첫 번째 바이트에서 Opcode를 추출하려면 & 0x0F 연산을, 두 번째 바이트에서 Payload Length를 추출하려면 & 0x7F를 해야 한다고 했습니다.
비트 연산을 해본 적이 거의 없어서 막막했지만, 블로그의 예제 코드를 참고하면서 하나씩 구현했습니다.
Masking Key라는 4바이트를 읽어서, 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);
3일 정도 고통을 받았던 것 같습니다. 이상한 문자가 나오거나 Exception이 발생하는 일이 반복되었고, 여러 블로그 글을 읽고 RFC 문서를 확인하며 디버깅했습니다.
그러다 어느 순간 서버 콘솔에 "Hello"가 찍혔습니다.
서버에서 클라이언트로 메시지 보내기
클라이언트에서 서버로 메시지를 받는 데 성공하고 나니, 이제 서버에서 클라이언트로 메시지를 보낼 차례였습니다.
다행히 서버에서 클라이언트로 보낼 때는 Masking을 하지 않아도 된다고 했습니다. 조금 더 간단했습니다.
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();
}
브라우저에서 메시지를 받았습니다. 양방향 통신이 드디어 작동했습니다.
세션 관리와 브로드캐스트
양방향 통신이 작동하고 나니, 이제 여러 클라이언트를 동시에 관리해야 했습니다. 각 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);
}
}
이제 각 게임룸이 독립적으로 작동했습니다.
돌이켜보면 WebSocket을 처음 구현하면서 정말 많은 어려움이 있었습니다. 특히 비트 연산으로 Frame을 파싱하는 부분은 몇일 동안 진도를 나가지 못할 정도로 어려웠습니다.
하지만 RFC 6455 문서와 여러 블로그 글을 참고하면서, 특히 한국어로 된 자료들 덕분에 조금씩 이해할 수 있었습니다. 프레임워크에서는 자동으로 처리되던 Handshake, Frame 파싱, Masking 같은 복잡한 과정들을 직접 구현하면서, WebSocket이 내부적으로 어떻게 작동하는지 깊이 이해할 수 있었습니다.
이제 실시간 양방향 통신이 가능해졌고, 본격적으로 멀티플레이 게임을 만들 수 있게 되었습니다. 하지만 다음 단계에서는 멀티스레드 환경에서 발생하는 새로운 문제들을 마주하게 됩니다.