12/27/2023

캐싱과 Redis

발단

 AWS를 사용해서 애플리케이션을 배포하고 API를 호출했는데 상품 상세 API와 상품 목록 API 응답 속도가 너무 느렸다. 대략 속도가 362ms 정도 나왔는데 사용자 관점에서 보면 정말 불편하고 답답하다고 볼 수 있다. 검색 연산의 속도를 향상시키는 방법을 생각하니 대략 3가지 정도가 떠올랐다. 캐싱, 인덱싱, 역정규화.

캐싱은 애플리케이션의 읽기 성능을 개선하는 기술로 캐시는 고성능 데이터 접근을 제공하는 임시 데이터 저장소 역할을 한다. 인덱싱은 DBMS에 요청된 레코드/도큐먼트에 빠르게 접근할 수 있도록 지원하는 데이터와 관련된 부가적인 자료 구조인 인덱스를 구성하고 생성하여 탐색키와 일치하는 레코드/도큐먼트를 빠르게 찾아 읽기 성능을 향상시킨다. 정규화는 테이블 분할을 통해 데이터의 중복성을 최소화 해서 데이터 저장의 효율성과 무결성을 위한 과정이지만 조인 연산이 많을 경우 읽기 성능에 악영향을 줄 수 있다. 역정규화는 정규화의 반대 과정으로 정규화를 통해 분리되었던 테이블을 통합하는 구조의 재조정을 통하여 데이터의 부분적 중복을 허용하지만 읽기 성능을 개선하는 기법이다.

각 방법마다 단점이 있는데 일단 역정규화의 경우 중복된 데이터의 저장을 위한 추가 공간과 중복된 데이터의 일관성을 유지하기 위해 쓰기 성능이 저하될 수 있다. 인덱싱의 경우 인덱스를 추가할 때마다 쓰기 연산에 더 많은 시간이 걸려 쓰기 성능에 영향을 줄 수 있다. 또한, 인덱스를 추가할수록 디스크 공간과 메모리를 많이 차지하는 단점이 있다. 이러한 단점을 고려해서 캐싱을 선택했는데 이전 인터뷰 질문에서 Redis 사용 여부를 물어보기도 했고 Wevre의 실시간 경매방을 구현하면서 사용한 스티키 세션과 연관점을 가지기에 이번 기회에서 사용하면서 익숙해질 필요가 있다고 생각해 이러한 결정을 내렸다!


인 메모리 캐시

NestJS 공식문서에서 인 메모리 캐시라는 단어를 봤을 때 주기억장치(메모리)와 중앙처리장치(CPU) 사이에 위치한 하드웨어 기억장치를 의미하는 줄 알았는데 그게 아니었다! 여기서 인 메모리 캐시는 소프트웨어 기반의 캐싱 메커니즘으로 시스템의 메모리 공간을 활용하여 자주 접근되는 데이터를 저장한다. 즉, NestJS에서 인 메모리 캐시는 시스템의 메모리의 일부를 사용하여 데이터를 원본(데이터베이스 또는 외부 API)에서 가져오는데 필요한 시간을 줄여 빠르게 저장하고 검색하여 성능을 향상시킨다. 

NestJS는 내부적으로 cache-manager 패키지를 사용하여 데이터를 내장 인 메모리 캐시인 애플리케이션의 메모리에 저장한다. NestJS의 내장 캐시 인터셉터인 CacheInterceptor를 사용하여 NestJS가 캐싱을 자동으로 처리한다. 코드에서 getItem() 메서드의 엔드포인트를 두 번 빠르게 호출하면 두 번째는 메서드가 호출되지 않으며 캐시에 저장된 데이터를 반환한다. getItems() 메서드의 경우, 중요한 점은 NestJS가 응답을 쿼리 매개변수의 모든 조합에 따라 별도로 저장한다는 것이다. 즉, /items?sort=low&search=남성은 /items?sort=high&search=여성은 다르다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 컨트롤러 전체에 인 메모리 캐시를 적용한다.
@UseInterceptors(CacheInterceptor)
@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}
 
  @HttpCode(HttpStatus.OK)
  @Serialize(ItemDto)
  @Get('/:id')
  async getItem(@Param('id', ParseIntPipe) id: number) {
    return await this.itemsService.findOne(id);
  }
 
  @HttpCode(HttpStatus.OK)
  @Serialize(ItemsDto)
  @Get('/')
  async getItems(
    @Query()
    paginationDto: PaginationDto,
  ) {
    return await this.itemsService.find(paginationDto);
  }
}
cs

