🗂️

nextjs 캐시전략 정하기.

작성일
2026년 08월 01일
태그
nextjs
react
typescript
카테고리
nextjs
Last edited time
Last updated August 1, 2026
날짜
2026년 06월 11일
같은 사이트인데 어떤 페이지는 58KB, 어떤 페이지는 1.88MB였습니다.
그리고 캐시는 전 라우트에서 하나도 안 먹고 있었습니다. no-store가 계속 찍혀 나왔어요.
둘 다 원인이 따로 있었고, 고치는 방법도 달랐습니다.

전부 SSR로 통일한 게 시작이었습니다

초기엔 단순했습니다. "서버에서 다 그려서 내려주면 빠르고 SEO도 되겠지."
그런데 아이템 상세 페이지를 재보니 HTML이 1.88MB였습니다. 같은 사이트의 맵 상세는 58KB였고요.
차이는 매물 테이블이었습니다. 인기 아이템은 매물이 수천 건씩 붙는데, 그걸 전부 서버에서 직렬화해 HTML에 실어 보내고 있었습니다.
문제는 그 테이블이 가상화라는 겁니다. 실제 DOM엔 46행만 그려집니다.
여기까지는 "그럼 매물은 클라이언트에서 받자"로 끝날 문제였습니다. 그런데 응답 헤더를 보다가 더 이상한 걸 발견했습니다.

캐시가 하나도 안 먹고 있었습니다

# 아이템 상세 cache-control: no-store cf-cache-status: BYPASS decoded size: 1.77MB # 맵 상세 cache-control: s-maxage=86400 cf-cache-status: HIT decoded size: 58KB
아이템 상세만이 아니었습니다. 홈도, 도감 목록도, 전 라우트가 `no-store` 로 나가고 있었어요.
ISR을 쓰고 있다고 생각했는데 실제로는 매 요청마다 서버가 새로 그리고 있었던 겁니다. CDN은 아무것도 못 하고 그냥 통과시키고 있었고요.
원인을 찾는 데 시간이 좀 걸렸습니다.

범인 ① 강제로 dynamic을 만드는 호출

스트리밍을 쓰려고 홈 페이지에 connection()을 넣어뒀습니다. 이게 있으면 그 라우트는 무조건 동적이 됩니다.
당시엔 "TTFB를 줄이려면 스트리밍이 필요하고, 그러려면 dynamic이어야 한다"고 생각했는데, 잘못된 전제였습니다. 캐시된 셸을 즉시 보내는 것과 스트리밍은 양자택일이 아니거든요.
빼고 나니 홈이 바로 x-nextjs-cache: HIT으로 바뀌었습니다.

범인 ② 프리렌더 목록을 안 만들었습니다

그런데 아이템 상세는 connection()을 빼도 여전히 no-store였습니다.
처음엔 "동적 라우트라 어쩔 수 없나 보다" 하고 넘겼는데, 그게 틀렸습니다. `params`를 읽는 것 자체는 캐시를 막지 않습니다. generateStaticParams로 경로 목록을 주면 빌드 때 미리 그려두고 ISR로 캐시됩니다.
문제는 우리가 그걸 안 쓰고 있었다는 것이었습니다. 이유가 두 개 있었습니다.
  • 빌드 때 API를 때려야 합니다 — 아이템 목록을 받아와야 경로를 만들 수 있는데, CI엔 API 서버가 없습니다. 도감 목록에서 이미 ECONNREFUSED로 빌드가 깨진 적이 있었습니다
  • 아이템이 계속 늘어납니다 — 새 아이템이 추가될 때마다 재빌드해야 합니다
그래서 프리렌더를 포기하고 요청 시 렌더하기로 했는데, cacheComponents 환경에서 프리렌더 대상이 아닌 라우트가 params를 읽으면 그 지점부터 동적으로 처리됩니다. 결과적으로 문서 응답이 `no-store`로 나갑니다.
내부에서 use cache를 써도 마찬가지였습니다. 오리진이 하는 일은 줄어들지만 응답 헤더는 그대로였고, CDN 입장에선 캐시할 수 없는 응답이었습니다.
정리하면 프레임워크 제약이 아니라 우리가 내린 선택의 결과였습니다. 그리고 그 선택 자체는 지금도 맞다고 봅니다 — 빌드가 API에 의존하는 쪽이 더 위험하니까요. 대신 캐시를 다른 층에서 해결하기로 했습니다.

그래서 페이지마다 다르게 가기로 했습니다

"전부 SSR" 같은 단일 전략을 버렸습니다. 페이지마다 성격이 다른데 하나로 통일하려던 게 애초에 무리였습니다.
기준은 두 가지로 잡았습니다.
  • 이 콘텐츠가 검색엔진에 노출돼야 하나? — 검색 유입이 대부분이라 중요했습니다
  • 얼마나 자주 바뀌나? — 초 단위인가, 몇 분인가, 거의 안 바뀌나

홈 — 정적 셸 + 클라이언트 아일랜드

