Contents

Nest.js - Cache

๊ฐœ์š”

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•˜๋Š” ์š”์ฒญ ๊ณผ์ •์„ ์‚ดํŽด๋ณด๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ˆœ์„œ๋กœ ์ง„ํ–‰๋œ๋‹ค.
middleware-guard-interceptor-endpoint-service layer-database layer

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊นŒ์ง€ ๊ฐ€๋Š” ๊ณผ์ •์€ ์ƒ๋‹นํžˆ ๊ธด ํŽธ์ด๋‹ค(์‚ฌ์‹ค pipe, filter ๊นŒ์ง€ ํฌํ•จํ•˜๋ฉด ๋” ๊ธธ๋‹ค). ๋˜‘๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•˜๋Š”๋ฐ ์ด๋Ÿฌํ•œ ๊ณผ์ •์„ ๊ณ„์† ๋ฐ˜๋ณตํ•˜๋Š” ๊ฒƒ์€ ๋ถˆํ•„์š”ํ•œ ์ผ์ด๋‹ค.

์ด๋ฒˆ ํฌ์ŠคํŠธ์—์„œ๋Š” interceptor layer์—์„œ ์บ์‹œ๋ฅผ ์ด์šฉํ•ด ์ตœ๊ทผ์— ์กฐํšŒ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋” ๋น ๋ฅด๊ฒŒ ์‘๋‹ตํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์•Œ์•„๋ณด๊ณ ์ž ํ•œ๋‹ค.

1. ๋‚ด๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ด์šฉํ•œ caching

CacheInterceptor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Injectable()
export class CacheInterceptor implements NestInterceptor {
  protected allowedMethods = ['GET'];
  constructor(
    @Inject(CACHE_MANAGER) protected readonly cacheManager: any,
    @Inject(REFLECTOR) protected readonly reflector: any,
  ) {}

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const key = this.trackBy(context); // ์บ์‹œ key๋ฅผ ๊ตฌ์„ฑ, ์บ์‹œํ•  ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ falsy๊ฐ’์„ ๋ฐ˜ํ™˜
    if (!key) return next.handle(); // ์บ์‹œํ•  ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ ๋น„์ฆˆ๋‹ˆ์Šค๋กœ์ง์„ ์ฒ˜๋ฆฌ

    try {
      const value = await this.cacheManager.get(key); // ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธ
      if (!isNil(value)) return of(value); // ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์‘๋‹ต

      // ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ
      // @CahceTTL() ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ ์ž‘์„ฑํ•œ ์บ์‹œ TTL(Time To Live) ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์กฐํšŒ
      const ttlValueOrFactory = this.reflector.get(CACHE_TTL_METADATA, context.getHandler()) ?? null;
      const ttl = isFunction(ttlValueOrFactory)  ? await ttlValueOrFactory(context) : ttlValueOrFactory;
      return next.handle().pipe(
        tap(response => {
          // ์‘๋‹ตํ•˜๋ฉฐ ๋™์‹œ์—, ์‘๋‹ต ๋ฐ์ดํ„ฐ ์บ์‹ฑ ์ฒ˜๋ฆฌ ์ง„ํ–‰(์ž…๋ ฅํ•œ TTL ๋งŒํผ)
          const args = isNil(ttl) ? [key, response] : [key, response, { ttl }];
          this.cacheManager.set(...args);
        }),
      );
    } catch {
      return next.handle(); // ์บ์‹œ ์ฒ˜๋ฆฌ, ์บ์‹œ ์กฐํšŒ ๊ณผ์ •์—์„œ ์˜ค๋ฅ˜ ๋ฐœ์ƒ์˜ ๊ฒฝ์šฐ ๋น„์ฆˆ๋‹ˆ์Šค๋กœ์ง์„ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ
    }
  }
}

CacheInterceptor๋Š” ์–ด๋–ค ์ปจํŠธ๋กค๋Ÿฌ์—์„œ cache๋ฅผ ์‚ฌ์šฉํ•˜๊ฒŒ ํ•˜๊ณ  ์‹ถ์„๋•Œ, interceptor๊ฐ€ ํ•ด๋‹น cache ๊ฐ’์„ ๋ฆฌํ„ดํ•  ์ˆ˜ ์žˆ์„์ง€ ์—†์„์ง€ ํŒ๋‹จํ•œ๋‹ค.

