채팅 서버를 여러 대로 늘리면, 1번 서버에 붙은 사용자의 메시지를 2번 서버에 붙은 사용자에게 전달해야 합니다. Redis Pub/Sub은 이 문제를 가장 간단하게 해결하는 것처럼 보입니다. 서버마다 채널을 구독하고, 메시지가 오면 PUBLISH하면 됩니다.
그런데 "이 메시지는 언제 사라지는가"를 공식 문서로 확인해 보니, 채팅 메시지를 Pub/Sub에 맡길 수 없었습니다.
Pub/Sub은 fire-and-forget이다
Redis Streams 문서는 Pub/Sub을 이렇게 설명합니다.
While in Pub/Sub messages are fire and forget and are never stored anyway (…)
fire-and-forget은 "보내고 잊는다"는 뜻입니다. Redis는 메시지를 지금 구독 중인 클라이언트의 소켓에 쓰고, 그 뒤로는 기억하지 않습니다. 메시지를 저장하지 않고, 받은 쪽의 확인(ACK)도 기다리지 않습니다.
Pub/Sub 문서도 전달 보장을 같은 내용으로 설명합니다.
Redis' Pub/Sub exhibits at-most-once message delivery semantics. (…) If the subscriber is unable to handle the message (for example, due to an error or a network disconnect) the message is forever lost.
at-most-once는 메시지를 최대 한 번 전달한다는 뜻입니다. 한 번 전달되거나, 아예 전달되지 않습니다. 다시 보낼 메시지를 갖고 있지 않으니 재전송도 없습니다.
메시지가 사라지는 경우
공식 문서와 redis.conf에서 확인한 경우는 다섯 가지입니다.
- 발행하는 순간 구독자가 없을 때: 메시지를 보관하지 않으므로 아무에게도 가지 않습니다. 서버 재시작이나 재연결 중에 발행된 메시지가 여기에 해당합니다.
- 전달 중에 연결이 끊길 때: 공식 문서가 직접 든 예시입니다. 끊긴 동안 발행된 메시지도 다시 받을 수 없습니다.
- 구독자가 느릴 때: 보내지 못한 메시지는 Redis의 클라이언트별 출력 버퍼(Output Buffer)에 쌓입니다. 이 버퍼가 한도를 넘으면 Redis가 연결을 끊고, 버퍼에 있던 메시지도 함께 사라집니다. 기본 한도는
client-output-buffer-limit pubsub 32mb 8mb 60입니다. 32MB에 닿으면 바로, 8MB 초과가 60초 이어지면 연결을 끊습니다. - 클라이언트 메모리 합계가 한도를 넘을 때: Redis 7.0의 클라이언트 축출(Client Eviction)은
maxmemory-clients를 넘으면 메모리를 많이 쓰는 연결부터 끊습니다. 출력 버퍼가 커진 구독자가 대상이 되기 쉽습니다. 기본값은 꺼짐이지만, 공식 문서는 운영 환경에서 켜기를 권합니다. - 받은 서버가 처리 중에 실패할 때: Redis는 이미 메시지를 보냈고, ACK가 없으니 실패를 알 수 없습니다.
PUBLISH의 반환값으로 유실을 감지할 수도 없습니다. 반환값은 메시지를 보낸 클라이언트 수입니다. 공식 문서에 따르면 Redis Cluster에서는 발행한 노드에 연결된 클라이언트만 이 수에 포함됩니다. 그리고 1이 나와도 그 구독자가 처리에 성공했다는 뜻은 아닙니다.
참고: Redis 7.0의 샤딩된 Pub/Sub(
SPUBLISH/SSUBSCRIBE)도 전달 보장은 같습니다. 메시지를 전파하는 범위만 샤드 안으로 줄어듭니다.
큐는 무엇이 다른가
큐(Queue)는 메시지를 저장해 두고, 소비자(Consumer)가 가져가 처리를 확인할 때까지 보관합니다.
[Pub/Sub: 보내고 잊기]
발행자 ──PUBLISH──→ Redis ──→ 지금 연결된 구독자에게 전송
└── 전송 후 메시지를 잊음
[큐: 맡겨 두기]
생산자 ──저장──→ Redis (메시지 보관)
├── 소비자가 가져감 → 처리 → ACK → 완료
└── ACK가 없으면 보관 중인 메시지를 다시 처리Pub/Sub에서는 Redis가 메시지를 보내면 끝납니다. 큐에서는 소비자가 처리했다고 알려야 끝납니다.
Redis에서는 Streams의 컨슈머 그룹(Consumer Group)이 이런 큐 역할을 합니다. 컨슈머 그룹은 하나의 스트림을 여러 소비자가 나눠 처리하게 하는 기능입니다.
- 소비자가
XREADGROUP으로 메시지를 받습니다. Redis는 이 메시지를 PEL(Pending Entries List)에 기록합니다. PEL은 전달했지만 아직 처리 확인을 받지 못한 메시지 목록입니다. - 처리를 마치면
XACK를 보냅니다. Redis는 PEL에서 이 메시지를 지웁니다. - 소비자가 처리 중에 죽으면 메시지가 PEL에 남습니다. 재시작한 소비자가 PEL을 다시 읽거나, 다른 소비자가
XCLAIM/XAUTOCLAIM으로 가져가 처리합니다.
앞의 유실 경우를 이 구조로 다시 보면 다음과 같습니다.
| 경우 | Pub/Sub | Streams 컨슈머 그룹 |
|---|---|---|
| 구독자·소비자가 없다 | 메시지가 사라진다 | 스트림에 남아 있다가 연결되면 읽는다 |
| 연결이 끊긴다 | 다시 받을 수 없다 | 재연결 후 이어서 읽는다 |
| 소비자가 느리다 | 연결이 끊기고 메시지가 사라진다 | 소비자가 가져갈 때까지 스트림에 쌓인다 |
| 처리 중 실패한다 | Redis는 실패를 모른다 | PEL에 남아 다시 처리한다 |
| 유실을 감지하고 싶다 | 방법이 없다 | XPENDING으로 미처리 메시지를 조회한다 |
큐의 비용
큐로 바꾸면 다른 문제를 챙겨야 합니다.
- 같은 메시지를 두 번 처리할 수 있습니다. 처리를 마치고
XACK를 보내기 전에 소비자가 죽으면 메시지가 다시 전달됩니다. 이것이 at-least-once(최소 한 번 전달)입니다. 그래서 메시지 ID로 중복을 거르는 처리가 필요합니다. - Redis 자체의 유실 가능성은 남습니다. Streams 문서에 따르면 Streams도 기본 영속화와 비동기 복제를 씁니다. 그래서 장애 조치(failover) 후 일부 메시지가 빠질 수 있습니다. 메시지 영속성이 중요하면 AOF를 강한 fsync 정책으로 쓰라고 안내합니다.
채팅에서는 메시지가 사라지는 것보다 같은 메시지가 두 번 오는 쪽이 다루기 쉽습니다. 중복은 메시지 ID로 거를 수 있지만, 사라진 메시지는 되찾을 수 없습니다.
마무리
Pub/Sub은 fire-and-forget이라서, 구독자가 그 순간 받지 못하면 메시지는 사라집니다. 채팅 메시지는 사라지면 안 되므로 Pub/Sub에 맡기지 않았습니다.
Pub/Sub이 맞는 곳도 있습니다. "입력 중…" 표시나 접속 상태처럼, 하나쯤 빠져도 다음 신호로 덮이는 데이터입니다.
참고 문서
- Redis 공식 문서, Redis Pub/sub
- Redis 공식 문서, Redis Streams
- Redis 공식 문서, Redis client handling
- Redis 공식 문서, PUBLISH
- Redis 저장소, redis.conf의
client-output-buffer-limit주석