본문 바로가기
NoSQL

Redis Streams로 경량 메시지 큐 만들기 - XADD와 컨슈머 그룹, ACK와 PEL로 실패 메시지 재처리하기

빅토르최·2026년 9월 1일·조회 0

WAS 여러 대를 붙여 운영하다 보면 "이 작업은 요청 응답에서 떼어내 뒤에서 처리하고 싶다"는 순간이 온다. 메일 발송, 썸네일 생성, 외부 API 재시도 같은 것들이다. 이럴 때 곧장 Kafka나 SQS부터 떠올리는 경우가 많은데, 이미 세션 캐시나 레이트리밋 용도로 Redis를 띄워 둔 현장이라면 굳이 새 미들웨어를 하나 더 들일 필요가 없을 때가 있다. Redis 5.0부터 들어온 Streams 자료형이 그 자리를 꽤 잘 메운다.

결론부터 말하자면, Redis Streams는 XADD로 메시지를 append-only 로그에 쌓고, XREADGROUP 기반의 컨슈머 그룹으로 여러 워커에 메시지를 나눠 준다. 각 메시지는 처리 후 XACK로 확인 응답을 보내야 하며, 확인되지 않은 메시지는 PEL(Pending Entries List)에 남아 있다가 XCLAIM 또는 XAUTOCLAIM으로 다른 워커가 다시 가져가 재처리할 수 있다. 즉 at-least-once 전달과 실패 복구에 필요한 최소 장치가 명령어 수준에서 이미 갖춰져 있다.

Streams와 컨슈머 그룹, PEL이 무엇인가

Redis Stream은 이름 그대로 append-only 로그다. 각 항목은 <밀리초>-<시퀀스> 형식의 ID(예: 1692632086370-0)를 시간순으로 부여받고, 그 안에 필드-값 쌍을 담는다. 리스트(LPUSH/BRPOP)와 달리 메시지를 읽어도 사라지지 않고 로그에 남기 때문에, 여러 소비자가 같은 스트림을 각자의 위치에서 읽을 수 있다.

컨슈머 그룹은 한 스트림을 여러 워커가 나눠서 소비하게 해 주는 장치다. 그룹은 "마지막으로 배달한 ID"를 하나 들고 있고, 그룹에 속한 컨슈머가 새 메시지를 요청하면 서로 겹치지 않게 다른 메시지를 넘겨준다. Kafka의 컨슈머 그룹과 개념이 닮았다.

PEL(Pending Entries List)은 그룹마다 유지되는 내부 목록으로, 배달했지만 아직 XACK를 받지 못한 메시지를 추적한다. 어떤 컨슈머가 들고 있는지, 마지막 배달 이후 얼마나 지났는지(idle time), 몇 번 배달됐는지(delivery count)를 함께 기록한다. 이 PEL 덕분에 워커가 처리 도중 죽어도 메시지가 유실되지 않고, 나중에 다른 워커가 집어갈 수 있다. 아래에서 XADD부터 재처리까지 차례로 살펴본다.

XADD로 메시지 넣기

먼저 메시지를 스트림에 넣는다. ID 자리에 *를 주면 Redis가 서버 시각 기준으로 ID를 자동 생성한다.

127.0.0.1:6379> XADD jobs:mail * to alice@example.com tmpl welcome
"1756704000123-0"
127.0.0.1:6379> XADD jobs:mail * to bob@example.com tmpl reset
"1756704000456-0"
127.0.0.1:6379> XLEN jobs:mail
(integer) 2

스트림은 그대로 두면 무한히 자란다. 큐 용도라면 MAXLEN으로 상한을 두는 편이 좋다. ~를 붙이면 근사 트리밍이라 성능 손해가 거의 없다. 정확히 N개로 자르는 =보다 ~를 권한다.

127.0.0.1:6379> XADD jobs:mail MAXLEN ~ 100000 * to carol@example.com tmpl welcome
"1756704000789-0"

XGROUP CREATE로 컨슈머 그룹 만들기

그룹을 만들 때 시작 ID를 정해야 한다. $는 "지금 이후 새 메시지부터", 0은 "스트림 처음부터"를 뜻한다. 큐라면 대개 0으로 두어 이미 쌓인 메시지도 처리하게 한다. 스트림이 아직 없다면 MKSTREAM으로 스트림과 그룹을 한 번에 만든다(Redis 6.2+).

