혹시 실시간으로 많은 트래픽을 처리해야 하는 시스템에서 캐시 만료 문제로 인해 성능 저하를 경험한 적이 있나요? 그렇다면, 이 글에서 소개할 캐시 관리 전략이 해당 문제를 해결하는데 도움이 될 겁니다.
일반적으로 캐시를 사용하면 반복적이고 무겁게 처리되는 요청의 응답 시간을 크게 개선할 수 있습니다. 그러나 캐시가 주기적으로 만료되어 캐시 미스가 발생하는 경우, 만료된 데이터를 다시 불러오는 과정에서 응답 시간이 길어지는 문제가 발생할 수 있습니다.
제가 맡은 서비스 실시간 서비스에서도 위와 같은 문제로 캐시 만료 주기마다 응답시간이 크게 튀는 현상이 발생하였습니다.
초기에는 해결방안으로 별도의 컴포넌트를 도입하여 해당 컴포넌트에서 주기적으로 캐시를 업데이트 해주는 방식을 고려하였으나, DB 데이터 Soft/hard delete 방식으로 관리하는 것과 같이 캐시도 유사하게 관리하는 게 어떤가 하는 생각이 들었습니다.
그래서 위 문제를 해결할 수 있는 두 가지 방식 중, 시간과 비용을 아낄 수 있고 저희 서비스에 더 적합한 상태 관리를 통한 개선을 선택하여 구현하였습니다.
저는 캐시 상태를 FRESH, STALE, EXPIRED 세 가지 상태로 구분하고, 각 상태에 맞게 데이터를 처리하는 방식으로 구현하였습니다. 이러한 상태 관리 개념을 통해 캐시된 데이터가 만료되었더라도 일관된 응답 시간을 유지할 수 있습니다.
(이와 유사한 개념으로 HTTP Caching을 참조하였습니다. 참조)
캐시 상태 추가:
우선, 캐시된 데이터의 상태를 관리하기 위해 CacheState
클래스를 추가합니다. 이 클래스는 캐시된 데이터의 상태와 최근 갱신 시간을 포함합니다.
export enum CacheState {
FRESH = 'fresh',
STALE = 'stale',
EXPIRED = 'expired',
}
export class CacheState {
state: CacheState;
cachedAt: Date;
constructor() {
this.state = CacheState.FRESH;
this.cachedAt = new Date();
}
}
```
캐시 갱신:
캐시된 데이터가 오래될 경우, 이를 STALE 상태로 변경하고 갱신할 필요가 있음을 나타내며, 비동기적으로 데이터를 갱신합니다. 다음은 캐시 갱신 로직을 담은 부분입니다.
async getCache(cacheArgs: CacheArgs): Promise<CachedData> {
// 캐시 또는 캐시 상태가 존재하지 않는 경우, 캐시가 없다고 판단합니다.
const stored = await this.cacheManager.get(cacheArgs.getCacheKey());
if (!stored) return null;
const state = await this.cacheManager.get(cacheArgs.getStateCacheKey());
if (!state) return null;
// 캐시가 stale 상태인 지, 확인합니다.
const isStale = this.updateCacheStateIfStale(state, cacheArgs);
return { data: stored, isUpdateRequired: isStale };
}
updateCacheStateIfStale(state: CacheState, cacheArgs: CacheArgs): boolean {
const cachedAt = new Date(state.cachedAt).getTime();
const lifetime = (new Date().getTime() - cachedAt) / 1000;
const isUpdateRequired = state.state === CacheState.FRESH && lifetime > this.softTTL;
// 캐시가 stale 상태가 되어야 하는 경우, state를 먼저 stale로 변경합니다.
if (isUpdateRequired) {
state.state = CacheState.STALE;
this.cacheManager.set(cacheArgs.getStateCacheKey(), state, { ttl: this.hardTTL - Math.floor(lifetime) });
}
return isUpdateRequired;
}
캐시 비동기 갱신:
만약 캐시된 데이터가 STALE 상태라면, 요청과 연관되지 않게 비동기적으로 데이터를 갱신합니다. 이렇게 하면 사용자에게는 STALE 데이터를 반환하면서도 백그라운드에서 최신 데이터를 준비할 수 있습니다.
async getData(id: string, span?: Span): Promise<any> {
const cacheArgs = new CacheArgs(id);
// 캐시가 존재하는 지 확인합니다.
const cache = await this.getCache(cacheArgs);
if (cache) {
const { data, isUpdateRequired } = cache;
// 바로 위 코드 예시에서 받은 응답으로 캐시가 업데이트가 필요한 경우,
// 비동기적으로 캐시를 업데이트 하도록 트리거하고, 기존 캐시 데이터를 반환합니다.
if (isUpdateRequired) {
this.fetchNewDataAsynchronously(id, cacheArgs);
}
return data;
}
return this.fetchNewData(id, cacheArgs);
}
fetchNewDataAsynchronously(id: string, cacheArgs: CacheArgs) {
this.fetchNewData(id, cacheArgs).catch((e) => {
console.error(`Failed to update cache: ${e}`);
});
}
마지막으로, async
함수를 호출하면서 반드시 await
하지 않아도 되는 로직은 .catch()
로 핸들링 하여, 비동기 동작 중 발생할 수 있는 사이드 이펙트를 방지합니다.
이렇게 캐시 상태 관리를 도입함으로써, 만료된 데이터로 인한 응답 지연 문제를 해결할 수 있습니다. FRESH와 STALE 상태를 사용하여 데이터 갱신을 비동기적으로 처리함으로써 실시간 트래픽의 성능을 개선할 수 있습니다.
위 방식을 통해 저희 서비스의 응답시간은 P95 기준으로 기존의 22%(256 -> 57ms) 수준으로 개선되었으며, 주기적으로 발생하던 만료로 인한 응답시간 튐 현상도 해결하게 되었습니다.
캐시는 시스템 성능에 큰 영향을 미칠 수 있는 중요한 요소입니다. 이번 기회에 캐시 상태 개념을 적용해보세요. 분명 성능 향상을 체감하실 수 있을 겁니다.
여기까지 읽어주셔서 감사합니다!🥳