Skip to main content
Summary

Redis를 메시징 시스템으로 바라보기

지금까지 Redis를 빠른 저장소와 캐시로 다뤘다. 하지만 실제 서비스에서는 값을 저장하는 것만큼 중요한 일이 있다. 어떤 일이 발생했음을 다른 서버에 알려야 한다.

예를 들어 주문이 결제되면 알림 서버는 사용자에게 푸시를 보내야 하고, 통계 서버는 매출 지표를 갱신해야 하며, 쿠폰 서버는 사용 완료 처리를 해야 한다. 이때 주문 서버가 모든 서버의 API를 직접 호출하면 서버 간 의존성이 커진다. 알림 서버가 잠시 느려져도 주문 API가 영향을 받을 수 있다.

메시징은 이 의존성을 줄이는 방법이다. 한쪽은 이벤트를 발행하고, 다른 쪽은 필요한 이벤트를 받아 처리한다. Redis에서는 대표적으로 두 가지 방식을 사용할 수 있다.

방식특징적합한 상황
Pub/Sub현재 구독 중인 클라이언트에게 즉시 전달채팅, 접속 상태, 화면 갱신 알림
Stream메시지를 로그처럼 저장하고 Consumer Group으로 처리주문 이벤트, 알림 발송, 비동기 작업, 재처리 필요한 이벤트

핵심 차이는 메시지가 저장되는지 여부다. Pub/Sub은 빠르고 단순하지만 메시지를 보관하지 않는다. Stream은 메시지를 저장하고 처리 상태를 추적할 수 있어 장애 후 복구에 적합하다.

메시징과 이벤트 처리AI 생성 이미지

Pub/Sub: 지금 접속한 대상에게 바로 알리기

Pub/Sub은 Publish/Subscribe의 줄임말이다. 발행자 publisher는 채널에 메시지를 보내고, 구독자 subscriber는 관심 있는 채널을 구독한다. 발행자는 누가 듣고 있는지 몰라도 되고, 구독자는 누가 보냈는지 몰라도 된다.

터미널 하나에서 채팅방 채널을 구독해 보자.

SUBSCRIBE chat:room:1001

다른 터미널에서 같은 채널로 메시지를 발행한다.

PUBLISH chat:room:1001 'userId=42;text=hello'

구독 중인 클라이언트는 메시지를 즉시 받는다. PUBLISH의 응답은 메시지를 받은 구독자 수다. 응답이 0이면 그 순간 구독자가 없었다는 뜻이다.

이 구조는 실시간 채팅 알림, 타이핑 중 표시, 관리자 화면 새로고침 알림처럼 즉시성이 중요한 기능에 잘 맞는다. 웹소켓 서버가 여러 대라면 각 서버가 Redis 채널을 구독하고, 사용자가 보낸 메시지를 PUBLISH해서 다른 웹소켓 서버에 연결된 사용자에게 전달할 수 있다.

채널 이름도 키처럼 규칙이 필요하다.

prod:chat:room:1001
prod:user:42:notify
staging:chat:room:1001

Redis Pub/Sub은 Redis 데이터베이스 번호와 무관하게 동작한다. 따라서 운영, 스테이징, 테스트 환경을 같은 Redis에서 다룬다면 채널 prefix로 범위를 분리해야 한다.

또 하나의 실무 주의점은 구독 연결을 일반 명령 실행 연결과 분리하는 것이다. 클라이언트가 SUBSCRIBE 상태에 들어가면 메시지를 계속 읽는 용도로 사용된다. 대부분의 애플리케이션에서는 Pub/Sub 전용 커넥션을 따로 둔다.

Pub/Sub의 한계: 받지 못한 메시지는 사라진다

Pub/Sub을 작업 큐처럼 쓰면 위험하다. Redis Pub/Sub은 at-most-once 전달 방식이다. 쉽게 말해 메시지는 한 번 전달될 수도 있고, 상황에 따라 아예 전달되지 않을 수도 있다. Redis가 메시지를 보낸 뒤 구독자의 네트워크가 끊기거나 구독자가 처리 중 오류를 내면 그 메시지는 다시 받을 수 없다.1

