NestJS에서 Spring Boot Cache 흉내내기
15 minutes

페인 포인트#

최근 큰 규모의 고객사가 사내 서비스로 이전하게 되면서 서비스 전반의 부하 안정성 개선이 최우선 과제로 떠올랐습니다. 저희 서비스는 모두 MSA로 구성되어 있었고, 저는 이 서비스들을 팔로업 하는 ‘BFF(Backend for Frontend)’ 서버를 담당하고 있습니다.

MSA 환경에서 BFF 서버는 다양한 서비스의 엔드포인트에 의존하다 보니, 트래픽이 집중될 때 병목 현상이 발생하기 가장 쉬운 구간이었습니다. 이 문제를 빠르게 해결하기 위해 (ASAP …😅) 기존 비즈니스 로직과 복잡하게 얽혀있던 캐시 구현을 모두 걷어낸 후, 지속 가능하고 편한 캐시 관리 방안을 모색했습니다.

기존 캐시 로직에는 몇 가지 문제점이 있었습니다.

  • 비즈니스 로직의 오염
  • 조건부 캐싱의 어려움 (혹은 귀찮은)
  • 에러 핸들링을 위한 보일러플레이트 코드
  • 캐시 무효화의 일관성 문제
  • 계층형 캐시 미지원

위와 같은 문제를 해결하지 않고 구현에만 급급했던 탓에 다음과 같은 코드가 남발되고 있었습니다.

async getPaymentsKey(@Ctx() user: UserContext) {
    const storeId = user.storeId;
    
    const cacheKey = `get-clientKey-${storeId}`;
    let cacheValue = null;
    
    try {
        cacheValue = await this.cacheService.get(cacheKey);
    } catch (err) {
        this.logger.error(`캐시 조회 실패: ${cacheKey}`, err.stack);
        cacheValue = null;
    }
    
    if (cacheValue) {
        return cacheValue;
    }
    
    const {clientKey, state} = await this.paymentsServcie.getPaymentsKey(storeId);
    
    try {
        if (state === 'ACTIVE') {
            await this.cacheService.set(cacheKey, {clientKey, state});
        }
    } catch (err) {
        this.logger.error(`캐시 설정 실패: ${cacheKey}`, err.stack);
    }
    
    return new GetPaymentsKeyResponse(clientKey, state);
}

깔끔한 문제 해결을 위해 Spring Boot의 @Cacheable 어노테이션과 같은 선언적 캐싱 방식을 도입하기로 했습니다.

Spring Boot Cache 참고하기#

문제 해결에 앞서 참고할만한 스프링 부트 캐시의 옵션을 몇가지 추려보겠습니다.

@Cacheable#

Option Description
cacheNames / value 캐시를 그룹화하는 네임스페이스
key 캐시 항목을 식별하는 고유 키
cacheManager 여러 캐시 전략(Redis, Memory) 중 하나 선택
condition 메서드 실행 전 캐시 로직을 실행할지 결정하는 SpEL
unless 메서드 실행 후 결과값을 캐시에 저장할지 결정하는 SpEL

@CacheEvict#

Option Description
cacheNames / value 삭제할 캐시가 속한 네임스페이스
key 삭제할 특정 캐시 항목의 키
cacheManager 캐시 삭제를 수행할 캐시 매니저를 선택
condition 메서드 실행 전 캐시 삭제 로직을 실행할지 결정하는 함수
allEntries true일 경우, key와 무관하게 네임스페이스의 모든 항목을 삭제
beforeInvocation true일 경우, 메서드 실행 전 캐시 삭제 (메서드 성공 여부와 무관)

캐시 네임스페이스 / 메서드 실행 전, 후 평가 / 캐시 전략 선택 등 다양한 옵션들을 추려보았습니다. BFF 서비스에서 사용하기에 부족하지 않은 옵션들 입니다. 하지만 구현하기에 앞서 한가지 큰 문제에 직면합니다.

NestJS에 AOP 적용하기#

NestJS는 Spring Boot의 AOP(Aspect-Oriented Programming) 즉, 메서드 호출을 가로채 실행 전 후로 원하는 로직을 실행 시킬 수 있는 횡단 관심사 기능을 제공하고 있지 않다는 점입니다. 그렇다면 NestJS에서 선언적 캐시를 위한 데코레이터를 어떻게 구현할 수 있을까요? (비슷한 기능으로 interceptor가 있지만 라우트 핸들러에서만 동작하기 때문에 적합하지 않습니다.)

간단한 해결책으로 @toss/nestjs-aop 를 사용했습니다. 1

nestjs-aop는 라우터 핸들러에서만 동작하는 Interceptor가 아닌 부팅 라이프 사이클을 이용해 DI 컨테이너가 초기화된 후, AOP의 대상이 되는 메서드를 찾아 런타임에 직접 교체(Monkey-Patching) 하는 원리로 서비스 내부 메서드를 포함한 모든 프로바이더에 AOP를 적용할 수 있게 합니다.

