NestJS) Custom Caching Decorator 구현기

백엔드·2024년 11월 21일

들어가며

제가 속한 팀은 인플루언서 마케팅 시장의 비효율을 혁신하기 위해, 인플루언서의 실제 영향력을 측정하는 알고리즘과 적정 섭외 비용 산출 기능을 활용하여 광고주와 인플루언서를 효과적으로 연결하는 광고 매칭 서비스를 제공하고 있는데요.

그중에서도 캠페인 레포트 기능은 서비스의 핵심 기능 중 하나로, 광고 캠페인에 참여한 인플루언서들이 업로드한 콘텐츠 데이터를 기반으로 통계를 산출하여 광고주에게 제공하는 기능입니다.

캠페인에 포함된 모든 콘텐츠 데이터를 기반으로 다양한 계산 및 통계 작업을 수행하기 때문에 CPU 연산이 많이 요구되는 작업입니다. 따라서 서버의 CPU 부하를 줄이고 성능을 최적화하기 위해 캐시 로직을 도입하였습니다.

이 과정에서 NestJS를 활용해 AOP(Aspect-Oriented Programming) 관점으로 Custom Caching Decorator를 구현해보았습니다. 해당 데코레이터를 통해 핵심 로직과 캐싱 로직을 분리하여 코드의 모듈화와 유지보수성을 향상시킬 수 있었습니다.


구현하기

1. Decorator


먼저 Decorator를 정의합니다.

import { applyDecorators, SetMetadata } from '@nestjs/common';
import { ClaCacheKeyPrefix } from '../constant/cache.constant';

// Metadata 키로 사용할 고유 심볼 정의
export const CLA_CACHE_KEY = Symbol('CLA_CACHE_KEY');

// 캐시 옵션 인터페이스 정의
export interface ClaCacheOptions {
  key: ClaCacheKeyPrefix; // 캐시 키의 접두어
  ttl: number; // 캐시 유효 시간 (Time-To-Live)
  index?: number; // 메서드 인수에서 키로 사용할 인덱스 (선택 사항)
}

export function ClaCache(options: ClaCacheOptions): MethodDecorator {
   // 메서드에 캐싱 관련 Metadata를 정의
  return applyDecorators(SetMetadata(CLA_CACHE_KEY, options));
}

ClaCacheOptions의 key 속성

현재 팀에서 사용하는 캐시 키의 형태는 두 가지로 나눌 수 있습니다.

  1. 특정 자원의 ID가 포함된 캐시 키:
    예를 들어, CAMPAIGN_REPORT_${campaign_id}와 같이 특정 자원의 ID가 포함된 형태입니다. 이 경우 key는 캐시 키의 접두어로 사용됩니다.

  2. 단순한 캐시 키:
    또 다른 경우는 자원의 ID가 포함되지 않은 단순한 캐시 키가 필요할 때입니다. 이때는 key가 캐시 키 자체로 사용됩니다.


ClaCacheOptions의 index 속성

특정 자원의 ID가 포함된 캐시 키를 사용할 경우, index 속성이 필요합니다. index는 메서드 인수 중에서 캐시 키를 구성하는 데 필요한 ID 값을 찾는 데 사용됩니다. 예를 들어, 캐시 키가 CAMPAIGN_REPORT_${campaign_id}와 같은 형태라면, campaign_id를 메서드 인수에서 찾아야 합니다.

NestJS의 데코레이터는 클래스가 초기화되는 시점에 실행되므로, ID 값을 직접 전달할 수 없습니다. 이를 해결하기 위해, index는 메서드 인수에서 캐시 키를 구성하는 데 필요한 ID 값을 추출하는 역할을 합니다. 즉, index는 메서드의 인수 배열에서 몇 번째 인수를 사용할지를 지정하여, 해당 값(예: campaign_id)을 캐시 키에 동적으로 추가할 수 있도록 합니다.


2. Cache Module

SetMetadata, DiscoveryModule, MetadataScanner를 활용하여 Cache Module을 구현합니다.


DiscoveryService

NestJS에서 제공하는 DiscoveryModuleDiscoveryService는 애플리케이션의 모든 모듈, 컨트롤러, 프로바이더 클래스를 조회하는 데 유용한 도구입니다. 내부적으로 modulesContainer를 사용하여 모듈을 탐색하고 필요한 정보를 제공합니다.

다음은 DiscoveryService를 사용하여 서비스 인스턴스를 검색하고 캐싱 로직을 적용하는 예시 코드입니다.

@Module({
  imports: [LoggerModule, DiscoveryModule, RedisModule],
})
export class CacheModule {
  constructor(
    private readonly discoveryService: DiscoveryService,
    private readonly metadataScanner: MetadataScanner,
    private readonly reflector: Reflector,
    private readonly redis: RedisProvider,
    private readonly loggerService: LoggerService,
  ) {}

  // 모듈 초기화 시 호출되는 메서드
  async onModuleInit() {
    this.registerCacheWithLogging();
  }

