최근 실시간 다중 사용자 퀴즈 게임을 구현하면서 단순하게 서버 1대와 인메모리(이하 세션)를 이용한 방식으로 웹소켓을 이용해 게임 진행과 채팅 기능을 구현했었는데요.
당시 부하 테스트 결과로 초당 5,000건 이상의 메시지 처리가 가능했기 때문에, 목표로 했던 300명 이상의 참여자 동시 진행이 가능했습니다.
하지만 게임에 참여하는 사용자가 많아지면 현재 상황에서는 분명 한계가 있겠죠? 그래서 웹소켓 서버의 처리량을 늘리는 방법들에 대해서 알아보려 합니다.
수평 확장과 수직 확장
시스템의 성능을 향상시킬 때 기본적으로 수평 확장과 수직 확장 두 가지 접근 방식을 고려할 수 있습니다.
수직 확장 (Scale Up)
단일 서버의 성능을 향상시키는 방법입니다. CPU, 메모리, 디스크 등 하드웨어 리소스를 증설하여 성능을 개선합니다.
- 장점:
- 구현이 간단하며 추가적인 아키텍처 변경이 필요 없음
- 단일 서버이므로 데이터 일관성 유지가 쉬움
- 네트워크 복잡도가 낮음
- 단점:
- 하드웨어 비용이 기하급수적으로 증가
- 물리적인 한계가 존재
- 장애 발생 시 전체 시스템에 영향
수평 확장 (Scale Out)
동일한 서버를 여러 대 추가하여 부하를 분산시키는 방법입니다.
- 장점:
- 선형적인 비용 증가로 효율적
- 무한한 확장이 이론적으로 가능
- 고가용성과 장애 허용성 확보
- 단점:
- 아키텍처가 복잡해짐
- 데이터 일관성 유지가 어려움
- 네트워크 오버헤드 발생
수직 확장은 단순히 서버 스펙을 올리는 것으로 한계가 명확하기 때문에(비싸기 때문에), 수평 확장 위주로 성능을 개선하는 것이 바람직해보입니다.
이제 수평 확장을 위한 구체적인 방법들을 살펴보겠습니다.
웹소켓의 특성
먼저 여러 시도를 하기 전 웹소켓의 연결 특성에 대해 이해할 필요가 있습니다.
웹소켓은 클라이언트와 서버 간에 지속 연결 상태를 유지하므로, 수평 확장 시 각 서버에 클라이언트 연결이 분산됩니다.
이를 해결하기 위해 서버 간 상태를 동기화하거나 상황에 맞게 클라이언트가 특정 서버에 고정되도록 설정해야 합니다.
로드 밸런싱
수평 확장을 통해 서비스의 요청을 분산하기 위해 여러 대의 서버를 배포하면 실제로 배포된 여러 서버로 요청을 분산해야겠죠
로드 밸런싱은 들어오는 네트워크 트래픽을 여러 서버에 효율적으로 분산하는 기술입니다.
Sticky Sessions vs Non-Sticky Sessions
네트워크 트래픽을 분산하기 위한 여러 알고리즘이 존재하는데, 클라이언트 요청이 같은 서버로 가는지 아닌지에 따라 나눌 수 있습니다.
Non-Sticky Sessions은 클라이언트의 요청이 매번 다른 서버로 자유롭게 라우팅될 수 있는 방식으로 stateless 아키텍처를 구현할 때 선호되는 방식입니다.
주요 차이점을 비교하면:
- 상태 관리
- Sticky Sessions: 서버가 클라이언트의 상태를 메모리에 보관
- Non-Sticky Sessions: 모든 상태를 Redis 같은 외부 저장소에 보관
- 장애 대응
- Sticky Sessions: 서버 장애 시 해당 서버의 모든 세션 손실
- Non-Sticky Sessions: 서버 장애와 무관하게 세션 유지 가능
- 확장성
- Sticky Sessions: 특정 서버에 부하가 집중될 수 있음
- Non-Sticky Sessions: 더 균등한 부하 분산 가능
웹소켓의 경우 연결 자체가 stateful하기 때문에 일반적으로 Sticky Sessions을 사용하지만, 대규모 시스템에서는 Non-Sticky Sessions 방식을 채택하고 모든 상태를 외부 저장소에 보관하는 방식을 사용하기도 합니다.
서버 간 상태 동기화
저희 서비스에서는 사용자가 퀴즈존을 기반으로 서버의 세션을 통해 관리되기 때문에, 퀴즈존에 참여하고 있는 모든 사용자가 동일한 서버에 연결되어야 합니다.
이는 상황에 따라 사용자가 많이 참여하고 있는 퀴즈존이 특정 서버에 집중되는 경우 수평 확장의 장점을 가져갈 수 없게 되는 문제를 발생시킬 수 있습니다.
이렇게 특정 서버에 세션에 의존하지 않게 만들기 위해 서버 간의 상태를 동기화 해야할 필요가 있습니다.
단순히 서버 간의 상태를 동기화 한다면, 모든 사용자에 대한 정보를 모든 서버가 메모리로 가져야하고 이를 수시로 업데이트 해야하는 문제가 있습니다.
이는 배포된 각각의 서버의 메모리가 충분해야는 것이 기본 전제이므로 메모리로 인한 서버 비용 절감은 의미가 없어질 뿐 아니라 상태 동기화를 위한 여러 오버헤드가 발생하므로 효과적인 방식으로 보긴 어렵습니다.
그래서 더 단순한 방법으로 배포된 여러 서버가 같은 세션을 바라보게 만드는 방법을 활용하는 경우가 많고, 세션은 빠른 읽기/쓰기가 요구되므로 주로 Redis
를 활용합니다.
Pub/Sub 패턴 적용
위처럼 서버 간의 상태를 동기화했다고 하더라도, 웹소켓의 특성상 특정 서버에 연결된 클라이언트에게만 직접 메시지를 전송할 수 있습니다. 다른 서버에 연결된 클라이언트에게는 직접적인 메시지 전송이 불가능한 것이죠.
이 문제를 해결하기 위해 메시지 브로커를 활용한 Pub/Sub 패턴을 적용할 수 있습니다.
- 각 서버는 메시지 브로커(예: Redis, RabbitMQ, Kafka)의 채널을 구독(Subscribe)
- 메시지 전송이 필요할 때는 해당 채널에 메시지를 발행(Publish)
- 모든 서버가 메시지를 수신하고, 자신에게 연결된 클라이언트에게 필요한 메시지를 전달
이러한 방식을 통해 서버 간 직접적인 통신 없이도 모든 클라이언트에게 메시지를 전달할 수 있습니다.
Auto Scaling
리소스를 효율적으로 관리하기 위해, 트래픽에 따라 서버를 동적으로 확장하고 축소하는 것이 이상적입니다.
- 저희 서비스도 근본적으로는 게임이라서 저녁 시간대에 트래픽이 집중되지 않을까 예상해봅니다.
하지만 웹소켓 서버의 동적 확장 시에는 몇 가지 고려해야 할 문제들이 있습니다.
서버 추가
새로운 서버가 추가되었을 때, 기존 클라이언트들은 이미 다른 서버들과 연결이 되어있는 상태입니다. 이로 인해 새로운 서버는 새로 접속하는 클라이언트의 연결만 받게 되어 서버 간 부하 분산이 균형있게 이루어지지 않을 수 있습니다.
이를 해결하기 위해서는 다음과 같은 방법들을 고려할 수 있습니다:
- 연결 재분배 (Connection Rebalancing)
- 주기적으로 서버 간 연결 수를 확인하고 불균형이 발생하면 일부 클라이언트에게 재연결 요청
- 클라이언트는 재연결 시 로드밸런서를 통해 새로운 서버에 고르게 분배됨
- 가중치 기반 라우팅
- 새로운 서버에 더 높은 가중치를 부여하여 신규 연결을 더 많이 할당
- 점진적으로 서버 간 연결 수가 균형을 이루도록 조정
서버 제거
서버를 제거할 때는 해당 서버에 연결된 클라이언트들의 연결을 적절히 처리해야 합니다. 갑작스러운 연결 종료는 사용자 경험을 해칠 수 있으며, 서비스의 안정성에도 영향을 미칩니다.
이를 위한 해결 방안으로는:
- Draining Mode 구현
- 서버 제거 전에 해당 서버를 ‘draining’ 상태로 전환
- 더 이상 새로운 연결을 받지 않도록 로드밸런서 설정 변경
- 기존 연결은 유지하면서 자연스럽게 감소하도록 유도
- Graceful Shutdown with Notification
- 종료 예정인 서버의 클라이언트들에게 재연결 필요성을 알리는 메시지 전송
- 클라이언트는 메시지 수신 후 다른 서버로 재연결 시도
- 일정 시간 후 남아있는 연결을 정상적으로 종료
ZooKeeper
이러한 처리들의 원활한 관리를 위해 서버들의 상태를 효과적으로 관리하고 모니터링하는 것이 중요합니다. Apache ZooKeeper와 같은 도구를 활용하면 이러한 분산 환경에서의 서버 관리를 효율적으로 수행할 수 있습니다.
- 서버 상태 관리
- 각 서버는 ZooKeeper에 임시 노드(ephemeral node)를 생성하여 자신의 상태를 등록
- 서버 장애 시 노드가 자동으로 삭제되어 빠른 장애 감지 가능
- 현재 활성화된 서버 목록을 실시간으로 파악 가능
- 부하 분산 정보 공유
- 각 서버의 현재 연결 수, 리소스 사용량 등을 ZooKeeper에 저장
- 로드밸런서는 이 정보를 바탕으로 최적의 서버 선택 가능
- 동적 가중치 조정에 활용
- 설정 정보 관리
- 서버 구성 정보, 환경 설정 등을 중앙화된 저장소에서 관리
- 설정 변경 시 모든 서버에 실시간으로 반영 가능
- 일관된 설정 유지 가능
Auto Scaling과의 연계
ZooKeeper를 Auto Scaling과 연계하면 더욱 효과적인 서버 관리가 가능합니다:
- 스케일 인/아웃 이벤트 발생 시 서버 목록 자동 업데이트
- 새로운 서버 추가 시 기존 서버들과의 설정 동기화
- 서버 제거 시 안전한 종료 절차 조율
- 서버 간 부하 분산 상태 모니터링 및 재조정
이러한 ZooKeeper의 활용은 특히 Auto Scaling 환경에서 서버들의 동적인 변화를 안정적으로 관리하는 데 큰 도움이 될 수 있고, 서버의 추가와 제거가 자주 발생하는 환경에서도 서비스의 안정성과 가용성을 높은 수준으로 유지할 수 있다는 장점도 있습니다.
정리
웹소켓 기반의 실시간 게임 서비스를 확장하면서 발생할 수 있는 문제들과 해결 방안들을 살펴보았습니다.
- 서버 확장성 문제
- 수평적 확장을 통한 처리량 증가
- 상태 관리 문제
- Redis를 활용한 세션 저장소 외부화
- 메시지 전달 문제
- Pub/Sub 패턴 도입
- 메시지 브로커를 통한 효율적인 메시지 전파
- 동적 확장 문제
- 서버의 동적 추가, 제거로 인한 문제
- ZooKeeper 활용
연결이 유지되는 특성 때문에 발생하는 문제들이 많았네요
어떤 방식들을 적용해야할지 고민을 더 해봐야겠습니다.
끝까지 읽어주셔서 감사합니다😊