서버 컴포넌트
서버 컴포넌트가 브라우저 번들과 비밀 정보 노출을 줄이며 서버에서 데이터와 UI를 만드는 과정을 이해합니다.
Next.js 16 App Router는 서버 컴포넌트(Server Components)를 기본으로 데이터와 UI를 준비합니다. 이 구조는 React 애플리케이션의 성능, 보안, 개발 경험을 함께 개선하는 방향을 제시했습니다. 클라이언트 컴포넌트 중심의 개발에 익숙하다면, 서버 컴포넌트의 개념과 작동 원리를 정확히 이해하는 것이 App Router를 제대로 쓰는 첫걸음입니다.
7장에서는 서버 컴포넌트와 클라이언트 컴포넌트를 차례로 살펴보고, 두 컴포넌트를 조합하는 방법과 "use client" 지시어를 어디에 두어야 하는지 정리합니다. 이 글에서는 서버 컴포넌트가 무엇이며 왜 필요하고 어떻게 동작하는지 알아봅니다.
서버 컴포넌트란 무엇인가요?
서버 컴포넌트는 이름 그대로 서버 환경에서 렌더링되고 실행되는 React 컴포넌트입니다. 클라이언트 컴포넌트는 브라우저에서 상태와 이벤트를 처리하지만, 첫 방문의 초기 HTML을 만드는 데는 서버도 참여합니다. 따라서 어떤 컴포넌트가 서버 모듈인지 클라이언트 모듈인지와, 그 컴포넌트를 요청할 때 렌더링하는지 빌드할 때 렌더링하는지는 서로 다른 기준입니다.
App Router에서 페이지와 레이아웃은 기본적으로 서버 컴포넌트입니다. 다만 "use client"가 붙은 파일에서 이어지는 import 그래프의 모듈은 지시어가 없어도 클라이언트에서 평가될 수 있습니다. 서버 컴포넌트의 코드는 서버에서만 실행되고 클라이언트 번들에 들어가지 않으므로, 브라우저가 내려받아야 하는 JavaScript 번들을 줄일 수 있으며 이는 특히 모바일 환경의 초기 로딩 속도에 큰 영향을 줍니다.
서버 컴포넌트는 데이터베이스, 파일 시스템, 내부 API 같은 서버 전용 자원에 직접 접근해 데이터를 가져올 수 있습니다. Route Handler를 따로 만들지 않고 컴포넌트 안에서 바로 데이터를 가져올 수 있다는 뜻입니다. 또한 컴포넌트를 async 함수로 정의할 수 있어 async/await로 데이터 페칭을 직관적으로 작성할 수 있습니다.
대신 서버 컴포넌트에서는 할 수 없는 일도 있습니다. useState, useEffect 같은 상태와 효과의 훅을 쓸 수 없고, onClick, onChange 같은 이벤트 핸들러를 직접 붙일 수 없으며, React Context도 직접 쓸 수 없습니다. 이런 일은 클라이언트 컴포넌트의 몫이며, 서버 컴포넌트끼리는 props를 넘기거나 서버 전용 캐싱 패턴으로 데이터를 나눕니다.
서버 컴포넌트가 필요한 이유
서버 컴포넌트는 기존의 클라이언트 중심 렌더링 방식이 가졌던 여러 문제를 해결하며, 이점은 성능, 보안, 개발 경험의 세 갈래로 나누어 볼 수 있습니다.
성능에서는 먼저 서버 컴포넌트의 코드가 클라이언트로 전송되지 않으므로 사용자가 내려받아야 하는 JavaScript 양이 줄어들고, 이는 초기 페이지 로딩 속도에 직접 영향을 줍니다. 데이터 페칭의 워터폴(waterfall)도 줄일 수 있습니다. 독립적인 요청을 먼저 모두 시작한 뒤 함께 기다리면 순차 요청을 줄일 수 있지만, 서버 컴포넌트에서도 await를 차례로 쓰면 워터폴이 생기며 요청이 자동으로 병렬화되지는 않습니다. 또한 Next.js는 서버 컴포넌트의 HTML을 청크(chunk) 단위로 스트리밍할 수 있어, 페이지의 일부가 먼저 보인 뒤 데이터 로딩이 끝나는 대로 나머지 콘텐츠가 점진적으로 나타납니다.
보안에서는 서버 전용 코드를 브라우저 번들에서 제외할 수 있다는 점이 중요합니다. 그래도 렌더링 결과나 props로 보낸 비밀 값은 브라우저에 노출되므로, 공개해도 되는 값만 골라 보내야 합니다. 서버 컴포넌트에서 백엔드 로직을 직접 실행할 수 있어, Route Handler를 따로 만들지 않고도 데이터를 서버 안에서 처리할 수 있습니다.
개발 경험에서는 useState나 useEffect 없이 async/await로 데이터를 가져올 수 있어 Pages Router의 getServerSideProps나 getStaticProps보다 유연합니다. 어떤 컴포넌트를 서버에서 실행하고 어떤 컴포넌트를 클라이언트에서 실행할지 개발자가 명시적으로 정할 수 있습니다. 서버 컴포넌트는 빌드할 때의 프리렌더링과 요청할 때의 렌더링 모두에 쓰이므로, 서버 컴포넌트라는 이유만으로 요청마다 SSR이 되는 것은 아닙니다.
서버 컴포넌트 작동 원리
첫 방문에서 서버 컴포넌트는 RSC Payload(React Server Component Payload)로 표현됩니다. Next.js는 이 결과와 클라이언트 컴포넌트를 함께 써서 초기 HTML을 만듭니다.
첫 방문에서 브라우저로 전달되는 세 역할
서버의 RSC 결과와 초기 HTML, 브라우저 첫 표시·RSC 트리 결합·JS 인터랙션 가능 상태를 논리적 준비 단계로 보여 줄 수 있습니다. 원문의 세 행을 네트워크 파일 도착 완료 순서라고 설명하지 않습니다.
NEXT · CH7-1
첫 방문은 HTML·RSC·Client JS의 역할이 다릅니다
초기 HTML은 화면을 보여주고, RSC Payload는 서버 결과·Client 참조·props로 트리를 맞추며, Client JavaScript는 해당 컴포넌트를 하이드레이션합니다.
각 행은 전달값의 역할이며 다운로드 완료 순서가 아닙니다. 서버 컴포넌트 자체 코드는 브라우저 번들에 들어가지 않지만 결과는 전달됩니다. 후속 클라이언트 탐색이 첫 방문의 HTML 전달을 그대로 반복하지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 서버 컴포넌트는 RSC Payload로 렌더됩니다. Next.js는 이 결과와 클라이언트 컴포넌트로 초기 HTML을 만듭니다. 브라우저는 HTML을 표시하고 RSC Payload로 트리를 맞추며 클라이언트 JavaScript로 해당 컴포넌트의 이벤트를 연결합니다. 세 행은 전송 완료 순서가 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 3 | 첫 방문에서 HTML·RSC·Client JS의 역할은 서로 다릅니다 |
| 4 | 초기 HTML은 화면을 보여 주고, RSC Payload는 서버 결과·Client 참조·props로 트리를 맞추며, Client JavaScript는 해당 컴포넌트를 하이드레이션합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 서버 결과 준비 Server Component를 RSC Payload로 렌더하고 Client Component와 함께 초기 HTML을 준비합니다. | 서버 산출물: RSC·초기 HTML · 논리: 렌더 준비 | 브라우저 첫 표시의 자료가 준비됩니다. |
| 브라우저 첫 표시 브라우저가 HTML을 표시하고 RSC Payload로 트리를 맞춥니다. | 화면: HTML 표시 · 트리: RSC 결합 · 전송 완료 순서: 이 단계의 계약 아님 | 정적인 첫 화면과 컴포넌트 트리가 준비됩니다. |
| Client 이벤트 연결 Client JavaScript가 해당 컴포넌트의 이벤트를 연결합니다. | Client: 이벤트 연결 · Server Component: 브라우저 실행 아님 | Client 경계가 상호작용 가능한 상태로 바뀝니다. |
첫 방문에서 브라우저로 전달되는 세 역할
서버의 RSC 결과와 초기 HTML, 브라우저 첫 표시·RSC 트리 결합·JS 인터랙션 가능 상태를 논리적 준비 단계로 보여 줄 수 있습니다. 원문의 세 행을 네트워크 파일 도착 완료 순서라고 설명하지 않습니다.
NEXT · CH7-1
첫 방문은 HTML·RSC·Client JS의 역할이 다릅니다
초기 HTML은 화면을 보여주고, RSC Payload는 서버 결과·Client 참조·props로 트리를 맞추며, Client JavaScript는 해당 컴포넌트를 하이드레이션합니다.
각 행은 전달값의 역할이며 다운로드 완료 순서가 아닙니다. 서버 컴포넌트 자체 코드는 브라우저 번들에 들어가지 않지만 결과는 전달됩니다. 후속 클라이언트 탐색이 첫 방문의 HTML 전달을 그대로 반복하지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 서버 컴포넌트는 RSC Payload로 렌더됩니다. Next.js는 이 결과와 클라이언트 컴포넌트로 초기 HTML을 만듭니다. 브라우저는 HTML을 표시하고 RSC Payload로 트리를 맞추며 클라이언트 JavaScript로 해당 컴포넌트의 이벤트를 연결합니다. 세 행은 전송 완료 순서가 아닙니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 3 | 첫 방문에서 HTML·RSC·Client JS의 역할은 서로 다릅니다 |
| 4 | 초기 HTML은 화면을 보여 주고, RSC Payload는 서버 결과·Client 참조·props로 트리를 맞추며, Client JavaScript는 해당 컴포넌트를 하이드레이션합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 서버 결과 준비 Server Component를 RSC Payload로 렌더하고 Client Component와 함께 초기 HTML을 준비합니다. | 서버 산출물: RSC·초기 HTML · 논리: 렌더 준비 | 브라우저 첫 표시의 자료가 준비됩니다. |
| 브라우저 첫 표시 브라우저가 HTML을 표시하고 RSC Payload로 트리를 맞춥니다. | 화면: HTML 표시 · 트리: RSC 결합 · 전송 완료 순서: 이 단계의 계약 아님 | 정적인 첫 화면과 컴포넌트 트리가 준비됩니다. |
| Client 이벤트 연결 Client JavaScript가 해당 컴포넌트의 이벤트를 연결합니다. | Client: 이벤트 연결 · Server Component: 브라우저 실행 아님 | Client 경계가 상호작용 가능한 상태로 바뀝니다. |
위 그림은 첫 방문에서 브라우저로 전달되는 초기 HTML, RSC Payload, 클라이언트 JavaScript가 각각 어떤 역할을 하는지 보여 줍니다. 서버에서 서버 컴포넌트를 RSC Payload로 렌더링하고, 이 결과와 클라이언트 컴포넌트로 초기 HTML을 준비합니다. 브라우저는 HTML을 상호작용이 아직 없는 미리보기로 먼저 표시하고, RSC Payload로 서버와 클라이언트의 결과를 맞추어 트리를 조정하며, 클라이언트 JavaScript가 해당 컴포넌트를 하이드레이션해 이벤트를 연결합니다. 그림의 단계도 서버 결과 준비, 브라우저 첫 표시, 클라이언트 이벤트 연결의 순서입니다.
그림의 세 행은 전달값의 역할을 나눈 것이며 다운로드가 끝나는 순서가 아닙니다. 서버 컴포넌트 자체의 코드는 브라우저 번들에 들어가지 않지만, 그 결과는 브라우저로 전달됩니다.
RSC Payload에는 서버 컴포넌트의 렌더링 결과, 클라이언트 컴포넌트가 들어갈 위치와 그 모듈의 참조, 클라이언트 컴포넌트에 넘기는 props가 담깁니다. 이는 React가 지원하는 직렬화 형식이며, 모든 데이터를 단순히 JSON으로 바꾸는 과정은 아닙니다. 첫 방문 뒤의 클라이언트 탐색에서는 RSC Payload를 받아 트리를 갱신하므로, 첫 방문의 HTML 전달 과정을 그대로 반복하지 않을 수 있습니다.
서버 컴포넌트 예시 (복습)
이전 장에서 다룬 많은 페이지 컴포넌트와 레이아웃 컴포넌트가 이미 서버 컴포넌트로 작성되었습니다. 다음 레이아웃은 children만 받는 일반적인 대시보드 레이아웃입니다. 4장 4절의 병렬 슬롯 실습을 같은 프로젝트에 쌓아 왔다면, 기존의 슬롯 props와 렌더링 부분은 그대로 두고 사용자 정보를 표시하는 부분만 옮깁니다. 파일 전체를 이 코드로 바꾸면 기존의 슬롯 UI가 빠집니다.
// "use client" 지시어가 없으므로 서버 컴포넌트입니다.
import Link from 'next/link';
// 이 함수는 서버에서 실행됩니다.
async function getUserInfo() {
console.log('Fetching user info on server...');
// 실제 DB 접근 또는 내부 API 호출
await new Promise(resolve => setTimeout(resolve, 500)); // 지연 시뮬레이션
return { username: '서버 사용자', role: 'admin' };
}
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const userInfo = await getUserInfo(); // 서버에서 데이터 페칭
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<aside style={{ width: '250px', padding: '20px', backgroundColor: '#f0f4f8', borderRight: '1px solid #e0e0e0' }}>
<h2>관리자 대시보드</h2>
<p>환영합니다, {userInfo.username}님!</p> {/* 서버에서 가져온 데이터 사용 */}
<p>권한: {userInfo.role}</p>
<nav style={{ marginTop: '20px' }}>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li style={{ marginBottom: '10px' }}><Link href="/dashboard">홈</Link></li>
<li style={{ marginBottom: '10px' }}><Link href="/dashboard/settings">설정</Link></li>
{/* 여기에 다른 서버 컴포넌트나 클라이언트 컴포넌트를 자식으로 렌더링 */}
</ul>
</nav>
</aside>
<main style={{ flexGrow: 1, padding: '20px' }}>
{children} {/* 하위 페이지나 레이아웃이 여기에 렌더링됩니다. */}
</main>
</div>
);
}위 DashboardLayout은 서버에서 getUserInfo를 호출합니다. 이 함수는 500ms 지연 뒤 고정된 이름과 역할을 돌려주는 모의 구현이며, 실제 DB 조회나 권한 검증은 하지 않습니다.
getUserInfo와 레이아웃의 서버 계산은 브라우저에서 다시 실행되는 코드가 아닙니다. 계산한 이름과 역할은 HTML과 RSC 결과에 들어가며, <Link>나 그 안에 포함된 클라이언트 컴포넌트와 Next.js 런타임에는 별도의 브라우저 JavaScript가 필요할 수 있습니다. 서버 코드가 번들에서 빠진다는 말과 페이지 전체의 JavaScript가 0이라는 말은 구분해야 합니다.
정리
- App Router에서 페이지와 레이아웃은 기본적으로 서버 컴포넌트이며, 코드가 서버에서만 실행되고 클라이언트 번들에 들어가지 않습니다.
- 서버 컴포넌트는
async/await로 서버 전용 자원에 직접 접근할 수 있지만, 상태와 효과의 훅, 이벤트 핸들러, React Context는 쓸 수 없습니다. - 차례로 쓴
await는 워터폴을 만들며, 독립적인 요청은 먼저 시작하고 함께 기다려야 병렬로 처리됩니다. - 첫 방문에서는 초기 HTML, RSC Payload, 클라이언트 JavaScript가 각각 화면 표시, 트리 맞추기, 이벤트 연결을 맡습니다.
- 서버 코드가 번들에서 빠져도 렌더링 결과와 props는 브라우저로 전달되므로, 공개해도 되는 값만 넘깁니다.
연습 문제
1. 서버에 남길 값 판단하기
DB의 모의 결과에 username, role, internalNote가 있고, 마지막 값은 공개하면 안 되는 내부 메모라고 가정합니다. 서버에서 읽었다는 이유로 이 객체 전체를 클라이언트 컴포넌트의 props에 넣어도 될까요? 예제의 role이 admin이라는 표시만으로 관리 작업의 실행 권한까지 확인했다고 말할 수 있는지도 설명해 보세요.
해설 보기
넣으면 안 됩니다. 서버 모듈을 브라우저 번들에서 제외해도, props나 렌더링 결과에 넣은 값은 RSC Payload를 거쳐 브라우저로 전달됩니다. 화면에 필요한 공개 이름과 표시용 역할만 골라 넘기고, 내부 메모는 넘기지 않아야 합니다.
role을 표시했다고 권한을 검증한 것도 아닙니다. 실제 관리 작업은 그 작업을 처리하는 서버 코드에서 현재 사용자의 권한과 작업 조건을 다시 검사해야 하며, 이 예제의 getUserInfo는 고정된 표시 값을 돌려주는 모의 구현일 뿐입니다.
2. 순차 요청과 병렬 요청
서버 컴포넌트에서 서로 관계없는 두 API를 다음처럼 호출합니다. 각 요청이 1초씩 걸린다면 두 데이터가 모두 준비되기까지 대략 얼마나 걸릴까요? 시간을 줄이려면 어떻게 고쳐야 할까요?
const user = await getUser();
const posts = await getPosts();해설 보기
getUser()가 끝나야 getPosts()가 시작되므로 대략 2초가 걸립니다. 서버 컴포넌트라고 해서 차례로 쓴 await가 자동으로 병렬화되지는 않습니다.
두 요청이 서로의 결과를 필요로 하지 않으므로, 먼저 둘 다 시작한 뒤 함께 기다리면 대략 1초로 줄일 수 있습니다.
const [user, posts] = await Promise.all([getUser(), getPosts()]);3. 서버 컴포넌트에 버튼 이벤트 붙이기
DashboardLayout의 "관리자 대시보드" 제목 옆에 onClick으로 사이드바를 접는 버튼을 바로 추가하려고 합니다. 이 레이아웃 파일에서 그대로 할 수 있을까요?
해설 보기
그대로는 할 수 없습니다. DashboardLayout은 서버 컴포넌트이므로 onClick 같은 이벤트 핸들러를 직접 붙이거나 useState로 접힘 상태를 관리할 수 없습니다.
버튼과 접힘 상태를 담은 작은 클라이언트 컴포넌트를 "use client" 파일로 따로 만들고, 레이아웃은 서버 컴포넌트로 둔 채 그 컴포넌트를 가져와 렌더링합니다. 이렇게 하면 사용자 정보를 가져오는 서버 코드는 번들에서 빠진 채로, 버튼에 필요한 코드만 브라우저로 보낼 수 있습니다.
서버 컴포넌트와 RSC Payload의 자세한 설명은 Next.js 공식 문서의 Server and Client Components에서 확인할 수 있습니다.
다음 글 클라이언트 컴포넌트 사용법에서는 "use client"로 클라이언트 컴포넌트를 만들고 상태와 이벤트를 다루는 방법을 알아봅니다.