raw
Frontend

RSC CDN 캐싱 파편화, 어떻게 해결해야 하나?

2026.01.10·16분

서론: 새로운 프로젝트, 그리고 기술 채택의 무게

최근 새로운 프로젝트를 준비하며 Next.js App Router를 기술 스택으로 검토하게 되었습니다. SEO 최적화와 서버 사이드 렌더링의 이점을 극대화하기 위해 RSC(React Server Components)를 채택하기로 했고, 전 세계 사용자에게 동일한 속도를 제공하기 위해 글로벌 CDN 도입을 전제로 설계했습니다.

기술 검토 과정에서 팀 내의 한 분이 결정적인 단서를 공유해 주셨습니다. "Next.js의 RSC 응답이 CDN 환경에서 캐싱이 잘 되지 않는다"는 GitHub 이슈였습니다.

RSC and CDN interaction makes next.js inefficient for highload projects - GitHub Issue #65335

이 시점부터 우리 팀은 단순히 프레임워크의 장단점을 나열하는 수준을 넘어, 실제 서비스 환경에서 발생할 수 있는 병목 현상을 해결하기 위한 치열한 논의를 시작했습니다. 과연 RSC는 글로벌 서비스의 정답일까요?

이 질문의 답을 찾기 위해 직접 데이터를 파헤치기로 했습니다.

문제를 정의하고 원인을 파악하자

팀 내에서 제기된 이슈의 핵심은 캐시 파편화(Cache Fragmentation) 였습니다. 이를 이해하기 위해 먼저 Next.js App Router가 데이터를 어떻게 캐싱하고 렌더링하는지 살펴볼 필요가 있습니다.

Next.js의 Client-side Transitions

Next.js 최신 공식 문서의 Linking and Navigating 섹션을 보면, Next.js가 빠른 내비게이션을 위해 수행하는 최적화 방식이 설명되어 있습니다.

"Next.js avoids this with client-side transitions... by: Keeping any shared layouts and UI." (Next.js는 공유된 레이아웃과 UI를 유지함으로써... 클라이언트 측 전환을 수행합니다.)

즉, 사용자가 페이지를 이동할 때 전체를 새로 그리는 것이 아니라, 공유되는 레이아웃은 그대로 두고 변경된 부분(Segment)만 서버에서 받아옵니다. 흔히 내비게이션 시 부분 렌더링(Partial Rendering on Navigation) 이라 불리는 동작이 바로 문제의 시발점이었습니다.

Next.js Caching Architecture - nextjs.org

최적화의 역설

서버가 공유된 레이아웃을 빼고, 변경된 부분만 보내주기 위해서는 클라이언트가 현재 어떤 레이아웃을 보고 있는지(Router State)를 알아야 합니다. Next.js는 이 정보를 URL의 쿼리 파라미터인 _rsc에 해시값으로 포함시켜 요청을 보냅니다.

이것이 어떤 문제를 야기하는가?

글로벌 CDN(AWS CloudFront 등)은 기본적으로 URL(Query String 포함)을 기준으로 리소스를 구분하고 캐싱합니다. 서버가 주는 데이터 내용(Payload)이 같더라도 URL에 붙은 _rsc 값이 다르면 CDN은 이를 완전히 다른 파일로 인식합니다.

⚠️Warning

문제의 핵심: 출발지에 따른 URL 파편화

  • Case A: 홈(/)에서 상세 페이지로 이동할 때 → _rsc=hash_from_home
  • Case B: 뉴스(/news)에서 상세 페이지로 이동할 때 → _rsc=hash_from_news

사용자가 어디서 유입되었느냐에 따라 URL이 계속 달라지기 때문에, CDN 캐시 히트율(Hit Rate)은 급격히 떨어지고 요청은 오리진 서버로 몰리게 됩니다. 이는 '전 세계 어디서든 빠른 속도'라는 우리의 목표에 치명적인 응답 지연(Latency)을 초래합니다.

실제로 그런가? 데이터로 검증해보자

이 가설을 검증하기 위해 실제 개발 환경의 네트워크 탭을 열어 확인했습니다.

왼쪽: 홈에서 진입 시 요청 헤더 / 오른쪽: 뉴스에서 진입 시 요청 헤더

스크린샷에서 볼 수 있듯, 동일한 상세 페이지(products?tab=detail)를 요청했음에도 불구하고 출발지에 따라 _rsc 파라미터가 완전히 다른 값을 가지고 있습니다. 혹시 prefetch만 그런 것은 아닐까 의심했지만, 실제 클릭 시 발생하는 fetch 요청조차도 파편화된 해시값을 사용하고 있음을 확인했습니다. 이는 글로벌 CDN 효율을 떨어뜨리는 명확한 증거였습니다.

