서버·클라이언트 컴포넌트 조합
데이터 조회는 서버에 두고 상호작용만 클라이언트 섬으로 분리하는 조합 패턴으로 번들 크기와 복잡도를 줄입니다.
Next.js App Router의 주요 특징이면서 동시에 가장 많이 헷갈리는 부분이 서버 컴포넌트와 클라이언트 컴포넌트의 조합입니다.
두 유형을 올바르게 섞어 쓰는 능력은 성능 최적화, 개발 복잡도 관리, 사용자 경험 개선에 직접 연결됩니다.
이전 절에서 각 컴포넌트의 역할을 확인했다면, 이번 절에서는 두 방식을 어떻게 조합해 시너지를 낼지 정리하겠습니다.
서버는 데이터와 보안을, 클라이언트는 이벤트와 브라우저 상태를 맡는다.
- Server
DB fetch, secret, 초기 HTML
- Client
state, event, 브라우저 상호작용
- Boundary
props only, 직렬화 가능한 값
- Risk
bundle 증가와 secret 노출
| 작업 | 서버 | 클라이언트 |
|---|---|---|
| 데이터 조회 | 권장 | 토큰 노출 주의 |
| 버튼/입력 | 불가 | 권장 |
| 공유 상태 | props 전달 | useState/useEffect |
서버-클라이언트 컴포넌트 조합의 중요성
Next.js App Router의 핵심 철학은 기본은 서버, 필요할 때만 클라이언트입니다.
즉, 가능한 한 많은 작업을 서버에서 처리하여 클라이언트의 부담을 줄이고, 상호작용이 필수적인 부분만 클라이언트 컴포넌트로 분리하는 것입니다.
이러한 조합은 다음과 같은 이점을 제공합니다.
- 초기 로딩 성능 최적화: 서버 컴포넌트는 JavaScript 번들에 포함되지 않으므로, 초기 페이지 로딩 시 클라이언트가 다운로드하고 파싱해야 할 JavaScript 양이 크게 줄어듭니다. 이는 특히 모바일 환경에서 Critical Rendering Path를 단축시켜 체감 로딩 속도를 향상시킵니다.
- 향상된 SEO: 서버에서 미리 HTML이 렌더링되므로, 검색 엔진 크롤러가 쉽게 콘텐츠를 읽고 색인화할 수 있습니다.
- 보안 강화: 민감한 데이터 페칭 로직이나 API 키는 서버 컴포넌트에서 안전하게 처리되며 클라이언트에 노출되지 않습니다.
- 개발 편의성: 데이터베이스나 파일 시스템과 같은 백엔드 리소스에 직접 접근하여 데이터 페칭 로직을 더욱 단순하게 작성할 수 있습니다.
- 하이드레이션 최적화: 서버에서 미리 렌더링된 HTML을 클라이언트 컴포넌트가 받아서 상호작용 가능한 상태로 만드는 하이드레이션 과정이 더욱 효율적입니다.
서버-클라이언트 경계 이해하기
서버에서 클라이언트로 넘기는 값은 작고 명확해야 한다.
- Server result
필요 값만 선택하고 과한 데이터는 제거한다.
- Serialize
plain data만 넘기고 함수/클래스는 제외한다.
- Client prop
작은 입력값으로 UI 상태를 계산한다.
- Action
서버 변경 작업은 별도 경로로 호출한다.
| 실수 | 증상 | 대안 |
|---|---|---|
| 큰 객체 전달 | 번들/HTML 증가 | 필드 선택 |
| 함수 전달 | 직렬화 실패 | Server Action |
| Date/class | 값 모양 깨짐 | 문자열/DTO |
서버 컴포넌트와 클라이언트 컴포넌트를 조합할 때 가장 중요한 개념은 바로 경계(Boundary)입니다.
"use client" 지시어가 있는 파일은 그 파일과 정적으로 import하는 의존 그래프를 클라이언트 번들 경계에 넣습니다.
반면 Server Component가 미리 구성해 props나 children으로 넘긴 Server Component 결과는 클라이언트 모듈로 바뀌지 않습니다.
기본은 서버 컴포넌트: "use client" 지시어가 없는 모든 .tsx (또는 .js, .jsx) 파일은 서버 컴포넌트입니다.
클라이언트 컴포넌트 선언: 파일의 맨 위에 "use client"를 추가하면 해당 파일은 클라이언트 컴포넌트가 됩니다.
서버 -> 클라이언트 (허용): 서버 컴포넌트는 클라이언트 컴포넌트를 import하여 렌더링할 수 있습니다.
이것이 가장 일반적이고 권장되는 패턴입니다.
// ServerComponent.tsx (서버)
import ClientComponent from './ClientComponent'; // 클라이언트 임포트 가능
export default function ServerComponent() {
return <ClientComponent />;
}클라이언트 -> 서버 (불가능, children 패턴 사용): 클라이언트 컴포넌트는 서버 컴포넌트를 직접 import할 수 없습니다.
대신, 서버 컴포넌트를 클라이언트 컴포넌트의 children prop으로 전달받아 렌더링해야 합니다.
"use client";
// import ServerComponent from './ServerComponent'; // ❌ 불가능!
export default function ClientComponent({ children }: { children: React.ReactNode }) {
return <div>{children}</div>; // children으로 서버 컴포넌트의 결과물을 받음
}import ClientComponent from './ClientComponent';
import ServerContent from './ServerContent'; // 서버 컴포넌트
export default function ServerParent() {
return (
<ClientComponent>
{/* ServerContent는 여기서 서버에서 렌더링되어 HTML로 ClientComponent의 children으로 전달됨 */}
<ServerContent />
</ClientComponent>
);
}props 직렬화: 서버 컴포넌트에서 클라이언트 컴포넌트로 전달되는 props는 직렬화 가능(Serializable) 해야 합니다.
문자열·숫자·불리언·일반 객체·배열뿐 아니라 React의 Server Component 직렬화 계약이 지원하는 Date, Map, Set 같은 내장 값도 전달할 수 있습니다.
일반 함수와 클래스 인스턴스, 사용자 정의 프로토타입을 가진 데이터베이스 문서 등은 그대로 전달할 수 없습니다.
이벤트 함수는 클라이언트 컴포넌트에서 정의하고, 서버 동작을 전달해야 한다면 명시적으로 선언한 Server Function을 사용합니다.
ORM·ODM 결과는 필요한 필드만 일반 객체로 변환해 경계를 넘기는 편이 안전합니다.
서버-클라이언트 조합 패턴 예시
실제 애플리케이션에서 서버 컴포넌트와 클라이언트 컴포넌트가 어떻게 함께 사용되는지 구체적인 시나리오를 통해 살펴보겠습니다.
시나리오 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>
);
}"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()}원</strong></p>
{loading ? <p>업데이트 중...</p> : null}
{/* <Line data={...} /> */} {/* 실제 차트 렌더링 */}
<button onClick={() => alert('차트 클릭 이벤트 처리 (클라이언트)')} style={{ marginTop: '10px' }}>
차트 데이터 상세 보기
</button>
</div>
);
}"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>
);
}실습 확인:
src/app/dashboard 폴더에 위 파일들을 생성하고 http://localhost:3000/dashboard로 접속합니다.
- 초기 로딩 시 서버 콘솔에
Server: Fetching initial dashboard data...가 찍히고, 빠르게 페이지가 표시됩니다. - 페이지가 로드된 후 3초마다 브라우저 콘솔에
Client: Fetching updated sales data...가 찍히면서 매출 숫자가 자동으로 변하는 것을 볼 수 있습니다. - 차트 데이터 상세 보기 버튼이나 프로필 편집 버튼을 클릭하면 클라이언트 컴포넌트에서 상호작용이 일어납니다.
이 예시에서 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";
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>
);
}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.로그가 찍힙니다. 이는ImportantServerMessage가 페이지 로드 시점에 이미 서버에서 렌더링되었음을 의미합니다. - 중요 메시지 보기 버튼을 클릭하면 메시지가 토글됩니다. 이 토글은 클라이언트에서만 일어나며, React는 RSC Payload가 나타내는 서버 렌더 결과를 기존 트리와 조정해 표시합니다.
이 패턴은 클라이언트 컴포넌트가 서버 컴포넌트의 렌더 결과를 조합하는 것이지, 서버 컴포넌트 코드를 브라우저에서 실행하는 것이 아님을 보여줍니다.
아래 다이어그램은 실제 화면을 서버 영역, 클라이언트 섬, children 슬롯으로 나누어 배치하는 판단 흐름을 정리한 것입니다.
페이지 전체를 클라이언트로 바꾸지 않고 서버 subtree 안에 작은 interactive island를 합성한다.
- serverProductInfo
변하지 않는 상세 정보와 가격
- serverReviews
DB에서 읽은 목록을 HTML로 전달
- clientAddToCart
수량 state와 클릭 event 처리
- clientGallery
선택 이미지와 키보드 탐색 처리
언제 어떤 컴포넌트를 사용할 것인가?
두 가지 컴포넌트 유형 중 어떤 것을 선택할지는 컴포넌트의 역할과 필요한 기능에 따라 신중하게 결정해야 합니다.
- 기본은 서버 컴포넌트: 특별한 이유가 없다면 모든 컴포넌트를 서버 컴포넌트로 시작하세요. 이는 Next.js의 성능 이점을 최대한 활용하는 방법입니다.
-
클라이언트 컴포넌트가 필요한 경우
useState,useEffect등의 React 훅을 사용해야 할 때.onClick,onChange등 이벤트 핸들러를 사용해야 할 때.window,document등 브라우저 전용 API에 접근해야 할 때.- 클라이언트 측 애니메이션라이브러리를 사용해야 할 때.
- 클라이언트 측 상태 관리 라이브러리 (Redux, Zustand 등)와 연동해야 할 때.
localStorage,indexedDB등 브라우저 저장소를 사용해야 할 때.
서버와 클라이언트를 조합할 때는 기능 목록보다 먼저 데이터 위치, 상호작용 필요성, 번들 영향을 같이 봐야 합니다.
아래 다이어그램은 실제 컴포넌트를 어디에 둘지 결정하는 기준을 한 화면에 모은 것입니다.
데이터 위치, 상호작용 필요성, 번들 영향을 함께 보면 경계를 작게 유지할 수 있다.
- 데이터
어디에서 읽어야 안전하고 빠른가
- 행동
사용자 입력으로 즉시 바뀌는 부분인가
- 비용
브라우저에 보내는 JS가 얼마나 늘어나는가
| 질문 | 서버 쪽 판단 | 클라이언트 쪽 판단 |
|---|---|---|
| 데이터가 민감한가 | 토큰, DB, 권한은 서버에서 처리한다. | 표시 가능한 결과만 전달한다. |
| 즉시 반응해야 하는가 | 읽기 전용 결과라면 서버 HTML로 충분하다. | 입력, 토글, 드래그는 client island로 둔다. |
| 번들이 커지는가 | 무거운 계산과 라이브러리는 서버에 남긴다. | 상호작용에 필요한 최소 코드만 보낸다. |
서버와 클라이언트 컴포넌트를 조합할 때는 서버가 데이터와 구조를 만들고, 클라이언트가 작은 상호작용 섬을 맡는 식으로 경계를 작게 유지하는 것이 핵심입니다.
서버 결과를 유지한 채 상호작용 경계를 좁히면 초기 HTML·보안·번들 크기를 함께 지킬 수 있다.
- dataServer fetch
데이터와 권한을 확인해 표현 가능한 결과 생성
- shellServer layout
페이지 구조와 비상호작용 콘텐츠를 HTML로 구성
- slotProps / children
경계를 건너는 값과 서버 결과의 위치를 명시
- islandClient interaction
토글·입력·로컬 상태만 브라우저에서 실행
서버 컴포넌트와 클라이언트 컴포넌트를 나누는 기준은 Next.js App Router 구조를 이해하는 핵심입니다.
데이터 접근 위치, 상호작용 필요 여부, 번들 비용을 함께 보고 컴포넌트 경계를 정해야 합니다.
이 다이어그램은 서버와 클라이언트 컴포넌트를 조합할 때 Next.js 프로젝트에 넣을 때 결정해야 할 파일 위치와 런타임 경계를 정리합니다.
서버는 데이터를 준비하고 클라이언트는 작은 상호작용만 맡는다. 경계가 높아질수록 번들과 보안 비용이 커진다.
- 서버 부모를 기준으로 둔다
라우트 데이터와 비밀 접근은 서버 부모에서 끝낸다.
- 상호작용 단위를 작게 자른다
좋아요 버튼, 필터 패널, modal trigger만 분리한다.
- children 패턴을 활용한다
client shell 내부 콘텐츠를 server children으로 유지할 수 있다.
// Server
<Tabs defaultTab="info">
<ProductSpecs specs={specs} />
</Tabs>
// Tabs만 client component
마지막으로 서버 컴포넌트와 클라이언트 컴포넌트를 조합할 때 데이터 준비와 상호작용 책임을 나눕니다.
데이터 조회와 큰 구조는 서버가 소유하고 필터·버튼·provider는 실제 사용 범위에만 둔다.
- shellServer list · detail
초기 데이터와 SEO 콘텐츠를 준비
- propsSerializable input
client에 필요한 최소 값만 전달
- filterClient input
검색·필터처럼 즉시 변하는 UI
- actionClient button
장바구니·좋아요 같은 사용자 의도
- providerNarrow scope
사용하는 하위 layout 가까이에 공유 상태 배치