더블 버퍼링으로 캐시 타이밍 이슈 해결하기

June·2024년 8월 25일
0
post-thumbnail

1. 문제 상황

현재 운영중인 서비스에서는 다양한 형태의 모임 목록을 유저에게 보여주고 있었고, 성능 최적화를 위해 다음과 같은 방식을 사용하고 있었습니다:

  • 대부분의 모임 정렬, 필터 집합들이 cron 작업을 통해 특정 주기마다 다양한 형태의 캐시로 생성됩니다.
  • API 요청이 들어오면, 백그라운드에서 생성된 캐시들을 조건에 따라 필터링하거나 정렬하여 응답으로 제공합니다.

이 방식은 대부분의 경우 잘 작동했지만, 캐시를 리프레시하는 타이밍에 조회 요청이 오면 응답 목록에서 모임들이 누락되거나 최악의 경우 빈 리스트가 응답되는 문제가 발생하고 있었습니다. 문제가 꽤나 자주 발생했기에 모임을 운영하는 호스트들과 참가하는 멤버들 모두 매우 불편함을 느끼고 있었고, 고객 문의가 자주 들어오고 있던 상황이었습니다. 해당 이슈는 사용자 경험 관점에서 매우 좋지 않고 반복되는 문제로 인해 사용자들의 불만이 쌓이면 장기적으로 서비스에 대한 신뢰도가 저하되어 플랫폼 이탈로 이어질 수 있는 상황이었습니다.

2. 해결책

문제를 해결하기 위해 기존의 캐시 시스템을 개선할 필요가 있었고, 이 과정에서 캐시 더블 버퍼링 을 도입하게 되었습니다. 이 기법이 어떻게 캐시 타이밍 문제를 해결하고 서비스를 개선했는지, 그 과정을 자세히 살펴보도록 하겠습니다.

더블 버퍼링 : 컴퓨터 그래픽스, 신호 처리, 데이터 전송 등 다양한 컴퓨팅 분야에서 사용되는 기술입니다. 이 기법의 핵심 아이디어는 두 개의 버퍼를 번갈아가며 사용함으로써, 한 버퍼가 처리되는 동안 다른 버퍼는 새로운 데이터를 준비하거나 출력할 수 있게 하는 것입니다. 이를 통해 처리 속도를 향상시키고, 데이터의 일관성을 유지하며, 사용자 경험을 개선할 수 있습니다.

이러한 더블 버퍼링의 일반적인 개념은 캐시 시스템에서도 유용하게 적용될 수 있습니다.

  1. 현재 사용 중인 캐시 키와 별도로 임시 키를 사용합니다.
  2. 새로운 데이터를 임시 키에 추가합니다.
  3. 모든 데이터가 추가되면, redis rename 메서드로 임시 키를 실제 키로 변경합니다.

이 방식을 사용하면 캐시를 갱신하는 동안에도 클라이언트는 항상 일관된 데이터를 받을 수 있습니다.

3. 예제

문제를 단순화하여 예시를 만들어보겠습니다.

3.1 문제 정의

  • 인기 있는 프로그래밍 언어 목록을 제공하는 API가 있다고 가정합니다. 이 목록은 매시간 캐시에서 갱신되는데, 갱신 과정에서 아래와 같은 문제가 발생했습니다
    • 캐시 리프레시 시작: 기존 캐시 삭제
    • 새로운 데이터를 순차적으로 추가 중...
    • 이 때 클라이언트가 API를 호출
    • 클라이언트는 불완전한(일부 언어가 누락된) 목록을 받게 됨

프로그래밍 언어 목록 캐시 갱신 함수 정의

  • 아래 함수는 프로그래밍 언어 목록을 전달받아 redis sorted set에 저장을 수행합니다.
  • 이 때 실제 환경에서는 다른 테스크들도 함께 실행하기 때문에 테스트 환경에서는 배치 처리와 임의의 대기를 적용했습니다.
async function setPopularLanguages(
      key: string,
      languages: { score: number; member: string }[],
      batchSize: number,
      _delay: number = 100,
    ) {
      // 기존 캐시 삭제
      await cache.del(key);

      await Promise.all(
        languages
          .reduce<{ score: number; member: string }[][]>(
            (acc, language, index) => {
              const batchIndex = Math.floor(index / batchSize);
              if (!acc[batchIndex]) acc[batchIndex] = [];
              acc[batchIndex].push(language);
              return acc;
            },
            [],
          )
          .map(async (batch) => {
            // 다른 작업을 수행한다고 가정하고 (_delay) 만큼 대기
            await delay(_delay);
            await cache.zadd(
              `${key}:temp`,
              ...batch.flatMap(({ score, member }) => [score, member]),
            );
          }),
      );
    }

