Theme:

여러 서버가 실시간으로 같은 이벤트를 받아야 할 때, 가장 간단한 방법은 무엇일까요?

Pub/Sub이란

Pub/Sub(Publish/Subscribe)은 발행자가 채널에 메시지를 보내면, 해당 채널을 구독하고 있는 모든 클라이언트에게 실시간으로 전달되는 메시징 패턴입니다. Redis의 Pub/Sub은 브로커 없이 Redis 서버가 직접 메시지를 중계하며, 메시지를 저장하지 않는 Fire-and-Forget 방식입니다.

왜 필요한가

  • 실시간 알림: 채팅 메시지, 알림을 여러 서버에 즉시 전달
  • 이벤트 전파: 캐시 무효화 이벤트를 모든 앱 서버에 브로드캐스트
  • 설정 변경 알림: 설정이 바뀌면 모든 인스턴스에 즉시 알림
  • 간단한 구현: 별도의 메시지 브로커 없이 Redis만으로 구현 가능

기본 명령어

SUBSCRIBE / PUBLISH

BASH
# 터미널 1 — 구독자
127.0.0.1:6379> SUBSCRIBE chat:room:1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "chat:room:1"
3) (integer) 1    # 현재 구독 중인 채널 수

# 터미널 2 — 발행자
127.0.0.1:6379> PUBLISH chat:room:1 "안녕하세요!"
(integer) 1       # 메시지를 받은 구독자 수

# 터미널 1에서 수신
1) "message"
2) "chat:room:1"
3) "안녕하세요!"

여러 채널 동시 구독

BASH
127.0.0.1:6379> SUBSCRIBE chat:room:1 chat:room:2 notifications

구독 해제

BASH
127.0.0.1:6379> UNSUBSCRIBE chat:room:1
# 특정 채널만 해제

127.0.0.1:6379> UNSUBSCRIBE
# 모든 채널 해제

패턴 구독 (PSUBSCRIBE)

글로브 패턴으로 여러 채널을 한 번에 구독할 수 있습니다.

BASH
# chat:으로 시작하는 모든 채널 구독
127.0.0.1:6379> PSUBSCRIBE chat:*

# 메시지 수신 시 어떤 패턴/채널에서 왔는지 표시
1) "pmessage"
2) "chat:*"         # 매칭된 패턴
3) "chat:room:42"   # 실제 채널
4) "메시지 내용"

패턴 문법

BASH
# * — 모든 문자 매칭
PSUBSCRIBE news:*          # news:sports, news:tech 등

# ? — 한 문자 매칭
PSUBSCRIBE chat:room:?     # chat:room:1, chat:room:2 (한 자리만)

# [abc] — 문자 클래스
PSUBSCRIBE log:[ew]*       # log:error, log:warn 등

패턴 구독 주의사항

  • 패턴 구독은 일반 구독보다 약간 느립니다 (매칭 연산 필요)
  • 같은 메시지가 일반 구독과 패턴 구독 모두에 매칭되면 두 번 받습니다
  • 패턴이 많아지면 CPU 사용량이 증가합니다

구독 상태의 제약

구독 중인 클라이언트는 다음 명령만 실행할 수 있습니다.

PLAINTEXT
허용: SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PING, RESET
금지: GET, SET, HSET 등 모든 일반 명령

따라서 하나의 연결로 구독과 일반 명령을 동시에 처리할 수 없고, 별도의 연결이 필요합니다.

JAVA
// Spring에서의 구현 — 별도 리스너 컨테이너
@Bean
public RedisMessageListenerContainer messageListenerContainer(
        RedisConnectionFactory connectionFactory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(connectionFactory);  // 별도 연결 사용

    container.addMessageListener(chatMessageListener,
        new ChannelTopic("chat:room:1"));
    container.addMessageListener(notificationListener,
        new PatternTopic("notification:*"));

    return container;
}

메시지 유실 가능성

Redis Pub/Sub의 가장 큰 한계는 메시지가 유실될 수 있다는 것입니다.

유실되는 경우들

PLAINTEXT
1. 구독자가 없을 때
   PUBLISH chat:empty "아무도 없는 채널" → 메시지 사라짐

2. 구독자의 네트워크가 순간 끊겼을 때
   → 끊긴 동안의 메시지를 복구할 방법 없음

3. 구독자가 느려서 출력 버퍼가 가득 찰 때
   → Redis가 해당 클라이언트 연결을 끊어버림

출력 버퍼 제한 설정

BASH
# client-output-buffer-limit pubsub <hard> <soft> <seconds>
# hard: 즉시 연결 끊김, soft: seconds 동안 지속되면 끊김
client-output-buffer-limit pubsub 32mb 8mb 60
# 32MB 초과 시 즉시 끊김, 8MB 초과 상태가 60초 지속되면 끊김

Keyspace Notification

Redis의 키 변경 이벤트를 Pub/Sub으로 수신하는 기능입니다.

활성화