Postman으로 상품 검색 시 응답 속도가 확연하게 줄어든 것을 확인할 수 있다.
39ms -> 7ms

내장 인 메모리 캐시인 애플리케이션의 메모리는 사용 방법이 간단하고 효율적이지만 굉장히 큰 단점이 있는데 애플리케이션을 여러 인스턴스에 실행하는 경우 트래픽이 로드 밸런싱되고 이에 따라 여러 인스턴스로 리다이렉션된다. 즉, 애플리케이션의 메모리를 사용하면 애플리케이션의 여러 인스턴스가 동일한 애플리케이션의 메모리를 공유하지 않는다. 또한 데이터가 휘발성이라서 애플리케이션을 다시 시작하면 애플리케이션의 메모리 안에 저장된 데이터가 모두 유실된다. 이러한 이유 때문에 Redis와 같은 인 메모리 데이터 저장소를 인 메모리 캐시로 사용한다.


Redis vs. Memcached

인 메모리 데이터 저장소에는 대표적으로 Redis 외에도 Memcached가 있다. 특징을 간략하게 설명하자면 Memcached는 데이터 타입을 String만 지원하고 오로지 메모리에만 데이터를 저장한다. 이 때문에 메모리가 부족할 경우 일부 데이터를 삭제하여 메모리를 사용하며 메모리에만 저장을 할 수 있기에 복제가 불가능하여 캐시로만 사용을 할 수 있다. 또한 다중스레드를 지원하고 캐시 용량은 키는 250KB 값은 1MB까지 가능하다. Redis는 Set, Hash, Hyperloglog 등 다양한 데이터 타입을 지원하고 하드디스크에 데이터를 저장할 수 있다. 스냅샷을 통해서 하드디스크에 담을 수 있는 비휘발성의 특성을 가지며 반복적인 스냅샷을 통하여 디스크에 저장해 메모리의 여유 공간을 만들 수 있다. 단일스레드를 지원하며 캐시 용량의 키와 값은 모두 512MB까지 가능하다.

그런데 대부분 Redis를 사용한다. 왜 그럴까? Memacached의 장점은 작은 데이터를 임시 저장할 수 있는 것과 다중스레드를 활용하여 다중처리가 빠르다는 것이지만 이를 제외한 나머지 모든 것이 단점이다. 특히 저장할 공간이 없다고 데이터를 일부 지워야하는 것은 로그같은 데이터는 그럴 수 있지만 치명적인 단점이라고 볼 수 있다.


redis vs. ioredis

Redis는 키-값(key-value) 형식을 가지는 대표적인 인 메모리 데이터 저장소이다. NestJS 공식 문서에 따르면 redis 패키지는 cache-manager-redis-store 패키지와 함께 ioredis 패키지는 cache-manager-ioredis-store 패키지와 함께 사용하라고 한다. 둘 중 무엇을 사용해야 하는지 몰라서 검색을 했는데 ioredis가 더 빠르고, 더 많은 기능을 가지며 호환도 더 잘 된다고 한다. 따라서, ioredis와 cache-manager-ioredis-store 당첨!


비동기 설정 & 속도

