
실시간 채팅 시스템 설계: WebSocket만으로 될까?
1:1 채팅은 쉬웠는데 그룹 채팅, 읽음 표시, 오프라인 메시지까지 고려하니 완전히 다른 문제였다.

1:1 채팅은 쉬웠는데 그룹 채팅, 읽음 표시, 오프라인 메시지까지 고려하니 완전히 다른 문제였다.
HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

처음 채팅 기능을 만들 때, 나는 완전히 얕잡아봤다. WebSocket 라이브러리 하나 깔고, 연결 열고, send()하고 onmessage 받으면 되는 거 아닌가? 1:1 채팅 데모는 정말 그렇게 10분 만에 됐다. 양쪽 브라우저에서 타이핑하면 실시간으로 뜨고, "우와 된다!" 하며 뿌듯했다.
그런데 PM이 요구사항을 추가하기 시작했다. "그룹 채팅도 되죠?" "읽음 표시 필요해요" "앱 꺼져 있을 때도 메시지 오면 푸시 알림 가야죠?" 하나씩 추가할 때마다 내 코드는 스파게티가 됐고, 결국 깨달았다. 채팅은 실시간 연결의 문제가 아니라 상태 관리와 동기화의 문제였다.
마치 수도꼭지를 생각했는데, 알고 보니 상수도 시스템 전체를 설계해야 하는 것과 같았다. 물이 나오는 건 쉬운데, 100개 집에 동시에 안정적으로 물을 공급하고, 누가 얼마나 썼는지 추적하고, 수압 유지하고, 누수 감지하는 게 진짜 문제였다.
처음엔 왜 WebSocket이 필요한지도 몰랐다. HTTP로 1초마다 새 메시지 있나 확인하면 안 되나? 실제로 옛날 채팅은 그렇게 했다. 이게 Polling이다.
// Polling: 매번 서버에 물어보기
setInterval(() => {
fetch('/api/messages/new')
.then(res => res.json())
.then(messages => updateUI(messages));
}, 1000); // 1초마다 요청
문제가 뭐였나? 메시지가 없어도 계속 요청을 날린다. 사용자가 1000명이면 초당 1000개 요청이 날아간다. 대부분은 "새 메시지 없음" 응답만 받는다. 마치 우체통을 1초마다 열어보는데 99%는 비어있는 것과 같다.
Long Polling은 좀 똑똑하다. 서버가 새 메시지가 올 때까지 응답을 보류한다.
// Long Polling: 새 메시지 있을 때까지 대기
function longPoll() {
fetch('/api/messages/wait') // 서버가 메시지 올 때까지 hold
.then(res => res.json())
.then(messages => {
updateUI(messages);
longPoll(); // 다시 대기
});
}
이건 쓸데없는 요청을 줄였지만, 여전히 HTTP 오버헤드가 있다. 매번 새 연결을 열고, 헤더를 주고받고, 끊고를 반복한다.
WebSocket은 완전히 다른 접근이다. 한 번 악수(handshake)하면 계속 연결이 열려있다. TCP 소켓처럼 양방향 통신이 가능하다.
// WebSocket: 한 번 연결, 계속 사용
const ws = new WebSocket('ws://chat.example.com');
ws.onopen = () => {
console.log('연결됨');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
updateUI(message);
};
ws.send(JSON.stringify({ text: 'Hello!' }));
그럼 SSE(Server-Sent Events)는? 이건 서버에서 클라이언트로만 푸시할 수 있다. 채팅처럼 양방향이 필요하면 SSE로는 부족하다. 알림, 주식 시세처럼 서버→클라이언트만 필요할 때 쓴다.
결국 나한테 와닿은 건 이거였다. Polling은 물어보기, Long Polling은 기다리기, WebSocket은 전화선 연결해두기, SSE는 방송 듣기.
WebSocket으로 send() 하면 메시지가 도착할까? 네트워크가 끊기면? 서버가 재시작하면? 이때부터 전달 보장(delivery guarantee) 문제가 나온다.
At-most-once: 최대 한 번. 메시지를 보내고 확인 안 한다. 빠르지만 유실될 수 있다.
At-least-once: 최소 한 번. 확인(ACK)을 받을 때까지 재전송한다. 중복될 수 있다.
Exactly-once: 정확히 한 번. 이건 진짜 어렵다. 보통 메시지 ID로 중복 제거를 해야 한다.
채팅에서는 보통 at-least-once로 간다. 메시지가 중복되는 건 괜찮은데(UI에서 같은 ID는 하나만 표시), 유실되면 안 되니까.
// 클라이언트: ACK 받을 때까지 재전송
const pendingMessages = new Map();
function sendMessage(text) {
const msgId = generateId();
const msg = { id: msgId, text, timestamp: Date.now() };
pendingMessages.set(msgId, msg);
ws.send(JSON.stringify(msg));
// 5초 내 ACK 안 오면 재전송
setTimeout(() => {
if (pendingMessages.has(msgId)) {
ws.send(JSON.stringify(msg));
}
}, 5000);
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'ack') {
pendingMessages.delete(data.msgId); // 확인됨
} else if (data.type === 'message') {
// 메시지 받음, 서버에 ACK 전송
ws.send(JSON.stringify({ type: 'ack', msgId: data.id }));
displayMessage(data);
}
};
이걸 이해하고 나니까 Kafka나 RabbitMQ 같은 메시지 큐들이 왜 이런 개념을 강조하는지 와닿았다. 분산 시스템에서 "전달됐다"는 게 생각보다 훨씬 복잡하다.
1:1 채팅은 간단하다. Alice가 Bob에게 메시지 보내면, Bob의 WebSocket으로 전달하면 끝.
그런데 100명 그룹 채팅방에서 Alice가 메시지를 보내면? 99명에게 다 전달해야 한다. 이게 fan-out(팬아웃) 문제다. 쓰기가 증폭된다.
// 나이브한 그룹 채팅: 모든 멤버에게 반복문
function broadcastToRoom(roomId, message) {
const room = rooms.get(roomId);
const members = room.members; // 100명
members.forEach(userId => {
const ws = connections.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
});
}
100명은 괜찮은데, 10,000명 그룹이면? 메시지 하나에 10,000번 send() 호출이다. 서버가 터진다.
여기서 두 가지 최적화를 배웠다.
1. Pub/Sub 패턴 with RedisWebSocket 서버가 여러 대면, 사용자들이 다른 서버에 연결돼 있다. Alice는 서버A, Bob은 서버B에 연결됐으면, 서버끼리 메시지를 전달해야 한다. Redis Pub/Sub을 쓴다.
// 서버A: Alice가 메시지 전송
function onMessageReceived(userId, roomId, message) {
// DB에 저장
db.saveMessage(message);
// Redis에 publish (모든 서버가 구독 중)
redis.publish(`room:${roomId}`, JSON.stringify(message));
}
// 모든 서버: room 채널 구독
redis.subscribe(`room:${roomId}`);
redis.on('message', (channel, data) => {
const message = JSON.parse(data);
const roomId = channel.split(':')[1];
// 이 서버에 연결된 멤버들에게만 전송
broadcastToLocalConnections(roomId, message);
});
이제 서버가 10대여도, Alice의 메시지가 Bob에게 도달한다. 마치 전국 라디오 방송국들이 중앙 송신소에서 신호 받아서 각자 지역에 송출하는 것과 같다.
2. 온라인 사용자에게만 전송10,000명 그룹인데 지금 온라인은 50명이면? 50명에게만 보내면 된다. 나머지 9,950명은 나중에 앱 켤 때 히스토리 불러오면 된다.
// 온라인 멤버 추적
const onlineUsers = new Set();
ws.on('open', () => {
onlineUsers.add(userId);
});
ws.on('close', () => {
onlineUsers.delete(userId);
});
// 온라인만 필터링
function broadcastToRoom(roomId, message) {
const members = rooms.get(roomId).members;
members
.filter(userId => onlineUsers.has(userId))
.forEach(userId => {
const ws = connections.get(userId);
ws.send(JSON.stringify(message));
});
}
이렇게 하니 쓰기 증폭이 확 줄었다. 온라인/오프라인을 추적하는 게 핵심이었다.
"Bob이 온라인인가?"를 어떻게 알 수 있나? WebSocket이 연결돼 있으면 온라인? 근데 네트워크가 끊겼는데 서버가 아직 모르면? 또는 앱이 백그라운드로 갔는데 연결은 살아있으면?
Heartbeat(ping/pong)을 쓴다. 주기적으로 신호를 보내서 살아있나 확인한다.
// 서버: 30초마다 ping, 45초 안에 pong 없으면 끊김으로 간주
const PING_INTERVAL = 30000;
const PONG_TIMEOUT = 45000;
const heartbeats = new Map();
setInterval(() => {
connections.forEach((ws, userId) => {
if (!heartbeats.has(userId) ||
Date.now() - heartbeats.get(userId) > PONG_TIMEOUT) {
// Timeout, 연결 끊김
ws.terminate();
onlineUsers.delete(userId);
broadcastPresence(userId, 'offline');
} else {
ws.ping();
}
});
}, PING_INTERVAL);
ws.on('pong', () => {
heartbeats.set(userId, Date.now());
});
// 클라이언트: pong은 자동, 또는 명시적으로
ws.on('ping', () => {
ws.pong();
});
이제 "Bob이 온라인"이라는 상태를 신뢰할 수 있다. 읽음 표시, 타이핑 인디케이터 같은 기능이 이 위에서 동작한다.
읽음 표시는 "Bob이 메시지를 읽었다"는 이벤트를 보내는 거다.
// 클라이언트: 메시지 화면에 보이면 read event 전송
observer.observe(messageElement, {
threshold: 1.0, // 100% 보이면
callback: () => {
ws.send(JSON.stringify({
type: 'read',
messageId: msg.id,
roomId: msg.roomId
}));
}
});
// 서버: read event를 room 멤버들에게 브로드캐스트
function onReadEvent(userId, roomId, messageId) {
db.markAsRead(userId, messageId);
redis.publish(`room:${roomId}`, JSON.stringify({
type: 'read_receipt',
userId,
messageId,
timestamp: Date.now()
}));
}
WhatsApp의 파란 체크 마크가 이렇게 동작한다. 메시지 전송→ 1개 체크(서버 도착)→ 2개 체크(상대 수신)→ 파란 체크(상대 읽음).
모든 메시지를 DB에 저장하면 느리다. 메모리에만 두면 서버 재시작하면 날아간다. 결국 하이브리드가 답이었다.
최근 메시지는 캐시(Redis), 오래된 건 DB(PostgreSQL)// 메시지 저장 전략
async function saveMessage(roomId, message) {
// 1. DB에 영구 저장 (비동기)
db.messages.insert(message); // await 안 함
// 2. Redis에 최근 100개 캐시 (빠른 조회용)
await redis.lpush(`room:${roomId}:recent`, JSON.stringify(message));
await redis.ltrim(`room:${roomId}:recent`, 0, 99); // 100개만 유지
// 3. 온라인 유저들에게 실시간 전송
broadcastToRoom(roomId, message);
}
// 메시지 조회
async function getMessages(roomId, limit = 50) {
// 1. 먼저 캐시 확인
const cached = await redis.lrange(`room:${roomId}:recent`, 0, limit - 1);
if (cached.length >= limit) {
return cached.map(JSON.parse);
}
// 2. 부족하면 DB에서 가져오기
const messages = await db.messages
.where({ roomId })
.orderBy('timestamp', 'desc')
.limit(limit);
return messages;
}
이렇게 하니 90% 이상 요청이 Redis에서 처리됐다. DB 부하가 확 줄었다. 마치 자주 쓰는 도구는 작업대 위에, 가끔 쓰는 건 창고에 두는 것과 같다.
사용자가 많아지면 WebSocket 서버를 여러 대 띄운다. 여기서 Sticky Session 문제가 나온다.
Alice가 서버A에 연결했다가 끊겼다가 다시 연결할 때, 서버B로 가면 안 된다. 왜? 서버A의 메모리에 Alice의 상태가 있으니까. 로드밸런서가 같은 사용자를 같은 서버로 보내야 한다.
# nginx에서 IP 기반 sticky session
upstream websocket_servers {
ip_hash; # 같은 IP는 같은 서버로
server ws1.example.com:8080;
server ws2.example.com:8080;
server ws3.example.com:8080;
}
또는 Redis로 상태 공유를 한다. 어느 서버든 Redis에서 상태를 읽으면 된다.
// WebSocket 연결 정보를 Redis에 저장
ws.on('open', () => {
redis.hset('ws:connections', userId, serverInstanceId);
redis.hset('ws:sessions', userId, JSON.stringify(sessionData));
});
// 메시지 전달할 때 어느 서버인지 확인
async function sendToUser(userId, message) {
const serverId = await redis.hget('ws:connections', userId);
if (serverId === currentServerInstanceId) {
// 이 서버에 연결됨, 직접 전송
const ws = connections.get(userId);
ws.send(JSON.stringify(message));
} else {
// 다른 서버에 연결됨, Redis Pub/Sub으로 전달
redis.publish(`server:${serverId}`, JSON.stringify({
userId,
message
}));
}
}
이제 사용자가 어느 서버에 연결돼도 메시지를 받을 수 있다.
Bob이 앱을 끄고 있으면? WebSocket은 끊겼지만, 메시지는 와야 한다. Push Notification이 필요하다.
// 메시지 전송 시 온라인 체크
async function deliverMessage(userId, message) {
if (onlineUsers.has(userId)) {
// 온라인: WebSocket으로 전송
const ws = connections.get(userId);
ws.send(JSON.stringify(message));
} else {
// 오프라인: 푸시 알림 전송
const deviceToken = await db.getDeviceToken(userId);
await sendPushNotification({
token: deviceToken,
title: message.senderName,
body: message.text,
data: {
roomId: message.roomId,
messageId: message.id
}
});
// DB에 unread 카운트 증가
await db.incrementUnread(userId, message.roomId);
}
}
앱을 다시 열면 unread 카운트 확인하고, 히스토리를 불러온다.
// 앱 재시작 시
async function onAppLaunch() {
const unreadRooms = await db.getUnreadRooms(userId);
for (const room of unreadRooms) {
// 마지막으로 읽은 메시지 이후 것들 가져오기
const newMessages = await getMessages(
room.id,
{ after: room.lastReadMessageId }
);
displayMessages(newMessages);
}
// WebSocket 재연결
connectWebSocket();
}
이게 Slack이나 Discord가 동작하는 방식이다. 온라인이면 즉시, 오프라인이면 푸시 알림 + 히스토리 동기화.
채팅 시스템은 단순히 WebSocket 연결의 문제가 아니었다. 정리하면 이렇다.
1. 연결 방식: Polling → Long Polling → WebSocket으로 진화했고, 양방향 실시간은 WebSocket이 최적이다.
2. 메시지 전달 보장: At-least-once로 가되, 클라이언트가 중복 제거를 해야 한다. ACK와 재전송 로직이 필수다.
3. 그룹 채팅: Fan-out 문제를 Redis Pub/Sub으로 해결하고, 온라인 사용자만 필터링해서 쓰기 증폭을 줄인다.
4. 온라인 상태: Heartbeat(ping/pong)로 확인하고, 이를 기반으로 읽음 표시, 타이핑 표시를 구현한다.
5. 메시지 저장: 최근 메시지는 Redis 캐시, 오래된 건 DB. 90% 이상 요청을 캐시에서 처리한다.
6. 스케일링: 서버가 여러 대면 sticky session 또는 Redis 상태 공유. Pub/Sub으로 서버 간 통신.
7. 오프라인: WebSocket 끊기면 푸시 알림 보내고, 앱 재시작 시 히스토리 동기화.
가장 크게 배운 건, 실시간 시스템은 "지금 연결된 사람"만 고려하는 게 아니라 "방금 나갔거나, 곧 들어올 사람"까지 고려해야 한다는 것이다. 상태의 경계가 모호하고, 네트워크는 언제든 끊긴다. 이 불확실성 위에서 확실한 경험을 만드는 게 시스템 설계의 핵심이었다.
수도꼭지에서 물 나오게 하는 건 쉽지만, 도시 전체에 24시간 안정적으로 물을 공급하는 시스템을 만드는 건 완전히 다른 문제다. 채팅도 똑같았다.