개발/REDIS

#7 📡 Pub/Sub vs Streams: 실시간 메시징

cedis 2026. 3. 19. 10:09
채팅, 실시간 알림, 이벤트 처리... Redis에는 두 가지 메시징 방식이 있습니다. 언제 Pub/Sub을 쓰고, 언제 Streams를 써야 할까요?

🔔 실시간 알림, 어떻게 구현하나요?

사용자가 댓글을 달면 원글 작성자에게 즉시 알림을 보내고 싶습니다. 주문이 들어오면 주방 화면에 즉시 표시되어야 합니다. 이런 "실시간 전달"이 필요한 순간, Redis는 두 가지 선택지를 제공합니다.

첫 번째는 Pub/Sub(발행/구독)입니다. 이름 그대로 누군가 메시지를 발행(Publish)하면 구독(Subscribe) 중인 모든 수신자에게 즉시 전달됩니다. 마치 라디오 방송처럼, 방송 중에 채널을 켠 사람만 들을 수 있습니다.

두 번째는 Streams입니다. Streams는 메시지를 로그처럼 디스크에 기록합니다. 나중에 연결된 소비자도 과거 메시지를 읽을 수 있고, 처리 확인(ACK)까지 가능합니다. 마치 카카오톡처럼 내가 오프라인이었어도 다시 켜면 메시지가 남아있죠.

두 방식의 차이를 이해하면 어떤 상황에 무엇을 써야 할지 명확해집니다. 이번 편에서는 두 방식을 직접 실습하고 선택 기준을 정리합니다.

💡 이번 편의 핵심 질문
"메시지가 유실되어도 괜찮은가?" — YES라면 Pub/Sub, NO라면 Streams

📻 핵심 개념 1: Pub/Sub — 즉시 전달, 보관 없음

Pub/Sub은 채널(Channel)이라는 개념을 사용합니다. 구독자는 채널을 구독하고, 발행자는 채널에 메시지를 보냅니다. Redis는 그 메시지를 현재 구독 중인 모든 구독자에게 즉시 전달합니다.

핵심은 보관하지 않는다는 점입니다. 구독자가 오프라인이었다면? 그 사이에 발행된 메시지는 영원히 사라집니다. 연결이 끊기면 재구독 전까지의 모든 메시지를 놓칩니다.

주요 명령어

명령어 설명
SUBSCRIBE channel 채널 구독 (연결 유지 필요)
PSUBSCRIBE pattern 패턴으로 다중 채널 구독 (예: chat:*)
PUBLISH channel message 채널에 메시지 발행, 반환값: 수신한 구독자 수
UNSUBSCRIBE channel 구독 해제
PUBSUB CHANNELS 현재 활성 채널 목록
redis-cli — 터미널 A (구독자)
# 터미널 A: 채널 구독 (이 명령 실행 후 대기 상태)
SUBSCRIBE chat:room1
# 결과:
# 1) "subscribe"
# 2) "chat:room1"
# 3) (integer) 1  ← 현재 구독 중인 채널 수
redis-cli — 터미널 B (발행자)
# 터미널 B: 메시지 발행
PUBLISH chat:room1 "안녕하세요!"
# 결과: (integer) 1  ← 수신한 구독자 수

PUBLISH chat:room1 "반갑습니다"
# 결과: (integer) 1

# 구독자가 없는 채널에 발행
PUBLISH chat:empty "아무도 없음"
# 결과: (integer) 0  ← 아무도 받지 못함
⚠️ Pub/Sub 사용 시 주의
SUBSCRIBE 명령을 실행한 연결은 구독 전용 모드로 전환됩니다. 이 연결에서는 SUBSCRIBE/UNSUBSCRIBE/PING 외 다른 명령을 실행할 수 없습니다. 발행(PUBLISH)은 별도 연결이 필요합니다.

🌊 핵심 개념 2: Streams — 처리 추적, 재처리 가능

Redis Streams는 2018년 Redis 5.0에서 도입된 자료구조입니다. 메시지를 로그처럼 순서대로 저장하며, 각 메시지에는 자동으로 고유 ID가 부여됩니다. ID 형식은 타임스탬프-시퀀스입니다 (예: 1710000000000-0).

Streams의 핵심 기능은 소비자 그룹(Consumer Group)입니다. 여러 소비자가 작업을 나눠서 처리할 수 있고, 처리 완료 시 XACK로 확인합니다. XACK 전까지는 메시지가 "대기 중" 상태로 남아 재처리가 가능합니다.

Streams 핵심 명령어

명령어 설명
XADD stream * field value 메시지 추가 (*는 자동 ID)
XLEN stream 스트림의 메시지 수
XREAD COUNT n STREAMS stream id 메시지 읽기 (0 = 처음부터)
XRANGE stream - + 전체 메시지 조회
XGROUP CREATE stream group id 소비자 그룹 생성
XREADGROUP GROUP g c COUNT n STREAMS s > 그룹 소비 (미처리 메시지)
XACK stream group id 처리 완료 확인
redis-cli
# Lab 6 — Streams 기본 실습
DEL stream:order

