Next.js App Router에서는 서버 fetch가 기본적으로 Data Cache를 사용해요[114]. 요청별 값이 필요한 cookies()·headers()는 세그먼트를 동적으로 만들고[117], cache: 'no-store'dynamic = 'force-dynamic'은 정적 렌더링을 끄는 수단이에요[116][118]. 반대로 force-staticgenerateStaticParams는 정적 생성 쪽 설정이며[119][120], 경로·태그 단위 갱신은 revalidatePath()·revalidateTag()로 처리해요[121].

App Router 캐시는 서버 `fetch`를 기본 단위로 이해하면 가장 정확해요.

App Router에서 가장 먼저 잡아야 할 기준은 fetch예요. Next.js 공식 문서에 따르면 App Router의 내장 Data Cache는 서버에서 실행되는 fetch 요청 결과를 요청 간, 그리고 배포 간에도 캐시할 수 있고, 별도 옵트아웃이 없으면 기본적으로 사용돼요[114]. 즉, 페이지 파일 이름이나 폴더 구조보다 먼저, “이 데이터가 서버 fetch로 어떻게 읽히는가”가 동작을 좌우한다고 보는 편이 실무적으로 맞아요.

이 기본값 때문에 App Router에서는 예전 Pages Router 감각으로 “아무 설정 안 했으니 매 요청 새로 불러오겠지”라고 생각하면 자주 어긋나요. 공식 출처 [114]는 캐시가 기본값이라고 명시하므로, 최신성이 중요한 요청은 의도적으로 캐시 제외나 재검증 방식을 선언해야 해요. 반대로 자주 바뀌지 않는 목록, 문서, 공개 콘텐츠는 기본 동작만으로도 충분히 안정적인 출발점을 만들 수 있어요.

항목기본 동작출처
서버 fetchData Cache 사용이 기본[114]
fetch(..., { cache: 'no-store' })해당 요청은 Data Cache 제외, 동적 요청[116]
fetch(..., { next: { revalidate: 60 } })60초 기준 시간 기반 재검증[115]
cookies() / headers()해당 세그먼트를 동적으로 전환[117]

`cache: 'no-store'`는 특정 `fetch`를 캐시에서 제외하는 명시적 선택이에요.

공식 문서 [116]는 fetchcache: 'no-store'를 주면 그 요청이 Data Cache를 사용하지 않으며 동적으로 처리된다고 설명해요. 여기서 중요한 점은 이것이 “캐시를 완전히 싫어한다”는 철학 선언이 아니라, 특정 데이터의 최신성 요구를 코드 수준에서 표시하는 장치라는 점이에요. 예를 들어 재고 수량, 개인화 추천, 요청 시점의 권한 확인처럼 매 요청 맥락이 중요한 데이터에 적합해요.

다만 제공된 출처 [116]은 no-store의 성능상 유불리까지 말해주지는 않아요. 그래서 “항상 더 느리다” 또는 “대부분 더 낫다”처럼 단정적으로 비교하는 것은 출처 범위를 넘어가요. 출처에 근거해 안전하게 말할 수 있는 부분은 1개뿐이에요. cache: 'no-store'는 캐시를 쓰지 않고, 해당 요청을 동적으로 만든다는 사실이에요[116].

tsx
export default async function ProductPage() {
  const res = await fetch('https://api.example.com/stock', {
    cache: 'no-store',
  })

  const stock = await res.json()

  return <pre>{JSON.stringify(stock, null, 2)}</pre>
}

`revalidate`는 시간 기반으로 데이터를 다시 검토하게 하는 설정이에요.

Next.js는 fetchnext 옵션에 revalidate를 줄 수 있고, 예시로 next: { revalidate: 60 }은 60초 후 재검증되도록 해요[115]. 이 기능은 “완전 정적”과 “완전 동적” 사이에 있는 중간 선택지로 이해하면 쉬워요. 즉, 매 요청마다 캐시를 버리지는 않지만, 무기한 고정하지도 않고 일정 시간 단위로 최신화를 허용하는 방식이에요.

