Redis Streams의 PEL(Pending Entries List)과 장애 복구 시나리오

2026. 3. 31.

Redis Streams

Redis Streams는 append-only 로그처럼 동작하면서도, 일반적인 append-only 로그의 한계를 넘어서는 여러 연산을 지원하는 데이터 구조입니다. O(1) 시간의 랜덤 액세스와 Consumer Group 같은 복합적인 소비 전략이 이에 해당합니다.

 

각 엔트리는 고유 ID(<밀리초 타임스탬프>-<시퀀스 번호> 형식)와 필드-값 쌍으로 구성됩니다. XADD로 엔트리를 추가하고, XREAD나 XREADGROUP으로 소비하며, XRANGE로 특정 ID 구간을 조회할 수 있습니다.

 

아래와 같이 발행, 소비 구조를 나타낼 수 있습니다.

 

이를 활용할 수 있는 대표적인 사례로는 이벤트 소싱(사용자 행동·클릭 등 추적), 센서 모니터링(디바이스 데이터 수집), 알림 시스템(사용자별 알림 기록) 등이 있습니다.

 

즉, Redis Streams는 실시간 이벤트 스트림 플랫폼으로서 역할을 수행할 수 있습니다.

 

참고 - Redis Streams | Docs

 

Redis Pub/Sub과 List 자료구조

하지만 Redis에서 Pub/Sub이나 List를 활용해서 메시지 전달 용도로 사용할 수 있지 않나? 라는 생각이 들었습니다.

 

그래서 이 둘에는 어떤 문제가 있고, Streams는 이를 어떻게 해결하는지에 대해 학습해 보았습니다.

 

먼저 Pub/Sub의 메시지는 fire-and-forget이며, 어디에도 저장되지 않습니다. 메시지가 발행되는 순간 연결된 구독자에게만 전달되고, 구독자가 다운 상태였다면 그 메시지는 사라집니다.

 

List 또한 메시지를 수신하는 순간 리스트에서 pop 되어 사라집니다. 따라서 소비자가 메시지를 꺼낸 직후 죽으면, 그 메시지는 리스트에서도 사라지고, 처리도 되지 않은 상태가 됩니다.

 

Redis Streams는 모든 메시지가 스트림에 보존되며, Consumer Group은 마지막으로 전달한 메시지 ID를 그룹 단위 커서(last_delivered_id)로 관리하여 새 메시지를 판별합니다.

 

결국 해결하는 것은 세 가지인데요.

  1. 같은 그룹의 워커들이 메시지를 중복 없이 나눠 가지는 분산 소비
  2. 누가 어떤 메시지를 받았고 처리를 완료했는지 기록하는 처리 추적
  3. 워커가 죽었을 때 미완료 메시지를 다시 이어받는 장애 복구

로 정리해 볼 수 있습니다.

 

이제 이를 Redis Streams는 어떻게 구현했는지에 대해 구체적으로 알아보겠습니다.

 

last_delivered_id와 PEL(Pending Entries List)

Stream 자체는 append-only 로그입니다. 메시지가 추가만 될 뿐 읽는다고 사라지지 않습니다. 따라서 Redis는 이 스트림 위에 Consumer Group을 연결하고, 그룹마다 두 가지 상태를 관리합니다.

 

1. last_delivered_id

last_delivered_id는 "이 그룹에 마지막으로 전달한 메시지의 ID"를 가리키는 커서입니다. 컨슈머가 새 메시지를 요청하면 커서 이후의 메시지가 전달되고, 커서가 전진합니다.

 

2. PEL

PEL은 "전달했지만 아직 ACK를 받지 않은 메시지"의 목록입니다. 각 목록에는 메시지 ID, 소유 컨슈머, 전달 시각, 전달 횟수(delivery count)가 기록됩니다. XACK이 호출되면 해당 엔트리가 PEL에서 제거됩니다. (Stream에서 메시지가 삭제되는 것이 아닙니다.)

 

동작 흐름

  • XACK(msg-4)가 오면 PEL에서 msg-4는 제거되고, stream에서는 삭제되지 않습니다.
  • XREADGROUP 요청이 오면 last_delivered_id가 msg-6으로 전진되고, msg-6은 PEL에 등록됩니다.

결국 커서가 어디로 움직이고 PEL에 무엇이 추가·제거되는지를 추적해 보면, 메시지가 정상적으로 소비되는 상황과 소비되지 않은 경우 어떻게 복구되는지에 대해 이해할 수 있습니다.

 

정상적인 동작 케이스 (커서 전진, PEL 등록, 제거)

Consumer Group을 생성할 때, 커서의 시작점을 정합니다.

XGROUP CREATE mystream mygroup $ MKSTREAM

$는 "지금 이후로 들어오는 메시지부터"라는 뜻입니다. 0으로 주면 스트림의 처음부터 전부 읽습니다. 이 값이 last_delivered_id의 초기값이 됩니다.

 

