2026-05-12·[디버깅 경험]
프론트엔드 캐시 정리 2: Next.js 캐시와 15·16 비교
Next.js 캐시는 서버 캐시와 Router Cache를 분리해서 봐야 이해하기 쉽다.
지난 글에서는 브라우저가 이미 하고 있는 캐시를 먼저 봤다. bfcache, HTTP 캐시, 그리고 "뒤로 가기에서는 이상한데 새로고침하면 괜찮다" 같은 신호들 말이다.
그런데 그걸 다 걷어내고 나서도 설명이 안 되는 순간이 있다. 브라우저 캐시를 끄고 hard reload를 해도 이상하고, 응답 헤더도 별문제 없어 보이는데 어떤 화면은 여전히 늦게 갱신된다. 이쯤 되면 슬슬 막막해진다. 브라우저 탓은 아닌 것 같은데, 그렇다고 어디를 봐야 하는지도 명확하지 않은 그 공백. 보통 이 시점에서 Next.js 쪽 레이어를 보기 시작한다.
이 글은 App Router 기준으로, 특히 Next.js 15와 16 사이에서 캐시를 보는 방식이 어떻게 달라졌는지 정리한 글이다.

브라우저 캐시를 걷어낸 뒤 Next.js 쪽을 보기 시작하면, 최소한 서버 캐시와 Router Cache는 따로 생각해야 한다. 이 둘을 한 장으로 구분해두면 15와 16 비교도 훨씬 읽기 쉬워진다.
Next.js 레이어를 보기 시작하는 시점
Next.js는 브라우저 위에 프레임워크 레이어를 하나 더 올린다. 이게 헷갈리는 이유는 서버와 클라이언트를 동시에 다루기 때문이다.
예를 들면 이런 식이다.
- 서버 컴포넌트에서 읽어온 데이터를 재사용할 수 있다.
- 페이지 이동을 빠르게 하려고 클라이언트 메모리에 route segment를 들고 있을 수 있다.
- 어떤 값은 정적으로 굽고, 어떤 값은 request time에 다시 계산한다.
그러니까 "캐시됐다"는 말이 또 한 번 모호해진다. 브라우저가 캐시한 건지, Next.js 서버가 캐시한 건지, App Router가 클라이언트에서 잡고 있는 건지 한 번 더 분리해야 한다.
여기서는 우선 두 가지로 나눠서 본다.
- 서버에서 계산한 결과를 재사용하는 캐시
- 앱 내부 이동을 빠르게 만들기 위한 클라이언트 쪽 Router Cache
이 두 개를 구분하는 것만으로도 구조가 훨씬 단순해진다.
Next.js 15의 기본 캐시 변화
Next.js 15의 변화는 "기본 캐시를 덜 믿는 방향"으로 요약할 수 있다.
공식 릴리즈 노트를 보면 Next.js 15에서 fetch 요청, GET Route Handler, 그리고 client navigation이 더 이상 기본적으로 캐시되지 않는 방향으로 정리됐다. 특히 App Router의 Client Router Cache에서 page segment는 기본적으로 재사용하지 않도록 바뀌었고, staleTimes.dynamic 기본값도 30초에서 0초로 내려갔다.
이 변화는 실무에서도 체감이 컸다. 예전에는 "왜 안 바뀌지?"를 먼저 묻게 되는 경우가 많았다면, 15부터는 적어도 page navigation 쪽에서 최신성을 더 우선하는 방향이 강해졌다.
다만 바뀌지 않은 것도 있다.
- shared layout은 계속 재사용된다.
- loading.js는 계속 캐시된다.
- browser back/forward navigation에서는 여전히 복원 효과가 있다.
그래서 페이지 이동에서는 최신처럼 보이는데, 뒤로 가기에서는 예전 느낌이 난다 같은 현상이 여전히 가능하다. 이 지점에서는 Router Cache와 브라우저의 bfcache를 다시 한 번 구분해야 한다.
Next.js 16은 명시적 캐시로 이동했다
Next.js 16의 Cache Components에서 눈에 띄는 점은, 예전 App Router의 암묵적인 캐시보다 훨씬 명시적이라는 것이다.
공식 문서 기준으로 cacheComponents: true를 켜면, 동적 코드는 기본적으로 request time에 실행된다. 그리고 정말 캐시하고 싶은 페이지, 컴포넌트, 함수에만 "use cache"를 붙인다.
여기서 중요한 포인트는 이렇다.
- 15는 기본 캐시를 보수적으로 줄인 버전 같았다.
- 16은 거기서 한 걸음 더 가서 캐시를 opt-in으로 명시하게 만든 버전 같았다.
간단히 말하면, 15가 "기본 캐시를 보수적으로 줄인다"였다면, 16은 "캐시하고 싶으면 네가 명시해라"에 가깝다.
use cache는 캐시 의도를 코드에 남긴다
예를 들어 공지사항 목록이 있고, 이 데이터는 여러 사용자가 같이 보며 그렇게 자주 바뀌지 않는다고 해보자. 이런 값은 use cache 후보가 된다.
import { cacheLife, cacheTag } from 'next/cache';
export async function getNotices() {
'use cache';
cacheLife('hours');
cacheTag('notices');
return db.notice.findMany({
orderBy: { publishedAt: 'desc' },
});
}
여기서 중요한 점은 캐시 의도가 코드에 드러난다는 점이다.
- "use cache": 이 계산 결과는 재사용 가능하다.
- cacheLife('hours'): 얼마나 오래 쓸지 정한다.
- cacheTag('notices'): 나중에 무효화할 이름표를 붙인다.
이제 "왜 이게 안 바뀌지?"라는 상황이 오면 적어도 이 함수부터 보면 된다. 추적 경로가 훨씬 분명해진다.
revalidateTag와 updateTag는 비슷해 보이는데 다르다
이 둘은 처음 보면 꽤 헷갈린다. 나는 처음에 이름만 보고 둘 다 "무효화" 계열이려니 했다. 실제로는 타이밍이 다른, 완전히 다른 신호였다.
revalidateTag('notices', 'max')는 공식 문서 기준으로 stale-while-revalidate semantics에 가깝다. 그러니까 "이 태그는 이제 stale로 보고, 다음 방문 때 stale 데이터를 줄 수는 있지만 뒤에서 fresh 데이터를 다시 준비하자" 쪽이다.
반면 updateTag('notices')는 read-your-own-writes에 더 가깝다. 같은 앱 안 Server Action에서 데이터를 수정한 직후, 사용자가 바로 fresh한 결과를 보게 하고 싶을 때 쓴다.
이 차이를 실무식으로 요약하면 이렇다.
- revalidateTag: 조금 늦어도 괜찮다.
- updateTag: 지금 바로 새 값이어야 한다.
예를 들어 같은 프로젝트 안에서 공지사항을 작성하는 Server Action이라면 이렇게 갈 수 있다.
'use server';
import { updateTag } from 'next/cache';
export async function createNotice(formData: FormData) {
await db.notice.create({
data: {
title: String(formData.get('title')),
body: String(formData.get('body')),
},
});
updateTag('notices');
}
이건 "내가 방금 쓴 공지사항이 다음 화면에서 바로 보여야 한다"는 흐름에 맞다.
외부 어드민 프로젝트에서는 전략이 달라진다
여기서 특히 중요한 건 이 구분이다.
관리자 화면이 아예 다른 프로젝트라면, 그 프로젝트 안에서 updateTag를 호출해봐야 사용자용 블로그 앱의 캐시는 안 바뀐다. 캐시를 들고 있는 주체가 다른 앱이기 때문이다.
이때는 보통 사용자용 Next.js 앱에 revalidate endpoint를 두고, 외부 어드민이 그 엔드포인트를 호출하는 구조가 더 자연스럽다.
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const { tags } = await req.json();
for (const tag of tags) {
revalidateTag(tag, 'max');
}
return NextResponse.json({ ok: true });
}
이렇게 해두면 어드민 프로젝트는 "공지사항이 바뀌었다"는 사실만 알려주고, 실제 캐시 무효화는 캐시를 들고 있는 사용자용 앱 내부에서 일어난다.
이 구분이 되면 updateTag와 revalidateTag를 섞어 쓰는 일이 줄어든다.
Router Cache는 별도로 구분해야 한다
Next.js에는 클라이언트 메모리에 route segment를 들고 있는 Router Cache도 있다. 이건 브라우저 bfcache와는 다르지만, 사용자 체감은 비슷할 때가 있다. 뒤로 가기나 앱 내부 이동이 유난히 빠르기 때문이다.
공식 문서를 보면 Next.js 15에서 page segment는 기본적으로 navigation에 재사용되지 않도록 바뀌었지만, shared layout과 back/forward 쪽 복원은 남아 있다. 그래서 실무에서는 이런 구분이 꽤 중요했다.
- 앱 내부 링크 이동에서만 빠르다 → Router Cache를 먼저 의심
- 브라우저 뒤로 가기에서 스크롤까지 복원된다 → bfcache 가능성이 큼
겉으로는 둘 다 "빨리 뜬다"처럼 보여서, 이 차이를 분리해두는 게 중요하다.
15와 16의 차이 요약
이 시점에서 핵심 비교는 결국 이거다.
Next.js 15는 기본 캐시를 줄이면서 최신성 쪽으로 조금 더 이동한 버전 같고, Next.js 16은 거기서 더 나아가 "캐시를 하고 싶으면 네가 명시해라"라고 말하는 버전 같았다.
후자가 이해하기 쉬운 방향이다. 물론 손이 더 가긴 한다. 하지만 적어도 무엇을 왜 캐시했는지는 코드에서 바로 드러난다.
처음에 Next.js 캐시를 공부할 때는 "왜 이렇게 종류가 많아"라는 생각이 먼저 들었다. 그런데 서버 캐시와 Router Cache를 나누고, 다시 15와 16의 기본값 변화를 따로 보니까 훨씬 덜 헷갈렸다.
다음 글에서는 여기서 한 층 더 올라가서, 브라우저 메모리 안에서 애플리케이션이 직접 관리하는 캐시, 즉 TanStack Query와 SWR을 정리한다. 좋아요 수나 장바구니처럼 "바로 화면이 바뀌어야 하는 값"은 그 레이어가 더 잘 설명해준다.
이 시리즈는 아래 순서로 이어진다.
참고
Next.js 15 release Next.js 16 release Next.js - Cache Components Next.js - Revalidating Next.js - staleTimes