[Deep Dive] Redis 고도화 (2): 트랜잭션과 논블로킹 대용량 무효화 전략

1부에서 데코레이터를 통해 캐시를 '언제, 어디서' 적용할지 결정했다면, 2부에서는 '어떻게 안전하고 빠르게' 데이터를 관리할 것인가에 집중합니다.

수만 개의 캐시 데이터 사이에서 성능 저하 없이 특정 그룹만 골라 삭제하는 '태그 무효화'의 정수를 다룹니다.

1. 데이터 무결성을 위한 Redis 트랜잭션 (multi)

캐시를 저장할 때 단순히 값만 저장하면 반쪽짜리 설계입니다. 나중에 특정 태그로 해당 캐시를 찾아 지우려면, [캐시 키 저장][태그-키 매핑 저장]이라는 두 가지 작업이 한 몸처럼 움직여야 합니다.

  • 문제: 캐시 데이터는 저장됐는데 태그 매핑에 실패하면? 해당 캐시는 영원히 지워지지 않는 '유령 캐시'가 됩니다.

  • 해결: ioredis의 multi()를 사용하여 트랜잭션을 구현했습니다. 모든 명령어가 원자적으로 실행되거나, 하나라도 실패하면 모두 실패하게 하여 데이터 간 정합성을 완벽히 맞췄습니다.

  async set(
    key: string,
    value: unknown,
    tags: string[],
    ttl?: number,
  ): Promise<void> {
    //* 트랜잭션.
    const serialized =
      typeof value === 'string' ? value : JSON.stringify(value);

    const tx = this.redis.multi();

    if (ttl) {
      tx.set(key, serialized, 'EX', ttl);
    } else {
      tx.set(key, serialized);
    }

    tags.forEach((tag) => {
      tx.sadd(`tag:${tag}`, key);
    });

    await tx.exec();
  }

많은 개발자가 캐시 무효화를 위해 keyssmembers를 사용합니다. 하지만 데이터가 수만 건이 넘어가면 이 명령어들은 Redis를 멈추게(Blocking) 합니다. 저는 이를 방지하기 위해 스트림 기반의 논블로킹 무효화를 구현했습니다.

무효화 로직의 핵심은 "서비스 중단 없이 수만 개의 키를 지우는 것"입니다. 저는 기존의 del 방식에서 한 발 더 나아가, Redis의 비동기 삭제 명령인 unlink동시성 제어를 결합해 시스템 부하를 극도로 낮췄습니다.

🛠️ 진화된 무효화 로직: 동시성과 파이프라이닝

sscanStream으로 키를 가져오되, 서버 자원을 효율적으로 쓰기 위해 이중 동시성 제어를 도입했습니다.

// 1. 태그 그룹별 동시성 제어 (p-limit 활용)

private readonly MAX_CONCURRENCY = 3;
private readonly limit = pLimit(this.MAX_CONCURRENCY);

async invalidateByTags(tags: string[]): Promise<void> {
  await Promise.all(
    tags.map((tag) => this.limit(() => this.invalidateByTag(tag))),
  );
}
private async invalidateByTag(tag: string): Promise<void> {
  const tagKey = `tag:${tag}`;
  const stream = this.redis.sscanStream(tagKey, { count: 500 });

  const MAX_WORKER_CONCURRENCY = 5;
  let activeWorker = 0;
  let isStreamEnded = false;

  return new Promise((resolve, reject) => {
    stream.on('data', async (keys: string[]) => {
      if (keys.length > 0) {
        activeWorker += 1;

        if (activeWorker >= MAX_WORKER_CONCURRENCY) {
          stream.pause();
        }

        try {
          // 3. pipeline과 unlink를 사용하여 비동기 대량 삭제 최적화
          const pipeline = this.redis.pipeline();
          keys.forEach((key) => pipeline.unlink(key)); 
          await pipeline.exec();
        } catch (err) {
          stream.destroy();
          reject(new Error(`태그 [${tag}] 무효화 실패: ${err}`));
        } finally {
          activeWorker -= 1;

          // 여유가 생기면 다시 스트림 재개
          if (activeWorker < MAX_WORKER_CONCURRENCY) {
            stream.resume();
          }

          // [트러블슈팅] 스트림 종료와 비동기 작업의 레이스 컨디션 해결
          if (isStreamEnded && activeWorker === 0) {
            await this.finalize(tagKey, resolve);
          }
        }
      }
    });
    // ... (이하 end, error 이벤트 로직)
  });
}

3. 기술적 화룡점정: unlink와 동시성 제어

