[Deep Dive] Redis 고도화 (1): NestJS에서 AOP로 커스텀 캐시 데코레이터 구현하기

단순히 CacheInterceptor를 사용하는 것은 쉽습니다. 하지만 실무에서는 특정 서비스 메서드의 결과만 캐싱하고 싶거나, HTTP 요청이 없는 백그라운드 로직에서도 캐싱이 필요할 때가 많습니다.

이번 포스팅에서는 NestJS의 DiscoveryService를 활용해 서비스 레이어에서 선언적으로 동작하는 커스텀 캐시 데코레이터 시스템을 구축한 과정을 상세히 다룹니다.

1. 설계의 출발점: 왜 커스텀인가?

기존 NestJS 방식은 컨트롤러에 인터셉터를 붙이는 방식이 주류입니다. 그리고 캐시 무효화는 명령적으로 서비스 내부 로직에 작성해야합니다.

@UseInterceptors(CustomCacheInterceptor)
  findOne(@Param('id', ParseIntPipe) id: number, @User() user: TokenPayload) {
    return this.postService.findOne(id, user?.userId);
  }
@Injectable()
export class PostService {
  constructor(@Inject(CACHE_MANAGER) private redis: RedisService) {}
  ...
  public async (id: number, userId?: number) {
    // ...
    await this.redis.del(캐시 무효화);
    return;
  }
}

물론 캐시 무효화하는 로직을 추상화 유틸함수나 서비스로 분리하는것도 고려를 했지만, 저는 다음과 같은 불편함을 해결하고 싶었습니다.

  • 관심사의 혼재: 서비스 로직 내부에 this.redis.del() 같은 코드가 섞이는 것을 방지하고 싶었습니다.

  • 유연성 부족: 인자에 따라 캐시 키를 동적으로 생성하고, 태그 기반으로 그룹화하여 관리하고 싶었습니다.

  • AOP 구현: 비즈니스 로직은 건드리지 않고, 데코레이터만으로 캐시 기능을 '주입'하고 싶었습니다.


2. 메타데이터와 데코레이터 설계

가장 먼저 캐싱과 무효화에 필요한 정보를 담을 인터페이스를 정의했습니다. 여기서 핵심은 tags입니다. 단순 키 삭제가 아닌, 특정 도메인 단위(예: posts)로 캐시를 묶어서 관리하기 위함입니다.

export const CACHE_METADATA = 'cache-key:cache';
export const EVICT_METADATA = 'cache-key:evict';

export interface CacheOptions {
  tags: string[]; // 캐시 그룹화를 위한 태그ttl?: number;   // 생략 시 영구 저장
}

export interface EvictOptions {
  tags: string[];
}

export const Cache = (options: CacheOptions) => SetMetadata(CACHE_METADATA, options);
export const CacheEvict = (options: EvictOptions) => SetMetadata(EVICT_METADATA, options);

3. CacheExplorerService: 시스템의 두뇌

이 시스템이 동작하게 만드는 핵심은 OnModuleInit 시점에 실행되는 CacheExplorerService입니다. NestJS가 구동될 때 모든 프로바이더를 뒤져서 우리가 만든 데코레이터를 찾아냅니다.

① 프로바이더 스캔과 메서드 추출

DiscoveryService로 등록된 모든 인스턴스를 가져오고, MetadataScanner를 통해 프로토타입에 정의된 메서드 이름을 추출합니다.