# 메시지 추가 (* = 자동 ID 생성)
XADD stream:order * order_id 1001 status created item "coffee"
XADD stream:order * order_id 1002 status created item "latte"
XADD stream:order * order_id 1003 status created item "tea"

XLEN stream:order           # → 3

# 처음부터 전체 조회
XRANGE stream:order - +
# 1) 1) "1710000000001-0"
#    2) 1) "order_id" 2) "1001" 3) "status" 4) "created" ...

# 최신 2개 읽기
XREAD COUNT 2 STREAMS stream:order 0

# 소비자 그룹 생성 ($ = 이후 메시지부터, 0 = 처음부터)
XGROUP CREATE stream:order kitchen $ MKSTREAM

# 새 메시지 추가 후 그룹 소비
XADD stream:order * order_id 1004 status created item "juice"
XREADGROUP GROUP kitchen worker1 COUNT 10 STREAMS stream:order >

# 처리 완료 확인 (ACK)
XACK stream:order kitchen [위에서 받은 메시지 ID]

🆚 Pub/Sub vs Streams 비교

📻 Pub/Sub

  • ✅ 구현이 매우 간단
  • ✅ 초저지연 (즉시 전달)
  • ✅ 다수 구독자에게 동시 전달
  • ❌ 메시지 보관 없음
  • ❌ 오프라인 구독자는 놓침
  • ❌ 처리 확인(ACK) 없음
  • ❌ 재처리 불가

🌊 Streams

  • ✅ 메시지 영구 보관
  • ✅ 오프라인 후 재연결 가능
  • ✅ 처리 확인(XACK) 지원
  • ✅ 재처리/감사 추적 가능
  • ✅ 소비자 그룹으로 병렬 처리
  • ❌ 구현이 복잡함
  • ❌ 메모리 사용량 증가

선택 기준 요약

상황 추천 이유
실시간 채팅 (메시지 유실 허용) Pub/Sub 단순, 즉각 전달
실시간 알림 (연결 중인 사용자에게만) Pub/Sub 구현 간단, 오프라인 무시
주문/결제 이벤트 (유실 불가) Streams ACK, 재처리, 감사 추적
로그 수집 / 이벤트 소싱 Streams 순서 보장, 재생 가능
여러 서비스가 같은 이벤트 처리 Streams (소비자 그룹) 병렬 처리, 부하 분산

🐍 Lab 6: Python으로 Pub/Sub 구현

Python — 발행자
# pip install redis
import redis
import time

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

# 발행자: 알림 메시지 발송
def publish_notification(channel: str, message: str):
    receivers = r.publish(channel, message)
    print(f"[Publisher] '{message}' → 채널: {channel}, 수신자: {receivers}명")

# 알림 발송 시뮬레이션
publish_notification("notify:user:1000", "새 댓글이 달렸습니다")
publish_notification("notify:user:1000", "팔로워가 생겼습니다")
publish_notification("notify:broadcast", "서버 점검 예정: 02:00~03:00")
Python — 구독자 (별도 프로세스)
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

# 구독자: 패턴으로 여러 채널 구독
pubsub = r.pubsub()
pubsub.psubscribe("notify:*")  # notify: 로 시작하는 모든 채널

print("[Subscriber] 알림 채널 구독 시작...")
for message in pubsub.listen():
    if message["type"] == "pmessage":
        print(f"[알림] 채널: {message['channel']}, 내용: {message['data']}")

🟢 Lab 6: Node.js로 Streams 구현

Node.js
// npm install redis
import { createClient } from "redis";

const producer = createClient();
const consumer = createClient();
await producer.connect();
await consumer.connect();

const STREAM_KEY = "stream:order";
const GROUP = "kitchen";

// 소비자 그룹 생성 (이미 있으면 무시)
try {
  await consumer.xGroupCreate(STREAM_KEY, GROUP, "$", { MKSTREAM: true });
  console.log("[Setup] 소비자 그룹 생성 완료");
} catch (e) {
  console.log("[Setup] 소비자 그룹 이미 존재");
}

// 프로듀서: 주문 이벤트 추가
async function addOrder(orderId, item) {
  const id = await producer.xAdd(STREAM_KEY, "*", {
    order_id: orderId,
    item,
    status: "created",
    timestamp: Date.now().toString(),
  });
  console.log(`[Producer] 주문 추가: ${orderId} (${item}), ID: ${id}`);
  return id;
}

