본문으로 건너뛰기

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 응답을 보내서 연결을 업그레이드합니다.

WebSocket Handshake 과정

이 과정에서 Sec-WebSocket-Key라는 값을 받아서, Magic String이라는 특정 문자열을 붙이고, SHA-1 해싱을 하고, Base64로 인코딩해서 돌려줘야 한다고 했습니다.

java
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를 적용하는 수준부터 시작했습니다.

비트 연산을 해본 적이 거의 없어서 막막했지만, 블로그의 예제 코드를 참고하면서 하나씩 구현했습니다.

Masking Key라는 4바이트를 읽어서, Payload의 각 바이트와 XOR 연산을 해야 원래 메시지가 나온다고 했습니다. i % 4로 Masking Key를 순환하면서 사용해야 했습니다.

java
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바이트 이하만 처리했습니다.

java
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에 저장하기로 했습니다.

java
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() 메서드를 썼기 때문이었습니다.

각 게임룸이 자신의 플레이어에게만 메시지를 보내도록 수정했습니다.

java
// GameRoom
private void broadcastToPlayers(String message) {
for (Player player : players.getPlayers()) {
String nickname = player.getNickname();
sessionManager.sendTo(nickname, message);
}
}

이제 각 게임룸이 독립적으로 작동했습니다.

돌이켜보면 WebSocket을 처음 구현하면서 정말 많은 어려움이 있었습니다. 특히 비트 연산으로 Frame을 파싱하는 부분은 몇일 동안 진도를 나가지 못할 정도로 어려웠습니다.

하지만 RFC 6455 문서와 여러 블로그 글을 참고하면서, 특히 한국어로 된 자료들 덕분에 조금씩 이해할 수 있었습니다. opening handshake와 제한된 frame 처리 경로를 직접 구현하면서 프로토콜의 구조를 구체적으로 볼 수 있었고, 동시에 실제 라이브러리가 처리해야 할 예외와 제어 frame이 얼마나 많은지도 뒤늦게 확인했습니다.

이제 실시간 양방향 통신이 가능해졌고, 본격적으로 멀티플레이 게임을 만들 수 있게 되었습니다. 하지만 다음 단계에서는 멀티스레드 환경에서 발생하는 새로운 문제들을 마주하게 됩니다.