아이템 상세 페이지 HTML이 1.88MB였습니다. 같은 사이트의 맵 상세는 58KB였고요.
헤더를 보다가 더 큰 걸 발견했습니다. 캐시가 전 라우트에서 하나도 안 먹고 있었어요.
재보니 이랬습니다
# 아이템 상세 cache-control: no-store cf-cache-status: BYPASS # 맵 상세 cache-control: s-maxage=86400 cf-cache-status: HIT
ISR을 쓰고 있다고 믿었는데 매 요청마다 서버가 새로 그리는 중이었습니다. CDN은 그냥 통과시키고 있었고요.
1.88MB의 정체는 매물 테이블이었습니다. 인기 아이템은 매물이 수천 건씩 붙는데, 그걸 전부 서버에서 직렬화해 HTML에 실어 보내고 있었어요.
그런데 그 테이블은 가상화입니다. 실제 DOM엔 46행만 그려집니다. 나머지 수천 행은 보내놓고 안 쓰는 데이터였습니다.
아이템 상세는 동적 라우트라 Next 레벨에서는 캐시가 안 걸립니다. 그래서 Next 캐시를 되살리는 대신, 캐시를 층으로 나눠 직접 설계하기로 했습니다.
캐시를 층으로 나눴습니다
요청이 지나가는 길을 놓고 보면 어디에 캐시를 둘 수 있는지가 보입니다.
flowchart LR B["브라우저"] --> CF["CF 엣지"] CF --> N["Next 서버"] N --> A["API 서버"] A --> D[("DB")]
오른쪽으로 갈수록 느리고 비쌉니다. DB는 하나뿐이고 CF는 전 세계에 깔려 있어요. 캐시의 목적은 오른쪽으로 덜 가게 하는 겁니다.
그래서 화살표마다 하나씩 뒀습니다.
CF 캐시 브라우저 → Next 서버 걸리면 서버가 아무 일도 안 함 use cache Next 서버 → API/DB 페이지는 만들되 DB는 안 침
그리고 페이지 안의 조각을 성격대로 갈랐습니다.
- 매물 테이블 — 초 단위로 바뀌고 크롤러도 46행만 봅니다. 클라이언트로 뺐습니다
- 히어로·시세 요약 — 검색엔진이 읽어야 하니 서버에서 그리되, use cache로 재사용합니다
- 문서 전체 — Next가 캐시를 못 하니 헤더를 덮어써서 CF가 들고 있게 했습니다
// next.config.ts headers: [ { source: '/items/:path*', 's-maxage': 300 }, // 5분 { source: '/database/:path*', 's-maxage': 86400 }, // 24시간 ]
그래서 한 요청이 이렇게 흐릅니다.
GET /items/2049100 │ ├─ CF 캐시 (5분) ─────── HIT면 여기서 응답. 서버까지 안 감 │ ↓ MISS ├─ Next 서버: 페이지 조립 │ ↓ ├─ 히어로 만들기 │ └─ use cache (10분) ── HIT면 여기서 끝. API/DB 안 감 │ ↓ MISS └─ API 서버 → DB
매물 JSON은 브라우저가 따로 부르고, 그건 CF가 10초만 들고 있습니다.
주기를 맞추는 게 핵심이었습니다
CF가 5분을 들고 있는 동안은 완벽합니다. 문제는 CF가 비는 순간인데, 생각보다 자주 옵니다.
처음 보는 URL 아이템이 10,795개. 각각 첫 요청은 무조건 오리진행 다른 지역 유저 서울 엣지가 들고 있어도 도쿄 엣지는 처음 5분 지남 만료되면 다시 오리진행
이때 오리진이 히어로를 매번 새로 만들면 DB를 그만큼 칩니다. 그래서 use cache 주기를 CF보다 길게 잡았습니다.
0분 CF MISS → 오리진 → use cache MISS → DB 조회 5분 CF MISS → 오리진 → use cache HIT → DB 안 침 ✅ 10분 CF MISS → 오리진 → use cache 만료 → DB 조회
CF가 두 번 비는 동안 DB는 한 번만 맞습니다.
반대로 CF를 use cache보다 길게 두면 안 됩니다. 오리진이 갱신해도 CF가 옛 HTML을 계속 들고 있어서 유저한테 전달이 안 되거든요.
5분과 10분이라는 숫자 자체는 감으로 정했습니다. CF가 더 짧아야 한다는 조건만 지켰고, 시세가 실제로 몇 분마다 의미 있게 바뀌는지를 재서 맞춘 건 아닙니다.
use cache에 함정도 하나 있었습니다.
revalidate가 5분 미만이면 그 블록은 프리렌더에서 빠지고, 결국 문서 전체가 no-store로 떨어집니다. 그래서 캐시 셸에 넣는 건 전부 5분 이상으로 맞추고 실시간이 필요한 건 클라이언트로 뺐습니다.대신 무효화를 잃었습니다
Next가 자기 캐시를 쓰면 데이터가 바뀔 때 프레임워크가 즉시 지울 수 있습니다. CF 캐시는 Next가 지우지 못합니다. Next 밖에 있는 저장소라서요.
도감이 특히 문제입니다. TTL이 24시간이라 어드민에서 노출을 꺼도 하루 동안 계속 보입니다. 더 나쁜 건 반대 방향이에요.
노출 끔 → 페이지가 404 → CF가 그 404를 24시간 캐시 다시 켬 → 유저는 하루 동안 계속 404를 봄
지금은
cacheTag만 심어두고 revalidateTag는 안 불렀습니다. CF purge는 배포 절차에 손으로 하는 단계로 들어가 있는데, 어드민에서 노출을 토글하는 건 배포가 아니라서 그때는 TTL이 지나기를 기다려야 합니다.게임 데이터가 4~6개월 주기로 바뀌니 하루 지연은 감수하기로 했습니다.
결과
라우트 전략 캐시 ──────────────────────────────────────────────────── / 정적 셸 + CSR 아일랜드 ISR HIT · 900s /database/* 정적 셸 + 런타임 데이터 홀 CF · 86400s /items/[slug] use cache 셸 + CSR 매물 CF · 300s
덤으로 하나 더 잡았습니다. 도감 목록엔 카드가 수천 개인데 Next Link가 뷰포트에 들어온 카드를 전부 prefetch하고 있었어요. 홈을 한 번 여는 것만으로 RSC 요청이 50건씩 나갔습니다.
자동 prefetch를 끄고 hover 시점에만 1회 요청하도록 바꿔서 0건이 됐습니다.
정리
"전부 X로 통일"은 답이 아니었습니다. 전부 SSR도, 전부 CSR도, 전부 ISR도요.
페이지 하나 안에서도 조각마다 성격이 다릅니다. 매물은 초 단위로 바뀌고 히어로는 10분이어도 되고 도감은 하루여도 됩니다. 하나로 묶어서 캐시하려 생긴문제였어요.. ㅠ
그리고 헤더를 안 봤으면 캐시가 안 먹고 있다는 것 자체를 몰랐을 겁니다. 잘 되고 있다고 믿었거든요. 혹시 ISR 쓰고 계신다면 cf-cache-status 한 번 찍어보시길 추천드립니다.
덧 — 한참 뒤에 다시 실측


같은 사이트인데 하나는 5분, 하나는 24시간입니다. 아이템은 시세가 붙어 있고 도감은 게임 패치 주기가 4~6개월이라 그렇게 갈랐어요.
/ cf=REVALIDATED x-nextjs-cache=HIT s-maxage=900 /database/equips cf=HIT s-maxage=86400 /database/maps/[id] cf=EXPIRED s-maxage=86400 /items/[slug] cf=EXPIRED s-maxage=300
전부 유지되고 있었습니다. EXPIRED는 TTL이 지나 다음 요청 때 다시 채워지는 정상 상태입니다.