BASH
# 기본적으로 비활성화 (CPU 오버헤드 때문)
127.0.0.1:6379> CONFIG SET notify-keyspace-events "KEA"
# K: Keyspace 이벤트 (__keyspace@<db>__)
# E: Keyevent 이벤트 (__keyevent@<db>__)
# A: 모든 이벤트 (g$lszhted의 별칭)

이벤트 유형 플래그

PLAINTEXT
g — DEL, EXPIRE 등 일반 명령
$ — String 명령
l — List 명령
s — Set 명령
h — Hash 명령
z — Sorted Set 명령
x — 만료 이벤트
e — 퇴거(eviction) 이벤트
t — Stream 명령

키 만료 이벤트 수신

BASH
# 설정
127.0.0.1:6379> CONFIG SET notify-keyspace-events "Ex"

# 구독
127.0.0.1:6379> SUBSCRIBE __keyevent@0__:expired

# 다른 터미널에서 TTL 설정
127.0.0.1:6379> SET session:abc "data" EX 5

# 5초 후 구독자에서 수신
1) "message"
2) "__keyevent@0__:expired"
3) "session:abc"

Keyspace vs Keyevent

BASH
# Keyspace: "키 X에 무슨 일이 일어났나"
SUBSCRIBE __keyspace@0__:user:1001
# 수신: "set", "expire", "del" 등 (이벤트 이름)

# Keyevent: "어떤 이벤트가 어떤 키에서 일어났나"
SUBSCRIBE __keyevent@0__:set
# 수신: "user:1001", "user:1002" 등 (키 이름)

활용 예: 세션 만료 처리

JAVA
@Component
public class SessionExpirationListener implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expiredKey = new String(message.getBody());
        if (expiredKey.startsWith("session:")) {
            String sessionId = expiredKey.substring("session:".length());
            // 세션 만료 후처리 로직
            auditService.logSessionExpired(sessionId);
            notificationService.notifyUserLogout(sessionId);
        }
    }
}

@Bean
public RedisMessageListenerContainer expirationListenerContainer(
        RedisConnectionFactory factory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(sessionExpirationListener,
        new PatternTopic("__keyevent@*__:expired"));
    return container;
}

Keyspace Notification 주의사항

  • 만료 이벤트는 정확하지 않습니다: Redis의 lazy/active expiration 때문에 실제 만료와 이벤트 발생 사이에 지연이 있을 수 있습니다
  • Fire-and-Forget: Pub/Sub 기반이므로 이벤트 유실 가능
  • CPU 오버헤드: 활성화하면 모든 키 변경마다 이벤트를 생성하므로 성능에 영향

Pub/Sub 활용 패턴

캐시 무효화 브로드캐스트

JAVA
// 캐시 갱신 시 모든 인스턴스에 알림
public void updateProduct(Long productId, ProductDto dto) {
    productRepository.update(productId, dto);
    localCache.invalidate("product:" + productId);

    // 다른 서버의 로컬 캐시도 무효화
    redisTemplate.convertAndSend("cache:invalidate",
        "product:" + productId);
}

// 리스너에서 로컬 캐시 무효화
@Override
public void onMessage(Message message, byte[] pattern) {
    String key = new String(message.getBody());
    localCache.invalidate(key);
}

실시간 알림

JAVA
// 알림 발송
public void sendNotification(Long userId, String content) {
    Notification noti = new Notification(userId, content);
    redisTemplate.convertAndSend("notification:user:" + userId,
        objectMapper.writeValueAsString(noti));
}

Pub/Sub의 한계와 대안

한계설명대안
메시지 유실구독자 없으면 사라짐Redis Stream
메시지 이력 없음과거 메시지 조회 불가Redis Stream
Consumer Group 없음분산 처리 불가Redis Stream
클러스터에서 비효율적모든 노드에 메시지 브로드캐스트Sharded Pub/Sub (Redis 7.0+)
영속성 없음재시작하면 구독 정보 사라짐Kafka, RabbitMQ

Sharded Pub/Sub (Redis 7.0+)

클러스터 환경에서 채널이 특정 슬롯에 매핑되어 해당 노드에서만 메시지가 처리됩니다.

BASH
127.0.0.1:6379> SSUBSCRIBE chat:room:1    # Sharded 구독
127.0.0.1:6379> SPUBLISH chat:room:1 "hello"  # Sharded 발행

정리

Redis Pub/Sub은 가장 간단하게 실시간 메시징을 구현할 수 있는 방법입니다. 캐시 무효화 브로드캐스트, 실시간 알림 같은 메시지 유실이 허용되는 시나리오에 적합합니다. 하지만 메시지 영속성, 재처리, 분산 처리가 필요하다면 Redis Stream이나 전용 메시지 브로커(Kafka, RabbitMQ)를 고려하세요. Keyspace Notification은 키 만료 이벤트 감지 등에 유용하지만, 역시 이벤트 유실 가능성을 감안해야 합니다.

댓글 로딩 중...