TypeORM도 설정도 비동기로 구현해서 Redis도 똑같이 하려고 했는데 이상하게 오류가 발생했다. CacheConfigService의 createCacaheOptions() 메서드에서 isGlobal 속성을 true로 설정했는데 이상하게 상품 모듈에서 Redis 모듈을 가져오지 않으면 의존성 오류가 튀어나왔다. 그러다가 registerAsyc() 메서드의 옵션에 isGlobal 옵션을 발견했다. 이 옵션의 값을 true로 설정하니 의존성 문제가 해결했지만 왜 createCacheOptions() 메서드에서 isGlobal: true가 적용되지 않는지 알아봐야 한다!
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
// app.module.ts
@Module({
  ...
  
    TypeOrmModule.forRootAsync({
      useClass: TypeOrmConfigService,
    }),
    CacheModule.registerAsync({
      useClass: CacheConfigService,
      // CacheConfigService에서 isGlobal: true가 있어도 app.module.ts 파일에서 설정해야 전역으로 적용된다.
      isGlobal: true,
    }),
 
  ...
})
 
...
 
// cache-config.service.ts
@Injectable()
export class CacheConfigService implements CacheOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
 
  async createCacheOptions(): Promise<CacheModuleOptions> {
    const redisOptions = {
      host: this.getValue('REDIS_HOST'),
      port: parseInt(this.getValue('REDIS_PORT')),
    };
 
    return {
      ttl: parseInt(this.getValue('CACHE_TTL')),
      max: parseInt(this.getValue('CACHE_MAX')),
      isGlobal: true,
      store: redisStore,
      ...redisOptions,
    };
  }
 
  private getValue(key: string) {
    const value = this.configService.get<string>(key);
 
    if (!value) {
      throw new Error(`환경 변수 ${key}가 설정되지 않음.`);
    }
 
    return value;
  }
}
cs

Postman으로 상품 1번을 검색했을 때 응답 속도와 Redis에 저장된 다음 응답 속도를 비교하면 다음과 같이 매우 빨라진 것을 확인할 수 있다. Redis 클라이언트를 통해서 GET 명령으로 상품을 검색하면 상품 1번이 Redis에 저장되어 있으며 TTL 명령으로 지속 시간을 알 수 있다.
123ms -> 8ms

상품 1번


맞춤 Redis 모듈

Redis를 적용했는데 스크린샷에서 알 수 있듯이 상품이 문자열로 저장되어서 가독성도 떨어진다.
Redis는 String 이외에 Hash, Set와 같은 여러 데이터 타입을 가진다. 그 중에서 상품은 속성이 많기에 Hash를 사용하기에 적합하다. 그런데 cache-manager 패키지의 Cache 인터페이스는 set() 메서드, get() 메서드와 같이 기본적인 메서드만 지원한다! 검색을 통해 cache.store.getClient()를 호출하여 Redis 클라이언트를 추출할 수 있지만 cache-manager 패키지의 Cache 인터페이스에는 getClient() 메서드가 정의되어 있지 않기에 추가적인 작업이 필요하다는 것을 알았다(앞의 cache는 Cache의 인스턴스이다.).

일단 Redis 관련 모듈을 하나 따로 만들기로 정했다. 다음으로 getClient() 메서드를 추가해야 하는데 두 가지 방법이 있다. 하나는 cache-manager 패키지의 Cache 인터페이스를 상속해서 맞춤 인터페이스를 생성하는 것이고 다른 하나는 @ts-expect-error 지시어를 사용하는 것이다. 후자를 선택했는데 좀 더 간단하다고 해야할까? 시간이 나면 전자도 알아보겠다!
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
// custom-cache.module.ts
@Module({
  providers: [CustomCacheService],
  exports: [CustomCacheService],
})
export class CustomCacheModule {}
 
// custom-cache.service.ts
@Injectable()
export class CustomCacheService {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly configService: ConfigService,
  ) {}
 
  private getValue(key: string) {
    const value = this.configService.get<string>(key);
 
    if (!value) {
      throw new Error(`환경 변수 ${key}가 설정되지 않음.`);
    }
 
    return value;
  }
 
  private get redisClient(): Redis {
    // @ts-expect-error: getClient() 메서드를 추가한다.
    return this.cache.store.getClient();
  }
 
  async hSet(key: string, obj: object) {
    return this.redisClient.hset(key, obj);
  }
 
  async hGet(key: string, field: string) {
    return this.redisClient.hget(key, field);
  }
 
  async hGetAll(key: string) {
    return this.redisClient.hgetall(key);
  }
}
cs

