Next.js의 캐시에 대해 잘 모른다. 아는 것 같은데 잘 모른다. 문제를 해결하며 Next.js 캐시를 뿌셔보겠다.
문제 상황
reservation-list
페이지를 SSR 형식으로 구현했다. 즉 페이지에 접근할 때마다 리패칭이 이루어지도록 fetch()
함수에는 no-store
를 적용했고, page.tsx
에는 dynamic = 'force-dynamic'
, revalidate=0
을 적용했다.
심지어는 tanstack query
도 이용하지 않았기 때문에, 리패칭이 이루어지길 바랬지만 전혀 일어나지 않는다!
그 말은 어디선가 캐싱이 이루어지고 있다는 말.. 이유를 찾아봐야 한다.
리액트의 동작방식
리액트는 bundle.js
라는 하나의 파일로 동작한다.
ES module
을 사용해서 우리는 여러 js 파일로 코드를 작성한다. 빌드를 하면(혹은 로컬서버를 열면) 번들러는 이들을 하나의 파일로 만들어 bundle.js
를 만든다.
이 번들러는 src/index.js
라는 시작점을 알고 있고, 여기에 import
되어있는 파일들을 확인하며 그래프 형식으로 탐색한다. 이후 babel
, tree-shaking
등을 거친 후 bundle.js
가 만들어진다.
이 bundle.js
는 디스크에서 관리된다. 개발환경에서는 이는 메모리(Node.js 프로세스 메모리(내 pc의 RAM))에서 관리되어 지는데, 배포환경에서 이는 디스크(vercel, netlify등)에 저장되어 실행된다.
가져오는 방식은 동일하다. api 요청처럼 http 메서드 요청을 날린다. /bundle.js
로 요청을 날리면 서버에서 값을 디스크로부터 받아와 웹에 서빙해준다. 그럼 브라우저내장 메모리에 이를 저장하고 파싱하여 실행한다.
리액트가 실행되면 index.html
를 읽고 <script src="bundle.js"></script>
등을 통해 js파일을 파싱한다.
그럼 브라우저엔진은 html을 해석해 메모리에 트리 자료구조 형태로 저장하는데, 이를 DOM
이라 한다. document.method()
형식으로 이 돔에 접근해 js를 수정할 수 있다.
리액트는 이 브라우저엔진에 virtual DOM
이라는 추상화된 돔을 만든다. 그리고 변경사항이 발생하면 virtual DOM
을 변경하고, 실제 돔과 차이점을 확인한 후 변경된 부분만 변경한다.
document.method()
로 실제 DOM을 조작하면 virtual DOM
은 이를 인지하지 못해 충돌이 일어날 수 있으니 리액트에서 이 방법은 지양된다.
이 방식은 페이지가 바뀔 때마다 컴포넌트가 새롭게 렌더링(마운트) 된다.
Next.js의 동작방식
14버전 기준으로 app 디렉터리가 라우팅 구조를 결정한다. 리액트와 달리 index.html
하나를 수정하지 않고 page.js
들이 각각의 엔트리 역할을 한다.
Next.js 역시 번들링이 일어난다. 개발서버에서는 내 pc의 next.js서버(node.js, 메모리)에 올라가고, 배포시에는 .next
에 저장된다. 실제 배포를 하게되면 디스크에 저장이 되고, 프로세스가 돌아가면 해당 서버환경(vercel 등)의 메모리에 올라가게 된다.
이를 통해 디스크(번들링)와 메모리(서버)가 혼합되어 사용된다.
SPA도 MPA도 아닌 SSR, SSG, CSR 혼용방식으로 사용된다.
전통적인(PHP)등의 MPA는 모든 html이 새롭게 렌더링 된다. Next.js에서는 초기로딩 시에는 페이지별 html을 받아오기 때문에 MPA처럼 동작한다.
하지만 페이지 라우팅 시, 만약 useRouter
같은 넥스트 훅을 사용하게 된다면 CC처럼 동작하여 클라이언트 라우팅을 수행해 SPA처럼 동작한다.
또한 페이지 간 이동 시, Hydration
, Partial re-rendering
을 거친다. pre-rendering
된 html에 Hydration
을 거치며 서버에 요청한 결과는 react의 리렌더링 방식을 거쳐 필요한 부분만 리렌더링 된다.
이를 통해 SPA처럼 빠른 화면전환을 체감한다.
SSR
매 요청마다 서버에서 페이지를 렌더링해 클라에 보내줌
요청 시마다 렌더링이 일어나 최신데이터를 잘 반영하고 성능이 좋고 보안이 강화되고 SEO에 좋으나 페이지 별 초기로딩시간이 CSR에 비해 길어질 수 있음, SSG에 비해 서버에 부하가 갈 수 있음
CSR
html, css, js를 한번에 받아오고 js를 통해 클라이언트(브라우저에) 렌더링 함
모든 요청 처리가 클라이언트에서 이루어지기에 첫 페이지 로딩 속도는 느리나 페이지 간 이동은 SSR보다 빠를 수 있음(번들링되어 있음), SEO에 좋지 않음(빈 index.html)
SSG
정적 생성 방식, 빌드 시 한번 렌더링되고 결과가 정적파일로 저장된다.
빌드 시 페이지가 생성, 요청에 대해 정적 페이지가 제공되어 페이지 로딩이 매우빠름, 변하지 않는 페이지에 좋음
기본 렌더링 방식
Next.js 14버전은 SSG를 사용한다. 즉 모든 요청을 캐싱하기 위해 최대한 노력한다.
Next.js의 캐싱
하나하나 살펴보면 어떻게 캐싱이 이루어지는 지 알 수 있다.
1. Request Memoization
서버에서 하나의 요청 /a
가 이루어질 때 요청에 대한 응답을 캐싱한다. 즉 메모이제이션의 지속시간은 하나의 요청이다.
2. Data cache
서버에서 요청이 이루어질 때, 그 응답을 캐싱한다. 1번과의 차이점은 캐시의 지속시간이 훨씬 길다는 것, 서버가 닫히기 전까지 라는 것이다.
이는 cache: no-store
config 설정을 통해 캐싱을 skip할 수 있다.
revalidate
재검증은 두 가지 방식으로 가능하다.
시간 기반 재검증
next: { revalidate: 3600 }
config설정을 해주면 일정시간이 지난 후 stale상태가 되어 동일한 요청이 들어오면 다시한번 요청을 보낸다.
온디멘드 재검증
요청에 tag를 걸어주고, revalidateTag
를 호출해 하면 해당 캐시는 purge
가 되어 다음 요청 시 새로운 요청을 보낸다.
3. Full route cache
빌드될 때(혹은 revalidate될때) 자동으로 렌더링하고 캐싱해주는 기능이다. 매 요청마다 새롭게 렌더링하는 대신 캐싱된 경로를 제공한다.
이를 이해하기 위해선 서버에서 react컴포넌트를 렌더링 시 어떻게 동작하는지 알아야 한다.
서버에서 react가 렌더링 된다면
서버컴포넌트는 RSC 페이로드
를 생성한다. RSC 페이로드는 서버컴포넌트의 렌더링 결과로 바이너리 형식으로 제공되며 react 컴포넌트 트리의 결과를 포함하는 형태이다. 이것을 통해 Hydration이 일어난다.
안에는 클라이언트 컴포넌트 플레이스홀더가 있어 클라이언트에서 js를 참조한다.
또한 RSC에서 CC로 전달하는 props가 들어있다.
그리고 서버에서 html을 렌더링하는데, RSC 페이로드
, html
을 서버는 캐싱하고 이를 Full route cache라 한다.
생각해볼 포인트는 Data cache는 Full route cache를 포함한다. 즉 Data cache가 삭제되면 Full route cache도 삭제되지만 Full route cache가 삭제된다고 해서 Data cache가 삭제되진 않는다.
이 캐싱을 해제하기 위해선 dynamic = 'force-dynamic'
혹은 revalidate = 0
를 적용한다. 단 Data cache는 삭제되지 않기 때문에, 원하는대로 동작하지 않는다.
여기까지가 내가 생각했던 부분인데, Next.js는 클라이언트 단에서 이루어지는 캐싱도 존재했고, 이게 SSR를 방해했다.
4. Route cache
사용자가 방문한 경로 세그먼트를 라우터 캐시에 저장 및 pre-fetching을 한다. 이 캐시는 정적/동적을 가리지 않고 저장한다.
이는 UX를 향상시키지만 원치않는 동작을 야기할 수 있다. 내 문제상황을 예시로 든다(문제상황을 분석해보자)
내 문제상황을 정확하게 분석해본다. reservation-list
에서 리패칭이 이루어지길 바라며 fetch()
함수에 no-store
를 적용한 것은 Data cache를 무효화 시킨 것이고 page.tsx
에 dynamic = 'force-dynamic'
, revalidate=0
를 적용한 것은 Full route Cache를 무효화 시킨 것이다.
서버단에서는 캐시가 무효화가 잘 되고 있다. 하지만 클라이언트 단에서 라우트 캐시를 관리하고 있고 이는 무효화되지 않았기 때문에 업데이트가 발생하더라도 리패칭이 되지 않아 제대로 확인할 수 없었다.
라우트캐시의 유효기간은 디폴트 30초, prefetch={true}
를 걸어주면 5분이 된다. 하지만 이 방법은 원하는 방법이 아니다. 저장하지 않는 방법을 찾고있으니깐.
14.1 이하버전에는 이 유효기간을 다룰 방법이 없다. 다른 방법들이 있긴 한데 예외가 존재한다.
router.refresh()
클라이언트 컴포넌트에만 사용할 수 있어 페이지 이동 간에 사용하기에는 부적합하다.
revalidatePath(), revalidateTag()
이 둘은 서버컴포넌트에서 재검증을 야기하는 방식인데, Route Handler
혹은 Server Actions
에서만 사용이 가능하다. 현재 코드에서는 적용할 수 없다.
다행히 나는 14.2를 사용하고 있고, 한가지 방법이 있다.
//next.config.js
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 10, // 동적경로의 캐시 유효기간을 10초로 설정
},
},
};
이 방법을 통해 라우트 캐시의 유효기간을 설정할 수 있다. 이렇게 하니 원하는대로 리패칭이 이루어졌다.
단 이 방식은 모든 페이지에 적용되어 모든 dynamic page의 라우트 캐싱이 적용되지 않으므로 로딩지연이 발생할 수 있으니 잘 확인하고 사용해야 하겠다.