다음 메시지는 Pub/Sub에 적합하다.

  • 채팅방에 새 메시지가 있으니 화면을 갱신하라는 신호

  • 사용자가 입력 중이라는 표시

  • 관리자 대시보드의 실시간 카운터 갱신 신호

  • 온라인 사용자에게만 보여 주는 가벼운 알림

반대로 다음 작업은 Pub/Sub만으로 처리하면 안 된다.

  • 결제 완료 후 영수증 메일 발송

  • 주문 생성 후 배송 시스템 연동

  • 회원 가입 후 쿠폰 지급

  • 재고 변경 이벤트 처리

  • 반드시 남아야 하는 감사 로그 기록

이런 작업은 메시지를 놓치면 데이터가 틀어진다. 사용자가 잠시 오프라인이었다는 이유로 결제 알림 기록이 사라져서는 안 된다. 워커가 재시작되는 순간 메일 발송 작업이 유실되어서도 안 된다.

판단 기준은 간단하다.

메시지를 놓쳐도 다음 조회나 새로고침으로 회복할 수 있으면 Pub/Sub을 고려한다. 메시지를 놓치면 별도 보정이 필요한 업무라면 Stream을 사용한다.

Stream: 이벤트를 로그로 남기기

Stream은 이벤트를 순서 있는 로그로 저장한다. XADD로 메시지를 추가하면 Redis는 각 메시지에 ID를 부여한다. ID는 보통 밀리초 타임스탬프와 순번이 결합된 형태다. Stream의 각 엔트리는 field-value 쌍으로 구성된다.2

주문 결제 이벤트를 Stream에 추가해 보자.

XADD order:events * type paid orderId 9001 userId 42 amount 39000

*는 Redis가 ID를 자동 생성하라는 뜻이다. 응답은 생성된 메시지 ID다.

1717000000000-0

Stream은 Pub/Sub과 달리 메시지를 저장한다. 소비자가 잠시 내려가 있어도 메시지는 Stream에 남아 있다. 나중에 다시 읽을 수 있다. 그래서 Stream은 비동기 작업 처리, 이벤트 파이프라인, 워커 기반 백그라운드 처리에 적합하다.

단, 저장된다는 말은 자동으로 영원히 안전하다는 뜻이 아니다. Stream도 Redis 메모리를 사용한다. 이벤트가 계속 쌓이면 메모리가 증가한다. 따라서 보관 기간이나 최대 길이를 정해야 한다.

XADD order:events MAXLEN ~ 100000 * type paid orderId 9002 userId 77 amount 12000

MAXLEN ~ 100000은 Stream 길이를 대략 100000개 수준으로 유지하라는 의미다. ~는 정확한 개수보다 성능을 우선하는 근사 trimming이다. 운영에서는 처리 지연과 pending 메시지를 확인한 뒤 너무 공격적으로 자르지 않도록 주의해야 한다.

Consumer Group: 여러 워커가 나누어 처리하기

Stream을 단순히 여러 워커가 읽기만 하면 같은 메시지를 중복 처리할 수 있다. Consumer Group은 이 문제를 해결한다. 하나의 그룹 안에 여러 consumer가 있으면 Redis가 메시지를 각 consumer에게 나누어 전달한다.

먼저 그룹을 만든다.

XGROUP CREATE order:events notification-workers $ MKSTREAM

여기서 notification-workers는 그룹 이름이다. $는 그룹 생성 이후 새로 들어오는 메시지부터 읽겠다는 뜻이다. 이미 쌓인 메시지까지 처음부터 처리하려면 $ 대신 0을 사용할 수 있다. MKSTREAM은 Stream이 없으면 생성하라는 옵션이다.

워커는 다음처럼 메시지를 읽는다.

XREADGROUP GROUP notification-workers worker-a COUNT 10 BLOCK 5000 STREAMS order:events >

각 부분의 의미는 다음과 같다.

  • notification-workers: 함께 작업을 나누는 그룹

  • worker-a: 현재 consumer 이름

  • COUNT 10: 한 번에 최대 10개 읽기

  • BLOCK 5000: 새 메시지가 없으면 최대 5초 대기

  • >: 이 그룹에 아직 전달되지 않은 새 메시지 읽기

