Redis의 메시징 패턴 (Pub/Sub, Stream)

 

Redis는 캐시나 데이터 저장소뿐 아니라, 메시지를 전달하는 브로커 역할도 수행할 수 있습니다. 하지만 Redis가 제공하는 메시징 기능은 카프카나 RabbitMQ 같은 전통적인 메시지 큐와는 성격이 다릅니다. 빠른 전달을 우선하되 영속성을 포기하는 방식이 있고, 영속성을 보장하되 약간의 복잡성이 추가되는 방식이 있습니다.

 

이 글에서는 Pub/Sub과 Redis Stream 두 가지 메시징 패턴을 중심으로, 각각의 동작 원리와 특성, 그리고 어떤 상황에서 어떤 방식을 선택해야 하는지 정리해 보겠습니다.


Pub/Sub

특징

Pub/Sub는 Publisher(발행자)와 Subscriber(구독자)가 채널을 통해 메시지를 주고받는 패턴입니다. 발행자가 특정 채널에 메시지를 발행하면, 해당 채널을 구독하는 모든 구독자가 실시간으로 메시지를 받습니다.

 

가장 큰 특징은 Loose Coupling(느슨한 결합)입니다. 발행자는 누가 구독하는지 알 필요 없고, 구독자는 누가 발행하는지 알 필요 없습니다. 오직 채널 이름 하나로 Publisher와 Subscriber가 연결됩니다. 마이크로 서비스 아키텍처에서 서비스 A가 이벤트를 발행하면 서비스 B, C가 각자 구독해서 처리하는 EDA(Event Driven Architecture) 구조가 대표적인 활용 사례입니다.

 

다만 Redis의 Pub/Sub은 Fire and Forget 방식입니다. 메시지를 전송하면 그 이후의 프로세스에 전혀 관여하지 않습니다. 메시지 자체에 영속성이 없기 때문에 메시지를 중간에 저장하지 않는데요. 구독자가 오프라인이면 메시지를 받을 수 없고, 구독자가 아무도 없으면 메시지가 아예 유실됩니다. 전통적인 MSQ처럼 메시지를 저장하고 관리하고 그룹까지 제공하는 시스템과는 방향이 다릅니다. Redis의 Pub/Sub은 이 모든 것을 생략해서 빠른 메시지 전달을 추구합니다.

 

구독 방식

일반 구독

  • `SUBSCRIBE` 명령으로 특정 채널을 하나 이상 구독할 수 있습니다. 구독을 취소하려면 `UNSUBSCRIBE` 명령을 사용합니다.

패턴 구독

  • `PSUBSCRIBE` 명령으로 와일드카드 패턴을 사용해 여러 채널을 한 번에 구독할 수 있습니다. 예를 들어 `news.*` 패턴으로 구독하면 `news.weather`, `news.sports` 등 `news.`로 시작하는 모든 채널의 메시지를 받을 수 있는데요. 별도 6개 이상의 문자열이 필요한 글로빙(globbing) 형태입니다.
  • 다만 패턴 구독은 시간복잡도가 O(N+M)입니다. N은 등록된 패턴 수, M은 해당 패턴의 구독자 수인데요. 패턴이 늘어날수록 매칭 비용이 증가하기 때문에 실무에서는 패턴 수를 최소화하는 것이 중요합니다.

 

내부 구조

Redis는 Pub/Sub의 구현을 위해서 두 개의 핵심 데이터 구조를 사용합니다.

 

첫 번째는 PubSub 채널의 HashTable입니다. 키는 채널 이름이고, 같은 채널을 구독하는 클라이언트들의 연결 리스트가 값으로 연결되어 있습니다. 일반적인 SUBSCRIBE 명령이 이 구조를 사용합니다.

 

두 번째는 PubSub 패턴과 연결된 리스트입니다. 각 노드는 패턴 문자열과 그것을 구독하는 클라이언트로 구성됩니다. 패턴 구독에서는 리스트를 처음부터 끝까지 순회하면서 패턴 매칭을 수행합니다.

 

Publishing이 발생하면 Redis는 먼저 해당 채널과 정확히 일치하는 일반 구독자에게 메시지를 전달하고, 그 다음 패턴 리스트를 순회하면서 매칭되는 패턴 구독자에게도 메시지를 전달합니다.

 

주의사항

