Node.js WebSocket 서버 개발 핵심 정리
WebSocket은 클라이언트와 서버 사이에 지속적인 연결을 유지하면서 양쪽에서 자유롭게 데이터를 주고받을 수 있게 해주는 통신 프로토콜이다.
일반적인 REST API는 클라이언트가 요청을 보내면 서버가 응답하는 구조다.
Client -> Request -> Server
Client <- Response <- Server
서버가 먼저 클라이언트에게 데이터를 보내려면 클라이언트가 별도의 요청을 보내거나 다른 통신 방식을 사용해야 한다.
WebSocket은 이 구조가 다르다.
Client <-> WebSocket Connection <-> Server
연결이 한번 성립되면 클라이언트와 서버 어느 쪽에서도 데이터를 보낼 수 있다.
이 특성 때문에 채팅, 실시간 알림, 게임, 주식 시세, 협업 도구처럼 서버의 상태 변화를 실시간으로 전달해야 하는 서비스에서 사용한다.
Node.js는 네트워크 I/O를 비동기 방식으로 처리하기 때문에 WebSocket 서버를 구성하기에 적합하다.
WebSocket은 어떻게 연결되는가
WebSocket 연결은 처음부터 별도의 TCP 연결을 만드는 방식이 아니다.
일반적으로 HTTP 연결에서 WebSocket 연결로 전환하는 Handshake 과정을 거친다.
Client -> HTTP Upgrade Request -> Node.js Server -> 101 Switching Protocols -> WebSocket Connection
클라이언트가 서버에 WebSocket 연결을 요청하면 HTTP Upgrade 요청을 보낸다.
서버가 이를 받아들이면 101 Switching Protocols 응답을 반환하고 이후 해당 연결을 WebSocket 통신에 사용한다.
그 이후에는 일반적인 HTTP Request/Response 방식이 아니라 WebSocket Frame을 이용해서 데이터를 주고받는다.
Client <====================> Server
WebSocket Frame
REST API와 WebSocket의 차이
두 기술은 서로 대체 관계라기보다는 목적이 다르다.
REST API는 요청 단위의 데이터 처리에 적합하다.
GET /users
GET /users/10
POST /users
DELETE /users/10
반면 WebSocket은 지속적인 연결을 유지하면서 이벤트를 주고받는 데 적합하다.
Client -> "chat message"
Server -> "new message"
Server -> "notification"
Client -> "typing"
예를 들어 채팅 서비스를 REST API만으로 구현한다면 클라이언트가 계속 서버에 새로운 메시지가 있는지 확인해야 한다.
Client -> GET /messages -> Server -> messages
Client -> GET /messages -> Server -> messages
Client -> GET /messages -> Server -> messages
WebSocket을 사용하면 서버가 새로운 메시지가 발생한 순간 연결되어 있는 클라이언트에게 바로 전달할 수 있다.
Client <-> WebSocket <-> Server -> New Message -> Client
그래서 실제 서비스에서는 REST API와 WebSocket을 같이 사용하는 경우도 많다.
REST API -> 로그인 -> 사용자 정보 -> 게시글 조회 -> 데이터 생성/수정
WebSocket -> 실시간 채팅 -> 알림 -> 실시간 상태 변경
Node.js에서 WebSocket 서버 만들기
Node.js에서 WebSocket 서버를 구현하는 방법은 여러 가지가 있다.
대표적으로 ws 같은 WebSocket 라이브러리를 사용할 수 있다.
npm install ws
간단한 서버는 다음처럼 만들 수 있다.
const WebSocket = require('ws');
const wss = new WebSocket.Server({
port: 3000
});
wss.on('connection', (socket) => {
console.log('Client connected');
socket.on('message', (message) => {
console.log(message.toString());
});
socket.on('close', () => {
console.log('Client disconnected');
});
});
여기서 중요한 것은 HTTP 서버처럼 요청 하나를 받고 응답하는 것이 아니라 connection 이벤트를 통해 연결 자체를 관리한다는 것이다.
WebSocket Server -> Client A
WebSocket Server -> Client B
WebSocket Server -> Client C
각 연결은 하나의 WebSocket 객체로 관리할 수 있다.
메시지 처리
클라이언트가 데이터를 보내면 message 이벤트가 발생한다.
socket.on('message', (message) => {
console.log(message.toString());
});
실제 서비스에서는 문자열 하나를 그대로 보내기보다는 JSON 형태로 메시지 구조를 정의하는 경우가 많다.
{
"type": "chat",
"data": {
"message": "Hello"
}
}
서버에서는 메시지 타입을 기준으로 처리할 수 있다.
socket.on('message', (message) => {
const packet = JSON.parse(message);
switch (packet.type) {
case 'chat':
handleChat(socket, packet.data);
break;
case 'ping':
handlePing(socket);
break;
}
});
이런 구조를 사용하면 하나의 WebSocket 연결에서 여러 종류의 이벤트를 처리할 수 있다.
서버에서 클라이언트로 메시지 보내기
WebSocket의 핵심은 서버도 연결된 클라이언트에게 먼저 메시지를 보낼 수 있다는 점이다.
socket.send(JSON.stringify({
type: 'notification',
data: {
message: 'New message'
}
}));
REST API에서는 서버가 먼저 응답을 보낼 수 없지만 WebSocket에서는 연결이 유지되고 있기 때문에 서버가 원하는 시점에 데이터를 전송할 수 있다.
이것이 실시간 통신을 구현하는 기본 구조다.
Server -> Client
Broadcast
채팅 서버처럼 하나의 메시지를 여러 클라이언트에게 전달해야 하는 경우가 있다.
예를 들어 A가 채팅 메시지를 보냈다고 하자.
Client A -> "Hello" -> Server -> Client B
-> Client C
-> Client D
ws에서는 연결된 클라이언트를 순회하면서 메시지를 전송할 수 있다.
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
이것이 기본적인 Broadcast다.
다만 실제 서비스에서는 모든 클라이언트에게 보내는 것이 아니라 특정 채팅방이나 게임방에 속한 사용자에게만 보내는 경우가 많다.
Room
채팅이나 게임 서버에서는 연결된 모든 사용자를 하나의 그룹으로 취급하지 않는다.
예를 들어 채팅방이 있다면 다음과 같이 관리할 수 있다.
Room A -> Client 1 -> Client 2 -> Client 3
Room B -> Client 4 -> Client 5
Room C -> Client 6
서버는 Room마다 연결된 클라이언트를 관리한다.
const rooms = new Map();
function joinRoom(roomId, socket) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(socket);
}
메시지를 보낼 때도 특정 Room의 클라이언트만 순회한다.
function broadcastRoom(roomId, message) {
const clients = rooms.get(roomId);
if (!clients) {
return;
}
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
게임 서버라면 Room이 하나의 게임 세션이 될 수도 있다.
Game Room 101 -> Player A -> Player B -> Player C -> Player D
연결 상태 관리
WebSocket 서버는 연결 자체를 관리해야 한다.
대표적인 이벤트는 다음과 같다.
connection -> message -> close -> error
연결이 생성되면 사용자 정보를 연결 객체에 연결할 수도 있다.
wss.on('connection', (socket) => {
socket.userId = null;
socket.roomId = null;
});
인증이 끝난 뒤 사용자 ID를 저장한다.
Socket -> userId -> roomId -> authenticated
이렇게 하면 메시지가 들어왔을 때 어떤 사용자가 보낸 것인지 확인할 수 있다.
연결이 종료되면 Room에서도 제거해야 한다.
socket.on('close', () => {
leaveRoom(socket);
});
이 부분을 처리하지 않으면 이미 연결이 끊어진 소켓이 서버의 자료구조에 남아 메모리나 상태 관리에 문제가 생길 수 있다.
WebSocket 인증
WebSocket도 결국 외부에서 들어오는 연결이다.
따라서 인증 없이 연결만 허용하면 누구나 해당 서버에 접속할 수 있다.
일반적으로 로그인은 REST API로 처리하고 인증 결과를 이용해 WebSocket 연결을 인증하는 구조를 많이 사용한다.
Login -> REST API -> Access Token -> WebSocket Connect -> Authentication -> WebSocket Session
토큰을 WebSocket 연결 과정에서 전달하고 서버가 검증하는 방식 등을 사용할 수 있다.
중요한 것은 WebSocket 연결이 만들어졌다고 해서 사용자가 자동으로 인증된 것은 아니라는 점이다.
인증과 권한 검사는 별도로 구현해야 한다.
Ping / Pong과 연결 상태 확인
네트워크 연결은 항상 정상적으로 유지된다고 가정할 수 없다.
클라이언트가 비정상적으로 종료되거나 네트워크가 끊겼는데 서버가 즉시 이를 알지 못하는 경우도 있다.
그래서 WebSocket에는 연결 상태를 확인하기 위한 Ping/Pong 메커니즘이 존재한다.
Server -> Ping -> Client -> Pong -> Server
서버가 일정 시간 동안 응답을 받지 못하면 해당 연결을 종료할 수 있다.
Connection -> Ping -> Pong -> Connection 유지
Connection -> Ping -> Timeout -> Connection 종료
실시간 서버에서는 연결 상태 관리가 상당히 중요하다.
특히 모바일 환경처럼 네트워크가 자주 변경되는 환경에서는 더 그렇다.
WebSocket과 Event Loop
Node.js WebSocket 서버를 이해하려면 Event Loop와 비동기 I/O를 같이 봐야 한다.
수많은 클라이언트가 연결되어 있어도 각각의 연결마다 JavaScript 스레드를 하나씩 만드는 방식은 아니다.
Node.js Process -> Event Loop -> Socket A
-> Socket B
-> Socket C
-> Socket D
-> ...
네트워크 I/O가 발생하면 Node.js가 이벤트를 처리하고 해당 Callback을 실행한다.
이 구조는 많은 연결을 유지해야 하는 I/O 중심 서버에 적합하다.
하지만 WebSocket이라고 해서 CPU 작업이 자동으로 병렬 처리되는 것은 아니다.
예를 들어 메시지를 받을 때마다 매우 무거운 계산을 수행하면 Event Loop가 해당 작업에 붙잡힌다.
Client A -> Heavy CPU Calculation -> Event Loop 점유 -> Client B / Client C / Client D
따라서 WebSocket 서버에서도 CPU 연산과 네트워크 I/O를 구분해서 생각해야 한다.
메시지 처리 구조
실제 서버에서는 message 이벤트 하나에 모든 로직을 작성하지 않는 편이 좋다.
socket.on('message', (message) => {
const packet = JSON.parse(message);
handleMessage(socket, packet);
});
그리고 메시지 타입에 따라 Handler를 분리한다.
function handleMessage(socket, packet) {
switch (packet.type) {
case 'join_room':
return handleJoinRoom(socket, packet);
case 'chat':
return handleChat(socket, packet);
case 'move':
return handleMove(socket, packet);
}
}
구조적으로 보면 다음과 같다.
WebSocket -> Message Parser -> Message Router -> Chat Handler
-> Room Handler
-> Game Handler
-> Notification Handler
REST API에서 Router와 Controller를 분리하는 것과 비슷한 개념이다.
WebSocket에서도 결국 통신 계층과 실제 비즈니스 로직을 분리하는 것이 관리하기 편하다.
WebSocket에서 비즈니스 로직은 어디에 있는가
WebSocket 자체는 비즈니스 로직을 제공하지 않는다.
예를 들어 게임 서버에서 다음과 같은 규칙이 있다고 하자.
Player가 이동 요청 -> 이동 가능한 위치인지 검사 -> 게임 상태 변경 -> 주변 플레이어에게 상태 전달
WebSocket은 이 과정에서 데이터를 전달하는 통신 계층에 가깝다.
Client -> Move Packet -> WebSocket -> Game Service -> 이동 검증 -> 게임 상태 변경 -> Broadcast
WebSocket 라이브러리가 게임 규칙을 알고 있는 것은 아니다.
게임 로직, 채팅방 규칙, 권한, 매칭, 아이템 처리 같은 것은 애플리케이션이 직접 구현해야 한다.
이 구분을 해두면 WebSocket 서버의 역할을 훨씬 명확하게 볼 수 있다.
REST API와 WebSocket을 같이 사용하는 구조
실제 서비스에서는 REST API와 WebSocket 중 하나만 선택해야 하는 것은 아니다.
각각 잘하는 일이 다르기 때문에 같이 사용할 수 있다.
Client -> REST API -> Request 처리 -> Business Logic -> Database
Client -> WebSocket -> 실시간 이벤트 -> Business Logic -> Database
예를 들어 게임 서비스라면 다음처럼 나눌 수 있다.
REST API -> 회원가입 -> 로그인 -> 캐릭터 정보 -> 인벤토리 조회 -> 게임 기록 조회
WebSocket -> 게임 입장 -> 플레이어 이동 -> 공격 -> 게임 상태 동기화 -> 실시간 이벤트
채팅 서비스도 비슷하다.
REST API -> 로그인 -> 채팅방 목록 -> 과거 메시지 조회
WebSocket -> 채팅방 연결 -> 메시지 전송 -> 메시지 수신 -> 사용자 상태 변경
REST API가 데이터를 조회하고 관리하는 통신 방식이라면 WebSocket은 지속적인 연결을 기반으로 실시간 이벤트를 전달하는 통신 방식으로 볼 수 있다.
단일 서버에서 여러 WebSocket 서버로 확장하기
개발 초기에는 하나의 Node.js 프로세스에서 모든 연결을 관리할 수 있다.
Client -> Node.js -> WebSocket -> Business Logic -> Database
사용자가 늘어나면 서버를 여러 개 실행할 수 있다.
Load Balancer -> Server A
Load Balancer -> Server B
Load Balancer -> Server C
문제는 WebSocket 연결이 특정 서버에 붙어 있다는 것이다.
예를 들어 사용자 A가 Server A에 연결되어 있고 사용자 B가 Server B에 연결되어 있다고 하자.
User A -> Server A
User B -> Server B
A가 B에게 메시지를 보내려면 Server A가 Server B에 해당 메시지를 전달할 방법이 필요하다.
이때 Redis Pub/Sub이나 Kafka 같은 메시징 시스템을 사용할 수 있다.
User A -> Server A -> Message Broker -> Server B -> User B
이 구조를 사용하면 여러 Node.js 서버 사이에서 이벤트를 전달할 수 있다.
다만 어떤 메시징 시스템을 사용할지는 서비스의 요구사항에 따라 달라진다. 단순한 실시간 이벤트 전달과 메시지 영속성, 순서 보장, 재처리 요구사항은 서로 다른 문제다.
WebSocket 서버에서 자주 발생하는 문제
WebSocket 서버는 연결을 오래 유지한다는 특성 때문에 일반적인 REST API 서버와 관리해야 하는 문제가 조금 다르다.
대표적으로 다음과 같다.
Connection Management -> Authentication -> Heartbeat -> Reconnect -> Room Management -> Broadcast -> Message Validation -> Rate Limiting -> Memory Management -> Multi Server Synchronization
특히 연결이 끊겼을 때 클라이언트가 어떻게 다시 연결할 것인지도 중요하다.
Connected -> Network Error -> Disconnected -> Reconnect -> Authentication -> Join Room -> Connected
재연결만 한다고 끝나는 것도 아니다.
연결이 끊긴 동안 발생한 이벤트를 어떻게 처리할지 결정해야 한다.
단순한 실시간 상태라면 최신 상태를 다시 받으면 될 수도 있고, 중요한 이벤트라면 서버에서 별도로 기록하고 클라이언트가 누락된 데이터를 요청하도록 설계할 수도 있다.
WebSocket과 게임 서버
게임에서 WebSocket을 사용할 때는 특히 통신과 게임 로직을 구분해야 한다.
예를 들어 플레이어가 이동한다고 하자.
Client -> Move(x, y) -> WebSocket Server -> Game Logic -> 이동 가능 여부 검사 -> 게임 상태 변경 -> 다른 플레이어 상태 변경 -> Broadcast -> Client A / Client B / Client C
여기서 WebSocket은 Move(x, y)라는 데이터를 전달하고 결과를 다시 보내는 역할을 한다.
실제 이동 가능 여부, 충돌, 데미지, 게임 규칙 같은 것은 게임 서버의 책임이다.
또한 게임의 실시간성 요구가 높아질수록 WebSocket만으로 충분한지 따져봐야 한다.
WebSocket은 TCP 기반의 양방향 통신이고 데이터 전달의 신뢰성과 순서를 보장하는 대신 네트워크 지연이나 Head-of-Line Blocking 같은 TCP 특성의 영향을 받는다.
따라서 빠른 상태 동기화가 핵심인 대규모 실시간 게임에서는 UDP 계열 프로토콜이나 별도의 네트워크 구조를 고려하기도 한다.
WebSocket이 게임 서버의 모든 상황에 적합한 것은 아니다.
전체 구조
Node.js WebSocket 서버를 전체적으로 보면 다음 구조로 정리할 수 있다.
Client -> WebSocket -> WebSocket Server -> Authentication -> Message Router -> Chat / Room / Game Logic -> Data / State -> Database / Redis
서버가 여러 대라면 메시지 브로커가 추가될 수 있다.
Client -> Load Balancer -> Server A / Server B / Server C -> Redis / Kafka -> Database
정리
Node.js WebSocket 서버의 핵심은 연결을 유지하면서 양방향으로 이벤트를 주고받는 구조를 이해하는 것이다.
REST API가
Request -> Response
를 기본 단위로 한다면 WebSocket은
Connection <-> Event
를 기본 단위로 생각할 수 있다.
Node.js는 Event Loop와 비동기 I/O를 기반으로 많은 네트워크 연결을 처리하고, WebSocket 라이브러리는 연결과 메시지 송수신을 관리한다.
그 위에 직접 구현해야 하는 것이 많다.
WebSocket -> Authentication -> Message Protocol -> Room -> Broadcast -> Business Logic -> Connection Management -> Reconnect -> Server Scaling
WebSocket 자체가 채팅 서버나 게임 서버를 만들어주는 것은 아니다. WebSocket은 통신 채널이고, 그 위에 어떤 메시지 규격과 상태 관리, 비즈니스 로직을 구성할지는 서버 애플리케이션의 책임이다.
Node.js REST API와 WebSocket 서버를 비교해서 보면 역할 차이가 더 명확하다.
REST API -> Client -> HTTP Request -> Server -> HTTP Response -> Client
WebSocket -> Client <=========================> Server -> Persistent Connection
결국 둘의 선택 기준은 간단하다. 요청이 들어왔을 때 데이터를 처리하고 응답하면 되는 기능은 REST API로 충분한 경우가 많고, 서버와 클라이언트가 지속적으로 상태를 주고받아야 한다면 WebSocket이 적합하다.
실제 서비스에서는 둘 중 하나를 고르는 것이 아니라 REST API는 일반적인 데이터 처리에 사용하고 WebSocket은 실시간 이벤트 전달에 사용하는 방식으로 조합하는 경우가 많다.