서버·클라이언트 컴포넌트 조합
데이터 조회는 서버에 두고 상호작용만 클라이언트 섬으로 분리하는 조합 패턴으로 번들 크기와 복잡도를 줄입니다.
서버 컴포넌트와 클라이언트 컴포넌트의 조합은 Next.js App Router의 주요 특징이면서 동시에 가장 많이 헷갈리는 부분입니다. 두 유형을 올바르게 섞어 쓰는 능력은 성능 최적화, 개발 복잡도 관리, 사용자 경험 개선과 직접 이어집니다. 이전 글에서 각 컴포넌트의 역할을 확인했으니, 이 글에서는 두 방식을 어떻게 조합해야 서로의 장점을 살릴 수 있는지 정리합니다.
서버-클라이언트 컴포넌트 조합의 중요성
Next.js App Router의 핵심 원칙은 기본은 서버에 두고 필요할 때만 클라이언트를 쓰는 것입니다. 즉 가능한 한 많은 작업을 서버에서 처리해 클라이언트의 부담을 줄이고, 상호작용이 꼭 필요한 부분만 클라이언트 컴포넌트로 분리합니다.
이런 조합은 여러 이점을 줍니다. 서버 컴포넌트는 JavaScript 번들에 들어가지 않으므로, 초기 페이지 로딩 때 클라이언트가 내려받고 파싱해야 하는 JavaScript 양이 크게 줄어듭니다. 이는 특히 모바일 환경에서 핵심 렌더링 경로(Critical Rendering Path)를 줄여 체감 로딩 속도를 높입니다. 서버에서 HTML을 미리 렌더링하므로 검색 엔진 크롤러가 콘텐츠를 쉽게 읽고 색인할 수 있어 SEO에도 유리합니다.
보안에서는 민감한 로직과 API 키를 서버에 남겨 둘 수 있습니다. 다만 렌더링 결과와 props에는 공개해도 되는 값만 골라 넣어야 하며, 서버 컴포넌트라는 이유만으로 모든 출력이 안전해지지는 않습니다. 데이터베이스나 파일 시스템 같은 백엔드 자원에 직접 접근할 수 있어 데이터 페칭 로직도 단순해지고, 하이드레이션할 클라이언트 컴포넌트가 상호작용이 필요한 부분으로 줄어들어 하이드레이션 과정도 더 효율적입니다.
서버와 클라이언트가 나뉘는 지점 이해하기
서버 컴포넌트와 클라이언트 컴포넌트를 조합할 때 가장 중요한 개념은 두 환경이 나뉘는 지점(boundary)입니다. "use client" 지시어가 있는 파일은 그 파일과, 그 파일이 정적으로 import하는 의존 그래프를 클라이언트 번들에 넣습니다. 반면 서버 컴포넌트가 미리 구성해 props나 children으로 넘긴 서버 컴포넌트의 결과는 클라이언트 모듈로 바뀌지 않습니다.
규칙을 차례로 정리해 보겠습니다. 페이지와 레이아웃은 서버 컴포넌트로 시작하며, 지시어가 없는 공유 모듈도 클라이언트 진입 파일에서 import되면 클라이언트 그래프에서 평가될 수 있습니다. 파일 맨 위에 "use client"를 추가하면 그 파일은 클라이언트 컴포넌트가 됩니다.
서버 컴포넌트는 클라이언트 컴포넌트를 import해 렌더링할 수 있습니다. 다음 짧은 코드들은 각각 독립된 패턴을 보여 주는 발췌이며, 모든 예제를 한 파일에 쌓아 가는 구성은 아닙니다.
// ServerComponent.tsx (서버)
import ClientComponent from './ClientComponent'; // 클라이언트 임포트 가능
export default function ServerComponent() {
return <ClientComponent />;
}서버에서만 실행되어야 하는 컴포넌트는 클라이언트 모듈에서 직접 import하지 않고 서버 부모에서 구성합니다. 대신 서버 컴포넌트를 클라이언트 컴포넌트의 children prop으로 넘겨 렌더링합니다. 다음 코드의 "불가능" 주석은 ServerContent가 서버에서만 실행되도록 유지하려는 이 패턴을 가리키며, 지시어가 없는 모든 공유 모듈의 import가 금지된다는 뜻은 아닙니다. 클라이언트에서 import한 공유 모듈은 클라이언트 그래프에서 동작할 수 있는 코드여야 합니다.
"use client";
// import ServerComponent from './ServerComponent'; // ❌ 불가능!
export default function ClientComponent({ children }: { children: React.ReactNode }) {
return <div>{children}</div>; // children으로 서버 컴포넌트의 결과물을 받음
}ClientComponent는 children에 무엇이 들어 있는지 모른 채 받은 자리에 그리기만 합니다. 그래서 그 안에 들어갈 서버 컴포넌트를 고르는 일은 서버 부모가 맡습니다. 서버 부모는 두 컴포넌트를 각각 가져와, ServerContent의 결과를 ClientComponent의 children으로 합성합니다.
import ClientComponent from './ClientComponent';
import ServerContent from './ServerContent'; // 서버 컴포넌트
export default function ServerParent() {
return (
<ClientComponent>
{/* ServerContent의 서버 렌더 결과를 React 노드 children으로 합성 */}
<ServerContent />
</ClientComponent>
);
}마지막으로 서버 컴포넌트에서 클라이언트 컴포넌트로 넘기는 props는 직렬화할 수 있어야(serializable) 합니다. 문자열, 숫자, 불리언, 일반 객체, 배열뿐 아니라 React의 서버 컴포넌트 직렬화 규칙이 지원하는 Date, Map, Set 같은 내장 값도 넘길 수 있습니다. 하지만 일반 함수, 클래스 인스턴스, 사용자 정의 프로토타입을 가진 데이터베이스 문서 등은 그대로 넘길 수 없습니다. 이벤트 함수는 클라이언트 컴포넌트에서 정의하고, 서버 동작을 넘겨야 한다면 명시적으로 선언한 서버 함수를 씁니다. ORM이나 ODM의 결과는 필요한 필드만 일반 객체로 바꾸어 넘기는 편이 안전합니다.
서버-클라이언트 조합 패턴 예시
실제 애플리케이션에서 서버 컴포넌트와 클라이언트 컴포넌트를 어떻게 함께 쓰는지 두 가지 시나리오로 살펴보겠습니다. 시나리오 1은 서버가 가져온 초기 데이터를 props로 넘기고, 클라이언트 컴포넌트가 그 값을 상태의 출발점으로 삼아 상호작용을 처리하는 패턴입니다. 시나리오 2는 서버가 렌더링한 결과를 children으로 넘기고, 클라이언트 컴포넌트는 그 결과를 언제 보여 줄지만 정하는 패턴입니다. 어느 쪽이든 서버 컴포넌트의 코드가 브라우저로 옮겨 가지는 않습니다.
시나리오 1
사용자별 대시보드에서 초기 데이터는 서버가, 상호작용은 클라이언트가 맡는 조합은 가장 흔한 패턴입니다. 대시보드의 주요 데이터는 서버에서 가져와 빠르게 렌더링하고, 특정 위젯의 상호작용은 클라이언트에서 처리합니다. 먼저 서버 컴포넌트인 대시보드 메인 페이지가 초기 데이터를 가져와 두 위젯에 나누어 넘깁니다.
import ChartWidget from './ChartWidget'; // 클라이언트 컴포넌트
import UserProfileCard from './UserProfileCard'; // 클라이언트 컴포넌트
interface UserData {
name: string;
totalSales: number;
// ... 기타 대시보드 초기 데이터
}
async function getDashboardData(): Promise<UserData> {
// ⛔️ 민감한 DB 쿼리나 내부 API 호출은 여기서 안전하게! ⛔️
console.log('Server: Fetching initial dashboard data...');
await new Promise(resolve => setTimeout(resolve, 800)); // 지연 시뮬레이션
return {
name: '김넥스트',
totalSales: 1234567,
// ...
};
}
export default async function DashboardPage() {
const data = await getDashboardData(); // 서버에서 초기 데이터 페칭
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ color: '#333' }}>환영합니다, {data.name}님의 대시보드!</h1>
<p style={{ fontSize: '1.1em', color: '#666' }}>
총 매출: <strong style={{ color: '#28a745' }}>{data.totalSales.toLocaleString()}원</strong>
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginTop: '30px' }}>
<div style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '15px' }}>
<h2>매출 차트</h2>
{/* ChartWidget은 클라이언트에서 동적으로 데이터 업데이트 및 차트 상호작용 */}
<ChartWidget initialSales={data.totalSales} />
</div>
<div style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '15px' }}>
<h2>사용자 프로필</h2>
{/* UserProfileCard는 프로필 수정 등 클라이언트 상호작용이 있음 */}
<UserProfileCard initialName={data.name} />
</div>
</div>
</div>
);
}페이지는 받은 data를 두 위젯에 필요한 만큼만 나누어, 차트에는 숫자 totalSales를, 프로필 카드에는 문자열 name을 넘깁니다. 위젯마다 객체 전체가 아니라 필요한 값만 넘기면, 브라우저로 전달되는 데이터도 그만큼 줄어듭니다. ChartWidget은 받은 매출을 초기값으로 쓰고, 마운트된 뒤 일정한 간격으로 모의 갱신을 합니다. setCurrentSales에 이전 값을 받는 함수를 넘기므로, 3초마다 실행되는 콜백이 첫 렌더링 때의 값에 묶이지 않고 직전 값에 증감을 더합니다.
"use client";
import React, { useState, useEffect } from 'react';
// import { Line } from 'react-chartjs-2'; // 실제 차트 라이브러리 가정
interface ChartWidgetProps {
initialSales: number;
}
export default function ChartWidget({ initialSales }: ChartWidgetProps) {
const [currentSales, setCurrentSales] = useState(initialSales);
const [loading, setLoading] = useState(false);
// 3초마다 새로운 매출 데이터 페칭 시뮬레이션
useEffect(() => {
const interval = setInterval(async () => {
setLoading(true);
console.log('Client: Fetching updated sales data...');
await new Promise(resolve => setTimeout(resolve, 500));
setCurrentSales(prev => prev + Math.floor(Math.random() * 10000 - 5000)); // 무작위 증감
setLoading(false);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div>
<p>실시간 매출: <strong style={{ color: '#007bff' }}>{currentSales.toLocaleString('ko-KR')}원</strong></p>
{loading ? <p>업데이트 중...</p> : null}
{/* <Line data={...} /> */} {/* 실제 차트 렌더링 */}
<button onClick={() => alert('차트 클릭 이벤트 처리 (클라이언트)')} style={{ marginTop: '10px' }}>
차트 데이터 상세 보기
</button>
</div>
);
}타이머 콜백은 0.5초를 기다리는 동안 loading을 true로 두어 업데이트 중...을 보여 주고, 정리 함수는 컴포넌트가 사라질 때 clearInterval로 예약을 멈춥니다. 실시간 매출은 toLocaleString('ko-KR')로 로캘을 정해 표시합니다. 로캘을 비워 두면 서버와 브라우저가 각자의 기본 로캘로 숫자를 쓰므로, 두 환경의 로캘이 다를 때 서버 HTML과 브라우저의 첫 렌더가 달라져 하이드레이션 불일치가 날 수 있습니다. UserProfileCard는 받은 이름을 초기값으로 쓰고, 편집 모드에서 입력을 받습니다. isEditing이 true이면 입력란과 저장, 취소 버튼을, false이면 이름과 편집 버튼을 보여 주므로, 상태값 하나로 두 화면을 오갑니다.
"use client";
import React, { useState } from 'react';
interface UserProfileCardProps {
initialName: string;
}
export default function UserProfileCard({ initialName }: UserProfileCardProps) {
const [userName, setUserName] = useState(initialName);
const [isEditing, setIsEditing] = useState(false);
const handleSave = () => {
// 클라이언트에서 사용자 이름 업데이트 API 호출 등
alert(`이름 저장: ${userName}`);
setIsEditing(false);
};
return (
<div>
{isEditing ? (
<div>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
/>
<button onClick={handleSave} style={{ marginRight: '10px' }}>저장</button>
<button onClick={() => setIsEditing(false)}>취소</button>
</div>
) : (
<div>
<p>이름: <strong>{userName}</strong></p>
<button onClick={() => setIsEditing(true)}>편집</button>
</div>
)}
</div>
);
}서버의 getDashboardData는 고정값을 돌려주고, ChartWidget은 지연과 난수로 값을 갱신합니다. 실제 매출 API나 차트 라이브러리는 연결하지 않았습니다. 프로필 저장은 alert만 부르며, 취소 버튼은 편집 모드만 닫을 뿐 입력을 원래 값으로 되돌리지 않습니다.
src/app/dashboard 폴더에 위 파일들을 만들고 http://localhost:3000/dashboard에 접속합니다. 처음 로딩할 때 서버 콘솔에 Server: Fetching initial dashboard data...가 찍히고 페이지가 표시됩니다. 마운트된 뒤에는 3초 간격으로 모의 갱신이 예약되며, 브라우저의 스케줄링 때문에 늦어질 수 있고 난수 결과가 같게 나올 수도 있습니다. "차트 데이터 상세 보기" 버튼이나 "편집" 버튼을 누르면 클라이언트 컴포넌트에서 상호작용이 일어납니다.
이 예시에서 서버 컴포넌트인 DashboardPage는 클라이언트 컴포넌트인 ChartWidget과 UserProfileCard에 정적인 초기 데이터를 props로 넘깁니다. 두 클라이언트 컴포넌트는 각자 필요한 동적인 갱신이나 사용자 상호작용을 스스로 처리합니다.
시나리오 2
클라이언트 컴포넌트 안에 서버 컴포넌트의 렌더링 결과를 넣는 children 패턴은, 클라이언트 컴포넌트가 조건부 렌더링이나 이벤트 처리 같은 로직을 맡으면서 서버에서 렌더링한 콘텐츠를 보여 주어야 할 때 씁니다. 서버 페이지가 두 컴포넌트를 가져와 조합합니다.
import ClientConditionalWrapper from './ClientConditionalWrapper';
import ImportantServerMessage from './ImportantServerMessage'; // 서버 컴포넌트
export default function ConditionalContentPage() {
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto' }}>
<h1>조건부 콘텐츠 페이지</h1>
<p>아래는 클라이언트 컴포넌트가 제어하는 서버 콘텐츠입니다.</p>
<ClientConditionalWrapper>
{/* ImportantServerMessage는 서버에서 렌더링되어 ClientConditionalWrapper의 children으로 전달됩니다. */}
<ImportantServerMessage />
</ClientConditionalWrapper>
</div>
);
}페이지에는 "use client"가 없고, ImportantServerMessage를 ClientConditionalWrapper의 태그 사이에 넣어 넘기기만 합니다. 클라이언트 래퍼는 버튼 상태에 따라 받은 children을 보여 주거나 숨깁니다. showContent가 false이면 children을 담은 div 자체를 그리지 않으며, 버튼 글자도 상태에 따라 중요 메시지 보기와 숨기기로 바뀝니다.
"use client";
import React, { useState } from 'react';
export default function ClientConditionalWrapper({ children }: { children: React.ReactNode }) {
const [showContent, setShowContent] = useState(false);
return (
<div style={{ border: '1px dashed orange', padding: '15px', marginTop: '20px', borderRadius: '8px' }}>
<h2>클라이언트 조건부 래퍼</h2>
<button onClick={() => setShowContent(!showContent)}>
{showContent ? '숨기기' : '중요 메시지 보기'}
</button>
{showContent && (
<div style={{ marginTop: '15px', padding: '10px', backgroundColor: '#fffbe6' }}>
{children} {/* 서버에서 렌더링된 ImportantServerMessage가 여기에 삽입됨 */}
</div>
)}
</div>
);
}공지 메시지는 300ms 모의 지연 뒤에 결과를 돌려주는 비동기 서버 컴포넌트입니다. async 함수 컴포넌트는 서버 컴포넌트에서만 쓸 수 있으므로 이 파일에는 "use client"를 붙이지 않습니다. 화면의 렌더링 시간은 이 함수가 서버에서 실행된 시각이라서, 버튼으로 메시지를 숨겼다가 다시 열어도 바뀌지 않습니다.
export default async function ImportantServerMessage() {
// 이 메시지는 서버에서 미리 생성됩니다.
console.log('Server: ImportantServerMessage rendered.');
await new Promise(resolve => setTimeout(resolve, 300)); // 시뮬레이션
return (
<div>
<h3 style={{ color: '#d9534f' }}>🚨 서버에서 전달된 중요한 공지 🚨</h3>
<p>이 내용은 서버에서 미리 준비되었으며, 클라이언트 상호작용에 따라 나타납니다.</p>
<p>렌더링 시간: {new Date().toLocaleTimeString()}</p>
</div>
);
}src/app/conditional-content 폴더에 위 파일들을 만들고 http://localhost:3000/conditional-content에 접속하면, 터미널의 서버 콘솔에 Server: ImportantServerMessage rendered. 로그가 찍힙니다. 이 로그는 서버에서 그 함수가 실행되었다는 표시입니다. 정적으로 프리렌더링되는 페이지라면 빌드할 때 실행될 수 있으므로, 페이지를 방문할 때마다 새로 실행된다는 뜻은 아닙니다. "중요 메시지 보기" 버튼을 누르면 메시지가 토글되며, 이 토글은 클라이언트에서만 일어나고 React는 RSC Payload가 나타내는 서버 렌더링 결과를 기존 트리와 맞추어 표시합니다. 이 패턴은 클라이언트 컴포넌트가 서버 컴포넌트의 렌더링 결과를 조합하는 것이지, 서버 컴포넌트의 코드를 브라우저에서 실행하는 것이 아님을 보여 줍니다.
NEXT · CH7-3
초기 데이터와 서버 결과를 서로 다른 prop으로 합성합니다
본문의 대시보드와 조건부 메시지 예제를 비교합니다. 서버 부모가 준비하고, Client 컴포넌트가 브라우저 상태와 표시를 담당합니다.
초기 값 → Client의 로컬 상태
DashboardPage · 서버
getDashboardData의 고정값 name·totalSales를 받습니다.
initialSales / initialName · props
ChartWidget과 UserProfileCard에 각각 초기 값을 전달합니다.
Client · useState
매출은 타이머·난수로 모의 갱신하고 이름은 입력으로 바뀝니다. 저장은 alert입니다.
서버 결과 → Client가 표시·숨김
ConditionalContentPage · 서버
ImportantServerMessage의 300ms 지연 후 서버 결과를 준비합니다.
children · React 노드
결과를 ClientConditionalWrapper에 전달합니다. 서버 모듈을 Client에서 import하지 않습니다.
Client · showContent
처음 false이며 버튼으로 받은 children의 표시만 바꿉니다. 클릭 때 서버 함수를 다시 실행하지 않습니다.
initial 값은 useState 초기화에 쓰이며 이후 props 변경을 자동 반영하지 않습니다. 프로필 취소는 편집 모드만 닫습니다. 두 예제에는 실제 매출 API·차트·프로필 저장·인증이 구현되어 있지 않습니다.
위 그림은 두 시나리오에서 초기 데이터와 서버 결과를 서로 다른 prop으로 합성하는 관계를 비교합니다. 첫째 흐름에서는 DashboardPage가 getDashboardData의 고정값인 name과 totalSales를 받아 initialName과 initialSales라는 props로 넘기고, 두 값은 클라이언트의 useState 초기값이 되어 이후 입력과 타이머로 바뀝니다. 둘째 흐름에서는 ConditionalContentPage가 300ms 지연 뒤에 준비된 ImportantServerMessage의 결과를 children으로 ClientConditionalWrapper에 넘기고, 클라이언트는 처음에 false인 showContent 상태로 그 노드를 보여 주거나 숨깁니다.
두 흐름 사이에는 실행 순서나 데이터 전달의 연결이 없으며, 화살표는 값과 결과가 전달되는 관계를 나타낼 뿐 클릭할 때마다 서버 컴포넌트를 새로 실행한다는 뜻이 아닙니다. 그림의 아래 설명처럼 초기값은 useState를 초기화하는 데만 쓰여 이후 props가 바뀌어도 저절로 반영되지 않습니다.
언제 어떤 컴포넌트를 사용할 것인가?
두 컴포넌트 유형 가운데 무엇을 고를지는 컴포넌트의 역할과 필요한 기능에 따라 정합니다. 특별한 이유가 없다면 모든 컴포넌트를 서버 컴포넌트로 시작하는 것이 Next.js의 성능 이점을 가장 잘 살리는 방법입니다.
클라이언트 컴포넌트는 useState, useEffect 같은 React 훅이나 onClick, onChange 같은 이벤트 핸들러를 써야 할 때 필요합니다. window, document 같은 브라우저 전용 API에 접근할 때, 클라이언트 애니메이션 라이브러리를 쓸 때, Redux나 Zustand 같은 클라이언트 상태 관리 라이브러리와 연동할 때, localStorage나 indexedDB 같은 브라우저 저장소를 쓸 때도 클라이언트 컴포넌트를 씁니다.
정리
- App Router의 기본 원칙은 서버 컴포넌트로 시작하고, 상호작용이 꼭 필요한 부분만 클라이언트 컴포넌트로 분리하는 것입니다.
"use client"파일과 그 파일이 정적으로 import하는 모듈은 클라이언트 번들에 들어가며, 서버 부모가 props나children으로 넘긴 서버 결과는 클라이언트 모듈로 바뀌지 않습니다.- 서버에서 클라이언트로 넘기는 props는 직렬화할 수 있어야 하며, ORM 결과는 필요한 필드만 일반 객체로 바꾸어 넘깁니다.
- 초기 데이터는 props로 넘겨 클라이언트 상태의 초기값으로 쓰고, 서버 결과는
children으로 넘겨 클라이언트가 보여 주거나 숨기게 합니다.
연습 문제
1. 버튼 이름과 실제 상태 전이 비교하기
UserProfileCard가 처음에는 "김넥스트"를 받고, 사용자가 편집 입력을 "김민수"로 바꾼 뒤 취소를 누른다고 가정합니다. 어떤 이름이 보일까요? 이어서 같은 인스턴스에 새 initialName인 "이지수"가 전달되지만 key나 마운트는 바뀌지 않는다면 저절로 "이지수"로 바뀔까요? 저장 버튼이 원격 저장을 보장하는지도 코드로 판단해 보세요.
해설 보기
입력의 onChange가 이미 userName을 "김민수"로 바꿨고 취소는 isEditing만 false로 바꾸므로 "김민수"가 표시됩니다. 새 initialName도 같은 인스턴스의 useState를 다시 초기화하지 않으므로 저절로 덮어쓰지 않습니다. 저장은 alert를 띄우고 편집 모드를 끝낼 뿐 원격 API를 호출하지 않습니다.
취소가 이전에 확정한 이름으로 되돌려야 한다면, 확정한 이름과 편집 중인 초안을 서로 다른 상태로 나누고 핸들러를 설계해야 합니다. 페이지 전체를 새로고침해 모의 데이터를 다시 받고 새로 마운트하면 "김넥스트"부터 시작합니다.
2. 상품 상세 페이지 나누기
상품 상세 페이지에 상품 이름, 설명, 가격, 리뷰 목록이 있고, "장바구니에 담기" 버튼과 수량 선택 입력이 있습니다. 어떤 부분을 서버 컴포넌트로, 어떤 부분을 클라이언트 컴포넌트로 만들면 좋을까요?
해설 보기
상품 이름, 설명, 가격, 리뷰 목록은 서버에서 데이터를 가져와 렌더링하는 서버 컴포넌트로 둡니다. 이 부분은 상호작용이 없고 SEO에도 중요하며, 코드가 번들에 들어가지 않습니다.
수량 선택과 "장바구니에 담기" 버튼은 상태와 이벤트 핸들러가 필요하므로, 이 둘만 묶은 작은 클라이언트 컴포넌트로 분리합니다. 서버 페이지는 상품 ID나 재고 같은 필요한 값만 props로 넘깁니다. 페이지 전체에 "use client"를 붙이면 리뷰 목록까지 번들에 들어가고 서버에서 직접 데이터를 가져오는 장점도 잃습니다.
3. ORM 결과를 그대로 넘기기
서버 컴포넌트에서 ORM으로 읽은 사용자 객체를 <UserCard user={user} />처럼 클라이언트 컴포넌트에 그대로 넘기려고 합니다. 이 객체에 메서드가 달린 클래스 인스턴스가 들어 있고, 비밀번호 해시 필드도 있다면 어떻게 해야 할까요?
해설 보기
필요한 필드만 골라 일반 객체로 바꾸어 넘깁니다. 예를 들어 <UserCard user={{ id: user.id, name: user.name }} />처럼 화면에 필요한 값만 담습니다.
메서드가 달린 클래스 인스턴스는 React가 직렬화할 수 없어 그대로 넘기면 오류가 날 수 있습니다. 또한 직렬화할 수 있는 값이라도 props로 넘긴 필드는 RSC Payload를 거쳐 브라우저로 전달되므로, 비밀번호 해시처럼 공개하면 안 되는 필드는 처음부터 넣지 않아야 합니다.
서버 컴포넌트와 클라이언트 컴포넌트의 조합 패턴은 Next.js 공식 문서의 Server and Client Components에서 확인할 수 있습니다.
다음 글 use client 지시어 활용에서는 "use client"를 어느 파일에 두어야 번들을 작게 유지할 수 있는지 알아봅니다.