이번 시간에는 Python WebSocket 채팅 서버와 Redis Stream으로, 마지막 메시지 ID를 넘겨 이어 받는 재접속 방식이 깨지는 경우를 하나씩 재현해 보며 배워가는 시간입니다.
채우기와 실시간 전달이 만나는 경계에서 메시지가 빠지거나 두 번 가는 경우, 마지막 메시지 ID가 Stream에서 잘려 나간 경우, 응답을 못 받고 다시 보낸 메시지가 두 번 저장되는 경우를 차례로 볼게요.
이어 받기 방식
클라이언트는 마지막으로 받은 메시지 ID를 접속 URL의 ?last_id=로 넘겨요. last_id를 받은 서버는 last_id 다음부터 Stream을 XRANGE로 읽어 채워 보내고, 채운 메시지를 보내기 전에 resume을 먼저 보냅니다.
{"type": "resume", "status": "ok"}:last_id바로 다음부터 빠짐없이 이어 보낸다는 뜻이에요.{"type": "resume", "status": "reset", "first_id": ...}:last_id가 Stream에 없어서 이어 붙일 수 없다는 뜻이에요.first_id는 Stream에 남아 있는 첫 엔트리 ID예요.
클라이언트가 {"type": "send", "sender": ..., "mid": ..., "text": ...}를 보내면, 서버는 XADD를 마친 뒤 {"type": "ack", "mid": ..., "id": <엔트리 ID>}를 돌려줘요.
실습 준비
이 글의 명령은 모두 실습 코드의 chat-reconnect-resume/ 디렉터리에서 실행합니다.
Dockerfile:server와bench가 함께 쓰는 Python 3.12 이미지compose.yaml:redis,server,bench세 서비스 정의server.py: 환경 변수로 동작을 바꾸는 WebSocket 채팅 서버(256행)bench.py:handover,trim,resume,retry등 시나리오별 측정 클라이언트(355행)
compose.yaml은 compose 프로젝트 chrec에 컨테이너 세 개를 띄워요.
redis: Redis 8.10.2예요. 호스트에 포트를 열지 않고 컨테이너 안의redis-cli로 봅니다.server: 채팅 서버 한 대예요(호스트 7710번 포트).bench: 측정 클라이언트예요. 필요할 때만docker compose run으로 띄우고, compose 네트워크 안에서server:8765로 붙어요.
클라이언트가 보낸 메시지는 XADD room:lobby MAXLEN ~ <MAXLEN> * ...로 Stream에 남기고, 만들어진 엔트리 ID를 ack로 돌려줘요. Stream 엔트리 ID는 <밀리초 Unix 시간>-<순번> 형식이고, MAXLEN ~은 길이를 대략 그 근처로 유지하라는 뜻이에요.
엔트리에는 보낸 기기의 ID(sender), 클라이언트가 메시지를 만들 때 정하는 UUID(mid, 같은 메시지를 다시 보낼 때는 같은 값), 빠진 번호를 세려고 측정 클라이언트가 붙이는 번호(seq), 본문(text)을 담아요.
서버에는 읽기 작업(reader)이 하나 있어요. XREAD BLOCK 1000 COUNT 100으로 새 엔트리를 받고, 이 서버에 붙은 연결 집합 conns에 나눠 보냅니다. 서버 동작은 다음 환경 변수로 바꿉니다.
HANDOVER: 재접속한 연결에 채우기와 실시간 전달을 어떤 순서로 붙일지 정해요.late-join,no-buffer,buffer(기본값) 세 가지예요.HANDOVER_DELAY: 채우기와 실시간 전달 사이의 경계 구간에 일부러 넣는 지연(초)이에요. 기본값은 0이에요.MAXLEN: Stream 길이 상한이에요. 기본값은 10000이에요.CHECK_TRIM: 1이면last_id가 잘려 나갔는지 확인해요(기본값). 0이면 확인하지 않아요.DEDUP: 재전송 중복을 거르는 방법이에요.off와idmp(기본값)를 씁니다.DROP_BEFORE_ACK: 1이면XADD를 마친 뒤ack를 보내기 직전에 연결을 한 번 끊어요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
docker compose build
docker compose up -d redis
export COMPOSE_PROJECT_NAME=chrec
mkdir -p out
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
bench() { docker compose run --rm -T bench python bench.py "$@"; }
slog() { docker compose logs --no-log-prefix server; }
restart_server() {
docker compose rm -sf server > /dev/null 2>&1
rcli DEL room:lobby > /dev/null
env "$@" docker compose up -d server > /dev/null 2>&1
until slog | grep -q listening; do sleep 0.2; done
}
out/에는 bench 상태 파일과 측정 결과가 쌓여요. restart_server는 환경 변수를 받아 server만 새로 띄우고 room:lobby를 지웁니다.
실습 1: 채우는 동안 도착한 메시지
끊긴 동안 쌓인 메시지는 XRANGE로 채우고, 지금 막 올라오는 메시지는 reader가 실시간으로 나눠 줘요. server.py의 resume은 HANDOVER 값에 따라 세 순서 중 하나로 둘을 붙여요.
late-join: 채우기를 다 보낸 뒤에conns에 넣어요.no-buffer: 먼저conns에 넣지만, 채우는 동안 온 실시간 메시지도pending없이 바로 보내요.buffer: 먼저conns에 넣고, 채우는 동안 도착한 실시간 메시지는 대기열pending에 모았다가 이미 보낸 ID 뒤의 것만 보내요.
HANDOVER_DELAY는 late-join에서는 XRANGE를 보낸 뒤와 등록 사이에, 나머지 둘에서는 등록과 XRANGE 사이에 들어가요. 경계 구간은 실제로는 아주 짧아서 지연 없이는 잘 드러나지 않거든요.
bench handover는 발신자 한 명이 초당 200건씩 2000건을 보내는 동안, 수신자 한 명이 300번을 받은 직후 끊고 2초 뒤 last_id로 다시 붙어요. 수신자는 같은 ID를 버리지 않고 그대로 셉니다. 순서 뒤바뀜은 앞서 받은 ID 중 가장 큰 것보다 작은 ID가 온 횟수예요.
먼저 late-join에 지연 0.2초를 넣어 볼게요.
restart_server HANDOVER=late-join HANDOVER_DELAY=0.2
bench handover --rate 200 --count 2000 --drop-at 300 --gap 2
slog | grep backfill
발신 2000건(200건/초). 수신자는 300번 직후 끊고 2.0초 뒤 재접속
resume: ok
수신 1956건, 빠진 번호 702-745 (44건), 중복 0건, 순서 뒤바뀜 0건
12:36:59.869 backfill 401 entries (late-join)
서버는 XRANGE로 301번부터 701번까지 401건을 채워 보냈어요.
그 뒤 0.2초 동안은 이 연결이 conns에 없었으니까, 그사이 reader가 나눠 준 702번부터 745번까지 44건은 이 연결에 오지 않았어요. 클라이언트 쪽에서는 아무 이상 신호 없이 701번 다음에 746번을 받습니다.
기본값인 buffer는 지연을 1초로 넣었어요.
restart_server HANDOVER=buffer HANDOVER_DELAY=1
bench handover --rate 200 --count 2000 --drop-at 300 --gap 2
slog | grep backfill
발신 2000건(200건/초). 수신자는 300번 직후 끊고 2.0초 뒤 재접속
resume: ok
수신 2000건, 빠진 번호 없음, 중복 0건, 순서 뒤바뀜 0건
12:44:05.936 backfill 600 entries, pending sent 0 skipped 200
1초 동안 실시간으로 도착한 200건은 pending에 들어갔어요. 그 200건은 이어서 실행한 XRANGE의 600건 안에 모두 들어 있었으니 pending에서 모두 버렸고(skipped 200), 2000건을 빠짐도 중복도 없이 순서대로 받았습니다.
세 방식에 지연 0, 0.2, 1초를 넣어 세 번씩 반복한 결과예요.
late-join: 빠진 번호가 지연 0초에서 세 번 모두 0건, 0.2초에서 44건, 45건, 44건, 1초에서 세 번 모두 203건이었어요.no-buffer: 빠진 번호는 모두 0건이에요. 중복은 지연 0.2초에서 세 번 모두 40건, 1초에서 세 번 모두 200건이었고, 순서 뒤바뀜은 0.2초에서 439~440건, 1초에서 599~600건이었어요.buffer: 9번 모두 빠짐, 중복, 순서 뒤바뀜이 0건이었어요.
평소에는 경계 구간이 몇 밀리초뿐이라 드물게 나타나지만, 서버가 바쁘거나 XRANGE로 채울 양이 많아 그 구간이 길어지면 빠짐과 중복도 그만큼 늘어나요. 연결을 먼저 실시간 전달 대상에 넣지 않으면 빠짐이, 채우는 동안 온 메시지를 모았다가 걸러 보내지 않으면 중복과 순서 뒤바뀜이 생깁니다.
실습 2: 마지막 ID가 잘려 나갔을 때
Stream은 MAXLEN으로 길이를 제한하니까, 오래 끊겨 있던 클라이언트의 last_id는 이미 지워졌을 수 있어요.
MAXLEN을 100으로 낮춰 볼게요. bench trim은 수신자가 10번까지 받고 끊은 뒤 발신자가 500건을 더 보내고, 수신자의 last_id와 받은 번호를 out/trim-state.json에 남기고 끝나요. bench resume이 그 last_id로 다시 붙습니다. 먼저 잘림을 확인하지 않는 CHECK_TRIM=0으로 해 볼게요.
restart_server MAXLEN=100 CHECK_TRIM=0
bench trim --drop-at 10 --count 500
rcli XLEN room:lobby
rcli XRANGE room:lobby - + COUNT 1
bench resume
수신자: 10번까지 받고 끊음, last_id=1791637580293-0
발신자: 11~510번 500건 저장
(integer) 160
1) 1) "1791637580427-0"
2) 1) "sender"
2) "phone-A"
3) "mid"
4) "1ce1323a-8790-47d7-ab43-37d1f9e0e533"
5) "seq"
6) "351"
재접속 last_id=1791637580293-0 -> resume ok
이어 받은 번호: 351-510 (160건)
빠진 번호: 11-350 (340건)
MAXLEN ~ 100인데XLEN은 160이에요.~를 붙인 근사 트림은 내부 노드 단위로만 지우니까 상한보다 조금 더 남을 수 있어요.- 남은 첫 엔트리는 351번이고, 수신자의
last_id(10번)는 이미 지워졌어요. - 그런데
XRANGE room:lobby (<last_id> +는 오류 없이 남아 있는 351번부터 돌려줘요. 서버는resume ok를 보냈고, 클라이언트는 340건이 빠진 줄 몰라요.
CHECK_TRIM=1인 서버는 XRANGE room:lobby <last_id> +처럼 last_id를 포함해서 읽어요. last_id가 아직 남아 있으면 첫 엔트리가 last_id 자신이고, 잘려 나갔으면 그보다 뒤의 엔트리가 첫 엔트리로 와요.
restart_server MAXLEN=100 CHECK_TRIM=1
bench trim --drop-at 10 --count 500
bench resume
slog | grep reset
수신자: 10번까지 받고 끊음, last_id=1791637610286-0
발신자: 11~510번 500건 저장
재접속 last_id=1791637610286-0 -> resume reset first_id=1791637610423-0
이어 받은 번호: 351-510 (160건)
빠진 번호: 11-350 (340건)
기록 저장소에서 받을 구간: 1791637610286-0 다음부터 1791637610423-0 전까지
13:06:58.435 reset last_id=1791637610286-0 first_id=1791637610423-0
받은 메시지는 같지만, 이번에는 서버가 reset과 first_id를 보냈어요. 그래서 클라이언트는 어느 구간이 비었는지 스스로 알 수 있어요.
클라이언트는 받은 메시지를 지금 화면에 그대로 이어 붙이지 말고, last_id 다음부터 first_id 전까지를 대화 기록 저장소(DB의 기록 조회 API)에서 받아 채우면 됩니다. 실습은 DB 조회를 구현하지 않고, 클라이언트가 그 구간을 출력하는 데까지만 했어요.
실습 3: 응답을 못 받고 다시 보낸 메시지
클라이언트가 메시지를 보냈고 서버가 XADD까지 마쳤는데, ack가 가기 전에 연결이 끊겼다고 해볼게요. 그러면 클라이언트는 저장됐는지 알 수 없어서 다시 보내요.
Redis 8.6부터 XADD에 중복을 거르는 기능이 들어왔어요. XADD key IDMP <producer-id> <idempotent-id> * ...로 추가하면, 같은 producer ID와 idempotent ID 조합이 다시 왔을 때 새 엔트리를 만들지 않고 처음 엔트리의 ID를 돌려줘요. server.py의 발신 처리(on_send)는 DEDUP 값에 따라 XADD에 붙일 인자를 고릅니다.
off: 중복을 거르지 않아요.idmp: producer ID는 보낸 기기(sender), idempotent ID는 메시지마다 만든mid예요.
bench retry는 메시지를 보내고 ack를 기다리다가, ack 없이 연결이 닫히면 다시 접속해 같은 mid로 다시 보내요.
restart_server DEDUP=off DROP_BEFORE_ACK=1
bench retry
slog | grep -E 'XADD|drop'
rcli XRANGE room:lobby - +
mid=6f567609: ack 없이 연결 끊김 -> 재접속해 같은 mid로 다시 보냄
1번째 mid=6f567609 text=ok -> ack 1791638417600-0
13:20:17.598 XADD mid=6f567609 -> 1791638417597-0
13:20:17.598 drop connection before ack
13:20:17.601 XADD mid=6f567609 -> 1791638417600-0
1) 1) "1791638417597-0"
2) 1) "sender"
2) "phone-A"
3) "mid"
4) "6f567609-387a-4497-8ff9-3023fcd39145"
5) "text"
6) "ok"
2) 1) "1791638417600-0"
2) 1) "sender"
2) "phone-A"
3) "mid"
4) "6f567609-387a-4497-8ff9-3023fcd39145"
5) "text"
6) "ok"
첫 XADD는 성공했지만 ack를 보내기 전에 연결이 끊겼어요. 클라이언트가 같은 mid로 다시 보냈고, Stream에는 같은 mid의 엔트리가 두 건 남았어요.
restart_server DEDUP=idmp DROP_BEFORE_ACK=1
bench retry
slog | grep -E 'XADD|drop'
rcli XRANGE room:lobby - +
mid=430413d6: ack 없이 연결 끊김 -> 재접속해 같은 mid로 다시 보냄
1번째 mid=430413d6 text=ok -> ack 1791638438923-0
13:20:38.923 XADD mid=430413d6 -> 1791638438923-0
13:20:38.923 drop connection before ack
13:20:38.926 XADD mid=430413d6 -> 1791638438923-0
1) 1) "1791638438923-0"
2) 1) "sender"
2) "phone-A"
3) "mid"
4) "430413d6-f3ad-4ccf-b6e7-8110266f9cbe"
5) "text"
6) "ok"
이번에는 다시 보낸 XADD가 첫 XADD와 같은 ID를 돌려줬고, 클라이언트는 그 ID를 ack로 받았어요. Stream에는 한 건만 있어요.
정리
범위: Redis 8.10.2, Python 3.12, websockets 17.2, redis-py 8.1.0, 서버 한 대와 측정 클라이언트를 compose로 띄운 Docker.
- 경계 지연 0.2초와 1초에서
late-join은 44~45건과 203건을 빠뜨렸고,no-buffer는 40건과 200건을 두 번 보냈어요. 연결을 먼저 전달 대상에 넣고, 채우는 동안 온 메시지는pending에 모았다가 이미 보낸 ID 뒤의 것만 보내세요. MAXLEN ~ 100으로 잘려 나간last_id로XRANGE를 부르자 오류 없이 351번부터 돌려줘 340건이 빠진 채resume ok가 나갔어요.last_id를 포함해 읽어 첫 엔트리가last_id인지 확인하고, 아니면reset과first_id를 보내세요.ack직전에 끊겨 다시 보낸 메시지는 중복을 거르지 않으면 두 건 저장됐고,XADD IDMP <기기 ID> <mid>는 한 건으로 걸렀어요. idempotent ID는 메시지마다 만든mid로 정하세요.