내부 동작 원리는 크게 [마킹] → [탐색] → [교체] → [실행] 4단계로 나뉩니다.

마킹#

metadata 식별자와 metadata를 인자로 받아 고유한 AOP 심볼로 데코레이터를 생성합니다. applyDecorators 를 통해 두 개의 데코레이터 함수를 순차적으로 적용합니다. 코드를 살펴보면 다음과 같습니다.

export const createDecorator = (
    metadataKey: symbol | string,
    metadata?: unknown,
): MethodDecorator => {
    const aopSymbol = Symbol('AOP_DECORATOR');
    return applyDecorators(
        // 1. 메타 데이터 저장 (Reflect를 통해 메서드에 부착되고 추후 Discovery 과정에서 식별됩니다.)
        (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
            return AddMetadata<symbol | string, AopMetadata>(metadataKey, {
                originalFn: descriptor.value, // 원본 메서드 
                metadata, // 데코레이터에 전달된 옵션 인자
                aopSymbol, // 고유 심볼
            })(target, propertyKey, descriptor);
        },
        
        // 2. 원본 메서드에 프록시 메서드 Wrapping 
        (_: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
            const originalFn = descriptor.value;

            descriptor.value = function (this: any, ...args: unknown[]) {
                // 데코레이터가 붙은 메서드가 호출되면, 런타임은 원본 함수 대신 wrappedFn 함수를 실행하게 됩니다.
                const wrappedFn = this[aopSymbol]?.[propertyKey];
                if (wrappedFn) {
                    return wrappedFn.apply(this, args);
                }

                // AopModule이 없거나 실패한 경우 원본 메서드 실행 (Fallback)
                return originalFn.apply(this, args);
            };

            Object.defineProperty(descriptor.value, 'name', {
                value: propertyKey.toString(),
                writable: false,
            });
            Object.setPrototypeOf(descriptor.value, originalFn);
        },
    );
};

descriptor는 자바스크립트 런타임이 메서드 데코레이터가 실행될 때 자동으로 전달하는 표준 내장 객체입니다. 해당 인자는 자바스크립트의 Object.getOwnPropertyDescriptor()와 동일합니다.

DMN 공식 문서에 따르면 주어진 객체는 속성 설명자인 descriptor 를 반환하고, 반환 값은 다음과 같다고 합니다. 2

  • value: 원본 메서드
  • writable: 값을 덮어쓸 수 있는지 여부
  • enumerable: 열거 가능 여부
  • configurable: 속성을 삭제하거나 descriptor를 수정할 수 있는지 여부

descriptor 통해 원본 메서드를 식별하여 AOP를 적용할 수 있게 됩니다.

탐색#

탐색은 AutoAspectExecutor 클래스의 OnModuleInit 부팅 라이프사이클 훅에서 시작됩니다. 애플리케이션이 시작되면 모든 의존성 주입이 완료되고 이후 discoveryService 를 통해 모든 프로바이더를 조회할 수 있게 됩니다. 조회된 프로바이더는 lookupLazyDecorators 를 통해 @Aspect() 에 포함된 메타데이터와 wrap 함수가 존재하는 클래스를 찾아낼 수 있습니다.

export class AutoAspectExecutor implements OnModuleInit {
    private readonly wrappedMethodCache = new WeakMap();

    constructor(
        private readonly discoveryService: DiscoveryService,
        private readonly metadataScanner: MetadataScanner,
        private readonly reflector: Reflector,
    ) {
    }

    onModuleInit() {
        this.bootstrapLazyDecorators();
    }

    private bootstrapLazyDecorators() {
        // 모든 프로바이더를 조회합니다.
        const controllers = this.discoveryService.getControllers();
        const providers = this.discoveryService.getProviders();

        // 조회된 프로바이더는 lookupLazyDecorators로 넘겨집니다.
        const lazyDecorators = this.lookupLazyDecorators(providers);
        if (lazyDecorators.length === 0) {
            return;
        }

        const instanceWrappers = providers
            .concat(controllers)
            .filter(({instance}) => instance && Object.getPrototypeOf(instance));

        // lazyDecorator 즉, AOP 클래스를 모두 찾아(Outer Loop) 모든 프로바이더를 순회(Inner Loop)합니다.
        for (const lazyDecorator of lazyDecorators) {
            for (const wrapper of instanceWrappers) {
                this.applyLazyDecorator(lazyDecorator, wrapper);
            }
        }
    }

    // AOP 데코레이터와 `wrap` 함수가 존재하는 클래스를 찾아 반환합니다.
    // 이 구간에서 나중에 구현하게 될 cacheable.aspect.ts가 찾아집니다.
    private lookupLazyDecorators(providers: InstanceWrapper[]): LazyDecorator[] {
        const {reflector} = this;

        return providers
            .filter((wrapper) => wrapper.isDependencyTreeStatic())
            .filter(({instance, metatype}) => {
                if (!instance || !metatype) {
                    return false;
                }
                const aspect =
                    reflector.get<string>(ASPECT, metatype) ||
                    reflector.get<string>(ASPECT, Object.getPrototypeOf(instance).constructor);

                if (!aspect) {
                    return false;
                }

                return typeof instance.wrap === 'function';
            })
            .map(({instance}) => instance);
    }