작성을 했는데 문제가 있다... Redis는 모든 것을 문자열로 저장하기에 사용자 정의 객체는  JSON.stringify() 메서드를 통해 문자열로 날짜는 숫자로 변환해야 한다. 그리고 반환값을 전달할 때는 반대로 역직렬화를 통해 객체, 날짜 형태로 변환해야 한다. 즉, 직렬화와 역직렬화 함수가 필요하다!


사용자 정의 타입 가드

hSet() 메서드를 위해 직렬화 함수를 먼저 만들려고 하는데 갑자기 생각이 들었다. 이 메서드의 2번 매개변수의 타입은 객체(Object)로 일반적인 객체를 받는다. 하지만 직렬화를 적용하려면 반드시 타입을 알아야 한다. instanceof 연산자가 바로 생각났는데 아쉽게도 typeof 연산자와 함께 사용자 정의 객체에 사용할 수 없다. 검색을 해보니 이런 경우 사용자 정의 가드를 만든다고 한다! 즉 인터페이스나 타입 별칭을 사용해서 타입의 구조를 정의하고 가드 함수를 만드는 것이다.

둘은 비슷하게 작동하지만 차이점은 다음과 같다. 인터페이스의 거의 모든 기능은 타입에서 사용할 수 있으며 항상 확장할 수 있지만, 타입은 새로운 속성을 추가할 수 없다는 것이다. 인터페이스가 확장이 가능하므로 JavaScript 객체의 작동 방식과 더 가까운 모습이라서 일반적으로 인터페이스를 권장한다. 하지만 유니언 또는 튜플 타입을 사용해야 할 경우, 타입 별칭을 사용하는 것이 좋은 방법이다. 타입 가드 함수는 간단한데 any 타입을 가지는 한 개의 매개변수를 취하고 ": 매개변수 변수 이름 is 사용자 정의 타입"을 함수 이름 옆에 추가한다. 예를 들면, isItem = (obj: any): obj is IItem. 타입 가드와 직렬화와 역직렬화를 적용한 해시 메서드는 다음과 같다. 하지만 아이러니하게도 이 코드는 사용하지 않게 되었다... 이유는 바로 밑에 등장한다!
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// types.interface.ts
export interface IItem {
  id: number;
  name: string;
  price: number;
  gender: string;
  description: string;
  isNew: boolean;
  discountRate: number;
  releaseDate: Date;
  createdAt: Date;
  updatedAt: Date | null;
  images: Image[];
  options: Option[];
  categories: Category[];
}
 
// type-guard.factory.ts
export const isItem = (obj: any): obj is IItem => {
  return (
    obj.price !== undefined &&
    obj.gender !== undefined &&
    obj.description !== undefined &&
    obj.isNew !== undefined &&
    obj.discountRate !== undefined
  );
};
 
// common.factory.ts
export const serializeItem = (item: Item) => {
  return {
    ...item,
    releaseDate: new Date(`${item.releaseDate}T00:00:00.000Z`).getTime(),
    images: JSON.stringify(item.images),
    options: JSON.stringify(item.options),
    categories: JSON.stringify(item.categories),
  };
};
 
export const deserializeItem = (item: { [key: string]: string }) => {
  return {
    ...item,
    id: parseInt(item.id),
    price: parseInt(item.price),
    discountRate: parseInt(item.discountRate),
    releaseDate: new Date(item.releaseDate),
    images: JSON.parse(item.images),
    options: JSON.parse(item.options),
    categories: JSON.parse(item.categories),
  };
};
 