์œ„ ์ฝ”๋“œ๋Š” ๋‹ค์Œ์˜ ๋‚ด์šฉ์„ ํฌํ•จํ•œ๋‹ค.

  • trackBy ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์บ์‹œ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ์š”์ฒญ์ธ์ง€ ํ™•์ธํ•œ๋‹ค. ๊ธฐ๋ณธ์ ์œผ๋กœ GET ์—”๋“œํฌ์ธํŠธ์— ๋Œ€ํ•ด์„œ๋งŒ ์ฒ˜๋ฆฌํ•˜๋„๋ก ๊ตฌ์„ฑ๋˜์–ด ์žˆ๋‹ค.

  • @CacheKey() ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ ์บ์‹œ ๊ฐ’์„ ์„ค์ •ํ•˜์ง€ ์•Š์•˜๋‹ค๋ฉด, default cache ๊ฐ’์€ ์š”์ฒญ url์ด๋‹ค. trackBy๋ฅผ cacheKey๋ฅผ ์ด์šฉํ•˜๋„๋ก overriding ํ•  ์ˆ˜ ์žˆ๋‹ค. ํ•ด๋‹น ์˜ˆ์ œ๋Š” ์•„๋ž˜์—์„œ.

  • cache key๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ, cache manager๋ฅผ ํ†ตํ•ด ํ•ด๋‹น cache key๋ฅผ ๊ฐ€์ง„ ๋ฐ์ดํ„ฐ๊ฐ€ ์กด์žฌํ•˜๋Š” ์ง€ ํ™•์ธํ•œ๋‹ค.

  • ์ผ์น˜ํ•˜๋Š” cache data๊ฐ€ ์—†๋‹ค๋ฉด, @CacheKey()/ @CacheTTL() ์„ ํ†ตํ•ด ์ž…๋ ฅํ•œ metadata๋ฅผ ์กฐํšŒํ•œ๋‹ค.

  • ํ•ด๋‹น cache data๋ฅผ RxJs ๋ฅผ ์ด์šฉํ•ด ์ฒ˜๋ฆฌํ•œ๋‹ค.

CacheInterceptor Customizing

๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋‚ด์žฅ๋œ CacheInterceptor๋ฅผ ์ด์šฉํ•ด ์บ์‹ฑ์„ ์ฒ˜๋ฆฌํ•œ๋‹ค๋ฉด, /posts/ ์™€ /posts?search=title3๋ฅผ ๋˜‘๊ฐ™์ด ์ฒ˜๋ฆฌํ•  ๊ฒƒ์ด๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@UseInterceptors(CacheInterceptor)
@CacheKey(GET_POSTS_CACHE_KEY)
@CacheTTL(120)
@Get()
async getPosts(
  @Query('search') search: string,
  @Query() { offset, limit, startId }: PaginationParams
) {
  if (search) {
    return this.postsService.searchForPosts(search, offset, limit, startId);
  }
  return this.postsService.getAllPosts(offset, limit, startId);
}

๋”ฐ๋ผ์„œ ์šฐ๋ฆฌ๋Š” CacheInterceptor๋ฅผ ํ™•์žฅํ•ด์„œ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค. ๋‹ค์Œ ์ฝ”๋“œ๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ์•ฝ @CacheKey๋ฅผ ํ†ตํ•ด ์บ์‹œ ํ‚ค๋ฅผ ์ „๋‹ฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, CacheInterceptor๊ฐ€ cache๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋™์ž‘ํ•˜๊ณ , ์บ์‹œ ํ‚ค๋ฅผ ์ „๋‹ฌํ•˜์˜€๋‹ค๋ฉด, ์ƒˆ๋กœ์šด ์บ์‹œํ‚ค๋ฅผ ์ƒ์„ฑํ•ด ์บ์‹œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒƒ์ด๋‹ค. (e.g POSTS_CACHE-null / POSTS_CACHE-search=hello)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { CACHE_KEY_METADATA, CacheInterceptor, ExecutionContext, Injectable } from '@nestjs/common';
 
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
  trackBy(context: ExecutionContext): string | undefined {
    const cacheKey = this.reflector.get(
      CACHE_KEY_METADATA,
      context.getHandler(),
    );
 
    if (cacheKey) {
      const request = context.switchToHttp().getRequest();
      return `${cacheKey}-${request._parsedUrl.query}`;
    }
 
    return super.trackBy(context);
  }
}

@CacheKey() : ํŠน์ • cache key๋ฅผ ์ง€์ •ํ•˜๊ธฐ ์œ„ํ•œ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ
@CacheTTL() : ํ•ด๋‹น cache key์˜ TTL์„ ์ง€์ •ํ•˜๊ธฐ ์œ„ํ•œ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ

์บ์‹œ ๋ฌดํšจํ™”