메시지를 수신하는 명령은 다음과 같습니다.

XREADGROUP GROUP mygroup consumer-1 COUNT 1 BLOCK 2000 STREAMS mystream >

>는 "last_delivered_id 이후의 아직 전달되지 않은 새 메시지를 달라"는 의미입니다. 이 명령이 실행되면 Redis 싱글 스레드 안에서 두 가지가 원자적으로 일어납니다. last_delivered_id가 방금 전달한 메시지 ID로 전진하고, 해당 메시지가 consumer-1의 PEL에 등록됩니다. 싱글 스레드이기 때문에 두 컨슈머가 동시에 같은 메시지를 받는 일은 구조적으로 불가능합니다.

 

컨슈머의 로직이 성공적으로 끝나면 XACK을 보냅니다.

XACK mystream mygroup 1234567890-0

PEL에서 해당 엔트리가 제거됩니다. 중요한 것은 Stream 자체에서 메시지가 삭제되는 것이 아니라는 점입니다. Stream은 append-only 로그이므로, 메시지의 물리적 삭제는 XDEL이나 XTRIM으로 별도 수행해야 합니다.

 

정상적인 케이스에서 커서와 PEL 변화는 시간 순서로 다음과 같습니다.

단계 명령 last_delivered_id PEL
초기 상태 msg-3 []
1. 메시지 수신 XREADGROUP GROUP mygroup c1 ... > msg-3 → msg-4 [msg-4 → c1]
2. 비즈니스 로직 처리 msg-4 [msg-4 → c1]
3. 처리 완료 XACK mystream mygroup msg-4 msg-4 []
4. 다음 메시지 수신 XREADGROUP GROUP mygroup c2 ... > msg-4 → msg-5 [msg-5 → c2]

 

이제 장애가 발생하는 케이스에 대해 알아보겠습니다.

 

장애 발생 케이스 1 (컨슈머 복구 후 재소비)

consumer-1이 메시지를 받은 후 처리 도중 죽었다가 다시 살아난 상황을 가정해 보겠습니다. 이 컨슈머의 PEL에는 미처리 메시지가 남아 있습니다.

 

재부팅된 컨슈머는 새 메시지를 받기 전에, 자기 PEL에 있는 미처리분부터 소비해야 합니다.

XREADGROUP GROUP mygroup consumer-1 STREAMS mystream 0

여기서 > 대신 0을 사용합니다. 0은 "내 PEL에 있는 것 중 아직 ACK 하지 않은 것을 다시 달라"는 의미입니다. 이 모드에서는 last_delivered_id가 움직이지 않습니다. 이미 전달된 메시지를 재열람하는 것이기 때문입니다. 새로운 메시지가 다른 컨슈머에게 전달되는 것을 방해하지도 않습니다.

 

미처리분을 모두 처리하고 XACK 한 뒤에, >로 전환해서 새 메시지를 받기 시작합니다.

 

정리하면, >와 0은 같은 XREADGROUP 명령이지만 완전히 다른 동작을 합니다.

  > (새 메시지)  0 (미처리 재열람)
커서 이동 last_delivered_id 전진 변화 없음
PEL 변화 새 엔트리 등록 변화 없음 (이미 등록된 것 재전달)

 

정상 케이스에서 복구가 어떻게 함께 이뤄지는지 흐름을 정리해 보겠습니다.

 

장애 발생 케이스 2 (다른 컨슈머가 대신 소비)

이번엔 consumer-1이 복구되는 데에 오래 걸리거나, 복구되지 않는 경우를 가정해 보겠습니다. PEL에 남아 있는 메시지는 아무도 건드리지 않으면 영원히 미처리 상태로 남습니다.

 

그럼 이 메시지는 어떻게 처리할 수 있을까요?

 

XCLAIM - 수동 소유권 이전

XCLAIM은 특정 메시지의 소유권을 다른 컨슈머에게 이전하는 명령입니다.

XCLAIM mystream mygroup consumer-2 5000 1234567890-0

여기서 5000은 min-idle-time(밀리초)입니다. 메시지가 보류 상태로 머무른 시간이 이 값을 초과한 경우에만 소유권 이전이 실행됩니다.

min-idle-time은 두 가지 역할을 합니다.


1. 정상 처리 중인 메시지의 보호

비즈니스 로직이 아직 실행 중일뿐인데 idle time 임계값이 너무 짧으면, 처리 도중에 소유권이 넘어가버립니다. min-idle-time을 정상 처리 시간보다 충분히 길게 설정하면 이를 방지할 수 있습니다.

 

2. 중복 이전 방지