여기서 조심할 점도 있어요. 제공된 사실 [115]는 “60초 후 재검증”을 말할 뿐, 모든 사용자가 정확히 60초 간격으로 동일한 새 데이터를 본다고까지 설명하지는 않아요. 따라서 실무 문서에서는 revalidate: 60을 “60초 기준의 시간 기반 재검증 설정”이라고 표현하는 것이 가장 출처 친화적이고 정확해요.

tsx
export default async function NewsPage() {
  const res = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 },
  })

  const news = await res.json()
  return <ul>{news.map((item: any) => <li key={item.id}>{item.title}</li>)}</ul>
}

`cookies()`와 `headers()`는 요청 의존 값을 읽기 때문에 세그먼트를 동적으로 만들어요.

App Router에서 cookies() 또는 headers()를 route segment 안에서 호출하면 그 세그먼트는 동적이 돼요[117]. 이유는 단순해요. 쿠키와 헤더 값은 들어오는 요청마다 달라질 수 있으므로, 빌드 시점에 완전히 고정된 결과를 전제로 할 수 없기 때문이에요. 공식 문서 [117]는 이 점을 “incoming request에 의존한다”는 표현으로 분명히 설명해요.

여기서 표현을 과장하지 않는 것이 중요해요. 제공된 출처는 세그먼트가 동적이 된다고 말하지만, 이를 곧바로 “무조건 요청마다 전부 다시 계산된다”로 더 구체화하지는 않아요[117]. 따라서 문서나 리뷰에서는 “정적 전제를 버리고 요청 의존 세그먼트가 된다” 정도로 설명하는 편이 공식 표현과 잘 맞아요.

tsx
import { cookies } from 'next/headers'

export default async function DashboardPage() {
  const cookieStore = await cookies()
  const theme = cookieStore.get('theme')?.value ?? 'light'

  return <div>Theme: {theme}</div>
}

`force-dynamic`과 `force-static`은 세그먼트 단위 렌더링 방식을 강제하는 설정이에요.

페이지나 레이아웃에서 export const dynamic = 'force-dynamic'을 선언하면 해당 세그먼트의 정적 렌더링이 비활성화돼요[118]. 반대로 export const dynamic = 'force-static'을 선언하면 정적으로 강제할 수 있어요[119]. 이 두 옵션은 “자동 판단에 맡길지, 개발자가 방향을 못 박을지”를 정하는 설정으로 이해하면 좋아요.

특히 force-static은 동적 트리거가 아니라 정적 강제 설정이라는 점을 분리해서 봐야 해요. 공식 출처 [119]에 따르면 force-static을 사용한 세그먼트가 cookies()headers() 같은 dynamic server API를 쓰면 에러가 나요. 즉, force-static은 “정적을 만들고 싶다”는 강한 의사표현이고, 요청 의존 API와는 양립하지 않는다고 정리하는 편이 가장 정확해요.

설정의미충돌/제약출처
dynamic = 'force-dynamic'정적 렌더링 비활성화공식 출처는 그 이상을 단정하지 않음[118]
dynamic = 'force-static'정적 렌더링 강제cookies()·headers() 사용 시 에러[119]
cookies() / headers()요청 의존 값 읽기세그먼트를 동적으로 만듦[117]
tsx
export const dynamic = 'force-static'

export default function AboutPage() {
  return <div>Static about page</div>
}

`generateStaticParams`는 동적 라우트의 파라미터를 빌드 시점에 미리 준비하는 함수예요.

동적 세그먼트가 있다고 해서 항상 요청 시점에만 처리해야 하는 것은 아니에요. generateStaticParams는 동적 라우트에서 build time에 params를 미리 생성하는 함수이고, 해당 세그먼트가 렌더링되기 전에 실행돼요[120]. 예를 들어 블로그의 상위 100개 글 슬러그, 상품 카테고리 12개, 지역 페이지 17개처럼 미리 열거 가능한 경로에 잘 맞아요.

실무에서는 이 함수를 “동적 URL을 정적으로 다루는 도구”로 보면 이해가 빨라요. URL 패턴은 /posts/[slug]처럼 동적이지만, 실제 값 slug 목록을 빌드 전에 알 수 있다면 정적으로 미리 준비할 수 있는 거예요[120]. 결국 핵심 질문은 하나예요. “이 params 목록을 빌드 전에 안정적으로 알 수 있는가”예요.

