서버와 클라이언트 컴포넌트 조합하기
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 // ServerComponent.tsx (서버) import ClientComponent from './ClientComponent'; // 클라이언트 임포트 가능 export default function ServerComponent() { return <ClientComponent />; }
- 클라이언트 -> 서버 (불가능, children 패턴 사용): 클라이언트 컴포넌트는 서버 컴포넌트를 직접
import
할 수 없습니다. 대신, 서버 컴포넌트를 클라이언트 컴포넌트의children
prop으로 전달받아 렌더링해야 합니다.ClientComponent.tsx // ClientComponent.tsx (클라이언트) "use client"; // import ServerComponent from './ServerComponent'; // ❌ 불가능! export default function ClientComponent({ children }: { children: React.ReactNode }) { return <div>{children}</div>; // children으로 서버 컴포넌트의 결과물을 받음 }
// ServerParent.tsx (서버) 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
사용자별 대시보드 (초기 데이터는 서버, 상호작용은 클라이언트) 형태로 조합하는 것은 가장 흔한 패턴으로, 대시보드의 주요 데이터는 서버에서 가져와 빠르게 렌더링하고, 특정 위젯의 상호작용은 클라이언트에서 처리합니다.
// src/app/dashboard/page.tsx (서버 컴포넌트) - 대시보드 메인 페이지
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>
);
}
// src/app/dashboard/ChartWidget.tsx (클라이언트 컴포넌트)
"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>
);
}
// src/app/dashboard/UserProfileCard.tsx (클라이언트 컴포넌트)
"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 패턴)을 하는 것은 클라이언트 컴포넌트가 특정 로직(예: 조건부 렌더링, 이벤트 핸들링)을 수행하면서 그 내부에 서버에서 렌더링된 콘텐츠를 포함해야 할 때 사용합니다.
// src/app/conditional-content/page.tsx (서버 컴포넌트)
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>
);
}
// src/app/conditional-content/ClientConditionalWrapper.tsx (클라이언트 컴포넌트)
"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>
);
}
// src/app/conditional-content/ImportantServerMessage.tsx (서버 컴포넌트)
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)을 다루는 것이지, 서버 컴포넌트 코드를 클라이언트에서 실행하는 것이 아님을 명확히 보여줍니다.
언제 어떤 컴포넌트를 사용할 것인가?
두 가지 컴포넌트 유형 중 어떤 것을 선택할지는 컴포넌트의 역할과 필요한 기능에 따라 신중하게 결정해야 합니다.
- 기본은 서버 컴포넌트: 특별한 이유가 없다면 모든 컴포넌트를 서버 컴포넌트로 시작하세요. 이는 Next.js의 성능 이점을 최대한 활용하는 방법입니다.
- 클라이언트 컴포넌트가 필요한 경우
useState
,useEffect
등의 React 훅을 사용해야 할 때.onClick
,onChange
등 이벤트 핸들러를 사용해야 할 때.window
,document
등 브라우저 전용 API에 접근해야 할 때.- 클라이언트 측 애니메이션 라이브러리를 사용해야 할 때.
- 클라이언트 측 상태 관리 라이브러리 (Redux, Zustand 등)와 연동해야 할 때.
localStorage
,indexedDB
등 브라우저 저장소를 사용해야 할 때.
서버 컴포넌트와 클라이언트 컴포넌트를 올바르게 조합하는 것은 Next.js App Router 아키텍처를 마스터하는 핵심 단계입니다. 각 컴포넌트의 장점을 이해하고, 적절한 시점에 올바른 컴포넌트를 사용함으로써 강력하고 효율적인 웹 애플리케이션을 구축할 수 있습니다.