서버와 클라이언트 컴포넌트 조합하기
Next.js App Router의 가장 강력한 특징이면서 동시에 가장 많이 헷갈리는 부분이 서버 컴포넌트와 클라이언트 컴포넌트의 조합입니다.
두 유형을 올바르게 섞어 쓰는 능력은 성능 최적화, 개발 복잡도 관리, 사용자 경험 개선에 직접 연결됩니다. 이전 절에서 각 컴포넌트의 역할을 확인했다면, 이번 절에서는 두 방식을 어떻게 조합해 시너지를 낼지 정리하겠습니다.
서버-클라이언트 컴포넌트 조합의 중요성
Next.js App Router의 핵심 철학은 기본은 서버, 필요할 때만 클라이언트입니다. 즉, 가능한 한 많은 작업을 서버에서 처리하여 클라이언트의 부담을 줄이고, 상호작용이 필수적인 부분만 클라이언트 컴포넌트로 분리하는 것입니다. 이러한 조합은 다음과 같은 이점을 제공합니다.
- 초기 로딩 성능 최적화: 서버 컴포넌트는 JavaScript 번들에 포함되지 않으므로, 초기 페이지 로딩 시 클라이언트가 다운로드하고 파싱해야 할 JavaScript 양이 크게 줄어듭니다. 이는 특히 모바일 환경에서 Critical Rendering Path를 단축시켜 체감 로딩 속도를 향상시킵니다.
- 향상된 SEO: 서버에서 미리 HTML이 렌더링되므로, 검색 엔진 크롤러가 쉽게 콘텐츠를 읽고 색인화할 수 있습니다.
- 보안 강화: 민감한 데이터 페칭 로직이나 API 키는 서버 컴포넌트에서 안전하게 처리되며 클라이언트에 노출되지 않습니다.
- 개발 편의성: 데이터베이스나 파일 시스템과 같은 백엔드 리소스에 직접 접근하여 데이터 페칭 로직을 더욱 단순하게 작성할 수 있습니다.
- 하이드레이션 최적화: 서버에서 미리 렌더링된 HTML을 클라이언트 컴포넌트가 받아서 상호작용 가능한 상태로 만드는 하이드레이션 과정이 더욱 효율적입니다.
서버-클라이언트 경계 이해하기
서버 컴포넌트와 클라이언트 컴포넌트를 조합할 때 가장 중요한 개념은 바로 경계(Boundary)입니다. "use client" 지시어가 있는 파일은 해당 파일부터 그 하위 모든 컴포넌트(자식)를 포함하여 클라이언트 경계를 형성합니다.
기본은 서버 컴포넌트: "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) 해야 합니다. 함수, Date 객체, Map, Set 등은 직접 전달할 수 없으며, JSON으로 표현 가능한 원시 값, 객체, 배열 등이 전달되어야 합니다. 함수를 전달하려면 클라이언트 컴포넌트에서 정의해야 합니다.
서버-클라이언트 조합 패턴 예시
실제 애플리케이션에서 서버 컴포넌트와 클라이언트 컴포넌트가 어떻게 함께 사용되는지 구체적인 시나리오를 통해 살펴보겠습니다.
시나리오 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가 페이지 로드 시점에 이미 서버에서 렌더링되었음을 의미합니다. - 중요 메시지 보기 버튼을 클릭하면 메시지가 토글됩니다. 이 토글은 클라이언트에서만 일어나며, 메시지의 내용은 서버에서 받은 HTML 그대로입니다.
이 패턴은 클라이언트 컴포넌트가 서버 컴포넌트의 결과물(미리 렌더링된 HTML)을 다루는 것이지, 서버 컴포넌트 코드를 클라이언트에서 실행하는 것이 아님을 명확히 보여줍니다.
아래 다이어그램은 실제 화면을 서버 영역, 클라이언트 섬, children 슬롯으로 나누어 배치하는 판단 흐름을 정리한 것입니다.
언제 어떤 컴포넌트를 사용할 것인가?
두 가지 컴포넌트 유형 중 어떤 것을 선택할지는 컴포넌트의 역할과 필요한 기능에 따라 신중하게 결정해야 합니다.
- 기본은 서버 컴포넌트: 특별한 이유가 없다면 모든 컴포넌트를 서버 컴포넌트로 시작하세요. 이는 Next.js의 성능 이점을 최대한 활용하는 방법입니다.
-
클라이언트 컴포넌트가 필요한 경우
useState,useEffect등의 React 훅을 사용해야 할 때.onClick,onChange등 이벤트 핸들러를 사용해야 할 때.window,document등 브라우저 전용 API에 접근해야 할 때.- 클라이언트 측 애니메이션라이브러리를 사용해야 할 때.
- 클라이언트 측 상태 관리 라이브러리 (Redux, Zustand 등)와 연동해야 할 때.
localStorage,indexedDB등 브라우저 저장소를 사용해야 할 때.
서버와 클라이언트를 조합할 때는 기능 목록보다 먼저 데이터 위치, 상호작용 필요성, 번들 영향을 같이 봐야 합니다. 아래 다이어그램은 실제 컴포넌트를 어디에 둘지 결정하는 기준을 한 화면에 모은 것입니다.
서버와 클라이언트 컴포넌트를 조합할 때는 서버가 데이터와 구조를 만들고, 클라이언트가 작은 상호작용 섬을 맡는 식으로 경계를 작게 유지하는 것이 핵심입니다.
서버 컴포넌트와 클라이언트 컴포넌트를 올바르게 조합하는 것은 Next.js App Router 아키텍처를 마스터하는 핵심 단계입니다. 각 컴포넌트의 장점을 이해하고, 적절한 시점에 올바른 컴포넌트를 사용함으로써 강력하고 효율적인 웹 애플리케이션을 구축할 수 있습니다.
아래 다이어그램은 이 섹션의 핵심 판단 기준과 적용 흐름을 한 번에 점검할 수 있도록 정리한 것입니다.