# 실시간 채팅# Redis

채팅 재접속, 마지막 메시지 ID만 넘기면 끝일까?

@Hong2026-10-14

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
채팅 재접속 때 마지막 메시지 ID만 넘겨 이어 받으면 메시지가 빠지지 않을까?
답
빠질 수 있다. Redis Stream 길이를 100건 남짓으로 잘라 둔 서버는 이미 잘려 나간 마지막 ID를 받고도 오류 없이 이어 보낸다고 응답하며 340건을 건너뛰었다.
조건
Redis 8.10.2, Python 3.12, websockets 17.2, redis-py 8.1.0, 서버 한 대와 측정 클라이언트를 compose로 띄운 Docker

환경

Redis
8.10.2
서버
Python 3.12, websockets 17.2, redis-py 8.1.0으로 만든 WebSocket 채팅 서버 한 대
측정 클라이언트
bench.py, 같은 compose 네트워크의 bench 컨테이너
실행
Docker Compose(프로젝트 chrec)
반복
경계 지연은 설정마다 세 번씩
목차
  1. 이어 받기 방식
  2. 실습 준비
  3. 실습 1: 채우는 동안 도착한 메시지
  4. 실습 2: 마지막 ID가 잘려 나갔을 때
  5. 실습 3: 응답을 못 받고 다시 보낸 메시지
  6. 정리

이번 시간에는 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로 정하세요.
내 링크 만들기