Tiptap 공동편집 Editor 환경에서 Redis 장애가 발생할 경우, 어떤 문제가 생기고 어떻게 대응할 수 있을지 시뮬레이션을 통해 정리해보았습니다. 특히 실제 구현 코드와 함께 개선 방향을 공유합니다.
✅ 캐시 개선 사항
이전 구성에서는 아래 코드처럼 Redis에 문서의 마지막 상태를 1시간 동안 캐싱하고 있었습니다.
await this.redisClient.set(`doc:${docId}:temp`, contentStr, 'EX', 3600);
하지만 에디터의 내용 동기화 로직이 아래와 같이 이미 room이 존재할 경우 소켓을 통해 받도록 이루어지고 있기 때문에, 굳이 1시간 동안 보관할 필요가 없었습니다.
onCreate: ({ editor: currentEditor }) => {
provider.on('synced', () => {
if (currentEditor.isEmpty) {
currentEditor.commands.setContent(defaultContent);
}
});
}
이에 따라 Redis의 TTL(Time-To-Live)을 3600초에서 120(크론 주기 중 장애가 났을 경우 최대)+5(여유시간)초로 단축하여, 장애 대응 측면에서도 보다 효율적인 구성이 가능하도록 개선하였습니다.
⚠️ Redis 장애 시나리오
현재 시스템은 단일 인스턴스의 Redis를 개발 및 검증 목적으로 사용하고 있습니다. 향후 운영 환경에서는 Master/Slave 구성으로 확장하고, Master 장애 발생 시 Slave 승격(Failover) 시나리오를 검증할 예정입니다.
redis 장애 발생 시 Slave가 Master로 승격하는 과정에서 몇 초에서 수십 초간의 연결 단절(disconnection)이 발생할 수 있으며, 이러한 상황을 적절히 처리하지 않으면 데이터 유실 및 사용자 경험 저하로 이어질 위험이 있습니다.
💡 장애 대응 전략 고안
초기에는 Circuit Breaker와 내부 메모리 큐를 조합한 cron 기반 fallback 방식을 고려하였으나, 장애가 장기화되면 NestJS에 과부하가 발생하고 큐 길이 제한으로 인해 데이터 유실 가능성이 있었습니다.
또한, Map을 사용하면 Redis의 동작을 어느 정도 대체할 수 있어 사용자에게 발생할 수 있는 불편을 최소화할 수 있을 것으로 판단하였습니다.
비록 카프카와 같은 메시지 큐를 사용하는 것이 이상적일 수 있으나, 현재 서비스의 크기와 회사의 사정 상 도입이 어려워 내부 메모리 Map을 최종으로 결정하였습니다.
최종적으로 아래와 같은 전략으로 대응합니다.
- Redis 이중화: Master/Slave 구성을 통해 가용성 확보
- Fallback 저장소: 장애 발생 시(try/catch 사용) NestJS 단일 인스턴스 내의 내부 메모리 Map과 Set을 fallback 저장소로 활용
- 데이터 Flush: fallback 데이터를 Postgres로 5분마다 일괄 전송
- 주기 조절: cron 주기를 조절하여 Redis 부하를 완화 (단계적으로 10초 → 30초 → 1분)
- 장애 복구: 장애 중에는 내부 Map에 저장된 데이터만으로 데이터 흐름 진행
🧪 구현 코드 (NestJS 기준)
interface DocData {
contentStr: string;
currentHash: string;
}
@Injectable()
export class RedisFallbackMapService implements OnModuleInit {
private readonly logger = new Logger(RedisFallbackMapService.name);
// private redisCluster: Cluster;
private redisCluster: Redis;
private fallbackMap: Map<string, DocData> = new Map();
private fallbackSet: Set<string> = new Set();
private isRedisAvailable = true;
private redisCircuitBreakerOpen = false;
private circuitBreakerOpenedAt = 0;
private readonly circuitBreakerTimeout = 30000; // 30초
private circuitBreakerFailureCount = 0;
private readonly circuitBreakerFailureThreshold = 3;
constructor(private readonly postgresService: PostgresService) {}
onModuleInit() {
//this.redisCluster = new Redis.Cluster([
// { host: '/your/redis/host', port: 2222 },
// { host: '/your/redis/host', port: 2223 },
//]);
this.redisCluster = new Redis(2222, '/your/redis/host', {
maxRetriesPerRequest: 3,
retryStrategy: (times) => {
// 최대 3초까지 재시도 간격을 늘리고, 특정 시간 이후에는 재시도를 중단
if (times > 5) return null; // 재시도 중단
return Math.min(times * 100, 3000);
},
});
this.redisCluster.on('connect', () => {
this.isRedisAvailable = true;
this.logger.log('Redis connected');
});
this.redisCluster.on('error', (error) => {
this.isRedisAvailable = false;
this.logger.error(`Redis error: ${error.message}`);
});
this.redisCluster.on('end', () => {
this.isRedisAvailable = false;
this.logger.warn('Redis connection closed');
});
}
async setDocData(docId: string, content: any): Promise<{ success: boolean; message?: string }> {
const contentStr = JSON.stringify(content);
const currentHash = crypto.createHash('sha256').update(contentStr).digest('hex');
try {
// 중복 요청 확인
const fallbackData = this.fallbackMap.get(docId);
const lastSavedHash =
fallbackData?.currentHash || (await this.redisCluster.get(`doc:${docId}:lastSavedHash`));
if (lastSavedHash === currentHash) {
this.logger.log(`문서 ${docId}: 중복된 요청으로 저장을 생략합니다.`);
return { success: false, message: '중복된 요청입니다.' };
}
// Circuit breaker 상태 확인
if (this.redisCircuitBreakerOpen && !this.isRedisAvailable) {
if (Date.now() < this.circuitBreakerOpenedAt + this.circuitBreakerTimeout) {
this.logger.warn(
`Circuit breaker 활성화 상태입니다. 30초 후 재시도 바랍니다. fallbackMap에 저장합니다.`,
);
this.fallbackMap.set(docId, { contentStr, currentHash });
this.fallbackSet.add(docId);
return { success: true, message: 'fallbackMap에 저장' };
} else {
// 30초 이후 circuit breaker 재설정
this.redisCircuitBreakerOpen = false;
}
}
let retryCount = 0;
while (retryCount < this.circuitBreakerFailureThreshold) {
try {
if (this.isRedisAvailable) {
await this.redisCluster.set(`doc:${docId}:temp`, contentStr, 'EX', 125);
await this.redisCluster.set(`doc:${docId}:lastSavedHash`, currentHash, 'EX', 125);
await this.redisCluster.sadd('modifiedDocs', docId);
this.logger.log(`Data stored in Redis for doc ${docId}`);
return { success: true };
} else {
throw new Error('Redis not available');
}
} catch (error) {
retryCount++;
this.logger.error(`Redis 저장 에러 (시도 ${retryCount}): ${error?.message || error}`);
if (retryCount >= this.circuitBreakerFailureThreshold) {
// Circuit breaker 활성화
this.redisCircuitBreakerOpen = true;
this.isRedisAvailable = false;
this.circuitBreakerOpenedAt = Date.now();
this.logger.error(`3회 이상 Redis 저장 실패: circuit breaker 활성화.`);
break;
}
const baseDelay = 100; // 100ms
const maxDelay = 10000; // 10초
const backoffTime =
Math.min(baseDelay * Math.pow(2, retryCount), maxDelay) + Math.floor(Math.random() * 100);
await new Promise((resolve) => setTimeout(resolve, backoffTime));
}
}
} catch (error) {
// Fallback: fallbackMap에 저장
this.fallbackMap.set(docId, { contentStr, currentHash });
this.fallbackSet.add(docId);
this.logger.error(`Redis error: ${error?.message || error}`);
return { success: true, message: 'fallbackMap에 저장' };
}
}
async getFallback(docId: string): Promise<{ contentStr: string; currentHash: string } | null> {
if (this.fallbackMap.has(docId)) return this.fallbackMap.get(docId);
const pgContent = await this.postgresService.getDocumentContent(docId);
if (pgContent) {
const hash = crypto.createHash('sha256').update(pgContent).digest('hex');
const data = { contentStr: pgContent, currentHash: hash };
this.fallbackMap.set(docId, data);
return data;
}
return null;
}
async getDocument(docId: string, userId: string): Promise<{ contentStr: string; currentHash: string } | null> {
let contentStr: string | null = null;
let currentHash: string | null = null;
let retryCount = 0;
// Circuit breaker 상태 확인: Redis가 사용 가능하면 circuit breaker가 활성화되어 있어도 Redis에 요청합니다.
if (this.redisCircuitBreakerOpen && !this.isRedisAvailable) {
if (Date.now() < this.circuitBreakerOpenedAt + this.circuitBreakerTimeout) {
this.logger.warn(`Circuit breaker 활성화 상태입니다. 30초 후 재시도 바랍니다. fallbackMap 사용.`);
return await this.getFallback(docId);
} else {
// 30초 이후 circuit breaker를 재설정합니다.
this.redisCircuitBreakerOpen = false;
}
}
// Redis로부터 문서 데이터를 가져오기 위해 최대 3회 재시도
while (retryCount < this.circuitBreakerFailureThreshold) {
try {
contentStr = await this.redisCluster.get(`doc:${docId}:temp`);
currentHash = await this.redisCluster.get(`doc:${docId}:lastSavedHash`);
if (contentStr && currentHash) {
this.circuitBreakerFailureCount = 0;
this.logger.log(`Redis에서 doc ${docId} 데이터 반환`);
return { contentStr, currentHash };
}
const lockKey = `lock:${docId}`;
// 락을 10초 동안 설정 (NX: key가 존재하지 않을 때만, EX: 만료 시간 설정)
const lock = await this.redisClient.set(lockKey, 'locked', 'EX', 10, 'NX');
if (lock) {
try {
// 락을 획득했으므로, 다시 캐시를 확인 (다른 프로세스가 캐시를 채웠을 수 있음)
contentStr = await this.redisClient.get(doc:${docId}:temp);
if (contentStr) {
return contentStr;
}
const client = await this.pool.connect();
try {
//postgres에서 문서를 조회해와서 레디스에 캐싱
const result = await client.query(`select content_json from documents where doc_id = $1`, [docId]);
contentStr = result.rows[0]?.content_json;
if (contentStr) {
await this.redisClient.set(key, contentStr, 'EX', 125);
}
return contentStr;
} finally {
client.release();
}
} finally {
// 락 해제
await this.redisClient.del(lockKey);
}
}
} catch (error) {
retryCount++;
this.circuitBreakerFailureCount++;
this.logger.error(`Redis get 에러 (시도 ${retryCount}): ${error?.message || error}`);
if (retryCount >= this.circuitBreakerFailureThreshold) {
// 3회 이상 실패 시 Redis를 사용할 수 없는 상태로 전환
this.redisCircuitBreakerOpen = false;
this.isRedisAvailable = false;
this.circuitBreakerOpenedAt = Date.now();
this.logger.error(
`3회 이상 Redis 요청 실패: redisCircuitBreakerOpen과 isRedisAvailable를 false로 설정합니다.`,
);
break;
}
}
const baseDelay = 100; // 100ms
const maxDelay = 10000; // 10초
const backoffTime =
Math.min(baseDelay * Math.pow(2, retryCount), maxDelay) + Math.floor(Math.random() * 100);
await new Promise((resolve) => setTimeout(resolve, backoffTime));
}
return this.getFallback(docId);
}
async getSet(setId: string) {
try {
if (this.isRedisAvailable) {
return this.redisCluster.smembers(setId);
}
} catch (error) {
this.logger.warn(`${setId} 해당 set을 가져오는 중 에러가 발생했습니다. error: ${error.message || error}`);
return [];
}
return [];
}
async deleteRedisSet(setId: string, key: string) {
if (this.isRedisAvailable) {
return this.redisCluster.srem(setId, key);
}
return [];
}
async getRedisData(key: string) {
if (this.isRedisAvailable) {
return this.redisCluster.get(key);
}
return null;
}
@Cron('* */5 * * * *')
async flushFallbackMap() {
if (this.fallbackSet.size === 0) return;
this.logger.log(`Fallback Map flush 시작. Map 문서 수: ${this.fallbackMap.size}`);
this.logger.log(`Fallback Map flush 시작. Set 문서 수: ${this.fallbackSet.size}`);
for (const docId of this.fallbackSet) {
try {
await this.postgresService.saveDocument(docId, this.fallbackMap.get(docId).contentStr, '');
this.fallbackMap.delete(docId);
this.fallbackSet.delete(docId);
this.logger.log(`Postgres에 fallback 데이터 저장 성공: doc ${docId}`);
} catch (error) {
this.logger.error(`Postgres 저장 실패: ${docId}, 에러: ${error?.message || error}`);
}
}
}
}
🔄 SchedulerRegistry를 활용한 동적 Cron 주기 재조정
장애 발생 시 cron 주기를 늘려 재시도할 필요가 있습니다. NestJS의 Schedule 모듈에서 제공하는 SchedulerRegistry를 활용하면, CronJob의 실행 간격을 동적으로 변경할 수 있습니다.
동적 재스케줄링
- 에러 발생 단계별 조정:
errorCount 변수를 통해 장애 발생 횟수를 추적합니다.
- 첫 번째 장애 시: cron 주기를 30초마다 (’*/30 * * * * *’)
- 두 번째 장애 시: 1분마다 (‘0 * * * * *’)
- SchedulerRegistry 활용:
- SchedulerRegistry를 통해 지정한 CronJob 인스턴스를 조회, 중지한 후 새로운 주기로 재설정하고 재시작합니다.
예제 코드
@Injectable()
export class Service {
private readonly logger = new Logger(Service.name);
private errorCount = 0; // 에러 발생 횟수를 추적합니다.
constructor(
@Inject('REDIS') private readonly redisClient: Redis,
@Inject('PG_POOL') private readonly pool: Pool,
private schedulerRegistry: SchedulerRegistry, // SchedulerRegistry 주입
) {}
// 주기적인 작업: 변경사항이 있을 때만 saveFinal 실행
// @Cron 데코레이터에 name 옵션을 추가하여 SchedulerRegistry에서 접근할 수 있게 함
@Cron('*/10 * * * * *', { name: 'periodicSaveJob' })
async periodicSave() {
const docIdsSnapshot = await this.redisClient.smembers('modifiedDocs');
if (docIdsSnapshot.length === 0) {
return;
}
from(docIdsSnapshot)
.pipe(
mergeMap((docId) =>
from(this.saveFinal(docId)).pipe(
catchError((error) => {
this.logger.error(`문서 ${docId} 저장 중 에러 발생:`, error);
// 에러 발생 시 cron 주기를 늘리는 로직 실행
this.errorCount++;
this.rescheduleCronJob('periodicSaveJob', this.errorCount); // 예: 30초마다 실행
// 에러가 있어도 흐름을 이어가기 위해 null 반환
return of(null);
}),
),
),
)
.subscribe();
}
// SchedulerRegistry를 사용하여 CronJob의 주기를 재조정하는 함수
rescheduleCronJob(jobName: string, errorCount: number) {
const intervals = [
'*/30 * * * * *', // 첫 번째 장애: 30초마다
'0 * * * * *', // 두 번째 장애: 1분마다
];
const newCronTime = intervals[Math.min(errorCount - 1, intervals.length - 1)];
try {
const job: CronJob = this.schedulerRegistry.getCronJob(jobName);
this.logger.log(`기존 cron 주기 변경: ${jobName} → ${newCronTime}`);
// CronJob 중지
job.stop();
// 새로운 스케줄로 재설정
job.setTime(new CronJob(newCronTime).cronTime);
// CronJob 재시작
job.start();
} catch (error) {
this.logger.error(`cron job 재설정 실패: ${jobName}`, error);
}
}
}
🚀 회고 및 참고 자료
혼자서 장애 발생 시나리오를 분석하고 이를 대응하기 위한 구조를 고민하며 구현해보니, 예상했던 위험 시나리오에 대한 대응력을 높일 수 있었습니다.
특히 토스의 캐시 트래픽 실전 대응 글에서 제시한 핵심 개념과 방향성이 유사하여, 어느 정도 방향을 잘 잡고 있었구나 하는 자신감도 얻었습니다.
향후에는 Null Object Pattern 도입을 개발 방향을 보며 추가 적용할 계획입니다.
읽어주셔서 감사합니다!