52.1 서버 한 대로는 왜 부족해요?

실시간 서비스는 연결을 계속 열어둔다는 게 특징이에요. 일반 웹은 페이지 하나 받으면 연결이 끝나는데, 웹소켓(WebSocket, 연결을 유지하는 통신)은 사용자가 창을 닫을 때까지 그 연결이 살아 있죠. 그 말은 접속자 한 명당 서버 메모리를 계속 붙잡고 있다는 뜻이에요.


접속자가 백 명이면 웃으며 넘어가요. 그런데 만 명, 십만 명이 몰리면요? 한 대가 열어둘 수 있는 연결 수에는 한계가 있어요. 서버 스펙을 키우는 수직 확장(vertical scaling, 한 대를 더 좋게)도 어느 순간 벽에 부딪히고 값도 무섭게 올라요. 그래서 현실적인 답은 서버를 여러 대로 늘리는 수평 확장(horizontal scaling, 대수를 늘리기)이에요.


저도 접속자 좀 는다고 신나서 서버 한 대에 다 몰아넣었다가, 저녁 트래픽에 메모리가 꽉 차 연결이 우수수 끊긴 적이 있어요. 근데 막상 여러 대로 늘려보면 예상 못 한 함정이 기다려요. 이번 편은 그 함정을 하나씩 풀어요.

52.2 서버를 여러 대로 늘리면 뭐가 문제죠?

서버가 A, B 두 대라고 해볼게요. 앞단의 로드밸런서(load balancer, 트래픽 분배기)가 접속자를 두 서버에 나눠 꽂아요. 철수는 A에, 영희는 B에 붙었다고 쳐요. 그런데 우리가 지난 편에 짠 브로드캐스트를 떠올려 보세요.


// A 서버가 아는 접속자 = 자기한테 붙은 사람뿐
function broadcast(data) {
const msg = JSON.stringify(data);
for (const c of wss.clients) // A에 붙은 연결만 들어 있음
if (c.readyState === 1) c.send(msg);
}


여기서 wss.clients자기 서버에 붙은 연결만 담고 있어요. 그러니 철수(A)가 보낸 메시지는 A에 붙은 사람들에게만 퍼지고, B에 붙은 영희는 영영 못 받아요. 같은 채팅방인데 서버가 다르다는 이유로 대화가 반쪽 나는 거죠. 이걸 메시지 격리(서버끼리 서로 모름)라고 불러요. 서버를 늘리는 순간 제일 먼저 터지는 문제예요.

52.3 서버끼리 메시지를 어떻게 나눠요?

답은 서버들 사이에 공용 통로를 하나 두는 거예요. 각 서버가 이 통로에 메시지를 던지고, 통로를 듣고 있다가 오는 건 자기 접속자에게 뿌리는 방식이죠. 이 통로로 흔히 쓰는 게 레디스(Redis, 빠른 메모리 저장소)의 펍섭(Pub/Sub, 발행·구독)이에요.


원리는 방송국과 라디오예요. 한 서버가 채널에 발행(publish)하면, 그 채널을 구독(subscribe)한 모든 서버가 똑같이 받아요. 받은 서버는 각자 자기 접속자에게 로컬 브로드캐스트만 하면 되고요.


상황: 채팅 메시지를 내 서버에만 뿌리지 말고, 레디스로 모든 서버에 흘려요.
import { createClient } from 'redis';
const pub = createClient(); // 발행용
const sub = pub.duplicate(); // 구독용
await pub.connect();
await sub.connect();

// 다른 서버가 보낸 메시지를 받으면 내 접속자에게 뿌려요
await sub.subscribe('chat', (raw) => {
broadcast(JSON.parse(raw));
});

// 누가 채팅을 보내면 직접 뿌리지 말고 채널에 발행해요
function onChat(msg) {
pub.publish('chat', JSON.stringify(msg));
}


핵심은 구독용과 발행용 연결을 따로 둔다는 거예요. 레디스는 구독 상태에 들어가면 그 연결로 다른 명령을 못 쓰거든요. 이제 철수가 onChat으로 보낸 메시지는 chat 채널에 발행되고, A와 B 둘 다 그걸 받아 각자 접속자에게 뿌려요. 드디어 영희도 철수 말을 듣죠.

52.4 그런데 연결이 자꾸 끊긴다고요?