이번 고도화에서 가장 신경 쓴 세 가지 디테일입니다.

  1. unlink의 도입: del은 호출 즉시 메모리에서 데이터를 삭제하며 블로킹을 유발할 수 있지만, unlink는 별도의 스레드에서 비동기적으로 메모리를 해제합니다. 덕분에 대량 삭제 시에도 Redis의 메인 스레드 응답성을 유지할 수 있습니다.

  2. 파이프라이닝 (Pipeline): 수백 개의 unlink 명령을 개별적으로 보내지 않고 pipeline으로 묶어 한 번에 보냅니다. 이는 네트워크 왕복 시간(RTT)을 획기적으로 줄여줍니다.

  3. Backpressure (배압 제어): activeWorkerMAX_CONCURRENCY에 도달하면 stream.pause()를 호출해 데이터 공급을 잠시 멈춥니다. 이는 Node.js 메모리가 급증하는 현상을 방지하고 서버가 감당 가능한 수준에서 작업을 처리하게 만듭니다.


4. 트러블슈팅: 스트림의 배신과 activeWorker의 도입

sscanStream을 구현하면서 가장 큰 기술적 난관에 부착했습니다. 바로 "스트림은 끝났는데, 삭제 작업은 아직 진행 중"인 상황에서 Promise가 먼저 반환되어 버리는 이슈였습니다.

❌ 발생했던 현상

  1. 스트림이 마지막 데이터를 던지고 end 이벤트를 발생시킴.

  2. Promise가 즉시 resolve되어 무효화 작업이 끝났다고 판단함.

  3. 하지만 비동기로 실행 중이던 태그 삭제 작업이 채 끝나기 전이라, 순간적으로 여전히 과거 캐시 데이터가 조회되는 레이스 컨디션 발생.

✅ 해결책: 작업 카운팅(Active Worker) 모델

이를 해결하기 위해 두 가지 상태를 조합한 최종 확정 모델을 설계했습니다.

  1. activeWorker: 현재 태그 삭제를 수행 중인 백그라운드 서비스 워커개수를 추적합니다.

  2. isStreamEnded: Redis로부터 더 이상 가져올 키 목록이 없다는 신호를 확인합니다.

  3. 결과: isStreamEndedtrue이고, 동시에 activeWorker0이 되는 그 찰나의 순간에만 finalize()를 호출하여 "완전한 삭제"를 보장했습니다.

const MAX_CONCURRENCY = 5;
let activeWorker = 0;
let isStreamEnded = false;

stream.on('data', async (keys: string[]) => {
  if (keys.length > 0) {
    activeWorker += 1;

  if (activeWorker >= MAX_CONCURRENCY) {
    stream.pause();
  }

  try {
    const pipeline = this.redis.pipeline();
    keys.forEach((key) => pipeline.unlink(key));
    await pipeline.exec();
  } catch (err) {
    stream.destroy();
    reject(new Error(`태그 [${tag}] 무효화 실패: ${err}`));
  } finally {
    activeWorker -= 1;

    if (activeWorker < MAX_CONCURRENCY) {
      stream.resume();
    }

    if (isStreamEnded && activeWorker === 0) {
      await this.finalize(tagKey, resolve);
    }
  }
});

stream.on('end', async () => {
  isStreamEnded = true;
  if (activeWorker === 0) {
    await this.finalize(tagKey, resolve);
  }
});

stream.on('error', (err) => reject(err));

이 로직을 통해 "더 이상 들어올 데이터도 없고(end), 실행 중인 비동기 작업도 없다(activeWorker: 0)"는 두 가지 조건이 모두 충족될 때만 최종적으로 무효화 완료를 선언하도록 설계하여 안정성을 확보했습니다.


5. 트러블슈팅 2: 좀비 캐시와 flushall의 구원 (개발 환경의 팁)

로직은 완벽한데 화면이 안 바뀐다면? 가끔은 시스템 외부의 요인을 의심해야 합니다.

  • 원인: 설계 변경 전, 태그 매핑 없이 저장된 옛날 캐시 키들이 Redis 어딘가에 좀비처럼 남아 있었습니다. 태그 무효화 로직은 '태그와 연결된 키'만 지우기 때문에, 연결 고리가 없는 이 좀비들은 지워지지 않고 계속 버텼던 것이죠.

  • 해결: 로컬 개발 환경이라면 주저하지 말고 redis-cli flushall을 사용하세요.

docker exec -it redis-server redis-cli flushall

이후 캐시 정합성이 완벽하게 돌아오는 것을 확인했습니다. "깨끗한 도화지에서 다시 시작하는 것"은 때로 가장 빠른 트러블슈팅 방법입니다. (물론, 운영 환경에서는 제가 만든 태그 기반 무효화 로직만 믿고 가야 합니다! ^^)


6. 마치며: 백엔드 캐싱, 그 이상의 가치

프론트엔드(react-query)에서 수동으로 관리하던 캐시를 백엔드 레이어의 선언적 데코레이터안정적인 트랜잭션 시스템으로 이전했습니다.

그 과정에서 스트림의 비동기 처리와 레이스 컨디션을 해결하며 얻은 경험은, 단순히 "빠른 웹사이트"를 만드는 것을 넘어 "신뢰할 수 있는 고성능 인프라"를 설계하는 눈을 뜨게 해주었습니다. 백엔드에서 전역적으로 캐시를 제어할 수 있게 된 지금, 블로그의 응답 속도는 이전과는 비교할 수 없을 정도로 쾌적해졌습니다.