Pub/Sub에서 가장 중요한 점은 메시지 영속성이 없다는 것입니다. 채널 자체도 구독자가 있을 때만 메모리에 존재하는데요. 구독자가 없는 채널은 Pub/Sub 채널에서 완전히 제거됩니다. 메모리 사용량은 줄어들지만 메시지를 보관할 방법이 없습니다.

 

또한 Redis 클러스터 환경에서는 Pub/Sub이 일반 Pub/Sub과는 완전히 다르게 동작합니다. 일반적인 Pub/Sub에서 Publish된 메시지는 해당 메시지를 받은 노드와 연결된 모든 구독자에게만 전달되지만, 클러스터에서는 모든 노드에 브로드캐스트됩니다. 노드가 3개이고 각 노드에 클러스터가 있으면 네트워크 대역폭은 3배 트래픽이 발생하는 셈입니다.


Redis Stream

Pub/Sub과 List의 한계

Pub/Sub은 Fire and Forget 방식이라 메시지 영속성이 없습니다. 구독자가 오프라인이면 메시지를 받을 수 없고, 구독자가 아무도 없으면 메시지가 아예 유실됩니다.

 

그렇다면 List를 메시지 큐로 사용하면 되지 않을까 생각할 수 있는데요. List에도 한계가 있습니다. `RPOP`하면 메시지가 삭제되기 때문에 여러 소비자가 같은 메시지를 처리하기 어렵고, 처리 중 실패하면 이미 꺼낸 메시지가 유실됩니다. 소비자가 메시지를 꺼내서 처리 중에 죽으면 메시지가 사라지는 구조입니다.

 

Redis Stream은 이런 Pub/Sub과 List의 한계를 해결하기 위해 Redis 5.0에서 추가된 데이터 타입입니다. 카프카나 RabbitMQ 같은 메시지 브로커의 핵심 기능을 Redis 안에서 제공하는 것인데요. 별도의 시스템을 띄울 필요 없이 Redis 하나로 메시지 큐 기능을 사용할 수 있습니다.

 

Stream의 핵심 특징을 정리하면 다음과 같습니다.

  • 소비해도 메시지가 보존됩니다. Pub/Sub이나 List와 달리 메시지가 소비되어도 삭제되지 않고 계속 남아있습니다.
  • 여러 소비자가 같은 메시지를 독립적으로 읽을 수 있습니다. A 서비스가 읽고 B 서비스가 읽고 C 서비스가 같은 메시지를 각자 읽어도 무방합니다.
  • Consumer Group으로 분산 처리가 가능합니다. 카프카의 컨슈머 그룹과 비슷한 구성입니다.
  • ACK로 처리 완료를 확인할 수 있습니다.

실무에서는 Pub/Sub과 유사한 형태로 이벤트 소싱이나 애플리케이션의 모든 상태 변경을 이벤트로 기록하는 용도로 활용됩니다.

 

메시지 ID

Stream의 각 메시지에는 고유한 아이디가 자동으로 생성됩니다. 아이디는 타임스탬프와 시퀀스 번호를 하이픈으로 연결한 형태인데요. 예를 들면 `1704067200000-0` 같은 형식입니다.

 

타임스탬프가 밀리초 단위로 들어가기 때문에 시간순 정렬이 가능합니다. 같은 밀리초에 여러 메시지가 들어왔어도 시퀀스 번호로 구별할 수 있습니다.

 

중요한 규칙은 아이디 값이 항상 증가해야 한다는 것입니다. 이전보다 작은 아이디는 추가할 수 없는데요. 스트림 자체가 기본적으로 시간순으로 정렬이 되어야 하기 때문입니다.

 

메시지 내용 자체는 필드와 값 쌍으로 구성되어 있습니다. 일종의 해시와 비슷한 구조입니다.

 

주요 명령어

XADD

  • 스트림에 메시지를 추가합니다. `XADD` 뒤에 스트림 이름과 `*`(아이디 자동 생성)을 쓰고, 그 다음에 field와 value를 연속적으로 넣어주면 됩니다. `MAXLEN` 옵션을 넣어 스트림의 크기도 제한할 수 있습니다. 예를 들어 1,000으로 설정하면 가장 오래된 것부터 삭제됩니다.