tsx
export async function generateStaticParams() {
  return [
    { slug: 'nextjs-cache' },
    { slug: 'app-router-basics' },
  ]
}

export default function PostPage({ params }: { params: { slug: string } }) {
  return <article>{params.slug}</article>
}

`revalidatePath()`와 `revalidateTag()`는 캐시 무효화를 경로와 태그라는 두 축으로 나눠서 다뤄요.

공식 문서 [121]에 따르면 revalidatePath()는 특정 path의 캐시 데이터를 무효화하고, revalidateTag()는 특정 tag와 연결된 모든 캐시 엔트리를 무효화해요. 이 차이는 매우 실무적이에요. URL 1개만 새로고침하고 싶으면 path, 여러 페이지가 공유하는 동일 데이터 집합을 함께 갱신하고 싶으면 tag라는 식으로 나눠 생각할 수 있어요.

예를 들어 /posts 목록과 /posts/[slug] 상세가 같은 게시물 데이터를 공유한다면, 경로 1개보다 태그 단위가 더 자연스러울 수 있어요. 반대로 공지사항 한 페이지처럼 URL 1개만 명확히 다시 계산하면 되는 경우는 revalidatePath('/notice')가 직관적이에요. 다만 제공된 출처 [121]는 성능 차이의 크기나 태그 설계 우열까지 직접 말하지 않으므로, 그 부분은 과장 없이 “무효화 범위가 다르다”는 수준으로 설명하는 게 안전해요.

ts
import { revalidatePath, revalidateTag } from 'next/cache'

export async function refreshPostPage() {
  revalidatePath('/posts')
}

export async function refreshPostData() {
  revalidateTag('posts')
}

Client Component는 서버 전용 기능을 직접 사용할 수 없기 때문에 캐시 전략도 경계가 분명해요.

App Router의 React Server Components는 서버 측 데이터 페칭을 가능하게 하고, 클라이언트로 보내는 JavaScript를 줄여줘요[122]. 반면 'use client'가 붙은 Client Component는 cookies() 같은 서버 전용 함수나 직접 데이터베이스 호출을 사용할 수 없어요[122]. 따라서 캐시와 동적 렌더링 판단은 주로 Server Component, Route Handler, 서버 fetch 레이어에서 설계해야 해요.

이 구분은 구조 설계에도 바로 영향을 줘요. 예를 들어 사용자별 테마를 쿠키에서 읽어야 한다면, 그 읽기 작업은 Client Component 내부가 아니라 서버 쪽 세그먼트에서 이뤄져야 해요[117][122]. 제공된 출처에는 Server Actions 관련 사실이 없으므로, 여기서는 서버 액션을 전제로 한 우회 패턴까지 확장하지 않고 공식 범위 안에서 설명하는 편이 맞아요.

Route Handler 캐시는 `GET`과 정적 응답이라는 두 조건을 중심으로 이해하면 돼요.

App Router의 Route Handlers는 Web RequestResponse API를 사용할 수 있어요[123]. 그리고 기본적으로 캐시되는 경우는 GET이면서 응답이 static일 때뿐이고, GET이 아닌 메서드는 캐시되지 않아요[123]. 즉, 같은 /api/posts라도 GETPOST는 캐시 관점에서 출발점이 다르다고 보면 돼요.

이 사실은 API 설계에서 꽤 중요해요. 읽기 전용 데이터 제공 엔드포인트는 GET으로 두고 정적 응답 가능성을 검토할 수 있지만, 생성·수정·삭제처럼 상태 변경이 있는 작업은 POST, PUT, DELETE 등 비-GET 메서드가 일반적이며 공식 출처 [123] 기준으로 기본 캐시 대상이 아니에요. 결국 Route Handler에서도 “이 엔드포인트는 읽기인가, 변경인가”를 먼저 나누는 것이 합리적이에요.

ts
export async function GET() {
  return Response.json({ version: '1.0.0' })
}

