Next.js 16 revalidateTag와 브라우저 캐시: 사이드바 숫자가 어긋난 문제
한 번에 글을 일곱 편 발행한 날, 블로그 왼쪽의 카테고리 메뉴가 이상하게 보였습니다. 카테고리를 오가며 스크린샷 두 장을 찍었습니다. 첫 장은 전체 글 수가 377, 「데이터베이스 설계」가 14편이었고, 몇 초 뒤에 다른 카테고리를 눌러 찍은 둘째 장은 전체 370, 「데이터베이스 설계」 7편이었습니다. 메뉴가 글 수 순서로 정렬되기 때문에 숫자만 다른 것이 아니라 항목 위치까지 달랐습니다.
370 과 7 은 그날 발행하기 전의 숫자였습니다. 같은 사람이 같은 브라우저에서 몇 초 사이에 본 두 화면이, 서로 다른 시점의 메뉴를 보여 주고 있었습니다.
이 블로그는 Next.js 16 을 쓰고, Cache Components 를 켜지 않은 이전 캐싱 모델(unstable_cache 와 라우트의 revalidate)로 되어 있습니다. 원인은 둘이었고, 첫 번째를 고친 뒤에야 두 번째가 보였습니다.
발행 직후 첫 방문자가 받던 옛 페이지
글을 발행하면 관리 시스템이 블로그의 /api/revalidate 를 부르고, 그 안에서 이렇게 캐시를 무효화하고 있었습니다.
revalidateTag(POST_CACHE_TAG, "max");바로 위 주석에는 「"max" 는 이 태그로 캐시된 항목을 남은 수명과 관계없이 모두 만료시킨다」고 적혀 있었습니다. 문서를 열어 보니 반대였습니다. Next.js 16 의 revalidateTag 문서는 두 번째 인자 "max" 를 이렇게 설명합니다.
The tag entry is marked as stale, and the next time a resource with that tag is visited, it will use stale-while-revalidate semantics. This means the stale content is served while fresh content is fetched in the background.
"max" 는 만료가 아니라 낡음 표시입니다. 발행 뒤 그 페이지를 처음 연 사람은 옛 페이지를 받고, 그 요청이 뒤에서 새 페이지를 만듭니다. 카테고리 페이지는 페이지마다 따로 캐시되므로, 어느 페이지를 먼저 누가 열었느냐에 따라 새 메뉴와 옛 메뉴가 섞여 보였습니다.
같은 문서에 이 경우를 위한 형태가 따로 있었습니다.
For webhooks or third-party services that need immediate expiration, you can pass
{ expire: 0 }as the second argument.
외부 시스템이 부르는 무효화 경로라는 설명이 이 블로그의 경우와 정확히 맞았습니다. { expire: 0 } 으로 바꾸고 주석도 실제 동작대로 고쳤습니다. 대가는 발행 직후 각 페이지의 첫 방문자가 새로 렌더링하는 시간만큼 기다리는 것입니다.
고친 뒤에도 남은 옛 메뉴
이 수정을 커밋하고 아직 배포하기 전에, 카테고리를 옮기는 작업을 이어서 했습니다. 「커머스」와 「Electron」 카테고리를 다른 곳으로 합쳤습니다. 그리고 다시 메뉴가 어긋나 보였습니다. 「디자인 시스템」을 선택한 화면에는 방금 없앤 「커머스」와 「Electron」이 그대로 있었고, 「개발 도구」를 선택한 화면에는 없었습니다.
서버 쪽 수정이 배포되지 않았으니 서버가 옛 페이지를 준 것일 수도 있었습니다. 그런데 응답 헤더를 보니 다른 단서가 있었습니다.
x-nextjs-stale-time: 300Next.js 는 정적으로 만든 페이지를 브라우저 안에도 보관합니다. staleTimes 문서에 따르면 정적 페이지의 기본 보관 시간이 5분입니다. 한 번 열어 본 카테고리로 5분 안에 다시 이동하면, 브라우저는 서버에 묻지 않고 그때 받아 둔 페이지를 보여 줍니다. 「디자인 시스템」 페이지는 카테고리를 옮기기 전에 열어 둔 것이었습니다.
서버 캐시를 아무리 빨리 비워도 이 브라우저 쪽 사본에는 닿지 않습니다. 첫 번째 수정만으로는 부족했던 이유입니다.
페이지마다 복사되던 사이드바
두 원인을 겹쳐 보니 구조의 문제가 드러났습니다. 카테고리 메뉴는 목록 페이지마다 각 페이지 컴포넌트 안에서 그려지고 있었습니다. 페이지 20개를 서로 다른 시점에 열었다면 메뉴 사본도 서로 다른 시점의 20개입니다. 서버의 사본도, 브라우저의 사본도 페이지 단위였습니다.
Next.js 에는 여러 페이지가 하나를 공유하는 자리가 있습니다. 레이아웃입니다. 레이아웃 문서의 설명입니다.
On navigation, layouts preserve state, remain interactive, and do not rerender.
그래서 메뉴를 페이지에서 꺼내 목록 페이지들이 공유하는 레이아웃으로 옮겼습니다. 목록 페이지들(/, /page/[n], /category/[name])을 라우트 그룹 (archive) 로 묶고 그 layout.tsx 에 메뉴를 두었습니다. 괄호로 된 폴더 이름은 주소에 나타나지 않으므로 URL 은 하나도 바뀌지 않았습니다.
한 가지가 따라왔습니다. 레이아웃은 페이지를 이동해도 다시 렌더링되지 않으므로, 「지금 어느 카테고리가 선택됐는지」를 페이지가 넘겨줄 수 없습니다. 레이아웃 문서도 같은 이유로 레이아웃에서는 현재 경로를 읽을 수 없다고 적습니다. 선택 표시만 작은 클라이언트 컴포넌트로 분리해 useSelectedLayoutSegments 로 현재 세그먼트를 읽게 했습니다. 목록 자체는 서버 컴포넌트로 남았습니다.
레이아웃이 정말 다시 그려지지 않는지 확인한 방법
배포하고 운영 사이트를 헤드리스 Chrome 으로 열어 카테고리를 차례로 눌렀습니다. 메뉴의 DOM 노드가 처음 것 그대로였고 선택 표시만 바뀌었습니다.
다만 DOM 이 같다는 것만으로는 부족했습니다. 페이지를 이동할 때 서버가 보내는 응답을 열어 보니 그 안에 메뉴 데이터가 여전히 들어 있었기 때문입니다. 브라우저가 그 데이터로 메뉴를 다시 그리는지는 응답만 봐서는 알 수 없었습니다.
그래서 이동 응답을 가로채 「웹 인프라」의 글 수를 가짜 값 999 로 바꿔 넣었습니다. 이동은 정상적으로 끝났고, 화면의 메뉴는 90 그대로였습니다. 응답에 메뉴 데이터가 있어도 브라우저는 공유 레이아웃을 다시 그리지 않는다는 것을 이렇게 확인했습니다. 이제 한 번 방문하는 동안 메뉴는 하나이고, 새로고침하면 최신 숫자로 바뀝니다.
캐시가 두 층이라는 사실
이번 일에서 제가 가장 늦게 본 것은 캐시가 서버와 브라우저, 두 층이라는 점이었습니다.
| 층 | 무엇이 보관하나 | 무엇으로 비우나 |
|---|---|---|
| 서버 | 렌더링 결과와 unstable_cache 데이터 |
revalidateTag, 라우트의 revalidate |
| 브라우저 | 방문한 페이지의 렌더링 결과 (정적 페이지 기본 5분) | 시간이 지나거나 새로고침 |
서버 층의 문제는 무효화 방식("max" 와 { expire: 0 })을 바꿔 고쳤습니다. 브라우저 층은 발행하는 쪽에서 비울 방법이 없습니다. 그래서 캐시 설정이 아니라 구조로 풀었습니다. 페이지마다 다른 시점의 사본이 생길 수밖에 없다면, 어긋나면 안 되는 부분은 페이지가 아니라 공유 레이아웃에 두는 것입니다.
staleTimes 로 브라우저 보관 시간을 줄이는 방법도 있었습니다. 저는 이 방법을 택하지 않았습니다. 어긋나는 시간이 짧아질 뿐 어긋남은 남고, 페이지 이동마다 요청이 늘어납니다.
여기까지가 확실한 부분
"max" 의 stale-while-revalidate 동작과 { expire: 0 } 안내, 정적 페이지의 브라우저 보관 기본 5분, 레이아웃이 이동 중에 다시 렌더링되지 않는다는 점은 Next.js 16 문서 기준입니다. 스크린샷의 숫자, 응답 헤더, 999 로 바꾼 확인은 2026년 9월 18일 이 블로그의 운영 배포에서 직접 본 결과입니다. Cache Components 를 켠 프로젝트는 캐싱 모델이 달라서 이 글의 설정 이름이 그대로 적용되지 않습니다.
함께 읽기
- Next.js 병렬 라우트와 인터셉트 라우트: 모달 패턴과 default.js사진 목록에서 사진을 누르면 모달로 크게 보여 주고, 같은 주소를 새 탭에서 열면 전체 페이지로 보여 주는 화면이 있습니다. 주소는 /photo/123 하나인데 보이는 모양이 둘입니다.
- Next.js 데이터 가져오기 순서: 순차와 병렬, 그리고 Suspense 위치서버 컴포넌트에서 데이터를 읽는 코드는 이렇게 생깁니다.
- Next.js 다국어 경로 설계: 쿠키와 하위 경로공개하지 않고 내부에서만 여는 제품 카탈로그 데모에 언어 전환을 붙였습니다. 한국어, 영어, 중국어, 일본어, 독일어, 프랑스어 여섯 개입니다. 고른 언어는 쿠키에 저장하고, 쿠키가 없으면 브라우저의 Accept-Language 헤더를 봅니다. 주소는 나누지 않았습니다. /products/<기종> 하나로 여섯 언어를 모…
- Next.js 라우트 핸들러와 서버 함수 구분앱 라우터에서 서버로 무언가를 보내는 방법이 둘입니다. app/api/.../route.ts 에 라우트 핸들러를 만들거나, "use server" 를 붙인 서버 함수를 부르는 것입니다. 폼 하나를 붙일 때마다 어느 쪽을 쓸지 정해야 하는데, 「둘 다 서버에서 도니 아무거나」로 두면 나중에 갈립니다.
- Next.js 이미지 최적화: next/image와 빌드 때 굽기제품 카탈로그 데모를 만들면서 이미지 처리 방식을 정해야 했습니다. 화면에 나가는 사진은 제품 사진 몇 장과 로고뿐이고, 모두 저장소에 들어 있는 파일입니다. 요청마다 달라질 것이 없습니다.