이번 시간에는 Python WebSocket 채팅 서버 두 대와 NGINX, Redis로, 서버를 늘렸을 때 다른 서버의 사용자가 메시지를 못 받는 문제를 재현하고 Redis Pub/Sub와 Streams로 풀어 보며 배워가는 시간입니다.
먼저 이 문제를 그대로 재현해 볼게요. 그다음 Redis Pub/Sub로 서버 간에 메시지를 넘겨줄 때 구독이 끊긴 사이 메시지가 사라지는 과정을 보고, Redis Streams로 바꿔 놓친 메시지를 다시 채우는 방법을 확인합니다.
실습 준비
이 글의 명령은 모두 실습 코드의 chat-scale-out/ 디렉터리에서 실행합니다.
Dockerfile: 채팅 서버와 측정 클라이언트가 함께 쓰는 Python 3.12 이미지compose.yaml:nginx,chat1,chat2,redis컨테이너와 측정용bench서비스nginx.conf:/wsWebSocket 연결을 두 서버에 라운드로빈으로 나누는 NGINX 설정server.py: 환경 변수MODE에 따라 메시지를 퍼뜨리는 방식을 바꾸는 채팅 서버bench.py: 사용자 10명을 접속시켜 메시지 100건을 보내고 서버별 수신 수를 세는 측정 클라이언트
compose 프로젝트는 chat 하나에 컨테이너 네 개를 띄워요.
nginx: NGINX 1.30.5. 클라이언트가 접속하는 곳이에요(호스트 7270번 포트).chat1,chat2: 같은 코드로 띄운 채팅 서버 두 대예요(7271, 7272번 포트). 주소는10.72.70.11,10.72.70.12로 고정해서 NGINX 로그에서 바로 구분되게 했어요.redis: Redis 8.10.2예요(7273번 포트).
NGINX의 access log에는 클라이언트 주소와 포트, 연결을 넘겨받은 서버 주소($upstream_addr), 연결이 유지된 시간을 남깁니다.
서버는 /ws?room=lobby로 접속을 받고, 이 서버에 붙은 연결은 conns에 넣어 두었다가 보낼 메시지가 생기면 deliver로 이 연결들에 보냅니다. 받은 메시지를 어떻게 퍼뜨릴지는 환경 변수 MODE로 골라요.
none: 이 서버의 연결에만 보내요.pubsub: Redis 채널에PUBLISH하고, 구독으로 받은 메시지를 이 서버의 연결에 보내요.streams: Redis Stream에XADD로 남기고,XREAD로 읽은 메시지를 이 서버의 연결에 보내요.
Redis 연결은 용도마다 따로 만들고 chat2-sub처럼 이름을 붙여요. 그래야 그 연결만 골라 끊을 수 있거든요. redis-py 8.1은 기본으로 연결 오류가 나면 최대 10번까지 다시 시도하는데, 그대로 두면 서버 코드가 끊긴 시점을 알기 어려워서 재시도를 끄고 다시 붙는 일은 서버 코드가 직접 하게 했어요.
bench.py는 같은 방에 사용자 10명을 NGINX로 접속시키고, chat1에 붙은 첫 사용자가 번호를 붙인 메시지 100건을 0.05초 간격으로 보내요. 다 보낸 뒤 2초 기다렸다가 서버별로 사용자들이 받은 건수를 세고, 빠진 번호나 0.5초 넘게 늦게 도착한 번호가 있으면 사용자별로 보여 줘요. --kill 이름을 주면 발신 도중(기본 40번을 보낸 직후, 번호는 --kill-at으로 바꿈) 그 이름의 Redis 연결을 CLIENT KILL로 끊어요.
restart_as는 Stream 키를 지우고 두 서버를 주어진 모드로 다시 띄워요. NGINX도 다시 시작해서 라운드로빈이 매번 chat1부터 시작하게 합니다. nginx_log, server_logs는 NGINX 접속 로그와 두 서버의 로그를 보여 줘요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
docker compose build
docker compose up -d
bench() { docker compose run --rm -T bench python bench.py "$@"; }
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
restart_as() {
docker compose stop chat1 chat2 > /dev/null 2>&1
rcli DEL room:lobby > /dev/null
MODE=$1 docker compose up -d --force-recreate chat1 chat2 > /dev/null 2>&1
docker compose restart nginx > /dev/null 2>&1
nginx_log_clear
sleep 2
}
nginx_log() { docker compose exec -T nginx cat /var/log/nginx/ws.log; }
nginx_log_clear() { docker compose exec -T nginx sh -c ': > /var/log/nginx/ws.log'; }
server_logs() { docker compose logs --no-log-prefix chat1 chat2 | sort; }
서버 로그의 시간은 컨테이너 기준(UTC)이에요.
실습 1: WebSocket 연결은 한 서버에 머문다
WebSocket은 HTTP 요청으로 시작해요. 클라이언트가 Upgrade: websocket 헤더를 붙인 GET 요청을 보내고, 서버가 101 Switching Protocols로 답하면 그 TCP 연결은 그때부터 WebSocket 메시지를 주고받는 통로가 돼요. NGINX는 핸드셰이크 요청이 들어올 때 서버를 한 번 고르고, 그 뒤로는 양쪽 바이트를 그대로 넘겨요.
서버 간 전달이 없는 none 모드로 띄우고 측정해 볼게요.
restart_as none
bench
nginx_log
접속: user0=chat1 user1=chat2 user2=chat1 user3=chat2 user4=chat1 user5=chat2 user6=chat1 user7=chat2 user8=chat1 user9=chat2
발신: user0 (chat1), 100건, 0.05초 간격
chat1: 사용자 5명, 수신 500/500 (100%)
chat2: 사용자 5명, 수신 0/500 (0%)
10.72.70.4:56788 -> 10.72.70.11:8765 status=101 time=7.391
10.72.70.4:56800 -> 10.72.70.12:8765 status=101 time=7.391
10.72.70.4:56810 -> 10.72.70.11:8765 status=101 time=7.389
10.72.70.4:56812 -> 10.72.70.12:8765 status=101 time=7.389
10.72.70.4:56826 -> 10.72.70.11:8765 status=101 time=7.389
10.72.70.4:56836 -> 10.72.70.12:8765 status=101 time=7.387
10.72.70.4:56850 -> 10.72.70.11:8765 status=101 time=7.387
10.72.70.4:56854 -> 10.72.70.12:8765 status=101 time=7.386
10.72.70.4:56862 -> 10.72.70.11:8765 status=101 time=7.385
10.72.70.4:56868 -> 10.72.70.12:8765 status=101 time=7.384
- NGINX는 연결 10개를 chat1(
10.72.70.11)과 chat2(10.72.70.12)에 번갈아 5개씩 넘겼어요. status=101은 핸드셰이크가 성공했다는 뜻이고,time은 연결이 열려 있던 시간이에요. 측정이 7초 남짓 걸렸으니 연결 하나가 측정 내내 같은 서버에 붙어 있었던 거죠.- chat1은 자기 연결 5개에만 보냈고, chat2의 사용자 5명은 0건을 받았어요.
서버마다 자기에게 붙은 WebSocket 연결만 알고 있어서, chat1이 받은 메시지를 chat2의 사용자에게 보낼 방법이 없기 때문이에요. 서버를 늘리면, 어느 서버가 메시지를 받든 나머지 서버에도 그 메시지를 넘겨줄 통로가 필요해요.
실습 2: Pub/Sub 구독이 끊긴 사이 메시지는 사라진다
가장 단순한 통로는 Redis Pub/Sub예요. 서버는 받은 메시지를 room:lobby 채널에 PUBLISH하고, 모든 서버가 같은 채널을 구독하다가 받은 메시지를 자기 연결에 보내요.
PUBLISH는 메시지를 받은 구독자 수를 돌려줘요. 서버는 이 값이 바뀔 때만 로그를 남겨요.- 구독 연결이 끊기면
RESUB_DELAY(1초) 뒤에 새 연결로 다시 구독해요.
운영 중에는 서버와 Redis 사이 연결이 끊길 수 있어요. 40번을 보낸 직후 chat2의 구독 연결을 CLIENT KILL로 끊어서 이 상황을 만들어 볼게요.
restart_as pubsub
bench --kill chat2-sub --kill-at 40
server_logs
접속: user0=chat1 user1=chat2 user2=chat1 user3=chat2 user4=chat1 user5=chat2 user6=chat1 user7=chat2 user8=chat1 user9=chat2
발신: user0 (chat1), 100건, 0.05초 간격
seq 40: CLIENT KILL ID 168 (chat2-sub)
chat1: 사용자 5명, 수신 500/500 (100%)
chat2: 사용자 5명, 수신 405/500 (81%)
user1(chat2): 빠진 번호 41-59 (19건)
user3(chat2): 빠진 번호 41-59 (19건)
user5(chat2): 빠진 번호 41-59 (19건)
user7(chat2): 빠진 번호 41-59 (19건)
user9(chat2): 빠진 번호 41-59 (19건)
09:15:07 chat1 listening, MODE=pubsub
09:15:07 chat1 subscribed room:lobby
09:15:07 chat2 listening, MODE=pubsub
09:15:07 chat2 subscribed room:lobby
09:15:23 chat1 PUBLISH seq=1 receivers=2
09:15:25 chat1 PUBLISH seq=41 receivers=1
09:15:25 chat2 subscriber lost: ConnectionError
09:15:26 chat1 PUBLISH seq=60 receivers=2
09:15:26 chat2 subscribed room:lobby
- chat2의 사용자 5명이 모두 41번부터 59번까지 19건을 받지 못했어요.
- chat1의 로그를 보면 41번을 발행할 때 받은 구독자 수가 2에서 1로 떨어졌고, 60번부터 다시 2가 됐어요. 빠진 번호 구간과 정확히 겹치죠.
- chat2가 다시 구독한 뒤의 60번부터는 정상으로 받았어요. 빠진 19건은 그 뒤에도 끝내 오지 않았습니다.
같은 실험을 세 번 했고, 빠진 구간은 41~59번, 41~58번, 41~59번이었어요.
Redis 문서는 Pub/Sub의 전달 방식을 at-most-once라고 설명해요. 구독자가 그 순간 메시지를 처리하지 못하면 그 메시지는 사라지고, PUBLISH가 돌려준 구독자 수로 누군가 못 받았다는 것은 알 수 있어도 다시 보낼 방법은 없습니다.
실습 3: Streams로 바꾸면 서버는 마지막 ID부터 다시 읽는다
Redis Stream은 메시지를 ID와 함께 남겨요. 읽어도 지워지지 않으니까, 끊겼던 서버는 마지막으로 받은 ID 다음부터 다시 읽으면 돼요.
- 메시지를 받은 서버는
XADD room:lobby MAXLEN ~ 10000 * m <메시지>로 Stream에 남겨요.~는 길이를 대략 1만 건 근처로 유지하라는 뜻이에요. - 서버마다
XREAD BLOCK 1000 COUNT 100 STREAMS room:lobby <last_id>를 반복해요.last_id보다 큰 ID의 엔트리를 돌려주고, 없으면 최대 1초 기다려요. - 시작할 때는 Stream의 마지막 엔트리 ID(
XREVRANGE ... COUNT 1)를, Stream이 비어 있으면0-0을 시작점으로 써요.
실습 2와 똑같이, 이번에는 chat2의 XREAD 연결(chat2-xread)을 40번 직후에 끊어 볼게요.
restart_as streams
bench --kill chat2-xread --kill-at 40
server_logs
접속: user0=chat1 user1=chat2 user2=chat1 user3=chat2 user4=chat1 user5=chat2 user6=chat1 user7=chat2 user8=chat1 user9=chat2
발신: user0 (chat1), 100건, 0.05초 간격
seq 40: CLIENT KILL ID 190 (chat2-xread)
chat1: 사용자 5명, 수신 500/500 (100%)
chat2: 사용자 5명, 수신 500/500 (100%)
user1(chat2): 0.5초 넘게 늦은 번호 41-49 (최대 1.0초)
user3(chat2): 0.5초 넘게 늦은 번호 41-49 (최대 1.0초)
user5(chat2): 0.5초 넘게 늦은 번호 41-49 (최대 1.0초)
user7(chat2): 0.5초 넘게 늦은 번호 41-49 (최대 1.0초)
user9(chat2): 0.5초 넘게 늦은 번호 41-49 (최대 1.0초)
09:17:39 chat1 listening, MODE=streams
09:17:39 chat1 read from 0-0
09:17:39 chat2 listening, MODE=streams
09:17:39 chat2 read from 0-0
09:18:00 chat2 reader lost: ConnectionError
09:18:01 chat2 resume from 1790846280564-0
- 두 서버의 사용자 모두 100건을 받았어요.
- chat2의 사용자들은 41번부터 49번까지를 최대 1초 늦게 받았어요. 끊긴 동안 쌓인 엔트리를 다시 연결한 직후 한꺼번에 읽어서 보냈기 때문이에요.
- chat2는
resume from뒤의 ID부터 다시 읽었어요.
반복한 세 번 모두 수신률이 100%였고, 늦게 도착한 구간은 매번 41~49번에 최대 1.0초였어요.
정리
범위: NGINX 1.30.5, Redis 8.10.2, websockets 17.1, redis-py 8.1.0, 채팅 서버 2대, 사용자 10명, 메시지 100건, 끊김 실험은 3번씩.
- 서버 간 전달 없이 두 대로 늘리자 발신자가 붙은 chat1의 사용자만 100건을 받았고 chat2의 사용자 5명은 0건이었어요. 서버를 늘리면 서버 간에 메시지를 넘겨줄 통로를 두세요.
- Pub/Sub에서 chat2의 구독 연결을 40번 직후 끊자 chat2 사용자 5명이 41~59번 19건을 받지 못했고, 다시 구독한 뒤에도 끝내 오지 않았어요. 놓치면 안 되는 메시지를 Pub/Sub만으로 넘기지 마세요.
- 같은 끊김에서 Streams는 세 번 모두 수신률 100%였고, 41~49번만 최대 1.0초 늦게 도착했어요. 서버 간 전달은 서버마다 마지막으로 받은 ID부터
XREAD로 다시 읽게 하세요.