RSS듀오랩스
Next.js

Next.js 16 revalidateTag와 브라우저 캐시: 사이드바 숫자가 어긋난 문제

작성자
듀오랩스 대표·8분 읽기

한 번에 글을 일곱 편 발행한 날, 블로그 왼쪽의 카테고리 메뉴가 이상하게 보였습니다. 카테고리를 오가며 스크린샷 두 장을 찍었습니다. 첫 장은 전체 글 수가 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: 300

Next.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 를 켠 프로젝트는 캐싱 모델이 달라서 이 글의 설정 이름이 그대로 적용되지 않습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.