[Redis] Pub/Sub의 동작 원리와 전달 보장(Streams·Kafka 비교)

개요

Redis는 캐시나 세션 저장소로만 쓰이지 않는다. 여러 인스턴스로 흩어진 애플리케이션 사이에 실시간 신호를 전달하는 용도로도 쓰인다. 캐시가 갱신됐다는 신호를 전 인스턴스에 보내거나, 채팅 서버 여러 대에 붙은 클라이언트에게 메시지를 팬아웃하거나, 피처 플래그가 바뀌었다는 신호를 즉시 전파하는 자리에 Redis Pub/Sub(발행/구독)이 등장한다.

다만 Pub/Sub을 결제 완료 통지나 주문 상태 변경 같은 반드시 전달돼야 하는 도메인 이벤트에 사용할 경우 문제가 발생할 수 있다. Pub/Sub은 결과 미추적(fire-and-forget) 방식이다. 구독자가 잠깐이라도 없거나 연결이 끊겨 있으면 메시지는 흔적도 없이 사라진다.

Pub/Sub과 Streams, Kafka는 모두 메시지를 발행자에서 구독자로 전달하지만 전달 보장의 성격이 완전히 다르다. 이 차이를 구조로 이해하면 어떤 상황에 어떤 도구를 써야 하는지가 명확해진다.

핵심 키워드

용어의미
채널(Channel)PUBLISH/SUBSCRIBE가 메시지를 주고받는 논리적 이름 공간. 저장 공간이 아니라 구독자 리스트의 키일 뿐
발행/구독(Pub/Sub)발행자가 채널에 메시지를 보내면, 그 채널을 구독 중인 모든 클라이언트가 즉시 받는 메시징 패턴
결과 미추적(fire-and-forget)발행자가 메시지를 보낸 뒤 구독자의 수신·처리 결과를 확인하지 않는 전달 방식
샤드 Pub/Sub(Sharded Pub/Sub)Redis 7.0+에서 도입된 SSUBSCRIBE/SPUBLISH. 채널을 클러스터 슬롯에 매핑해 전파 범위를 좁힌다
PEL(Pending Entry List)Redis Streams의 Consumer Group이 ACK(확인 응답, Acknowledgement) 안 된 메시지를 추적하는 목록
백프레셔(Backpressure)구독자가 메시지 처리 속도를 못 따라가 발행 측 또는 중간 버퍼에 부하가 쌓이는 현상

채널과 명령어: SUBSCRIBE·PSUBSCRIBE·PUBLISH

채널은 물리적으로 존재하는 저장소가 아니다. Redis 서버 프로세스 안에는 pubsub_channels라는 딕셔너리 하나가 있고, 이 딕셔너리의 키가 채널 이름, 값이 그 채널을 구독 중인 클라이언트 목록이다. SUBSCRIBE news:sports를 호출하면 서버는 이 딕셔너리에서 news:sports 키를 찾아(없으면 새로 만들어) 그 리스트에 현재 클라이언트를 추가할 뿐이다.

# 구독: 정확한 채널명으로
SUBSCRIBE news:sports
 
# 패턴 구독: 글롭(glob) 패턴으로 여러 채널을 한 번에
PSUBSCRIBE news:*
 
# 발행: 채널에 메시지 전송, 반환값은 수신한 클라이언트 수
PUBLISH news:sports "손흥민 골"
# (integer) 2   ← 이 채널을 구독 중이던 클라이언트가 2개였다는 뜻
 
# 메타 정보
PUBSUB CHANNELS            # 활성 채널 목록
PUBSUB NUMSUB news:sports  # 채널별 구독자 수

PSUBSCRIBE는 별도의 pubsub_patterns 리스트에 패턴 문자열과 클라이언트를 등록해 둔다. PUBLISH가 호출되면 서버는 두 자료구조를 모두 확인한다. 먼저 pubsub_channels에서 정확히 일치하는 채널을 찾아 그 리스트의 클라이언트에게 메시지를 복사하고, 그다음 pubsub_patterns를 순회하며 채널명이 패턴과 일치하는 구독자에게도 같은 메시지를 복사한다. 이 전체 과정은 단일 스레드에서 순차적으로 실행되므로, 같은 채널에 대한 메시지 순서는 발행 순서 그대로 보존된다.

