개요

예전부터 Next.js의 Data Cache가 File System에 저장되는 방식의 한계를 느껴왔고, 이에 대한 내용을 몇 차례 정리해둔 바 있습니다. Next.js 팀 역시 이 문제를 인지하고 있었고, 최근에는 내부의 Cache Handler를 커스텀할 수 있는 옵션과 간단한 예제를 공식적으로 제공하기 시작했습니다.

하지만 이 예제는 최소한의 기능만을 제공하기 때문에, 실제 현업에서 사용하기에는 부족한 점이 많았습니다. 기존 캐싱 방식을 유지하면서도 다양한 상황에 대응할 수 있도록 확장 가능한 라이브러리를 만들고자 했습니다. 그 결과물이 바로 next-payload-handler입니다.

next-payload-handler 소개

해결하고자 한 문제

이 라이브러리는 "File System 기반 저장 방식"의 한계를 해결하는 데서 출발했습니다. 서버 내부가 아닌 외부의 공용 캐시 저장소(Redis, GCS 등) 에 데이터를 저장함으로써, 모든 서버가 동일한 캐시 데이터에 접근할 수 있게 됩니다.

이로 인해 revalidateTag 기능을 활용한 정확하고 빠른 캐시 무효화가 가능해지고, 캐시 동기화 이슈도 자연스럽게 해결됩니다.

핵심 컨셉

1. 기존 캐싱 방식과 동일한 동작

Next.js의 예제Map 객체를 기반으로 단순한 get, set, revalidateTag 기능만 제공합니다. 하지만 실제 내부 구현은 훨씬 더 복잡하며, 성능 최적화를 위한 로직이 포함되어 있습니다.

예를 들어 revalidateTag는 단순히 Map을 순회하며 key를 삭제하는 방식이지만, Next.js 내부에서는 tagsManifest라는 객체에 revalidatedAt을 기록해두고, 이후 get 시점에 이를 비교해 stale 여부를 판단하는 구조입니다.

이를 기반으로 next-payload-handler도 동일한 구조와 성능을 유지하도록 설계했습니다.

  • 예제
cache-handler.js
1const cache = new Map()
2
3async get(key) {
4 // 특정 key가 없기 때문에 origin server로 새 요청을 한다.
5 return cache.get(key)
6}
7
8async revalidateTag(tags) {
9 // 모든 cache를 순회 후, key를 찾아 삭제한다.
10 for (let [key, value] of cache) {
11 if (value.tags.some((tag) => tags.includes(tag))) {
12 cache.delete(key)
13 }
14 }
15}
  • NextJS 내부 코드
file-system-cache.ts
1// 내부 구조 요약
2public async revalidateTag(...args) {
3 for (const tag of tags) {
4 tagsManifest.items[tag] = {
5 // 호출된 시점의 현재 시간을 기록
6 revalidatedAt: Date.now(),
7 };
8 }
9}
10
11public async get(...args) {
12 const wasRevalidated = tags.some((tag) => {
13 return (
14 tagsManifest?.items[tag]?.revalidatedAt >= (data?.lastModified || Date.now())
15 );
16 });
17
18 if (wasRevalidated) {
19 data = undefined;
20 }
21
22 return data ?? null;
23}

➡️ 예제 코드와 달리, O(N) 순회 없이 O(1)로 revalidate 가능하며, 내부 로직과 동일한 방식으로 동작합니다.

2. 공용 저장소를 활용한 캐시 동기화

가장 중요한 컨셉 중 하나는, 하나의 공용 저장소를 모든 서버가 함께 사용하는 구조입니다. 이를 통해 서버 수와 무관하게 항상 최신 캐시 데이터를 공유할 수 있으며, 외부에서 데이터를 무효화하더라도 즉시 반영됩니다.

이론상 key-value 형식으로 저장될 수 있다면 공용 저장소로 사용할 수 있습니다. 현재는 라이브러리는 Redis와 GCS(Storage)를 지원하며, 추가 저장소 확장도 고려하고 있습니다. 각 방식의 자세한 컨셉은 Redis를 이용한 공유GCS를 이용한 공유 포스트을 통해 확인할 수 있습니다.

  • A, B, C 어떤 서버에 접근해도 하나의 저장소에서 캐시데이터를 받아온다.

확장 기능

1. 캐시 키 커스터마이징

Next 내부에서 캐시 무효화를 할 때는 revalidateTag()를 쓰면 되지만, CMS처럼 외부 시스템에서 캐시를 무효화하려면 복잡한 설정이 필요합니다.

  1. CMS에서 Next App으로 무효화할 tag를 query string에 담아 요청 전송
  2. Next App에선 CMS에서 요청을 받기 위해 CORS와 Access-Control-Allow-* header 추가
  3. query string을 파싱해 무효화할 tag 추출
  4. revalidateTag 함수 실행

캐시 무효화를 위해 너무 많은 코드를 추가해야 합니다. 이를 개선하기 위해, Next App를 거쳐 무효화하는 방식이 아닌, CMS에서 직접 캐시를 무효화하는 방법을 생각했습니다. ( CMS를 이용한 Next fetch 캐시 핸들링 글 참고 )