XREAD

  • 메시지를 읽는 명령입니다. `0`을 주면 처음부터 전부 가져오고, $를 주면 이후에 추가되는 메시지만 받습니다. `BLOCK` 옵션을 사용하면 새 메시지가 올 때까지 대기하는 것도 가능한데요. `BLOCK 0`이면 무한 대기, `BLOCK 5000`이면 5초 동안 대기합니다.

XRANGE

  • 특정 범위의 메시지를 조회합니다. 시작 아이디와 끝 아이디를 지정하면 해당 범위의 메시지를 반환하는데요. `-`는 처음부터, `+`는 끝까지를 의미합니다. `COUNT` 옵션으로 최신 10개만 가져오는 것도 가능합니다.

 

Consumer Group

Consumer Group은 Stream에서 가장 핵심적인 기능입니다. 카프카의 컨슈머 그룹과 동일한 개념인데요. 그룹을 통해서 메시지를 서로 다른 소비자들에게 분배할 수 있습니다.

 

예를 들어 메시지 1은 컨슈머 A, 메시지 2는 컨슈머 B, 메시지 3은 컨슈머 C가 처리하는 구조입니다. 각 그룹별로 만들고 각 그룹은 독립적으로 모든 메시지를 받는데요. 그룹 내에서는 메시지가 소비자 한 명에게만 전달됩니다.

 

Consumer Group의 핵심 개념은 Last Delivered ID입니다. 그룹 단위로 어디까지 전달했는지를 추적하는 메시지 아이디인데요. 이를 통해 Pub/Sub에서 없었던 영속성 개념이 추가됩니다.

 

그룹은 별도의 명령으로 만들 수 있고, 편리하면 초기에 그룹이 없으면 자동 생성하는 옵션도 있습니다.

 

PEL과 XCLAIM

Stream에서는 메시지를 컨슈머에게 전달한 뒤에도 처리가 완료되었는지를 추적합니다. 이 역할을 하는 것이 PEL(Pending Entries List)입니다.

 

컨슈머가 메시지를 가져가면 PEL에 기록됩니다. 어떤 컨슈머가 어떤 메시지를 가져갔는지 추적하는데요. 컨슈머가 처리를 완료하고 `XACK`을 보내면 PEL에서 제거됩니다.

 

만약 컨슈머가 처리 도중에 죽어서 `XACK`을 보내지 못했다면, 해당 메시지는 PEL에 계속 남아있게 됩니다. 이때 XCLAIM 명령으로 다른 컨슈머가 소유권을 가져갈 수 있는데요. 일정 시간(예를 들어 60초) 이상 처리되지 않은 메시지를 다른 컨슈머가 대신 처리할 수 있는 구조입니다.

 

이를 통해 메시지가 절대 유실되지 않고 재처리될 수 있습니다. 최소 한 번은 처리된다는 의미에서 at least once 전달이 보장됩니다.

 

XPENDING

  • PEL의 요약 정보를 확인하는 명령입니다. 대기 중인 메시지가 몇 개인지, 가장 오래된 메시지가 몇 개인지, 어떤 컨슈머가 몇 개를 들고 있는지 확인할 수 있습니다.

 

주의사항

Stream은 메시지를 저장하기 때문에 영속성이 보장되지만, 메시지가 계속 쌓이면 메모리가 증가합니다. `MAXLEN` 옵션이나 별도 배치를 통해 오래된 데이터를 주기적으로 삭제하는 작업이 필요합니다.

 

또한 Pub/Sub과 Stream은 대체 관계가 아닙니다. Pub/Sub은 이벤트가 들어오면 즉시 구독자에게 전달하고 메시지를 저장하지 않는 방식이고, Stream은 메시지 전체에 대한 히스토리를 유지하면서 소비자가 원하는 시점부터 읽을 수 있는 방식입니다. 영속성이 필요한지, 빠른 실시간 전달만 필요한지에 따라 선택이 달라집니다.


마무리

이번 글에서는 Redis의 두 가지 메시징 패턴인 Pub/Sub과 Stream을 정리해 보았습니다. Pub/Sub의 Fire and Forget 방식이 왜 가볍고 빠른지, 그리고 그 대가로 메시지 유실이라는 한계를 갖는지가 정리하면서 명확해졌습니다. Stream은 이 한계를 Consumer Group, PEL, XCLAIM 같은 구조로 해결하면서도 Redis 안에서 동작한다는 점이 인상적이었습니다.

 

다음 글에서는 Redis Cache Strategy와 Redis Cluster에 대해 정리해 보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다!