Pub-Sub — 실시간 메시징의 동작 원리와 한계
여러 서버가 실시간으로 같은 이벤트를 받아야 할 때, 가장 간단한 방법은 무엇일까요?
개념 정의
Pub/Sub(Publish/Subscribe)은 발행자가 채널에 메시지를 보내면 구독자 전원에게 실시간으로 전달하는 메시징 패턴입니다. Redis Pub/Sub은 메시지를 저장하지 않는 Fire-and-Forget 방식이므로, 구독자가 없으면 메시지가 사라집니다.
왜 필요한가
- 실시간 알림: 채팅 메시지, 알림을 여러 서버에 즉시 전달
- 이벤트 전파: 캐시 무효화 이벤트를 모든 앱 서버에 브로드캐스트
- 설정 변경 알림: 설정이 바뀌면 모든 인스턴스에 즉시 알림
- 간단한 구현: 별도의 메시지 브로커 없이 Redis만으로 구현 가능
기본 명령어
SUBSCRIBE / PUBLISH
# 터미널 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) "안녕하세요!"
여러 채널 동시 구독
127.0.0.1:6379> SUBSCRIBE chat:room:1 chat:room:2 notifications
구독 해제
127.0.0.1:6379> UNSUBSCRIBE chat:room:1
# 특정 채널만 해제
127.0.0.1:6379> UNSUBSCRIBE
# 모든 채널 해제
패턴 구독 (PSUBSCRIBE)
글로브 패턴으로 여러 채널을 한 번에 구독할 수 있습니다.
# chat:으로 시작하는 모든 채널 구독
127.0.0.1:6379> PSUBSCRIBE chat:*
# 메시지 수신 시 어떤 패턴/채널에서 왔는지 표시
1) "pmessage"
2) "chat:*" # 매칭된 패턴
3) "chat:room:42" # 실제 채널
4) "메시지 내용"
패턴 문법
# * — 모든 문자 매칭
PSUBSCRIBE news:* # news:sports, news:tech 등
# ? — 한 문자 매칭
PSUBSCRIBE chat:room:? # chat:room:1, chat:room:2 (한 자리만)
# [abc] — 문자 클래스
PSUBSCRIBE log:[ew]* # log:error, log:warn 등
패턴 구독 주의사항
- 패턴 구독은 일반 구독보다 약간 느립니다 (매칭 연산 필요)
- 같은 메시지가 일반 구독과 패턴 구독 모두에 매칭되면 두 번 받습니다
- 패턴이 많아지면 CPU 사용량이 증가합니다
구독 상태의 제약
구독 중인 클라이언트는 다음 명령만 실행할 수 있습니다.
허용: SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PING, RESET
금지: GET, SET, HSET 등 모든 일반 명령
따라서 하나의 연결로 구독과 일반 명령을 동시에 처리할 수 없고, 별도의 연결이 필요합니다.
// 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의 가장 큰 한계는 메시지가 유실될 수 있다는 것입니다.
유실되는 경우들
1. 구독자가 없을 때
PUBLISH chat:empty "아무도 없는 채널" → 메시지 사라짐
2. 구독자의 네트워크가 순간 끊겼을 때
→ 끊긴 동안의 메시지를 복구할 방법 없음
3. 구독자가 느려서 출력 버퍼가 가득 찰 때
→ Redis가 해당 클라이언트 연결을 끊어버림
출력 버퍼 제한 설정
# 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으로 수신하는 기능입니다.
활성화
# 기본적으로 비활성화 (CPU 오버헤드 때문)
127.0.0.1:6379> CONFIG SET notify-keyspace-events "KEA"
# K: Keyspace 이벤트 (__keyspace@<db>__)
# E: Keyevent 이벤트 (__keyevent@<db>__)
# A: 모든 이벤트 (g$lszhted의 별칭)
이벤트 유형 플래그
g — DEL, EXPIRE 등 일반 명령
$ — String 명령
l — List 명령
s — Set 명령
h — Hash 명령
z — Sorted Set 명령
x — 만료 이벤트
e — 퇴거(eviction) 이벤트
t — Stream 명령
키 만료 이벤트 수신
# 설정
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
# Keyspace: "키 X에 무슨 일이 일어났나"
SUBSCRIBE __keyspace@0__:user:1001
# 수신: "set", "expire", "del" 등 (이벤트 이름)
# Keyevent: "어떤 이벤트가 어떤 키에서 일어났나"
SUBSCRIBE __keyevent@0__:set
# 수신: "user:1001", "user:1002" 등 (키 이름)
활용 예: 세션 만료 처리
만료된 키 이름을 받아서 세션 관련 후처리를 수행하는 리스너입니다.
@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);
}
}
}
리스너를 __keyevent@*__:expired 패턴 토픽에 등록하면 모든 DB의 만료 이벤트를 수신할 수 있습니다.
@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 활용 패턴
캐시 무효화 브로드캐스트
// 캐시 갱신 시 모든 인스턴스에 알림
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);
}
실시간 알림
// 알림 발송
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+)
클러스터 환경에서 채널이 특정 슬롯에 매핑되어 해당 노드에서만 메시지가 처리됩니다.
127.0.0.1:6379> SSUBSCRIBE chat:room:1 # Sharded 구독
127.0.0.1:6379> SPUBLISH chat:room:1 "hello" # Sharded 발행
함정/Pitfall
1. 구독자가 없으면 메시지가 사라진다
Pub/Sub은 Fire-and-Forget이므로 PUBLISH 시점에 구독자가 없으면 메시지가 영구 유실됩니다. 메시지 보존이 필요하면 Redis Stream을 사용해야 합니다.
2. 느린 구독자가 서버 메모리를 위험에 빠뜨린다
구독자가 메시지를 느리게 소비하면 출력 버퍼가 계속 쌓입니다. client-output-buffer-limit pubsub 설정으로 하드/소프트 리밋을 반드시 설정해야 합니다.
3. Keyspace Notification의 만료 이벤트는 정확하지 않다
Redis의 lazy/active expiration 때문에 실제 만료 시점과 이벤트 발생 사이에 지연이 있을 수 있습니다. Pub/Sub 기반이므로 이벤트 유실도 가능합니다.
정리
| 항목 | 핵심 내용 |
|---|---|
| 동작 방식 | Fire-and-Forget, 메시지 저장 없음 |
| 적합 시나리오 | 캐시 무효화, 실시간 알림 등 유실 허용되는 경우 |
| 패턴 구독 | PSUBSCRIBE로 글로브 패턴 매칭 |
| Keyspace Notification | 키 만료 이벤트 감지, CPU 오버헤드 주의 |
| 한계 | 메시지 영속성/재처리 필요 시 Redis Stream 또는 Kafka 사용 |