export async function POST(req: Request) {
  const body = await req.json()
  return Response.json({ saved: true, body })
}

실무 선택 기준은 데이터의 변경 주기와 요청 의존성이라는 2개 질문으로 압축할 수 있어요.

제공된 공식 사실 [114]~[123]만 놓고 보면, 실무 판단은 복잡한 듯해도 2개 질문으로 줄일 수 있어요. 첫째, 이 데이터가 요청마다 달라지는가예요. 둘째, 빌드 시점에 고정하거나 일정 시간 간격으로 재검증해도 되는가예요. 이 질문에 답하면 cookies()·headers() 사용 여부[117], no-store[116], revalidate[115], force-static·force-dynamic[118][119], generateStaticParams[120] 중 무엇을 택할지 자연스럽게 좁혀져요.

  1. 공개 문서·소개 페이지처럼 빌드 시점 고정이 가능하면 force-static 또는 기본 정적 동작을 우선 검토해요[119][114].

  2. 동적 라우트지만 경로 목록을 미리 알 수 있으면 generateStaticParams를 써요[120].

  3. 최신성이 필요하지만 매 요청 개인화는 아니면 next: { revalidate: 60 } 같은 시간 기반 재검증을 검토해요[115].

  4. 요청별 쿠키·헤더 값이 필요하면 cookies()headers()를 사용하고, 세그먼트가 동적이 됨을 받아들여요[117].

  5. 특정 요청만 캐시에서 제외해야 하면 그 fetch에만 cache: 'no-store'를 줘요[116].

  6. 콘텐츠 수정 직후 특정 화면만 갱신하려면 revalidatePath(), 여러 화면이 공유하는 데이터 묶음이면 revalidateTag()를 고려해요[121].

핵심은 공식 문서가 제공하는 보장 범위를 넘어서 과하게 단정하지 않는 거예요. 예를 들어 force-dynamic은 정적 렌더링을 비활성화한다는 사실까지는 분명하지만[118], 이를 모든 상황에서 동일한 실행 패턴으로 설명하는 것은 출처보다 한 단계 더 나간 해석일 수 있어요. App Router 캐시 문서는 옵션의 의미를 정확히 기억하고, 성능이나 빈도에 대한 세부 판단은 실제 코드와 운영 환경에서 별도로 검증하는 방식이 가장 안전해요.

FAQ

`fetch`에 아무 옵션도 안 주면 항상 캐시되나요?

App Router에서는 서버 fetch가 기본적으로 Data Cache를 사용해요[114]. 다만 구체적인 전체 동작은 세그먼트 설정이나 동적 API 사용 여부와 함께 봐야 하므로, 기본값은 캐시 사용이라고 이해하는 것이 맞아요.

`cookies()`를 쓰면 페이지 전체가 무조건 매 요청 렌더링되나요?

공식 출처는 cookies()headers()를 호출한 route segment가 동적이 된다고 설명해요[117]. 그 이상으로 모든 경우를 “무조건 매 요청 렌더링”이라고 단정하는 표현은 제공된 사실 범위를 넘어가요.

`force-static`과 `cookies()`를 같이 쓰면 어떻게 되나요?

dynamic = 'force-static'을 사용한 페이지나 레이아웃이 cookies() 또는 headers() 같은 dynamic server API를 쓰면 에러가 나요[119]. 둘은 같은 세그먼트 안에서 양립하지 않는다고 보면 돼요.

`revalidatePath()`와 `revalidateTag()` 중 무엇을 먼저 써야 하나요?

특정 URL 1개를 갱신하려면 revalidatePath()가 더 직접적이고, 같은 데이터를 공유하는 여러 캐시 엔트리를 함께 무효화하려면 revalidateTag()가 맞아요[121]. 기준은 성능 추정이 아니라 무효화 범위예요.

Client Component에서 `cookies()`를 직접 읽을 수 있나요?

아니요. 'use client'가 붙은 Client Component는 cookies() 같은 서버 전용 함수나 직접 데이터베이스 호출을 사용할 수 없어요[122]. 이런 작업은 Server Component나 서버 측 레이어에서 처리해야 해요.