에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/캐시

[Redis] Streams 메시지 큐: 소비자 그룹, XACK, PEL 재처리 그리고 Kafka 비교

반응형
[Redis] Streams 메시지 큐: 소비자 그룹, XACK, PEL 재처리 그리고 Kafka 비교

[Redis] Streams 메시지 큐: 소비자 그룹, XACK, PEL 재처리 그리고 Kafka 비교

Redis로도 메시지 큐를 만들 수 있어요. List로 간단한 큐는 되지만 여러 소비자가 나눠 처리하거나 처리 실패를 추적하려면 부족해요. Redis Streams는 그걸 제대로 해줘요. 카프카의 로그·소비자 그룹 개념을 Redis 안에 가져온 자료구조예요.

List와 Pub/Sub이 어디서 부족한지 먼저 짚어요. Streams의 동작과 소비자 그룹, 미처리 메시지 추적을 차례로 본 다음, "언제 Redis Streams를 쓰고 언제 카프카로 가는지"로 마무리해요. 자료구조 글을 먼저 보면 좋아요.

01. List·Pub/Sub로는 부족해요

Redis로 메시지를 주고받는 방법은 원래 둘이 있었어요. 둘 다 한계가 있어요.

Redis 메시지 전달 방식 비교. List 큐는 꺼낸 소비자가 죽으면 메시지가 사라지고, Pub/Sub은 영속성이 없어 구독자가 없을 때 발행된 메시지가 소멸하며, Streams는 append-only 로그에 소비자 그룹과 ack와 미처리 재처리를 갖췄다

List 큐LPUSH/RPOP으로 간단히 큐를 만들지만 한 메시지를 한 소비자만 가져가요. 여러 소비자가 "그룹으로 나눠 처리"하거나, 가져간 메시지의 처리 성공을 추적하는 기능이 없어요. 소비자가 메시지를 꺼낸 뒤 죽으면 그 메시지는 사라져요.

Pub/Sub은 발행하면 그 순간 구독 중인 모두에게 가요. 그런데 영속성이 없어요. 구독자가 없을 때 발행된 메시지는 그냥 사라지고 나중에 들어온 구독자는 과거 메시지를 못 봐요. "놓치면 끝"이라 신뢰성 있는 처리엔 안 맞아요.

02. Redis Streams

Streams는 append-only 로그예요. 메시지를 추가하면 시간순 ID가 붙어 영구히(또는 정해진 만큼) 쌓여요. 카프카의 토픽·오프셋과 비슷한 개념이에요.

// 메시지 추가 — 자동 ID 부여
redisTemplate.opsForStream().add("orders", Map.of("orderId", "o-1"));

// 읽기
redisTemplate.opsForStream().read(StreamOffset.fromStart("orders"));

List와 달리 읽어도 메시지가 사라지지 않아요. 여러 소비자가 같은 스트림을 각자의 위치에서 읽을 수 있고 나중에 들어온 소비자도 과거 메시지를 볼 수 있어요. Pub/Sub의 휘발성과 List의 단일 소비 한계를 둘 다 푼 거예요.

메시지 ID는 밀리초-시퀀스 형태예요(예: 1690000000000-0). 시간순으로 증가해서 이 ID로 "어디까지 읽었나"를 표시해요. 카프카의 오프셋과 같은 역할이에요. 같은 밀리초에 여러 메시지가 들어오면 뒤의 시퀀스 번호가 올라가요. 그래서 ID만 보면 순서가 명확하고 특정 시점 이후만 읽는 범위 조회도 돼요.

03. 소비자 그룹

Streams의 핵심은 소비자 그룹이에요. 카프카의 컨슈머 그룹처럼, 여러 소비자가 한 그룹을 이뤄 메시지를 나눠 처리해요. 한 메시지는 그룹 안에서 한 소비자만 받아요.

// 그룹 생성
redisTemplate.opsForStream().createGroup("orders", "order-workers");

// 그룹으로 읽기 — 소비자끼리 나눠 받음
redisTemplate.opsForStream().read(Consumer.from("order-workers", "worker-1"),
    StreamReadOptions.empty(), StreamOffset.create("orders", ReadOffset.lastConsumed()));

그래서 소비자를 늘리면 처리량이 올라가요. 그리고 처리한 메시지는 XACK으로 확인(ack)해요. ack를 해야 "처리 완료"로 기록돼요. ack 안 한 메시지는 미처리로 남아서 추적할 수 있어요.

04. 미처리 메시지 추적 (PEL)

소비자가 메시지를 받고 처리하다 죽으면 어떻게 될까요. Streams는 받았지만 아직 ack 안 한 메시지를 PEL(Pending Entries List)에 기록해둬요. 그래서 잃어버리지 않아요.

