Redis Pub/Sub 메시지 손실: 원인과 신뢰할 수 있는 대안

네트워크 연결 끊김이나 느린 소비자 상황에서 Redis Pub/Sub가 메시지를 손실하는 이유를 알아보고, Redis Streams 및 리스트 기반 큐와 같은 패턴을 통해 메시지 전달을 보장하는 방법을 살펴봅니다.

Redis Pub/Sub 메시지 손실: 원인과 신뢰할 수 있는 대안

Redis Pub/Sub에 처음 데인 순간을 기억합니다. 밤 11시쯤이었는데, 알림 시스템이 메시지를 놓치기 시작했습니다. 전부는 아니었지만, 우리가 알아채기 전에 사용자들이 먼저 알아챌 정도였습니다. 당직 엔지니어(불행히도 저였죠)는 두 시간 동안 애플리케이션 로그를 뒤졌고, 결국 명백한 진실이 드러났습니다. Redis Pub/Sub는 아무것도 큐에 저장하지 않는다는 것이었습니다. 메시지 브로커가 아니라, 소방호스와 같아서 바로 앞에 서서 입을 벌리고 있지 않으면 무언가를 놓칠 수밖에 없습니다.

처음 Redis Pub/Sub를 사용할 때 아무도 알려주지 않는 사실입니다. 문서에는 분명히 나와 있지만, API가 얼마나 간단한지에 흥분하면 쉽게 간과하게 됩니다. 한쪽에서 발행하고 다른 쪽에서 구독하면 작동합니다. 문제가 생기기 전까지는요.

발행 후 망각의 현실

Redis Pub/Sub는 무자비하게 단순한 원칙으로 작동합니다. 메시지를 발행하면 Redis는 그 순간 해당 채널의 모든 연결된 구독자에게 메시지를 푸시합니다. 구독자가 연결되어 있지 않거나, 연결되어 있어도 따라잡지 못하면 메시지는 사라집니다. 지속성 계층, 확인 메커니즘, 데드 레터 큐가 없습니다. 메시지는 전송 중에만 존재합니다.

구체적인 예를 들어보겠습니다. 주문 상태 업데이트를 발행하는 서비스와 확인 이메일을 보내기 위해 구독하는 서비스가 있다고 가정해 보겠습니다. 정상 부하에서는 모든 것이 순조롭게 작동합니다. 그런데 이메일 서비스에 문제가 생깁니다. SMTP 릴레이가 느리거나 가비지 컬렉션 일시 중지가 발생할 수 있습니다. 그 동안 Redis는 계속 메시지를 푸시합니다. 구독자의 TCP 버퍼가 가득 차고, 결국 연결이 끊어집니다. 구독자가 다시 연결되면 중단된 지점이 아닌 지금부터 메시지를 받기 시작합니다. 연결이 끊어진 동안 발행된 모든 메시지는 사라집니다.

간단한 테스트 설정으로 실제로 측정해 본 결과, 초당 10,000개의 메시지를 발행하는 발행자와 50밀리초 동안 가끔 차단되는 구독자가 있을 때, 짧은 일시 중지 한 번만으로도 수십 개의 메시지가 손실됩니다. 구독자는 메시지가 전송된 사실을 전혀 모르고, 발행자는 메시지가 손실된 사실을 전혀 모릅니다. Redis는 완벽하게 만족합니다. 설계된 대로 정확히 작동했기 때문입니다.

실제로 메시지 손실을 유발하는 원인

Pub/Sub가 메시지를 손실하는 세 가지 주요 시나리오가 있으며, 각각 다른 방식으로 나타나기 때문에 이해하는 것이 중요합니다.

네트워크 불안정은 가장 명백한 원인입니다. 구독자와 Redis 사이의 일시적인 네트워크 분할은 연결을 끊습니다. Redis는 클라이언트 시간 초과(기본값 60초, 더 낮게 설정했을 수도 있음)를 통해 이를 감지합니다. 그 동안 발행된 모든 메시지는 해당 구독자에게 손실됩니다. 다른 구독자는 메시지를 정상적으로 받을 수 있기 때문에 디버깅이 더욱 재미있어집니다. 서비스 전체에 걸쳐 일관성 없는 상태를 보게 되고, 자신이 미쳐 가고 있는지 의심하게 될 것입니다.