    private applyLazyDecorator(lazyDecorator: LazyDecorator, instanceWrapper: InstanceWrapper<any>) {
        const target = instanceWrapper.isDependencyTreeStatic()
            ? instanceWrapper.instance
            : instanceWrapper.metatype?.prototype;

        if (!target) {
            console.debug('[applyLazyDecorator] not found target');
            return;
        }

        // 클래스의 모든 메서드를 metadataScanner로 조회합니다
        const propertyKeys = this.metadataScanner.scanFromPrototype(
            target,
            instanceWrapper.isDependencyTreeStatic() ? Object.getPrototypeOf(target) : target,
            (name) => name,
        );

        // createDecorator를 통해 메타데이터를 생성할때 인자로 넘겨 받은 metadataKey를 조회합니다.
        const metadataKey = this.reflector.get(ASPECT, lazyDecorator.constructor);
        for (const propertyKey of propertyKeys) {
            // @see: https://github.com/rbuckton/reflect-metadata/blob/9562d6395cc3901eaafaf8a6ed8bc327111853d5/Reflect.ts#L938
            const targetProperty = target[propertyKey];
            if (!targetProperty || (typeof targetProperty !== "object" && typeof targetProperty !== "function")) {
                continue;
            }

            // 프로바이더 내 메서드를 순회하면서 metadataKey가 적용되어있는 메서드를 찾아 AOP 메타데이터를 반환합니다.
            const metadataList: AopMetadata[] = this.reflector.get<AopMetadata[]>(
                metadataKey,
                targetProperty,
            );
            if (!metadataList) {
                continue;
            }

            // 찾은 메서드는 실제 AOP 로직을 연결하기위해 wrapMethod 넘겨집니다.
            for (const aopMetadata of metadataList) {
                this.wrapMethod({lazyDecorator, aopMetadata, methodName: propertyKey, target});
            }
        }
    }
}

교체#

wrapMethod 는 AOP 로직(lazyDecorator.wrap)을 실행하여 AOP가 적용된 함수(wrappedMethod)를 생성합니다. 이 함수는 WeakMap 을 통해 캐시되어 성능을 최적화합니다. 이렇게 생성된 wrappedFn[마킹] 단계에서 만든 프록시 함수가 참조하는 aopSymbol 에 연결됩니다.

private wrapMethod({
    lazyDecorator,
    aopMetadata,
    methodName,
    target,
  }: {
    lazyDecorator: LazyDecorator;
    aopMetadata: AopMetadata;
    methodName: string;
    target: any;
  }) {
    // 원본 메서드, 데코레이터에 전달된 인자, 고유 심볼 조회
    const { originalFn, metadata, aopSymbol } = aopMetadata;

    const self = this;
    const wrappedFn = function (this: object, ...args: unknown[]) {
      const cache = self.wrappedMethodCache.get(this) || new WeakMap();
      const cached = cache.get(originalFn);
      if (cached) {
        return cached.apply(this, args);
      }

      // 클래스 인스턴스와 원본 메서드와 인자, 메서드 이름을 전달하고 캐시합니다.
      const wrappedMethod = lazyDecorator.wrap({
        instance: this,
        methodName,
        method: originalFn.bind(this),
        metadata,
      });
      cache.set(originalFn, wrappedMethod);
      self.wrappedMethodCache.set(this, cache);
      return wrappedMethod.apply(this, args);
    };

    target[aopSymbol] ??= {};
    // 데코레이터의 프록시 함수를 wrappedFn으로 교체합니다.
    target[aopSymbol][methodName] = wrappedFn;
}

실행#

애플리케이션 로직 어딘가에서 someService.someMethod() 를 실행하고, 해당 메서드에 AOP 데코레이터가 붙어있다면 [마킹] 단계에서 덮어쓴 프록시 함수가 먼저 실행됩니다. 프록시 함수는 this[aopSymbol][methodName] 을 호출하며, [교체] 단계에서 wrappedFn 을 연결해 두었기 때문에 AOP 클래스가 실행됩니다.

아래 예제는 추후 구현하게 될 캐시 AOP 클래스의 실행 순서를 간략하게 표현한 코드입니다.

@Aspect()
@Injectable()
export class CacheableAspect
    implements LazyDecorator<any, CacheableOption> {
    
    wrap({method, metadata: options}) {
        return (...args: any[]) => {
            console.log('AOP: Before original method'); // ('before' 로직)

            // '원본 함수'가 '스텁 함수' 내부에 매핑 (Closure)
            const result = method(...args);

            console.log('AOP: After original method'); // ('after' 로직)
            return result;
        };
    }
    
}

간단(?)하게 내부 동작 원리를 살펴보았으니 이제 구현만 하면 되겠습니다.

구현하기#