Redis Streams 소비자 그룹과 PEL. worker-1이 메시지를 받고 ack 전에 죽으면 그 메시지는 PEL에 미처리로 남고, XPENDING으로 발견해 XCLAIM으로 다른 소비자가 넘겨받아 재처리한다

XPENDING으로 미처리 메시지를 조회하고, XCLAIM으로 죽은 소비자의 미처리 메시지를 다른 소비자가 넘겨받아 재처리할 수 있어요. <code>XCLAIM</code>을 자동화한 <code>XAUTOCLAIM</code>(6.2+)도 있어요. idle 시간이 넘은 메시지를 스캔과 클레임 한 번에 처리해줘요. 카프카에서 리밸런싱으로 파티션이 재할당되는 것과 비슷한 안전장치예요. 이게 List 큐엔 없던, 신뢰성 있는 처리의 핵심이에요.

05. 스프링에서 Streams 소비하기

매번 직접 read를 돌리는 대신, 스프링은 리스너로 자동 소비를 지원해요. StreamMessageListenerContainer에 리스너를 등록하면, 새 메시지가 오면 콜백이 불려요. 카프카의 @KafkaListener와 비슷한 느낌이에요.

var container = StreamMessageListenerContainer.create(connectionFactory, options);
container.receive(
    Consumer.from("order-workers", "worker-1"),
    StreamOffset.create("orders", ReadOffset.lastConsumed()),
    message -> {
        process(message.getValue());                 // 처리
        redisTemplate.opsForStream().acknowledge(
            "orders", "order-workers", message.getId()); // ack
    });
container.start();
여기서 함정 — ack를 빼먹으면 메시지가 PEL에 미처리로 계속 쌓여요. 처리 자체는 잘 되는 것처럼 보이는데 PEL이 무한히 자라서 메모리가 새는 거죠. 카프카에서 acknowledge() 안 불리는 경로가 lag을 만들던 것과 똑같은 패턴이에요. 처리 성공을 명확히 ack하고 실패하면 재시도나 별도 처리로 넘기는 흐름을 잡아야 해요.

06. Redis Streams vs Kafka

둘 다 로그·소비자 그룹·재처리를 주는데, 무엇을 고를까요. 규모와 운영으로 갈려요.

Redis Streams와 Kafka 선택 기준. 이미 Redis가 있고 중간 규모면 Streams로 인프라 추가 없이 시작하되 메모리 한계가 있고, 대용량 장기 보관과 여러 팀 구독이 필요하면 운영 비용을 감수하고 Kafka로 간다
  • Redis Streams — 이미 Redis를 쓰고 있고 중간 규모의 메시지 처리면 충분해요. 별도 인프라 없이 가볍게 시작해요. 메모리 기반이라 보관량에 한계가 있어요.
  • Kafka — 대용량·장기 보관·높은 처리량·내구성이 필요하면 카프카예요. 다만 카프카의 순서 보장은 파티션 단위예요. 스트림 하나가 통째로 단일 순서인 Streams와는 결이 달라요. 대신 클러스터 운영 비용이 들어요.

대략의 기준은 이래요. "이미 Redis가 있고 큐가 그렇게 크지 않다"면 Streams로 인프라를 안 늘리고 해결해요. "대용량 이벤트 파이프라인이고 오래 보관하며 여러 팀이 쓴다"면 카프카예요. Streams는 카프카의 가벼운 대안이지, 모든 규모에서 대체재는 아니에요.

07. 자주 만나는 문제

메시지가 자꾸 사라져요

List나 Pub/Sub을 쓴 거예요. 신뢰성 있는 처리가 필요하면 Streams + 소비자 그룹 + ack로 바꿔요.

스트림이 메모리를 계속 먹어요

Streams는 append-only라 계속 쌓여요. MAXLEN으로 최대 길이를 제한하거나 오래된 메시지를 잘라 메모리를 관리해요.

죽은 소비자의 메시지가 안 처리돼요

PEL에 미처리로 남아 있는 거예요. XPENDING으로 찾아 XCLAIM으로 다른 소비자가 넘겨받아 재처리해요.

정리

Redis Streams는 로그·소비자 그룹·미처리 추적을 갖춘 메시지 큐예요. List의 단일 소비와 Pub/Sub의 휘발성을 둘 다 풀었어요. 여러 소비자가 신뢰성 있게 나눠 처리하고 죽은 소비자의 메시지도 XPENDING·XCLAIM으로 넘겨받아 재처리해요.

그래서 메시지 큐가 필요할 때 규모로 갈라요. 이미 Redis가 있고 큐가 중간 규모면 인프라를 안 늘리고 Streams로 가볍게 시작하고, 대용량·장기 보관·여러 팀 구독이면 운영 비용을 감수하고 카프카로 가요. 이 편은 자료구조 위에 서 있고 다음은 운영을 마무리짓는 TTL·메모리 정책과 고가용성이에요.

출처: Redis — Streams · Spring Data Redis — Streams

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."