같은 그룹에 worker-b, worker-c를 추가하면 메시지가 워커들 사이에 분산된다. 하나의 주문 이벤트는 같은 그룹 안에서 한 워커에게만 전달된다.

흥미로운 점은 그룹을 여러 개 만들 수 있다는 것이다.

order:events
  ├─ notification-workers
  ├─ analytics-workers
  └─ coupon-workers

알림 그룹, 통계 그룹, 쿠폰 그룹은 같은 Stream을 독립적으로 읽는다. 알림 처리가 끝났다고 통계 처리가 끝난 것은 아니다. 각 그룹은 자기 처리 위치와 pending 상태를 따로 가진다. 이 구조는 하나의 이벤트를 여러 후속 업무로 확장할 때 유용하다.

ACK와 Pending Entry: 처리 완료를 명확히 표시하기

Consumer Group에서 메시지를 읽었다고 처리가 끝난 것은 아니다. Redis는 XREADGROUP으로 전달한 메시지를 Pending Entries List, 줄여서 PEL에 넣는다. PEL은 전달됐지만 아직 완료 확인을 받지 못한 메시지 목록이다.2

워커는 실제 작업을 성공한 뒤 XACK를 호출해야 한다.

XACK order:events notification-workers 1717000000000-0

흐름은 다음과 같다.

  1. Producer가 XADD로 이벤트를 추가한다.

  2. Worker가 XREADGROUP으로 메시지를 받는다.

  3. Redis는 해당 메시지를 PEL에 등록한다.

  4. Worker가 알림 발송, DB 저장 같은 실제 작업을 수행한다.

  5. 성공하면 XACK로 완료를 알린다.

  6. Redis는 해당 메시지를 pending 상태에서 제거한다.

중요한 원칙이 있다.

먼저 처리하고, 성공한 뒤 ACK한다.

반대로 ACK를 먼저 보내고 작업을 나중에 하면 워커 장애 시 메시지가 사라진 것처럼 보인다. Redis는 이미 완료됐다고 판단하기 때문이다.

pending 상태는 다음 명령으로 확인할 수 있다.

XPENDING order:events notification-workers

상세 목록을 보려면 범위와 개수를 지정한다.

XPENDING order:events notification-workers - + 10

운영에서는 pending 개수와 idle time을 주기적으로 봐야 한다. pending이 계속 늘어난다면 워커가 처리에 실패하고 있거나, 처리 후 XACK를 누락하고 있을 가능성이 크다.

장애 후 재처리: 멈춘 워커의 메시지 회수하기

워커가 메시지를 받은 뒤 죽으면 어떻게 될까? 메시지는 Stream에서 사라지지 않는다. 대신 PEL에 남는다. 그래서 다른 워커가 회수해 다시 처리할 수 있다.

예를 들어 worker-a가 메시지를 받은 뒤 60초 이상 ACK하지 않았다면 worker-b가 다음처럼 가져올 수 있다.

XAUTOCLAIM order:events notification-workers worker-b 60000 0 COUNT 10

의미는 간단하다. notification-workers 그룹에서 60000ms 이상 idle 상태인 pending 메시지를 worker-b가 가져오겠다는 뜻이다. 이렇게 회수한 메시지를 다시 처리하고 성공하면 XACK한다.

이 구조는 at-least-once 처리에 가깝다. 적어도 한 번은 처리되도록 복구할 수 있지만, 같은 메시지가 두 번 처리될 수도 있다. 예를 들어 워커가 DB에는 성공적으로 저장했지만 XACK를 보내기 직전에 죽으면 Redis 입장에서는 아직 완료되지 않은 메시지다. 다른 워커가 다시 가져와 처리할 수 있다.

그래서 Stream 기반 이벤트 처리는 멱등성이 필요하다. 멱등성이란 같은 작업을 여러 번 실행해도 결과가 한 번 실행한 것과 같게 만드는 성질이다.