pubsub_channels 딕셔너리는 구독자 목록만 들고 있을 뿐 메시지 자체를 저장하지 않는다.PUBLISH는 그 순간 리스트에 있는 클라이언트의 출력 버퍼에 메시지를 즉시 복사하고 끝난다. 큐도, 로그도, 디스크 기록도 없다. 그래서 다음 두 상황에서 메시지는 복구 불가능하게 사라진다.

  • 채널에 구독자가 하나도 없으면 PUBLISH는 반환값 0과 함께 그냥 끝난다. 메시지는 어디에도 남지 않는다.
  • 클라이언트가 구독 중이다가 연결이 끊긴 순간, 서버는 그 클라이언트를 리스트에서 제거한다. 끊겨 있던 동안 발행된 메시지는 재연결 후에도 조회할 방법이 없다. 채널에는 히스토리라는 개념 자체가 없기 때문이다.

PUBLISH의 반환값이 수신 클라이언트 수라는 점일 뿐, 그 클라이언트가 메시지를 실제로 파싱했는지, 비즈니스 로직을 성공적으로 처리했는지는 전혀 보장하지 않는다.

클러스터 환경에서의 Pub/Sub

단일 인스턴스에서는 pubsub_channels가 문제없이 동작하지만, Redis Cluster 환경에서는 다르다. 일반 PUBLISH는 클러스터 안의 모든 노드로 전파된다. 어떤 노드에 발행하든, 그 메시지가 클러스터 전체 노드에 브로드캐스트되어 각 노드에 붙어 있는 로컬 구독자에게 전달되는 방식이다. 노드가 어느 슬롯을 담당하는지, 그 채널을 실제로 구독하는 클라이언트가 어느 노드에 있는지와 무관하게 전 노드가 메시지를 받는다.

이 방식은 노드 수가 늘어날수록 대역폭 낭비가 커진다. 100개 노드 클러스터에서 채널 하나에 초당 1000건을 발행하면, 실제 구독자가 노드 1개에만 붙어 있어도 나머지 99개 노드까지 전부 그 트래픽을 받아 처리한다.

Redis 7.0은 이 문제를 풀기 위해 샤드 Pub/Sub(SSUBSCRIBE/SPUBLISH)을 도입했다. 일반 채널과 달리 샤드 채널은 이름을 해시 슬롯에 매핑하고, 그 슬롯을 담당하는 노드와 레플리카에서만 전파된다.

# 일반 Pub/Sub: 클러스터 전체 노드로 전파
PUBLISH news:sports "손흥민 골"
 
# 샤드 Pub/Sub(Redis 7.0+): 채널이 매핑된 슬롯의 노드에서만 전파
SSUBSCRIBE shard:news:sports
SPUBLISH shard:news:sports "손흥민 골"
구분일반 Pub/Sub샤드 Pub/Sub(SSUBSCRIBE/SPUBLISH)
전파 범위클러스터 전체 노드채널이 매핑된 슬롯의 노드만
도입 버전전 버전Redis 7.0 이상
대역폭 비용노드 수에 비례해 증가슬롯 단위로 제한
클라이언트 연결아무 노드에 연결 가능슬롯 담당 노드에 연결해야 함(MOVED 리다이렉션)

느린 구독자와 백프레셔

서버는 클라이언트마다 출력 버퍼를 두고, 메시지를 그 버퍼에 적재한 뒤 소켓으로 흘려보낸다. 구독자가 메시지를 받아가는 속도보다 발행 속도가 빠르면 이 버퍼가 계속 쌓인다. 문제는 Redis가 싱글 스레드로 동작한다는 점이다. 버퍼가 무한정 쌓이도록 두면 메모리를 잠식하고, 극단적으로는 서버 전체가 영향을 받는다.

Redis는 client-output-buffer-limit 설정으로 이 상황을 방어한다.