프로그래밍 언어 목록 조회 함수 정의

async function getPopularLanguages(key: string) {
   return cache.zrevrange(key, 0, -1);
}

3.2 캐시 타이밍 이슈 재현 테스트

  • 아래 테스트 코드에서 캐시 갱신 시 100ms의 임의 대기를 설정합니다.
  • when 블럭에서 캐시 리프레시와 엑세스가 거의 동시에 발생하도록 구성되어 있습니다.
  • 결과적으로 캐시가 아직 정상적으로 갱신되지 않았기 때문에 전체 목록 조회가 실패합니다. (then 블럭 참조)
it('캐시 엑세스 & 캐시 리프레시 타이밍 에러 발생 케이스', async () => {
  // given
  // 초기 데이터 적재
  await setPopularLanguages(key, languages, 3, 0);

  // when
  // 캐시 리프레시와 동시에 캐시 엑세스
  const [, received] = await Promise.all([
    setPopularLanguages(key, languages, 3, 100),
    getPopularLanguages(key),
  ]);

  // then
  expect(received).not.toEqual(languages.map(({ member }) => member)); // result : pass
});

3.3 더블 버퍼링 적용

  • 더블 버퍼링을 위해 이전에 정의 했던 캐시 갱신 함수를 변경했습니다.
    1. 더블 버퍼링이 적용되면 기존 캐시를 삭제하지 않습니다.
    2. 더블 버퍼링이 적용되면 데이터를 임시키에 저장합니다.
    3. 더블 버퍼링이 적용되면 데이터 적재가 완료된 후 임시 키를 실제 키로 변경합니다.
    async function setPopularLanguages(
      key: string,
      languages: { score: number; member: string }[],
      batchSize: number,
      _delay: number = 100,
      withDoubleBuffering: boolean = false,
    ) {
      // 캐시 초기화
      if (!withDoubleBuffering) await cache.del(key);

      await Promise.all(
        languages
          .reduce<{ score: number; member: string }[][]>(
            (acc, language, index) => {
              const batchIndex = Math.floor(index / batchSize);
              if (!acc[batchIndex]) acc[batchIndex] = [];
              acc[batchIndex].push(language);
              return acc;
            },
            [],
          )
          .map(async (batch) => {
            // 다른 작업을 수행한다고 가정하고 대기
            await delay(_delay);
            await cache.zadd(
              // 더블 버퍼링 적용 시 임시키에 저장
              withDoubleBuffering ? `${key}:temp` : key,
              ...batch.flatMap(({ score, member }) => [score, member]),
            );
          }),
      );

      // 캐시 리프레시
      if (withDoubleBuffering) await cache.rename(`${key}:temp`, key);
    }

3.4 더블 버퍼링 적용 테스트

아래 테스트 코드 처럼 더블 버퍼링 옵션을 true로 하여 테스트하면 리프레시와 동시에 엑세스를 하더라도 클라이언트는 정상적인 응답을 받을 수 있습니다.

it('캐시 엑세스 & 캐시 리프레시 타이밍 에러 해결 케이스', async () => {
  // given
  // 초기 데이터 적재
  await setPopularLanguages(key, languages, 3, 0, true);

  // when
  // 캐시 리프레시와 동시에 캐시 엑세스
  const [, received] = await Promise.all([
    // 더블 버퍼링 적용 
    setPopularLanguages(key, languages, 3, 100, true),
    getPopularLanguages(key),
  ]);

  // then
  expect(received).toEqual(languages.map(({ member }) => member)); // result : pass
});

4. 결론

  • 캐시 시스템에서 동시성 문제는 빈번히 발생하며, 이는 데이터 일관성을 해칠 수 있습니다. 더블 버퍼링 기법은 이러한 문제를 효과적으로 해결할 수 있는 방법 중 하나입니다.
  • 이 기법을 사용하면 캐시를 갱신하는 동안에도 클라이언트에게 항상 일관된 데이터를 제공할 수 있습니다. 대규모 시스템을 설계할 때 이러한 기법을 고려해보는 것이 도움이 될 수 있습니다.
  • 더블 버퍼링은 캐시 시스템뿐만 아니라 다양한 컴퓨팅 분야에서 활용되는 유용한 기법입니다. 그래픽스, 신호 처리, 데이터 전송 등 다양한 영역에서 이 개념을 응용할 수 있습니다.
  • 캐시 시스템을 설계하실 때 이 글이 도움이 되셨기를 바랍니다. 질문이나 의견이 있으시다면 언제든 댓글로 남겨주세요.
  • 예제 코드는 여기 에서 확인할 수 있습니다

0개의 댓글

관련 채용 정보