// custom-cache.service.ts
@Injectable()
export class CustomCacheService {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly configService: ConfigService,
  ) {}
 
  private getValue(key: string) {
    const value = this.configService.get<string>(key);
 
    if (!value) {
      throw new Error(`환경 변수 ${key}가 설정되지 않음.`);
    }
 
    return value;
  }
 
  private get redisClient(): Redis {
    // @ts-expect-error: getClient() 메서드를 추가한다.
    return this.cache.store.getClient();
  }
 
  async hSet(key: string, value: object) {
    if (isItem(value)) {
      const serializedItem = serializeItem(value);
      return await this.redisClient.hset(key, serializedItem);
    }
    throw new InternalServerErrorException('HSET 연산 중 오류 발생.');
  }
 
  async hGet(key: string, field: string) {
    const value = await this.redisClient.hget(key, field);
    // HGET()메서드는 키에 해당하는 값이 없으면 null를 반환한다.
    return value ? value : null;
  }
 
  async hGetAll(key: string) {
    const value = await this.redisClient.hgetall(key);
    if (isItem(value)) {
      const deserializedItem = deserializeItem(value);
      return deserializedItem;
    }
    // HGETALL()메서드는 키에 해당하는 값이 없으면 빈 객체({})를 반환한다.
    return {};
  }
}
cs


난관

문제가 발생했다... Redis에 저장된 객체는 맞춤 직렬화 인터셉터를 적용하기 전의 객체이다. 다시 말해, 관계를 가진 다른 객체들을 보면 아이디 외에 필요없는 필드를 가지고 있다. 검색을 통해 알아보니 맞춤 캐시 인터셉터를 구현하라고 해서 구현을 했는데 여기서 삽집을 엄청했다. 앞서 구현한 맞춤 Redis 모듈은 사실상 사용하지 않기에 삽질의 부산물이라 볼 수 있다. 지금은 이렇게 놔두지만 시간이 나면 다시 알아보고 개선해야 한다!
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
51
52
53
54
55
56
57
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
  // getClient() 메서드를 추가한다.
  // private get redisClient(): Redis {
  //   return this.cacheManager.store.getClient();
  // }
 
  trackBy(context: ExecutionContext): string | undefined {
    // @CacheKey() 데코레이터로 맞춤 캐시 키를 추출한다.
    const cacheKey = this.reflector.get(
      CACHE_KEY_METADATA,
      context.getHandler(),
    );
 
    // 사용자 정의 캐시 키
    if (cacheKey) {
      const request = context.switchToHttp().getRequest();
      const id = request.params.id;
 
      return createCachekey(cacheKey, id);
    }
 
    return super.trackBy(context);
  }
 
  // set(), get()와 같은 기본 메서드 외에 Redis의 모든 메서드를 사용할 수 있다.
  // async intercept(
  //   context: ExecutionContext,
  //   next: CallHandler<any>,
  // ): Promise<Observable<any>> {
  //   const customTtl = this.reflector.get(
  //     CACHE_TTL_METADATA,
  //     context.getHandler(),
  //   );
 
  //   const key = this.trackBy(context) as string;
 
  //   return next.handle().pipe(
  //     map(async (data: any) => {
  //       const keyExists = await this.redisClient.exists(key);
 
  //       if (keyExists) {
  //         const obj = JSON.parse((await this.redisClient.get(key)) as string);
  //         const deserializedItem = deserializeItem(obj);
 
  //         return deserializedItem;
  //       }
 
  //       if (isItem(data)) {
  //         const serializedItem = serializeItem(data);
 
  //         return this.cacheManager.set(key, serializedItem);
  //       }
  //     }),
  //   );
  // }
}
cs

코드를 하나씩 살펴보면 일단 redisClient 게터는 CustomCacheService의 게터와 정확히 같은 역할이다. Redis가 제공하는 모든 메서드를 사용하기 위해 getClient() 메서드를 정의하는 것이다! trackBy() 메서드는 @CacheKey() 데코레이터로 전달된 캐시 키를 조작할 수 있는 메서드로 만약 맞춤 캐시 키가 존재한다면 바로 반환하고 그렇지 않다면 일반적인 캐시 키를 반환한다.맞춤 캐시 키는 경로가 /items/30이라고 하면 items#30이고 일반적인 캐시 키는 /items?search=신발과 같이 경로 자체이다. 밑의 intercept() 메서드는 삽질의 정수이다! 여기서 set(), get() 등 기본적인 메서드만 제공하는 Cache 인터페이스에 getClient() 메서드를 추가하여 hset(), hget(), hgetall()와 같은 Redis의 모든 메서드를 사용하는 것이다. 문제는 이렇게 구현하고 테스트를 했는데 이상하게 시간이 너무 많이 걸렸다! 단순히 CacheInterceptor를 사용하면 많이 걸려도 11ms 정도인데 구현한 인터셉터는 46ms로 대략 4배나 더 걸렸다! 그래서 캐시 키를 수정하는 trackBy() 메서드만 사용하기로 결정했다. intercept() 메서드는 아쉽지만 다음에 다시 살펴보겠다. 다시 생각해보니 캐시 설정은 서비스 계층에서 하고 반환은 인터셉터에서 하면 될 것 같은데 생각을 더 해보자!


