대규모 웹소켓 환경에서 스티키 세션으로 인한 서버 과부하 문제를 해결하고, Pub/Sub 패턴을 활용한 스케일 아웃 아키텍처로 전환한 실전 트러블슈팅 경험을 공유합니다.
실시간 통신이 핵심인 서비스에서 웹소켓(WebSocket)은 필수적인 기술입니다. 사용자에게 즉각적인 피드백을 제공하고, 끊김 없는 경험을 선사하기 위해 많은 개발 팀이 웹소켓을 도입하곤 합니다. 하지만 서비스가 성장하고 수많은 사용자가 동시 접속하면서, 예상치 못한 서버 과부하와 스케일 아웃(Scale-out)의 난관에 부딪히는 경우가 허다합니다. 특히, 로드 밸런서 환경에서 흔히 사용되는 Sticky Session(세션 고정) 방식이 대규모 웹소켓 환경에서 치명적인 함정이 될 수 있다는 사실을 뒤늦게 깨닫는 경우가 많습니다.
이 글에서는 수많은 웹소켓 연결을 처리하는 과정에서 발생했던 실제 서버 과부하 문제와 그 원인, 그리고 Sticky Session의 한계를 극복하고 Pub/Sub 패턴을 활용하여 안정적인 스케일 아웃 아키텍처를 구축했던 경험을 상세히 공유하고자 합니다. 5년차 이상의 시니어 개발자라면 누구나 공감할 만한 깊이 있는 분석과 실용적인 해결 방안, 그리고 아키텍처 설계 시 고려해야 할 트레이드오프(Trade-off) 논의까지 다룰 예정입니다. 과연 우리는 어떻게 새벽을 깨운 경고음 속에서 고가용성(High Availability)과 확장성(Scalability)을 확보할 수 있었을까요?
📑 목차
- 1. 새벽을 깨운 경고음: 대규모 웹소켓 연결 환경의 예측 불허 과부하
- 1.1. 불균형한 서버 부하와 사용자 경험 저하
- 2. 문제의 핵심 탐구: Sticky Session, 그 매혹적인 함정
- 2.1. Sticky Session의 작동 방식과 초기 이점
- 2.2. 웹소켓 환경에서 Sticky Session이 야기하는 불균형
- 3. 근본적인 해결책 모색: Pub/Sub 패턴으로의 전환
- 3.1. Pub/Sub 패턴의 기본 원리와 웹소켓 적용 가능성
- 3.2. Redis를 활용한 Pub/Sub 아키텍처 구현 전략
- 4. 스케일 아웃을 위한 아키텍처 재설계 및 구현
- 4.1. 메시지 브로커 선정 및 고려사항 (Redis vs. Kafka)
- 4.2. 클라이언트-서버-브로커 간 메시지 흐름 재정의
- 4.3. 서버리스 웹소켓(AWS API Gateway, Lambda)과의 시너지 (선택적 고려)
- 5. 성공적인 전환 후 얻은 교훈과 성능 지표
- 5.1. 가용성과 확장성 확보의 중요성
- 5.2. 아키텍처 설계 시 트레이드오프와 미래 확장성
- 결론 및 요약
1. 새벽을 깨운 경고음: 대규모 웹소켓 연결 환경의 예측 불허 과부하
우리가 개발하던 서비스는 실시간 채팅, 알림, 그리고 사용자 간의 상호작용이 중요한 핵심 기능이었습니다. 초기에는 단일 서버로 충분했지만, 사용자가 폭증하면서 서버를 여러 대 증설하고 로드 밸런서(Load Balancer)를 도입하는 것은 자연스러운 수순이었습니다. 당시 우리는 웹 애플리케이션의 일반적인 세션 관리 방식과 유사하게, 웹소켓 연결 또한 특정 서버에 고정시키는 Sticky Session 방식을 선택했습니다. 사용자마다 고유한 세션 ID를 기반으로 로드 밸런서가 동일한 서버로 요청을 전달하여, 연결의 연속성을 보장하는 방식이었죠. 이 방식은 초기에는 별다른 문제 없이 작동하는 것처럼 보였습니다.
문제는 서비스의 성장과 함께 동시 접속자 수가 수십만 명을 넘어서면서 발생했습니다. 어느 날 새벽, 모니터링 시스템에서 경고음이 울리기 시작했습니다. 특정 웹소켓 서버 인스턴스들의 CPU 사용률이 90% 이상으로 치솟고, 메모리 사용량도 급증하는 현상이 관측되었습니다. 다른 인스턴스들은 비교적 안정적인 상태를 유지하고 있었지만, 과부하가 걸린 인스턴스들로 인해 서비스 응답 속도가 현저히 느려지고, 심지어는 연결이 끊기는 현상까지 발생했습니다. 사용자들은 불만을 토로하기 시작했고, 급기야 서비스의 신뢰도까지 위협받는 상황에 이르렀습니다.
1.1. 불균형한 서버 부하와 사용자 경험 저하
모니터링 대시보드를 보면, 로드 밸런서 뒤편의 웹소켓 서버 인스턴스 간 부하 분배가 심각하게 불균형했습니다. 어떤 서버는 CPU 사용률이 거의 100%에 육박하는 반면, 다른 서버는 30% 미만으로 한가로운 상태였습니다. 이는 명백히 로드 밸런싱의 실패를 의미했습니다. 과부하가 걸린 서버에 연결된 사용자들은 실시간 메시지 전송 지연, 연결 끊김 등의 문제를 겪었고, 이는 서비스의 핵심 가치인 '실시간성'을 심각하게 훼손하는 결과를 초래했습니다.
긴급하게 서버를 증설하고, 트래픽을 분산시키기 위해 여러 시도를 했지만, 일시적인 완화 효과만 있을 뿐 근본적인 해결책이 되지 못했습니다. 새로운 서버를 추가해도 결국 특정 서버로만 트래픽이 몰리는 현상은 반복되었습니다. 우리는 이 문제를 해결하지 않고서는 더 이상의 서비스 성장을 기대하기 어렵다고 판단했습니다. 이제는 근본적인 원인 분석과 함께 아키텍처 재설계가 절실한 시점이었습니다.
2. 문제의 핵심 탐구: Sticky Session, 그 매혹적인 함정
처음에는 로드 밸런서 설정이나 웹소켓 서버 애플리케이션 코드에 문제가 있을 것이라고 추측했습니다. 하지만 심층적인 분석 결과, 가장 큰 원인은 우리가 당연하게 사용하고 있던 Sticky Session 방식에 있었습니다. 웹소켓 연결의 특성을 충분히 고려하지 않은 선택이었던 것이죠.
2.1. Sticky Session의 작동 방식과 초기 이점
Sticky Session은 로드 밸런서가 클라이언트의 첫 요청을 특정 서버로 보낸 후, 이후 모든 요청을 동일한 서버로 고정하여 보내는 방식입니다. 이는 주로 HTTP 세션 관리를 위해 사용되며, 서버가 클라이언트의 상태 정보를 자체적으로 유지(Stateful)해야 하는 경우에 유용합니다. 웹소켓의 경우, 한번 연결이 수립되면 해당 연결은 장시간 유지되기 때문에, 연결이 끊어지지 않는 한 같은 서버와의 통신을 보장하는 것이 중요하다고 생각했습니다. 이는 초기 개발 단계에서 서버 간 상태 동기화 문제를 피하고 개발 복잡성을 줄이는 데 도움이 되었습니다.
| 특징 | Sticky Session | Non-Sticky (Stateless) |
|---|---|---|
| 세션 유지 방식 | 로드 밸런서가 특정 서버로 연결 고정 | 각 요청마다 임의의 서버로 분산 가능 |
| 서버 상태 관리 | 서버가 클라이언트 상태 유지 (Stateful) | 서버가 클라이언트 상태 유지 안 함 (Stateless) |
| 초기 개발 편의성 | 높음 (서버 간 상태 동기화 불필요) | 낮음 (분산 환경 고려 필요) |
| 로드 밸런싱 효율 | 대규모 환경에서 불균형 발생 위험 높음 | 높음 (연결 분산에 유리) |
| 장애 대응 | 해당 서버 다운 시 연결 손실 | 유연한 재연결 및 다른 서버로 전환 가능 |
2.2. 웹소켓 환경에서 Sticky Session이 야기하는 불균형
하지만 Sticky Session은 대규모 웹소켓 환경에서 다음과 같은 치명적인 문제점을 드러냈습니다:
- 불균형한 부하 분배 (Uneven Load Distribution): 웹소켓 연결은 HTTP 요청처럼 짧게 유지되는 것이 아니라, 수 시간 또는 수일 동안 지속될 수 있습니다. 특정 시점에 많은 사용자가 특정 서버로 연결되면, 해당 서버의 연결 수가 다른 서버보다 훨씬 많아지게 됩니다. Sticky Session은 이 연결을 계속 그 서버에 묶어두기 때문에, 연결 해제 전까지는 부하가 해소되지 않습니다. 이로 인해 트래픽이 적은 서버가 있어도, 과부하 서버의 부담은 줄어들지 않는 현상이 발생합니다.
- 스케일 아웃의 어려움 (Scalability Bottleneck): 새로운 서버 인스턴스를 추가해도, 기존 연결들은 여전히 과부하 서버에 고정되어 있습니다. 새 서버는 새로운 연결만을 처리하게 되므로, 즉각적인 부하 분산 효과를 얻기 어렵습니다. 과부하 서버의 연결 수가 줄어들려면 기존 연결들이 스스로 끊어지거나, 서버를 강제로 재시작해야 하는 비효율적인 상황이 발생합니다.
- 단일 장애 지점 (Single Point of Failure): 특정 서버에 많은 연결이 고정되어 있을 때, 해당 서버에 장애가 발생하면 그 서버에 연결된 모든 사용자가 일시에 서비스 중단을 겪게 됩니다. 이는 고가용성을 심각하게 저해하는 요소입니다.
- 자원 소모 비효율: 어떤 서버는 수십만 개의 웹소켓 연결을 유지하며 과부하에 시달리는 반면, 다른 서버는 몇 천 개의 연결만 유지하며 자원을 낭비하는 상황이 발생합니다. 이는 클라우드 환경에서 비용 효율성 측면에서도 큰 손실입니다.
결론적으로, Sticky Session은 웹소켓의 장기 연결 특성과 결합했을 때, 로드 밸런싱의 본래 목적을 달성하지 못하고 오히려 시스템의 가용성과 확장성을 저해하는 주범이었습니다. 우리는 Stateless하며 수평 확장이 용이한 새로운 아키텍처가 필요하다는 것을 깨달았습니다.
3. 근본적인 해결책 모색: Pub/Sub 패턴으로의 전환
Sticky Session의 한계를 명확히 인지한 후, 우리는 웹소켓 서버를 Stateful하지 않고 Stateless하게 만들 수 있는 방안을 모색했습니다. 즉, 어떤 웹소켓 서버가 어떤 클라이언트의 연결을 처리하든 상관없이 모든 서버가 동일한 메시지를 주고받을 수 있도록 하는 분산 시스템(Distributed System) 아키텍처가 필요했습니다. 이러한 요구사항에 가장 적합한 패턴이 바로 Pub/Sub(Publish/Subscribe) 패턴이었습니다.
3.1. Pub/Sub 패턴의 기본 원리와 웹소켓 적용 가능성
Pub/Sub 패턴은 메시지 발신자(Publisher)가 특정 채널(Channel)이나 토픽(Topic)으로 메시지를 발행(Publish)하면, 해당 채널을 구독(Subscribe)하고 있는 모든 메시지 수신자(Subscriber)에게 메시지가 전달되는 비동기 통신 모델입니다. 발신자와 수신자는 서로의 존재를 알 필요가 없으며, 메시지 브로커(Message Broker)가 이들의 중개자 역할을 수행합니다.
이 패턴을 웹소켓 환경에 적용하면 다음과 같은 이점을 얻을 수 있습니다:
- 서버의 Stateless화: 각 웹소켓 서버는 더 이상 클라이언트의 상태나 특정 연결 정보를 직접 관리할 필요가 없습니다. 모든 메시지는 메시지 브로커를 통해 전달되므로, 서버는 단순히 메시지를 수신하고 연결된 클라이언트에게 전송하는 역할만 수행합니다.
- 진정한 로드 밸런싱: Sticky Session을 제거하고 로드 밸런서가 연결을 무작위로 또는 최소 연결 수 기준으로 분산시키도록 설정할 수 있습니다. 어떤 서버에 연결되든, 모든 서버는 동일한 메시지 브로커를 통해 메시지를 주고받으므로 기능적 문제가 없습니다.
- 뛰어난 확장성: 웹소켓 서버 인스턴스를 필요에 따라 자유롭게 추가하거나 제거할 수 있습니다. 새로운 서버가 추가되면, 기존 메시지 브로커에 구독하여 즉시 서비스에 참여할 수 있습니다. 이는 수평 확장(Horizontal Scaling)을 극대화합니다.
- 강력한 장애 내성: 특정 웹소켓 서버에 장애가 발생하더라도, 해당 서버에 연결된 클라이언트만 영향을 받습니다. 클라이언트는 재연결 로직을 통해 다른 정상적인 서버로 연결될 수 있으며, 메시지 브로커는 여전히 메시지를 전달하고 있으므로 전체 시스템의 중단 없이 서비스 연속성을 유지할 수 있습니다.
3.2. Redis를 활용한 Pub/Sub 아키텍처 구현 전략
메시지 브로커 선택은 중요한 결정이었습니다. 우리는 다음과 같은 기준을 고려했습니다:
- 성능: 실시간 메시지 처리를 위해 낮은 지연 시간을 보장해야 합니다.
- 운영의 용이성: 기존 인프라와의 통합이 쉽고, 관리 부담이 적어야 합니다.
- 확장성: 메시지 처리량이 증가해도 안정적으로 확장 가능해야 합니다.
- 기능: Pub/Sub 기능 외에 다른 유용한 기능(예: 캐싱, 영속성)을 제공하면 좋습니다.
고려 끝에 우리는 Redis를 메시지 브로커로 선정했습니다. Redis는 인메모리 데이터 스토어로 매우 빠른 처리 속도를 자랑하며, 내장된 Pub/Sub 기능은 물론, 다른 캐싱 및 데이터 저장 기능도 함께 활용할 수 있다는 장점이 있었습니다. 또한, 클러스터 모드를 통해 고가용성과 확장성을 확보할 수 있다는 점도 매력적이었습니다.
새로운 아키텍처는 다음과 같은 형태로 구성되었습니다:
- 클라이언트: 웹소켓 연결을 통해 웹소켓 서버와 통신합니다.
- 로드 밸런서: Sticky Session을 해제하고, 라운드 로빈(Round Robin) 또는 최소 연결(Least Connections)과 같은 알고리즘으로 웹소켓 서버에 연결을 분산시킵니다.
- 웹소켓 서버 (Stateless): 클라이언트로부터 메시지를 받으면, 이를 Redis의 특정 채널로 발행(Publish)합니다. 또한, Redis의 특정 채널을 구독(Subscribe)하여 메시지가 발행되면, 해당 메시지를 관련된 클라이언트에게 전달합니다.
- Redis (Message Broker): 발행된 메시지를 구독 중인 모든 웹소켓 서버로 전달하는 역할을 합니다. Redis는 또한 사용자 세션 정보나 기타 공유 상태를 저장하는 캐시로도 활용될 수 있습니다.
- 백엔드 서비스: 사용자에게 메시지를 보내야 할 경우, 직접 Redis의 채널로 메시지를 발행합니다. 웹소켓 서버는 이를 받아 클라이언트에게 푸시합니다.
// 예시: Node.js 웹소켓 서버에서 Redis Pub/Sub 활용
const WebSocket = require('ws');
const Redis = require('ioredis');
const wss = new WebSocket.Server({ port: 8080 });
const redisPublisher = new Redis(); // 메시지 발행용 Redis 클라이언트
const redisSubscriber = new Redis(); // 메시지 구독용 Redis 클라이언트
// 특정 유저에게 연결된 웹소켓 객체를 관리 (각 서버 인스턴스에서)
const userSockets = new Map(); // Map<userId, WebSocket>
wss.on('connection', ws => {
console.log('Client connected');
// 클라이언트 연결 시 사용자 인증 및 ID 할당 (실제 구현에서는 JWT 등 활용)
const userId = 'user-' + Math.random().toString(36).substr(2, 9);
userSockets.set(userId, ws);
// 각 유저의 특정 채널을 구독
redisSubscriber.subscribe(`user:${userId}`, (err, count) => {
if (err) console.error('Failed to subscribe:', err);
console.log(`Subscribed to user:${userId} channel. Total subscriptions: ${count}`);
});
ws.on('message', message => {
console.log(`Received message from ${userId}: ${message}`);
// 클라이언트로부터 받은 메시지를 Redis의 'global_chat' 채널로 발행
redisPublisher.publish('global_chat', JSON.stringify({ sender: userId, message: message.toString() }));
});
ws.on('close', () => {
console.log(`Client ${userId} disconnected`);
userSockets.delete(userId);
redisSubscriber.unsubscribe(`user:${userId}`); // 연결 해제 시 구독 해제
});
ws.on('error', error => {
console.error('WebSocket error:', error);
});
});
// Redis로부터 메시지를 수신했을 때 처리
redisSubscriber.on('message', (channel, message) => {
console.log(`Received from Redis channel ${channel}: ${message}`);
const parsedMessage = JSON.parse(message);
// 'global_chat' 채널 메시지 처리 (모든 연결된 클라이언트에게 전송)
if (channel === 'global_chat') {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// 특정 유저 채널 메시지 처리
else if (channel.startsWith('user:')) {
const targetUserId = channel.split(':')[1];
const targetWs = userSockets.get(targetUserId);
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(message);
}
}
});
console.log('WebSocket server started on port 8080');
console.log('Redis Pub/Sub connected');
// 백엔드에서 특정 유저에게 메시지를 보내는 예시 (별도 스크립트 또는 서비스)
// redisPublisher.publish('user:user-xxxxxxxx', JSON.stringify({ type: 'notification', content: '새로운 알림이 도착했습니다!' }));
위 코드 예시는 Pub/Sub 패턴을 웹소켓 서버에 적용하는 기본적인 방식을 보여줍니다. 각 웹소켓 서버는 Redis에 연결하여 메시지를 발행하고 구독합니다. 이로써 클라이언트가 어떤 서버에 연결되든, 모든 서버가 Redis를 통해 동일한 메시지를 주고받을 수 있게 됩니다. 실제 구현에서는 사용자 인증, 채널 관리, 메시지 형식 정의 등 더 많은 고려사항이 필요합니다.
4. 스케일 아웃을 위한 아키텍처 재설계 및 구현
Pub/Sub 패턴을 핵심으로 하는 새로운 아키텍처는 다음과 같은 주요 구성 요소와 흐름으로 재설계되었습니다.
4.1. 메시지 브로커 선정 및 고려사항 (Redis vs. Kafka)
Redis를 Pub/Sub 메시지 브로커로 선택했지만, 대안으로 Apache Kafka도 심도 있게 검토했습니다. 각 브로커의 장단점은 다음과 같습니다.
| 기준 | Redis Pub/Sub | Apache Kafka |
|---|---|---|
| 메시지 영속성 | 없음 (메시지 발행 즉시 소멸) | 있음 (설정된 기간 동안 저장) |
| 처리 지연 시간 | 매우 낮음 (인메모리) | 낮음 (디스크 기반이지만 최적화) |
| 메시지 보장 | At-most-once (네트워크 문제 시 손실 가능) | At-least-once (중복 가능성, 강력한 보장) |
| 복잡성/운영 | 상대적으로 단순, 캐싱 기능과 통합 용이 | 높음 (클러스터 관리, 설정 등) |
| 주요 사용처 | 실시간 알림, 채팅 (메시지 손실 허용) | 로그 수집, 이벤트 스트리밍, 데이터 파이프라인 (메시지 보장 중요) |
우리의 서비스는 실시간 채팅과 알림이 주를 이루었으며, 메시지 손실이 치명적이지는 않고(클라이언트 재연결 시 일부 메시지 누락 감수), 무엇보다 매우 낮은 지연 시간이 중요했습니다. 또한, 기존에 Redis를 캐싱 용도로 사용하고 있었기 때문에 운영 복잡성을 최소화할 수 있다는 점도 큰 장점이었습니다. 따라서 Redis Pub/Sub을 선택했고, 메시지 손실에 대한 잠재적 위험은 클라이언트의 재연결 로직 강화와 메시지 재전송 메커니즘을 통해 보완하기로 했습니다.
4.2. 클라이언트-서버-브로커 간 메시지 흐름 재정의
재설계된 메시지 흐름은 다음과 같습니다.
- 클라이언트 연결: 클라이언트는 로드 밸런서를 통해 임의의 웹소켓 서버 인스턴스에 연결합니다. Sticky Session은 비활성화됩니다.
- 서버 구독: 웹소켓 서버는 연결된 클라이언트의 세션 ID, 또는 참여하고 있는 채팅방 ID 등을 기반으로 Redis의 특정 채널을 구독합니다. 예를 들어, 사용자 'A'가 채팅방 'X'에 입장하면, 'A'가 연결된 웹소켓 서버는 Redis에서 'chat:X' 채널과 'user:A' 채널을 구독합니다.
- 메시지 발행 (클라이언트 → 서버 → 브로커): 클라이언트가 메시지를 보내면, 해당 웹소켓 서버는 메시지를 받아 Redis의 관련 채널(예: 'chat:X')로 발행합니다.
- 메시지 전달 (브로커 → 서버 → 클라이언트): Redis는 'chat:X' 채널을 구독하고 있는 모든 웹소켓 서버로 메시지를 전달합니다. 이 메시지를 수신한 웹소켓 서버들은 각자 자신이 관리하는 연결 중 'chat:X'에 해당하는 클라이언트들에게 메시지를 전송합니다.
- 백엔드 서비스 연동: 백엔드 서비스(예: 새로운 알림 생성)가 특정 사용자에게 메시지를 보내야 할 경우, 해당 서비스는 직접 Redis의 'user:A' 채널로 메시지를 발행합니다. 그러면 'user:A' 채널을 구독하고 있는 웹소켓 서버가 메시지를 받아 'A'에게 전달합니다.
이러한 구조를 통해, 어떤 웹소켓 서버에 연결되든 클라이언트는 모든 관련 메시지를 받을 수 있게 됩니다. 또한, 웹소켓 서버는 클라이언트의 연결 상태 정보만을 유지하며, 메시지 라우팅 로직은 Redis가 담당하므로 Stateless한 아키텍처를 구현할 수 있었습니다.
4.3. 서버리스 웹소켓(AWS API Gateway, Lambda)과의 시너지 (선택적 고려)
우리는 기존 서버 인스턴스 환경을 유지했지만, 만약 처음부터 완전한 스케일 아웃과 관리 부담 최소화를 고려했다면 AWS API Gateway의 웹소켓 API 및 AWS Lambda를 활용하는 방안도 유력하게 검토했을 것입니다. 이 조합은 웹소켓 연결 관리 및 스케일링을 AWS가 전적으로 담당하므로, 개발팀은 메시지 처리 로직에만 집중할 수 있게 합니다. API Gateway는 연결 관리와 라우팅을 수행하고, Lambda 함수는 연결, 메시지 수신, 연결 해제 이벤트를 처리합니다. 메시지 브로커는 여전히 Redis(또는 DynamoDB)를 사용하여 Lambda 함수 간의 메시지 전달을 구현할 수 있습니다. 이는 특히 트래픽 변동성이 크고 운영 리소스가 제한적인 스타트업 환경에서 매력적인 선택지가 될 수 있습니다.
5. 성공적인 전환 후 얻은 교훈과 성능 지표
Sticky Session을 제거하고 Pub/Sub 패턴 기반의 Stateless 웹소켓 아키텍처로 전환한 후, 우리는 극적인 변화를 경험했습니다. 몇 주간의 개발, 테스트, 그리고 단계적인 배포 과정을 거쳐 새로운 시스템이 안정화되었을 때, 이전의 고통스러운 과부하 문제들은 사라졌습니다.
5.1. 가용성과 확장성 확보의 중요성
가장 눈에 띄는 변화는 서버 부하의 균형이었습니다. 모니터링 대시보드를 보면, 모든 웹소켓 서버 인스턴스들의 CPU 사용률과 연결 수가 균일하게 분산되는 것을 확인할 수 있었습니다. 특정 서버가 과부하에 시달리는 일은 더 이상 없었고, 평균 CPU 사용률은 70%에서 30% 미만으로 크게 감소했습니다. 이는 서버의 자원을 훨씬 효율적으로 사용하고 있음을 의미합니다.
또한, 스케일 아웃의 용이성이 비약적으로 향상되었습니다. 트래픽 증가가 예상되거나 특정 인스턴스의 부하가 높아질 때, 새로운 웹소켓 서버 인스턴스를 추가하면 즉시 로드 밸런서에 의해 트래픽이 분산되고, Redis 구독을 통해 기존 시스템에 완벽하게 통합되었습니다. 시스템의 가용성도 크게 개선되었습니다. 특정 서버에 장애가 발생하더라도, 로드 밸런서가 자동으로 해당 서버를 트래픽 분배 대상에서 제외하고, 클라이언트는 재연결 로직을 통해 다른 정상적인 서버로 연결되어 서비스 중단 시간을 최소화할 수 있었습니다.
5.2. 아키텍처 설계 시 트레이드오프와 미래 확장성
물론, Pub/Sub 패턴으로의 전환이 만능 해결책은 아닙니다. 이 과정에서 다음과 같은 트레이드오프를 고려해야 했습니다.
- 복잡성 증가: Sticky Session 방식에 비해 Redis와 같은 메시지 브로커를 추가로 도입하고 관리해야 하므로, 시스템의 복잡성이 증가합니다. Redis 클러스터 구성, 모니터링, 장애 대응 등 추가적인 운영 부담이 발생합니다.
- 메시지 손실 가능성: Redis의 Pub/Sub은 메시지 영속성을 보장하지 않습니다. 메시지가 발행되는 순간 구독하고 있는 클라이언트가 없거나, 네트워크 문제로 메시지 전달에 실패하면 해당 메시지는 손실될 수 있습니다. 이를 보완하기 위해 중요한 메시지(예: 결제 알림)는 별도의 영속성 저장소(DB)에 저장하고, 웹소켓 연결이 끊겼다가 다시 연결될 때 놓친 메시지를 동기화하는 로직을 추가해야 했습니다.
- 초기 개발 비용: Stateless 아키텍처는 초기 설계 및 구현에 더 많은 시간과 노력이 필요합니다.
하지만 장기적인 관점에서 볼 때, 가용성, 확장성, 그리고 운영의 안정성 측면에서 Pub/Sub 패턴 기반의 아키텍처는 훨씬 더 나은 선택이었습니다. 특히 대규모 서비스를 지향한다면, 이러한 초기 투자는 필수적이라고 생각합니다.
우리의 경험은 "단순한 것이 항상 좋은 것은 아니다"라는 교훈을 주었습니다. 초기에는 간단해 보였던 Sticky Session이 서비스가 성장하면서 치명적인 병목 현상을 일으켰습니다. 아키텍처 설계 시에는 현재의 요구사항뿐만 아니라, 미래의 트래픽 증가와 서비스 확장성을 깊이 있게 예측하고, 그에 맞는 견고한 기반을 다지는 것이 얼마나 중요한지 다시 한번 깨닫게 되었습니다.
결론 및 요약
대규모 웹소켓 연결 환경에서 Sticky Session은 서버 과부하와 스케일 아웃의 어려움이라는 예측 불가능한 문제를 야기할 수 있는 치명적인 함정입니다. 이는 웹소켓의 장기 연결 특성과 로드 밸런싱의 한계가 결합될 때 발생하는 전형적인 문제입니다. 이러한 문제를 해결하기 위한 가장 효과적인 방법은 웹소켓 서버를 Stateless하게 만들고, Redis와 같은 메시지 브로커를 활용한 Pub/Sub 패턴으로 전환하는 것입니다.
이 전환을 통해 우리는:
- 서버 간 부하를 균일하게 분산시켜 자원 활용 효율을 극대화했습니다.
- 필요에 따라 웹소켓 서버 인스턴스를 자유롭게 추가/제거할 수 있는 뛰어난 수평 확장성을 확보했습니다.
- 특정 서버 장애 시 전체 서비스 중단을 방지하고 고가용성을 보장했습니다.
물론, 이 과정에서 시스템 복잡성 증가와 메시지 영속성 부족과 같은 트레이드오프가 발생하지만, 이는 적절한 설계와 보완책으로 충분히 극복할 수 있습니다. 궁극적으로 이 경험은 아키텍처 설계 시 단기적인 편의성보다는 장기적인 확장성과 유연성, 그리고 가용성을 우선해야 한다는 중요한 교훈을 남겼습니다.
여러분은 대규모 웹소켓 환경에서 어떤 트러블슈팅 경험을 하셨나요? Sticky Session 외에 또 다른 함정이나, Pub/Sub 패턴 외에 다른 훌륭한 해결책이 있다면 댓글로 자유롭게 공유해주세요. 함께 더 나은 시스템을 만들어가는 데 도움이 될 것입니다!
📌 함께 읽으면 좋은 글
- [데이터 엔지니어링] Flink Checkpoint/Savepoint 실패: 데이터 유실 방지를 위한 5가지 핵심 진단 및 복구 전략
- [튜토리얼] 이미지 EXIF 메타데이터 방치하면 안 되는 이유: 서버 측 자동 제거 및 재작성 핵심 원리 파헤치기
- [보안] Stateful Firewall, 그 깊이를 파헤쳐보니: 세션 트래킹부터 NAT까지, 제가 직접 경험한 내부 동작 원리
이 글이 도움이 되셨다면 공감(♥)과 댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.
'튜토리얼' 카테고리의 다른 글
| 서비스 확장이 두려운 개발자를 위한 멀티테넌시 데이터베이스 설계 비밀 (0) | 2026.07.22 |
|---|---|
| 웹 성능의 핵심, 이미지 최적화: WebP와 AVIF 중 무엇을 선택해야 할까요? (0) | 2026.07.21 |
| 대용량 파일 업로드, 어떤 전략이 현명할까요? Pre-signed URL vs. 백엔드 프록시 비교 (0) | 2026.07.19 |
| 이미지 EXIF 메타데이터 방치하면 안 되는 이유: 서버 측 자동 제거 및 재작성 핵심 원리 파헤치기 (0) | 2026.07.18 |
| 복잡한 웹 UI, 게임 개발 ECS 패턴으로 50% 이상 관리 효율 높이는 3가지 방법 (0) | 2026.07.16 |