// 컨슈머: 주문 처리
async function processOrders() {
  // > : 아직 전달되지 않은 새 메시지
  const messages = await consumer.xReadGroup(GROUP, "worker1", [
    { key: STREAM_KEY, id: ">" }
  ], { COUNT: 10 });

  if (!messages || messages.length === 0) {
    console.log("[Consumer] 처리할 주문 없음");
    return;
  }

  for (const { messages: msgs } of messages) {
    for (const { id, message } of msgs) {
      console.log(`[Consumer] 주문 처리: ${message.order_id} - ${message.item}`);

      // 처리 완료 확인 (ACK)
      await consumer.xAck(STREAM_KEY, GROUP, id);
      console.log(`[Consumer] ACK 완료: ${id}`);
    }
  }
}

// 실행
await addOrder("1001", "아메리카노");
await addOrder("1002", "라떼");
await addOrder("1003", "녹차");

await processOrders();

await producer.quit();
await consumer.quit();

⚠️ 실전 팁: 한국 실무 사례로 보는 선택 기준

📖 LINE 오픈챗 트래픽 스파이크 사례 (2023)
LINE의 오픈챗 서비스는 인기 채널에 트래픽이 집중되는 "핫키" 문제를 경험했습니다. 단일 Redis 노드에 부하가 집중되면 Pub/Sub 지연이 급증합니다. 이처럼 특정 채널에 수만 명이 몰리는 구조라면 샤딩 전략이 필요합니다.
📖 Kakao 실시간 메시징 성능 테스트 사례 (2024)
Kakao는 Redis의 CPU 지표와 성능 이슈를 모니터링하며 메시징 아키텍처를 개선했습니다. 대용량 실시간 메시징에서 Redis만으로는 한계가 있을 수 있으며, Kafka 등 전용 메시지 브로커와의 혼용도 실무에서 자주 등장하는 패턴입니다.

🚨 Pub/Sub의 치명적 약점

Redis가 재시작되거나 구독자가 잠깐 연결이 끊기면 그 사이의 모든 메시지는 영원히 사라집니다. 결제, 주문, 재고 변경 같은 비즈니스 크리티컬 이벤트에는 절대 Pub/Sub만 사용하지 마세요.

📌 7편 핵심 요약

  1. Pub/Sub: 즉시 전달, 보관 없음 → 연결된 구독자에게만 전달
  2. Streams: 메시지 저장 + ACK + 재처리 → 비즈니스 크리티컬 이벤트에 적합
  3. 선택 기준: "메시지 유실이 허용되는가?" YES → Pub/Sub, NO → Streams
  4. 소비자 그룹: 여러 워커가 Streams 메시지를 나눠 처리, XACK로 완료 확인
  5. 핫키 주의: 인기 채널 집중 시 단일 Redis 노드 과부하 가능 → 샤딩 고려

✅ 7편 체크리스트

  • SUBSCRIBE/PUBLISH로 Pub/Sub 기본 동작을 확인했다
  • XADD/XRANGE로 Streams에 메시지를 추가하고 조회했다
  • XGROUP CREATE로 소비자 그룹을 생성했다
  • XREADGROUP ... > 로 미처리 메시지를 소비했다
  • XACK로 처리 완료를 확인했다
  • Pub/Sub과 Streams의 사용 시나리오 차이를 설명할 수 있다

🧠 퀴즈 3문항

Q1. PUBLISH chat:room1 "hello" 결과가 0이면 무슨 의미인가요?
📭 현재 구독 중인 클라이언트가 없다는 의미입니다. 메시지는 아무에게도 전달되지 않고 사라집니다.
Q2. Streams에서 XREADGROUP의 > (꺾쇠)는 무엇을 의미하나요?
📨 아직 다른 소비자에게 전달되지 않은 새 메시지를 의미합니다. 특정 메시지 ID를 쓰면 그 ID 이후의 메시지를 다시 읽을 수 있습니다(재처리).
Q3. 결제 완료 이벤트에 Pub/Sub과 Streams 중 어떤 것을 써야 하나요?
💳 Streams입니다. 결제 이벤트는 유실되면 안 되므로 메시지 보관, ACK, 재처리가 가능한 Streams를 사용해야 합니다. Pub/Sub은 구독자가 잠깐 끊기면 메시지가 사라집니다.

📝 과제 3가지

  1. 패턴 구독: PSUBSCRIBE notify:* 로 여러 알림 채널을 한꺼번에 구독하고, 각각 다른 채널에 발행해 수신되는지 확인하세요.
  2. Streams 재처리: XREADGROUP으로 메시지를 받고 XACK를 하지 않은 상태에서 XPENDING으로 대기 중인 메시지를 확인하세요.
  3. 메시지 크기 제한: XADD stream MAXLEN 100 * ... 로 스트림 최대 100개로 제한하고, 100개 이상 추가 시 어떻게 되는지 확인하세요.
Redis Open Source 8.6.1 기준 · 2026년 3월 기준 작성
참고: Redis Data Types 공식 문서