이번 편의 결과물: 같은 공지사항 데이터를 force-cache와 no-store 두 가지 fetch 옵션으로 불러오는 비교 페이지가 동작하고, 새로고침할 때마다 두 옵션의 응답 시간 차이가 눈에 보입니다. · 다루는 개념: Route Handler mock API, fetch 캐시 옵션(force-cache/no-store), 캐시 여부에 따른 응답 시간 차이
05편까지는 CDN 계층(CloudFront)의 캐시만 다뤘습니다. 이 편부터는 Next.js 서버 안쪽, fetch 요청 하나하나가 어떻게 캐시되는지를 봅니다. 먼저 실제 지연이 있는 mock API를 만들고, 같은 요청을 캐시 옵션만 다르게 해서 비교합니다.
이 편에서 만드는 파일
notice-board/
├── .env.local (+, NEXT_PUBLIC_BASE_URL 정의)
└── app/
├── api/
│ └── notices/
│ └── route.js (+, 공지사항 목록 mock API. 응답 전 1초 지연)
└── cache-demo/
└── page.js (+, force-cache/no-store 비교 페이지)개념 정리
서버 컴포넌트에서 내 API를 부를 때는 절대 경로가 필요하다
서버 컴포넌트 안의 fetch는 브라우저가 아니라 Node.js 런타임에서 실행됩니다. 상대 경로(/api/notices)만 주면 어느 호스트로 보낼지 알 수 없어 오류가 납니다. NEXT_PUBLIC_BASE_URL 같은 환경 변수에 배포 도메인을 담아 절대 경로를 만듭니다.
# .env.local (로컬 개발용)
NEXT_PUBLIC_BASE_URL=http://localhost:3000Amplify 콘솔 → 앱 선택 → 환경 변수(Environment variables)에도 같은 이름으로 실제 배포 도메인(https://main.d1234567890abc.amplifyapp.com)을 등록하고 재배포해야 배포 환경에서도 정상 동작합니다.
fetch 캐시 옵션
| 옵션 | 동작 |
|---|---|
| 기본값(옵션 생략) | 빌드 시점에 한 번 가져오거나, 요청마다 새로 가져옴(라우트의 렌더링 방식에 따라 다름) |
| force-cache | Next.js의 서버 캐시(Data Cache)에서 먼저 찾고, 있으면 그 값을 그대로 씀 |
| no-store | 캐시를 전혀 쓰지 않고 매번 origin에 새로 요청함 |
force-cache로 저장된 응답은 요청의 URL·메서드·헤더·본문이 모두 같을 때만 같은 캐시 항목으로 취급됩니다. no-store는 이 캐시 저장소 자체에 들어가지 않습니다.
이 캐시는 어디에 저장되는가
Data Cache는 Next.js 서버 인스턴스의 로컬 디스크에 저장됩니다. 지금처럼 인스턴스 하나로 테스트하는 동안은 새로고침해도 같은 캐시를 계속 재사용하지만, 트래픽이 늘어 인스턴스가 여러 개로 늘어나면 인스턴스마다 별도의 캐시를 갖게 됩니다. 이 문제는 09편에서 직접 재현합니다.
실습
1. 환경 변수 파일 만들기
# .env.local
NEXT_PUBLIC_BASE_URL=http://localhost:3000create-next-app이 만든 .gitignore에는 .env.local이 이미 포함되어 있어 저장소에 올라가지 않습니다.
2. mock API 작성
// app/api/notices/route.js
import { NextResponse } from 'next/server';
import notices from '../../../data/notices.seed.json';
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function GET() {
console.log(`[API] GET /api/notices 처리 시작 - ${new Date().toISOString()}`);
await wait(1000);
console.log(`[API] GET /api/notices 처리 끝 - ${new Date().toISOString()}`);
return NextResponse.json(notices);
}실제 데이터베이스 조회를 흉내 내기 위해 응답 전 1초를 기다립니다. 요청이 실제로 이 함수까지 도달했는지는 두 console.log 사이 간격으로 확인합니다. 응답은 03편 시드 데이터의 필드 이름(id, title, category, author, publishedAt, content)을 그대로 내보냅니다. 이 과목은 16편까지 이 필드 이름을 그대로 유지합니다.
3. 비교 페이지 작성
// app/cache-demo/page.js
async function timedFetch(cacheOption) {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000';
const start = performance.now();
const response = await fetch(`${baseUrl}/api/notices`, { cache: cacheOption });
const notices = await response.json();
const elapsedMs = Math.round(performance.now() - start);
return { notices, elapsedMs };
}
export default async function CacheDemoPage() {
const cached = await timedFetch('force-cache');
const fresh = await timedFetch('no-store');
return (
<section>
<h1>fetch 캐시 옵션 비교</h1>
<table>
<thead>
<tr>
<th>옵션</th>
<th>응답 시간</th>
<th>공지사항 수</th>
</tr>
</thead>
<tbody>
<tr>
<td>force-cache</td>
<td>{cached.elapsedMs}ms</td>
<td>{cached.notices.length}건</td>
</tr>
<tr>
<td>no-store</td>
<td>{fresh.elapsedMs}ms</td>
<td>{fresh.notices.length}건</td>
</tr>
</tbody>
</table>
</section>
);
}두 fetch는 같은 URL을 부르지만 cache 옵션이 달라 서로 다른 요청으로 취급됩니다.
4. 실행과 확인
npm run devhttp://localhost:3000/cache-demo를 처음 열면 터미널에 [API] 로그가 두 번(각 fetch당 한 번) 찍히고, 화면의 두 응답 시간 모두 1000ms 안팎으로 나옵니다.
새로고침을 다시 하면 다음과 같이 갈립니다.
- force-cache 행: 응답 시간이 몇 ms 수준으로 크게 줄고, 터미널에 로그가 새로 찍히지 않습니다(Data Cache에서 바로 반환).
- no-store 행: 여전히 1000ms 안팎이고, 터미널에 로그가 매번 새로 찍힙니다.
배포된 환경에서 이 페이지의 응답 헤더도 함께 확인합니다.
curl -I https://main.d1234567890abc.amplifyapp.com/cache-demoHTTP/2 200
content-type: text/html; charset=utf-8
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-cache: Miss from cloudfront
age: 0no-store fetch가 하나라도 있으면 페이지 전체가 동적 렌더링으로 분류되어 cache-control이 private, no-store가 됩니다. CDN도 이 응답을 절대 캐시하지 않아 몇 번을 다시 요청해도 x-cache는 항상 Miss from cloudfront입니다. 정적 페이지와 동적 페이지의 헤더 차이는 07편에서 자세히 다룹니다.
직접 해보기
timedFetch('force-cache')를 한 번 더 호출하는 세 번째 행을 추가해, 이미 캐시된 요청을 또 불러도 로그가 찍히지 않는지 확인해보세요.wait(1000)을wait(3000)으로 늘려서 첫 로딩과 캐시된 이후의 체감 차이를 비교해보세요.
정답 보기
timedFetch('force-cache')를 몇 번을 호출하든 URL과 옵션이 같으면 같은 Data Cache 항목을 가리키므로, 이미 캐시된 뒤에는 몇 번을 추가로 불러도 [API] 로그가 새로 찍히지 않습니다. wait를 3000ms로 늘리면 첫 로딩은 두 fetch를 순서대로 기다려 총 6초 가까이 걸리지만(직렬 실행이므로), 캐시된 뒤에는 force-cache 쪽만 즉시 반환되어 체감 차이가 훨씬 커집니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| fetch failed 또는 접속 거부 오류 | NEXT_PUBLIC_BASE_URL을 안 만들거나 값이 틀림 | .env.local 값과 npm run dev 포트(기본 3000)가 일치하는지 확인한다 |
| 두 옵션 모두 항상 느림 | cache 옵션 철자를 틀림(예: forceCache) | fetch 두 번째 인자 이름과 값의 철자를 정확히 맞춘다 |
| 배포 환경에서만 안 됨 | Amplify 환경 변수에 NEXT_PUBLIC_BASE_URL을 등록하지 않음 | Amplify 콘솔 → 환경 변수에 배포 도메인으로 등록하고 재배포한다 |