혹시 실시간으로 많은 트래픽을 처리해야 하는 시스템에서 캐시 만료 문제로 인해 성능 저하를 경험한 적이 있나요? 그렇다면, 이 글에서 소개할 캐시 관리 전략이 해당 문제를 해결하는데 도움이 될 겁니다.

캐시 만료로 인한 실시간 서비스에서 발생하는 문제점

일반적으로 캐시를 사용하면 반복적이고 무겁게 처리되는 요청의 응답 시간을 크게 개선할 수 있습니다. 그러나 캐시가 주기적으로 만료되어 캐시 미스가 발생하는 경우, 만료된 데이터를 다시 불러오는 과정에서 응답 시간이 길어지는 문제가 발생할 수 있습니다.

도입 배경

제가 담당하고 있는 실시간 서비스에서도 위와 같은 문제로 캐시 만료 주기마다 응답시간이 크게 튀는 현상이 발생하였습니다.

초기에는 해결방안으로 별도의 컴포넌트를 도입하여 해당 컴포넌트에서 주기적으로 캐시를 업데이트 해주는 방식을 고려하였으나, DB 데이터 Soft/hard delete 방식으로 관리하는 것과 같이 캐시도 유사하게 관리하는 게 어떤가 하는 생각이 들었습니다.

그래서 위 문제를 해결할 수 있는 두 가지 방식 중, 시간과 비용을 아낄 수 있고 저희 서비스에 더 적합한 상태 관리를 통한 개선을 선택하여 구현하였습니다.

캐시 상태 개념

저는 캐시 상태를 FRESH, STALE, EXPIRED 세 가지 상태로 구분하고, 각 상태에 맞게 데이터를 처리하는 방식으로 구현하였습니다. 이러한 상태 관리 개념을 통해 캐시된 데이터가 만료되었더라도 일관된 응답 시간을 유지할 수 있습니다.
(이와 유사한 개념으로 HTTP Caching을 참조하였습니다. 참조)

  • FRESH: 캐시된 데이터가 최신 상태이며, 그대로 사용할 수 있습니다.
  • STALE: 캐시된 데이터가 오래되었지만, 사용이 가능합니다. 이 상태에서 요청을 받는 경우, 비동기적으로 데이터를 갱신을 트리거합니다.
  • EXPIRED: 캐시된 데이터가 만료되어 사용할 수 없습니다. 이때는 새 데이터를 불러와서 갱신해야 합니다.

캐시 상태 관리 구현 방법

  1. 캐시 상태 추가:
    우선, 캐시된 데이터의 상태를 관리하기 위해 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();
       }
     }
     ```
    
  2. 캐시 갱신:
    캐시된 데이터가 오래될 경우, 이를 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;
     }
    
  3. 캐시 비동기 갱신:
    만약 캐시된 데이터가 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()로 핸들링 하여, 비동기 동작 중 발생할 수 있는 사이드 이펙트를 방지합니다.

결론

이렇게 캐시 상태 관리를 도입함으로써, 만료된 데이터로 인한 응답 지연 문제를 해결할 수 있습니다. FRESHSTALE 상태를 사용하여 데이터 갱신을 비동기적으로 처리함으로써 실시간 트래픽의 성능을 개선할 수 있습니다.

위 방식을 통해 저희 서비스의 응답시간은 P95 기준으로 기존의 22%(256 -> 57ms) 수준으로 개선되었으며, 주기적으로 발생하던 만료로 인한 응답시간 튐 현상도 해결하게 되었습니다.

캐시는 시스템 성능에 큰 영향을 미칠 수 있는 중요한 요소입니다. 이번 기회에 캐시 상태 개념을 적용해보세요. 분명 성능 향상을 체감하실 수 있을 겁니다.

여기까지 읽어주셔서 감사합니다!🥳

profile
Corca Backend Engineer, dha

0개의 댓글