같은 사이트인데 어떤 페이지는 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 한 번 찍어보시길 추천드립니다. 🙂