Pub-Sub — 실시간 메시징의 동작 원리와 한계
여러 서버가 실시간으로 같은 이벤트를 받아야 할 때, 가장 간단한 방법은 무엇일까요?
Pub/Sub이란
Pub/Sub(Publish/Subscribe)은 발행자가 채널에 메시지를 보내면, 해당 채널을 구독하고 있는 모든 클라이언트에게 실시간으로 전달되는 메시징 패턴입니다. Redis의 Pub/Sub은 브로커 없이 Redis 서버가 직접 메시지를 중계하며, 메시지를 저장하지 않는 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);
}
}
}
@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 발행
정리
Redis Pub/Sub은 가장 간단하게 실시간 메시징을 구현할 수 있는 방법입니다. 캐시 무효화 브로드캐스트, 실시간 알림 같은 메시지 유실이 허용되는 시나리오에 적합합니다. 하지만 메시지 영속성, 재처리, 분산 처리가 필요하다면 Redis Stream이나 전용 메시지 브로커(Kafka, RabbitMQ)를 고려하세요. Keyspace Notification은 키 만료 이벤트 감지 등에 유용하지만, 역시 이벤트 유실 가능성을 감안해야 합니다.
댓글 로딩 중...