느린 소비자는 연결이 유지되기 때문에 더 교활합니다. Redis는 푸시 모델을 사용하므로, 발행자가 생산하는 만큼 빠르게 구독자 소켓에 데이터를 씁니다. 구독자가 메시지를 충분히 빨리 처리하지 못하면 커널의 TCP 수신 버퍼가 가득 찹니다. 버퍼가 가득 차면 Redis가 더 이상 데이터를 쓸 수 없게 되고 결국 연결이 실패합니다. 구독자는 연결이 끊어질 때까지 자신이 뒤처지고 있다는 사실조차 인지하지 못할 수 있습니다.

각 메시지에 대해 동기식 데이터베이스 쓰기를 수행하는 구독자에서 이런 상황이 발생하는 것을 본 적이 있습니다. 낮은 볼륨에서는 문제가 없지만, 피크 시간에는 데이터베이스가 병목 현상이 되고 구독자는 뒤처지며 TCP 버퍼에 메시지가 쌓입니다. 버퍼가 오버플로되면 연결이 재설정되고 구독자는 소켓에서 아직 읽지 않은 모든 메시지를 잃게 됩니다.

배포 또는 재시작 중 클라이언트 연결 끊김은 세 번째 주요 범주입니다. 롤링 배포를 수행 중이고 구독자 인스턴스가 다운되면, 해당 인스턴스가 없는 동안 발행된 모든 메시지를 놓치게 됩니다. "따라잡기" 메커니즘이 없습니다. 다시 온라인 상태가 되면 새로 시작합니다.

놀라웠던 한 가지는 깔끔한 종료조차 도움이 되지 않는다는 점입니다. 구독자가 종료되기 전에 정상적으로 구독을 취소하더라도, 구독 취소와 재시작 사이에 발행된 메시지는 여전히 손실됩니다. 구독 취소는 즉각적으로 이루어지며, "잠시만 내 메시지를 보관해 줘"와 같은 옵션은 없습니다.

Pub/Sub가 실제로 괜찮은 경우

Redis Pub/Sub가 쓸모없다고 말하고 싶지는 않습니다. 특정 사용 사례에는 탁월하며, 여전히 정기적으로 사용하고 있습니다. 핵심은 이러한 사용 사례가 무엇인지 이해하는 것입니다.

가끔 손실이 허용되는 실시간 알림은 훌륭하게 작동합니다. 실시간 스포츠 점수, 주식 시세, 채팅 앱의 입력 표시기 등을 생각해 보세요. 사용자가 점수 업데이트를 놓치더라도 몇 초 후에 다음 업데이트가 옵니다. 데이터는 유효 기간이 짧고 내구성 요구 사항이 없습니다.

서비스 검색 및 구성 브로드캐스팅은 또 다른 강점입니다. 기능 플래그를 변경하고 모든 애플리케이션 인스턴스에 발행할 때, 현재 다시 시작 중인 인스턴스가 업데이트를 놓쳐도 괜찮습니다. 다시 온라인 상태가 되거나 다음 정기 새로 고침 시 현재 상태를 가져오면 됩니다.

또한 여러 애플리케이션 서버 간의 캐시 무효화를 위해 Pub/Sub를 성공적으로 사용해 왔습니다. 무효화할 캐시 키를 발행하면 모든 서버가 로컬 캐시를 지웁니다. 한 서버가 메시지를 놓치면 최악의 경우 캐시 항목이 자연스럽게 만료될 때까지 오래된 데이터를 제공하게 됩니다. 이상적이지는 않지만 치명적이지도 않습니다.

여기서 공통점은 Pub/Sub가 메시지가 본질적으로 일시적이고, 손실이 다른 메커니즘을 통해 복구 가능하며, 순서 보장이나 정확히 한 번 전달이 필요하지 않은 경우에 적합하다는 것입니다.

