Redis Streams 제대로 알기: 기본 개념부터 운영 노하우까지
1. Redis Streams란
1-1. 기본 개념: 계속 쌓이는 로그 구조
이전 글에서 다룬 Redis Pub/Sub의 가장 큰 단점은 메시지가 저장되지 않고 즉시 사라진다는 점이었습니다. 구독자가 그 순간 연결되어 있지 않으면 메시지는 영구히 유실됩니다.
Redis Streams는 Redis 5.0에 도입된 데이터 구조로, 이 문제를 정면으로 해결합니다. 데이터를 삭제하지 않고 시간 순서대로 계속 추가(Append-only)하는 로그 형태의 구조입니다. 쉽게 말해 “Redis 내부에서 동작하는 가볍고 강력한 Kafka”라고 이해하면 됩니다. 메시지가 저장되기 때문에 구독자가 나중에 접속해도 지나간 메시지를 다시 읽을 수 있습니다.
1-2. 핵심 동작 원리
- 자동 타임스탬프 ID 생성: 메시지가 저장될 때마다
1692430000000-0형태의 고유 ID가 자동 생성됩니다.밀리초 타임스탬프-일련번호구조라 시간 순서가 항상 보장됩니다. - 메시지 영구 저장: Pub/Sub과 달리 수신자가 없어도 메시지가 Redis 메모리에(그리고 RDB/AOF로 설정했다면 디스크에도) 저장됩니다.
- 컨슈머 그룹(Consumer Group): Kafka의 Consumer Group과 같은 개념입니다. 하나의 스트림을 여러 워커가 나눠서 병렬로 처리할 수 있습니다. 그룹 안의 워커들은 서로 다른 메시지를 받아가므로 중복 처리 없이 부하를 분산합니다.
- ACK(Acknowledge) 메커니즘: 워커가 메시지 처리를 끝내면
XACK로 “이 메시지는 다 처리했다”고 알려줘야 합니다. ACK를 받지 못한 메시지는 처리 중으로 간주되어, 워커가 죽어도 사라지지 않고 다른 워커가 이어받아 재처리할 수 있습니다.
1-3. 실무에서 언제 쓰나
- 유실이 허용되지 않는 백그라운드 작업 큐: 이메일 발송, 결제 후처리처럼 반드시 처리되어야 하는 비동기 작업
- 이벤트 스트리밍: 사용자 클릭 스트림, IoT 센서 데이터처럼 순서와 이력이 중요한 대량 데이터 수집
- 오프라인 유저의 안 읽은 메시지 처리: 채팅에서 유저가 오프라인이었던 동안의 메시지를 재접속 시 그대로 가져와야 하는 경우
Pub/Sub이 “지금 이 순간 연결된 사람에게만 전달하면 되는 실시간 방송”이라면, Streams는 “누가 언제 와서 가져가든 반드시 전달되어야 하는 우편함”에 가깝습니다.
2. Redis Streams vs List 큐 vs Pub/Sub 비교
| 기능 | Redis List (LPUSH/RPOP) |
Redis Pub/Sub | Redis Streams |
|---|---|---|---|
| 데이터 보존 | O (조회 시 삭제됨) | X (즉시 휘발) | O (지속 보존) |
| 1:N 브로드캐스팅 | X (단일 소비자만 수신) | O | O (Consumer Group 활용) |
| 소비자 분산 처리 | X (직접 구현 필요) | X | O (Consumer Group 분배) |
| 메시지 재처리(ACK) | X | X | O (XPENDING/XCLAIM) |
| 과거 데이터 조회 | 제한적(LRANGE) |
불가 | O (특정 ID/시간대 범위 조회) |
선택 기준은 결국 “유실 허용 여부”와 “여러 소비자가 같은 메시지를 각자 받아야 하는지, 나눠서 받아야 하는지”입니다.
- 유실돼도 되는 단순 실시간 브로드캐스트 → Pub/Sub
- 유실은 안 되지만 여러 소비자가 각자 다른 목적으로 같은 이벤트를 받아야 함 → Streams (Consumer Group)
- 그냥 순서대로 하나씩 꺼내 쓰는 단순 작업 큐면 충분 → List로도 가능하지만, 재처리·병렬 분산이 필요하면 결국 Streams가 낫습니다.
3. 운영 시 반드시 알아야 할 핵심 사항
3-1. 메모리 관리 (MAXLEN 설정 필수)
기초 개념: 스트림은 기본적으로 데이터를 계속 저장하므로 아무 제한이 없으면 용량이 무한정 늘어납니다.
XADD mystream MAXLEN ~ 10000 * field value
# 최근 약 10000개 항목만 유지 (~는 근사치 트리밍으로 성능 부담을 줄임)
실전 기준: MAXLEN 값은 “컨슈머가 정상 처리에 걸리는 최대 지연 시간 × 초당 유입량”보다 여유 있게 잡아야 합니다. 예를 들어 초당 100개씩 쌓이고 컨슈머가 최대 5분 지연될 수 있다면, 최소 100 × 300 = 30,000개 이상의 여유분을 둬야 트리밍이 미처리 메시지를 삼켜버리는 사고를 막을 수 있습니다.
3-2. 미처리 메시지 관리 (PEL, XAUTOCLAIM)
기초 개념: XACK를 받지 못한 메시지는 Pending Entries List(PEL)에 쌓입니다. 워커가 처리 중 죽으면 그 메시지는 PEL에 갇힌 채로 남습니다.
과거에는 XPENDING으로 오래 대기 중인 메시지를 찾고 XCLAIM으로 다른 워커가 인수하는 2단계 작업이 필요했습니다. 지금은 이 둘을 한 번에 처리하는 XAUTOCLAIM을 쓰는 것이 표준입니다.
실전 사례: 각 워커가 시작할 때(또는 주기적으로) 자신의 그룹에서 XAUTOCLAIM을 돌려, 일정 시간(idle) 이상 처리되지 않은 메시지를 스스로 인수하도록 구성하면 죽은 워커의 메시지가 보이지 않게 방치되는 일을 막을 수 있습니다.
XAUTOCLAIM mystream mygroup myconsumer 30000 0-0 COUNT 100
# 30초 이상 idle 상태인 메시지를 myconsumer가 인수, 0-0부터 최대 100개 스캔
3-3. MAXLEN 트리밍과 PEL의 충돌 (놓치기 쉬운 함정)
문제: XTRIM이나 MAXLEN 트리밍은 PEL에 아직 남아있는(= 아직 ACK되지 않은) 엔트리라도 실제 데이터를 지워버릴 수 있습니다. 즉, ID는 PEL에 남아있는데 실제 메시지 본문은 이미 사라진 상태가 됩니다. 이 경우 XAUTOCLAIM이 그 ID를 다시 넘겨줘도 재처리가 불가능합니다.
대처: 이런 케이스는 로그로 남기고 Dead Letter Queue(별도 스트림이나 테이블)로 라우팅해 감사(audit)할 수 있도록 처리해야 합니다. 3-1에서 MAXLEN을 여유 있게 잡는 것도 이 문제를 줄이는 방법이지만, 완전히 막을 수는 없으므로 컨슈머 로직에 “데이터가 사라진 pending 항목”을 감지하는 방어 코드를 넣는 게 안전합니다.
3-4. 컨슈머 생명주기 관리
문제: 컨슈머 이름을 매번 랜덤하게(예: 프로세스 시작 시 UUID로) 생성하면, 재배포나 오토스케일링으로 워커가 자주 뜨고 죽을 때마다 그룹 안에 “유령 컨슈머”가 계속 쌓입니다. 이 유령 컨슈머들이 가져갔던 메시지는 PEL에 남아 방치됩니다.
대처: 컨슈머 이름은 인스턴스 ID나 호스트명처럼 재시작해도 어느 정도 예측 가능한 값을 쓰는 것이 좋습니다. 이미 죽은 것이 확실한 컨슈머는 XGROUP DELCONSUMER로 명시적으로 정리해 그룹 상태를 깨끗하게 유지해야 합니다.
3-5. At-least-once 전달과 멱등성(Idempotency) 처리
기초 개념: Streams는 메시지 유실을 막아주지만, “정확히 한 번(Exactly-once)” 처리를 보장하지는 않습니다. 워커가 메시지를 처리한 뒤 XACK를 보내기 전에 죽으면, 그 메시지는 PEL에 남아 다른 워커가 다시 가져가 처리합니다. 즉 같은 메시지가 두 번 처리될 수 있는 At-least-once(최소 한 번) 보장입니다.
실전 대처: 컨슈머 로직은 반드시 멱등적으로 설계해야 합니다. 예를 들어 “포인트 적립” 같은 작업이라면 메시지 ID나 별도의 고유 키를 기준으로 “이미 처리한 적립 요청인지” 먼저 확인하고, 처리했다면 그대로 XACK만 보내고 종료하는 방식입니다. 이 검증 로직이 없으면 워커 재시작이나 네트워크 지연 한 번에 중복 적립 같은 사고가 발생할 수 있습니다.
3-6. 스케일링의 한계
기초 개념: 하나의 스트림은 결국 하나의 Redis 키입니다. Redis Cluster 환경에서는 키 하나가 하나의 해시슬롯에만 위치하므로, 스트림 하나는 물리적으로 한 노드(샤드)에서만 처리됩니다. Kafka처럼 파티션이 여러 브로커에 자동으로 분산되는 구조가 아닙니다.
실전 대처: 트래픽이 한 노드의 처리량을 넘어설 것으로 예상되면, 처음부터 스트림을 여러 개로 쪼개 애플리케이션 레벨에서 분산해야 합니다. 예를 들어 orders-stream-0, orders-stream-1처럼 유저 ID나 주문 ID를 해싱해 스트림을 나누고, 각 워커 그룹이 자신이 담당하는 스트림만 구독하게 설계하는 방식입니다. 이건 Kafka의 파티션 전략을 수동으로 구현하는 것과 같습니다. 처음부터 단일 스트림으로 설계했다가 나중에 쪼개려면 데이터 마이그레이션이 필요하므로, 트래픽이 커질 가능성이 있다면 초기 설계 단계에서 미리 고려해두는 것이 좋습니다.
핵심요약
- Redis Streams는 메시지를 영구 저장하는 Append-only 로그 구조로, Pub/Sub의 유실 문제를 해결하고 Consumer Group을 통한 병렬 처리와 ACK 기반 재처리를 지원한다.
- 무한정 쌓이는 것을 막기 위해
MAXLEN트리밍이 필수이며, 트리밍 값은 컨슈머 최대 지연시간을 감안해 여유 있게 잡아야 한다. - 미처리 메시지는 PEL에 쌓이며, 오늘날은
XPENDING+XCLAIM대신XAUTOCLAIM한 번으로 복구하는 것이 표준이다. - MAXLEN 트리밍이 PEL에 남은 미처리 메시지의 실제 데이터를 지울 수 있으므로, 이런 경우를 감지해 Dead Letter Queue로 라우팅하는 방어 로직이 필요하다.
- Streams는 At-least-once 전달만 보장하므로 컨슈머는 반드시 멱등적으로 설계해야 하며, 컨슈머 이름 관리와
XGROUP DELCONSUMER로 유령 컨슈머를 정리해야 한다. - 하나의 스트림은 하나의 해시슬롯/노드에 묶이므로, 대용량 트래픽이 예상되면 초기 설계 단계에서 스트림을 여러 개로 나누는 파티셔닝 전략을 고려해야 한다.