📢 어렵고 정석적인 개념 설명보다는 저같은 초보자도 이해하기 쉽게 정리하는 것을 원칙으로 포스팅하고 있습니다. 😄

[Next.js] 캐시 사용으로 성능 최적화하기 (데이터 캐시, 라우트 캐시 등)

데이터 캐시

export default async function Page() {
    const response = await fetch("~/api", { cache: "force-cache" });
}

우리가 백엔드 서버로 fetch()를 통해 API를 요청하는 경우가 있다고 하자. 위 코드처럼 우리는 fetch()를 이용해 백엔드 서버와 통신할 수 있다. 그런데 만약 이 데이터가 한 번 쓰고 끝나는 데이터가 아니라 여러 곳에서 재사용되는 데이터라면, 매번 사용할 때마다 백엔드 서버로 계속 요청을 보내야 할까?

 

데이터 캐시는 이러한 불필요한 요청을 줄이기 위한 기능으로, fetch()로 불러온 데이터를 Next.js 서버에 보관하는 역할을 한다. 이렇게 하면, 다음번에 동일한 데이터가 필요할 때 백엔드 서버로 다시 요청하는 것이 아니라, Next.js 서버에서 캐싱된 데이터를 반환할 수 있다. (캐싱된 데이터를 특정 시간 동안 유지하거나, 특정 조건에 따라 갱신하는 것도 가능하다.)

 

단, 데이터 캐시는 오직 fetch()를 통해 가져온 데이터에만 적용되며, 기본 설정값은 데이터를 저장하지 않는 "no-store"이다. "no-store"는 페칭된 데이터를 캐싱하지 않고, 항상 최신 데이터를 백엔드에서 가져오도록 하는 옵션이다. 반대로, 데이터를 무조건 캐싱하는 설정은 "force-cache"이다. 이 옵션은 항상 캐시된 데이터를 반환하며, 한 번 호출된 후에는 다시 백엔드에 요청하지 않는다.

 

데이터 캐시는 자주 불러오는 데이터를 Next.js 서버에 보관해 불필요한 백엔드 호출을 줄여 성능을 최적화하고 비용을 절감할 수 있다. 하지만 캐시된 데이터를 영구적으로 유지할 경우 데이터가 최신 상태로 업데이트되지 않을 수 있다는 단점이 있다.

export default async function Page() {
    // 1. 시간 기반 재검증
    const response = await fetch("~/api", { next: { revalidate: 3600 }});

    // 2. 온디맨드 재검증
    const response = await fetch("~/api", { next: { tag: ["a"]}});
    revalidateTag('a');
}

데이터 캐시를 갱신하는 방식에는 크게 2가지가 있다. 시간 기반 재검증(Time-based Revalidation)온디맨드 재검증(On-demand Revalidation)이 있다.

 

시간 기반 재검증(Time-based Revalidation)

시간 기반 재검증은 특정 시간이 지나면 데이터가 만료되고, 그 후 요청이 있을 때 데이터를 다시 가져와 캐시를 갱신하는 방식이다. 예를 들어 { next: { revalidate: 3600 }} 처럼 설정하면, 3600초(1시간) 후에 캐시가 만료되며, 그 이후 첫 번째 요청이 들어올 때 데이터를 새로 가져와 캐시를 갱신하게 된다. (데이터의 유통기한을 설정한다고 생각하면 된다.)

 

온디맨드 재검증(On-demand Revalidation)

온디맨드 재검증은 데이터 갱신을 특정 시간에 의존하지 않고, 필요할 때 언제든 갱신할 수 있는 방식이다. fetch()로 데이터를 캐싱할 때, tag 속성으로 캐시된 데이터에 태그를 부여하고, 나중에 revalidateTag() 함수를 호출해 해당 태그가 부여된 데이터만 선택적으로 갱신할 수 있다. 이는 시간 기반 재검증보다 더 유연하게 데이터를 관리할 수 있다는 장점이 있다.

 

리퀘스트 메모이제이션(Request Memoization)