실무에서는 다음 방법을 자주 사용한다.

  • 이벤트 ID나 주문 ID를 DB의 unique key로 저장한다.

  • 이미 처리한 이벤트 ID는 SET processed:event:{id} 1 NX EX 86400으로 기록한다.

  • 알림 발송 이력을 남기고 같은 사용자에게 같은 알림을 중복 발송하지 않는다.

  • 외부 API 호출 전후 상태를 DB에 저장해 재시도 기준을 명확히 한다.

계속 실패하는 메시지도 있다. 예를 들어 payload가 잘못되었거나, 이미 삭제된 사용자에 대한 이벤트일 수 있다. 이런 메시지를 poison message라고 부른다. 무한 재시도하면 워커가 계속 같은 메시지에 묶인다. 일정 횟수 이상 실패한 메시지는 별도 Stream으로 옮기는 DLQ, 즉 Dead Letter Queue를 둔다.

XADD order:events:dlq * originalId 1717000000000-0 reason invalid_user
XACK order:events notification-workers 1717000000000-0

DLQ로 보낸 뒤 원본 메시지를 ACK할지 여부는 서비스 정책에 따라 결정한다. 보통은 운영자가 DLQ를 확인하고 원인을 수정한 뒤 재처리 도구로 다시 투입한다.

Pub/Sub과 Stream을 함께 쓰는 알림 구조

실시간 알림 기능을 만든다고 해 보자. 사용자가 온라인이면 즉시 화면에 보여 주고, 오프라인이면 나중에 알림함에서 확인할 수 있어야 한다.

이 요구사항을 Pub/Sub만으로 만들면 오프라인 사용자의 알림이 사라진다. 반대로 Stream만으로 만들면 웹소켓 서버에 즉시 신호를 보내는 부분이 번거로울 수 있다. 둘을 함께 쓰면 역할이 분명해진다.

  1. 주문 서버가 XADD notification:events로 알림 이벤트를 남긴다.

  2. 알림 워커 그룹이 Stream을 읽는다.

  3. 워커는 알림 데이터를 DB나 Redis Hash에 저장한다.

  4. 저장에 성공하면 PUBLISH user:42:notify로 온라인 서버에 신호를 보낸다.

  5. 웹소켓 서버는 해당 사용자가 연결되어 있으면 즉시 전달한다.

  6. 워커는 모든 처리가 성공한 뒤 XACK한다.

여기서 Stream은 놓치면 안 되는 알림 기록을 담당한다. Pub/Sub은 지금 접속 중인 사용자에게 빠르게 알려 주는 신호 역할만 한다. 사용자가 오프라인이라 Pub/Sub 메시지를 못 받아도 알림 기록은 남아 있으므로 다음 접속 때 조회할 수 있다.

설계할 때 확인할 질문

Redis 메시징을 사용할 때는 명령어보다 먼저 다음 질문에 답해야 한다.

  • 이 메시지는 유실되어도 되는가?

  • 한 메시지를 모든 구독자가 받아야 하는가, 워커 중 하나만 처리해야 하는가?

  • 처리 성공 여부를 추적해야 하는가?

  • 실패한 메시지를 언제, 누가, 몇 번 재처리할 것인가?

  • 같은 메시지가 두 번 처리되어도 안전한가?

  • Stream 길이와 보관 기간은 어떻게 제한할 것인가?

답이 즉시 전달이고 유실 허용이라면 Pub/Sub이 단순하다. 답이 비동기 처리, 재시도, 장애 복구라면 Stream과 Consumer Group이 더 적합하다.

Redis는 전문 메시지 브로커를 완전히 대체하기 위한 도구는 아니다. 복잡한 라우팅, 장기 보관, 대규모 메시지 추적, 엄격한 전달 보장이 핵심이라면 Kafka나 RabbitMQ 같은 전용 시스템을 검토해야 한다. 하지만 캐시와 세션을 위해 이미 Redis를 운영하고 있고, 가벼운 실시간 알림이나 중간 규모 이벤트 파이프라인이 필요하다면 Redis Pub/Sub과 Stream은 충분히 강력한 선택지가 된다.

참고

1Redis Pub/Sub 공식 문서

2Redis Insight Stream Consumer Group 문서

책 목록

Redis 입문과 실전 운영

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기