
원문: https://tanstack.com/blog/we-stopped-using-rsc-on-tanstack-com
Tanner Linsley · 2026년 7월 24일
올해 초, tanstack.com은 제가 가장 좋아하는 리액트 서버 컴포넌트(React Server Components, RSC) 사례가 되었습니다. 콘텐츠가 많은 페이지가 거대한 마크다운·구문 강조(syntax highlighting) 스택을 브라우저로 전송하고 있었는데, 그 작업을 서버로 옮기자 상당한 양의 자바스크립트가 사라졌고 사이트는 빨라졌습니다. 우리는 그 과정을 글로 쓰고 측정했으며, RSC가 해결해야 할 바로 그런 종류의 문제였기에 결과에 꽤 만족했습니다.
성능 개선 효과는 분명했습니다. 하지만 이 아키텍처를 한동안 운영해 보니, 꽤 따분했어야 할 콘텐츠 파이프라인을 설명하는 데 시간이 얼마나 걸리는지 자꾸 눈에 들어왔습니다.
마크다운은 서버 전용 파일에서 JSX가 되었고, JSX는 Flight 페이로드가 되었으며, 라우트 코드는 contentRsc를 전달받았고, 어떤 변경이든 런타임 경계의 어느 쪽을 건드려도 되는지 알아야 했습니다. RSC API 자체는 우리가 요청한 대로 동작했지만, 그 주변 코드는 컨텍스트, 번들러 설정, 의존성 해석, 수동 청킹, 직렬화 경계, 특수 파일, 라우트에 도착할 즈음에는 더 이상 콘텐츠처럼 보이지 않는 값들을 계속 쌓아 갔습니다.
대안이 모든 문서 독자에게 Shiki와 기존 마크다운 스택을 전송하는 것뿐이었을 때는 여전히 합리적인 트레이드오프였습니다. 그러면서도 RSC가 어려운 애플리케이션 문제를 해결하고 있는 것인지, 아니면 대부분은 쓸데없이 큰 의존성을 브라우저 밖에 두는 역할만 하는 것인지 의문이 들었습니다.
첫 번째 성능 개선 작업은 tanstack.com에서 서드파티 광고를 제거한 뒤에 진행되었습니다. 광고 스택이 사라지자 남아 있는 퍼스트파티(first-party) 비용이 훨씬 잘 보였고, 문서 페이지가 명백한 문제였습니다.
당시 문서 페이지 하나가 약 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에 곧잘 함께 묶이는 라우팅, 데이터 로딩, 코로케이션(colocation) 이야기를 걷어내고 나면, 제가 자꾸 되돌아오는 구체적인 기술 이점은 훨씬 단순했습니다. 서버 컴포넌트는 브라우저로 절대 전송되지 않는 로직과 의존성을 쓸 수 있습니다. 의존성이 거대한 마크다운·구문 강조 스택이라면 이 이점은 엄청나게 중요합니다. 그런 의존성이 없는 상황에서 같은 장치 일체를 정당화할 만큼 이 이점이 중요한 곳은 좀처럼 찾기 어려웠습니다.
그래서 우리는 그 거대한 의존성이 곧 유스케이스(use case)였는지 직접 확인해 보기로 했습니다.
우리는 기존 스택을 @tanstack/markdown과 @tanstack/highlight로 교체했습니다. tanstack.com에 필요한 마크다운·코드 렌더링 계약에 딱 맞춰 만든 작은 패키지입니다. Introducing TanStack Markdown and TanStack Highlight에서 왜 두 라이브러리를 분리했는지, 각각 무슨 일을 하는지, 이 둘을 작게 유지해 주는 의도적으로 좁힌 계약이 무엇인지 다룹니다.
결과물이 자바스크립트 제로는 아니었지만, 전송하는 일이 더 이상 무책임하게 느껴지지 않을 만큼 작아졌습니다. 우리가 측정한 프로덕션 라우트에서 명시적인 마크다운·코드 렌더러는 전송 기준 약 27 KiB로, RSC 버전보다 대략 18~19 KiB 더 큰 정도입니다.
바로 이 지점이 제 결정을 통째로 바꿔 놓았습니다. RSC는 의존성 문제를 아키텍처 결정으로 바꾸는 방식으로 해결해 왔는데, 의존성 문제가 대부분 사라지고 나자 그 아키텍처 전부가 여전히 그 자리에 남아 우리가 대가를 치르기를 기다리고 있었습니다.
일반 SSR이라면 서버 함수로 원시 마크다운과 소스 데이터를 반환하고, 우리가 소유한 패키지로 렌더링하고, 브라우저 페이로드를 정상 범위로 유지할 수 있습니다. 더 이상 라우트가 콘텐츠를 표시하려고 콘텐츠를 특별하게 직렬화된 리액트 값으로 만들 필요가 없었습니다. 콘텐츠는 그냥 다시 콘텐츠면 됐습니다.
우리는 현재 프로덕션인 tanstack.com을 측정 기간 동안 여전히 RSC 버전으로 운영 중이던 old.tanstack.com과 비교했습니다. 프로덕션 URL을 대상으로 Lighthouse CLI 모바일 측정을 직접 돌렸고, 2026년 7월 4일에 두 번 돌려 평균을 낸 값입니다.
현재 사이트에는 기존 사이트가 분리된 이후 반영한 일반적인 프로덕션 작업이 들어 있습니다. 그러니 이번 비교는 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 문서 | 현재 문서 | 문서 증감 |
|---|---|---|---|---|---|---|
| 문서(document) | 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의 명시적인 마크다운·코드 렌더링 청크와 몇 KiB의 CSS입니다. 177 KiB에 달하는 앱 JS 감소분 일부는 아마 RSC와 무관한 정리 작업 덕분일 테고, 그래서 저는 그 수치를 RSC 증감분이라고 부르지 않겠습니다. 하지만 눈에 보이는 트레이드오프만으로도 이미 충분합니다. RSC에서 벗어나면서 작은 렌더러 항목이 추가되었지만, 현재 프로덕션은 두 라우트 모두에서 여전히 전체적으로 더 작습니다.
사실 첫 로드 비교는 현재 아키텍처를 바라보는 가장 불리한 관점입니다. 브라우저가 렌더러 비용을 지불하는 순간은 그때뿐이기 때문입니다. 그 이후의 모든 페이지는 트레이드오프를 일반 SSR 쪽으로 더 기울입니다.
RSC 버전에서는 서버가 제공하는 새 콘텐츠 조각마다 그 콘텐츠를 렌더링한 Flight 페이로드가 함께 왔습니다. 렌더러 자체는 브라우저 밖에 두었지만, 내비게이션, 리페치(refetch), 새로운 서버 데이터 값이 생길 때마다 직렬화된 컴포넌트 트리 비용을 다시 지불해야 할 수 있었습니다.
일반 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 수준으로 줄이고 나니 한 번만 지불하고 변경된 데이터만 보내는 방식이 우리 트래픽에 훨씬 잘 맞았습니다.
아키텍처를 시험할 때 제가 계속 사용한 질문이 하나 있습니다. 마크다운은 어디에서 리액트가 렌더링할 수 있는 무언가가 될까요?
RSC의 답은 fetchDocs나 fetchBlogPost에서 시작해 renderMarkdownToRsc를 거치고, processor.rsc.tsx에서 마크다운을 JSX로 렌더링하고, renderRsc.tsx에서 그 JSX를 프래그먼트로 감싸고, renderServerComponent를 호출하고, 결과를 직렬화한 다음, 마침내 contentRsc로 라우트에 도착했습니다.
핵심 부분은 이런 모습이었습니다.
import { renderServerComponent } from '@tanstack/react-start/rsc'
import * as React from 'react'
import { renderMarkdownToJsx } from './processor.rsc'
export async function renderMarkdownToRsc(content: string) {
const { content: contentJsx, headings } = await renderMarkdownToJsx(content)
const contentRsc = await renderServerComponent(
React.createElement(React.Fragment, null, contentJsx),
)
return {
contentRsc,
headings,
}
}
이 함수 자체에 거슬리는 점은 없고, 이 글의 어떤 내용도 TanStack Start의 RSC 설정이나 헬퍼 API를 성토하려는 것이 아닙니다. 그 설정과 API는 우리가 요청한 일을 해냈습니다. 문제는 경계 주변에 쌓여 간 형태였습니다. 라우트 컴포넌트는 contentRsc: React.ReactNode를 전달받았고, 마크다운을 표시하는 곳은 더 이상 마크다운이 어떻게 마크업이 되는지 설명하지 않았으며, 그 경로에서 일하는 모든 사람과 코딩 에이전트는 어떤 파일이 일반 리액트이고, 어떤 파일이 서버 전용 리액트이고, 어떤 값이 원시 콘텐츠이고, 어떤 값이 Flight 페이로드이고, 어떤 컴포넌트 트리가 직렬화 이후에만 존재하는지 알아야 했습니다.
이런 컨텍스트를 배우는 일이 불가능하지는 않지만, 평범한 작업이 이상하리만치 비싸게 느껴졌습니다. 콘텐츠 변경이 의존성 그래프 문제로 번질 수 있었고, 컴포넌트 변경이 서버 경계 문제로 번질 수 있었으며, 번들러 동작은 결코 멀리 있지 않았습니다. 그중 어느 하나도 그 자체로 터무니없는 것은 아니었습니다. 다만 더 이상 거대한 의존성을 숨길 필요가 없어진 마크다운 파이프라인치고는 그런 것들이 너무 많았을 뿐입니다.
마이그레이션 커밋 92b1c481은 콘텐츠 전용 RSC 경로 전체를 제거하거나 이름을 바꿨습니다.
src/utils/markdown/processor.rsc.tsxsrc/utils/markdown/renderRsc.tsxsrc/components/markdown/CodeBlock.server.tsxsrc/components/markdown/renderCodeBlock.server.tsxsrc/utils/landing-code-example.functions.tssrc/components/landing/LandingCodeExampleCard.server.tsxsrc/components/landing/codeExamples.server.tsxsrc/components/markdown/MarkdownHeadingContext.tsx삭제된 코드는 RSC 전용 콘텐츠 경로와 기존 렌더링 플러그인으로 나뉩니다.
| 제거된 코드 | 파일 수 | 삭제된 줄 수 |
|---|---|---|
| RSC 전용 콘텐츠 연결 코드 | 9 | 555 |
| 기존 마크다운/렌더링 플러그인 | 8 | 994 |
콘텐츠 경로는 다시 따분해졌습니다. 서버 함수는 콘텐츠 데이터를 반환하고, MarkdownContent는 마크다운 문서를 받아 일반 Markdown 컴포넌트를 렌더링하고, 랜딩 예제는 컴포넌트 데이터이며, 스크롤 아래쪽의 미디어 위주 섹션은 여전히 선별적인 Hydrate 타이밍을 사용해 브라우저가 첫 로드에 너무 많은 작업을 성급하게 예약하지 않도록 합니다.
지금은 브라우저가 렌더링 작업을 조금 더 합니다. 그 양이 작고, 예측 가능하고, 재사용되기 때문에 저로서는 기꺼이 받아들일 만한 트레이드오프입니다. 그 대가로 앱 안을 흐르는 데이터는 다시 원본 자료처럼 보이고, 라우트는 자신이 무엇을 렌더링하는지 설명하며, 파일 하나를 여는 데 번들러 전체의 멘털 모델이 필요하지 않습니다.
우리의 첫 RSC 전환이 가짜였거나 잘못된 판단이었다고 생각하지 않습니다. 당시 우리가 겪던 문제를 해결했고 아주 실질적인 성능 개선을 안겨 주었습니다. 일반 SSR이 모든 곳에서 RSC를 이긴다고 증명하려는 것도 아닙니다. 한 사이트의 프로덕션 라우트 두 개로는 제가 원한다 해도 그런 것을 증명할 수 없기 때문입니다.
동시에 더 폭넓은 RSC 홍보에는 전보다 회의적입니다. 리액트 코어와 Next.js는 서버 컴포넌트를 라우팅, 데이터 로딩, 코로케이션에 관한 훨씬 큰 이야기로 포장하는 경향이 있지만, 여기서 RSC를 가치 있게 만든 것은 그중 무엇도 아니었습니다. 우리가 실제로 짚을 수 있었던 기술 이점은 비싼 컴포넌트 로직과 의존성을 서버에 두고 클라이언트 번들에서 빼는 것이었습니다. 거대한 렌더러, 파서, 하이라이터, 포매터, 콘텐츠 파이프라인에는 엄청나게 중요한 이점입니다.
그 이점이 이 모든 장치를 정당화하는 유스케이스는 훨씬 더 많은데 아직 우리가 만나 보지 못한 것일 수도 있습니다. 제가 틀렸다면 기꺼이 인정하겠습니다. 하지만 우리의 가장 강력한 RSC 유스케이스는 의존성이 작아지자마자 사라졌고, 그 사실이 아무 의미 없는 일이라고는 생각하지 않습니다.
RSC는 우리를 거대한 마크다운·구문 강조 스택에서 벗어나게 해 주었습니다. 그런데 그 스택이 더 이상 거대하지 않게 되자, 남은 것은 런타임 경계, 번들러 컨텍스트, 직렬화, 특수 파일, 그리고 사람과 코딩 에이전트가 따라가기 더 어려운 콘텐츠 경로에 치르는 비용뿐이었습니다. 표준 API가 나빴던 것이 아니라, 아키텍처가 더 이상 밥값을 하지 못하게 된 것뿐입니다.
Manuel의 최근 발표인 TanStack Start and How It Supports React Server Components는 이 변화 직전에 우리가 서 있던 지점을 거의 완벽하게 담은 스냅샷입니다. TanStack.com은 여전히 우리의 주요 RSC 놀이터였고, 마크다운과 구문 강조는 이 설계가 겨냥한 무거운 서버 렌더링 UI의 전형이었습니다. Manuel은 RSC가 아키텍처가 아니라 프리미티브(primitive)여야 한다는 점, 모든 새 프로젝트의 기본값이 아니라 말이 될 때 사용하는 것이어야 한다는 점을 분명히 했습니다.
이 글이 TanStack Start의 RSC 지원 뒤에 있는 작업량을 깎아내리는 것도 원치 않습니다. Manuel은 애플리케이션의 나머지 부분이 Flight 스트림 주위를 공전하도록 강요하지 않으면서도 Flight 스트림을 쓸 수 있게 하려고 어려운 프레임워크·번들러 작업을 상당히 많이 해냈습니다. 우리는 그 작업을 계속 지원합니다.
RSC가 옵트인(opt-in)이기 때문에 이번 결정은 이토록 대수롭지 않은 일이 됩니다. TanStack Start가 기능을 제거하거나, 방향을 바꾸거나, 다른 모든 Start 사용자에게 우리와 같은 생각을 하라고 요구하지 않아도, tanstack.com은 RSC 사용을 중단할 수 있습니다.
RSC 지원은 생태계의 체크박스 같은 것이 되기도 했습니다. Next.js가 현재 아키텍처의 근간을 RSC로 삼으면서, 사람들은 RSC가 해결해 주리라 기대하는 문제를 설명할 수 있게 되기 한참 전부터 프레임워크가 RSC를 지원하는지부터 묻습니다. 저는 오늘날 대부분의 애플리케이션에 서버 컴포넌트가 필요하지 않다고 생각하지만, 개발자들은 여전히 문이 열려 있는지 알고 싶어 하고, 그것은 정당한 요구입니다.
RSC가 이 역할을 영원히 맡는 프리미티브로 남을지도 저는 알 수 없습니다. 더 나은 프레임워크 프리미티브가 결국 같은 영역을 충분히 대신하게 될 수도 있습니다. Flight나 그와 비슷한 무언가가 프레임워크에 구애받지 않게 되거나, 플랫폼에 더 가까워지거나, 리액트가 프런트엔드 세계의 중심이 아닌 미래에는 훨씬 덜 중요해질 수도 있습니다. 헉.
지금 좋은 결정을 내리기 위해 그런 미래를 예측할 필요는 없습니다. Start는 RSC를 모든 애플리케이션의 필수 중심으로 취급하지 않으면서 선택적 기능으로 지원할 수 있습니다. 솔직히 Manuel의 작업을 가장 잘 증명하는 것은, tanstack.com이 RSC를 걷어내는 동안에도 TanStack Start는 RSC를 계속 지원할 수 있고, 어느 쪽 선택도 다른 쪽을 훼손하지 않아도 된다는 사실일지 모릅니다.
일반 SSR은 다시 따분한 답이 되었고, tanstack.com에는 그편이 더 잘 맞습니다. 렌더러는 아주 작고, 우리가 측정한 라우트에서 첫 로드는 여전히 전체적으로 더 작으며, 이후 다섯 페이지는 새로 렌더링된 Flight 페이로드 대신 변경된 콘텐츠 데이터만 보내고, 라우트를 열어 보면 코드가 스스로를 설명합니다.
서버 경계가 애플리케이션 모델의 일부가 될 자격이 있을 만큼 충분한 일을 하고 있다면 언젠가 RSC를 다시 사용하는 모습도 상상할 수 있습니다. 하지만 의존성 문제를 숨기려고 아키텍처에 손을 뻗는 일은 이제 하고 싶지 않습니다. 그보다는 의존성을 작게 만들고, 콘텐츠는 콘텐츠로 남겨 두고 싶습니다.
충격이네요..이제는 내 사이트에 딱 맞춘 도구를 만들 수 있음에도 무거운 도구를 브라우저 밖으로만 옮기진 않았는지 검토해봐야겠습니다. 좋은 글 번역 감사드립니다!