post-custom-banner

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

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

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

도입 배경

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

초기에는 해결방안으로 별도의 컴포넌트를 도입하여 해당 컴포넌트에서 주기적으로 캐시를 업데이트 해주는 방식을 고려하였으나, 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
post-custom-banner

0개의 댓글