127.0.0.1:6379> XGROUP CREATE jobs:mail mailer 0 MKSTREAM
OK
127.0.0.1:6379> XINFO GROUPS jobs:mail
1)  1) "name"
    2) "mailer"
    3) "consumers"
    4) (integer) 0
    5) "pending"
    6) (integer) 0
    7) "last-delivered-id"
    8) "0-0"

여기서 한 번 걸리기 쉽다. 같은 이름으로 그룹을 또 만들면 BUSYGROUP Consumer Group name already exists 에러가 난다. 부팅 시점에 그룹 생성을 매번 호출하는 구조라면 이 에러는 무시하도록 잡아 두면 된다.

XREADGROUP으로 읽고 분배하기

워커는 XREADGROUP으로 메시지를 가져간다. 컨슈머 이름은 워커마다 다르게 준다(예: worker-1, worker-2). 읽기 위치에 특수 ID >를 주면 아직 이 그룹의 누구에게도 배달되지 않은 새 메시지를 받는다. 이 순간 메시지는 PEL에 등록된다.

127.0.0.1:6379> XREADGROUP GROUP mailer worker-1 COUNT 10 STREAMS jobs:mail >
1) 1) "jobs:mail"
   2) 1) 1) "1756704000123-0"
         2) 1) "to"
            2) "alice@example.com"
            3) "tmpl"
            4) "welcome"
      2) 1) "1756704000456-0"
         2) 1) "to"
            2) "bob@example.com"
            3) "tmpl"
            4) "reset"

새 메시지가 없을 때 busy-loop로 계속 쏘면 CPU만 태운다. BLOCK으로 블로킹 대기를 걸면 메시지가 올 때까지 지연 없이 깨어난다. 0을 주면 무한 대기다.

127.0.0.1:6379> XREADGROUP GROUP mailer worker-1 COUNT 10 BLOCK 5000 STREAMS jobs:mail >
(nil)     # 5초 동안 새 메시지 없으면 nil 반환, 그때 다시 호출

같은 워커가 재기동한 뒤 "내가 예전에 받았지만 아직 ACK 못 한 메시지"를 다시 보려면 위치에 > 대신 0을 준다. 이때는 새 메시지가 아니라 그 컨슈머의 PEL에 남은 것만 돌려준다. 워커 시작 시 0으로 밀린 것부터 훑고, 비면 >로 넘어가는 방식이 안정적이다.

참고로 NOACK 옵션을 주면 PEL에 넣지 않고 곧바로 배달 처리한다. 로그 수집이나 통계처럼 유실을 감수할 수 있는 fire-and-forget 용도에만 쓴다. 재처리가 필요한 작업 큐에는 쓰지 않는다.

XACK와 PEL - 처리 확인

메시지를 성공적으로 처리했으면 XACK로 확인 응답을 보낸다. 그래야 PEL에서 빠진다. ACK를 빠뜨리면 메시지는 계속 pending 상태로 남아 나중에 재처리 대상이 된다.

127.0.0.1:6379> XACK jobs:mail mailer 1756704000123-0
(integer) 1
127.0.0.1:6379> XPENDING jobs:mail mailer
1) (integer) 1                    # 아직 pending 1건 (bob 메시지 미ACK)
2) "1756704000456-0"
3) "1756704000456-0"
4) 1) 1) "worker-1"
      2) "1"

핵심은 순서다. 처리를 끝낸 뒤에 XACK를 호출해야 한다. 처리 전에 ACK부터 하면(또는 NOACK를 쓰면) 워커가 중간에 죽었을 때 그 메시지를 복구할 근거가 사라진다.

XPENDING, XCLAIM, XAUTOCLAIM으로 실패 재처리

워커가 처리 도중 죽으면 그 메시지는 죽은 워커의 PEL에 갇힌다. 살아 있는 다른 워커가 이걸 집어가야 재처리가 된다. 먼저 XPENDING의 확장형으로 오래 묵은 pending 메시지를 조회한다. 마지막 컬럼이 delivery count다.

127.0.0.1:6379> XPENDING jobs:mail mailer - + 10
1) 1) "1756704000456-0"
   2) "worker-1"      # 소유 컨슈머
   3) (integer) 92000 # idle time(ms), 92초째 방치
   4) (integer) 1     # delivery count

