이 글은 Next 14 를 기준으로 작성되었습니다.
이번 프로젝트에서는 제이쿼리와 장고 템플릿으로 구성된 기존 애플리케이션을 Next.js 14.1의 앱 라우터를 사용하여 마이그레이션했습니다.
처음 Next.js 14.1를 도입할 때, 단순히 앱 라우터를 활용하고 폴더 구조를 재정리하는 것만으로 Next.js의 진정한 이점을 충분히 누릴 수 있을지 의문이 있었습니다. 라우팅 구조를 변경하는 것 이상의 무언가가 필요하다고 생각했고, Next.js 14의 전체 생태계를 깊이 이해하고 활용하는 것이 중요하다는 결론에 도달했습니다.
특히, Next.js 14의 캐싱 메커니즘은 성능과 효율성을 극대화하는 데 중요한 역할을 합니다. 이를 깊이 이해하면 Next.js 14의 핵심 요소인 서버 컴포넌트와 클라이언트 컴포넌트에 대해 더욱 명확하게 이해하는데 도움이 됩니다. 이를 위해, 저는 관련 문서를 여러 번 읽고, 제공된 템플릿을 분석하며, 실제 프로젝트에 적용하면서 많은 시행착오를 겪었습니다.
이 글은 Next.js의 캐싱 메커니즘에 대한 저의 경험과 학습을 기록한 것입니다. Next.js의 생태계를 이해하는 데 조금이나마 도움이 되기를 바라며, 이 글을 읽기 전에 Next.js의 캐싱 관련 문서를 한 번쯤은 읽어보시길 권장합니다.
우선 기본 개념인 렌더링 방식에 대해 간단히 알아봅시다.
서버 사이드 렌더링은 매 요청마다 페이지를 서버에서 렌더링하여 클라이언트에 전송하는 방식입니다.
클라이언트 사이드 렌더링은 브라우저가 초기 HTML, CSS, JavaScript 파일을 서버로부터 받아온 후, JavaScript를 통해 클라이언트(브라우저)에서 페이지를 렌더링하는 방식입니다.
프론트엔드 개발자라면 적어도 SSR과 CSR에 대해 한번쯤은 공부해 봤을 것이고 기본적으로 CSR 을 사용하며 SSR 은 한번쯤 적용 해봤을것입니다. 앞서 설명한것과 같이 각각의 렌더링 방식에는 장단점이 있으며, 이를 적절히 활용하면 빠르고 효율적인 웹 애플리케이션을 구축할 수 있습니다.
하지만 next.js 에서는 보다 다양한 렌더링 방식을 제공합니다.
정적 사이트 생성은 빌드 시에 페이지가 한번 렌더링되고, 그 결과가 정적 파일로 저장되는 방식입니다.
증분 정적 재생성은 SSG와 SSR의 장점을 결합한 방식으로, 정적 페이지를 주기적으로 재생성합니다.
Next.js 14는 기본적으로 빌드 타임에 사이트 전체를 사전 렌더링합니다. 이로 인해 전체 사이트가 정적으로 생성되며, 이를 정적 사이트 생성(SSG)이라고 합니다. 이 과정에서 모든 페이지가 서버에서 미리 렌더링되고 정적 파일로 저장됩니다.
이는 서버에 큰 부담이 되지 않을까 생각할 수 있습니다. 그러나 Next.js 14는 이러한 부담을 줄이기 위해 강력한 내부 캐싱 메커니즘을 가지고 있습니다. Next.js에서 SSG가 기본값인 이유도 바로 이 때문입니다. 정적 생성(Static Generation)은 서버의 부하를 줄이고, 빠르고 안정적인 성능을 제공하는 데 매우 효과적입니다.
앞서 다룬 렌더링 방식에 대해 간단하게 요약해보자면
SSR (Server-Side Rendering) 만 사용하는 경우, 매 요청마다 서버에서 페이지를 렌더링해야 하므로 서버 부하가 증가할 수 있습니다. 반면에, SSG (Static Site Generation)는 빌드 시점에 HTML 파일을 생성하여 배포하는 방식입니다. 요청 시마다 서버에서 렌더링할 필요가 없으므로 서버 부하가 줄어듭니다. 하지만 정적 생성된 페이지는 데이터가 자주 업데이트되지 않는 경우에 적합합니다.
이를 보완하기 위해 ISR (Incremental Static Regeneration)을 사용할 수 있습니다. ISR은 SSG의 장점을 유지하면서도, 지정된 시간 간격마다 또는 특정 조건(예: 사용자 액션)이 발생할 때 페이지를 재생성하여 최신 데이터를 반영할 수 있는 기능을 제공합니다.
이 글에서 여기서 '서버'란 일반적으로 데이터를 받아오는 백엔드 서버를 의미하는 것이 아니라, 프론트엔드 서버를 뜻합니다. 많은 신입 개발자들은 Node.js와 npm을 사용하지만, Node.js가 실제로 어떤 역할을 하는지, 그리고 SSR에서 언급되는 '서버'가 무엇인지에 대해 명확히 이해하지 못하는 경우가 많습니다.
전통적인 서버는 웹 서버를 의미합니다. 예를 들어, Node.js 환경에서 실행되는 서버나 PHP가 구동되는 Apache/Nginx 서버 등이 이에 속합니다. Next.js도 Node.js 기반으로 실행되며, SSR이나 SSG와 같은 작업은 이 Node.js 기반 서버에서 수행됩니다.
이글에서는 서버라 함은 주로 Node.js 환경을 기준으로 설명 합니다.
참고 :
기본적으로 Next.js 14.1 애플리케이션은 React Server Components 를 사용하며 더이상 getStaticProps, getServerSideProps 를 사용하여 SSR 를 하지 않습니다.
이는 즉 더이상 페이지 단위의 SSR 이 아닙니다. nextjs 는 서버컴포넌트와 클라이언트컴포넌트를 이용해 컴포넌트 단위로 SSG, SSR, ISR, CSR 로 렌더링 방식을 정할수 있다.
서버 컴포넌트와 클라이언트 컴포넌트에 대해 미리 숙지하기 바라며 이 글을 읽으면 캐싱 매커니즘과 함께 서버컴포넌트와 클라이언트의 동작 방식을 이해하는데 도움이 될 수 있습니다.
각각의 캐시 메커니즘에 대해 더 깊이 이해하고자 한다면, Next.js 문서를 정독하는 것이 좋습니다. 제가 작성한 Velog에도 번역된 내용이 있으니 참고하시기 바랍니다. 이 글은 Next.js 문서만으로 이해하기 어려웠던 부분을 설명하고 보충하기 위해 작성되었습니다.
https://nextjs.org/docs/app/building-your-application/caching
https://velog.io/@leejpsd/Next.js에서의-캐싱
기본적으로 Next.js는 성능을 향상하고 비용을 줄이기 위해 가능한 한 많이 캐시합니다.
요청 메모이제이션은 Next.js가 아닌 React의 기능으로 앞으로 다룰 내용과 직접적인 관련은 적으므로 여기서는 생략하겠습니다.
위의 이미지를 보면 nextjs의 기본인 정적 사이트 생성(SSG)에 대한 캐싱매커니즘에 대해 알수 있습니다.
Next.js의 정적 사이트 생성(SSG)는 기본적으로 모든 캐싱 메커니즘을 활용하여 성능을 극대화합니다. 이를 이해하기 위해, Next.js에서의 캐시를 크게 클라이언트 캐시와 서버 캐시로 나눌 수 있습니다:
SSG는 서버 캐시를 사용하는 기본적인 캐싱 방식입니다. 이를 설명하기 위해, 이미지에서 보듯이 클라이언트와 서버 사이에 "빌드 타임"이라는 개념이 있습니다.
빌드 타임 동안에 ‘/a’
페이지에 대한 서버 렌더링이 이루어집니다. 이때 필요한 데이터가 있으면, Data Source(데이터 소스)에서 데이터를 가져와 데이터 캐시에 저장합니다. 데이터 소스는 일반적으로 프론트 서버이지만, 외부 데이터를 사용하는 경우 백엔드 서버일 수도 있습니다. 이후, 데이터를 기반으로 렌더링을 수행한 결과를 풀라우트 캐시에 RSC 페이로드와 HTML 형태로 저장합니다.
이후 ‘/a’
페이지에 대한 요청이 들어오면, 풀라우트 캐시에 저장된 HTML과 RSC 페이로드를 사용하여 페이지를 빠르게 렌더링합니다. 이는 데이터 요청이 한 번만 이루어지고, 이후에는 갱신되지 않음을 의미합니다. 이 점에서 SSG는 데이터를 정적이고 변경되지 않게 관리하는 방식으로, 헤더나 푸터처럼 데이터 업데이트가 필요 없는 정적인 콘텐츠에 적합합니다.
결론적으로, SSG는 풀라우트캐시를 사용하여 페이지를 정적으로 렌더링하는 방법입니다. 데이터의 변경이 거의 없거나 업데이트가 필요 없는 경우에 이상적이며, 성능을 극대화하는 데 효과적입니다.
useEffect
를 사용하여 필요한 데이터를 API로부터 페칭. 애니메이션: 클라이언트 컴포넌트가 API로 데이터를 요청하고, 받은 데이터를 화면에 렌더링하는 애니메이션.하이드레이션(hydration)은 서버에서 렌더링된 HTML을 클라이언트 측에서 React가 다시 활성화(attach)하는 과정입니다. 즉, 서버에서 렌더링된 HTML에 React의 이벤트 핸들러와 상태 관리 기능을 추가하여 인터랙티브하게 만드는 것입니다. 클라이언트 컴포넌트가 데이터를 페칭하고 결과를 로드하는 과정은 하이드레이션 이후에 이루어지는 클라이언트 측 로직입니다.
React Server Components(RSC) 페이로드는 서버에서 렌더링된 React 컴포넌트 트리의 결과를 포함하는 데이터입니다. 이 데이터는 클라이언트 측에서 React 컴포넌트를 업데이트하고 렌더링하는 데 사용됩니다. RSC는 서버와 클라이언트 사이에서 효율적으로 데이터를 교환하고, 클라이언트 측에서 불필요한 JavaScript 로드를 줄이는 것을 목표로 합니다.
기본적인 SSG(Static Site Generation)에서 SSR(Server-Side Rendering)로 전환하려면 어떤 방법이 있을까요? 대부분의 경우, 우리는 데이터가 업데이트될 때마다 최신 상태를 반영하는 동적 렌더링을 원합니다. 예를 들어, 뉴스 웹사이트에서 사용자가 항상 최신 기사를 볼 수 있도록 하려면, 매 요청 시마다 서버에서 페이지를 렌더링하는 SSR이 필요할 수 있습니다. SSG는 빌드 타임에 생성된 정적 페이지를 제공하지만, SSR은 서버에서 실시간으로 페이지를 생성하여 최신 데이터를 반영할 수 있습니다.
앞서 설명한 것처럼, SSG(Static Site Generation)는 Full Route Cache를 사용하여 정적인 콘텐츠를 제공합니다. 이 캐시는 빌드 타임에 생성된 HTML과 데이터를 저장하며, 사용자가 페이지를 요청할 때마다 정적인 콘텐츠를 빠르게 제공할 수 있습니다.
이미지를 보면, 정적 경로(Static Route)의 경우 SSG를 통해 생성된 콘텐츠가 Full Route Cache에 저장됩니다. 이로 인해 사용자는 빌드 시점에 생성된 데이터를 항상 동일하게 받아볼 수 있습니다.
반면에, 동적 경로(Dynamic Route)를 사용하는 SSR(Server-Side Rendering)에서는 Full Route Cache를 사용하지 않고, 매 요청마다 서버에서 페이지를 새로 렌더링합니다. 따라서 SSR을 사용하려면 Full Route Cache를 스킵해야 합니다. 이렇게 하면, 페이지가 요청될 때마다 최신 데이터를 사용하여 동적으로 렌더링됩니다.
그렇다면 Full Route Cache 를 무효화 하거나 선택 해제 하는 방법을 docs에서 찾아봅시다.
무효화
전체 경로 캐시를 무효화하는 방법에는 두 가지가 있습니다.
- 데이터 재검증 (데이터 캐시):
재검증 은 서버의 구성 요소를 다시 렌더링하고 새 렌더링 출력을 캐싱하여 라우터 캐시를 무효화합니다.- 재배포 :
배포 전반에 걸쳐 지속되는 데이터 캐시와 달리 전체 경로 캐시는 새 배포에서 지워집니다.
즉 Full Route Cache 를 무효화하기 위해선 데이터 캐시를 재검증 하거나 재배포 하는 방법밖에 없습니다. 여기서 알아야 할것은 풀라우트 캐시와 데이터 캐시의 상호관계입니다 .이 둘은 서버 캐시이며 서로 밀접한 관계가 있습니다.
다음은 데이터캐시와 풀라우트캐시의 상호관계에 대한 docs 의 내용입니다.
- Revalidating or opting out of the Data Cache will invalidate the Full Route Cache, as the render output depends on data.
- Invalidating or opting out of the Full Route Cache does not affect the Data Cache. You can dynamically render a route that has both cached and uncached data. This is useful when most of your page uses cached data, but you have a few components that rely on data that needs to be fetched at request time. You can dynamically render without worrying about the performance impact of re-fetching all the data.
첫번째 단락을 보면 데이터 캐시를 재검증하거나 선택 해제(즉, 사용하지 않도록 설정)하면 전체 경로 캐시도 무효화됩니다. 이는 데이터가 달라지면 그에 따라 렌더링 결과도 달라지기 때문입니다. 쉽게 말해, 데이터 캐시가 무효화되면 전체 경로 캐시도 무효화되며, 이렇게 하면 최신 데이터를 사용하여 페이지를 렌더링하게 됩니다.
두번째 단락을 보면 반대로, 전체 경로 캐시를 무효화하거나 제외해도 데이터 캐시는 영향을 받지 않습니다. 즉, 전체 경로 캐시가 없더라도 개별 데이터는 여전히 캐시될 수 있습니다. 이는 페이지의 대부분이 캐시된 데이터를 사용하더라도, 일부 구성 요소가 요청 시 가져올 새로운 데이터를 필요로 할 때 유용합니다. 이렇게 하면 필요한 데이터만 동적으로 가져와 페이지를 렌더링할 수 있어, 모든 데이터를 다시 가져올 때의 성능 부담을 줄일 수 있습니다. 이것은 nextjs가 하이브리드 캐싱을 지원하기 때문입니다.
- Next.js 14의 하이브리드 캐싱
Next.js 14는 하이브리드 캐싱을 지원하여, 기존의 페이지 단위 서버 사이드 렌더링(SSR) 방식과는 다른 접근을 제공합니다. 이제 서버 컴포넌트가 도입되면서 각 API 요청에 대해 개별적으로 캐싱 여부를 결정할 수 있게 되었습니다. 이는 서버 컴포넌트와 클라이언트 컴포넌트가 함께 동작하며, 컴포넌트 단위로 세밀한 캐싱 제어를 가능하게 합니다.
하이브리드 캐싱의 특징
- 컴포넌트 단위 캐싱 : 서버 컴포넌트는 각 API 요청에 대해 개별적으로 데이터 캐싱을 설정할 수 있습니다. 즉, 페이지 전체가 아닌, 특정 컴포넌트의 데이터만 캐시할 수 있습니다.
- 데이터 캐시 우선순위: 특정 API 요청이 동적 데이터를 가져와야 하는 경우에도, 전체 페이지가 아닌 해당 컴포넌트의 데이터만 동적으로 렌더링할 수 있습니다.
- 하이브리드 데이터 접근: 컴포넌트는 필요에 따라 캐시된 데이터나 백엔드에서 실시간으로 가져온 데이터를 선택적으로 사용할 수 있습니다. 이는 빠른 응답 시간과 최신 데이터 제공을 균형 있게 유지하는 데 유용합니다.
- React Suspense와 사전 부분 렌더링
React Suspense를 사용하면 이러한 개별 캐싱 전략을 더 효과적으로 활용할 수 있습니다. Suspense를 통해 컴포넌트가 준비될 때까지 대기하고, 필요한 데이터를 사전에 부분적으로 렌더링할 수 있습니다. 이를 통해 사용자 경험을 향상시키고, 초기 로딩 시간을 줄일 수 있습니다.
Next.js 14의 문서에 따르면, 데이터 캐시를 무효화 하는 방법 외에 특정 페이지나 레이아웃의 전체 경로 캐시를 선택적으로 해제할 수 있는 방법이 있습니다.
문서에서는 이를 다음과 같이 설명하고 있습니다
Using the
dynamic = 'force-dynamic'
orrevalidate = 0
route segment config options : This will skip the Full Route Cache and the Data Cache. Meaning components will be rendered and data fetched on every incoming request to the server. The Router Cache will still apply as it's a client-side cache.
이를 번역하면, "dynamic = 'force-dynamic' 또는 revalidate = 0 라우팅 세그먼트 구성 옵션을 사용하면 전체 경로 캐시와 데이터 캐시를 건너뜁니다. 즉, 서버로 들어오는 모든 요청에 대해 컴포넌트가 렌더링되고 데이터가 새로 페치됩니다. 라우터 캐시는 클라이언트 측 캐시이기 때문에 여전히 적용됩니다."라는 의미입니다.
이 설정들은 명시적으로 페이지나 레이아웃에 선언하여 그 하위에 있는 모든 API를 동적 렌더링하게 합니다. 즉, 페이지 전체의 서버 캐시인 풀라우트 캐시와 데이터 캐시를 모두 무효화할 수 있습니다.
데이터 캐시 로깅
14.1 버전부터 데이터 캐시를 로깅할 수 있는 기능이 추가되었습니다.
이제 캐시가 있었는지HIT
또는SKIP
요청된 전체 URL이 있는지 표시할 수 있습니다.// next.config.js module.exports = { logging: { fetches: { fullUrl: true, }, }, };
뒤에 자세히 데이터 캐시의 무효화 방법에 대해 알아겠습니다.
이미지를 보시면, dynamic = 'force-dynamic'
또는 revalidate = 0
옵션을 사용했음에도 불구하고 모든 API 요청에 대해 데이터 캐시가 무효화되지 않고 계속 사용되는 것을 확인할 수 있습니다. 이는 HIT 상태로 표시되어 데이터 캐시가 활성화된 것을 의미합니다.
Next.js 14에서는 기본적으로 SSG(Static Site Generation) 방식을 채택하고 있으며, 이로 인해 데이터 캐시의 기본 설정은 force-cache입니다. 이는 다음과 같은 코드에서 확인할 수 있습니다:
const res = await fetch('https://api.example.com/weather', { cache: 'force-cache' })
라고 직접 선언할수 있지만 const res = await fetch('https://api.example.com/weather')
이처럼 만약 설정하지 않더라도 기본적으로 force-cache가 적용됩니다. 즉, fetch 요청의 기본값은 캐시를 사용하도록 설정되어 있습니다.
이로 인해, dynamic = 'force-dynamic'
또는 revalidate = 0
를 사용하여도 데이터 캐시와 풀라우트 캐시가 무효화되지 않는 경우가 발생합니다. 이는 데이터 캐시의 기본 설정이 캐시를 사용하도록 되어 있기 때문입니다.
앞서 공부한 것처럼 데이터 캐시에 우선 순위가 있기 때문입니다.
또한 경로에 여러 API 요청 중 하나라도 동적 렌더링을 수행한다면, 해당 경로의 풀 라우트 캐시가 이미 무효화됩니다.
따라서, dynamic = 'force-dynamic'
또는 revalidate = 0
를 사용하지 않아도 경로의 일부가 동적으로 렌더링되면 전체 경로 캐시가 무효화됩니다. 이 옵션을 사용하는 것은 경로 내 모든 서버 캐시를 무효화한다는 것을 명시적으로 표시하기 위한 효과적인 방법입니다.
Next.js 팀도 이러한 문제를 인식하여, Next.js 15 릴리스 후보(RC)부터는 fetch 요청의 기본 캐시 속성을 force-cache에서 no-store로 변경하기로 결정했습니다. 이는 fetch 요청이 기본적으로 캐시되지 않음을 의미합니다.
이렇게 변경되면, dynamic = 'force-dynamic'
또는 revalidate = 0
를 사용하는 경우, fetch 요청이 기본적으로 캐시되지 않기 때문에 의도한 대로 동작할 가능성이 높아집니다. 즉, 해당 옵션들이 적용된 페이지나 레이아웃의 모든 API 요청이 최신 데이터를 가져오도록 보장됩니다.
이미지는 데이터 캐시의 작동 원리를 설명합니다. 외부 API를 사용하면, 이미지 속에 나타난 데이터 소스는 실제로 백엔드 서버에 위치하게 됩니다.
각각의 API의 데이터 캐시를 선택 해제하려면 fetch 요청 시에 cache 속성을 'no-store'로 설정하면 됩니다. 예를 들어:
const res2 = await fetch('https://api.example.com/weather',
{ cache: 'no-store' });
// 이 요청은 데이터 캐시를 사용하지 않음
이렇게 하면 해당 요청에 대해 데이터 캐시가 사용되지 않고, 매번 새로운 데이터를 가져오게 됩니다.
이처럼 데이터 캐시를 선택 해제하면 풀 라우트 캐시도 무효화됩니다. 데이터 캐시와 풀 라우트 캐시는 모두 서버 캐시로 분류됩니다. 서버 캐시의 무효화를 통해 우리는 동적으로 데이터를 페칭하고 페이지를 렌더링하는 SSR(Server-Side Rendering)을 구현할 수 있습니다.
데이터 캐시를 선택 해제: 특정 fetch 요청에 대해 cache: 'no-store'를 설정하여 캐시를 사용하지 않도록 합니다.
풀 라우트 캐시의 무효화: 데이터 캐시가 무효화되면, 풀 라우트 캐시도 함께 무효화됩니다.
데이터 캐시와 풀 라우트 캐시는 서버 캐시로서, 이를 무효화하면 SSR을 통해 항상 최신 데이터를 기반으로 페이지를 렌더링할 수 있습니다.
SSG (정적 사이트 생성)와 SSR (서버 사이드 렌더링)을 Next.js에서 어떻게 사용하는지에 대해 배웠습니다.
그렇다면 ISR (Incremental Static Regeneration)은 어떻게 사용할 수 있을까요?
ISR은 Next.js의 기본 캐시 전략인 SSG를 기반으로 하되, 특정 조건에 따라 새로운 데이터를 가져와 페이지를 업데이트할 수 있도록 합니다. 기본적으로는 캐시된 데이터를 사용하지만, 데이터가 주기적으로 업데이트되어야 하거나, 사용자의 특정 액션에 따라 실시간으로 업데이트가 필요할 때 유용합니다. 예를 들어, 데이터가 1시간마다 업데이트되거나, 사용자가 특정 이벤트를 트리거할 때 새로운 데이터를 로드해야 하는 경우가 이에 해당합니다.
이런 경우, 데이터 캐시를 무효화하여 SSR 방식으로 페이지를 동적으로 렌더링할 수 있습니다. 이를 '데이터 재검증'이라고 합니다.
캐시된 데이터는 다음 두 가지 방법으로 재검증될 수 있습니다.
- 시간 기반 재검증(Time-based Revalidation):
일정 시간이 지난 후 새로운 요청이 발생한 후 데이터를 재검증합니다. 이는 자주 변경되지 않고 최신성이 중요하지 않은 데이터에 유용합니다.
- 주문형 재검증:
이벤트(예: 양식 제출)를 기반으로 데이터를 재검증합니다. 주문형 재검증에서는 태그 기반 또는 경로 기반 접근 방식을 사용하여 데이터 그룹을 한 번에 재검증할 수 있습니다. 이는 가능한 한 빨리 최신 데이터를 표시하려는 경우에 유용합니다(예: 헤드리스 CMS의 콘텐츠가 업데이트되는 경우).
Next.js에서는 일정 간격으로 데이터를 재검증하여 최신 상태를 유지할 수 있습니다. 이를 위해 fetch 요청에서 리소스의 캐시 수명을 초 단위로 설정할 수 있는 next.revalidate 옵션을 사용합니다.
예를 들어, 데이터를 매시간 재검증하려면 다음과 같이 설정할 수 있습니다:
// 최대 1시간마다 데이터 재검증
fetch('https://...', { next: { revalidate: 3600 } });
이 설정은 해당 리소스의 데이터가 캐시된 후 3600초(1시간) 동안 유효함을 의미합니다. 1시간이 지나면 데이터가 자동으로 재검증되고, 다음 요청 시 새로운 데이터가 가져와집니다.
시간 기반 재검증 작동 방식
- 가져오기 요청이 처음
revalidate
호출되면 외부 데이터 소스에서 데이터를 가져와 데이터 캐시에 저장합니다.- 지정된 기간(예: 60초) 내에 호출되는 모든 요청은 캐시된 데이터를 반환합니다.
- 해당 기간이 지난 후에도 다음 요청은 여전히 캐시된(현재는 오래된) 데이터를 반환합니다.
- Next.js는 백그라운드에서 데이터 재검증을 실행합니다.
- 데이터를 성공적으로 가져오면 Next.js는 데이터 캐시를 새로운 데이터로 업데이트합니다.
- 백그라운드 재검증이 실패하면 이전 데이터는 변경되지 않은 상태로 유지됩니다.
필요에 따라 경로( revalidatePath
) 또는 캐시 태그( revalidateTag
)를 통해 데이터의 유효성을 다시 검사할 수 있습니다.
주문형 재검증 작동 방식
- 요청 이 처음
fetch
호출되면 외부 데이터 소스에서 데이터를 가져와 데이터 캐시에 저장합니다.- 요청 시 유효성 재검사가 트리거되면 해당 캐시 항목이 캐시에서 제거됩니다.
- 이는 새로운 데이터를 가져올 때까지 오래된 데이터를 캐시에 보관하는 시간 기반 재검증과는 다릅니다.
- 다음에 요청이 이루어지면 다시 캐시가 되며
MISS
데이터는 외부 데이터 소스에서 가져와 데이터 캐시에 저장됩니다.
Next.js에서는 특정 데이터 항목을 태그로 구분하여 캐시를 관리할 수 있습니다. revalidateTag 기능을 사용하면 지정된 태그와 관련된 캐시 항목을 재검증하거나 무효화할 수 있습니다. 이 방법은 특정 조건이나 이벤트가 발생했을 때 해당 데이터만 재검증하고 싶을 때 유용합니다.
데이터를 가져올 때 tags 옵션을 사용하여 캐시 항목에 태그를 설정할 수 있습니다:
// 태그를 설정하여 데이터를 캐시합니다.
fetch('https://...', { next: { tags: ['a', 'b', 'c'] } });
위 코드에서 데이터를 a, b, c라는 태그와 함께 캐시합니다.
이후, 특정 태그와 관련된 캐시 항목을 무효화하려면 revalidateTag 함수를 호출합니다:
// 특정 태그와 관련된 항목을 재검증합니다.
revalidateTag('a');
위 코드는 태그 a와 관련된 모든 캐시 항목을 재검증합니다.
revalidateTag를 사용하는 위치에 따라 두 가지 방법이 있습니다:
1.경로 처리기 (Route Handlers):
2.서버 작업 (Server Actions):
revalidatePath는 특정 경로의 데이터를 수동으로 재검증하고 해당 경로 아래의 경로 세그먼트를 다시 렌더링할 수 있는 기능입니다. 이 메서드를 호출하면 경로에 있는 모든 데이터 캐시가 무효화되고, 결과적으로 전체 경로 캐시도 무효화됩니다. 이를 통해 최신 데이터를 사용하여 경로를 다시 렌더링할 수 있습니다.
// 루트 경로('/')의 데이터를 재검증합니다.
revalidatePath('/');
위 코드를 사용하면 루트 경로와 관련된 모든 데이터 캐시가 재검증되고, 해당 경로의 전체 페이지가 다시 렌더링됩니다.
revalidatePath 도 revalidateTag 와 마찬가지로
경로 처리기 (Route Handlers)와 서버 작업 (Server Actions)에서 사용가능한 함수입니다.
주문형 재검증(On-demand Revalidation)은 revalidateTag와 revalidatePath 함수를 통해 구현할 수 있습니다. 이 두 가지 기능은 특정 경로나 캐시 태그를 기반으로 데이터를 수동으로 재검증하는 매우 중요한 역할을 합니다.
이 함수들은 이벤트 트리거로 사용할 수 있습니다. 즉, 양식 제출, 버튼 클릭 등의 사용자 상호작용 또는 외부 이벤트가 발생할 때 데이터를 무효화하고 최신 상태로 유지하는 데 사용할 수 있습니다. 이러한 기능은 실제 서비스에서 매우 유용하며, 캐시된 데이터를 유연하게 관리할 수 있게 해줍니다.
이 두 기능의 API는 반드시 Next.js 문서에서 읽어보고, 실제로 재현해보기를 권장합니다. 이를 통해 Next.js에서 제공하는 강력한 캐싱 및 데이터 재검증 메커니즘을 더 깊이 이해하고 활용할 수 있을 것입니다.
시간 기반 재검증:
지정된 시간 간격마다 데이터의 유효성을 자동으로 다시 검사하여, 주기적으로 최신 데이터를 반영하도록 설정합니다. 예를 들어, fetch 요청에 next.revalidate 옵션을 사용하여 리소스의 캐시 수명을 설정할 수 있습니다.
// Revalidate at most every hour
fetch('https://...', { next: { revalidate: 3600 } });
주문형 재검증:
특정 이벤트나 사용자 상호작용(예: 양식 제출, 버튼 클릭)에 따라 데이터의 유효성을 수동으로 다시 검사합니다. 이를 위해 revalidateTag 또는 revalidatePath 함수를 사용하여 특정 태그나 경로와 관련된 캐시 항목을 제거하고 다시 렌더링합니다.
// Revalidate entries with a specific tag
revalidateTag('tag-name');
// Revalidate a specific path
revalidatePath('/path-to-revalidate');
ISR의 이점:
ISR을 사용하면 기본적으로 캐시된 데이터를 사용하면서도 필요한 경우에만 최신 데이터를 반영할 수 있습니다. 이는 데이터가 자주 변경되지만 실시간으로 업데이트할 필요가 없는 페이지에 특히 유용합니다.
* 중요 :
revalidateTag 와 revalidatePath를 사용하면 서버 캐시뿐만 아니라, 클라이언트 캐시인 라우터 캐시도 재검증할 수 있습니다. 이는 클라이언트 측에서 캐시된 데이터의 유효성을 검사하여, 항상 최신 상태의 데이터를 보여주기 위해 사용됩니다.
위의 이미지는 Next.js 14의 기본 캐시 전략을 시각적으로 설명하고 있습니다.
이 이미지에서 알 수 있듯이, Next.js는 첫 방문 시 서버 캐시(풀 라우트 캐시 및 데이터 캐시)를 사용하지만, 이후의 모든 재방문 시에는 클라이언트 측 라우터 캐시에서 페이지를 로드합니다. 이로 인해 서버사이드에서 렌더링 하는것이 정적이던 동적이던 우리는 클라이언트의 라우터 캐시에 저장된 서버 구성 요소 페이로드 결과 통해 페이지를 보게 됩니다.
Next.js는 사용자가 경로 사이를 탐색할 때 방문한 경로 세그먼트를 클라이언트 측 라우터 캐시에 저장하고, 사용자가 탐색할 가능성이 있는 경로를 미리 가져와(pre-fetching) 탐색 성능을 향상시킵니다. 특히, 컴포넌트가 뷰포트에 들어오면 Next.js는 해당 링크의 경로를 미리 가져와 준비합니다.
이러한 캐싱과 미리 가져오기 메커니즘 덕분에, 사용자는 더욱 원활하고 빠른 탐색 경험을 할 수 있습니다.
이러한 방식으로, Next.js는 사용자에게 빠르고 일관된 탐색 경험을 제공하면서도 서버와 클라이언트 간의 효율적인 자원 관리를 실현합니다.
라우터 캐시와 전체 경로 캐시의 차이점 :
라우터 캐시는 사용자 세션 동안 브라우저에 React 서버 구성 요소 페이로드를 임시로 저장하는 반면, 전체 경로 캐시는 여러 사용자 요청에 걸쳐 서버에 React 서버 구성 요소 페이로드와 HTML을 지속적으로 저장합니다.
전체 경로 캐시는 정적으로 렌더링된 경로만 캐시하는 반면, 라우터 캐시는 정적 및 동적으로 렌더링된 경로 모두에 적용됩니다.
Next.js 14.1 버전에서는 라우터 캐시가 기본적으로 유지됩니다.
물론, 라우터 캐시는 유효 기간을 가집니다. 기본적으로 설정된 시간 동안 캐시가 유지되며, 이 기간이 지나면 자동으로 무효화됩니다. 하지만 14.1 버전에서는 라우터 캐시를 완전히 비활성화할 수 있는 방법은 없기 때문에, 몇 가지 문제가 발생할 수 있습니다.
예를 들어, 특정 경로에 대한 첫 방문 후 해당 페이지의 데이터가 업데이트되었다고 가정해봅시다. 이 후에 동일한 페이지를 다시 방문하면, 서버 캐시를 재검증하거나 무효화하여 SSR 또는 ISR을 사용하더라도 사용자는 업데이트된 데이터를 보지 못할 수 있습니다. 이것은 사용자 경험에 있어 큰 불편을 초래할 수 있습니다.
이 문제에 대해 많은 개발자들이 이슈를 제기해왔고, 이에 따라 Next.js 팀은 라우터 캐시의 유효 기간을 조절할 수 있는 실험적 기능을 14.2 버전에 도입했습니다. 이 기능을 통해 라우터 캐시의 시간을 조절할 수 있게 되었습니다.
더 나아가, Next.js 15 RC 버전에서는 라우터 캐시가 기본적으로 캐시되지 않습니다. 즉, 사용자가 원하는 경우에만 라우터 캐시를 사용할 수 있으며, 캐시의 지속 시간을 직접 설정할 수 있습니다.
The cache is stored in the browser's temporary memory. Two factors determine how long the router cache lasts:
prefetch={null}
or unspecified): 30 secondsprefetch={true}
or router.prefetch
): 5 minutesWhile a page refresh will clear all cached segments, the automatic invalidation period only affects the individual segment from the time it was prefetched.
위의 내용처럼 라우터 캐시의 유효 기간은 프리페치된 방식에 따라 다릅니다. Next.js 14.1 버전까지는 이 라우터 캐시의 유효 시간을 직접 지정할 수 없습니다.
기본적으로 구성 요소는 자동으로 전체 경로 캐시에서 경로를 미리 가져오고, React Server 구성 요소 페이로드를 라우터 캐시에 추가합니다. 이를 우리는 프리페치라고 합니다. 라우터 캐시는 프리페치의 영향을 받기 때문에 프리페치 캐시라고 불리기도 합니다.
링크 태그의 속성이 prefetch={null} 또는 명시되어 있지 않다면 라우터 캐시의 유효 기간은 30초입니다.
prefetch={true}로 명시한다면 저장된 라우터 캐시는 5분간 지속됩니다.
prefetch={null}
또는 unspecified (기본값):prefetch={true}
:Next.js 14에서 동적 경로의 프리페치 동작은 정적 경로와 약간 다릅니다. prefetch
속성이 null
로 설정된 경우, Next.js는 동적 경로를 처리할 때 부분적인 프리페치를 수행합니다. 이 경우의 동작 방식을 이해하기 위해 예를 들어보겠습니다.
예제: /product/[id]
경로
app/product/[id]/page.js
: 특정 제품을 보여주는 페이지.app/product/[id]/loading.js
: 데이터 로딩 시 표시되는 로딩 컴포넌트./product/[id]
는 동적 경로입니다. 이 경로는 [id]
라는 변수를 포함하며, 이는 product
ID와 같은 동적 데이터를 나타냅니다.null
일 때, Next.js는 사용자가 /product/[id]
로 이동할 가능성이 있을 때 이 경로에 대한 프리페치를 시작합니다.product
)에 대한 정보를 미리 가져오지만, 모든 데이터 요청까지는 수행하지 않습니다./product/[id]
경로에 포함된 가장 가까운 로딩 경계를 찾아, 이 세그먼트까지의 데이터를 미리 가져옵니다.app/product/[id]/loading.js
파일이 있으면, Next.js는 이 로딩 파일을 먼저 프리페치합니다./product/[id]
경로의 구체적인 데이터(예: product
상세 정보)는 프리페치하지 않고, 사용자 요청 시에 로드됩니다.prefetch={true}
를 사용하면 클라이언트 측 내비게이션의 성능을 개선할 수 있지만, 네트워크 사용량, 서버 부하, 메모리 사용, 그리고 최신 데이터 유지의 어려움 등 여러 가지 단점을 동반할 수 있습니다. 이러한 단점을 줄이기 위해서는 상황에 맞게 프리페칭을 적절히 조절하는 것이 중요합니다. 사용자의 실제 사용 패턴, 데이터의 중요성 및 변동성, 네트워크 및 서버 자원의 제약 조건 등을 고려하여 프리페칭 전략을 설계하는 것이 좋습니다.prefetch={false}
속성을 사용하면 프리페치를 비활성화할 수 있습니다. 하지만 프리페치 기능을 비활성화해도 여전히 라우터 캐시는 30초 동안 유효합니다. 즉, 사용자가 페이지를 방문하면 방문한 페이지에 대한 데이터는 라우터 캐시에 저장됩니다.
prefetch={false}:
Next.js 14.1 버전까지는 라우터 캐시를 완전히 선택해제(opt-out)할 수 있는 방법이 없습니다. 그러나 라우터 캐시를 무효화하는 몇 가지 방법이 있습니다. 이를 통해 유효 기간이 남은 라우터 캐시를 무효화 하고 최신 데이터를 반영하도록 할 수 있습니다:
1. router.refresh:
2. revalidatePath:
3. revalidateTag:
라우터 캐시
캐싱은 빠르고 신뢰할 수 있는 웹 애플리케이션을 구축하는 데 중요한 요소입니다. 특히 변경이 발생할 때, 사용자와 개발자는 캐시가 최신 상태로 업데이트되기를 기대합니다. Next.js는 앱 라우터에서 캐싱 경험을 개선하기 위해 새로운 기능들을 실험하고 있습니다.
클라이언트 측 라우터 캐시는 방문한 경로와 프리페치된 경로를 클라이언트에서 캐싱하여 빠른 탐색 경험을 제공하는 캐싱 레이어입니다.
커뮤니티 피드백에 기반하여, 클라이언트 측 라우터 캐시의 무효화 기간을 설정할 수 있는 staleTimes 옵션을 실험적으로 추가했습니다.
기본적으로, 프리페치된 경로는 (프리페치 속성이 설정되지 않은 <Link>
컴포넌트를 사용한 경우) 30초 동안 캐시됩니다. 프리페치 속성이 true
로 설정된 경우, 5분 동안 캐시됩니다. staleTimes를 사용하여, next.config.js
파일에서 이러한 기본값을 덮어쓰고 사용자 정의 재검증 시간을 정의할 수 있습니다.
next.config.ts
파일에서 설정:
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // 동적 경로의 캐시 기간을 30초로 설정
static: 180, // 정적 경로의 캐시 기간을 180초로 설정
},
},
};
module.exports = nextConfig;
위 설정을 통해 동적 경로는 30초, 정적 경로는 180초 동안 캐시됩니다.
데이터캐시
Next.js App Router는 고성능을 제공하기 위해 기본적으로 강력한 캐싱 기본 설정을 적용하며, 필요시 선택적으로 캐싱을 해제할 수 있는 옵션을 제공했습니다.
여러분의 피드백을 바탕으로, 우리는 캐싱 휴리스틱스와 이들이 Partial Prerendering (PPR) 및 fetch를 사용하는 타사 라이브러리와 어떻게 상호작용하는지를 재평가했습니다.
Next.js 15에서는 fetch 요청, GET 라우트 핸들러, 클라이언트 라우터 캐시의 캐싱 기본값을 "기본적으로 캐싱"에서 "기본적으로 캐싱되지 않음"으로 변경하고 있습니다. 이전의 캐싱 동작을 유지하고자 한다면, 여전히 캐싱을 선택할 수 있습니다.
우리는 향후 몇 달 동안 Next.js의 캐싱 기능을 계속 개선할 것이며, Next.js 15의 GA 발표에서 더 많은 세부 사항을 공유할 것입니다.
Next.js는 서버 사이드 fetch 요청이 프레임워크의 지속적인 HTTP 캐시와 어떻게 상호작용하는지 구성하기 위해 Web fetch API의 캐시 옵션을 사용합니다:
fetch('https://...', { cache: 'force-cache' | 'no-store' });
Next.js 14에서는 캐시 옵션이 제공되지 않은 경우 기본적으로 force-cache가 사용되었습니다(동적 함수 또는 동적 구성 옵션이 사용되지 않는 한).
Next.js 15에서는 캐시 옵션이 제공되지 않은 경우 기본적으로 no-store가 사용됩니다. 이는 fetch 요청이 기본적으로 캐시되지 않음을 의미합니다.
여전히 fetch 요청을 캐시하려면 다음 방법을 사용할 수 있습니다:
라우터캐시
클라이언트 라우터 캐시는 더 이상 기본적으로 페이지 구성 요소를 캐시하지 않습니다.
Next.js 14.2.0에서는 실험적인 기능을 도입했습니다.staleTimes
라우터 캐시의 사용자 정의 구성을 허용하는 플래그.
Next.js 15에서는 이 플래그에 계속 액세스할 수 있지만 페이지 세그먼트에 대한 기본 동작 staleTime
을 변경하고 있습니다. 0
즉, 앱을 탐색할 때 클라이언트는 항상 탐색의 일부로 활성화되는 페이지 구성 요소의 최신 데이터를 반영합니다. 그러나 여전히 변경되지 않은 중요한 동작이 있습니다.
staleTimes.static
5분(또는 구성 값 ) 동안 캐시된 상태로 유지됩니다.다음 구성을 설정하여 이전 클라이언트 라우터 캐시 동작을 선택할 수 있습니다.
next.config.ts
파일에서 설정:
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 30
},
},
};
module.exports = nextConfig;
프로젝트에서는 사용자들이 좋아요와 싫어요를 투표할 수 있는 기능이 있습니다. 이 기능을 구현하기 위해, 백엔드 서버에 GET 요청을 보내어 전체 좋아요 수를 받아옵니다.
실시간 데이터를 표시하기 위해 데이터 캐시(서버 캐시)를 사용하지 않고 동적 렌더링을 적용하기로 결정했습니다. 따라서, 항상 최신 데이터를 가져오기 위해 fetch 요청 시 cache 옵션을 ’no-store’로 설정했습니다.
//page.tsx
import ProductVoteButtons from './ProductVoteButtons';
import { getProductVoteCounts } from './actions';
export default async function Vote({ id }: { id: string }) {
const voteCounts = await getProductVoteCounts(String(id));
return <ProductVoteButtons id={id} voteCounts={voteCounts} />;
}
// 버튼 컴포넌트
'use client';
import { useState } from 'react';
import { VoteCounts } from '@/types';
import { updateVoteCount } from './actions';
export default function ProductVoteButtons({ id, voteCounts: initialVoteCounts }: { id: string; voteCounts: VoteCounts }) {
const [voteCounts, setVoteCounts] = useState(initialVoteCounts);
const handleVote = async (isLiked: string, e: any) => {
e.preventDefault();
const updatedVoteCounts = await updateVoteCount(id, isLiked);
setVoteCounts(updatedVoteCounts);
};
return (
<>
<div className={`item-reaction ${'isLiked' in voteCounts ? 'on' : ''}`}>
<button type="button" className="btn-like" onClick={(e) => handleVote('true', e)}>
<span>
<em className="score">
좋아요 {voteCounts.voteLikeCount > 0 && voteCounts.voteLikeCount}
</em>
</span>
</button>
</div>
</>
);
}
// 서버액션 (함수)
'use server';
import { cookies } from 'next/headers';
import { fetchProductVoteCounts, postVote } from '@/lib/serverApi';
import { isAuth } from '@/lib/serverAuth';
export async function updateVoteCount(id: string, isLiked: string) {
const cookieStore = cookies();
const sessionid = cookieStore.get('sessionid');
let userId;
if (isAuth() && sessionid) {
userId = {
cookie: `${sessionid.name}=${sessionid.value}`,
};
}
return await postVote(id, isLiked, userId);
}
export async function getProductVoteCounts(id: string) {
const cookieStore = cookies();
const sessionid = cookieStore.get('sessionid');
let userId;
if (isAuth() && sessionid) {
userId = {
cookie: `${sessionid.name}=${sessionid.value}`,
};
}
return await fetchProductVoteCounts(String(id), userId);
}
//api.tsx
export async function fetchProductVoteCounts(id: string, userId?: any): Promise<VoteCounts> {
const endpoint = `/api/v2/product/${id}/vote`;
return get<VoteCounts>({ userId, endpoint, cache: 'no-store' });
}
export function postVote(id: string, payload: any, userId?: any): Promise<VoteCounts> {
const endpoint = `/api/v2/product/${id}/vote`;
const vote = {
isLiked: payload,
};
return post<VoteCounts>({ userId, endpoint, payload: vote });
}
이슈 :
그러나, 문제는 좋아요를 투표한 후 다른 페이지로 이동했다가 다시 원래 페이지로 돌아왔을 때 발생했습니다. 백엔드 서버에서 받아오는 데이터는 업데이트되었지만, 사용자는 업데이트된 좋아요 수를 볼 수 없었습니다. 이는 클라이언트 캐시(라우터 캐시)에 이전 렌더링 결과가 저장되어 있기 때문이었습니다.
이를 해결하기 위해, 우리는 주문형 재검증(on-demand revalidation)을 사용했습니다. 투표 수를 가져오는 GET 요청 API에 태그를 추가하고, 사용자가 투표를 할 때마다 revalidateTag
함수를 호출하여 라우터 캐시를 무효화했습니다. 이로 인해 사용자가 투표를 할 때마다 라우터 캐시가 지워지고, 페이지가 다시 로드될 때 최신 데이터를 반영할 수 있게 되었습니다.
// 서버액션 (함수)
export async function updateVoteCount(id: string, isLiked: string) {
const cookieStore = cookies();
const sessionid = cookieStore.get('sessionid');
let userId;
if (isAuth() && sessionid) {
userId = {
cookie: `${sessionid.name}=${sessionid.value}`,
};
}
revalidateTag(TAGS.vote);
return await postVote(id, isLiked, userId);
}
//api.tsx
export async function fetchProductVoteCounts(id: string, userId?: any): Promise<VoteCounts> {
const endpoint = `/api/v2/product/${id}/vote`;
return get<VoteCounts>({ userId, endpoint, tags: [TAGS.vote] });
}
우리 프로젝트에서는 게시글 리스트 페이지에서 각 게시글에 대한 조회수를 표시합니다. 이 조회수는 사용자가 게시글 페이지에 진입하여 상품 상세 정보를 가져오는 GET 요청을 할 때 백엔드 서버에서 증가하게 됩니다.
이슈:
마이그레이션 후, 게시글의 조회수가 평균보다 현저히 낮게 나오는 현상이 발생했습니다. 처음에는 단순히 사용자의 관심이 줄어들었을 것이라고 생각했습니다. 하지만, Google Analytics 4 (GA4)를 통해 추적한 결과, 동일한 상품 페이지에 접근한 사용자 수는 약 500명인데, 백엔드 서버에 기록된 조회수는 약 200건에 불과했습니다. 여기서 조회수는 프론트 서버에서 api(get) 를 이용해 데이터를 요청한 횟수가 기록됩니다.
우리는 실시간성을 보장하기 위해 조회수를 가져오는 API 요청에 데이터 캐시(서버 캐시)를 사용하지 않기로 결정하고, fetch 요청에서 ‘no-store’를 설정했습니다. 따라서, 모든 사용자는 첫 방문 시 반드시 서버로 GET 요청을 보내서 최신 데이터를 가져와야 했습니다. 하지만 GA4에서는 약 500명의 사용자가 페이지를 방문했다고 기록되었음에도, 서버의 조회수는 200건에 불과했습니다. 나머지 약 300명은 어떻게 게시글 페이지를 볼 수 있었을까요?
디버그:
오류 메시지를 통해 문제의 원인을 파악할 수 있었습니다:
⚠ fetch for https://www.sample.com/api/v2/culture/6095/products?? on / specified "cache: no-store" and "revalidate: 3600", only one should be specified.
return fetch(fetchUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
cache: 'no-store',
next: {
revalidate: 3600,
},
})
동적 렌더링을 위해 cache: 'no-store'를 사용하면서도, 동시에 시간 기반 검증을 위해 next: { revalidate: 3600 } 옵션을 함께 사용하고 있었습니다. 이에 대해 Next.js 문서에서는 다음과 같이 명확히 설명하고 있습니다:
Good to know:
- If an individual fetch() request sets a revalidate number lower than the default revalidate of a route, the whole route revalidation interval will be decreased.
- If two fetch requests with the same URL in the same route have different revalidate values, the lower value will be used.
- As a convenience, it is not necessary to set the cache option if revalidate is set to a number since 0 implies cache: 'no-store' and a positive value implies cache: 'force-cache'.
- Conflicting options such as { revalidate: 0, cache: 'force-cache' } or { revalidate: 10, cache: 'no-store' } will cause an error.
네번째 줄을 보면 우리가 확인한 오류 메세지는 fetch 요청에서 cache: 'no-store'와 revalidate: 3600 옵션이 동시에 지정되어 충돌이 발생했음을 나타냅니다. 이 두 옵션은 상호 배타적이며, 둘 중 하나만 지정되어야 합니다. 이러한 충돌로 인해 예상치 못한 캐싱 동작이 발생하여 일부 사용자의 조회수가 제대로 기록되지 않았던 것입니다.
문서를 더 살펴보았지만, 이 두 가지 옵션 중 어떤 것이 우선 순위를 가지는지에 대한 명확한 설명은 찾을 수 없었습니다. 하지만 분명한 것은 cache: 'no-store'
와 revalidate
옵션을 함께 사용하면 충돌이 발생하고, 이러한 충돌은 정확한 데이터 제공을 방해할 수 있다는 것입니다.
이번 이슈에서, 우리는 cache: 'no-store'
를 사용하여 SSR을 의도했지만, revalidate: 3600
옵션이 설정되어 있어 데이터 캐시가 사용되었습니다. 그 결과, 일부 사용자는 서버로부터 새로운 데이터를 요청하지 않고, 캐시된 데이터를 보게 되었습니다. 이로 인해, 실제 방문한 유저 수에 비해 조회수가 낮게 기록되었습니다.
이를 해결하기 위해, next: { revalidate: 3600 }
옵션을 제거하여 명확하게 cache: 'no-store'
만을 사용하도록 수정했습니다. 그 결과, 모든 사용자가 페이지를 방문할 때마다 서버로부터 새로운 데이터를 가져오게 되어, 조회수가 정확하게 반영되었습니다.
이와 같은 경험을 통해, 캐시 옵션 설정 시 충돌을 피하고 의도한 캐싱 동작을 유지하는 것이 얼마나 중요한지 깨달았습니다. 앞으로는 이러한 캐시 설정을 더욱 신중하게 다루어야 할 것입니다. 그리고 이번 경험으로 데이터 캐시의 존재를 확인하며 이를 이용해 프론트 서버의 부하를 낮출 수 있겠다는 확신을 얻었습니다.
현재 서비스는 실시간 데이터를 처리하기 위해 대부분 데이터 캐시(서버 캐시)를 기본적으로 no-store로 설정하여 캐싱을 사용하지 않고 있습니다. 그러나 이러한 방식은 프론트 서버에 과도한 부하를 초래할 수 있습니다.
실시간성을 유지하면서도 서버 부하를 줄이기 위해, Next.js의 ISR(Incremental Static Regeneration)을 활용할 수 있습니다. 이는 SSG(Static Site Generation)를 기본으로 하면서, 데이터가 업데이트될 때 해당 데이터의 캐시를 무효화하여 최신 데이터를 반영하는 방식입니다.
앞서 학습한 대로, 데이터 캐시(서버 캐시)의 무효화는 경로 처리기와 서버 액션에서 동작할 수 있습니다. 이 중 서버 액션은 사용자가 어떤 동작을 해야 하기 때문에, 자동화된 경로 처리기를 사용하여 데이터 캐시를 무효화하는 방법, 웹훅을 구현해보겠습니다.
우선, 최상위 폴더에 api
폴더와 그 하위에 route.ts
파일을 생성합니다.
api
|-- route.ts
route.ts
파일 설정route.ts
파일은 Next.js의 API 라우트로, 서버에서 HTTP 요청을 처리할 수 있는 엔드포인트를 제공합니다. 다음은 route.ts
파일의 예시입니다:
import { NextRequest, NextResponse } from 'next/server';
import { revalidate } from './actions';
// export const runtime = 'edge';
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}
이 코드에서는 POST 요청을 처리하기 위한 API 엔드포인트를 정의하고 있습니다. 여기서 POST 함수는 revalidate함수를 호출합니다. 이 함수는 백엔드 서버에서 우리의 프론트엔드 서버로 POST 요청을 보낼 때 사용됩니다.
revalidate 함수는 웹훅을 통해 받은 요청을 처리하고, 해당 데이터의 캐시를 무효화하는 역할을 합니다. 다음은 revalidate 함수의 예시입니다:
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { TAGS } from '@/types';
export async function revalidate(req: NextRequest): Promise<NextResponse> {
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const secretKey = process.env.REVALIDATION_SECRET || 'eddy-test';
const topic = headers().get('topic') || 'unknown';
// xconst secret = req.nextUrl.searchParams.get("secret");
const secret = req.nextUrl.searchParams.get('secret');
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== secretKey) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 200 });
}
if (!isProductUpdate) {
return NextResponse.json({ status: 200 });
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
console.log('캐시 무효화');
return NextResponse.json({ status: 202, revalidated: true, now: Date.now() });
}
이 함수는 다음과 같은 작업을 수행합니다:
POST
요청을 수신합니다.secret
키를 확인하여 유효성을 검증합니다.products/create
, products/delete
, products/update
)에 해당하는 경우, 지정된 태그(TAGS.products
)를 기반으로 캐시를 무효화합니다.POST
요청을 보내도록 설정합니다. 예를 들어, 상품이 생성, 삭제 또는 업데이트될 때 해당 웹훅이 트리거됩니다.POST
요청을 수신하고, 지정된 태그에 따라 해당 데이터의 캐시를 무효화합니다.이렇게 구현함으로써, 상품 정보나 리스트와 같은 데이터를 SSG로 유지하면서도, 관리자가 데이터를 업데이트하면 백엔드 서버에서 프론트엔드 서버로 웹훅 요청을 보내 캐시를 무효화하고, 최신 데이터를 반영할 수 있습니다.
이 방식은 실시간성이 필요한 데이터를 효율적으로 관리할 수 있으며, 프론트엔드 서버의 부하를 줄이면서도 사용자에게 최신 데이터를 제공할 수 있습니다.
//page.tsx
export default async function Page({ params }: { params: { id: string } }) {
const product = await getProductDetailById(params.id);
return <></>;
}
export async function getProductDetailById(id: string): Promise<ProductInfo> {
const endpoint = `/api/v2/product/id/${id}`;
return get<ProductInfo>({ endpoint, tags: [TAGS.products] });
}