Redis Pub/Sub 메시지 유실 문제 해결: 신뢰성 있는 메시징 구현
Redis Pub/Sub의 메시지 유실 문제를 분석하고 신뢰성 있는 메시징 시스템을 구현하는 방법을 설명합니다.
Redis Pub/Sub 메시지 유실 문제 해결
Introduction
Redis Pub/Sub는 간단하고 빠른 메시징 시스템을 구현할 수 있는 강력한 도구이지만, fire-and-forget 특성으로 인해 메시지 유실 문제가 빈번하게 발생합니다. 프로덕션 환경에서 메시지 유실은 심각한 데이터 불일치와 비즈니스 로직 오류를 초래할 수 있습니다. 이 글에서는 메시지 유실의 원인을 분석하고 해결 방법을 다루겠습니다.
Environment
# Redis Pub/Sub 테스트 환경
redis-cli
# 모니터링 도구
redis-cli MONITOR// Node.js Redis 클라이언트 설정
const Redis = require('ioredis');
const publisher = new Redis({
host: 'localhost',
port: 6379,
retryStrategy: (times) => {
return Math.min(times * 50, 2000);
}
});
const subscriber = new Redis({
host: 'localhost',
port: 6379
});Problem
Pub/Sub 메시지 유실 상황:
// 발행자 (publisher.js)
async function publishMessages() {
for (let i = 0; i < 10000; i++) {
await publisher.publish('orders', JSON.stringify({
orderId: i,
amount: Math.random() * 1000,
timestamp: Date.now()
}));
}
console.log('Published 10000 messages');
}
// 구독자 (subscriber.js)
let receivedCount = 0;
subscriber.subscribe('orders', (err) => {
if (err) {
console.error('Subscribe error:', err);
}
});
subscriber.on('message', (channel, message) => {
receivedCount++;
// 처리 지연 시뮬레이션
setTimeout(() => {
console.log(`Received: ${receivedCount}`);
}, 10);
});
// 실행 결과
// Published 10000 messages
// Received: 8547 // 1453개 메시지 유실!# 구독자 연결 끊김 시 메시지 유실 확인
# 구독자 재시작 후
$ node subscriber.js
# 메시지 수신 안됨 (재시작 전 발행된 메시지 유실)Analysis
메시지 유실의 원인을 분석했습니다:
// Redis Pub/Sub 동작 원리 확인
// 1. 메시지는 연결된 모든 구독자에게만 전달됨
// 2. 구독자가 연결되지 않은 상태에서 발행된 메시지는 버려짐
// 3. 메모리 기반이므로 오프라인 큐 기능 없음
// 구독자 상태 확인
const clientInfo = await publisher.client('INFO', 'clients');
console.log(clientInfo);
// 연결된 구독자 수 확인
const subscribedClients = await publisher.pubsub('NUMSUB', 'orders');
console.log('Subscribers:', subscribedClients);# Redis 메모리 사용량 확인
redis-cli INFO memory
# used_memory:1048576
# used_memory_human:1.00M
# Pub/Sub 채널 정보
redis-cli PUBSUB CHANNELS
# 1) "orders"
# 2) "notifications"Solution
1단계: Redis Streams를 활용한 신뢰성 있는 메시징
// streams-publisher.js
const Redis = require('ioredis');
const redis = new Redis();
async function publishToStream(message) {
const id = await redis.xadd(
'orders-stream',
'*', // 자동 ID 생성
'orderId', message.orderId,
'amount', message.amount.toString(),
'timestamp', message.timestamp.toString()
);
console.log(`Published message with ID: ${id}`);
return id;
}
// 메시지 발행
async function publishMessages() {
for (let i = 0; i < 10000; i++) {
await publishToStream({
orderId: i,
amount: Math.random() * 1000,
timestamp: Date.now()
});
}
}// streams-consumer.js
const Redis = require('ioredis');
const redis = new Redis();
const CONSUMER_GROUP = 'order-processors';
const CONSUMER_NAME = 'processor-1';
async function createConsumerGroup() {
try {
await redis.xgroup(
'CREATE', 'orders-stream', CONSUMER_GROUP, '0', 'MKSTREAM'
);
console.log('Consumer group created');
} catch (err) {
if (err.message.includes('BUSYGROUP')) {
console.log('Consumer group already exists');
} else {
throw err;
}
}
}
async function processMessages() {
await createConsumerGroup();
while (true) {
try {
const messages = await redis.xreadgroup(
'GROUP', CONSUMER_GROUP, CONSUMER_NAME,
'COUNT', 10,
'BLOCK', 5000,
'STREAMS', 'orders-stream', '>'
);
if (messages) {
for (const [stream, entries] of messages) {
for (const [id, fields] of entries) {
await processMessage(id, fields);
// 메시지 처리 완료 확인
await redis.xack('orders-stream', CONSUMER_GROUP, id);
}
}
}
} catch (err) {
console.error('Consumer error:', err);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
async function processMessage(id, fields) {
const message = {};
for (let i = 0; i < fields.length; i += 2) {
message[fields[i]] = fields[i + 1];
}
console.log(`Processing message ${id}:`, message);
// 비즈니스 로직 처리
// 데이터베이스 저장 등
}2단계: Pending 메시지 복구
// 미처리 메시지 확인 및 복구
async function recoverPendingMessages() {
const pending = await redis.xpending(
'orders-stream', CONSUMER_GROUP, 'COUNT', 100
);
for (const [id, consumer, idleTime, deliveryCount] of pending) {
if (idleTime > 60000) { // 60초 이상 미처리
console.log(`Recovering message ${id}, idle: ${idleTime}ms`);
// 다른 컨슈머에게 메시지 전달
const claimResult = await redis.xclaim(
'orders-stream',
CONSUMER_GROUP,
'backup-processor',
60000, // 최소 유휴 시간
id
);
if (claimResult.length > 0) {
console.log(`Claimed message ${id}`);
}
}
}
}
// 주기적으로 미처리 메시지 확인
setInterval(recoverPendingMessages, 30000);3단계: 하이브리드 아키텍처
// 하이브리드 메시징 시스템
class HybridMessageSystem {
constructor() {
this.redis = new Redis();
this.fallbackQueue = []; // 로컬 큐 (Redis 장애 시 대체)
}
async publish(message) {
try {
// Redis Streams에 발행
const id = await this.redis.xadd(
'messages-stream',
'*',
'data', JSON.stringify(message),
'timestamp', Date.now().toString()
);
return { success: true, id };
} catch (err) {
console.error('Redis publish failed, using fallback:', err);
// Redis 실패 시 로컬 큐에 저장
this.fallbackQueue.push({
message,
timestamp: Date.now(),
retries: 0
});
return { success: false, fallback: true };
}
}
async retryFailedMessages() {
const pending = [...this.fallbackQueue];
this.fallbackQueue = [];
for (const item of pending) {
if (item.retries < 3) {
const result = await this.publish(item.message);
if (!result.success) {
item.retries++;
this.fallbackQueue.push(item);
}
}
}
}
}Lessons Learned
- Redis Streams 도입: Pub/Sub 대신 Redis Streams를 사용하면 메시지 유실을 방지할 수 있습니다
- Consumer Group 활용: 컨슈머 그룹을 사용하면 메시지 처리를 분산하고 미처리 메시지를 추적할 수 있습니다
- ACK 메커니즘: 메시지 처리 완료를 명시적으로 확인하는 ACK 메커니즘이 필수적입니다
- 하이브리드 접근: Redis 장애에 대비한 폴백 메커니즘을 구현해야 합니다
- 모니터링: 처리 지연과 미처리 메시지 수를 지속적으로 모니터링해야 합니다
이 블로그는 외부 스폰서십, 제휴 마케팅 또는 광고 수익을 받지 않습니다.