XCLAIM이 실행되면 해당 메시지의 idle time이 0으로 리셋됩니다. 다른 컨슈머가 동시에 같은 메시지를 XCLAIM 하려 해도, idle time이 0이므로 min-idle-time 조건을 충족하지 못해 이전이 실패합니다.

 

XCLAIM이 실행되면 PEL에서 세 가지가 변경됩니다.

해당 메시지의 소유자가 consumer-1에서 consumer-2로 바뀌고, idle time이 0으로 리셋되고, delivery count가 1 증가합니다.
(last_delivered_id는 변하지 않습니다. 이미 전달된 메시지의 소유권만 이전하는 것이기 때문입니다.)

 

XAUTOCLAIM — 자동 소유권 이전

XCLAIM을 사용하려면 먼저 XPENDING으로 PEL을 조회해서 idle time이 초과한 메시지 ID를 확인하고, 그 ID를 XCLAIM에 넘겨야 합니다. 두 단계를 별도로 수행해야 하는 것입니다.

XAUTOCLAIM mystream mygroup consumer-2 5000 0-0 COUNT 10

이 명령은 PEL을 스캔하면서 idle time이 min-idle-time(5000ms) 이상인 메시지를 찾고, 해당 메시지들의 소유권을 consumer-2로 변경한 뒤, 메시지 내용과 함께 다음 스캔 시작점(커서 ID)을 반환합니다. 반환된 커서 ID를 다음 XAUTOCLAIM 호출에 넘기면 PEL 전체를 반복적으로 순회할 수 있습니다. 커서가 0-0으로 반환되면 전체 스캔이 완료된 것입니다.

 

컨슈머가 죽은 게 아니라 느리게 처리한 것이었다면? (At-Least-Once)

지금까지 두 가지 장애 복구 시나리오를 알아봤습니다. 그런데 이 시나리오에서 consumer가 죽은 게 아니라 외부 API 지연으로 느려진 것뿐인데, idle time이 min-idle-time을 초과하면 어떻게 될까요?

 

XCLAIM이나 XAUTOCLAIM으로 consumer-2에게 소유권이 넘어가고, consumer-2가 처리를 시작합니다. 그런데 consumer-1도 아직 살아서 같은 메시지를 처리 중이게 됩니다.

 

이것이 Consumer Group이 at-least-once만 보장하는 구조적 이유이고, 메시지 ID를 멱등키로 사용해서 애플리케이션 레벨에서 중복 처리를 방어해야 하는 이유입니다.

 

무한으로 재시도하는 케이스와 Dead Letter

XCLAIM이나 XAUTOCLAIM으로 소유권을 넘겨도 처리에 실패하면, delivery count만 계속 올라갑니다.

 

이렇게 delivery count만 올라가는 경우는 파싱 에러, 스키마 불일치 등 근본적으로 처리 불가능한 메시지일 가능성이 높습니다.

 

이런 메시지는 재시도를 하더라도 복구 가능성이 없는 메시지라고 볼 수 있습니다. 이런 메시지를 dead letter라고 합니다.

 

XPENDING 응답에 포함된 delivery count를 확인하고, 임계값(ex. 3회)을 초과하면 해당 메시지를 별도 스트림(DLQ)으로 XADD 한 뒤, 원래 그룹에서 XACK 처리해서 PEL에서 제거하여 처리할 수 있습니다.

XADD dlq-stream * original-id 1234567890-0 payload "..."
XACK mystream mygroup 1234567890-0

 

PEL에서 제거되므로 더 이상 XCLAIM 대상이 되지 않고, 무한 재시도 루프가 끊깁니다. DLQ에 들어간 메시지는 별도로 분석하거나 관리자가 수동 재처리할 수 있습니다.

 

미처리 메시지가 처리되는 흐름을 정리하면 다음과 같습니다.

운영에서 추가로 고려하면 좋을 사항

 

1. idle time 임계값 설정

너무 짧으면 정상적으로 느린 처리도 XCLAIM 대상이 되어 중복 처리가 빈발할 수 있습니다. 반대로 너무 길면 진짜 죽은 컨슈머의 메시지가 그 시간만큼 방치됩니다. 비즈니스 로직의 정상 처리 시간 분포를 관측해서 적절한 수치로 설정하는 것이 좋습니다.

 

2. Stream 메모리 초과

Stream은 append-only이고, XACK은 PEL에서만 제거할 뿐 Stream 자체의 메시지를 지우지 않습니다. XTRIM이나 MAXLEN을 설정하지 않으면, 처리 완료된 메시지까지 전부 메모리에 남아서 Redis 메모리가 끝없이 증가할 수 있습니다. XADD mystream MAXLEN ~ 10000 * field value 식으로 근사 트리밍을 걸어두거나, 별도 배치로 XTRIM을 주기적으로 실행해야 합니다.

 

참고 자료

Redis Streams | Docs
Commands | Docs

 

Redis Streams

Introduction to Redis streams

redis.io