  // 서비스 인스턴스를 검색하여 반환하는 메서드
  private getInstances() {
    return this.discoveryService
      .getProviders() // 모든 프로바이더를 검색
      .filter((v) => v.isDependencyTreeStatic())  // 정적 의존성 트리만 필터링
      .filter(({ metatype, instance }) => instance && metatype);  // 인스턴스와 메타타입이 존재하는 프로바이더만 필터링
  }

 
  private registerCacheWithLogging() {
    const instances = this.getInstances();
    for (const instance of instances) {
      this.wrapInstanceMethods(instance);
    }
  }
}


MetadataScanner, Reflector

MetadataScanner를 활용하여 클래스의 모든 메서드를 순회하고, Reflector를 통해 CLA_CACHE_KEY에 해당하는 메타데이터가 마킹된 메서드가 존재할 경우 캐시 로직을 적용할 수 있습니다.

코드 예시입니다.

  private wrapInstanceMethods(instance: any) {
   // 인스턴스의 프로토타입에서 모든 메서드 이름을 가져옵니다.
    const methodNames = this.metadataScanner.getAllMethodNames(
      Object.getPrototypeOf(instance.instance),
    );

    for (const methodName of methodNames) 
  	  // 원본 메서드 참조
      const originalMethod = instance.instance[methodName];
  	  // 해당 메서드에 설정된 메타데이터(CLA_CACHE_KEY) 조회
      const metadata: ClaCacheOptions = this.reflector.get(CLA_CACHE_KEY, originalMethod);
		
  	  // 메타데이터가 존재하는 경우 캐시 로직을 적용
      if (metadata) {
        instance.instance[methodName] = this.wrapMethod(
          originalMethod, // 원본 메서드
          instance.instance, // 해당 메서드가 속한 인스턴스
          metadata,
        );
      }
    }
  }

  • this.metadataScanner.getAllMethodNames(...)를 사용하여 인스턴스의 모든 메서드 이름을 가져옵니다.

  • this.reflector.get(CLA_CACHE_KEY, originalMethod)를 사용하여 CLA_CACHE_KEY 심볼을 메타데이터로 가지고 있는 메서드들을 찾습니다.


(4) Wrapping

원본 메서드를 감싸서 캐시 로직과 함께 실행되도록 합니다.

코드 예시


// ...

  private wrapMethod(originalMethod: any, instance: any, metadata: ClaCacheOptions) {
    const { loggerService, redis } = this;

    return async function (...args: any[]) {
      try {
        const cacheKeyPrefix = metadata.key; // metadata의 key를 통해 접두어 설정
 		const cacheKeySuffix = metadata.index ? args[metadata.index] : ''; // args 배열에서 metadata의 인덱스를 통해 접미어 설정
        const cacheKey = cacheKeyPrefix + cacheKeySuffix;
        
        // 캐시 조회
        const cached = await redis.getCache(cacheKey);
        if (cached) {
          loggerService.info(originalMethod.name, `cache hit: ${cacheKey}`);
          return JSON.parse(cached);
        }
       
       // 캐시 Miss 시, 원본 메서드를 실행하고 결과를 캐시에 저장
        const result = await originalMethod.apply(instance, args);
        await redis.setCache(cacheKey, JSON.stringify(result), metadata.ttl);

        return result;
      } catch (error) {
        loggerService.error(
          originalMethod.name,
          error,
          'there was an error during cache operation',
        );
      }
    };
  }

cache key 생성 과정

  1. 특정 자원의 ID가 포함된 캐시 키

    key: CAMPAIGN_REPORT__
    index: 0 (캠페인 ID가 첫 번째 인수로 제공된다고 가정)
    메서드 인수: [123] (예를 들어, campaign_id = 123)

    CAMPAIGN_REPORT_${campaign_id}와 같은 형태의 캐시 키를 사용할 수 있습니다. 이때 key는 캐시 키의 접두어로 사용되며, campaign_id는 메서드 인수에서 동적으로 추출됩니다.

  2. 단순한 캐시 키

    key: 'CAMPAIGN_REPORT'
    index: 설정되지 않음 (단순한 키이므로 인덱스는 필요 없음)
    메서드 인수: [] (인수 없음)
    결과 캐시 키:

    이 경우, key는 캐시 키 자체로 사용됩니다. 예를 들어, 전체 캠페인의 리포트를 캐싱할 때, 자원의 ID 없이 단순히 CAMPAIGN_REPORT라는 키를 사용합니다.


3. 사용 예시

  
import { Injectable } from '@nestjs/common';
import { ClaCache, ClaCacheOptions } from './decorators/cache.decorator';
import { RedisProvider } from './providers/redis.provider';

@Injectable()
export class CampaignService {
 constructor(private readonly redis: RedisProvider) {}

 // 모든 캠페인에 대한 리포트 
 @ClaCache({ key: 'CAMPAIGN_REPORT', ttl: 3600 }) // TTL 1시간
 async getCampaignReport(): Promise<any> {
    // ...
 }

 // 특정 캠페인 ID에 대한 리포트
 @ClaCache({ key: 'CAMPAIGN_REPORT_', ttl: 3600, index: 0 }) // campaign_id가 첫 번째 인수로 전달
 async getCampaignReportById(campaignId: number, anotherId: number, ...): Promise<any> {
   // ...
 }
}

profile
백엔드 개발자

0개의 댓글