# redis.conf
# client-output-buffer-limit pubsub <hard-limit> <soft-limit> <soft-seconds>
client-output-buffer-limit pubsub 32mb 8mb 60
  • 하드 리밋(32MB): 버퍼가 이 값을 넘는 순간 서버가 그 클라이언트 연결을 즉시 끊는다.
  • 소프트 리밋(8MB)과 소프트 시간(60초): 버퍼가 소프트 리밋을 60초 이상 연속으로 초과하면 역시 연결을 끊는다. 순간적으로 넘었다가 곧 줄어들면 봐준다.

연결이 끊기면 그 구독자는 그 시점부터 발행되는 모든 메시지를 받지 못한다. 재연결해도 끊긴 동안의 메시지는 앞서 본 대로 복구할 방법이 없다.

이 한계 때문에 처리 속도가 들쭉날쭉한 구독자, 또는 잠깐의 재시작도 흔한 배포 환경의 구독자에게 중요한 메시지를 Pub/Sub으로 보내는 설계는 위험하다.

Redis Streams와의 선택 기준

Redis Streams는 XADD로 메시지를 로그 형태로 영속 저장하고, Consumer Group이 XREADGROUP으로 메시지를 분할 소비하며, 처리 완료를 XACK으로 확인한다. 확인되지 않은 메시지는 PEL에 남아 있어 재처리가 가능하다.

핵심 판단 기준은 유실 허용 여부다.

시나리오유실 허용선택
캐시 무효화 브로드캐스트허용(다음 갱신이 다시 옴)Pub/Sub
설정 리로드·피처 플래그 신호허용(주기적으로 재확인 가능)Pub/Sub
주문 생성·결제 완료 이벤트불허Streams 또는 Kafka
재고 차감 작업 큐불허, 재처리 필요Streams(단일 서비스 규모) 또는 Kafka
로그·이벤트 수집 파이프라인불허, 순서·재처리 필요Streams(소규모) 또는 Kafka(대규모)

Streams는 Pub/Sub보다 훨씬 안전하지만 Redis 프로세스 하나의 메모리와 디스크 용량 안에서 동작한다는 한계가 있다. 여러 서비스가 독립적으로 확장돼야 하거나, 메시지 보존 기간이 길거나, 파티션 단위 수평 확장이 필요하면 Kafka로 넘어간다.

Kafka와 비교: 왜 도메인 이벤트 전달 수단이 될 수 없는가

세 시스템의 차이는 저장 계층의 유무에서 시작해 세 갈래로 갈라진다.

항목Pub/SubStreamsKafka
저장 여부저장 안 함로그로 영속로그로 영속 + 복제
재연결 후 재처리불가능XRANGE로 과거 구간 재조회오프셋 재조회로 재처리
순서 보장없음(재연결·재구독 시)스트림 내 순서 유지파티션 내 순서 유지
컨슈머 그룹없음있음(XREADGROUP)있음(리밸런싱, 파티션 재분배)
브로커 장애 내구성해당 없음(단일 프로세스)RDB/AOF 영속성에 의존ISR(동기화된 복제본 집합, In-Sync Replica) 기반 복제

내구성(durability) 측면에서 Pub/Sub은 메시지를 어디에도 쓰지 않는다. Kafka는 프로듀서가 acks=all로 발행하면 리더와 ISR 전체에 복제된 뒤에야 성공으로 간주한다. 브로커 하나가 죽어도 ISR 안의 다른 브로커가 같은 데이터를 들고 있어 유실이 없다.

재처리(replay) 측면에서 Pub/Sub 구독자는 구독을 시작한 시점 이후의 메시지만 받는다. 과거로 되돌아갈 방법이 없다. Kafka 컨슈머는 커밋된 오프셋을 되돌리기만 하면 같은 메시지를 몇 번이든 다시 읽을 수 있다. 배포 후 버그가 발견돼 특정 시점부터 재처리해야 하는 상황에서 유용하다.

순서(ordering) 측면에서 단일 Redis 인스턴스라면 한 채널로의 메시지는 발행 순서대로 도착한다. 하지만 이 보장은 그 구독자가 연결을 유지하고 있을 때만 유효하다. 재연결하면 그 사이의 순서 정보 자체가 사라진다. Kafka는 파티션 안에서 오프셋이라는 물리적인 순서 값을 로그에 새겨 두므로, 컨슈머가 몇 번을 재시작해도 같은 순서로 읽는다.