시세 통계·급등락·최근 거래는 초 단위로 바뀝니다. 대신 이건 검색 결과에 꼭 노출될 필요가 없습니다.
셸만 정적으로 만들고 라이브 섹션은 전부 클라이언트에서 받도록 했습니다. connection()을 뺐더니 진짜 ISR HIT이 나왔습니다.
x-nextjs-cache: HIT · s-maxage=900

도감 목록 — 정적 셸 + 런타임 데이터 홀

여긴 데이터가 거의 안 바뀝니다(게임 패치 주기 4~6개월). 그래서 처음엔 셸에 데이터를 통째로 구워버릴까 했는데, 그러면 빌드 때 API를 때립니다.
CI에 API 서버가 없으니 빌드가 ECONNREFUSED로 깨졌습니다.
그래서 데이터를 <Suspense> 안의 서버 컴포넌트로 옮겼습니다. 셸은 정적으로 미리 만들어지고, 데이터는 런타임에 서버가 받아 prop으로 내려줍니다. 클라이언트 fetch는 0회고요.
빌드 결과: ◐ (Partial Prerender)

아이템 상세 — 검색엔진에 노출될 것만 서버에서

이 페이지는 Next 레벨 캐시가 불가능하니 다르게 접근했습니다.
  • 히어로·시세 요약·구조화 데이터 — 캐시된 서버 렌더. 크롤러가 읽는 건 이것들
  • 매물 테이블 — 클라이언트 아일랜드. 어차피 가상화라 크롤러에게도 46행만 보였습니다
JS를 끄고 봤을 때 최저가·평균가·거래량·판매중 개수가 나오면 크롤러에겐 충분합니다. 수천 행이 HTML에 있을 필요가 없었습니다.

use cache에 함정이 하나 있습니다

캐시 셸을 만들 때 revalidate를 짧게 잡고 싶었습니다. 데이터가 자주 바뀌니까요.
그런데 `revalidate`가 5분 미만이면 그 블록은 prerender에서 빠집니다. 정적 셸에 안 들어가고 dynamic hole이 되면서, 결국 문서 전체가 no-store로 떨어집니다.
그래서 캐시 셸에 넣는 건 전부 revalidate ≥ 5분으로 맞추고, 실시간이 필요한 건 클라이언트로 뺐습니다.

구조적으로 캐시가 안 되는 건 CDN으로

/items/[slug]는 Next에서 캐시가 안 됩니다. 그럼 CDN 레벨에서 하면 됩니다.
// next.config.ts headers: [ { source: '/items/:path*', 's-maxage': 300 }, // 준라이브 { source: '/database/:path*', 's-maxage': 86400 }, // 게임 데이터 ]
오리진은 여전히 no-store를 발행하지만, 헤더를 덮어써서 Cloudflare가 캐시 계층 역할을 하게 만들었습니다.
TTL은 성격대로 나눴습니다. 도감은 게임 패치가 4~6개월 주기라 24시간이어도 문제없고, 아이템은 시세가 붙어 있어서 5분으로 잡았습니다.

결과

라우트 전략 캐시 ──────────────────────────────────────────────────────── / 정적 셸 + CSR 아일랜드 HIT · s-maxage=900 /database/* 정적 셸 + 런타임 데이터 홀 ◐ PPR · CDN 24h /database/*/[id] 'use cache' 번들 CDN 24h /items/[slug] 캐시 셸 + CSR 아일랜드 CDN 5분 ──────────────────────────────────────────────────────── 아이템 상세 HTML 1.88MB → 465KB (75% 감소)
덤으로 하나 더 잡았습니다. 도감 목록엔 카드가 수천 개인데, Next <Link>가 뷰포트에 들어온 카드를 전부 prefetch하고 있었습니다. 홈을 한 번 여는 것만으로 RSC 요청이 50건씩 나갔어요.
자동 prefetch를 끄고 hover 시점에만 1회 요청하도록 바꿔서 50건 → 0건이 됐습니다.

회귀를 막으려고 걸어둔 것

이런 건 한 번 고쳐도 다음 커밋에서 조용히 되돌아갑니다. 그래서 검증을 게이트로 만들었습니다.
  • 상세 라우트 HTML 300KB 미만 단언
  • SSR HTML에 대형 하이드레이션 블롭이 없는지 grep
  • s-maxage 발행 여부와 CDN HIT 확인
  • connection() / cookies() / headers() 가 캐시 대상 라우트에 들어왔는지 감사
그리고 dev 서버에서는 검증하지 않습니다. HMR 때문에 하이드레이션이 불안정해서 값이 다르게 나옵니다. 반드시 next build && next start로 잽니다.

정리하면

제일 크게 배운 건 "전부 X로 통일"이 답이 아니라는 것이었습니다.
전부 SSR도, 전부 CSR도, 전부 ISR도 아니었습니다. 페이지마다 검색엔진에 노출돼야 하는지와 얼마나 자주 바뀌는지가 다르고, 거기에 프레임워크 사양(동적 라우트는 캐시 불가)까지 얹히니 답이 페이지마다 달랐습니다.
그리고 헤더를 안 봤으면 캐시가 안 먹고 있다는 것 자체를 몰랐을 겁니다. 잘 되고 있다고 믿었거든요. 혹시 ISR 쓰고 계신다면 cf-cache-status 한 번 찍어보시길 추천드립니다. 🙂