(리액트) 서버 컴포넌트
React Server Components가 왜 등장했고, SSR에 어떻게 결합되는지 이해합니다.
SSR만으로 부족했던 것
앞 문서에서 SSR과 CSR을 봤습니다.
SSR은 서버에서 완성된 HTML을 보내기 때문에 첫 화면이 빠릅니다.
하지만 서버가 보낸 HTML은 "정적인 그림"입니다.
버튼을 클릭해도 아무 반응이 없습니다. 이벤트 핸들러가 연결되지 않았기 때문입니다.
브라우저가 자바스크립트를 로드한 뒤, 이 HTML에 이벤트와 상태를 붙여야 비로소 동작합니다.
이 과정을 Hydration(수화)이라고 부릅니다.
여기서 근본적인 문제가 생깁니다.
페이지 안에는 성격이 다른 부분이 섞여 있습니다.
글 본문처럼 데이터를 보여주기만 하는 부분이 있고, "좋아요" 버튼처럼 클릭이 필요한 부분이 있습니다.
하지만 Hydration은 페이지 전체에 대해 일어납니다.
React는 Hydration할 때 컴포넌트 트리 전체를 브라우저에서 다시 실행하여 가상 DOM을 만들고, 서버가 보낸 HTML과 대조합니다.
이 과정에서 React는 어떤 컴포넌트가 클릭이 필요하고 어떤 컴포넌트가 필요 없는지 구분하지 않습니다.
그래서 글 본문처럼 클릭할 일이 없는 컴포넌트의 코드까지 전부 JS 번들에 포함됩니다.
서버 컴포넌트의 아이디어
React Server Components(RSC)는 컴포넌트 단위로 서버와 클라이언트를 나눕니다.
- 데이터를 가져와서 보여주기만 하는 컴포넌트 → 서버 컴포넌트
- 클릭, 입력 같은 상호작용이 필요한 컴포넌트 → 클라이언트 컴포넌트
ArticleContent는 글 내용을 보여주기만 합니다. 이벤트 핸들러가 없습니다.
이 컴포넌트는 서버에서 실행되고, 브라우저에 JS를 보내지 않습니다.
LikeButton은 클릭해야 하므로 브라우저에서 실행됩니다.
이 컴포넌트의 JS만 브라우저에 전달됩니다.
서버 컴포넌트의 특징
브라우저에 JS를 보내지 않습니다
서버 컴포넌트는 서버에서 실행된 결과(HTML과 직렬화된 데이터)만 브라우저에 전달됩니다.
컴포넌트 코드 자체는 브라우저 번들에 포함되지 않습니다.
마크다운 파서처럼 큰 라이브러리를 쓰더라도, 그 코드가 브라우저에 전달되지 않으므로 번들 크기에 영향을 주지 않습니다.
서버 자원에 직접 접근할 수 있습니다
서버에서 실행되기 때문에, 기존처럼 fetch로 API를 호출하는 대신 데이터베이스에 바로 접근할 수 있습니다.
async function ArticleContent({ id }) {
const article = await db.article.findUnique({ where: { id } });
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
</article>
);
}기존 React에서는 브라우저 → API 서버 → DB 순서로 거쳐야 했지만,
서버 컴포넌트는 이미 서버에 있으므로 중간 단계 없이 DB를 직접 조회합니다.
async 컴포넌트를 쓸 수 있습니다
위 코드에서 함수 앞에 async가 붙어 있습니다.
클라이언트 컴포넌트에서는 async를 쓸 수 없지만, 서버 컴포넌트는 await를 바로 쓸 수 있습니다.
왜 클라이언트 컴포넌트는 async가 안 될까?
React는 컴포넌트 함수를 호출하면 JSX를 즉시 돌려받을 것으로 기대합니다.
async를 붙이면 함수가 JSX 대신 Promise를 반환하기 때문에, React가 화면을 그릴 수 없습니다.
서버 컴포넌트는 서버에서 실행되므로, 서버 런타임이 Promise를 끝까지 기다린 뒤 결과만 브라우저에 보냅니다.
쓸 수 없는 것들
서버에서 실행되므로, 브라우저에만 있는 기능은 쓸 수 없습니다.
| 사용 불가 | 이유 |
|---|---|
useState, useEffect | 서버 컴포넌트는 한 번 실행되고 끝이라 상태를 유지할 수 없음 |
onClick, onChange | 이벤트 핸들러는 브라우저에서 동작 |
window, document | 브라우저 API |
localStorage | 브라우저 저장소 |
이런 기능이 필요한 컴포넌트는 클라이언트 컴포넌트로 만들어야 합니다.
서버 컴포넌트가 결합되면서 달라진 것
서버 컴포넌트는 SSR을 대체하는 게 아닙니다.
SSR은 여전히 일어나고, 서버 컴포넌트는 그 위에서 동작합니다.
| SSR만 있을 때 | SSR + 서버 컴포넌트 | |
|---|---|---|
| 단위 | 페이지 전체를 서버에서 렌더링하고, 전체를 Hydration | 서버에서 렌더링하되, Hydration은 클라이언트 컴포넌트만 |
| JS 번들 | 모든 컴포넌트의 JS가 브라우저에 전달 | 서버 컴포넌트의 JS는 전달 안 됨 |
| 데이터 조회 | API 호출 또는 getServerSideProps(Pages Router) | 서버 컴포넌트 안에서 직접 await |
핵심은, SSR이라는 큰 틀은 그대로인데
컴포넌트 단위로 "서버에서 끝낼 것"과 "브라우저에서 활성화할 것"을 나눌 수 있게 됐다는 점입니다.
Next.js App Router에서의 기본값
Next.js 13의 App Router부터, 모든 컴포넌트가 기본적으로 서버 컴포넌트입니다.
app/
page.tsx ← 서버 컴포넌트 (기본값)
layout.tsx ← 서버 컴포넌트 (기본값)
components/
LikeButton.tsx ← 'use client'를 붙이면 클라이언트 컴포넌트특별한 선언 없이 만든 컴포넌트는 서버에서 실행됩니다.
클라이언트에서 실행해야 할 컴포넌트에만 'use client'를 붙입니다.
이 구분이 다음 문서의 주제입니다.
직접 해보기
- Next.js 프로젝트를 하나 만들어서(
npx create-next-app@latest)app/page.tsx에console.log를 넣어보기 — 터미널에 찍히는지, 브라우저 콘솔에 찍히는지 확인 useState를app/page.tsx에 추가하면 어떤 에러가 나는지 확인해보기