Pub/Sub은 지금 이 순간 연결돼 있는 구독자에게 알림을 전달하는 도구이지, 언젠가는 반드시 처리돼야 하는 사실을 실어 나르는 도구가 아니다. 결제 완료, 주문 확정 같은 도메인 이벤트는 유실되면 곧 사업적 손실이나 데이터 불일치로 이어지므로 Kafka 같은 내구성 있는 전달 수단이 필요하다.

// 잘못된 패턴: UseCase가 RedisTemplate으로 직접 발행. 구독자가 없거나 끊겨 있으면 통지가 그냥 사라진다
@Service
class ConfirmPaymentUseCase(
    private val paymentDomainService: PaymentDomainService,
    private val redisTemplate: StringRedisTemplate,
) {
    @Transactional
    fun execute(command: ConfirmPaymentCommand): ConfirmPaymentResult {
        val payment = paymentDomainService.confirm(command)
        redisTemplate.convertAndSend("payment:confirmed", payment.id.toString())
        return ConfirmPaymentResult.of(payment)
    }
}
// 올바른 패턴: UseCase는 DomainService만 호출하고, 이벤트는 domain interface 뒤의 Kafka 구현체가 발행한다
@Service
class ConfirmPaymentUseCase(
    private val paymentDomainService: PaymentDomainService,
) {
    @Transactional
    fun execute(command: ConfirmPaymentCommand): ConfirmPaymentResult {
        val payment = paymentDomainService.confirm(command)
        return ConfirmPaymentResult.of(payment)
    }
}
 
// domain/payment/PaymentDomainService.kt
class PaymentDomainService(
    private val paymentRepository: PaymentRepository,
    private val eventPublisher: DomainEventPublisher,
) {
    fun confirm(command: ConfirmPaymentCommand): Payment {
        val payment = paymentRepository.findBy(command.paymentId)
            ?: throw PaymentNotFoundException(command.paymentId)
        payment.confirm()
        return paymentRepository.save(payment).also {
            eventPublisher.publishAll(it.pullDomainEvents())
        }
    }
}
 
// infrastructure/event/KafkaDomainEventPublisher.kt가 event.payment.payment.v1 토픽에 발행
// 각 주문 컨텍스트가 자기 EventWorker와 고유 groupId로 구독해 확정 처리

실무에서 Pub/Sub이 적합한 경우

지금까지의 한계에도 불구하고 Pub/Sub이 정확히 들어맞는 자리가 있다. 공통점은 유실 허용, 실시간성, 그리고 최신 상태만 중요하고 중간 값은 몰라도 되는 성격이다.

  • 캐시 무효화 브로드캐스트: 로컬 캐시(Caffeine 등)를 쓰는 여러 인스턴스에 데이터 변경을 알릴 때, 신호 하나를 놓쳐도 다음 변경 시점에 다시 무효화되므로 치명적이지 않다. 최악의 경우 잠깐 오래된 캐시를 보여주는 정도다.
  • WebSocket·SSE(Server-Sent Events) 팬아웃: 채팅방이나 실시간 알림처럼 여러 서버 인스턴스에 붙은 클라이언트에게 메시지를 뿌릴 때, Pub/Sub이 인스턴스 간 브로드캐스트 역할을 한다. 접속 중인 사람에게만 전달하면 되고, 접속 끊긴 사용자는 재연결 시 최신 상태를 다시 조회하면 된다.
  • 설정 리로드·피처 플래그 신호: 값 자체는 DB나 설정 저장소에 있고, Pub/Sub은 그저 다시 읽어오라는 신호일 뿐이다. 신호를 하나 놓쳐도 다음 배포나 주기적 재확인 때 다시 반영된다.

이 세 사례의 공통 구조는 Pub/Sub이 데이터의 원천이 아니라 알림의 역할만 한다는 점이다. 실제 값은 DB, 캐시, 설정 저장소 같은 다른 곳에 있고 Pub/Sub은 그 값이 바뀌었다는 사실만 실시간으로 전파한다. 이렇게 설계하면 메시지 하나를 놓쳐도 시스템 전체가 최종적으로는 같은 상태로 수렴하므로 이런 상황에서 유용하다.