수동으로 넘길 때는 XCLAIM을 쓴다. min-idle-time을 조건으로 걸어, 그 시간보다 오래 놀고 있던 메시지만 새 컨슈머 소유로 바꾼다. 여기서 idle 조건이 중요하다. 아직 다른 워커가 처리 중일 수 있는 메시지를 성급히 뺏지 않도록, 처리 타임아웃보다 넉넉히 큰 값을 준다.

127.0.0.1:6379> XCLAIM jobs:mail mailer worker-2 60000 1756704000456-0
1) 1) "1756704000456-0"
   2) 1) "to"
      2) "bob@example.com"
      3) "tmpl"
      4) "reset"

ID를 일일이 지정하기 번거로우면 XAUTOCLAIM(Redis 6.2+)이 편하다. XPENDING과 XCLAIM을 한 번에 처리하는 명령으로, PEL을 SCAN처럼 커서로 훑으며 조건에 맞는 메시지를 자동으로 넘긴다. 시작 커서는 0-0, COUNT는 기본 100이다. 반환값 첫 요소가 다음 호출에 쓸 커서이고, 0-0이 돌아오면 한 바퀴 다 훑은 것이다.

127.0.0.1:6379> XAUTOCLAIM jobs:mail mailer worker-2 60000 0-0 COUNT 25
1) "0-0"                       # 다음 커서(0-0 = 스캔 완료)
2) 1) 1) "1756704000456-0"
      2) 1) "to"
         2) "bob@example.com"
         3) "tmpl"
         4) "reset"
3) (empty array)              # 스트림에서 삭제돼 PEL에서 정리된 ID들

운영에서는 워커마다 주기적으로(예: 수 초~수십 초 간격) XAUTOCLAIM ... 0-0을 한 번씩 돌려 밀린 메시지를 회수하게 두면 된다. min-idle-time만 처리 타임아웃보다 크게 잡으면 정상 처리 중인 메시지를 건드리지 않는다.

독약 메시지와 데드레터 처리

재처리 장치를 붙이면 반대 위험이 생긴다. 특정 메시지가 매번 워커를 죽이면 무한히 다시 배달되며 큐를 막는다. 이런 독약(poison) 메시지는 delivery count로 잡는다. XPENDING 또는 XCLAIM/XAUTOCLAIM 결과의 배달 횟수가 임계값(예: 5회)을 넘으면, 별도 데드레터 스트림으로 옮기고 원본은 XACK로 정리한다.

# delivery count가 임계 초과한 메시지를 데드레터로 이관
127.0.0.1:6379> XADD jobs:mail:dead * orig-id 1756704000456-0 reason max-retries
"1756704099999-0"
127.0.0.1:6379> XACK jobs:mail mailer 1756704000456-0
(integer) 1

Redis에는 SQS 같은 데드레터 큐가 명령어로 내장돼 있지 않다. delivery count 판정과 이관을 워커 코드에서 직접 해야 한다. 이 점은 뒤에서 다룰 선택 기준과도 연결된다.

워커 처리 순서 정리

지금까지 명령을 한 워커의 반복 처리 순서로 묶으면 이렇다.

  1. 기동 시 XGROUP CREATE ... 0 MKSTREAM(BUSYGROUP 에러는 무시).
  2. XREADGROUP GROUP g worker-N COUNT k STREAMS key 0로 밀린 PEL 먼저 비운다.
  3. XREADGROUP ... BLOCK 5000 STREAMS key >로 새 메시지를 블로킹 수신.
  4. 메시지 처리 성공 시 XACK, 실패 시 그대로 두어 PEL에 남긴다.
  5. 주기적으로 XAUTOCLAIM key g worker-N <타임아웃ms> 0-0으로 죽은 워커 몫을 회수.
  6. 회수한 메시지의 delivery count가 임계 초과면 데드레터로 이관 후 XACK.

Java라면 Lettuce의 xreadgroup/xack/xautoclaim, Jedis의 동명 메서드로 그대로 옮겨진다. 명령 이름과 인자가 CLI와 1:1이라 옮기기 쉽다.

Kafka, SQS 대신 Redis Streams를 고르는 기준

세 가지는 겨루는 관계라기보다 규모와 요구사항이 다르다. 직접 운영해 본 기준으로 정리하면 이렇다.

