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의 실행 간격을 동적으로 변경할 수 있습니다.

동적 재스케줄링

  1. 에러 발생 단계별 조정:

errorCount 변수를 통해 장애 발생 횟수를 추적합니다.

  • 첫 번째 장애 시: cron 주기를 30초마다 (’*/30 * * * * *’)
  • 두 번째 장애 시: 1분마다 (‘0 * * * * *’)
  1. 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 도입을 개발 방향을 보며 추가 적용할 계획입니다.


읽어주셔서 감사합니다!