리퀘스트 메모이제이션은 하나의 페이지 내에서 동일한 API 요청이 여러 번 발생할 때, 같은 요청을 중복해서 보내지 않고 한 번만 데이터를 가져온 후 그 결과를 재사용하는 방식이다. 즉, 중복된 데이터 페칭을 방지하고 불필요한 네트워크 요청을 줄이는 캐싱 기법이라고 볼 수 있다.

 

Next.js로 개발을 하다 보면, 하나의 페이지에 다양한 컴포넌트들이 포함될 수 있다. 특히 Next.js의 App Router 방식에서는 서버 컴포넌트를 자주 사용하게 되는데, 이 경우 상위 컴포넌트가 아니더라도 각각의 컴포넌트에서 직접 데이터를 불러오는 경우가 많기 때문에 여러 컴포넌트에서 동일한 API를 호출하는 경우도 종종 볼 수 있다.

 

이럴 때, Next.js는 리퀘스트 메모이제이션을 통해 중복된 데이터 요청을 감지하고, 첫 번째 요청만 서버에 보낸 뒤 나머지 요청들은 첫 번째 요청으로 캐시에 저장된 결과값을 반환받는다. 이렇게 함으로써 불필요한 네트워크 트래픽을 줄이고, 성능을 최적화할 수 있다.

 

리퀘스트 메모이제이션의 단점이라고 하면 리퀘스트 메모이제이션은 데이터 캐시처럼 영구적으로 저장되지 않는다는 점이 있다. 페이지를 새로고침하거나, 다른 페이지로 이동할 때 메모리에 저장된 캐시는 초기화된다. (현재 페이지를 벗어나면 모두 초기화된다.)

 

풀 라우트 캐시

Next.js의 페이지는 정적 페이지와 동적 페이지로 나뉜다. 정적 페이지는 모든 사용자에게 동일한 콘텐츠를 제공하는 페이지로, 빌드 시점에 미리 렌더링 된 HTML을 그대로 제공한다. 반면, 동적 페이지는 요청이 있을 때마다 서버에서 데이터를 가져와 실시간으로 렌더링되는 페이지를 의미한다. 이 둘의 핵심 차이는 정적 페이지는 빌드 타임에 렌더링이 완료되며 추가적인 서버 작업 없이 제공되는 반면, 동적 페이지는 요청 시점에 데이터를 받아 HTML을 생성한다는 점이다.

 

[참고] fetch()로 데이터를 받는 페이지의 경우 무조건 동적 페이지일까? 정답은 아니다. 정적 페이지는 매번 동일한 데이터를 가져와야 하는데, fetch()의 캐시 옵션이 "force-cache"로 설정된 경우, 요청 시마다 캐시된 동일한 데이터를 반환하기 때문에 이 페이지는 정적 페이지가 된다. 반면, 캐시 없이 매번 다른 데이터를 가져온다면 해당 페이지는 동적 페이지라고 할 수 있다. (데이터를 요청하는 페이지가 정적 페이지가 되려면 반드시 데이터 캐시가 있어야 한다.)

 

정적 페이지는 빌드 타임에 미리 생성된 HTML 파일을 풀 라우트 캐시에 저장해 두고, 사용자가 해당 페이지를 요청할 때 매우 빠른 속도로 제공해 준다. 이는 렌더링 과정을 생략하고 캐시에서 바로 페이지를 반환하기 때문에 속도가 빠르고 서버 부하를 줄이는 데 효과적이다. 또한, 정적 페이지는 내용 변경이 거의 없기 때문에, 캐싱으로 인해 최신 데이터를 제공하지 못한다는 단점이 크게 문제 되지 않는다.

 

풀 라우트 캐시는 별도의 설정 없이 Next.js가 자동으로 정적 페이지에 대해 적용하는 기능이다. 따라서 서비스를 구성할 때, 정적 페이지의 비중이 클수록 성능 최적화에 유리하기 때문에 가능하면 동적 페이지보단 정적 페이지로 구성하는 것이 좋다.

// 동적 경로 페이지 정적 페이지로 만들기
export function generateStaticParams() {
    return [{ id: 1 }, { id: 2}, { id: 3 }];
}

// 미리 만든 경로 외의 경로에 접속했을 때 notFound 뜨게 하기
const dynamicParams = false;