해결의 실마리: node_modules를 열어보다

문제의 원인은 파악했지만, Next.js는 블랙박스처럼 동작하고 있었습니다. 이 문제를 근본적으로 해결하기 위해 프레임워크의 내부 동작을 파헤쳐 봤습니다.

node_modules/next 내부의 코드를 분석하던 중, 해시 값을 생성하는 핵심 함수 computeCacheBustingSearchParam을 발견했습니다.

js
1const _hash = require("../../hash");
2
3function computeCacheBustingSearchParam(
4 prefetchHeader,
5 segmentPrefetchHeader,
6 stateTreeHeader,
7 nextUrlHeader
8) {
9 if (
10 (prefetchHeader === undefined || prefetchHeader === '0') &&
11 segmentPrefetchHeader === undefined &&
12 stateTreeHeader === undefined &&
13 nextUrlHeader === undefined
14 ) {
15 return '';
16 }
17 return (0, _hash.hexHash)([
18 prefetchHeader || '0',
19 segmentPrefetchHeader || '0',
20 stateTreeHeader || '0', // <-- 결정적 원인
21 nextUrlHeader || '0'
22 ].join(','));
23}

발견: 해시는 '난수'가 아니라 '상태의 조합'이었다

코드를 보면 해시를 생성(hexHash)하는 데 4가지 변수가 사용됩니다. 그중 눈길을 끈 것은 단연 stateTreeHeader였습니다.

이 변수는 클라이언트가 요청 헤더에 실어 보내는 Next-Router-State-Tree, 즉 '현재 클라이언트의 라우터 상태(Flight Router State)' 를 의미합니다.

  • Home에서 진입 시: stateTreeHeader에는 'Home'의 구조가 담깁니다.
  • News에서 진입 시: stateTreeHeader에는 'News'의 구조가 담깁니다.
ℹ️Info

결론은 명확했습니다. Next.js는 부분 렌더링을 위해 출발지 상태(stateTreeHeader)를 해시 생성의 재료로 쓰고 있었고, 입력값이 달라지니 출력되는 해시(_rsc)도 달라질 수밖에 없었던 것입니다. 이것이 바로 글로벌 CDN 캐시를 무력화시킨 파편화의 근본 원인이었습니다.

GitHub 이슈 스레드 - Cache Hit Rate 0%, 모든 요청이 Cache Miss

실험과 검증: 가설을 확신으로

원인을 찾았으니 해결책은 단순했습니다. "만약 이 함수의 반환값을 우리가 원하는 대로 고정해 버린다면 어떨까?"

처음에는 간단하게 해결해 봤습니다.

js
1function computeCacheBustingSearchParam(
2 prefetchHeader,
3 segmentPrefetchHeader
4) {
5 if (prefetchHeader === '1' || segmentPrefetchHeader !== undefined) {
6 return "prefetch-fixed"; // 모든 prefetch는 이 해시로 통일
7 }
8
9 // 실제 진입(Full Navigation) 요청이라면
10 return "full-fixed"; // 모든 실제 진입은 이 해시로 통일
11}

결과는 즉각적이었습니다. 네트워크 탭을 확인해보니 그토록 파편화되던 _rsc 파라미터가 말끔하게 통일되었습니다.

패치 적용 후 - _rsc 파라미터가 prefetch-fixed, full-fixed로 통일된 모습

하지만, "성공했다"고 기뻐하기엔 찜찜한 구석들이 있었습니다. 계속해서 이 방식의 부작용을 고민했습니다.

  1. 부분 렌더링이 꼭 필요한 곳은 어떡하지?: 영상 플레이어(/video)처럼 상태 유지가 필수적인 페이지까지 강제로 새로고침(Full Fetch)시키면 사용자 경험이 망가지지 않을까?
  2. 데이터 부하(Payload) 문제: 부분 렌더링을 포기하면 매번 전체 데이터를 받아와야 하는데, 네트워크가 느린 환경에서는 오히려 로딩이 더 느려지는 역효과가 나지 않을까?
  3. CDN Invalidation의 악몽: 해시를 고정했다는 건, 우리가 수동으로 캐시를 지우지 않는 한 배포를 해도 사용자는 영원히 구버전 화면을 보게 된다는 뜻입니다. 이 파이프라인 구축이 너무 까다롭지 않을까?