캐시 무효화

캐시에 저장된 데이터가 수정되면 캐시를 무효화해야 한다. 나의 경우 상품 상세 API를 호출하면 캐시 인터셉터에 의해 개별 상품이 캐시에 저장된다. 하지만 주문 완료 및 주문 취소 시 옵션의 재고가 변하며 이 재고는 상품 상세 페이지에 표시된다. 따라서 캐시를 무효화해야 한다. 검색을 통해서 store.keys() 메서드로 Redis에 저장된 모든 키를 가져온 다음 주문하는 상품의 옵션의 아이디를 알아내서 캐시를 무효화 하려고 했는데 이 메서드가 작동하지 않았다... 어쩔 수 없이 옵션을 순회하면서 옵션의 아이디를 키로 변경하고 키에 해당하는 값이 있을 경우 삭제하는 논리로 캐시 무효화를 적용했다.
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
51
52
53
54
55
56
57
58
59
60
61
62
63
@Injectable()
export class OrdersService {
  constructor(
    private readonly cartsService: CartsService,
    private readonly ordersRepository: OrdersRepository,
    @Inject(CACHE_MANAGER) private cache: Cache,
  ) {}
 
  // 주문 완료 및 주문 취소 시 옵션의 재고 수가 변하기 때문에 상품 페이지의 캐시를 무효화한다.
  private async invalidateCache(ids: string[], keyName: string) {
    for (const id of ids) {
      const key = createCachekey(keyName, id);
      const value = await this.cache.get(key);
 
      if (value) {
        await this.cache.del(key);
      }
    }
  }
 
  async create(createOrderDto: CreateOrderDto, user: User) {
    const itemIds = await this.cartsService.findAll(user);
 
    await this.invalidateCache(itemIds, 'items');
 
    return await this.ordersRepository.createOne(createOrderDto, user);
  }
 
  async findOne(id: number) {
    try {
      const order = this.ordersRepository.findOne({
        where: { id },
        relations: ['orderStatus''orderToOptions''options'],
      });
 
      if (!order) {
        throw new NotFoundException('아이디에 해당하는 주문 없음.');
      }
 
      return order;
    } catch (error) {
      if (error instanceof NotFoundException) {
        throw error;
      }
 
      throw new InternalServerErrorException(
        '아이디로 주문 검색 중 오류 발생.',
      );
    }
  }
 
  async find(paginationDto: PaginationDto, user: User) {
    return await this.ordersRepository.findOrders(paginationDto, user);
  }
 
  async delete(id: number, user: User) {
    const itemIds = await this.ordersRepository.findAll(id);
 
    await this.invalidateCache(itemIds, 'items');
 
    return await this.ordersRepository.deleteOne(id, user);
  }
}
cs


한계

지금 구현한 HttpCacheInterceptor 클래스는 경로 매개변수의 키가 id인 URI에만 적용 가능하다. 상품 컨트롤러에는 상품 목록을 가져오는 API가 있는데 이 경우 검색어, 성별, 정렬, 출시일 등 여러 쿼리 매개변수를 조합하면 매우 많은 URI가 만들어지는데 어떻게 캐시를 적절하게 적용해야 하는지 알아봐야 한다. 쉬운게 없다... 하지만 힘내자!

update: 2024.01.30

댓글 없음:

댓글 쓰기