개요

오래전 글에서 Next Cache Key를 구하는 알고리즘을 분석하고 실제로 동일하게 만들어보기까지 하면서 RSC paylaod를 관리해 볼 생각이었습니다. 이와 관련해 npm 라이브러리도 만들어보면서 큰 기대를 만들고 적용해 보려 했지만...

막상 만들고 적용해보니 쓸모가 없어졌는데, CMS에서 key를 생성해야 하는데 키 생성에 필요한 값들은 대부분 클라이언트에서 사용된다는 점, config 값을 사용해야 하는 점 등 생각보다 사용하는데 너무 많은 공수가 들어 잠시 접어뒀습니다.

그래서 이번 글에서는, Cache Key를 구하는 쪽에서 원하는 대로 set하는 쪽으로 방향을 틀어 제어한 방법을 공유해 보려 합니다.

왜 Cache Key 구하는 것에 집착을 했는지?

다음은 NextJS가 제공하는 custom cache handler 예제입니다.

cache-handler.js
1module.exports = class CacheHandler {
2 constructor(options) {
3 this.options = options
4 }
5
6 async get(key) {
7 return cache.get(key)
8 }
9
10 async set(key, data, ctx) {
11 cache.set(key, {
12 value: data,
13 lastModified: Date.now(),
14 tags: ctx.tags,
15 })
16 }
17
18 ...
19}

캐시 흐름에 대해 보면, 요청이 들어올 때 get 메서드에서 key가 있다면 Cache HIT, 없다면 Cache MISS로 set 메서드를 통해 새 캐시를 저장합니다.

여기서 set 메서드에서 key를 커스텀 해서 저장한다면, get에서 key를 받아올 수가 없습니다. 이 key는 NextJS 내부에서 만들어지기 때문이죠. 따라서 제가 이 key를 구할 수만 있다면, 캐시에 접근해 관리를 해볼 생각이었습니다.

이 방법은 개요에서 소개한 것처럼, 더 효율적인 방법을 찾기 위해 잠시 중단했습니다.

두 번째 파라미터 ctx를 이용하자

문서엔 없지만, 구현체를 보면 get 메서드는 두 번째 파라미터 ctx를 제공합니다. ctx 안에는 무엇이 있을까요? incremetal-cache 파일 안에서 확인해 볼 수 있습니다.

next/src/server/lib/incremetal-cache/index.ts
1module.exports = class CacheHandler {
2...
3 async get(
4 cacheKey: string,
5 ctx: {
6 kindHint?: IncrementalCacheKindHint
7 revalidate?: Revalidate
8 fetchUrl?: string
9 fetchIdx?: number
10 tags?: string[]
11 softTags?: string[]
12 isRoutePPREnabled?: boolean
13 } = {}
14 ) { ... }
15
16...
17}

익숙한 옵션들이 많이 보이는데, 특히 눈에 띄는 건 tags입니다. 예제의 set 메서드에서 사용했죠. 예제에서 payload의 value는 { value, lastModified, tags }로 구성되어 있습니다.

tags는 무엇일까요? 예상했던 것처럼 On Demand 방식을 위한 Revalidate Tag의 tag입니다. 이제 get, set 메서드 간에 내부에서 생성되는 key 말고도 우리가 설정할 수 있는 tag를 공유할 수 있게 된 겁니다.

tags를 이용한 key 커스텀하기

next option을 이용해 다음과 같이 요청을 해보겠습니다.

1const response = await fetch(url, { next: { tags: ['posts'] } } );

이제 세팅한 tags를 이용해 동일하게 customKey를 만듭니다.

cache-handler.js
1module.exports = class CacheHandler {
2 ...
3
4 // ctx.tags를 받아 동일한 key를 반환한다.
5 private generateCustomKey(tags): {
6 return tags.join(',')
7 }
8
9 async get(key, ctx) {
10 const customKey = generateCustomKey(ctx.tags)
11
12 return cache.get(customKey)
13 }
14
15 async set(key, data, ctx) {
16 const customKey = generateCustomKey(ctx.tags)
17
18 cache.set(customKey, {
19 value: data,
20 lastModified: Date.now(),
21 tags: ctx.tags,
22 })
23 }
24
25 ...
26}

최종 저장된 값은 다음과 같습니다.

1{
2 posts : { "value":{"kind":"FETCH","data": .... }
3}

tags를 사용하지 않는 경우 리팩토링

tags를 설정하지 않는 요청이 더 많을 거라고 예상됩니다. 이 경우 key가 존재하지 않기 때문에, 캐시를 할 수 없습니다.

tags가 없는 경우는 그대로 NextJS의 key를 사용할 수 있게 리팩토링합니다. tags가 없다면 기존처럼 내부 캐시 파이프라인을 수행할 수 있게 하는 것이죠.

cache-handler.js
1module.exports = class CacheHandler {
2 ...
3
4 private generateCustomKey(key, tags): {
5 // tags가 비어있으면, 기존의 NextJS Cache Key를 이용한다.
6 if(!tags) {
7 return key;
8 }
9
10 return tags.join(',')
11 }
12
13 async get(key, ctx) {
14 const customKey = generateCustomKey(key, ctx.tags)
15
16 return cache.get(customKey)
17 }
18
19 async set(key, data, ctx) {
20 const customKey = generateCustomKey(key, ctx.tags)
21
22 cache.set(customKey, {
23 value: data,
24 lastModified: Date.now(),
25 tags: ctx.tags,
26 })
27 }
28
29 ...
30}

revalidate 과정을 없애자

NextJS가 알아서 해주는데 굳이 Cache를 핸들링 하려는 이유가 무엇일까요. CMS를 통해 외부에서 데이터를 관리한다면, On Demand로 revalidate 하기 굉장히 번거롭습니다.

  1. CMS에서 데이터 업데이트 시 Next 서비스로 queryString에 업데이트하고자 하는 tags를 포함해 요청하기
  2. 서비스에선 route handler를 이용해 CMS의 요청으로부터 queryString을 파싱, tags 추출
  3. revalidateTag 실행

보기엔 간결해 보이지만, 이 과정에서 CMS와 서비스 간의 CORS 설정이나, 무분별한 route handler 호출을 막기 위한 header 설정 등 굉장히 많은 공수가 들어가게 됩니다. 예외 처리해야 하는 코드 또한 늘어납니다.

반면 캐시를 커스텀 하면 어떻게 될까요. 단지 CMS에서 데이터 업데이트 시 저장소에 접근, customKey를 통해 Cache를 Purge 시키면 그만입니다. 그렇다면 자동으로 NextJS는 Cache MISS, DB에 접근해 새 데이터를 받아올 수 있는 것이죠. (다만 서비스와 CMS 간에 tags 관리는 더 연구가 필요해 보입니다.)

Sever Action을 이용하면 위의 과정을 거치진 않지만, 이 경우에도 즉시 Cache Purge를 통해 NextJS의 revalidate 실행하는 과정을 생략할 수 있습니다.

마무리

아직 POC 단계이지만 꾸준히 관심을 가지고 개선한 결과, 회사가 겪고 있는 기술적 챌린지를 하나씩 해결해 나간다는 점에서 큰 보람을 느낍니다.

사실 NextJS가 제공하는 가이드대로만 해도 문제를 해결할 수 있지만, 더 나은 방향으로 개선하기 위해 앞으로도 계속 다듬어 나가려고 합니다.