Redis Streams가 맞는 경우. 이미 Redis를 쓰고 있고, 처리량이 초당 수천에서 수만 메시지 수준이며, 메시지를 짧게 보관해도 되는 작업 큐다. 브로커를 새로 도입하지 않고 명령어 몇 개로 at-least-once와 재처리를 얻는 게 가장 큰 이점이다. 지연도 인메모리라 낮다.

Kafka가 맞는 경우. 초당 수십만 이상의 대용량 이벤트, 며칠에서 몇 주 단위의 장기 보존과 리플레이, 여러 소비 그룹이 같은 로그를 각자 재생하는 이벤트 소싱 구조다. 파티션 단위 확장과 디스크 기반 보존이 Redis Streams와 근본적으로 다른 지점이다.

SQS가 맞는 경우. 이미 AWS 위에서 돌고, 서버를 직접 관리하고 싶지 않을 때다. 데드레터 큐, 가시성 타임아웃, 자동 확장이 관리형으로 제공돼 운영 부담이 가장 적다. 대신 지연은 Redis보다 크고, 초당 처리량과 순서 보장에는 제약이 있다.

Redis Streams를 고를 때 감안할 한계도 분명하다. 데이터가 메모리에 있어 보존량이 RAM에 묶이고, 데드레터와 재시도 정책을 직접 구현해야 하며, 내구성은 Redis의 지속화(AOF/RDB)와 복제 구성에 달려 있다. "이미 있는 Redis로 가벼운 비동기 작업 큐를 빠르게 붙이고 싶다"가 핵심 동기라면 Streams가 잘 맞고, 대용량 로그 파이프라인이나 완전 관리형 운영이 목표라면 Kafka나 SQS 쪽으로 가는 게 맞다.

자주 묻는 질문

XREADGROUP의 > 와 0은 어떻게 다른가?

> 는 이 그룹의 어떤 컨슈머에게도 아직 배달되지 않은 새 메시지를 받고, 받는 즉시 PEL에 등록한다. 0(또는 특정 ID)은 새 메시지가 아니라 그 컨슈머가 이미 받았지만 아직 XACK하지 않은 pending 메시지만 돌려준다. 워커를 재기동하면 먼저 0으로 밀린 것을 훑고 비면 > 로 넘어가는 방식이 안정적이다.

XCLAIM과 XAUTOCLAIM 중 무엇을 써야 하나?

특정 메시지 ID를 골라 넘길 때는 XCLAIM, PEL 전체를 커서로 훑으며 오래 묵은 메시지를 자동 회수할 때는 XAUTOCLAIM(Redis 6.2+)을 쓴다. 운영 재처리 루프에는 대개 XAUTOCLAIM key group consumer <타임아웃ms> 0-0 형태가 편하다. XAUTOCLAIM은 XPENDING과 XCLAIM을 한 번에 처리하며 COUNT 기본값은 100이다.

같은 메시지가 계속 재처리되며 큐가 막힐 때는 어떻게 하나?

매번 워커를 실패시키는 독약 메시지다. XPENDING이나 XCLAIM/XAUTOCLAIM 반환값의 delivery count(배달 횟수)를 확인해 임계값(예: 5회)을 넘으면, 별도 데드레터 스트림으로 XADD한 뒤 원본은 XACK로 정리한다. Redis에는 데드레터 큐가 내장돼 있지 않아 이 판정과 이관을 워커 코드에서 직접 구현해야 한다.

XACK를 처리 전에 하면 안 되는 이유는?

XACK를 먼저 호출하거나 NOACK로 읽으면 메시지가 PEL에서 즉시 빠진다. 그 상태로 워커가 처리 도중 죽으면 복구할 근거가 사라져 메시지가 유실된다. at-least-once 전달을 유지하려면 반드시 처리를 끝낸 다음 XACK를 호출해야 한다. NOACK는 유실을 감수할 수 있는 로그, 통계 용도에만 쓴다.

스트림이 메모리를 무한히 먹지 않게 하려면?

XADD 시 MAXLEN 옵션으로 상한을 둔다. XADD key MAXLEN ~ 100000 * ... 처럼 물결표(~)를 붙이면 근사 트리밍이라 성능 손해가 거의 없다. 정확히 N개로 자르는 = 보다 ~ 를 권한다. 트리밍은 PEL과 무관하게 오래된 항목을 로그에서 지우므로, 보존 기간과 재처리 창을 함께 고려해 값을 잡아야 한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.