Redis Streams: 내장된 대안

Redis 5.0에서 도입된 Redis Streams는 이제 신뢰할 수 있는 메시지 전달이 필요할 때 사용하는 도구입니다. 지속성이 추가된 Pub/Sub가 아니라, Kafka와 같은 분산 로그에 더 가까운 근본적으로 다른 모델입니다.

Streams를 사용하면 메시지가 로그에 추가되고 명시적으로 확인될 때까지 유지됩니다. 소비자는 연결을 끊거나, 다시 시작하거나, 뒤처져도 따라잡을 수 있습니다. 스트림은 최대 길이나 보존 기간을 기준으로 메시지를 유지하므로, 얼마나 많은 기록을 보관할지 제어할 수 있습니다.

사고 방식이 어떻게 다른지 설명하겠습니다. Pub/Sub에서는 채널을 구독하고 메시지가 사용자에게 흘러갑니다. Streams에서는 사용자 자신의 속도로 메시지를 가져옵니다. 소비자 그룹은 각 소비자가 확인한 메시지를 추적하므로, 여러 소비자가 중복 없이(또는 팬아웃을 원하는 경우 의도적으로 중복하여) 동일한 스트림을 읽을 수 있습니다.

기본적인 Streams 설정은 다음과 같습니다.

XADD orders * status confirmed order_id 12345

이 명령은 orders 스트림에 메시지를 추가합니다. *는 Redis에 ID를 자동 생성하도록 지시합니다. 그런 다음 소비자는 다음과 같이 읽습니다.

XREADGROUP GROUP email-processor worker-1 COUNT 10 STREAMS orders >

>는 "이 그룹의 어떤 소비자에게도 아직 전달되지 않은 메시지를 제공"하라는 의미입니다. 처리가 완료되면 소비자는 다음을 확인합니다.

XACK orders email-processor <message-id>

소비자가 확인하기 전에 충돌하면 메시지는 보류 상태로 유지됩니다. 그룹의 다른 소비자는 시간 초과 후 XCLAIM을 사용하여 이를 클레임할 수 있습니다. 이것이 Pub/Sub에는 완전히 없는 확인 및 재전송 메커니즘입니다.

실제 소비자 그룹 모델

소비자 그룹은 Streams를 안정적인 처리에 진정으로 유용하게 만드는 요소입니다. 각 그룹은 스트림에서 자체 위치를 유지하므로, 이메일 알림용 그룹, 분석용 그룹, 감사 로깅용 그룹 등 동일한 스트림을 독립적으로 읽는 여러 그룹을 가질 수 있습니다.

그룹 내에서 메시지는 소비자 간에 분산됩니다. 이를 통해 수평적 확장성을 얻을 수 있습니다. 더 많은 소비자 인스턴스를 추가하면 부하를 공유합니다. 하나의 인스턴스가 죽으면 보류 중인 메시지를 다른 인스턴스가 클레임할 수 있습니다.

보류 중인 항목 목록은 모니터링에 매우 유용하다는 것을 알게 되었습니다. XPENDING을 실행하여 확인되지 않은 메시지와 대기 시간을 확인할 수 있습니다. 이를 통해 느린 소비자를 즉시 식별할 수 있으며, 사용자 불만을 통해 며칠 후에 메시지 손실을 발견하는 것보다 훨씬 좋습니다.

Streams의 한 가지 주의할 점은 메시지 ID가 단조 증가하는 타임스탬프이므로, 순서가 맞지 않는 메시지를 쉽게 삽입할 수 없다는 것입니다. 스트림 내에서 엄격한 순서가 필요한 경우 이는 사실상 기능입니다. 특정 메시지의 우선순위를 지정해야 하는 경우 여러 스트림이나 다른 접근 방식이 필요합니다.

더 간단한 요구 사항을 위한 리스트 기반 큐

