[채팅 서버] 채팅 서버 개선기 기록
Redis Adapter 가 모든 레디스 노드에 Broadcast 하는 문제
image.png
레디스 어댑터와 레디스를 활용하면 위와 같이 레디스 pub sub 을 통해서 이벤트를 전달하게 되는데, 문제는 여기서 redis-cluster 를 활용하게 되면 이벤트 전달을 어디에 해야할지 모르기 때문에 모든 레디스 노드에다가 전파하게 된다.
Redis Node A, B, C, D …. Z 까지 있을 때, A 에만 전달하면 되는 이벤트를 A~Z 까지 모두 전파하게 된다.
이를 해결하기 위해서는 ShardedAdapterhttps://redis.io/docs/latest/develop/interact/pubsub/#sharded-pubsub 를 활용해야 하는데, SharedAdapter 는 해시함수를 활용해서 데이터를 어떤 노드에 저장해야할지 알고 있다. 따라서 ‘room123’ 으로 메세지가 들어온다면 room123 의 해시값을 계산해서 해당 레디스 샤드군에만 전파한다.
그래서 ioredis + sharded adapter 를 활용하려고 보니까….
image.png
socket.io 공식문서 (https://socket.io/docs/v4/redis-adapter/#with-redis-sharded-pubsub)
현재 ioredis 와 redis cluster 를 활용한 sharded adapter 는 사용이 불가능하다고 한다.
실제로 sharded adapter 를 억지로 사용해보려고 하니 [**Too many cluster redirections during Redis cluster reshard**](https://stackoverflow.com/questions/46472130/too-many-cluster-redirections-during-redis-cluster-reshard)
라는 오류가 난다.
shardedAdapter 는 기존의 subscribe 커맨드가 아닌, (SSUBSCRIBE, SPUBLISH, SUNSUBCRIBE) 같은 커맨드를 사용하는데 ioredis 에서 해당 커맨드를 지원하지 않기 때문.
해결책으로는
- ioredis 를 node-redis 로 마이그레이션 + shardedAdapter 사용
- redis cluster 를 버리고 Nats.io 로 마이그레이션
- 파티셔닝을 통해서 모든 redis 를 redis cluster 로 묶는 것이 아니라, 실제로 필요한 redis 에만 publish 되도록 하기
ioredis 를 node-redis 로 마이그레이션 + shardedAdapter 사용
우선 1번의 경우 ioredis 를 사용하던 부분을 전부 node-redis 로 바꾸어야한다는 단점이 있다. 하지만 현재 ioredis 라이브러리 상태를 봤을 때 추가적인 업데이트도 없고 node-redis 의 경우에는 sharded adapter 도 업데이트를 해놓은걸 보면 바꾸는 것도 좋을 것 같다라는 생각이 들었다.
redis cluster 를 버리고 Nats.io 로 마이그레이션
2번의 경우에는
https://channel.io/ko/blog/articles/228efe0c
채널톡의 블로그에서 아주 잘 설명해주고 있다. Nats.io 은 완전 메시 형태로, 처리량은 redis 에 비해 월등히 좋은 수준을 보이지만 subject 가 생길 때 모든 nats.io 의 인스턴스에 pubsub 을 전파해주어야하는데 이 부분에서 오버헤드가 상당히 크게 작용한다.
파티셔닝을 통해서 모든 redis 를 redis cluster 로 묶는 것이 아니라, 실제로 필요한 redis 에만 publish 되도록 하기
3번의 경우도 채널톡에서 이미 진행했던 방식인데,
https://channel.io/ko/blog/articles/4ab1f0c2
레디스 클러스터의 문제점이 연관없는(굳이 publish 안보내도 되는) 노드에 publish 를 보낸다는 것이다. 그렇기 때문에 레디스와 채팅서버를 하나의 cluster 로 묶고, 이 클러스터 단위로 여러 개를 수평 확장해서 생성한다. 하나의 거대한 집합이었던 채팅서버 + Redis Cluster 를 조금 더 작은 여러개의 논리적 구조로 쪼개보는 것이다. 그리고 room ID 를 기반으로 이 서비스에 로드밸런싱 되도록 하는 구조이다.
하지만 채널톡 서비스와는 다르게 LiBoo 서비스에서는 SharedWorker 를 활용해서 클라이언트는 1개의 소켓만 생성하게 된다. 하나의 소켓으로 여러 개의 room 에 join 할 수 있다는 뜻인데, 3번처럼 파티셔닝을 하게 될 경우 소켓을 1개로 만든 이유가 사라지게 된다.
A 라는 채팅서버에 WebSocket 연결을 맺었는데 , 채팅 서버 B 에 있는 room 에 접근하려면 새로운 소켓을 생성해서 채팅 서버 B 에 연결해야하기 때문이다.
번외
채널톡 서비스처럼 만약 우리 서비스도 1개의 소켓이 1개의 room 에만 join 되는 것 처럼, 완벽하게 논리적으로 구조를 나눌 수 있었다면 어땠을지 테스트를 해보았다.
테스트 환경은 macos 에서 도커 컨테이너를 활용했다.
기존 환경
기존의 환경은 채팅서버 3대를 컨테이너로, 6대의 레디스 노드를 하나의 클러스터로 만든 레디스 클러스터를 컨테이너로 만들었다. 모두 각각의 docker bridge 네트워크를 공유하고 있다.
아래는 pubsub cluster 를 만들기 위해 사용한 docker-compose.yaml 과 config 파일이다.
yaml
version: "3"
services:
redis-pubsub-master-1:
platform: linux/x86_64 # m1 MacOS의 경우
image: redis:6.2
container_name: redis-pubsub-m1
networks:
redis-pubsub-network:
ipv4_address: 192.168.101.2
volumes: # 작성한 설정 파일을 볼륨을 통해 컨테이너에 공유
- ./redis-pubsub-master-7001.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- 7001:7001
redis-pubsub-master-2:
networks:
redis-pubsub-network:
ipv4_address: 192.168.101.3
platform: linux/x86_64
image: redis:6.2
container_name: redis-pubsub-m2
volumes:
- ./redis-pubsub-master-7002.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- 7002:7002
redis-pubsub-master-3:
networks:
redis-pubsub-network:
ipv4_address: 192.168.101.4
platform: linux/x86_64
image: redis:6.2
container_name: redis-pubsub-m3
volumes:
- ./redis-pubsub-master-7003.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- 7003:7003
redis-pubsub-master-4:
platform: linux/x86_64 # m1 MacOS의 경우
image: redis:6.2
container_name: redis-pubsub-m4
networks:
redis-pubsub-network:
ipv4_address: 192.168.101.5
volumes: # 작성한 설정 파일을 볼륨을 통해 컨테이너에 공유
- ./redis-pubsub-master-7004.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- 7004:7004
redis-pubsub-master-5:
networks:
redis-pubsub-network:
ipv4_address: 192.168.101.6
platform: linux/x86_64
image: redis:6.2
container_name: redis-pubsub-m5
volumes:
- ./redis-pubsub-master-7005.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- 7005:7005
redis-pubsub-master-6:
networks:
redis-pubsub-network:
ipv4_address: 192.168.101.7
platform: linux/x86_64
image: redis:6.2
container_name: redis-pubsub-m6
volumes:
- ./redis-pubsub-master-7006.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- 7006:7006
networks:
redis-pubsub-network:
driver: bridge
ipam:
config:
- subnet: 192.168.101.0/24 # 사용자 정의 서브넷
yaml
port 7001
# port : 7001 ~ 7006
maxmemory 256mb
maxmemory-policy volatile-lru
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
bind 0.0.0.0
파티셔닝 환경
파티셔닝 환경은 채팅 서버 3대, 레디스 노드 1대를 묶어서 docker stack 으로 만들었고 docker overlay 네트워크를 공유하고 있다.
아래는 파티셔닝 환경을 만들기 위해 작성한 docker-compose.yaml 이다.
yaml
version: '3.8'
services:
redis-master:
image: redis:6.2
command: ["redis-server", "/etc/redis/redis.conf"]
networks:
- shared-network
volumes:
- ./redis/master.conf:/etc/redis/redis.conf
deploy:
replicas: 1
chat-server:
image: chattest:latest
environment:
LOCAL_REDIS_CONFIG: '[{"host":"host.docker.internal","port":9001},{"host":"host.docker.internal","port":9002},{"host":"host.docker.internal","port":9003}]'
REDIS_PUBSUB_CONFIG: '{"host":"redis-master","port":6379}'
networks:
- shared-network
deploy:
replicas: 3 # 3개의 복제본을 생성합니다.
update_config:
parallelism: 1 # 한 번에 하나의 컨테이너만 업데이트하도록 설정
networks:
shared-network:
external: true
driver: overlay
LOCAL_REDIS_CONFIG 는 캐시용 레디스이므로 지금 진행중인 테스트랑은 무관하다.
결과
- 기존 환경(채팅서버 3대와 redis cluster), 파티셔닝 X
image.png
image.png
(그래프의 20:38 ~ 20:41)
채팅 서버에 150명의 유저가 3번의 채팅(일반채팅 2번 + 질문채팅 1번, ‘hello’ 라는 내용으로)을 보냈을 때, 각각의 PubSub 컨테이너는 송수신에 5MB 정도씩의 네트워크 사용률을 보였다. (총 30MB 정도)
- 채팅서버 1대 + 레디스 1대로 파티셔닝하여 여러 개의 서비스로 분리
image.png
아무래도 이벤트 자체를 다른 레디스 노드에 publish 하지 않다보니 네트워크 사용량이 많이 줄었다.
보내는 데이터가 1.6MB, 받는 데이터는 0.6MB 정도 되는 것 같다.
다만 파티셔닝 환경은 파티셔닝된 구조에 ‘골고루’ 데이터가 들어온다는 전제로 진행했다보니 당연히 안정적으로 보인다.
아무래도 하나의 (채팅서버 + 레디스) 에 트래픽이 몰린다면 이 구조도 추가 확장이 필요해보인다.
Default Room 을 나가면 socket.io 의 Broadcast 로직이 바뀌는 문제
socket.io 의 Default Room 을 나가면 Sender 를 포함해서 Broadcast 되는 현상
- socket.io 는 채팅을 하기 위해
room
에 join 하게 됩니다. 같은 room 에 있는 클라이언트는 같은 채팅방에 있는 시스템입니다. - socket.io 는 유저의 소켓 연결을 할 때, socket.id 와 동일한 이름의 room (Default Room) 에 자동으로 join 하게 됩니다. 이는 1:1 채팅이나 서버에서 개인에게 공지를 보내는 등의 용도로 사용하게 됩니다.
- 그러나 저희 서비스는 위와 같은 1:1 채팅이 없는 실시간 라이브 방송의 채팅이므로 이러한 Default Room 은 사용되지 않은 채 서버의 메모리를 낭비할 여지가 있습니다.
- 따라서 소켓 연결을 할 때 Default Room 에 join 하지 않도록 로직을 수정했는데 연관이 없다고 생각했던 broadcast 로직이 바뀌는 현상이 발생했습니다.
- broadcast 는 기본적으로 sender 를
제외
하고 다른 사람들에게 이벤트를 보내는 로직인데, Default Room 에서 나간 채로 broadcast 를 하게 되면 Sender 를포함
해서 이벤트를 보내는 로직으로 바뀌는 문제가 발생했습니다. (관련 이슈 🔗)
문제를 해결하기 위해 3가지 정도의 방법을 생각해보았습니다.
- [socket.io](http://socket.io/) 대신 WebSocket 을 사용하면서 room + broadcast 개념을 직접 구현
- [socket.io](http://socket.io/) 의 room 개념을 그대로 유지한 채로 broadcast 로직을 새롭게 구현
- [socket.io](http://socket.io/) 의 broadcast 로직을 직접 수정
이 중에서 broadcast 로직을 직접 수정하는 것이 근본적인 문제를 해결할 수 있는 가장 좋은 방법이라 생각했습니다. 그래서 첫 번째로 프로젝트에서 Default Room 을 수정했을 때 Broadcast 로직이 수정되었으므로 room 에서 나가는 로직과 broadcast 간의 관계에 대해 학습했습니다. broadcast 의 로직을 직접 보기 전까지는 sender 를 제외시키는 과정이 sender 의 [socket.id](http://socket.id/) 를 제외하는 방식일거라 생각했는데 실제로 확인해본 결과 [socket.id](http://socket.id/) 와 같은 room (default room)을 제외시키는 방식이었습니다. default room 을 나가게 되면 해당 room 자체가 사라져버리므로 sender 를 제외시킬 수 없는 문제였습니다.
따라서 만약 room 이 존재하지 않는다면 연결되어 있는 [socket.id](http://socket.id/) 에서 default room 과 같은 [socket.id](http://socket.id/) 가 존재하는지의 여부에 따라 제외할 수 있도록 로직을 추가했습니다.
Subscribe to Liboo.blog
Get the latest posts delivered right to your inbox