이전 세션에서 배운 규칙을 다시 떠올려 봅시다: 클라이언트 컴포넌트가 import한 것은 모두 클라이언트가 된다.
그렇다면 클라이언트 컴포넌트 파일에서 서버 컴포넌트를 import하면 어떻게 될까요?
'use client';
import ServerComponent from './ServerComponent';
// ⚠️ ServerComponent가 서버 전용 코드(DB 접근 등)를 사용한다면 에러가 발생합니다.
// 서버 전용 기능이 없더라도 클라이언트 번들에 포함되어 서버 컴포넌트의 이점을 잃습니다.
export function ClientWrapper() {
return (
<div>
<ServerComponent />
</div>
);
}
서버 컴포넌트가 DB 접근, 파일 시스템 읽기 등 서버 전용 기능을 사용한다면 클라이언트에서 실행될 수 없으므로 에러가 발생합니다. 서버 전용 기능을 쓰지 않더라도, 클라이언트에서 import하는 순간 해당 코드는 클라이언트 번들에 포함되어 브라우저에서 실행되는 코드로 취급됩니다.
그렇다면 "서버 컴포넌트에서 생성된 콘텐츠를 클라이언트 컴포넌트 안에 넣고 싶을 때"는 어떻게 해야 할까요?
해답은 import 대신 children(또는 다른 prop)으로 전달하는 것입니다.
import vs children 차이:
❌ import (클라이언트가 서버를 "끌어들임")
ClientComponent.tsx → import ServerComponent → ⚠️ 클라이언트로 취급됨
✅ children (서버가 결과를 "건네줌")
ServerParent.tsx
└── <ClientComponent>
<ServerComponent /> ← children으로 전달
</ClientComponent>
이것을 "택배 상자"로 비유할 수 있습니다: 클라이언트 컴포넌트는 택배 상자입니다. 열기, 닫기, 옮기기 같은 인터랙션을 담당하죠. 서버 컴포넌트의 렌더링 결과는 서버에서 미리 만들어진 상품입니다. 그리고 이 상품을 상자에 넣는 건 물류센터(ServerParent)가 합니다. 택배 상자는 안에 뭐가 들었는지 알 필요 없이, 그냥 담아서 배송하면 됩니다.
// components/InteractiveWrapper.tsx
'use client';
import { useState } from 'react';
export function InteractiveWrapper({
children,
}: {
children: React.ReactNode;
}) {
const [isVisible, setIsVisible] = useState(true);
return (
<div>
<button onClick={() => setIsVisible(!isVisible)}>
{isVisible ? '숨기기' : '보이기'}
</button>
{isVisible && children}
{/* ↑ children이 서버 컴포넌트여도 OK! */}
</div>
);
}
// app/page.tsx - 서버 컴포넌트
import { InteractiveWrapper } from './components/InteractiveWrapper';
import HeavyContent from './components/HeavyContent'; // 서버 컴포넌트
export default function Home() {
return (
<InteractiveWrapper>
<HeavyContent />
{/* ↑ 서버에서 렌더링된 결과가 children으로 전달됨 */}
</InteractiveWrapper>
);
}
전역 상태나 테마를 제공하는 Provider는 보통 Context API를 사용하므로 클라이언트 컴포넌트여야 합니다. 하지만 Provider 아래의 모든 페이지가 클라이언트가 되면 안 되겠죠?
// providers/ThemeProvider.tsx
'use client';
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggle: () => void;
}>({ theme: 'light', toggle: () => {} });
export function useTheme() {
return useContext(ThemeContext);
}
export function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
<div data-theme={theme}>{children}</div>
</ThemeContext.Provider>
);
}
// app/layout.tsx - 서버 컴포넌트
import { ThemeProvider } from './providers/ThemeProvider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<ThemeProvider>
{children}
{/* ↑ children은 서버 컴포넌트 → 번들에 포함 안 됨 */}
</ThemeProvider>
</body>
</html>
);
}
ThemeProvider는 클라이언트 컴포넌트이지만, children으로 전달된 페이지 컴포넌트들은 여전히 서버 컴포넌트로 유지됩니다. Provider가 children을 import하지 않고 slot으로 받기 때문입니다.
스크롤 애니메이션, 드래그 앤 드롭 등 인터랙션이 필요하지만 내부 콘텐츠는 서버에서 렌더링하고 싶을 때 사용합니다.
// components/Accordion.tsx
'use client';
import { useState } from 'react';
export function Accordion({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border rounded-lg">
<button
className="w-full p-4 text-left font-bold"
onClick={() => setIsOpen(!isOpen)}
>
{title} {isOpen ? '▲' : '▼'}
</button>
{isOpen && (
<div className="p-4 border-t">{children}</div>
)}
</div>
);
}
// app/faq/page.tsx - 서버 컴포넌트
import { Accordion } from './components/Accordion';
// 서버에서만 사용하는 데이터
const faqs = [
{ question: 'Next.js란?', answer: 'React 기반 풀스택 프레임워크입니다.' },
{ question: 'App Router란?', answer: '파일 기반 라우팅 시스템입니다.' },
];
export default function FAQ() {
return (
<main>
<h1>자주 묻는 질문</h1>
{faqs.map(faq => (
<Accordion key={faq.question} title={faq.question}>
<p>{faq.answer}</p>
</Accordion>
))}
</main>
);
}
서버 컴포넌트에서 클라이언트 컴포넌트로 props를 전달할 때, React가 직렬화 가능한 값만 전달할 수 있습니다. 다만 'use server'로 선언한 Server Action(Server Function)은 함수 형태라도 예외적으로 전달할 수 있습니다.
| 전달 가능 ✅ | 전달 불가 ❌ |
|---|---|
| 문자열, 숫자, bigint, 불리언 | 일반 함수 (콜백, 이벤트 핸들러) |
| 배열, 일반 객체, Promise | 클래스 인스턴스 |
null, undefined, 전역 Symbol(Symbol.for) | 임의로 만든 Symbol |
| Date, Map, Set | |
| JSX (React 엘리먼트), Server Action |
// ❌ 함수를 prop으로 전달하면 에러
// app/page.tsx (서버 컴포넌트)
import { ClientButton } from './components/ClientButton';
export default function Home() {
const handleClick = () => console.log('클릭!');
// ↑ 일반 함수는 직렬화 불가능
return <ClientButton onClick={handleClick} />;
// ↑ ❌ 에러: 일반 함수를 서버→클라이언트로 전달 불가
}
// ✅ 해결: 클라이언트 컴포넌트 내부에서 함수를 정의
// components/ClientButton.tsx
'use client';
export function ClientButton() {
const handleClick = () => console.log('클릭!');
// ↑ 클라이언트 컴포넌트 안에서 정의하면 OK
return <button onClick={handleClick}>클릭</button>;
}
// ❌ app/page.tsx (서버 컴포넌트)
import { useState } from 'react';
export default function Home() {
const [count, setCount] = useState(0);
// Error: useState only works in Client Components.
// Add the "use client" directive at the top of the file.
return <div>{count}</div>;
}
에러 메시지:
You're importing a component that needs `useState`. This React Hook only works in a
Client Component. To fix, mark the file (or its parent) with the `"use client"` directive.
해결: 파일 최상단에 'use client'를 추가하거나, 상태가 필요한 부분만 별도 클라이언트 컴포넌트로 분리하세요.
// ❌ app/page.tsx (서버 컴포넌트)
import { UserList } from "./components/UserList";
export default function Home() {
const formatName = (name: string) => name.toUpperCase();
return <UserList formatName={formatName} />;
}
에러 메시지:
Uncaught Error: Functions cannot be passed directly to Client Components unless you
explicitly expose it by marking it with "use server". Or maybe you meant to call this
function rather than return it.
해결: 함수는 클라이언트 컴포넌트 내부에서 정의하거나, Server Action('use server')으로 만들어 전달하세요. Server Action은 Ch.3에서 다룹니다.
서버에서만 실행되어야 하는 코드가 실수로 클라이언트 번들에 포함되는 것을 빌드 시점에 차단할 수 있습니다.
npm install server-only
// lib/database.ts
import 'server-only';
// ↑ 이 파일을 클라이언트 컴포넌트에서 import하면 빌드 에러 발생
export async function getUsers() {
// DB 쿼리 등 서버 전용 로직
return [{ id: 1, name: '홍길동' }];
}
이 파일을 클라이언트 컴포넌트에서 import하면 빌드 시점에 다음 에러가 발생합니다:
Error: This module cannot be imported from a Client Component module.
It should only be used from a Server Component.
| 패턴 | 핵심 아이디어 |
|---|---|
| children/props 전달 | import 대신 children slot이나 다른 prop으로 서버 콘텐츠를 클라이언트 안에 배치 |
| Provider 패턴 | Provider는 클라이언트, children(페이지)은 서버 유지 |
| 인터랙티브 래퍼 | 인터랙션 셸은 클라이언트, 내부 콘텐츠는 서버 |
| 직렬화 제약 | 서버→클라이언트 props는 React가 직렬화 가능해야 함 |
server-only | 서버 전용 코드의 클라이언트 유입을 빌드 시점에 차단 |
다음 세션에서는 이 모든 개념을 종합하여 Ch.1의 블로그 프로젝트를 리팩터링하고 새 기능을 추가하는 실습을 진행합니다.