Streams가 존재하기 전에 Redis로 안정적인 메시징을 위한 표준 패턴은 차단 팝이 있는 리스트 기반 큐였습니다. 이 패턴은 특히 이전 Redis 버전을 사용 중이거나 매우 간단한 것을 원하는 경우 여전히 완벽하게 실행 가능합니다.

아이디어는 간단합니다. 생산자는 LPUSH 또는 RPUSH를 사용하여 메시지를 리스트에 넣고, 소비자는 BLPOP 또는 BRPOP를 사용하여 메시지가 도착할 때까지 차단합니다. 차단 팝은 중요합니다. 그렇지 않으면 폴링을 해야 하므로 CPU가 낭비되고 지연 시간이 추가됩니다.

안정성은 보조 "처리" 리스트에서 비롯됩니다. 소비자는 BRPOPLPUSH(Redis 6.2+에서는 LMOVE)를 사용하여 보류 중인 큐에서 처리 큐로 메시지를 원자적으로 이동합니다. 처리가 완료되면 처리 큐에서 메시지를 제거합니다. 소비자가 충돌하면 처리 큐에 메시지가 남아 있고, 모니터 프로세스가 오래된 항목을 보류 중인 큐로 다시 이동할 수 있습니다.

이 패턴을 여러 번 구축했으며 작동하지만 예상보다 코드가 더 많습니다. 시간 초과를 처리하고, 메시지가 처리 큐에 얼마나 오래 남아 있을 수 있는지(버려진 것으로 간주하기 전) 결정하고, 중복 처리와 관련된 에지 케이스를 처리해야 합니다. Streams는 이 모든 것을 사실상 공식화했기 때문에, 수제 리스트 큐에서 대부분 벗어났습니다.

여전히 리스트 기반 큐를 사용하는 유일한 경우는 처리 순서가 중요하지 않고 가능한 가장 간단한 구현을 원하는 작업 큐입니다. 때로는 리스트와 BLPOP 루프만으로 충분하며, Streams를 추가하는 것은 과도한 엔지니어링일 수 있습니다.

Redis 7의 Pub/Sub 샤딩

Redis 7은 샤딩된 Pub/Sub를 도입했는데, 이는 메시지 손실과는 다른 문제를 해결하기 때문에 언급할 가치가 있습니다. 일반 Pub/Sub를 사용하면 모든 메시지가 클러스터의 모든 노드에 브로드캐스트되며, 특정 노드에 해당 채널의 구독자가 없더라도 마찬가지입니다. 이는 클러스터 상호 연결 대역폭을 낭비합니다.

샤딩된 Pub/Sub는 채널을 특정 클러스터 슬롯에 연결하므로, 메시지는 해당 채널의 구독자가 있는 노드로만 전파됩니다. 성능 최적화이지 안정성 기능이 아닙니다. 연결이 끊어지면 여전히 메시지가 손실됩니다. 그러나 클러스터 환경에서 대규모로 Pub/Sub를 실행하는 경우 알아두는 것이 좋습니다.

선택하기: Pub/Sub vs Streams vs 리스트

수년간 이러한 패턴을 사용해 온 결과, 제 결정 프로세스는 몇 가지 질문으로 단순화되었습니다.

첫째, 메시지 손실을 감수할 수 있습니까? 그렇다면, 그리고 데이터가 일시적이라면 Pub/Sub가 괜찮을 것입니다. 가장 낮은 지연 시간과 가장 간단한 운영 모델을 얻을 수 있습니다.

둘째, 메시지 지속성과 재생이 필요합니까? 그렇다면 Streams가 정답입니다. 소비자 버그 수정 후 메시지를 재처리할 수 있는 기능은 저를 여러 번 구해줬습니다. Pub/Sub를 사용하면 소비자 버그로 인해 한 시간 동안 메시지를 잘못 처리했다면 해당 메시지는 영원히 사라집니다. Streams를 사용하면 소비자 그룹 위치를 재설정하고 재생할 수 있습니다.

셋째, 동일한 데이터를 읽는 여러 독립적인 소비자 그룹이 필요합니까? Streams는 이를 기본적으로 처리합니다. Pub/Sub에서는 모든 구독자가 모든 메시지를 받으며, 이것이 원하는 것일 수 있지만, 독립적인 위치를 유지하는 다른 구독자 그룹을 가질 수 있는 방법은 없습니다.