전략적 타협과 지속적인 과제

patch-package를 활용해 코드를 우리 서비스에 맞게 커스텀하고, 이에 맞춰 CDN 운영 정책을 함께 수립해야 합니다.

Hybrid Code: 정적 페이지는 해시를 고정하되, /video 등 동적 상태 유지가 중요한 경로는 예외 처리하여 부분 렌더링을 허용했습니다.

js
1function computeCacheBustingSearchParam(
2 prefetchHeader,
3 segmentPrefetchHeader,
4 stateTreeHeader,
5 nextUrlHeader
6) {
7 // 1. 예외 처리: 부분 렌더링이 필수적인 구간 (예: 영상 재생 /video)
8 // 영상 플레이어처럼 상태 유지가 중요한 곳은 기존 로직(해시 생성)을 따르게 하여
9 // 사용자 경험(UX)을 해치지 않도록 합니다.
10 if (nextUrlHeader && nextUrlHeader.includes('/video')) {
11 return (0, _hash.hexHash)([
12 prefetchHeader || '0',
13 segmentPrefetchHeader || '0',
14 stateTreeHeader || '0',
15 nextUrlHeader || '0'
16 ].join(','));
17 }
18
19 // 2. Prefetch 요청인 경우
20 // 링크 호버 시 발생하는 미리 가져오기 요청은 'prefetch-fixed'로 통일합니다.
21 if (prefetchHeader === '1' || segmentPrefetchHeader !== undefined) {
22 return "prefetch-fixed";
23 }
24
25 // 3. 그 외 모든 일반 진입 (Full Navigation)
26 // 실제 페이지 이동 시에는 'full-fixed'로 통일하여 글로벌 CDN 히트율을 극대화합니다.
27 return "full-fixed";
28}
💡Tip

CDN Strategy: 데이터 부하(Full Payload) 문제는 CDN 엣지(Edge)의 속도로 상쇄하고, 데이터 정합성 문제는 CMS 발행 시 AWS CloudFront Invalidation을 자동 트리거하는 파이프라인으로 해결합니다.

이러한 해결 방안은 '완벽한 졸업'을 의미한다고 생각하지 않습니다. RSC의 강력한 SEO 이점과 개발 경험을 가져가기 위해 이 기술을 채택했지만, 그로 인해 발생하는 캐싱 파편화 문제는 지속적으로 안고 가야 할 관리 대상이 되었습니다.

따라서 이번 결정은 해결이라기보다는 RSC를 안정적으로 운영하기 위한 지속 가능한 전략을 수립한 것에 가깝습니다.

결론: 코딩을 넘어, 기술을 운전하는 Driver가 되자

단순히 성능 최적화라는 결과를 넘어, 기술을 대하는 태도에 대해 깊이 고민하게 되었습니다.

AI는 강력한 엔진, 핸들은 결국 사람이 쥔다

AI의 발전으로 코드를 작성하는 속도와 효율이 비약적으로 높아진 것은 사실입니다. 하지만 빠른 코딩이 곧 올바른 해결책을 의미하지는 않습니다. 이번에 마주한 문제처럼, 프레임워크의 기본 동작이 우리 서비스의 인프라(글로벌 CDN)와 충돌할 때, AI는 교과서적인 답변을 줄 뿐 그 맥락까지는 읽어내지 못했습니다.

결국 문제를 해결할 열쇠는 코드를 깊이 파고드는 집요함(Coder의 역량)과 데이터를 기반으로 전체 아키텍처를 조망하는 안목(Driver의 시야)이었습니다.

데이터 기반의 의사결정, 그리고 설득

우리는 'Next.js니까 당연히 좋겠지'라는 막연한 믿음 대신, 네트워크 탭의 파편화된 해시값과 내부 코드를 근거로 삼았습니다.

  • 관찰과 검증: 이슈를 발견하고, 내부 코드를 분석하여 원인을 규명했습니다.
  • 설득과 합의: "부분 렌더링의 이점"과 "CDN의 속도" 사이에서, 우리 서비스에 더 중요한 가치가 무엇인지 팀원들을 설득하고 합의점을 찾았습니다.
  • 지속적인 관리: 이번 패치가 영원한 정답이 아님을 인정하고, 앞으로의 변화에 유연하게 대처하겠다는 전략을 세웠습니다.

마치며

AI가 코딩의 효율을 높여준다면, 우리는 그 시간을 활용해 더 깊은 문제를 고민하고 기술의 방향을 결정하는 드라이버(Driver) 가 되어야 합니다.