Socket.IO 채팅 만들기 1편: Node.js 서버와 웹 클라이언트 구현
전면 개정 안내 (2026-07-24)
2022년에 작성한 설치 중심의 글을 Socket.IO 4.x와 ES Modules 기준으로 다시 작성했습니다. 이번 글을 끝까지 따라 하면 방에 입장하고 메시지를 주고받는 웹 채팅을 실행할 수 있습니다.

Socket.IO 채팅의 핵심은 단순히 emit() 한 번을 호출하는 것이 아닙니다. 서버가 사용자를 식별하고, 올바른 방에 메시지를 전달하며, 연결이 끊겼을 때의 상태와 중복 전송까지 다뤄야 합니다.
이 글에서는 먼저 학습용으로 충분히 작으면서도 다음 기능을 갖춘 채팅을 만듭니다.
- 닉네임과 방 이름을 입력해 입장
- 같은 방의 사용자에게만 메시지 전송
- 서버에서 입력값과 방 입장 여부 검증
- ACK(승인 응답)로 전송 성공 여부 확인
- 일시적인 연결 끊김 후 상태 복구
- 메시지 ID를 이용한 화면 중복 표시 방지
Socket.IO와 WebSocket은 같은 것일까?
완전히 같지는 않습니다. WebSocket은 양방향 통신 프로토콜이고, Socket.IO는 그 위에서 이벤트, 자동 재연결, 승인 응답, 방(Room), 브로드캐스트 같은 기능을 제공하는 라이브러리입니다. 환경에 따라 HTTP long-polling으로 연결한 뒤 WebSocket으로 업그레이드할 수도 있습니다.
따라서 순수 WebSocket 서버에 Socket.IO 클라이언트를 바로 연결할 수는 없습니다. 서버와 클라이언트가 모두 Socket.IO 프로토콜을 사용해야 합니다.
| 항목 | WebSocket | Socket.IO |
|---|---|---|
| 통신 방식 | 표준 프로토콜 | 이벤트 기반 라이브러리 |
| 자동 재연결 | 직접 구현 | 기본 제공 |
| 방과 브로드캐스트 | 직접 구현 | 기본 제공 |
| ACK | 직접 메시지 규약 설계 | 콜백 방식 제공 |
| 전송 폴백 | 없음 | long-polling 후 업그레이드 가능 |
1. 프로젝트 만들기
Node.js의 지원 중인 LTS 버전을 사용합니다. 빈 폴더에서 다음 명령을 실행하세요.
mkdir socketio-chat
cd socketio-chat
npm init -y
npm install express socket.io
mkdir public
package.json에는 ES Modules와 실행 스크립트를 추가합니다.
{
"name": "socketio-chat",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.0.0",
"socket.io": "^4.0.0"
}
}
패키지 버전은 글의 숫자를 그대로 고정하기보다 npm install express socket.io로 현재 호환 버전을 설치하고, 생성된 package-lock.json을 함께 보관하는 편이 안전합니다.
최종 폴더 구조는 다음과 같습니다.
socketio-chat/
├─ public/
│ └─ index.html
├─ package.json
├─ package-lock.json
└─ server.js
2. Socket.IO 서버 구현
프로젝트 루트에 server.js를 만듭니다.
import crypto from "node:crypto";
import { createServer } from "node:http";
import express from "express";
import { Server } from "socket.io";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: false
}
});
app.use(express.static("public"));
const recentMessages = new Map();
const deduplicationWindowMs = 10 * 60 * 1000;
function clean(value, maxLength) {
if (typeof value !== "string") return "";
return value.trim().slice(0, maxLength);
}
io.on("connection", (socket) => {
socket.on("chat:join", (payload = {}, ack = () => {}) => {
const room = clean(payload.room, 40);
const nickname = clean(payload.nickname, 24);
if (!room || !nickname) {
ack({ ok: false, error: "방 이름과 닉네임을 입력하세요." });
return;
}
socket.join(room);
socket.data.room = room;
socket.data.nickname = nickname;
ack({
ok: true,
room,
nickname,
recovered: socket.recovered
});
});
socket.on("chat:send", (payload = {}, ack = () => {}) => {
const room = socket.data.room;
const nickname = socket.data.nickname;
const text = clean(payload.text, 500);
const clientMessageId = clean(payload.clientMessageId, 80);
if (!room || !nickname) {
ack({ ok: false, error: "먼저 채팅방에 입장하세요." });
return;
}
if (!text || !clientMessageId) {
ack({ ok: false, error: "메시지를 확인하세요." });
return;
}
const deduplicationKey = `${room}:${nickname}:${clientMessageId}`;
const previous = recentMessages.get(deduplicationKey);
if (previous) {
ack({ ok: true, messageId: previous.id, duplicate: true });
return;
}
const message = {
id: crypto.randomUUID(),
clientMessageId,
nickname,
text,
sentAt: new Date().toISOString()
};
recentMessages.set(deduplicationKey, message);
setTimeout(() => {
recentMessages.delete(deduplicationKey);
}, deduplicationWindowMs).unref();
io.to(room).emit("chat:message", message);
ack({ ok: true, messageId: message.id });
});
});
const port = Number(process.env.PORT ?? 3000);
httpServer.listen(port, () => {
console.log(`http://localhost:${port}`);
});
중요한 점은 클라이언트가 보낸 닉네임과 방 정보를 메시지마다 그대로 신뢰하지 않는다는 것입니다. 입장 시 검증한 값을 socket.data에 저장하고, 메시지를 보낼 때 서버가 이 값을 사용합니다.
connectionStateRecovery는 짧은 네트워크 단절 동안 소켓의 방과 data, 놓친 패킷 복구를 시도합니다. 다만 복구는 항상 성공하는 기능이 아니므로, 실제 서비스에서는 실패 시 방 정보와 최근 메시지를 서버에서 다시 조회해야 합니다.
3. 웹 클라이언트 구현
public/index.html을 만듭니다. 예제를 한 파일로 구성해 바로 실행할 수 있게 했습니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Socket.IO Chat</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
font-family: system-ui, sans-serif; color: #e5e7eb; background: #081226;
}
main {
width: min(720px, 94vw); padding: 24px; border: 1px solid #263452;
border-radius: 18px; background: #111c32; box-shadow: 0 24px 70px #0006;
}
form { display: flex; gap: 8px; margin-top: 12px; }
input, button {
min-height: 44px; padding: 0 12px; border: 1px solid #344563;
border-radius: 10px; font: inherit;
}
input { flex: 1; color: #e5e7eb; background: #0b1528; }
button { color: white; background: #4f46e5; cursor: pointer; }
#messages {
height: 360px; margin: 16px 0 0; padding: 16px; overflow-y: auto;
list-style: none; border-radius: 12px; background: #091326;
}
#messages li { padding: 8px 0; border-bottom: 1px solid #1f2c46; }
#status { color: #93c5fd; }
</style>
</head>
<body>
<main>
<h1>Socket.IO Chat</h1>
<p id="status">연결 중…</p>
<form id="join-form">
<input id="nickname" maxlength="24" placeholder="닉네임" required>
<input id="room" maxlength="40" value="general" placeholder="방 이름" required>
<button>입장</button>
</form>
<ul id="messages" aria-live="polite"></ul>
<form id="message-form">
<input id="message" maxlength="500" autocomplete="off"
placeholder="메시지" disabled required>
<button id="send" disabled>전송</button>
</form>
</main>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io({
retries: 3,
ackTimeout: 5000
});
const statusElement = document.querySelector("#status");
const joinForm = document.querySelector("#join-form");
const messageForm = document.querySelector("#message-form");
const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const messages = document.querySelector("#messages");
const renderedMessageIds = new Set();
socket.on("connect", () => {
statusElement.textContent = socket.recovered
? "연결이 복구되었습니다."
: "서버에 연결되었습니다.";
});
socket.on("disconnect", () => {
statusElement.textContent = "연결이 끊겼습니다. 재연결 중…";
});
joinForm.addEventListener("submit", (event) => {
event.preventDefault();
socket.emit("chat:join", {
nickname: document.querySelector("#nickname").value,
room: document.querySelector("#room").value
}, (response) => {
if (!response?.ok) {
statusElement.textContent = response?.error ?? "입장에 실패했습니다.";
return;
}
statusElement.textContent = `${response.room} 방에 입장했습니다.`;
messageInput.disabled = false;
sendButton.disabled = false;
messageInput.focus();
});
});
messageForm.addEventListener("submit", (event) => {
event.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
const clientMessageId = crypto.randomUUID();
sendButton.disabled = true;
socket.emit("chat:send", { text, clientMessageId }, (response) => {
sendButton.disabled = false;
if (!response?.ok) {
statusElement.textContent = response?.error ?? "전송에 실패했습니다.";
return;
}
messageInput.value = "";
messageInput.focus();
});
});
socket.on("chat:message", (message) => {
if (renderedMessageIds.has(message.id)) return;
renderedMessageIds.add(message.id);
const item = document.createElement("li");
const strong = document.createElement("strong");
strong.textContent = `${message.nickname}: `;
item.append(strong, document.createTextNode(message.text));
messages.append(item);
messages.scrollTop = messages.scrollHeight;
});
</script>
</body>
</html>
채팅 내용을 innerHTML로 넣지 않고 textContent와 createTextNode()를 사용한 이유는 사용자가 입력한 HTML이나 스크립트가 실행되는 XSS 문제를 막기 위해서입니다.
4. 실행하고 확인하기
서버를 시작합니다.
npm start
브라우저에서 http://localhost:3000을 탭 두 개로 열고 다음 순서로 확인합니다.
- 두 탭에서 서로 다른 닉네임을 입력합니다.
- 방 이름을 모두
general로 설정하고 입장합니다. - 한쪽에서 메시지를 보내 양쪽에 나타나는지 확인합니다.
- 한 탭을 다른 방 이름으로 다시 열어 메시지가 분리되는지 확인합니다.
- 개발자 도구의 Network를 Offline으로 바꿨다가 복구해 재연결 상태를 확인합니다.
5. 재연결만 믿으면 메시지를 잃을 수 있다

Socket.IO는 메시지 순서를 보장하지만, 기본 도착 보장은 at most once(최대 한 번)입니다. 전송 도중 연결이 끊기면 서버가 받았는지 확실하지 않고, 연결이 끊긴 클라이언트가 놓친 서버 이벤트도 기본 설정만으로 다시 전달되지 않습니다.
이 예제는 다음 장치를 넣었습니다.
| 문제 | 예제의 처리 | 실제 서비스에서 추가할 것 |
|---|---|---|
| 클라이언트 → 서버 유실 | retries, ackTimeout, ACK |
영속 큐와 전송 상태 UI |
| 재시도로 인한 중복 | 서버의 임시 중복 검사 + 화면 중복 제거 | DB에서 clientMessageId 고유 제약 |
| 짧은 연결 단절 | connectionStateRecovery |
복구 실패 시 최근 메시지 재조회 |
| 서버 재시작 | 처리하지 않음 | 메시지 DB와 세션 저장소 |
현재 서버 예제는 최근 clientMessageId를 10분 동안 메모리에 보관합니다. 서버를 재시작하거나 여러 인스턴스를 사용하면 이 기록은 공유되지 않습니다. 따라서 프로덕션에서는 (사용자 ID, clientMessageId)에 DB 고유 제약을 걸고, 같은 요청이 다시 오면 기존 처리 결과를 ACK로 돌려주는 멱등성 처리가 필요합니다.
또한 브라우저를 새로고침하면 아직 ACK를 받지 못한 메모리상의 이벤트는 사라질 수 있습니다. 중요한 메시지는 IndexedDB 같은 로컬 저장소에 대기열로 보관하거나, 서버 API로 전송 상태를 다시 확인해야 합니다.
6. 운영 환경으로 옮기기 전 체크리스트
학습용 채팅과 실제 서비스 사이에는 큰 차이가 있습니다.
socket.handshake.auth의 토큰을 서버 미들웨어에서 검증하고 사용자 ID를 결정합니다.- 닉네임을 인증된 사용자 프로필에서 가져오고 클라이언트 입력을 신뢰하지 않습니다.
- 메시지 길이 제한, 사용자별 전송 속도 제한, 차단과 신고 정책을 적용합니다.
- 메시지를 DB에 저장하고 마지막 수신 ID 이후의 누락 메시지를 다시 조회합니다.
- HTTPS와 보안 헤더를 적용하고 운영 도메인만 CORS에 허용합니다.
- 여러 서버로 확장할 때 어댑터 호환성과 로드 밸런서의 sticky session을 함께 검토합니다.
- 로그에 메시지 본문이나 인증 토큰을 무분별하게 남기지 않습니다.
- 종료 신호를 받으면 새 연결을 중단하고 처리 중인 작업을 정리합니다.
프런트엔드와 Socket.IO 서버의 출처가 다를 때만 CORS를 명시합니다.
const io = new Server(httpServer, {
cors: {
origin: ["https://chat.example.com"],
methods: ["GET", "POST"],
credentials: true
}
});
인증 정보를 포함할 때 origin: "*"를 사용하면 안 됩니다. 허용할 운영 도메인을 정확히 지정하세요.
마무리
이번 글에서는 Socket.IO 채팅의 가장 작은 완성 형태를 만들었습니다. 서버는 입장 정보를 검증해 방에 저장하고, 클라이언트는 ACK와 연결 상태를 표시하며, 메시지 ID로 화면 중복을 막습니다.
다음 단계에서는 브라우저 예제를 다른 프런트엔드나 모바일 앱에 연결하더라도 이벤트 이름과 데이터 구조를 그대로 재사용할 수 있습니다. 다만 실제 서비스라면 인증, 메시지 영속화, 멱등성, 복구 실패 시 동기화까지 한 묶음으로 설계해야 합니다.
댓글
댓글 쓰기