넷째, Redis 버전은 무엇입니까? 5.0 이전 버전을 사용 중이라면 Streams를 사용할 수 없으며, 리스트 기반 큐나 외부 메시지 브로커를 고려해야 합니다. 저도 이런 상황에 처한 적이 있으며, 솔직히 안정적인 메시징이 필요하고 Streams를 사용할 수 없다면 Redis가 적합한 도구인지 고려해야 합니다. RabbitMQ나 NATS가 더 나은 선택일 수 있습니다.

아무도 이야기하지 않는 운영 측면

어렵게 배운 점은 Pub/Sub 모니터링이 기만적으로 어렵다는 것입니다. PUBSUB NUMSUB로 연결 수와 채널 구독을 모니터링할 수 있지만, 손실되는 메시지 수는 확인할 수 없습니다. Redis가 이를 추적하지 않기 때문에 "발행되었지만 수신되지 않은 메시지"에 대한 메트릭이 없습니다.

Streams를 사용하면 가시성을 얻을 수 있습니다. XINFO GROUPS는 소비자 지연을 보여줍니다. XPENDING은 확인되지 않은 메시지를 보여줍니다. 지연이 임계값을 초과하면 알림을 설정할 수 있습니다. 이러한 운영 가시성만으로도 Streams로 전환할 가치가 있었습니다.

메모리 관리도 또 다른 고려 사항입니다. Pub/Sub 메시지는 메모리에만 존재하며 전송 중에만 존재하므로, 메모리 사용량은 발행 속도와 소비자 속도에 의해 제한됩니다. Streams는 메시지를 트리밍할 때까지 저장하므로, 보존 정책에 대해 생각해야 합니다. 일반적으로 예상 처리량과 사용 가능한 메모리를 기반으로 최대 스트림 길이(MAXLEN)를 설정하고, 예상치 못한 누적을 감지하기 위해 스트림 길이를 모니터링합니다.

지금 실제로 하는 일

요즘에는 안정성이 필요한 새로운 메시징 사용 사례의 경우 기본적으로 Redis Streams를 사용합니다. API가 Pub/Sub보다 약간 더 복잡하지만, 크게 다르지 않으며 안정성 보장이 그만한 가치가 있습니다. Pub/Sub는 캐시 무효화, 실시간 상태 등 일시적인 작업에 사용합니다.

특히 중요한 메시징(결제 처리, 주문 이행)의 경우 Redis에서 완전히 벗어나 전용 메시지 브로커를 사용합니다. Redis는 많은 작업에 환상적이지만, 대용량 메시지 큐의 디스크 기반 지속성에 최적화되어 있지는 않습니다. 전체 Redis 재시작 후에도 메시지가 손실 없이 유지되어야 하는 경우 appendfsync always로 AOF 지속성을 구성해야 하며, 이는 쓰기 성능을 저하시킵니다. 그 시점에서는 Kafka나 Pulsar와 같은 것이 더 적합합니다.

그러나 메시지 손실이 치명적이지는 않지만 성가시거나 비용이 많이 들고, 이미 알고 있는 Redis 생태계 내에 머물고 싶은 광범위한 중간 지점에서 Streams는 적절한 지점을 찾습니다. 프로덕션에서 충분히 안정적이었고, 새로운 인프라 구성 요소를 도입하지 않는다는 운영상의 단순함은 실질적인 가치가 있습니다.

Pub/Sub에서 제가 저지른 원래 실수는 기술 자체에 관한 것이 아니었습니다. 세부 사항을 읽지 않고, "메시징"이 "메시지 전달 보장"을 의미한다고 가정한 것이었습니다. Redis Pub/Sub는 그러한 보장을 하지 않으며, 그렇게 가장하지도 않습니다. 이를 이해하면 적절하게 사용할 수 있고, 더 많은 것이 필요할 때 Streams를 사용할 수 있습니다.