2026-06-17·[기술 문서]
Next.js의 RSC Payload와 서버 컴포넌트 렌더링
Next.js App Router에서 서버 컴포넌트가 HTML과 RSC Payload로 나뉘어 렌더링되는 흐름을 정리한다.
Next.js App Router를 쓰다 보면 네트워크 탭에서 _rsc가 붙은 요청을 보게 된다.
처음 보면 조금 헷갈린다. 페이지는 이미 HTML로 내려온 것 같은데, 왜 또 RSC 요청이 보일까. 서버 컴포넌트는 서버에서 렌더링된다는데, 그럼 브라우저는 뭘 받는 걸까. 클라이언트 컴포넌트와 섞이면 어디까지 서버에서 그리고, 어디부터 브라우저에서 이어받는 걸까.
서버 컴포넌트는 어디에서 실행될까
App Router에서 page.tsx와 layout.tsx는 기본적으로 서버 컴포넌트다. 파일 상단에 "use client"를 붙이지 않으면 서버에서 실행되는 컴포넌트로 취급된다.
서버 컴포넌트는 브라우저에서 실행되지 않는다.
그래서 서버 컴포넌트 안에서는 이런 일을 할 수 있다.
- 데이터베이스나 내부 API에 가까운 위치에서 데이터를 읽는다.
- 서버에만 있어야 하는 토큰이나 API key를 다룬다.
- 브라우저로 보낼 JavaScript 번들을 늘리지 않는다.
반대로 이런 일은 할 수 없다.
- useState, useEffect 같은 클라이언트 훅을 쓴다.
- onClick 같은 이벤트 핸들러를 직접 붙인다.
- window, localStorage 같은 브라우저 API에 접근한다.
즉 서버 컴포넌트는 "서버에서 미리 계산되는 React 컴포넌트"에 가깝다. 중요한 점은 그 결과가 곧바로 브라우저에서 실행되는 JavaScript 컴포넌트 코드가 아니라는 것이다.
RSC Payload는 HTML이 아니다
여기서 RSC Payload가 등장한다.
Next.js 공식 문서에서는 RSC Payload를 "렌더링된 React Server Components 트리의 compact binary representation"이라고 설명한다. 서버 컴포넌트 트리를 React가 클라이언트에서 이어받을 수 있도록 직렬화한 데이터라고 볼 수 있다.
RSC Payload에는 대략 이런 정보가 들어간다.
- 서버 컴포넌트를 렌더링한 결과
- 클라이언트 컴포넌트가 들어갈 자리
- 클라이언트 컴포넌트 JavaScript 파일에 대한 참조
- 서버 컴포넌트에서 클라이언트 컴포넌트로 넘긴 props
여기서 중요한 구분이 있다.
RSC Payload는 HTML이 아니다. 브라우저가 바로 그릴 DOM 문자열도 아니고, 일반적인 JSON API 응답이라고 생각하는 것도 정확하지 않다. React가 서버 컴포넌트 트리를 클라이언트 쪽 React 트리와 맞추기 위해 사용하는 렌더링 데이터에 가깝다.
그래서 _rsc 요청을 네트워크 탭에서 볼 때는 페이지 전체 HTML 요청과 구분해서 봐야 한다. _rsc 요청은 보통 페이지 전체 HTML을 다시 받기 위한 요청이 아니라, App Router가 다음 화면을 구성하기 위해 필요한 RSC Payload를 가져오는 요청에 가깝다.
예를 들어 서버 컴포넌트가 이런 트리를 렌더링한다고 해보자.
import LikeButton from './LikeButton';
export default async function PostPage() {
const post = await getPost();
return (
<article>
<h1>{post.title}</h1>
<p>{post.summary}</p>
<LikeButton initialCount={post.likeCount} />
</article>
);
}
PostPage는 서버에서 실행된다. getPost()도 서버에서 호출된다. 하지만 LikeButton이 "use client" 컴포넌트라면, 서버는 그 컴포넌트의 클릭 이벤트나 상태 로직을 실행하지 않는다.
실제로 Next.js App Router에서 _rsc 요청을 보내면 응답은 HTML 문서가 아니라 text/x-component로 내려온다. body에는 이런 row들이 이어진다. 아래는 실제 서비스에서 본 RSC Payload 일부다. 너무 긴 chunk 목록만 중간 생략했다.
1:"$Sreact.fragment"
2:I[151330,[
"https://cdn.example.com/_next/static/chunks/3d7qlxvijv1li.js",
"https://cdn.example.com/_next/static/chunks/3_nxonkjqxwuu.js",
"...",
"https://cdn.example.com/_next/static/chunks/0t9g533go_0ix.js"
],"default"]
4:I[552373,[
"https://cdn.example.com/_next/static/chunks/3d7qlxvijv1li.js",
"...",
"https://cdn.example.com/_next/static/chunks/2n02t-3s4kb20.js"
],"Image"]
5:I[649359,[
"https://cdn.example.com/_next/static/chunks/3d7qlxvijv1li.js",
"...",
"https://cdn.example.com/_next/static/chunks/2n02t-3s4kb20.js"
],"CustomLink"]
6:[]
0:{"rsc":["$","$1","c",{"children":[
null,
["$","$L2",null,{
"parallelRouterKey":"children",
"template":["$","$L3",null,{}],
"notFound":[
["$","article",null,{
"className":"flex flex-1 flex-col items-center",
"children":[
["$","section",null,{
"children":[
["$","h1",null,{
"className":"text-[1.5rem] font-800",
"children":["요청하신 페이지를",["$","br",null,{}],"찾을 수 없습니다."]
}],
["$","$L4",null,{
"alt":"404 Not Found",
"src":"/assets/pngs/error/not-found/3.png",
"width":335,
"height":186
}]
]
}],
["$","$L5",null,{
"href":"/",
"children":"홈으로 이동하기"
}]
]
}]
]
}]
]}],"isPartial":false,"staleTime":300,"varyParams":"$W6"}
이 예시를 보면 몇 가지가 보인다.
- 1:"$Sreact.fragment"는 React fragment 같은 심볼을 참조한다.
- I[...] row는 클라이언트에서 로드해야 할 모듈을 가리킨다. 예시의 Image, CustomLink는 실제 컴포넌트 코드가 아니라 module id, chunk URL, export name으로 표현된다.
- 0:{"rsc": ...} 안에는 서버에서 계산된 React tree가 들어간다. ["$","article",null,{...}] 같은 배열 형태는 React element를 직렬화한 표현으로 볼 수 있다.
- "$L4", "$L5" 같은 값은 앞에서 선언된 client reference를 가리킨다. 서버가 <Image />, <CustomLink />를 직접 실행해서 이벤트까지 붙이는 것이 아니라, "여기에 이 클라이언트 참조가 들어간다"는 식으로 연결한다.
실제 RSC Payload의 wire format은 사람이 읽기 좋게 만든 문서 포맷이 아니고, React와 프레임워크가 사용하는 내부 표현에 가깝다. 그래도 대략적인 모양을 보면 "서버에서 실행된 컴포넌트의 결과"와 "클라이언트에서 이어서 로드해야 할 컴포넌트 참조"가 같이 들어간다는 점은 확인할 수 있다.
서버 컴포넌트의 결과와 클라이언트 컴포넌트의 경계는 같은 Payload 안에서 함께 전달된다. 브라우저는 이 정보를 보고 서버에서 이미 계산된 부분은 React 트리에 반영하고, 클라이언트 컴포넌트가 필요한 자리에는 해당 JavaScript를 받아 hydrate한다.
첫 페이지 로드에서는 HTML도 같이 필요하다
그럼 초기 페이지 로드에서는 어떤 일이 일어날까.
Next.js 공식 문서 기준으로 서버에서의 렌더링은 크게 두 단계로 볼 수 있다.
- React가 서버 컴포넌트를 RSC Payload로 렌더링한다.
- Next.js가 RSC Payload와 클라이언트 컴포넌트 JavaScript 정보를 사용해 HTML을 만든다.
초기 로드에서 브라우저는 HTML을 받는다. 그래서 사용자는 JavaScript가 모두 다운로드되고 실행되기 전에도 화면의 비상호작용 미리보기를 볼 수 있다.
그런데 이 HTML 안에는 화면을 그리기 위한 마크업만 들어 있는 게 아니다. Next.js는 초기 렌더링에 필요한 RSC Payload 조각도 HTML stream 안에 inline script로 함께 넣는다. 실제 page source를 보면 이런 형태를 볼 수 있다.
<script>
self.__next_f = self.__next_f || [];
self.__next_f.push([1, "1:\"$Sreact.fragment\"..."]);
</script>
즉 초기 로드에서는 HTML 문서 응답 안에 "먼저 보여줄 마크업"과 "React가 이어서 사용할 Flight/RSC 데이터"가 같이 실려 온다. 브라우저는 HTML로 먼저 화면을 보여주고, React는 self.__next_f에 쌓인 RSC Payload 조각을 읽어 서버 컴포넌트 트리와 클라이언트 컴포넌트 트리를 맞춘다.
그 다음 클라이언트 컴포넌트에 필요한 JavaScript가 실행되면서 이벤트 핸들러와 상태가 붙는다. 이 단계가 흔히 말하는 hydration이다.
흐름을 단순화하면 이렇다.
초기 요청
서버
1. Server Components 실행
2. RSC Payload 생성
3. HTML 생성
4. HTML stream 안에 RSC Payload chunk를 self.__next_f.push(...)로 포함
브라우저
1. HTML로 빠르게 화면 표시
2. self.__next_f에 쌓인 RSC Payload로 React 트리 reconcile
3. Client Component JavaScript hydrate
여기서 서버 컴포넌트 자체가 브라우저에서 hydrate되는 것은 아니다. 브라우저에서 hydrate되는 것은 클라이언트 컴포넌트다. 서버 컴포넌트의 결과는 RSC Payload를 통해 React 트리에 반영된다.
RSC tree는 어떻게 React 렌더링으로 이어질까
RSC Payload를 보면 0:{"rsc":[...]} 안에 React element처럼 보이는 배열이 들어 있다.
["$","article",null,{
"className":"flex flex-1 flex-col items-center",
"children":[
["$","section",null,{...}],
["$","$L5",null,{"href":"/","children":"홈으로 이동하기"}]
]
}]
이 배열을 HTML 문자열로 직접 바꿔서 DOM에 꽂는다고 생각하면 조금 다르다. React는 이 Payload를 읽어서 클라이언트 쪽 React element tree를 다시 구성한다.
위 형태를 JSX처럼 풀어 쓰면 대략 이런 구조다.
<article className="flex flex-1 flex-col items-center">
<section>{/* 서버에서 계산된 children */}</section>
<CustomLink href="/">홈으로 이동하기</CustomLink>
</article>
여기서 "article"이나 "section"처럼 문자열로 표현된 값은 일반 DOM element로 복원될 수 있다. 반면 "$L5" 같은 값은 앞에서 I[...] row로 선언된 client reference를 가리킨다.
5:I[649359,[
"https://cdn.example.com/_next/static/chunks/3d7qlxvijv1li.js",
"..."
],"CustomLink"]
즉 React가 Payload를 읽을 때 하는 일은 크게 두 가지다.
- 서버에서 이미 계산된 element tree를 클라이언트 React tree로 복원한다.
- client reference를 만나면 해당 컴포넌트의 JavaScript chunk와 연결한다.
초기 로드에서는 이미 서버가 만든 HTML이 DOM에 있다. React는 RSC Payload로 만든 tree와 기존 DOM을 맞추고, 클라이언트 컴포넌트 경계에 필요한 JavaScript를 로드해 이벤트와 상태를 붙인다. 그래서 사용자는 먼저 HTML을 보고, 이후 클라이언트 컴포넌트가 상호작용 가능해진다.
클라이언트 navigation에서는 상황이 조금 다르다. 이미 앱이 떠 있으므로 전체 HTML 문서를 새로 받지 않는다. Next.js router가 _rsc 요청으로 새 route segment의 Payload를 받고, React는 이 Payload로 현재 tree를 갱신한다. 기존 layout처럼 재사용 가능한 부분은 유지하고, 바뀐 segment만 새 Payload 기준으로 reconcile한다.
RSC Payload는 브라우저가 직접 그리는 완성 HTML이 아니라, React가 tree를 재구성하기 위한 입력값이다. Next.js는 서버에서 이 입력값을 만들고, 클라이언트 router는 이 입력값을 받아 React 렌더링에 넘긴다.
클라이언트 컴포넌트는 Payload 안에서 자리와 참조로 표현된다
서버 컴포넌트 안에서 클라이언트 컴포넌트를 렌더링하는 예를 생각해보자.
import LikeButton from './LikeButton';
export default async function PostPage() {
const post = await getPost();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton initialCount={post.likeCount} />
</article>
);
}
PostPage는 서버 컴포넌트다. 서버에서 getPost()를 실행하고, 제목과 본문을 렌더링할 수 있다.
하지만 LikeButton이 "use client" 컴포넌트라면 이야기가 달라진다. 서버는 LikeButton의 클릭 이벤트를 실행할 수 없다. 대신 RSC Payload 안에는 "여기에 이 클라이언트 컴포넌트가 들어간다"는 자리 정보와, 해당 컴포넌트의 JavaScript 파일 참조, 그리고 initialCount 같은 props가 포함된다.
브라우저는 나중에 이 정보를 바탕으로 LikeButton JavaScript를 받아 hydrate한다.
그래서 서버 컴포넌트와 클라이언트 컴포넌트는 이런 식으로 역할이 나뉜다.
Server Component
- 서버에서 실행
- 데이터 fetch 가능
- 결과는 RSC Payload에 포함
- 브라우저 JS 번들에 컴포넌트 코드가 포함되지 않음
Client Component
- 브라우저에서 hydrate
- state, effect, event handler 사용 가능
- RSC Payload에는 자리, 참조, props가 포함
- 실제 동작에는 JavaScript 번들이 필요
이 구분을 잡고 나면 "서버 컴포넌트 안에 클라이언트 컴포넌트를 넣으면 전부 클라이언트가 되는가?" 같은 질문도 조금 명확해진다. 서버 컴포넌트 트리 전체가 클라이언트로 넘어가는 것이 아니라, 클라이언트 컴포넌트 경계부터 브라우저에서 실행될 코드가 필요해진다.
이후 페이지 이동에서는 RSC Payload가 더 중요해진다
초기 로드에서는 HTML 응답 안에 RSC Payload 조각이 self.__next_f.push(...) 형태로 같이 들어온다. 하지만 App Router 내부에서 링크를 타고 이동할 때는 매번 전체 HTML 문서를 새로 받을 필요가 없다.
Next.js는 이후 navigation이나 prefetch에서 필요한 route segment의 RSC Payload를 가져온다. 그리고 클라이언트의 Router Cache에 저장된 값이 있으면 그것을 재사용할 수 있다.
그래서 네트워크 탭에서 이런 요청을 볼 수 있다.
GET /posts/nextjs-rsc?_rsc=...
이 요청은 "브라우저가 다음 화면을 만들기 위해 필요한 서버 컴포넌트 렌더링 결과를 가져오는 요청"으로 이해하면 된다.
공식 문서에서도 Router Cache는 React Server Component Payload를 route segment 단위로 저장한다고 설명한다. 즉 App Router의 빠른 이동 경험은 HTML을 매번 새로 받는 방식이라기보다, RSC Payload를 가져오고 캐시하고 React 트리에 반영하는 방식에 가깝다.
<Link>가 viewport에 들어오거나 사용자가 hover할 때 Next.js는 다음 route의 RSC Payload를 미리 가져올 수 있다. 그래서 페이지 이동 전에도 _rsc 요청이 보일 수 있다.
Full Route Cache와 RSC Payload
Full Route Cache는 브라우저 메모리에 있는 Router Cache가 아니라, Next.js 서버 쪽에 저장되는 route render 결과 캐시다.
Next.js가 어떤 route를 정적으로 렌더링할 수 있다고 판단하면, 매 요청마다 서버 컴포넌트 트리를 다시 실행하지 않아도 된다. 한 번 렌더링한 route 결과를 서버에 저장해두고, 다음 요청에서는 그 결과를 재사용할 수 있다. 이 서버 측 캐시가 Full Route Cache다.
여기서 "route 결과"는 HTML 하나만 뜻하지 않는다. 같은 React component tree에서 두 가지 산출물이 나온다.
route render
├─ HTML
│ └─ document request에서 브라우저에 보여줄 초기 화면
└─ RSC Payload
└─ client navigation, prefetch, React tree reconcile에 필요한 데이터
Full Route Cache는 이 둘을 같은 route render 결과로 저장한다.
Full Route Cache
/posts/rsc-payload
├─ HTML
└─ RSC Payload
그래서 요청 종류에 따라 같은 캐시 항목에서 꺼내 쓰는 산출물이 달라진다.
브라우저 새로고침 / 직접 진입
→ document request
→ Full Route Cache에서 HTML 사용
→ HTML 안에는 self.__next_f.push(...)로 초기 RSC Payload 조각도 포함
App Router 내부 이동 / prefetch
→ RSC request
→ Full Route Cache에서 RSC Payload 사용
→ React가 현재 tree에 새 route segment를 reconcile
브라우저가 새로고침으로 들어올 때는 HTML이 필요하고, 앱 내부에서 이동할 때는 RSC Payload가 필요하다. 응답 형태는 다르지만, 둘은 같은 route render에서 나온 결과여야 한다.
그래서 revalidation이 일어나면 HTML만 새로 만드는 것이 아니라 RSC Payload도 같은 기준으로 다시 만들어져야 한다. 둘이 서로 다른 시점의 결과를 가리키면, 브라우저 새로고침으로 본 화면과 클라이언트 navigation으로 본 화면이 달라질 수 있다.
여기서 Router Cache와도 구분해야 한다. Full Route Cache가 서버 쪽에서 HTML과 RSC Payload를 저장한다면, Router Cache는 브라우저 메모리에서 route segment 단위의 RSC Payload를 들고 있다가 다음 navigation에 재사용한다.
Full Route Cache
- 위치: Next.js 서버
- 저장 대상: HTML + RSC Payload
- 목적: 같은 route render 결과 재사용
Router Cache
- 위치: 브라우저 메모리
- 저장 대상: route segment 단위 RSC Payload
- 목적: client navigation 빠르게 처리
둘 다 RSC Payload와 관련이 있지만 위치와 목적이 다르다. Full Route Cache는 서버가 렌더링 결과를 다시 만들지 않기 위한 캐시이고, Router Cache는 브라우저가 이미 받은 RSC Payload를 다시 요청하지 않기 위한 캐시다.
Next.js 버전별로 달라진 지점
여기까지 설명한 RSC Payload의 큰 흐름은 특정 버전 하나에만 묶인 이야기는 아니다. App Router에서 서버 컴포넌트를 렌더링하고, 그 결과를 RSC Payload로 전달하고, 클라이언트에서 그 Payload를 React 트리에 반영한다는 구조는 계속 이어진다.
다만 Next.js 버전에 따라 "그 결과를 얼마나 캐시할 것인가", "클라이언트 라우터가 어떤 segment를 얼마나 재사용할 것인가"는 달라졌다.
Next.js 14까지의 감각
Next.js 14까지는 App Router의 여러 캐시가 비교적 적극적으로 동작한다는 인상이 강했다. 정적으로 렌더링 가능한 route는 HTML과 RSC Payload가 함께 캐시될 수 있고, 클라이언트 Router Cache도 방문하거나 prefetch한 route segment를 재사용한다.
이 시기에는 "왜 최신 데이터가 바로 안 보이지?"라는 질문을 자주 하게 된다. 서버 컴포넌트가 서버에서 다시 실행되지 않은 것인지, fetch 결과가 재사용된 것인지, Router Cache가 들고 있는 RSC Payload를 다시 쓰는 것인지 구분해야 했다.
Next.js 15: 기본 캐시를 덜 믿는 방향
Next.js 15에서는 캐시 기본값이 더 보수적으로 바뀌었다. 공식 릴리즈 노트 기준으로 fetch 요청, GET Route Handler, Client Router Cache의 기본 동작이 "기본 캐시"에서 "기본 비캐시"에 가까운 방향으로 이동했다.
이 변화가 RSC Payload의 역할 자체를 바꾸는 것은 아니다. 여전히 서버 컴포넌트의 렌더링 결과는 RSC Payload로 전달된다. 달라지는 것은 그 Payload를 언제 새로 만들고, 언제 재사용하느냐에 가깝다.
예를 들어 같은 페이지 이동이라도 14에서는 이전에 만들어둔 segment를 더 쉽게 재사용한다고 느낄 수 있고, 15에서는 page segment 쪽 최신성을 더 우선한다고 느낄 수 있다. 하지만 shared layout, loading state, browser back/forward처럼 여전히 재사용되는 영역은 남아 있다.
Next.js 16: Cache Components와 명시적 캐시
Next.js 16의 큰 변화는 Cache Components다. cacheComponents: true를 켜는 모델에서는 동적 코드는 기본적으로 request time에 실행되고, 캐시하고 싶은 컴포넌트나 함수에 "use cache"를 명시한다.
이 방향은 RSC Payload를 이해할 때도 도움이 된다. 이제는 "서버 컴포넌트니까 알아서 캐시되겠지"보다, 어떤 서버 렌더링 결과를 캐시할 것인지 코드에서 더 명확히 드러내는 쪽에 가깝다.
이 차이를 표로 줄이면 이렇다.
RSC Payload의 역할
- 서버 컴포넌트 렌더링 결과를 클라이언트 React 트리에 전달
- App Router 전반에서 유지되는 핵심 구조
버전별로 주로 달라진 것
- RSC Payload 자체의 개념보다는 캐시 기본값
- Router Cache가 어떤 segment를 재사용하는지
- 정적/동적 렌더링과 revalidation을 얼마나 명시적으로 다루는지
그래서 이 글의 렌더링 설명은 Next.js 16만의 이야기가 아니다. 다만 현재 Next.js 문서와 16의 Cache Components 흐름까지 고려하면, RSC Payload는 "서버 컴포넌트 렌더링 결과"이면서 "캐시될 수 있는 렌더링 산출물"이기도 하다.
Streaming은 RSC Payload를 조각내서 보낸다
서버 컴포넌트 렌더링은 한 번에 끝나야만 하는 작업이 아니다.
Next.js는 route segment와 Suspense boundary 단위로 렌더링 작업을 나눌 수 있다. 준비된 부분은 먼저 보내고, 느린 데이터가 필요한 부분은 나중에 이어서 보낼 수 있다.
예를 들어 이런 구조가 있다고 해보자.
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</>
);
}
Header는 빨리 렌더링되고, ProductList는 느린 API를 기다려야 한다면 전체 페이지가 ProductList 때문에 멈출 필요는 없다. Suspense boundary를 기준으로 먼저 보여줄 수 있는 부분을 보내고, 나중에 ProductList의 서버 컴포넌트 결과를 이어서 보낼 수 있다.
이때도 HTML만 스트리밍되는 것은 아니다. React와 Next.js는 서버 컴포넌트 트리의 렌더링 결과를 RSC Payload의 chunk로 나눠 전달하고, 클라이언트는 그 조각들을 받아 React 트리를 점진적으로 완성한다.
그래서 App Router에서 Suspense는 단순 로딩 UI 컴포넌트가 아니다. 서버 렌더링 작업을 어디서 끊고, 어떤 단위로 먼저 보낼지 결정하는 경계이기도 하다.
정리
Next.js App Router에서 서버 컴포넌트를 이해하려면 HTML만 보면 부족하다. 서버 컴포넌트의 렌더링 결과는 RSC Payload라는 별도 데이터로 만들어지고, Next.js는 이 데이터를 사용해 초기 HTML을 만들고 이후 navigation에서도 React 트리를 갱신한다.
- 서버 컴포넌트는 서버에서 실행된다.
- 서버 컴포넌트의 결과는 RSC Payload에 담긴다.
- RSC Payload는 HTML이 아니라 React가 트리를 맞추기 위해 쓰는 렌더링 데이터다.
- 초기 로드에서는 HTML로 먼저 보여주고, RSC Payload로 React 트리를 맞추고, 클라이언트 컴포넌트를 hydrate한다.
- 이후 navigation과 prefetch에서는 RSC Payload 요청이 핵심 경로가 된다.
- Suspense boundary는 RSC Payload를 나눠 보내는 streaming 경계로도 작동한다.
이 흐름을 알고 나면 _rsc 요청이 훨씬 덜 낯설어진다. RSC Payload는 Next.js 내부 구현 디테일처럼 보이지만, App Router의 렌더링·캐시·프리패칭 문제를 디버깅할 때 결국 자주 만나게 되는 데이터다.