
v15에서 "왜 자꾸 캐싱 안 돼?"라고 당황했던 개발자라면, v16의 변화가 반가울 것이다.
들어가며
2025년 10월 21일, Next.js 16이 정식 출시됐다.
릴리즈 노트를 읽다가 눈이 멈춘 부분이 있었다. revalidateTag() 시그니처 변경. 이거 기존 코드 전부 깨지는 거 아닌가? 확인해보니 맞았다. 그것도 두 번째 인자가 필수로 바뀌는 Breaking Change였다.
단순히 API 하나 바뀐 게 아니다. v14 → v15 → v16으로 이어지는 캐싱 철학의 변화가 이번 버전에서 완성됐다. 이 흐름을 이해하지 않으면 마이그레이션할 때 "왜 이렇게 바꿨지?"라는 의문만 남는다.
정리해봤다.
버전별 캐싱 정책의 변화
먼저 세 버전의 캐싱 정책을 한눈에 보자.
| 버전 | 캐싱 기본값 | 개발자 반응 |
|---|---|---|
| v14 | Cached by Default | "왜 자꾸 캐싱돼?" |
| v15 | Uncached by Default | "왜 갑자기 캐싱 안 돼?" |
| v16 | Explicit "use cache" |
"아, 내가 명시하면 되는구나" |
마치 롤러코스터다. v14에서 과도하게 캐싱하다가, v15에서 갑자기 캐싱을 끄고, v16에서 "너희가 직접 정해라"로 결론 난 것이다.
v14 → v15: "과잉 친절한 집사" 해고
v14의 문제: 시키지도 않은 캐싱
v14의 App Router는 모든 걸 캐싱하려고 했다. fetch 요청, GET 핸들러, 페이지 데이터까지.
// v14에서 이 코드는 자동으로 캐싱됨
async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
문제는 이게 의도치 않은 동작을 만들었다는 것이다.
사용자 A가 프로필 수정
↓
DB에는 새 데이터 저장됨
↓
페이지 새로고침
↓
??? 여전히 옛날 데이터가 보임 ???
↓
"아... 캐싱 때문이구나"
↓
revalidate 옵션 찾아서 추가이런 경험 한 번쯤 있지 않은가?
v15의 해결책: 일단 다 끄자
v15는 과감하게 기본값을 뒤집었다.
// v15부터 기본값이 no-store
const res = await fetch(`/api/users/${id}`);
// ↑ 캐싱 안 됨
// 캐싱하려면 명시적으로
const res = await fetch(`/api/users/${id}`, {
cache: 'force-cache'
});
그리고 동기 API들이 비동기로 바뀌었다.
// v14
const cookieStore = cookies();
// v15
const cookieStore = await cookies();
v16의 접근: "네가 직접 말해"
v16은 한 발 더 나아갔다. "use cache" 디렉티브를 도입해서 캐싱을 완전히 선언적으로 만들었다.
Cache Components 활성화
// next.config.ts
const nextConfig = {
cacheComponents: true,
};
export default nextConfig;
사용 방식
┌─────────────────────────────────────────────────┐
│ │
│ v14: "기본적으로 캐싱할게. 싫으면 말해" │
│ → 개발자: "왜 자꾸 캐싱돼?" │
│ │
│ v15: "기본적으로 캐싱 안 할게. 필요하면 말해" │
│ → 개발자: "매번 옵션 넣기 귀찮은데..." │
│ │
│ v16: "캐싱할 곳에 use cache 써. 거기만 캐싱함" │
│ → 개발자: "오, 명확하네" │
│ │
└─────────────────────────────────────────────────┘이게 PPR(Partial Prerendering)의 완성형이다. 2023년에 "정적 셸은 즉시, 동적 콘텐츠는 스트리밍"이라는 개념으로 시작했는데, v16에서 프로그래밍 모델로 완성된 것이다.
새로운 Caching API 삼총사
v16에서는 캐시 제어 API도 정비됐다.
revalidateTag() - 시그니처가 바뀌었다
이게 Breaking Change다. 기존 코드 전부 수정해야 한다.
import { revalidateTag } from 'next/cache';
// ❌ v15 이전 - 더 이상 안 됨
revalidateTag('blog-posts');
// ✅ v16 - 두 번째 인자 필수
revalidateTag('blog-posts', 'max');
revalidateTag('products', { expire: 3600 });
두 번째 인자는 cacheLife 프로필이다. 'max', 'hours', 'days' 같은 빌트인 값을 쓰거나, { expire: number }로 직접 지정한다.
updateTag() - 새로 추가됨
Server Actions 전용. 즉시 갱신이 필요할 때 쓴다.
'use server';
import { updateTag } from 'next/cache';
export async function updateUserProfile(userId: string, data: Profile) {
await db.users.update(userId, data);
// 캐시 만료 + 즉시 새 데이터 로드
updateTag(`user-${userId}`);
}
revalidateTag()와 차이점:
revalidateTag(): SWR 방식 (일단 캐시 보여주고 백그라운드에서 갱신)updateTag(): 즉시 갱신 (사용자가 바로 변경 확인 가능)
refresh() - 라우터 새로고침 (캐시된 건 유지)
'use server';
import { refresh } from 'next/cache';
export async function markNotificationAsRead(id: string) {
await db.notifications.markAsRead(id);
// 현재 라우트 다시 렌더링 트리거
refresh();
}
알림 카운트처럼 fetch 시 no-store로 설정한 데이터만 새로 받아온다. 캐싱된 컴포넌트나 데이터는 그대로 유지된다.
Turbopack: 이제 진짜 기본값
Turbopack이 드디어 개발과 빌드 모두에서 기본이 됐다.
체감 성능
| 항목 | 개선폭 |
|---|---|
| Fast Refresh | 최대 10배 |
| 프로덕션 빌드 | 2~5배 |
Webpack으로 돌아가고 싶다면?
next dev --webpack
next build --webpack
커스텀 Webpack 설정이 복잡하면 당분간 이렇게 쓸 수 있다.
File System Caching (Beta)
// next.config.ts
const nextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true,
},
};
컴파일 결과를 디스크에 저장해서 재시작 시 컴파일 시간을 단축한다. 모노레포 같은 대규모 프로젝트에서 체감된다.
middleware.ts → proxy.ts
미들웨어 파일명이 바뀌었다.
// proxy.ts (구 middleware.ts)
export default function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url));
}
왜 바꿨을까?
┌─────────────────────────────────────────────────┐
│ │
│ "middleware"라는 이름이 모호했다 │
│ │
│ 실제 역할: 네트워크 경계에서 요청 가로채기 │
│ 새 이름: proxy │
│ 런타임: Node.js (Edge 아님) │
│ │
└─────────────────────────────────────────────────┘middleware.ts는 Edge 런타임 케이스를 위해 아직 동작하지만, deprecated다.
Enhanced Routing: 프리페칭이 똑똑해졌다
Layout Deduplication
50개의 상품 링크가 있는 페이지를 생각해보자.
v15까지:
Link 1 프리페칭 → 레이아웃 + 페이지 데이터
Link 2 프리페칭 → 레이아웃 + 페이지 데이터
...
Link 50 프리페칭 → 레이아웃 + 페이지 데이터
결과: 레이아웃 50번 다운로드 (중복!)
v16:
Link 1 프리페칭 → 레이아웃 + 페이지 데이터
Link 2 프리페칭 → 페이지 데이터만 (레이아웃은 캐시됨)
...
Link 50 프리페칭 → 페이지 데이터만
결과: 레이아웃 1번만 다운로드Incremental Prefetching
- 이미 캐시된 부분은 건너뜀
- 뷰포트를 벗어나면 프리페칭 취소
- hover 시 우선순위 상승
- 데이터 무효화되면 자동 재프리페칭
개별 요청 수는 늘어날 수 있지만, 총 전송량은 크게 줄어든다.
React 19.2 Canary 기능들
v16은 React 최신 Canary를 사용한다.
View Transitions
트랜지션/네비게이션 시 요소에 애니메이션 적용.
useEffectEvent()
Effect에서 비반응형 로직을 분리.
<Activity/>
// display: none으로 숨기면서 상태 유지
<Activity mode="hidden">
<HeavyComponent />
</Activity>
탭 전환 같은 시나리오에서 유용하다.
Breaking Changes 체크리스트
버전 요구사항
| 항목 | 최소 버전 |
|---|---|
| Node.js | 20.9+ (18 지원 종료) |
| TypeScript | 5.1.0+ |
| Chrome/Edge | 111+ |
| Firefox | 111+ |
| Safari | 16.4+ |
제거된 것들
| 제거됨 | 대체 방법 |
|---|---|
| AMP 지원 | 완전 삭제됨 |
next lint |
Biome 또는 ESLint CLI 직접 사용 |
serverRuntimeConfig |
.env 환경변수 |
experimental.ppr |
cacheComponents |
동기 cookies(), headers() |
await 필수 |
동작 변경
| 항목 | Before | After |
|---|---|---|
images.minimumCacheTTL |
60초 | 4시간 |
images.qualities |
[1..100] |
[75] |
| Parallel routes | default.js 선택 |
default.js 필수 |
revalidateTag() |
인자 1개 | 인자 2개 필수 |
마이그레이션 가이드
자동 마이그레이션
npx @next/codemod@canary upgrade latest
대부분 자동 처리된다.
수동으로 확인할 것
1. revalidateTag() 전부 수정
// Before
revalidateTag('posts');
// After
revalidateTag('posts', 'max');
2. Parallel Routes에 default.js 추가
없으면 빌드가 실패한다.
// app/@modal/default.js
export default function Default() {
return null;
}
3. Node.js 버전 확인
node -v # 20.9.0 이상인지 확인
정리: 버전별 비교
| 구분 | v14 | v15 | v16 |
|---|---|---|---|
| 캐싱 모델 | Cached by Default | Uncached by Default | Explicit "use cache" |
| 번들러 | Webpack | Turbopack (Dev) | Turbopack (Full) |
| 데이터 API | 동기 | 비동기 필수 | 비동기 + 새 API |
| React | 18 | 19 | 19.2 Canary |
| 미들웨어 | middleware.ts |
middleware.ts |
proxy.ts |
마치며
Next.js의 캐싱 정책 변화는 "좋은 기본값이 뭔가?"에 대한 Vercel의 고민을 보여준다.
v14: "다 캐싱하면 빠르겠지" → 예측 불가능한 동작
v15: "일단 다 끄자" → 매번 명시하기 귀찮음
v16: "필요한 곳에 선언해라" → 명확하고 예측 가능
결국 "명시적인 게 암묵적인 것보다 낫다"는 결론에 도달한 것 같다.
마이그레이션 시 revalidateTag() 시그니처 변경과 Parallel Routes의 default.js 필수화를 특히 주의하자. 나머지는 codemod가 대부분 처리해준다.
'Frontend Development' 카테고리의 다른 글
| React의 Error Boundary와 비동기 오류 처리 (0) | 2025.10.13 |
|---|---|
| React 리렌더링(Re-rendering): Trigger → Render → Commit (1) | 2025.10.03 |
| 실무에서 꼭 알아야 할 JWT 저장소 보안 패턴과 공격 탐지 방법 (0) | 2025.09.13 |
| Next.js SSR 페이지 풀 페이지 캐싱 (0) | 2025.09.08 |
| useEffect에서 setInterval이 상태를 못 따라오는 이유 (stale closure) (0) | 2025.09.07 |