이 방법이 가능하려면, 캐시 키에 접근할 수 있어야합니다. 하지만 Data Cache의 key는 내부에서 생성되고 해싱되기 때문에, 외부에서 key를 정확히 매칭하기 힘듭니다.

이를 해결하기 위해, Next의 fetch를 확장한 patchFetch 를 제공하며, 여기에 커스텀 캐시키를 지정할 수 있습니다. 커스텀한 키를 CMS에서 곧바로 제거함으로써 무효화를 할 수 있습니다. 캐시 키를 커스텀하는 방법에 대해선 tags를 이용한 캐시키 핸들링하기을 참고해주세요.

http.ts
1import { patchFetch } from 'next-payload-handler';
2
3patchFetch('/api/post', {
4 method: 'GET',
5 next: {
6 cacheKey: 'custom-key',
7 tags: ['post'],
8 },
9});
10
11// CMS에서 직접 삭제 가능
12RedisClient.delete('custom-key');
13GCSBucket.delete('custom-key');

➡️ 커스텀 키 없이도 기존의 revalidateTag 방식도 그대로 사용할 수 있습니다.

2. 요청별 저장소 선택

클라우드 스토리지를 이용한 Next.js fetch cache 관리 글에서, Redis와 Storage를 공용 저장소로 사용했을 때의 장단점을 비교해봤습니다.

Redis는 빠르지만 휘발성이고, GCS는 안정적이지만 속도가 느릴 수 있습니다. 각각의 장단점을 활용하기 위해, 요청 단위로 캐시 저장소를 선택할 수 있도록 지원합니다.

http.ts
1import { patchFetch } from 'next-payload-handler';
2
3patchFetch('/api/post', {
4 method: 'GET',
5 next: {
6 handlerType: 'redis', // Redis에 캐시 저장
7 },
8});
9
10patchFetch('/api/comment', {
11 method: 'GET',
12 next: {
13 handlerType: 'gcs', // gcs에 캐시 저장
14 },
15});

3. 캐시 최대 크기 설정

Data Cache의 최대 Max Size는 2MB입니다. 이 제한 때문에, 많은 개발자들이 고통받고 있는데요, 저 역시 이 문제 때문에 BFF에서 응답 데이터의 사이즈를 줄이기 위해 노력했던 기억이 생각납니다.

라이브러리에서는 config 단계에서 cacheMaxSize 옵션을 이용해 커스텀할 수 있습니다.

cache-handler.js
1CacheHandler.initializeHandler({
2 cacheOptions: {
3 cacheMaxSize: '5', // MB 단위
4 },
5});

4. 네임스페이스를 통한 서비스 구분

CI/CD 또는 MSA 환경에서는 서로 다른 서비스가 하나의 캐시 저장소를 공유할 수 있습니다. 이때 캐시 키 충돌을 방지하기 위해 네임스페이스를 설정할 수 있습니다. customKey 혹은 해싱된 key의 prefix로 붙게 됩니다.

cache-handler.js
1// 서비스 A
2CacheHandler.initializeHandler({
3 cacheOptions: {
4 namespace: 'service-A',
5 },
6});
7
8// 서비스 B
9CacheHandler.initializeHandler({
10 cacheOptions: {
11 namespace: 'service-B',
12 },
13});

➡️ 같은 cacheKey를 사용해도 내부적으로는 service-A:custom-key, service-B:custom-key 형태로 구분됩니다.

마무리

이 라이브러리를 만들게 된 계기는 단순히 기술적인 필요 때문만은 아닙니다. 예전부터 File System 기반 캐시 방식의 한계를 느끼며 관련 글들을 작성해왔고, 그 고민의 연장선에서 직접 해결책을 만들고 싶었습니다.

단순히 문제를 우회하는 코드가 아닌, Next.js가 지향하는 캐싱 구조를 그대로 따르면서도 확장 가능하고 일관성 있는 방식으로 캐시를 다룰 수 있는 구조가 필요하다고 생각했습니다.

그래서 이 라이브러리는 다음과 같은 방향성을 가지고 설계되었습니다.

  • 내부 동작 원리를 해치지 않으면서도 확장 가능해야 한다.
  • 멀티 인스턴스 환경에서의 일관된 캐시 동기화를 가장 우선순위로 삼는다.
  • 외부 시스템(CMS 등)과의 연동도 쉽게 이루어져야 한다.
  • 성능과 유지보수 측면에서 무리가 없어야 한다.

더 자세한 예제 및 설정 방법은 GitHub 저장소를 참고해주세요. 라이브러리를 사용하는 과정에서 발견한 문제, 혹은 더 나은 아이디어가 있다면 언제든지 이슈나 PR로 공유해 주시면 정말 감사하겠습니다.

참고 자료

  1. Next.js 캐시 데이터 전략: 멀티 인스턴스 환경에서 캐시 공유가 필요한 과정
  2. 멀티 인스턴스 환경에서 Redis를 활용해 Next.js 캐시 데이터 공유하기
  3. CMS를 이용한 Next fetch 캐시 핸들링
  4. RSC payload key 핸들링하기
  5. 클라우드 스토리지를 이용한 Next.js fetch cache 관리