์บ์‹œ๋ฅผ ๋ฌดํ•œ์ •์œผ๋กœ ๊ธธ๊ฒŒ ๊ฐ€์ ธ๊ฐ€๋ฉด ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ƒ‰ํ•˜๋Š” ์†๋„๋ฅผ ๋น ๋ฅด๊ฒŒ ํ•  ์ˆ˜ ์žˆ์œผ๋‹ˆ๊น ์ข‹์€๊ฒŒ ์•„๋‹Œ๊ฐ€? ๋ผ๋Š” ์ƒ๊ฐ๋„ ๋“ค ์ˆ˜ ์žˆ์ง€๋งŒ, ๋ฐ์ดํ„ฐ๋Š” ๊ณ„์† ๋ณ€ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ๊ทธ๋ ‡์ง€๋งŒ๋„ ์•Š๋Š”๋‹ค. ์šฐ๋ฆฌ๋Š” ์ƒˆ๋กœ์šด ๋ฐ์ดํ„ฐ๊ฐ€ ์ถ”๊ฐ€, ์‚ญ์ œ, ์—…๋ฐ์ดํŠธ ๋  ๋•Œ ํ•ด๋‹น cache key๋ฅผ ๊ฐ€์ง„ cache ๊ฐ’์„ ๋ฌดํšจํ™”ํ•˜๊ณ , ์ƒˆ๋กœ์šด ์บ์‹œ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•ด์•ผ ํ•œ๋‹ค.

์บ์‹œ ๋ฌดํšจํ™”๋Š” ๋ณดํ†ต ์„œ๋น„์Šค layer์—์„œ ๊ตฌํ˜„ํ•˜๊ฒŒ ๋˜๋Š”๋ฐ, ์ด๋Š” ๋ช‡๊ฐ€์ง€ ๋ฌธ์ œ์ ์„ ๊ฐ€์ง„๋‹ค.

  • ์บ์‹œ๋ฅผ ๋ฌดํšจํ™”ํ•˜๋Š” ๋™์ผํ•œ ์ฝ”๋“œ๊ฐ€ ๋ฐ˜๋ณต๋จ
  • ์บ์‹œ ๊ด€๋ฆฌ๋Š” ์ฃผ์š” ๋กœ์ง์ด ์•„๋‹ˆ๋ผ ๋ถ€๊ฐ€์ ์ธ ๋กœ์ง์— ๊ฐ€๊นŒ์›€

๋”ฐ๋ผ์„œ ์บ์‹œ ๋ฌดํšจํ™”๋ฅผ ์œ„ํ•œ ์ž‘์—…์„ ์œ„์™€ ๊ฐ™์ด CacheInterceptor๋ฅผ ์ƒ์†๋ฐ›์•„ ๋ณ„๋„์˜ Interceptor ๋‚ด๋ถ€์—์„œ ๊ตฌํ˜„ํ•ด ์ค„ ์ˆ˜ ์žˆ๋‹ค. ํ•ด๋‹น ๋ธ”๋กœ๊ทธ ์ฐธ๊ณ 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// src/core/httpcache.interceptor.ts
import {
  CacheInterceptor,
  CallHandler,
  ExecutionContext,
  Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { Cluster } from 'ioredis';
import { Observable, tap } from 'rxjs';

@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
  private readonly CACHE_EVICT_METHODS = [
    'POST', 'PATCH', 'PUT', 'DELETE'
  ];

  async intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Promise<Observable<any>> {
    const req = context.switchToHttp().getRequest<Request>();
    if (this.CACHE_EVICT_METHODS.includes(req.method)) {
      // ์บ์‹œ ๋ฌดํšจํ™” ์ฒ˜๋ฆฌ
      return next.handle().pipe(tap(() => this._clearCaches(req.originalUrl)));
    }

    // ๊ธฐ์กด ์บ์‹ฑ ์ฒ˜๋ฆฌ
    return super.intercept(context, next);
  }

  /**
   * @param cacheKeys ์‚ญ์ œํ•  ์บ์‹œ ํ‚ค ๋ชฉ๋ก
   */
  private async _clearCaches(cacheKeys: string[]): Promise<boolean> {
    const client: Cluster = await this.cacheManager.store.getClient();
    const redisNodes = client.nodes();

    const result2 = await Promise.all(
      redisNodes.map(async (redis) => {
        const _keys = await Promise.all(
          cacheKeys.map((cacheKey) => redis.keys(`*${cacheKey}*`)),
        );
        const keys = _keys.flat();
        return Promise.all(keys.map((key) => !!this.cacheManager.del(key)));
      }),
    );
    return result2.flat().every((r) => !!r);
  }
}

์ „์ฒด ์ฝ”๋“œ๋Š” ๋‹ค์Œ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. ์ฝ”๋“œ

2. Redis๋ฅผ ์ด์šฉํ•œ caching