[id].tsx처럼 동적 경로를 사용하는 페이지의 경우 빌드 타임에 모든 동적 경로를 렌더링 할 수 없기 때문에 정적 페이지를 사용할 수 없다. 그러나, 생성될 경로들을 미리 정의하면 동적 경로를 사용하는 페이지도 정적 페이지로 사용할 수 있다.

 

generateStaticParams() 함수로 생성될 동적 경로들을 미리 정해주면, 빌드 타임에 해당 경로의 페이지를 생성하여 풀 라우트 캐시에 저장하게 된다. 하지만, 만약 미리 정의된 경로 외의 다른 경로로 접근하게 된다면 동적 경로를 가진 정적 페이지는 어떻게 될까? 이것은 fallback 설정에 따라 다른데, 여기서 dynamicParams 변수가 fallback 역할을 수행한다.

 

dynamicParamstrue 값을 넣으면, 해당 경로는 동적 페이지처럼 서버에서 렌더링 된다. 그리고 한 번 생성된 페이지는 이후부터 정적 페이지처럼 작동하게 된다. 반대로, false 값을 넣으면 정의되지 않은 경로에 대해 얄짤없이 그냥 404 페이지를 띄워버린다.

 

클라이언트 라우트 캐시

클라이언트 라우트 캐시는 페이지 이동 시 더 빠른 전환을 위해 페이지의 일부 데이터를 저장한다. 일반적으로 레이아웃이 저장되며, 레이아웃의 경우 여러 페이지에서 공통으로 사용하는 경우가 많기 때문에 그렇다. 한 번 접속한 레이아웃은 별도로 저장되어 이후 페이지 이동이 발생했을 때, 서버로부터 다시 불러오는 과정을 생략한다. (레이아웃 외에도 fetch 등을 통해 불러온 데이터도 클라이언트 라우트 캐시에 저장된다.)

 

또한, 클라이언트 라우트 캐시는 페이지 이동 최적화를 위해 프리페칭 데이터를 저장한다. 프리페칭이란 현재 페이지에서 이동할 페이지들의 데이터와 콘텐츠들을 미리 다운로드하여 저장해 두는 기능으로, 이 데이터를 통해 페이지 이동을 원활하게 할 수 있게 된다. (보통 Link 태그로 연결된 페이지들이 프리페칭의 대상이 된다.)

 

결론적으로, 클라이언트 라우트 캐시는 정적/동적 페이지 상관없이 페이지 간 전환 시에 UI 전환을 더 빠르게 하기 위해 브라우저 내부에 저장되는 캐시이다.

풀 라우트 캐시 VS 클라이언트 라우트 캐시
- 풀 라우트 캐시 : 서버 측에서 페이지를 빠르게 렌더링하고 제공하기 위해 서버가 페이지를 빌드 타임에 미리 렌더링하여
   캐시로 저장해 두고, 클라이언트에서 요청 시 캐시된 페이지를 즉시 제공함으로써 페이지 로드 속도를 향상시킨다.
- 클라이언트 라우트 캐시 : 클라이언트 측에서 페이지 전환 시 성능을 최적화하고 사용자 경험을 향상시키기 위해
   클라이언트가 미리 캐시된 데이터와 콘텐츠를 재사용하여 페이지를 빠르게 로드한다.
- 두 캐시 방식은 상호 보완적이며(서버-클라이언트), 함께 사용될 때 성능을 크게 향상시킬 수 있다.
1. 데이터 캐시 : fetch()를 이용해서 API를 요청할 때, 반환받은 데이터를 캐시에 저장함.
2. 리퀘스트 메모이제이션 : 하나의 페이지 안에서 중복된 API 요청이 있을 시, 첫 번째 요청을 캐시에 저장하고 사용함.
3. 풀 라우트 캐시 : 빌드 타임에 생성되는 정적 페이지는 모두 풀 라우트 캐시에 저장됨.
4. 클라이언트 라우트 캐시 : 페이지 간 이동할 때 레이아웃 같은 공통 요소들을 캐시에 저장함. 프리페칭 데이터도 포함.