Node.js 인터뷰 질문 65
질문: Node.js에서 WebSocket을 구현하는 방법과 실시간 애플리케이션 개발 방법에 대해 설명해주세요.
답변:
Node.js는 비동기 이벤트 기반 아키텍처로 인해 WebSocket과 같은 실시간 통신 구현에 매우 적합합니다. WebSocket은 클라이언트와 서버 간의 양방향 통신 채널을 제공하여 실시간 데이터 교환을 가능하게 합니다.
1. WebSocket 프로토콜 개요
WebSocket은 단일 TCP 연결을 통한 양방향 통신을 제공하는 프로토콜입니다. 기존 HTTP 요청/응답 모델과 달리, 연결이 한 번 수립되면 양쪽에서 언제든지 데이터를 보낼 수 있습니다.
WebSocket 연결은 HTTP 핸드셰이크로 시작하여 프로토콜을 WebSocket으로 업그레이드합니다:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버는 다음과 같이 응답합니다:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
2. Node.js에서 WebSocket 구현하기
2.1 ws 라이브러리 사용
ws
는 Node.js를 위한 인기 있는 WebSocket 라이브러리입니다. 다음은 기본적인 WebSocket 서버 구현 예시입니다:
const WebSocket = require("ws");
// WebSocket 서버 생성
const wss = new WebSocket.Server({ port: 8080 });
// 연결 이벤트
wss.on("connection", (ws) => {
console.log("클라이언트가 연결되었습니다");
// 메시지 수신 이벤트
ws.on("message", (message) => {
console.log(`수신한 메시지: ${message}`);
// 클라이언트에 응답
ws.send(`수신한 메시지에 대한 응답: ${message}`);
});
// 연결 종료 이벤트
ws.on("close", () => {
console.log("클라이언트 연결이 종료되었습니다");
});
// 초기 메시지 전송
ws.send("WebSocket 서버에 연결되었습니다!");
});
클라이언트 측 구현(브라우저):
// WebSocket 연결 생성
const socket = new WebSocket("ws://localhost:8080");
// 연결 이벤트
socket.addEventListener("open", (event) => {
console.log("서버에 연결되었습니다");
socket.send("안녕하세요 서버!");
});
// 메시지 수신 이벤트
socket.addEventListener("message", (event) => {
console.log(`서버로부터 메시지: ${event.data}`);
});
// 오류 이벤트
socket.addEventListener("error", (event) => {
console.error("WebSocket 오류:", event);
});
// 연결 종료 이벤트
socket.addEventListener("close", (event) => {
console.log("서버 연결이 종료되었습니다", event.code, event.reason);
});
2.2 Express와 통합
Express 애플리케이션에 WebSocket 통합:
const express = require("express");
const http = require("http");
const WebSocket = require("ws");
// Express 앱 생성
const app = express();
// 정적 파일 제공
app.use(express.static("public"));
// HTTP 서버 생성
const server = http.createServer(app);
// WebSocket 서버 생성 및 HTTP 서버와 연결
const wss = new WebSocket.Server({ server });
// WebSocket 이벤트 처리
wss.on("connection", (ws) => {
console.log("클라이언트가 연결되었습니다");
ws.on("message", (message) => {
console.log(`수신한 메시지: ${message}`);
// 모든 클라이언트에 메시지 브로드캐스트
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(`${message}`);
}
});
});
});
// 서버 시작
server.listen(3000, () => {
console.log("서버가 http://localhost:3000 에서 실행 중입니다");
});
3. Socket.IO를 사용한 고급 실시간 기능
Socket.IO는 WebSocket을 기반으로 구축된 라이브러리로, 더 많은 기능과 폴백 옵션을 제공합니다.
3.1 기본 Socket.IO 서버
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
// Express 앱과 HTTP 서버 생성
const app = express();
const server = http.createServer(app);
// Socket.IO 서버 생성
const io = new Server(server);
// 정적 파일 제공
app.use(express.static("public"));
// Socket.IO 연결 이벤트
io.on("connection", (socket) => {
console.log(`사용자 연결됨: ${socket.id}`);
// 방 참가
socket.on("join-room", (roomId, callback) => {
socket.join(roomId);
console.log(`${socket.id} 사용자가 ${roomId} 방에 참가했습니다`);
// 선택적 콜백
if (callback) callback(`${roomId} 방에 참가했습니다`);
});
// 채팅 메시지 수신 및 브로드캐스트
socket.on("chat-message", (data) => {
// 특정 방에만 메시지 전송
io.to(data.roomId).emit("chat-message", {
sender: socket.id,
message: data.message,
timestamp: new Date(),
});
});
// 연결 해제
socket.on("disconnect", () => {
console.log(`사용자 연결 해제됨: ${socket.id}`);
});
});
// 서버 시작
server.listen(3000, () => {
console.log("서버가 http://localhost:3000 에서 실행 중입니다");
});
3.2 Socket.IO 클라이언트 (브라우저)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Socket.IO 채팅 앱</title>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div id="chat-container">
<div id="messages"></div>
<input id="room-input" placeholder="방 ID" />
<button id="join-btn">방 참가</button>
<input id="message-input" placeholder="메시지 입력..." />
<button id="send-btn">전송</button>
</div>
<script>
const socket = io();
let currentRoom = null;
// DOM 요소
const messagesDiv = document.getElementById("messages");
const roomInput = document.getElementById("room-input");
const joinBtn = document.getElementById("join-btn");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
// 방 참가
joinBtn.addEventListener("click", () => {
const roomId = roomInput.value.trim();
if (roomId) {
socket.emit("join-room", roomId, (response) => {
addMessage(`시스템: ${response}`);
currentRoom = roomId;
});
}
});
// 메시지 전송
sendBtn.addEventListener("click", () => {
const message = messageInput.value.trim();
if (message && currentRoom) {
socket.emit("chat-message", {
roomId: currentRoom,
message,
});
messageInput.value = "";
}
});
// 메시지 수신
socket.on("chat-message", (data) => {
const isMe = data.sender === socket.id;
addMessage(`${isMe ? "나" : data.sender}: ${data.message}`);
});
// 연결 이벤트
socket.on("connect", () => {
addMessage(`시스템: 서버에 연결되었습니다. 내 ID: ${socket.id}`);
});
// 연결 해제 이벤트
socket.on("disconnect", () => {
addMessage("시스템: 서버와 연결이 끊어졌습니다.");
});
// 유틸리티 함수 - 메시지 표시
function addMessage(text) {
const messageElement = document.createElement("div");
messageElement.textContent = text;
messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
</script>
</body>
</html>
4. Socket.IO의 주요 기능
4.1 네임스페이스 및 방
Socket.IO는 연결을 네임스페이스와 방으로 구성할 수 있어 복잡한 응용 프로그램 구조를 만들 수 있습니다.
// 네임스페이스 생성
const chatNamespace = io.of("/chat");
const adminNamespace = io.of("/admin");
// 채팅 네임스페이스 이벤트 처리
chatNamespace.on("connection", (socket) => {
// 사용자 방 참가
socket.on("join", (room) => {
socket.join(room);
chatNamespace.to(room).emit("user-joined", { userId: socket.id });
});
// 채팅 메시지
socket.on("message", (data) => {
chatNamespace.to(data.room).emit("message", {
userId: socket.id,
text: data.text,
});
});
});
// 관리자 네임스페이스 이벤트 처리
adminNamespace.on("connection", (socket) => {
// 관리자 인증
socket.on("auth", (credentials) => {
// 인증 로직...
if (isAuthenticated) {
// 관리자 대시보드 데이터 전송
socket.emit("dashboard-data", getDashboardData());
}
});
// 시스템 명령
socket.on("system-command", (command) => {
// 시스템 명령 처리...
adminNamespace.emit("system-update", { status: "command-executed" });
});
});
4.2 미들웨어
Socket.IO는 미들웨어를 통해 연결 및 이벤트를 가로채고 수정할 수 있습니다.
// 인증 미들웨어
io.use((socket, next) => {
const token = socket.handshake.auth.token;
// 토큰 검증
verifyToken(token)
.then((user) => {
// 사용자 데이터를 소켓에 저장
socket.user = user;
next();
})
.catch((err) => {
next(new Error("인증 실패"));
});
});
// 로깅 미들웨어
io.use((socket, next) => {
console.log(`새 연결: ${socket.id}, IP: ${socket.handshake.address}`);
socket.onAny((event, ...args) => {
console.log(`이벤트: ${event}, 소켓: ${socket.id}, 인자:`, args);
});
next();
});
4.3 휘발성 메시지와 영구적 메시지
// 휘발성 메시지 (현재 연결된 클라이언트에만 전송)
io.volatile.emit("statistics", getSystemStats());
// 영구적 메시지 (모든 클라이언트에 전송, 오프라인 클라이언트도 포함)
io.emit("important-announcement", { message: "시스템 업데이트 예정" });
5. 실시간 애플리케이션 패턴
5.1 발행-구독 패턴
Redis를 사용하여 여러 서버 인스턴스 간 통신 구현:
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const { createAdapter } = require("@socket.io/redis-adapter");
const { createClient } = require("redis");
// Express 및 HTTP 서버 설정
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// Redis 클라이언트 생성
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
// Redis 연결
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
// Redis 어댑터 설정
io.adapter(createAdapter(pubClient, subClient));
// Socket.IO 이벤트 처리
io.on("connection", (socket) => {
console.log(`클라이언트 연결됨: ${socket.id}`);
// 방 참가
socket.on("join-room", (room) => {
socket.join(room);
io.to(room).emit("user-joined", socket.id);
});
// 메시지 수신 및 브로드캐스트
socket.on("message", (data) => {
io.to(data.room).emit("message", {
id: socket.id,
text: data.text,
timestamp: Date.now(),
});
});
});
// 서버 시작
server.listen(3000, () => {
console.log("서버가 포트 3000에서 실행 중입니다");
});
});
5.2 상태 동기화
여러 클라이언트 간 상태 동기화 패턴:
// 서버 측: 게임 상태 관리 및 동기화
const gameState = {
players: {},
objects: {},
lastUpdate: Date.now(),
};
io.on("connection", (socket) => {
// 새 플레이어 생성
socket.on("player-join", (playerData) => {
// 플레이어 초기 상태 설정
gameState.players[socket.id] = {
id: socket.id,
x: Math.random() * 500,
y: Math.random() * 500,
name: playerData.name,
score: 0,
lastInput: Date.now(),
};
// 현재 게임 상태 전송
socket.emit("game-state", gameState);
// 다른 플레이어에게 새 플레이어 알림
socket.broadcast.emit("player-joined", gameState.players[socket.id]);
});
// 플레이어 입력 처리
socket.on("player-input", (input) => {
if (!gameState.players[socket.id]) return;
// 플레이어 상태 업데이트
const player = gameState.players[socket.id];
player.x += input.dx;
player.y += input.dy;
player.lastInput = Date.now();
// 다른 모든 플레이어에게 업데이트 전송
socket.broadcast.emit("player-update", {
id: socket.id,
x: player.x,
y: player.y,
});
});
// 연결 해제 시 플레이어 제거
socket.on("disconnect", () => {
if (gameState.players[socket.id]) {
delete gameState.players[socket.id];
io.emit("player-left", socket.id);
}
});
});
// 게임 루프: 주기적으로 모든 클라이언트에 전체 상태 동기화
setInterval(() => {
gameState.lastUpdate = Date.now();
io.emit("game-state-update", gameState);
}, 5000);
6. 실시간 애플리케이션 최적화
6.1 이벤트 로직 최적화
// 불필요한 업데이트 방지
function shouldEmitUpdate(oldState, newState) {
// 변경사항이 충분히 중요한 경우에만 업데이트 전송
return (
Math.abs(oldState.x - newState.x) > 5 ||
Math.abs(oldState.y - newState.y) > 5 ||
oldState.status !== newState.status
);
}
// 상태 변경 처리
socket.on("update-position", (data) => {
const oldState = playerStates[socket.id];
const newState = { ...oldState, ...data };
// 중요한 변경사항이 있을 때만 브로드캐스트
if (shouldEmitUpdate(oldState, newState)) {
playerStates[socket.id] = newState;
socket.broadcast.to("game-room").emit("player-moved", {
id: socket.id,
x: newState.x,
y: newState.y,
status: newState.status,
});
}
});
6.2 데이터 최소화
// 불필요한 데이터 필터링
function getPublicPlayerData(player) {
// 필요한 데이터만 포함
return {
id: player.id,
name: player.name,
x: Math.round(player.x),
y: Math.round(player.y),
status: player.status,
};
}
// 변경된 속성만 전송
function getDelta(oldObj, newObj) {
const delta = {};
for (const key in newObj) {
if (oldObj[key] !== newObj[key]) {
delta[key] = newObj[key];
}
}
return delta;
}
6.3 확장성을 위한 아키텍처
// 여러 웹소켓 서버 인스턴스 간 통신을 위한 Redis
const { createAdapter } = require("@socket.io/redis-adapter");
const { createClient } = require("redis");
// Redis 클라이언트 설정
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
async function setupSocketServer() {
// Redis 연결
await Promise.all([pubClient.connect(), subClient.connect()]);
// Redis 어댑터 설정
io.adapter(createAdapter(pubClient, subClient));
// 상태 관리를 위한 Redis 사용
const stateClient = createClient({ url: process.env.REDIS_URL });
await stateClient.connect();
// 사용자 연결 관리
io.on("connection", async (socket) => {
// Redis에서 사용자 세션 정보 조회
const sessionData = await stateClient.hGetAll(`session:${socket.id}`);
// 사용자 인증 및 초기화
socket.on("authenticate", async (userData) => {
// Redis에 사용자 세션 저장
await stateClient.hSet(`session:${socket.id}`, {
userId: userData.id,
username: userData.username,
room: userData.room,
lastSeen: Date.now(),
});
// 방 참가
socket.join(userData.room);
});
// 연결 해제 시 정리
socket.on("disconnect", async () => {
await stateClient.del(`session:${socket.id}`);
});
});
}
7. 실시간 애플리케이션 사례
7.1 채팅 애플리케이션
// 채팅 서버
io.on("connection", (socket) => {
let user = null;
// 사용자 로그인
socket.on("login", async (userData, callback) => {
try {
user = await authenticateUser(userData);
// 사용자 정보 저장
socket.data.user = user;
// 사용자 채널 참가
user.channels.forEach((channel) => {
socket.join(channel);
});
// 온라인 상태 업데이트
await updateUserStatus(user.id, "online");
// 성공 응답
callback({ success: true, user });
// 다른 사용자에게 알림
socket.broadcast.emit("user-online", {
userId: user.id,
username: user.username,
});
} catch (err) {
callback({ success: false, error: err.message });
}
});
// 메시지 전송
socket.on("send-message", async (messageData) => {
if (!socket.data.user) return;
// 메시지 저장
const message = await saveMessage({
sender: socket.data.user.id,
channel: messageData.channel,
text: messageData.text,
timestamp: Date.now(),
});
// 채널에 메시지 브로드캐스트
io.to(messageData.channel).emit("new-message", {
id: message.id,
sender: {
id: socket.data.user.id,
username: socket.data.user.username,
},
text: message.text,
timestamp: message.timestamp,
});
});
// 타이핑 표시
socket.on("typing", (data) => {
socket.broadcast.to(data.channel).emit("user-typing", {
userId: socket.data.user?.id,
username: socket.data.user?.username,
channel: data.channel,
});
});
// 연결 해제
socket.on("disconnect", async () => {
if (socket.data.user) {
await updateUserStatus(socket.data.user.id, "offline");
io.emit("user-offline", {
userId: socket.data.user.id,
timestamp: Date.now(),
});
}
});
});
7.2 협업 도구
// 실시간 문서 편집기
io.on("connection", (socket) => {
// 문서 참가
socket.on("join-document", async (docId) => {
// 현재 문서에서 퇴장
if (socket.data.currentDoc) {
socket.leave(socket.data.currentDoc);
}
// 새 문서 참가
socket.join(docId);
socket.data.currentDoc = docId;
// 문서 데이터 로드
const document = await getDocument(docId);
// 문서 데이터 전송
socket.emit("document-data", document);
// 다른 사용자에게 알림
socket.broadcast.to(docId).emit("user-joined", {
userId: socket.data.user?.id,
username: socket.data.user?.username,
});
});
// 문서 변경
socket.on("document-change", (change) => {
// 변경 사항 검증
if (!socket.data.currentDoc || !change || !change.ops) return;
// 다른 편집자에게 변경 사항 브로드캐스트
socket.broadcast.to(socket.data.currentDoc).emit("document-change", {
userId: socket.data.user?.id,
username: socket.data.user?.username,
ops: change.ops,
timestamp: Date.now(),
});
// 변경 사항 저장
saveDocumentChanges(socket.data.currentDoc, change.ops);
});
// 커서 위치 공유
socket.on("cursor-move", (position) => {
if (!socket.data.currentDoc) return;
socket.broadcast.to(socket.data.currentDoc).emit("remote-cursor", {
userId: socket.data.user?.id,
username: socket.data.user?.username,
position: position,
});
});
// 주석 추가
socket.on("add-comment", async (comment) => {
if (!socket.data.currentDoc) return;
// 주석 저장
const savedComment = await saveComment({
document: socket.data.currentDoc,
user: socket.data.user.id,
text: comment.text,
position: comment.position,
timestamp: Date.now(),
});
// 주석 브로드캐스트
io.to(socket.data.currentDoc).emit("new-comment", {
id: savedComment.id,
user: {
id: socket.data.user.id,
username: socket.data.user.username,
},
text: savedComment.text,
position: savedComment.position,
timestamp: savedComment.timestamp,
});
});
});
요약
Node.js에서 WebSocket과 실시간 애플리케이션 개발을 위한 주요 접근 방식:
- 기본 WebSocket:
ws
라이브러리를 사용하여 저수준 WebSocket 서버 구현 - Socket.IO: 추가 기능, 폴백 메커니즘, 방, 네임스페이스 등을 제공하는 고급 실시간 프레임워크
- 실시간 패턴: 발행-구독, 상태 동기화, 이벤트 기반 통신을 사용한 효율적인 데이터 전송
- 확장성: Redis 어댑터를 사용하여 여러 서버 인스턴스 간 통신 구현
- 최적화: 불필요한 업데이트 방지, 데이터 최소화, 변경 사항만 전송하여 성능 향상
Node.js의 비동기 이벤트 기반 특성은 WebSocket 애플리케이션에 이상적이며, 채팅, 협업 도구, 게임, 실시간 대시보드와 같은 다양한 실시간 애플리케이션 개발에 적합합니다.