현재 운영중인 서비스에서는 다양한 형태의 모임 목록을 유저에게 보여주고 있었고, 성능 최적화를 위해 다음과 같은 방식을 사용하고 있었습니다:
이 방식은 대부분의 경우 잘 작동했지만, 캐시를 리프레시하는 타이밍에 조회 요청이 오면 응답 목록에서 모임들이 누락되거나 최악의 경우 빈 리스트가 응답되는 문제가 발생하고 있었습니다. 문제가 꽤나 자주 발생했기에 모임을 운영하는 호스트들과 참가하는 멤버들 모두 매우 불편함을 느끼고 있었고, 고객 문의가 자주 들어오고 있던 상황이었습니다. 해당 이슈는 사용자 경험 관점에서 매우 좋지 않고 반복되는 문제로 인해 사용자들의 불만이 쌓이면 장기적으로 서비스에 대한 신뢰도가 저하되어 플랫폼 이탈로 이어질 수 있는 상황이었습니다.
문제를 해결하기 위해 기존의 캐시 시스템을 개선할 필요가 있었고, 이 과정에서 캐시 더블 버퍼링
을 도입하게 되었습니다. 이 기법이 어떻게 캐시 타이밍 문제를 해결하고 서비스를 개선했는지, 그 과정을 자세히 살펴보도록 하겠습니다.
더블 버퍼링 : 컴퓨터 그래픽스, 신호 처리, 데이터 전송 등 다양한 컴퓨팅 분야에서 사용되는 기술입니다. 이 기법의 핵심 아이디어는 두 개의 버퍼를 번갈아가며 사용함으로써, 한 버퍼가 처리되는 동안 다른 버퍼는 새로운 데이터를 준비하거나 출력할 수 있게 하는 것입니다. 이를 통해 처리 속도를 향상시키고, 데이터의 일관성을 유지하며, 사용자 경험을 개선할 수 있습니다.
이러한 더블 버퍼링의 일반적인 개념은 캐시 시스템에서도 유용하게 적용될 수 있습니다.
rename
메서드로 임시 키를 실제 키로 변경합니다.이 방식을 사용하면 캐시를 갱신하는 동안에도 클라이언트는 항상 일관된 데이터를 받을 수 있습니다.
문제를 단순화하여 예시를 만들어보겠습니다.
- 인기 있는 프로그래밍 언어 목록을 제공하는 API가 있다고 가정합니다. 이 목록은 매시간 캐시에서 갱신되는데, 갱신 과정에서 아래와 같은 문제가 발생했습니다
- 캐시 리프레시 시작: 기존 캐시 삭제
- 새로운 데이터를 순차적으로 추가 중...
- 이 때 클라이언트가 API를 호출
- 클라이언트는 불완전한(일부 언어가 누락된) 목록을 받게 됨
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);
}
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
});
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);
}
아래 테스트 코드 처럼 더블 버퍼링 옵션을 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
});