React Server Components란 무엇인가요?
- 면접 출제
- ★
- 예상 시간
- 8분
React Server Components(RSC)는 서버에서만 실행되는 컴포넌트입니다. 클라이언트 번들에 포함되지 않아 번들 크기를 줄일 수 있고, 서버에서 직접 DB나 파일시스템에 접근해 데이터를 가져올 수 있습니다. 대신 상태(useState)나 이벤트 핸들러(onClick)는 사용할 수 없습니다.
RSC가 나온 배경은 SPA 시대의 두 가지 문제입니다.
첫 번째는 번들 크기입니다. 서버에서만 필요한 라이브러리(마크다운 파서, 날짜 포맷터 등)도 클라이언트 번들에 포함됩니다. 사용자는 쓰지도 않는 코드를 다운로드하고 파싱해야 했습니다.
두 번째는 데이터 fetching waterfall입니다. 컴포넌트가 마운트되어야 fetch를 시작할 수 있어서, 중첩된 컴포넌트는 부모 → 자식 순서로 순차적으로 데이터를 받습니다. 화면이 의미 있게 채워지기까지 여러 번의 왕복이 필요했습니다.
RSC는 컴포넌트 자체를 서버에서 실행해 이 두 문제를 해결합니다. 서버에서 실행된 결과만 클라이언트로 내려와 번들에 라이브러리가 포함될 필요가 없고, 서버에서 직접 DB에 접근해 데이터를 가져오므로 클라이언트와 서버 사이의 왕복이 줄어듭니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
서버에서만 실행되는데 왜 컴포넌트라고 부르나요?
컴포넌트의 본질이 DOM 조각이 아니라 props를 받아 UI를 설명하는(JSX를 반환하는) 함수이기 때문입니다. DOM은 그 설명을 렌더러가 처리한 결과물 중 하나일 뿐입니다.
react.dev는 컴포넌트를 'a JavaScript function that you can sprinkle with markup'이라고 정의합니다. DOM을 만드는 함수라고 정의하지 않습니다.
이건 RSC 이전부터 이미 그랬습니다. 같은 컴포넌트 모델(props를 받고 JSX를 반환하고 서로 합성되는 구조)이 렌더러만 바뀌면 완전히 다른 결과물을 만듭니다. react-dom은 DOM, react-native는 iOS/Android 네이티브 뷰, react-pdf는 PDF, ink는 터미널 텍스트로 렌더링합니다.
서버 컴포넌트도 마찬가지입니다. props를 받고, JSX를 반환하고, 다른 컴포넌트의 children으로 끼워지는 동일한 합성 모델을 그대로 따릅니다. 다른 건 어디서 실행되는지(서버)와 결과물이 뭐가 되는지(RSC payload로 직렬화)뿐이라, '컴포넌트'라는 이름을 그대로 씁니다.
다만 웹앱에서는 서버 컴포넌트가 만든 결과가 DOM과 아예 무관하진 않습니다. 그 payload를 클라이언트가 받아 결국 HTML/DOM으로 반영합니다. 컴포넌트 자체가 실행되는 시점·환경에 DOM/브라우저가 없다는 것이지, 최종 결과가 DOM에 안 쓰인다는 뜻은 아닙니다.
RSC와 클라이언트 컴포넌트(RCC)의 차이는?
RSC는 서버에서만 실행되고 클라이언트 번들에 포함되지 않습니다. RCC는 클라이언트에서 실행되며 상태, 이벤트 핸들러, 브라우저 API를 쓸 수 있습니다.
| RSC | RCC | |
|---|---|---|
| 실행 위치 | 서버 | 클라이언트 |
| 번들 포함 | 없음 | 있음 |
| useState / 이벤트 | 불가 | 가능 |
| DB / 파일 직접 접근 | 가능 | 불가 |
| Hydration | 없음 | 있음 |
RSC와 RCC는 같은 React 컴포넌트처럼 보이지만 실행 환경이 다릅니다. RSC는 서버에서 렌더링된 결과가 React의 직렬화 가능한 형태(RSC Payload)로 클라이언트에 전달됩니다. 클라이언트는 이 payload를 받아 DOM에 반영하지만, RSC 코드 자체는 클라이언트로 오지 않습니다.
RCC는 'use client' 경계 이후에 있는 컴포넌트 트리입니다. 클라이언트 번들에 포함되고 브라우저에서 실행됩니다. RSC 안에 RCC를 자식으로 넣을 수 있지만, RCC 안에 RSC를 직접 import하면 RCC로 변환됩니다. RSC를 RCC의 children prop으로 넘기는 패턴을 쓰면 RSC 특성을 유지할 수 있습니다.
RSC에서 useState를 쓸 수 없는 이유는?
useState는 값을 메모리에 들고 있다가 다시 렌더링될 때 꺼내 쓰는 방식으로 동작하는데, 서버에는 이 값을 계속 들고 있을 실행 환경이 없기 때문입니다.
이 값을 들고 있는 대상(React의 fiber)은 setState가 호출될 때까지 계속 살아있어야 합니다. 브라우저는 탭이 열려있는 동안 JS 프로세스가 안 꺼지니 가능하지만, 서버는 요청 하나 처리하고 응답을 보내면 그 실행 컨텍스트가 끝납니다. 다음에 뭔가 바뀌었을 때 이어서 실행할 대상 자체가 남아있지 않습니다.
RSC에서 onClick 같은 이벤트 핸들러를 쓸 수 없는 이유는?
onClick은 Server Component의 코드 자체가 애초에 client bundle에 포함되지 않고, 함수는 직렬화도 할 수 없다는 두 가지 이유로 쓸 수 없습니다. useState와는 다른 문제입니다.
Server Component와 Client Component는 겉보기엔 똑같은 컴포넌트지만, 빌드 시점에 완전히 다르게 취급됩니다. 'use client'가 붙은 파일은 그 코드 전체가 client bundle에 실제로 포함되어 브라우저로 전송됩니다. onClick 핸들러가 클릭되는 순간 실행될 수 있는 건, 그 함수 코드 자체가 번들 안에 물리적으로 존재하기 때문입니다.
반면 Server Component는 정의상 코드 자체를 클라이언트로 절대 보내지 않는 컴포넌트입니다. 서버에서 실행한 결과(RSC payload)만 보내고, 소스 코드는 번들에 포함되지 않습니다. 그래서 Server Component 안에 onClick을 적어도, 그 함수가 실행될 코드 자체가 브라우저에 존재할 방법이 없습니다. Client Component처럼 코드를 번들에 넣기로 설계됐다면 가능했겠지만, Server Component는 정의상 그 번들링 과정에서 빠지는 컴포넌트입니다.
여기에 더해, 설령 함수를 런타임에 전달하려 해도 onClick은 함수이고 RSC payload는 직렬화 가능한 값(문자열, 숫자, 객체)만 담을 수 있어서 함수 자체를 서버에서 클라이언트로 전달할 방법도 없습니다.
브라우저 API(window, document, localStorage 등)를 못 쓰는 이유도 같은 계열입니다. 코드가 브라우저에서 실행돼야 접근 가능한데, 서버는 브라우저 환경 자체가 아니라서 애초에 없습니다.
useContext 같은 Context API는 왜 못 쓰나요?
Next.js 공식 문서는 React context is not supported in Server Components라고 명시합니다. useState와는 다른 이유인데, Context는 Provider가 값을 들고 있다가 값이 바뀌면 구독 중인 하위 컴포넌트에 다시 알려 리렌더링시키는 구독(subscription) 메커니즘이라, 이것도 트리가 계속 살아있어야 성립하기 때문입니다.
useState와 원인은 같은 계열입니다. 구독 관계를 유지하려면 Provider와 그걸 구독하는 컴포넌트들이 렌더링이 끝난 뒤에도 메모리에 남아 있어야 하는데, Server Component는 요청 하나 처리하고 결과를 반환하면 그 트리가 사라집니다. 다시 값이 바뀌었을 때 알려줄 대상 자체가 없습니다.
그래서 Context를 쓰려면 Provider 자체를 Client Component로 만들고, children을 감싸는 방식으로 서버 컴포넌트와 연결합니다.
// Provider는 Client Component여야 한다
'use client';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}Server Component는 이 Provider를 그대로 import해서 렌더링할 수 있습니다. Provider 자체는 Client Component지만, 그 children으로 들어가는 Server Component는 여전히 서버에서 렌더링됩니다.
// Server Component가 Client Component인 Provider를 감싸는 건 가능하다
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}SSR과 RSC는 어떻게 다른가요?
SSR은 클라이언트 컴포넌트를 서버에서 HTML로 미리 렌더링하는 것이고, RSC는 서버에서만 실행되는 컴포넌트 자체를 의미합니다. SSR은 렌더링 전략이고, RSC는 컴포넌트 모델입니다. 함께 쓸 수 있습니다.
SSR(Server-Side Rendering)의 흐름은 서버에서 HTML을 만들어 클라이언트로 보내고, 클라이언트가 이 HTML에 JS를 붙이는(hydration) 과정입니다. 사용자는 JS 로딩 전에 이미 HTML을 볼 수 있지만, JS가 붙기 전까지 인터랙션이 안 됩니다.
RSC는 다릅니다. 서버에서 실행되고 결과가 클라이언트로 오지만, 클라이언트 JS에서 해당 컴포넌트를 hydration할 필요가 없습니다. RSC 코드 자체가 클라이언트 번들에 없기 때문입니다. 상태와 이벤트가 없으니 hydration이 필요하지 않습니다.
Next.js App Router에서는 RSC에 SSR이 함께 적용됩니다. RSC는 서버에서 실행되고, 그 결과가 HTML로 클라이언트에 먼저 내려갑니다. 클라이언트 컴포넌트 부분만 hydration이 필요합니다.
RSC payload는 정확히 뭔가요?
그냥 서버 렌더링 결과를 그대로 보내는 게 아닙니다. RSC payload는 렌더링된 Server Component 트리를 나타내는 별도의 압축 포맷으로, Server Component의 렌더링 결과뿐 아니라 Client Component가 들어갈 자리와 그 JS 파일 참조, Server에서 Client로 넘겨준 props까지 함께 담습니다.
Next.js 공식 문서는 RSC payload를 'a compact binary representation of the rendered React Server Components tree'라고 정의합니다. 여기엔 세 가지가 들어 있습니다.
- Server Component가 렌더링된 결과 (HTML 문자열이 아니라 React가 다시 조립할 수 있는 직렬화된 트리 형태)
- Client Component가 렌더링돼야 할 위치의 placeholder와, 어떤 JS 파일을 로드해야 하는지에 대한 참조
- Server Component에서 Client Component로 넘겨준 props
최초 페이지 로드에서는 HTML, RSC payload, JS 세 가지가 각자 다른 역할을 합니다. HTML은 인터랙션 없는 화면을 빠르게 보여주는 용도, RSC payload는 Server·Client 컴포넌트 트리를 React가 다시 맞추는(reconcile) 용도, JS는 Client Component를 hydrate해서 인터랙션 가능하게 만드는 용도입니다. 셋이 같이 와야 완전한 페이지가 됩니다.
이후 클라이언트 사이드 네비게이션에서는 다릅니다. 이때는 HTML을 다시 받지 않고 RSC payload만 새로 받아서(prefetch·캐시까지 됨), 이미 브라우저에 떠 있는 Client Component 트리는 그대로 유지한 채 Server Component 부분만 이 payload로 갱신합니다. 그래서 전체 리로드 없이 페이지를 이동하면서도 서버의 최신 데이터를 반영할 수 있습니다.
Hydration이란 무엇인가요?
SSR로 만들어진 정적 HTML에 JavaScript 이벤트 핸들러와 상태를 붙이는 과정입니다. 브라우저가 HTML을 먼저 그리고, 이후 JS가 로드되면 이 HTML을 React가 '인식'해 인터랙션 가능한 상태로 만듭니다.
SSR 없이 SPA로 동작하면 브라우저는 빈 HTML을 받고 JS가 모두 로드된 후에야 화면을 그립니다. SSR을 쓰면 서버가 이미 렌더링된 HTML을 보내 사용자가 빠르게 내용을 볼 수 있습니다. 하지만 이 HTML은 정적입니다. 버튼이 보여도 클릭이 안 됩니다.
Hydration은 JS가 로드된 후 React가 이 HTML을 훑으며 이벤트 핸들러를 붙이고 상태를 초기화하는 과정입니다. Hydration이 끝나야 인터랙션이 가능해집니다.
Hydration 비용이 크면 사용자가 버튼을 클릭해도 반응이 없는 구간이 생깁니다. RSC가 클라이언트 번들을 줄이면 Hydration할 코드도 줄어 이 구간이 짧아집니다.
`'use client'`를 쓰면 항상 CSR로만 동작하나요?
아닙니다. 'use client'는 서버 렌더링을 막는 지시어가 아니라, 컴포넌트를 클라이언트 번들에 포함시켜 브라우저에서도 실행되게 만드는 경계입니다. Client Component도 최초 요청 시에는 서버에서 HTML로 렌더링됩니다.
혼동하기 쉬운 지점은 '클라이언트에서 실행된다'와 '서버에서 실행 안 한다'를 같은 뜻으로 여기는 것입니다. 둘은 다른 축입니다.
Server Component는 서버에서만 실행되고 클라이언트 번들에 전혀 포함되지 않습니다. Client Component('use client')는 서버에서도 실행되고(최초 HTML을 만들기 위해) 클라이언트에서도 다시 실행됩니다(hydration으로 인터랙션을 붙이기 위해). 즉 'use client'는 실행 범위를 서버에서 클라이언트로 '옮기는' 게 아니라 클라이언트 실행을 '추가하는' 지시어입니다.
| 서버 실행 | 클라이언트 실행 | |
|---|---|---|
'use client' 없음 (Server Component) | O (SSR) | X |
'use client' 있음 (Client Component) | O (SSR) | O (hydration) |
ssr: false | X | O (진짜 CSR) |
진짜 'CSR로만 동작'하는 상황은 서버가 아예 렌더링을 건너뛰고 최소한의 뼈대 HTML만 주는 경우입니다. 이건 'use client'만으로는 안 되고, next/dynamic(() => import(...), { ssr: false })처럼 명시적으로 SSR을 꺼야 발생합니다. 주로 window, document 같은 브라우저 전용 API를 초기 렌더링 시점에 쓰는 라이브러리(지도, 차트 등)에 사용합니다.
RSC에서 데이터 페칭이 어떻게 다른가요?
RSC에서는 컴포넌트 함수를 async로 만들어 직접 await로 데이터를 가져올 수 있습니다. 클라이언트에서처럼 useEffect + fetch 조합이 필요 없고, 서버에서 DB에 직접 접근할 수 있습니다.
// RSC: 컴포넌트 안에서 바로 DB 접근
async function PostList() {
const posts = await db.posts.findMany();
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}클라이언트 컴포넌트에서는 마운트 후 useEffect에서 fetch를 시작합니다. 부모가 마운트되어야 자식이 마운트되고, 자식이 마운트되어야 자식의 fetch가 시작됩니다. 중첩이 깊을수록 waterfall이 생깁니다.
RSC에서는 서버에서 여러 fetch를 동시에 실행할 수 있습니다. Promise.all로 병렬 요청이 가능하고, 서버와 DB 사이의 지연이 클라이언트와 서버 사이보다 훨씬 짧습니다. API route를 만들어 클라이언트에서 호출하는 단계가 없어집니다.
async function Dashboard() {
// 병렬로 동시에 가져옴
const [user, posts] = await Promise.all([
db.users.findOne({ id: userId }),
db.posts.findMany({ authorId: userId }),
]);
return <DashboardView user={user} posts={posts} />;
}RSC가 번들 크기를 어떻게 줄이나요?
RSC 코드와 RSC에서만 쓰는 라이브러리는 클라이언트 번들에 포함되지 않습니다. 서버에서 실행되고 결과만 내려오기 때문입니다.
예를 들어 markdown을 파싱해 HTML로 변환하는 라이브러리(remark, unified 등)는 수십 KB입니다. 클라이언트 컴포넌트에서 쓰면 이 라이브러리 전체가 번들에 포함됩니다. RSC에서 쓰면 서버에서 파싱이 끝나고 HTML만 클라이언트로 내려오므로 라이브러리가 번들에 포함되지 않습니다.
날짜 포맷팅(date-fns, dayjs), 코드 하이라이터(shiki, prism), 암호화 라이브러리도 마찬가지입니다. 서버에서만 실행되면 클라이언트 번들에서 완전히 제거됩니다.
이 블로그도 RSC를 씁니다. MDX 파싱과 코드 하이라이팅이 서버에서 일어나서 관련 라이브러리가 클라이언트 번들에 포함되지 않습니다.
부가 설명
RSC는 2020년 React 팀이 처음 제안했고, Next.js 13 App Router에서 본격적으로 도입됐습니다. App Router에서는 기본적으로 모든 컴포넌트가 서버 컴포넌트이고, 'use client'를 선언해야 클라이언트 컴포넌트가 됩니다.
한 줄 정리
RSC는 서버에서만 실행되는 컴포넌트로, 클라이언트 번들 크기를 줄이고 서버에서 직접 데이터에 접근할 수 있습니다.