TanStack.com이 더 작은 마크다운 및 구문 강조 렌더러로 전환한 뒤 RSC를 중단하고 일반 SSR을 선택한 이유를 설명합니다.
Tanner Linsley 작성, 2026년 7월 24일.
올해 초, tanstack.com은 React Server Components를 보여 주는 제가 가장 좋아하는 사례 중 하나가 되었습니다. 콘텐츠가 많은 페이지들은 거대한 마크다운 및 구문 강조 스택을 브라우저에 전송하고 있었고, 우리는 그 작업을 서버로 옮겼습니다. 상당한 양의 JavaScript가 사라졌고 사이트는 더 빨라졌습니다. 이를 글로 썼고, 측정했으며, RSC가 해결하도록 설계된 바로 그런 종류의 문제였기 때문에 결과에 꽤 만족했습니다.
성능 향상은 실제였지만, 이 아키텍처를 한동안 사용하면서 꽤 평범했어야 할 콘텐츠 파이프라인을 설명하는 데 시간이 얼마나 오래 걸리는지 계속 նկատ했습니다.
마크다운은 서버 전용 파일에서 JSX가 되고, JSX는 Flight 페이로드가 되며, 라우트 코드는 contentRsc를 받았습니다. 모든 변경 사항은 런타임 경계의 어느 쪽을 건드릴 수 있는지 이해해야 했습니다. RSC API 자체는 우리가 요청한 일을 수행했지만, 그 주변 코드는 컨텍스트, 번들러 설정, 의존성 해석, 수동 청킹, 직렬화 경계, 특수 파일, 그리고 라우트에 도달할 때쯤에는 더 이상 콘텐츠처럼 보이지 않는 값들을 계속 쌓아 갔습니다.
대안이 모든 문서 독자에게 Shiki와 기존 마크다운 스택을 전송하는 것이었을 때는 여전히 합리적인 교환이었습니다. 하지만 RSC가 어려운 애플리케이션 문제를 해결하고 있는지, 아니면 대부분 불필요하게 큰 의존성을 브라우저에서 제외하고 있는지 궁금해지기도 했습니다.
첫 번째 성능 개선 작업은 tanstack.com에서 타사 광고를 제거한 뒤에 이루어졌습니다. 광고 스택이 사라지자 남은 자사 비용은 훨씬 보기 쉬워졌고, 문서가 명백한 문제였습니다.
당시 문서 페이지 하나는 약 1.1 MiB의 스크립트를 전송하고 있었습니다. 이 중 약 358 KiB는 구문 강조와 분명히 연관되어 있었으며, 대부분 Shiki, 그 런타임 구성 요소, 테마, 언어 청크였습니다. 마크다운도 여전히 클라이언트 경로에 있었으므로, 브라우저는 문서를 읽기 위해 사실상 작은 출판 시스템을 내려받고 있었습니다.
RSC는 가장 직접적인 방식으로 이 문제를 해결했습니다. 서버에서 마크다운과 강조된 코드를 렌더링하고, 결과를 Flight 데이터로 클라이언트에 전송하며, 거대한 렌더러를 모든 독자에게 전송하지 않는 방식입니다.
첫 전환으로 인한 클라이언트 번들 변화는 상당했습니다.
| 페이지 유형 | 클라이언트 JS 그래프 변화 |
|---|---|
| 블로그 게시물 페이지 | -153 KB gzip |
| 문서 페이지 | -153 KB gzip |
| 문서 예제 페이지 | -40 KB gzip |
프로덕션 페이지도 변화했습니다. /blog/react-server-components는 Lighthouse에서 52에서 74로 올랐고, Total Blocking Time은 1,200ms에서 260ms로 떨어졌으며, 전송 크기는 1,101 KiB에서 785 KiB로 줄었습니다. /router/latest/docs/overview는 78에서 81로 올랐고, TBT는 280ms에서 200ms로 떨어졌으며, 전송 크기는 917 KiB에서 777 KiB로 줄었습니다.
이 글의 나머지를 더 깔끔하게 만들기 위해 그 역사를 다시 쓰고 싶지는 않습니다. RSC는 작동했고, 무거운 클라이언트 코드는 클라이언트로 전송되지 않게 되었으며, 브라우저가 할 일도 줄었습니다. 하지만 이 성과 중 얼마나 많은 부분이 마크다운과 구문 강조에서 비롯됐는지 확인하자 더 명백한 질문이 저를 괴롭히기 시작했습니다.
마크다운 렌더링과 코드 강조에 애초에 왜 358 KiB가 필요했을까요?
RSC는 그 비용을 브라우저에서 제외하는 좋은 방법을 제공했지만, 기반 렌더러 자체를 더 작게 만들지는 않았습니다. 우리는 여전히 거대한 범용 콘텐츠 스택을 가지고 있었고, 이제는 이를 서버에 유지하기 위한 아키텍처도 갖추고 있었습니다. 그 스택을 전송할 수 있을 만큼 작게 만들 수 있다면, 그래도 RSC를 선택할까요?
누가 그럴까요?
TanStack.com이 모든 앱의 답을 줄 수는 없다는 것을 알았고, 틀렸다는 것이 증명되어도 여전히 기쁘겠지만, 당시에는 꽤 실존적인 문제처럼 느껴졌습니다. RSC에 함께 묶이곤 하는 라우팅, 데이터 로딩, 콜로케이션 이야기를 걷어 내면, 제가 계속 돌아오게 되는 구체적인 기술적 이점은 훨씬 단순했습니다. 서버 컴포넌트는 브라우저로 전송되지 않는 로직과 의존성을 사용할 수 있습니다. 의존성이 거대한 마크다운 및 구문 강조 스택일 때 이는 매우 중요합니다. 그런 스택 없이도 같은 기계를 정당화할 만큼 중요한 곳을 찾기는 더 어려웠습니다.
그래서 거대한 의존성이 바로 사용 사례인지 알아보기로 했습니다.
기존 스택을 @tanstack/markdown과 @tanstack/highlight로 교체했습니다. 이는 tanstack.com에 필요한 정확한 마크다운 및 코드 렌더링 계약을 중심으로 구축한 작은 패키지입니다. TanStack Markdown 및 TanStack Highlight 소개에서는 이들이 별도 라이브러리인 이유, 각각이 하는 일, 그리고 작게 유지하는 의도적으로 좁은 계약을 다룹니다.
결과가 JavaScript 0은 아니었지만, 전송하는 것이 더는 무책임하게 느껴지지 않을 만큼 작았습니다. 측정한 프로덕션 라우트에서 명시적인 마크다운 및 코드 렌더러는 약 27 KiB 전송량이며, RSC 버전보다 대략 18에서 19 KiB 더 많습니다.
이 부분이 제게 전체 결정을 바꿨습니다. RSC는 의존성 문제를 아키텍처 결정으로 바꿔 해결하고 있었고, 의존성 문제가 대부분 사라지자 그 아키텍처 전체는 여전히 비용을 치르기를 기다리고 있었습니다.
일반 SSR은 서버 함수를 통해 원시 마크다운과 소스 데이터를 반환하고, 우리가 소유한 패키지로 렌더링하며, 브라우저 페이로드를 건전하게 유지할 수 있었습니다. 라우트가 콘텐츠를 표시하기 전에 콘텐츠가 특별히 직렬화된 React 값이 될 필요도 없어졌습니다. 콘텐츠는 다시 그냥 콘텐츠가 될 수 있었습니다.
현재 tanstack.com 프로덕션을 측정 기간에 아직 RSC 버전을 실행 중이던 old.tanstack.com과 비교했습니다. 이는 2026년 7월 4일 기준으로 프로덕션 URL에 직접 실행한 Lighthouse CLI 모바일 측정이며, 두 번 실행한 평균입니다.
현재 사이트에는 이전 사이트가 분기된 뒤 반영된 일반적인 프로덕션 작업이 포함되어 있으므로, 완벽한 RSC 전용 실험실 테스트는 아닙니다. 아래의 모든 바이트를 RSC 제거에 할당하지도 않을 것입니다. 제가 중요하게 보는 질문은 더 좁습니다. 마크다운과 강조가 작아지고 콘텐츠를 일반 SSR로 되돌린 뒤, RSC가 우리에게 안겨 준 성능 향상을 포기했을까요?
이 라우트에서는 아니었습니다.
| 페이지 | 이전 RSC 점수 | 현재 SSR 점수 | 이전 TBT | 현재 TBT | 이전 바이트 | 현재 바이트 |
|---|---|---|---|---|---|---|
| /blog/react-server-components | 76 | 67 | 139 ms | 66 ms | 1,086 KiB | 889 KiB |
| /router/latest/docs/overview | 71 | 71 | 209 ms | 115 ms | 1,017 KiB | 836 KiB |
이 실행에서는 블로그 Lighthouse 점수가 더 낮고 문서는 동점이므로, 잡음이 있는 점수 하나를 제품의 진실인 양 가장하지는 않겠습니다. 바이트와 메인 스레드 이야기는 훨씬 명확합니다. 현재의 비 RSC 사이트는 원래 RSC 사례에서 가장 중요했던 두 라우트에서 더 작고 Total Blocking Time도 더 낮습니다.
페이로드 세부 내역을 보면 이 교환을 더 쉽게 이해할 수 있습니다.
| 항목 | 이전 RSC 블로그 | 현재 블로그 | 블로그 차이 | 이전 RSC 문서 | 현재 문서 | 문서 차이 |
|---|---|---|---|---|---|---|
| 문서 | 99 KiB | 37 KiB | -62 KiB | 86 KiB | 34 KiB | -52 KiB |
| 앱 JS/에셋 | 526 KiB | 349 KiB | -177 KiB | 543 KiB | 366 KiB | -177 KiB |
| 마크다운/코드 렌더러 | 9 KiB | 27 KiB | +18 KiB | 8 KiB | 27 KiB | +19 KiB |
| CSS | 46 KiB | 52 KiB | +6 KiB | 46 KiB | 52 KiB | +6 KiB |
그렇다면 무엇을 더 전송하고 있을까요? 명시적인 마크다운 및 코드 렌더링 청크가 약 18에서 19 KiB, 여기에 CSS 몇 KiB가 더해진 정도입니다. 177 KiB의 앱 JS 감소 일부는 아마 관련 없는 정리 작업일 것이므로 이를 RSC 차이라고 부르지는 않겠습니다. 하지만 보이는 교환만으로도 충분합니다. RSC에서 벗어나며 작은 렌더러 항목이 추가됐고, 현재 프로덕션은 두 라우트 모두에서 여전히 전체적으로 더 작습니다.
첫 로드 비교는 실제로 현재 아키텍처를 보는 데 가장 불리한 방식입니다. 브라우저가 렌더러 비용을 지불하는 유일한 때이기 때문입니다. 그 이후의 모든 페이지는 교환을 일반 SSR 쪽으로 더 기울게 합니다.
RSC 버전에서는 서버 기반 콘텐츠의 새 조각마다 해당 콘텐츠를 렌더링한 Flight 페이로드가 따라왔습니다. 렌더러 자체는 브라우저에서 제외했지만, 각 내비게이션, 리패치 또는 새 서버 데이터 값은 직렬화된 컴포넌트 트리 비용을 다시 지불하게 할 수 있었습니다.
일반 SSR 버전은 작은 렌더러 비용을 한 번 지불한 뒤, 서버 함수가 변경된 콘텐츠 데이터만 전송합니다. 문서 요청은 마크다운을 보내고, 예제 요청은 소스 데이터를 보내며, 클라이언트는 둘 다 렌더링하는 방법을 이미 알고 있습니다. 누군가 사이트를 이동할 때마다 렌더링된 컴포넌트 트리를 다시 전송하지 않습니다.
로컬 프로덕션 페이로드 점검은 그 방향을 꽤 분명하게 보여 주었습니다.
| 페이로드 | RSC gzip | SSR gzip | 차이 |
|---|---|---|---|
| Query 랜딩 코드 예제 | 4.6 KB | 0.0 KB | -4.6 KB |
| Router 문서 개요 콘텐츠 | 5.2 KB | 3.7 KB | -1.5 KB |
| Router 예제 초기 파일 | 5.6 KB | 1.5 KB | -4.1 KB |
| 무거운 블로그 게시물 콘텐츠 | 15.0 KB | 9.4 KB | -5.6 KB |
평균 방문자는 세션당 약 여섯 페이지를 방문합니다. 따라서 첫 로드 렌더러 비용은 보통 한 번 지불되는 반면, 페이로드 절감은 계속 나타납니다. 위 예시는 해당 콘텐츠를 요청할 때마다 1.5에서 5.6 KB를 절감하며, 한 페이지가 둘 이상의 서버 데이터 값을 요청할 수도 있습니다. 여섯 번째 페이지가 되면 렌더러 비용은 이미 지나간 일이지만, RSC 버전은 그 사이 계속 렌더링된 Flight 페이로드를 전송했을 것입니다.
이는 처음에 우리가 인정한 것보다 훨씬 더 큰 부분입니다. RSC는 재사용 가능한 클라이언트 의존성을 반복되는 직렬화 출력으로 바꿨습니다. 재사용 가능한 의존성이 거대했을 때는 타당했지만, 이를 수십 KiB까지 줄이고 나자 한 번 지불하고 데이터 변경분만 전송하는 방식이 우리 트래픽에 훨씬 잘 맞았습니다.
아키텍처를 시험하기 위해 계속 사용한 질문이 하나 있었습니다. 마크다운은 어디에서 React가 렌더링할 수 있는 무언가가 될까요?
RSC의 답은 fetchDocs 또는 fetchBlogPost에서 시작해 renderMarkdownToRsc를 거치고, processor.rsc.tsx에서 마크다운을 JSX로 렌더링하며, renderRsc.tsx에서 그 JSX를 프래그먼트로 감싸고, renderServerComponent를 호출해 결과를 직렬화한 뒤, 마침내 contentRsc로 라우트에 도달했습니다.
중요한 부분은 다음과 같았습니다.
그 함수 자체에 문제가 있는 것은 아니며, 이것은 TanStack Start RSC 설정이나 헬퍼 API에 대한 비난도 아닙니다. 이들은 우리가 요청한 일을 수행했습니다. 문제는 경계 주변에 축적된 형태였습니다. 라우트 컴포넌트는 contentRsc: React.ReactNode를 받았고, 마크다운을 표시하는 위치는 더 이상 마크다운이 어떻게 마크업이 되는지 설명하지 않았습니다. 그 경로에서 작업하는 모든 사람 또는 코딩 에이전트는 어떤 파일이 일반 React인지, 어떤 파일이 서버 전용 React인지, 어떤 값이 원시 콘텐츠인지, 어떤 값이 Flight 페이로드인지, 어떤 컴포넌트 트리가 직렬화 후에만 존재하는지를 알아야 했습니다.
그 맥락을 배우는 것이 불가능한 것은 아니지만, 평범한 작업을 이상할 정도로 비싸게 만들었습니다. 콘텐츠 변경은 의존성 그래프 문제가 될 수 있었고, 컴포넌트 변경은 서버 경계 문제가 될 수 있었으며, 번들러 동작은 결코 멀리 있지 않았습니다. 각각이 단독으로 터무니없는 것은 아니었습니다. 그저 더는 거대한 의존성을 숨길 필요가 없는 마크다운 파이프라인에 비해 너무 많은 것이었습니다.
마이그레이션 커밋 92b1c481은 콘텐츠 전용 RSC 경로 전체를 제거하거나 이름을 바꿨습니다.
삭제된 항목은 RSC 전용 콘텐츠 경로와 이전 렌더링 플러그인으로 나뉘었습니다.
| 제거된 코드 | 파일 | 제거된 줄 수 |
|---|---|---|
| RSC 전용 콘텐츠 연결 코드 | 9 | 555 |
| 이전 마크다운/렌더링 플러그인 | 8 | 994 |
콘텐츠 경로는 다시 평범해졌습니다. 서버 함수는 콘텐츠 데이터를 반환하고, MarkdownContent는 마크다운 문서를 받아 일반 Markdown 컴포넌트를 렌더링하며, 랜딩 예제는 컴포넌트 데이터가 됩니다. 접힌 영역 아래의 미디어 중심 섹션은 브라우저가 첫 로드에서 너무 많은 작업을 성급하게 예약하지 않도록 여전히 목표를 둔 Hydrate 타이밍을 사용합니다.
이제 브라우저는 렌더링 작업을 조금 더 수행하지만, 그 양은 작고 예측 가능하며 재사용되므로 기꺼이 감수할 만한 교환입니다. 그 대가로 앱을 통과하는 데이터는 다시 원본 자료처럼 보이고, 라우트는 무엇을 렌더링하는지 설명하며, 파일 하나를 열 때 전체 번들러의 정신 모델을 요구하지 않습니다.
첫 RSC 전환이 가짜였거나 잘못된 방향이었다고 생각하지 않습니다. 당시 가진 문제를 해결했고 매우 실제적인 성능 향상을 가져왔습니다. 또한 일반 SSR이 어디서나 RSC를 이긴다는 것을 증명하려는 것도 아닙니다. 한 사이트의 프로덕션 라우트 두 개로는 제가 원해도 그것을 증명할 수 없기 때문입니다.
RSC에 대한 더 폭넓은 홍보에는 더 회의적이게 되었습니다. React 코어와 Next.js는 서버 컴포넌트를 라우팅, 데이터 로딩, 콜로케이션에 관한 훨씬 큰 이야기로 감싸는 경향이 있지만, 여기서 RSC를 가치 있게 만든 것은 그런 것들이 아니었습니다. 실제로 가리킬 수 있었던 기술적 이점은 비싼 컴포넌트 로직과 의존성을 서버에 두고 클라이언트 번들에서 제외하는 것이었습니다. 그리고 이는 거대한 렌더러, 파서, 하이라이터, 포매터, 콘텐츠 파이프라인에서 매우 중요합니다.
그 이점이 기계를 정당화하는 사용 사례가 훨씬 더 많고 우리가 아직 마주치지 못했을 수도 있습니다. 틀렸다는 것이 증명되어도 기쁘겠지만, 가장 강력했던 RSC 사용 사례는 의존성이 작아지자마자 사라졌고, 그것이 아무것도 아니라고 생각하지 않습니다.
우리에게 RSC는 거대한 마크다운 및 강조 스택에서 벗어날 수 있게 해 주었습니다. 그러다 그 스택이 더는 거대하지 않게 되었고, 런타임 경계, 번들러 맥락, 직렬화, 특수 파일, 사람과 코딩 에이전트가 따라가기 더 어려운 콘텐츠 경로의 비용만 남았습니다. 표준 API가 나빴던 것은 아닙니다. 그 아키텍처가 더는 제 몫을 하지 못했을 뿐입니다.
Manuel의 최근 발표, TanStack Start와 React Server Components 지원 방식은 이 변경 직전 우리가 있던 위치를 거의 완벽하게 보여 주는 스냅샷입니다. TanStack.com은 여전히 주요 RSC 실험장이었고, 마크다운과 구문 강조는 설계가 겨냥한 바로 그런 무거운 서버 렌더링 UI였습니다. Manuel은 RSC가 아키텍처가 아니라 프리미티브여야 한다고 분명히 말했습니다. 모든 새 프로젝트의 기본값이 아니라, 타당할 때 사용하는 것이어야 한다는 뜻입니다.
이 글이 TanStack Start의 RSC 지원 뒤에 있는 작업량을 축소해서도 안 됩니다. Manuel은 애플리케이션의 나머지 부분을 Flight 스트림 주위로 돌게 강요하지 않으면서도 Flight 스트림을 사용할 수 있게 만들기 위해 어려운 프레임워크 및 번들러 작업을 상당히 많이 했으며, 우리는 그 작업을 계속 지원할 것입니다.
RSC가 옵트인이라는 사실이 이 결정을 아주 평범하게 만듭니다. TanStack.com은 TanStack Start가 기능을 제거하거나, 방향을 바꾸거나, 다른 모든 Start 사용자에게 우리 의견에 동의하라고 요구하지 않고도 사용을 중단할 수 있습니다.
RSC 지원은 생태계의 체크박스 같은 것이 되기도 했습니다. Next.js는 RSC를 현재 아키텍처의 토대로 만들었기에, 사람들은 RSC가 해결할 것으로 기대하는 문제를 설명하기 훨씬 전에 프레임워크가 RSC를 지원하는지 묻습니다. 오늘날 대부분의 애플리케이션에는 필요하지 않다고 생각하지만, 개발자는 문이 열려 있다는 사실을 알고 싶어 하며, 그것은 타당합니다.
RSC가 이 역할을 영원히 채우는 프리미티브로 남을지도 전혀 알 수 없습니다. 더 나은 프레임워크 프리미티브가 결국 같은 영역을 충분히 포괄할 수도 있습니다. Flight나 이와 비슷한 무언가가 프레임워크에 독립적이 되거나, 플랫폼에 더 가까워지거나, React가 프런트엔드 세계의 중심이 아닌 미래에는 훨씬 덜 중요해질 수도 있습니다. 이런.
지금 좋은 결정을 내리기 위해 그 미래를 예측할 필요는 없습니다. Start는 RSC를 모든 애플리케이션의 필수 중심으로 취급하지 않으면서 선택적 기능으로 지원할 수 있습니다. 솔직히 Manuel의 작업을 보여 주는 가장 좋은 증거는 TanStack Start가 RSC를 계속 지원하는 동안 tanstack.com은 이를 제거할 수 있고, 어느 선택도 다른 선택을 훼손할 필요가 없다는 점일 것입니다.
일반 SSR은 다시 지루한 답이 되었고, tanstack.com에 더 잘 맞습니다. 렌더러는 작고, 측정한 라우트에서는 첫 로드도 여전히 전체적으로 더 작으며, 다음 다섯 페이지는 새로 렌더링된 Flight 페이로드 대신 변경된 콘텐츠 데이터를 전송하고, 라우트를 열면 코드가 스스로를 설명합니다.
서버 경계가 애플리케이션 모델의 일부가 될 자격이 있을 만큼 충분한 일을 하는 경우에는 RSC를 다시 사용할 수 있다고 생각합니다. 하지만 더는 의존성 문제를 숨기기 위해 아키텍처를 선택하고 싶지 않습니다. 의존성을 작게 만들고 콘텐츠가 콘텐츠로 남게 두고 싶습니다.