1. 프레임워크의 숨겨진 모순
Next.js 15에서 fetch의 기본값이 no-store(캐싱 없음)로 변경되었음에도, 별도 옵션 없이 빌드(npm run build)를 실행하면 데이터가 갱신되지 않는 현상이 발생한다. 이 현상은 프레임워크가 관리하는 두 개의 독립적인 캐시 계층을 이해하지 못할 때 나타나는 전형적인 혼란이다.
| 캐시 계층 | 대상 | 기본 동작 |
|---|---|---|
| Data Cache | fetch로 가져온 JSON 등 순수 데이터 | Next.js 15 기준 기본 저장 안 함 |
| Full Route Cache | 렌더링이 완료된 HTML 페이지 전체 | 조건 충족 시 빌드 타임에 영구 결빙 |
fetch의 기본값이 변경된 것은 데이터 캐시 계층에만 해당한다. Full Route Cache는 별개의 기준으로 작동하며, 조건이 맞으면 페이지 HTML 자체를 빌드 시점에 고정해버린다. 이것이 모순처럼 보이는 현상의 실체다.
2. 컴파일러의 정적 렌더링 판단 기준
빌드 시점에 컴파일러는 각 페이지를 분석하여 동적 렌더링 여부를 결정한다. 아래 세 가지 조건 중 하나라도 해당하면 동적 페이지로 분류하고, 하나도 해당하지 않으면 정적 페이지로 판단하여 HTML을 영구 결빙한다.
cookies(),headers()등 요청 시점에 의존하는 동적 함수를 사용하는가?- URL의
searchParams를 읽어오는가? fetch옵션에cache: 'no-store'가 명시되어 있는가?
세 조건을 모두 충족하지 않는 페이지에 대해 컴파일러는 다음과 같이 판단한다.
“이 페이지는 사용자나 요청 맥락에 관계없이 항상 동일한 결과를 반환한다. 빌드 시점에 API를 단 한 번 호출하여 HTML로 결빙한다.”
결과적으로 데이터 캐시 설정과 무관하게, 페이지 껍데기 자체가 빌드 시점의 스냅샷으로 고정된다.
3. 핵심 용어 정리
| 용어 | 설명 |
|---|---|
| Opt-out | 기본적으로 모든 것을 캐싱하고, 개발자가 명시적으로 거부해야 캐싱이 해제되는 방식. Next.js 14 이하의 기본 철학이다. |
| Opt-in | 기본적으로 아무것도 캐싱하지 않고, 개발자가 명시적으로 허락한 영역에만 캐싱을 적용하는 방식. Next.js 15의 기본 철학이다. |
| Data Cache | fetch 함수가 외부 API로부터 가져온 데이터(JSON 등)를 Next.js 서버 메모리에 저장하는 캐시 계층이다. |
| Full Route Cache | 렌더링이 완료된 HTML 페이지 전체를 빌드 타임에 통째로 결빙하는 캐시 계층이다. SSG(정적 사이트 생성)와 동일한 개념이다. |
no-store | 데이터를 캐싱하지 않고, 매 요청마다 서버에서 동적으로 새롭게 렌더링하도록 강제하는 옵션이다. |
force-cache | 최초 1회 요청 결과를 영구적으로 캐싱하여, 이후 요청에는 저장된 데이터를 반환하는 성능 극대화 옵션이다. |
4. 실습 프로젝트 구조
두 캐시 전략의 동작 차이를 검증하기 위한 테스트베드를 구성한다.
src/
└── app/
├── globals.css
├── layout.tsx
├── page.tsx ← 테스트 진입 대시보드
└── flight/
├── realtime/
│ └── page.tsx ← [테스트 1] no-store: 강제 동적 렌더링
└── snapshot/
└── page.tsx ← [테스트 2] force-cache: Opt-in 선택적 캐싱
5. 1단계 — 강제 동적 렌더링 (no-store)
목적
컴파일러가 페이지를 정적으로 결빙하려는 시도를 명시적으로 차단하고, 매 요청마다 서버에서 새롭게 렌더링하도록 강제한다.
구현
// src/app/flight/realtime/page.tsx
export default async function RealtimeFlightStatus() {
// 아키텍트의 명시적 통제: no-store
// 컴파일러가 이 페이지를 빌드 타임에 정적으로 결빙하는 것을 차단한다.
// 모든 요청은 반드시 서버에서 동적으로 처리되어야 한다.
const res = await fetch(
'https://timeapi.io/api/Time/current/zone?timeZone=Asia/Seoul',
{ cache: 'no-store' }
);
const data = await res.json();
return (
<div className="p-10 max-w-xl mx-auto mt-10 bg-white rounded-xl shadow-lg border border-red-200">
<div className="bg-red-50 text-red-600 font-bold px-3 py-1 rounded-full w-max mb-4 text-sm">
Dynamic Rendering — 요청마다 서버 직접 조회
</div>
<h1 className="text-3xl font-black text-gray-900 mb-4">
실시간 항공편 현황
</h1>
<p className="text-gray-600 mb-4">
프레임워크의 정적 결빙 시도를 차단하고, 매 요청 시 서버 API를 직접 호출한다.
</p>
<div className="bg-gray-900 text-green-400 font-mono p-4 rounded-lg text-lg">
서버 처리 시각:<br />
<span className="text-white">{data.dateTime}</span>
</div>
</div>
);
}
동작 원리 상세
여기서 자연스러운 의문이 생긴다.
“Next.js 15에서
fetch기본값이 이미no-store인데, 왜 굳이 명시적으로 작성하는가?”
이 의문에 대한 정확한 답은 두 캐시 계층의 독립성에 있다.
fetch의 기본값 변경은 Data Cache에만 적용된다. 그러나 컴파일러가 빌드 시점에 판단하는 것은 Full Route Cache, 즉 HTML 페이지 전체의 결빙 여부다. 동적 함수 사용 여부, searchParams 참조 여부, no-store 명시 여부 중 어느 것도 감지되지 않으면 컴파일러는 해당 페이지를 정적으로 분류하고 HTML을 영구 결빙한다.
따라서 cache: 'no-store'를 명시하는 것은 단순한 중복이 아니다. 이것은 Full Route Cache 계층에도 동적 렌더링을 강제하는 신호이며, 컴파일러의 정적 분류 판단 기준 중 세 번째 조건을 충족시키는 명시적 선언이다.
캐시 동작은 개발 환경(
npm run dev)과 프로덕션 환경(npm run build→npm start)에서 다르게 작동한다. Full Route Cache를 포함한 정확한 캐시 동작 검증은 반드시 프로덕션 환경에서 수행해야 한다.
6. 2단계 — Opt-in 선택적 캐싱 (force-cache)
목적
모든 요청을 동적으로 처리하면 트래픽 급증 상황에서 서버가 한계에 도달한다. 변경 빈도가 낮거나 개인화가 필요하지 않은 데이터에는 명시적으로 캐싱을 적용하여 서버 부하를 제어한다.
구현
// src/app/flight/snapshot/page.tsx
export default async function SnapshotFlightInfo() {
// 아키텍트의 성능 통제: force-cache
// 최초 1회 요청 결과를 Data Cache에 영구 결빙한다.
// 이후 모든 요청은 캐싱된 스냅샷을 반환하며, 외부 API 호출은 발생하지 않는다.
const res = await fetch(
'https://timeapi.io/api/Time/current/zone?timeZone=Asia/Seoul',
{ cache: 'force-cache' }
);
const data = await res.json();
return (
<div className="p-10 max-w-xl mx-auto mt-10 bg-white rounded-xl shadow-lg border border-blue-200">
<div className="bg-blue-50 text-blue-600 font-bold px-3 py-1 rounded-full w-max mb-4 text-sm">
Opt-in Caching — 최초 1회 캐싱 후 스냅샷 반환
</div>
<h1 className="text-3xl font-black text-gray-900 mb-4">
항공편 공지사항
</h1>
<p className="text-gray-600 mb-4">
최초 1회만 외부 서버에 요청하고, 이후 모든 응답은 Data Cache에서 반환된다.
</p>
<div className="bg-gray-900 text-blue-400 font-mono p-4 rounded-lg text-lg">
캐시 스냅샷 시각:<br />
<span className="text-white">{data.dateTime}</span>
</div>
</div>
);
}
동작 원리 상세
cache: 'force-cache'가 적용된 fetch 요청은 다음 흐름으로 처리된다.
- 최초 요청 — 외부 API 서버에 실제 네트워크 요청을 수행하고, 응답 결과를 Data Cache에 저장한다.
- 이후 모든 요청 — 외부 API 호출 없이 Data Cache에 저장된 결과를 즉시 반환한다.
수백만 건의 요청이 동시에 유입되어도 외부 API 호출은 단 1회만 발생하며, 서버 부하는 사실상 0에 수렴한다. 이것이 Opt-in 캐싱 전략의 핵심이다.
7. 프로덕션 환경 검증
캐시 동작은 반드시 프로덕션 환경에서 검증해야 한다. 개발 환경은 캐시 엔진이 다르게 작동하므로 정확한 결과를 얻을 수 없다.
npm run build
npm start
테스트 1 — 동적 렌더링 검증
http://localhost:3000/flight/realtime 접속 후 새로고침을 반복한다.
예상 결과: 새로고침마다 화면의 시간이 변경된다. no-store 선언으로 컴파일러의 정적 결빙이 차단되고, 매 요청마다 서버에서 새롭게 렌더링된 것이다.
테스트 2 — Opt-in 캐싱 검증
http://localhost:3000/flight/snapshot 접속 후 새로고침을 반복한다.
예상 결과: 새로고침을 반복해도 시간이 변경되지 않는다. 최초 1회 요청 결과가 Data Cache에 영구 결빙되어, 이후 모든 요청은 캐싱된 스냅샷만 반환하는 것이다.
8. 전략 비교 요약
| 구분 | no-store | force-cache |
|---|---|---|
| 렌더링 방식 | SSR (동적, 매 요청마다 서버 처리) | SSG (정적, 최초 1회 후 캐시 반환) |
| 데이터 신선도 | 100% 실시간 | 캐시 시점으로 고정 |
| 서버 부하 | 요청 수에 비례하여 증가 | 최초 1회 이후 사실상 0 |
| 적합한 데이터 | 사용자 개인화 정보, 실시간 재고/가격 | 공지사항, 약관, 변경 빈도 낮은 콘텐츠 |
| Full Route Cache | 결빙 차단 (동적 페이지로 분류) | 조건 충족 시 HTML까지 결빙 가능 |
Next.js 15의 Opt-in 철학은 기본은 안전하게, 최적화는 의도적으로 적용하는 원칙이다. 아키텍트는 페이지 전체를 단일 전략으로 처리하는 것이 아니라, 컴포넌트 단위로 두 전략을 정밀하게 조합하여 신선도와 성능의 균형점을 설계해야 한다.