onModuleInit(): void {
    this.discoveryService.getProviders().forEach((wrapper) => {
      const instance = wrapper.instance as Instance;
      if (!instanceTypeGuard(instance)) return;

      const prototype = Object.getPrototypeOf(instance) as Prototype;
      const methodNames = this.metadataScanner.getAllMethodNames(prototype);
}

② 데코레이터 판별

추출한 메서드에 CACHE_METADATAEVICT_METADATA가 붙어 있는지 확인합니다. 만약 붙어 있다면 @Cache이나 @ChcheEvict 데코레이터라는 것이므로, 해당 메서드를 가로채서(Proxy) 우리가 정의한 로직으로 감싸버립니다(Wrapper).


4. 런타임 메서드 래핑: 뼈대부터 다시 세우는 Wrapper 로직

이 시스템의 가장 흥미롭고 난이도 높은 부분은 원본 메서드를 가로채서(Proxy) 캐시 로직을 주입하는 과정입니다. 단순한 가로채기가 아니라, 원본 메서드가 가진 인자(Parameter)와 실행 문맥(Context)을 완벽하게 보존해야 합니다.

① 메서드 추출과 문맥 보존 (Binding)

먼저 탐색된 인스턴스에서 메서드 참조를 가져옵니다. 이때 가장 중요한 것은 bind(instance)입니다. 래핑된 함수가 호출될 때 클래스 내부의 this가 깨지지 않도록 원본 인스턴스에 단단히 묶어줍니다.

const methodRef = instance[methodName];
if (!methodTypeGuard(methodRef)) return;

// 원본 인스턴스의 'this'를 보존하며 메서드 복사

const method = methodRef.bind(instance) as Method;

② 함수의 '정체' 파악: 파라미터 이름 추출 (getParamNames)

데코레이터 설정값(예: {postId})을 실제 값으로 바꾸려면, 현재 들어온 인자가 몇 번째 파라미터인지 알아야 합니다. 자바스크립트의 toString()과 정규표현식을 활용해 함수의 선언부에서 파라미터 명들을 배열로 뽑아냅니다.

private getParamNames(fn: () => unknown): string[] {
  // 함수의 소스코드 문자열에서 괄호 안의 인자 목록을 매칭

  const match = fn.toString().match(/\(([^)]*)\)/);if (!match || !match[1].trim()) return [];

// 기본값 할당(=) 등을 제거하고 순수 이름만 추출

  return match[1].split(',').map((p) => p.trim().split('=')[0].trim());
}

③ 동적 데이터 매핑: buildParamMap & flatten

런타임에 들어온 실제 인자 값들(args)과 위에서 얻은 파라미터 이름(names)을 매핑하여 하나의 객체(paramMap)로 만듭니다. 이후 중첩된 객체 구조를 1차원 평면 구조로 펼쳐(flatten), 키 생성 시 접근하기 쉬운 형태로 가공합니다.

private resolveKey(
    methodName: string,
    paramMap: Record<string, unknown>,
  ): string {
    const flatMap: Record<string, unknown> = flatten(paramMap);
    return `cache:${methodName}:${Object.entries(flatMap)
      .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
      .join('&')}`;
  }

④ 정교한 플레이스홀더 치환 (resolvePlaceholders)

단순한 값 매칭을 넘어, 복잡한 비즈니스 요구사항을 처리하기 위해 연산자 해석 로직을 직접 구현했습니다.

  • 중첩 경로 탐색 (getValueByPath): user.userId 같은 문자열 경로를 받아 실제 객체의 깊숙한 곳까지 reduce로 탐색합니다. 중간에 null이나 undefined를 만나도 에러 없이 안전하게 처리하도록 설계했습니다.

  • 복합 연산자 지원: {user.userId ?? query.id ?? 'guest'} 처럼 ??|| 연산자를 해석합니다. 우선순위에 따라 후보군을 탐색하고, 모두 실패하면 리터럴 문자열('guest')을 반환하는 고도의 유연성을 확보했습니다.

resolvePlaceholders는 정규표현식을 통해 중괄호 {} 내부의 표현식을 추출하고, 우선순위에 따라 유효한 값을 찾아냅니다.

private resolvePlaceholders(template: string, paramMap: Record<string, unknown>): string {
  return template.replace(/\{([^}]+)\}/g, (_match, expression: string) => {
    // 1. ?? 또는 || 연산자로 분리하여 후보 배열 생성
    // 예: "user.userId ?? 'guest'" -> ["user.userId", "'guest'"]
    const candidates = expression.split(/\?\?|\|\|/).map((s) => s.trim());

    // 2. 후보들 중 유효한 값을 순서대로 탐색 (reduce 활용)
    const foundValue = candidates.reduce<any>((acc, candidate) => {
      if (acc !== undefined && acc !== null) return acc;

      // 리터럴 문자열 처리 (예: 'guest')
      if (/^['"].*['"]$/.test(candidate)) {
        return candidate.replace(/['"]/g, '');
      }

      // 객체 경로 추출 (Optional Chaining 보정 포함)
      const path = candidate.replace(/\?\./g, '.').replace(/\?$/, '');
      return this.getValueByPath(paramMap, path);
    }, undefined);

    return String(foundValue);
  });
}

⑤ 안전한 객체 경로 탐색 (getValueByPath)

점(.)으로 연결된 경로를 따라가며 데이터를 추출합니다. 중간 단계에서 null이나 undefined를 만나도 런타임 에러가 발생하지 않도록 reduce 내부에서 타입 가드를 철저히 수행합니다.

private getValueByPath(obj: Record<string, unknown>, path: string) {
  return path.split('.').reduce<unknown>((acc, key) => {
    // 원시 타입이거나 null/undefined면 더 이상 하위 경로 탐색 불가
    if (typeof acc !== 'object' || acc === null) return acc;

    // 키가 존재할 때만 다음 단계로 이동
    if (key in acc) return (acc as any)[key];
    return undefined;
  }, obj);
}

⑥ 로직 실행 결과 예시 (The Scenario)

위의 복잡한 로직들이 실제로 어떻게 동작하는지, 게시글 조회 시나리오를 통해 살펴보겠습니다.

@Cache({ 
  tags: ['posts', 'user:{user.userId ?? "anonymous"}', 'post:{id}'], 
  ttl: 3600 
})
async findOne(id: number, user?: TokenPayload) { ... }
  • 파라미터 추출

    • ['id', 'user']

  • 데이터 매핑

    • { id: 10, user: { userId: 1 } }

  • 키(Key) 생성

    • cache:findOne:id=10&user={"userId":1}

  • 태그(Tag) 치환

    • post:10

    • user:1 또는 user:anonymous

⑦ 캐시 조회 및 미스 처리

래핑된 메서드는 호출될 때 먼저 Redis를 조회합니다.

  • Cache Hit: Redis에 데이터가 있다면 원본 메서드를 실행하지 않고 바로 반환합니다.

  • Cache Miss: 원본 메서드(method.bind(instance))를 실행하여 결과를 얻은 뒤, 이를 Redis에 저장하고 반환합니다.

// 캐시 조회

const cached = await this.redisService.get(key);if (cached !== null) return cached;

// 캐시 미스 → 실행 후 저장

const result = await method(...args);await this.redisService.set(key, result, tags, options.ttl);return result;

5. 트러블슈팅: 메서드 오버라이딩 순서 문제

CacheCacheEvict가 한 메서드에 동시에 붙어 있을 때, 어떤 래퍼가 먼저 감싸느냐에 따라 메타데이터를 잃어버릴 위험이 있었습니다.

  • 해결: 원본 메서드에서 메타데이터를 먼저 모두 추출한 뒤에 래퍼를 적용하는 방식으로 순서를 보장했습니다. 이를 통해 캐시 저장과 동시에 기존 캐시를 무효화해야 하는 복잡한 시나리오도 완벽히 대응했습니다.

methodNames.forEach((methodName) => {
  const methodRef = instance[methodName];
  if (!methodTypeGuard(methodRef)) return;

  // 1. 원본 메서드에서 메타데이터들을 먼저 추출

  const cacheOptions = this.reflector.get<CacheOptions | undefined>(CACHE_METADATA, methodRef);
  const evictOptions = this.reflector.get<EvictOptions | undefined>(EVICT_METADATA, methodRef);

  if (cacheOptions) {
    this.applyCacheWrapper(instance, methodName, cacheOptions);
  }

  if (evictOptions) {
    this.applyEvictWrapper(instance, methodName, evictOptions);
  }
});

이제 이런 것도 가능합니다

@Cache({ tags: ['posts/{id}', 'user/{userId}/posts/{id}'] })
  @CacheEvict({ tags: ['posts', 'posts/page/*', 'user/*/posts/page/*'] })
  async findOne(id: number, userId?: number) {...}

1부를 마치며

이렇게 구현한 시스템 덕분에 이제 우리 서비스 레이어는 다음과 같이 깔끔해졌습니다.

@Cache({ tags: ['posts', 'posts:{id}'], ttl: 3600 })
async findOne(id: number, userId?: number) {
  return await this.prisma.post.findUnique(...);
}

내부 로직을 전혀 수정하지 않고도 강력한 캐싱 기능을 손에 넣었습니다. 하지만 어떻게 대량의 태그를 성능 저하 없이 삭제할 것인가? 그리고 데이터 무결성은 어떻게 보장할 것인가?에 대한 숙제가 남았습니다.

이 디테일한 서버 사이드 최적화 로직은 2부: 트랜잭션과 논블로킹 무효화 전략에서 이어집니다.