펍섭을 붙였는데도 접속이 불안하게 툭툭 끊긴다는 얘기가 나와요. 원인은 다른 데 있어요. 로드밸런서는 기본적으로 요청을 이 서버 저 서버 돌려가며 뿌려요(라운드로빈). 웹소켓은 처음에 HTTP 업그레이드라는 악수(handshake)로 시작하는데, 폴백(long-polling, 옛 브라우저용 대체 통신)을 함께 쓰는 라이브러리라면 이 악수가 여러 번의 HTTP 요청으로 이뤄져요.


그 요청들이 매번 다른 서버로 흩어지면 어떻게 될까요? A에서 시작한 악수를 B가 받으니 "난 널 모르는데?" 하고 연결이 깨져요. 그래서 같은 사람은 늘 같은 서버로 보내주는 장치가 필요해요. 이걸 스티키 세션(sticky session, 고정 접속)이라고 해요.


로드밸런서 설정에서 켜면 돼요. 아이피를 기준으로 같은 서버에 고정하는 ip_hash 방식이 대표적이에요.


상황: 엔진엑스에서 같은 클라이언트를 같은 서버에 고정해요.
upstream ws_servers {
ip_hash; # 같은 아이피는 늘 같은 서버로
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}


ip_hash 한 줄이면 같은 아이피는 항상 같은 서버로 가요. 악수가 여러 요청으로 쪼개져도 흩어지지 않죠. 참고로 순수 웹소켓만 쓰고 폴백이 없다면 한번 연결된 뒤엔 그 연결이 한 서버에 계속 붙어 있어서 스티키가 덜 중요해요. 그래도 연결을 맺는 그 순간은 안전하게 고정해두는 게 마음 편해요.

52.5 접속자 명단은 어디에 둬요?

한 가지가 더 남았어요. 지난 편에서 접속자 명단을 online이라는 Map으로 서버 메모리에 뒀잖아요. 서버가 한 대일 땐 완벽했죠. 그런데 여러 대가 되면 A의 online엔 A에 붙은 사람만, B엔 B 사람만 들어 있어요. "지금 접속자 열 명"을 보여주고 싶은데 각자 절반씩만 아는 거예요.


그래서 여러 대가 공유해야 하는 상태는 메모리가 아니라 중앙 저장소에 둬야 해요. 여기서도 레디스가 제격이에요. 접속자 명단을 레디스의 집합(Set, 중복 없는 모음)에 넣고, 들어오고 나갈 때마다 갱신하면 어느 서버에서 읽어도 전체 명단이 나와요.


상황: 접속·이탈을 레디스 집합에 반영하고, 전체 인원을 읽어요.
// 들어오면 추가
await pub.sAdd('online', userId);
// 나가면 제거
await pub.sRem('online', userId);
// 전체 접속자는 어느 서버에서든 이렇게
const users = await pub.sMembers('online');


규칙은 하나예요. 한 서버 안에서만 유효한 상태는 메모리에, 여러 서버가 함께 봐야 하는 상태는 중앙 저장소에. 이 경계만 잘 그으면 서버를 세 대, 열 대로 늘려도 명단이 어긋나지 않아요.

52.6 오늘 배운 걸 정리해볼게요

실시간을 여러 대로 키울 때의 함정과 해법을 훑었어요. 다시 짚어볼게요.


연결을 계속 무는 실시간은 한 대로는 곧 벽을 만나서 수평 확장이 답이에요. 그런데 서버를 나누면 wss.clients가 자기 서버만 알아 메시지가 격리돼요. 이건 레디스 펍섭으로 서버끼리 메시지를 나눠 풀고요. 악수가 여러 서버로 흩어져 끊기는 문제는 스티키 세션(ip_hash)으로 같은 사람을 같은 서버에 고정해 막아요. 마지막으로 접속자 명단처럼 여러 대가 공유할 상태는 메모리가 아니라 레디스 같은 중앙 저장소에 둬야 어긋나지 않죠.


정리하면 실시간 확장의 절반은 "이 상태가 한 서버 것이냐, 모두의 것이냐"를 가르는 일이에요. 그 선을 긋는 순간 대부분의 버그가 사라져요. 로컬에 서버 두 개를 다른 포트로 띄우고 그 앞에 로드밸런서를 하나 둔 뒤, 펍섭을 뺐을 때 메시지가 반쪽 나는 걸 직접 눈으로 보세요. 왜 필요한지가 몸으로 남아요.