
내 서버 컴포넌트가 오염됐다 (Next.js Composition 패턴)
서버 컴포넌트를 클라이언트 컴포넌트 안에 import 했더니, DB 연결이 끊기고 에러가 폭발했습니다. Next.js App Router의 핵심인 'Composition Pattern'을 구멍 뚫린 도넛에 비유해 설명하고, Context Provider를 올바르게 분리하는 방법을 정리해봤습니다.

서버 컴포넌트를 클라이언트 컴포넌트 안에 import 했더니, DB 연결이 끊기고 에러가 폭발했습니다. Next.js App Router의 핵심인 'Composition Pattern'을 구멍 뚫린 도넛에 비유해 설명하고, Context Provider를 올바르게 분리하는 방법을 정리해봤습니다.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

엄청난 데이터를 아주 적은 메모리로 검사하는 방법. 100% 정확도를 포기하고 99.9%의 효율을 얻는 확률적 자료구조의 세계. 비트코인 지갑과 스팸 필터는 왜 이것을 쓸까요?

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

Next.js 13+ App Router로 프로젝트를 하던 중이었습니다.
DB에서 유저 목록을 직접 가져와서 보여주는 UserList(서버 컴포넌트)를 만들었습니다.
그리고 이걸 예쁜 Modal(클라이언트 컴포넌트) 안에 넣어서 보여주고 싶었죠.
단순하게 생각하고 Modal 파일 안에서 UserList를 import 했습니다.
/* ❌ 나쁜 예시: Client Component에서 Server Component import */
'use client'; // 이것 때문에 아래 import 되는 모든 것이 Client가 됨
import UserList from './UserList'; // 너도 이제 Client Component야
export default function Modal() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="modal">
<UserList />
</div>
);
}
결과는? 대참사였습니다.
UserList 안에는 db.query() 같은 서버 전용 코드가 있었는데, 브라우저 콘솔창에 빨간 에러가 떴습니다.
Module not found: Can't resolve 'fs' Module not found: Can't resolve 'pg'
브라우저가 "나보고 파일 시스템(fs)이랑 DB(pg)를 어떻게 쓰라는 거야?"라고 화를 낸 거죠.
Next.js에는 무서운 법칙이 있습니다. "클라이언트 컴포넌트가 import 하는 모든 컴포넌트는 클라이언트 컴포넌트로 간주된다."
'use client'라고 선언된 파일(Boundary)에서 import 되는 순간, 그 파일도 자바스크립트 번들에 포함되어 브라우저로 전송됩니다.
제 소중한 UserList가 강제로 클라이언트 컴포넌트로 강등당한 것입니다.
DB 접속 정보를 품은 채로, 보안도 취약해지고 번들 사이즈도 커져버렸죠.
그럼 클라이언트 컴포넌트(껍데기/상호작용) 안에 서버 컴포넌트(알맹이/데이터)를 넣으려면 어떻게 해야 할까요?
직접 import 하지 말고, children prop으로 받으면 됩니다.
이걸 저는 "도넛 패턴"이라고 부릅니다.
Modal은 도넛의 '빵' 부분(틀)이고, 가운데 구멍(children)은 비워둡니다.
그 구멍에 무엇을 채울지는 부모(서버 컴포넌트)가 결정합니다.
/* ✅ 좋은 예시: Children으로 전달 */
// 1. Modal.tsx (Client) - 도넛 껍데기
'use client';
export default function Modal({ children }) {
// useState 등 사용 가능
return <div className="modal">{children}</div>;
}
// 2. page.tsx (Server) - 도넛 가게 주인
import Modal from './Modal';
import UserList from './UserList';
export default async function Page() {
// 여기서 둘을 조립(Compose)합니다.
return (
<Modal>
<UserList /> {/* ✅ 서버 컴포넌트 유지됨! */}
</Modal>
);
}
이렇게 하면 Modal은 UserList의 존재를 모릅니다. 단지 children이라는 슬롯만 렌더링할 뿐이죠.
덕분에 UserList는 여전히 서버에서 실행되고, HTML 결과물만 Modal 안으로 쏙 들어갑니다.
JS 번들 사이즈도 줄어들고, 보안도 지켜집니다.
이 패턴이 가장 절실하게 쓰이는 곳은 Context Provider입니다.
전역 테마(ThemeProvider)를 적용하려면 최상위 layout.tsx에서 앱 전체를 감싸야 합니다.
하지만 Context(createContext)는 클라이언트 기능입니다.
그렇다고 RootLayout 파일 맨 위에 'use client'를 붙이면?
앱 전체가 클라이언트 렌더링(CSR)으로 변해버립니다. SEO도 망하고, 초기 로딩 속도도 느려집니다.
그래서 이렇게 분리해야 합니다.
/* providers.tsx (Client) - 껍데기 */
'use client';
export function Providers({ children }) {
return (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
);
}
/* layout.tsx (Server) - 알맹이 채우기 */
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Providers는 Client지만, 그 자식들은 Server Component 가능 */}
<Providers>
{children}
</Providers>
</body>
</html>
);
}
이렇게 하면 RootLayout은 여전히 서버 컴포넌트로 남을 수 있고, 그 안의 children들도 기본적으로 서버 컴포넌트로 동작합니다.
필요한 기능(테마)만 클라이언트에서 주입하는 것이죠.
React에서는 children 뿐만 아니라, 임의의 props로도 컴포넌트(ReactNode)를 전달할 수 있습니다.
이른바 "Named Slots" 패턴입니다.
/* Layout.tsx (Client) */
'use client';
export default function Layout({ header, sidebar, content }) {
const [showSidebar, setShow] = useState(true);
return (
<div>
{header}
<div className="flex">
{showSidebar && sidebar}
{content}
</div>
</div>
);
}
/* page.tsx (Server) */
import Layout from './Layout';
import Header from './ServerHeader';
import Sidebar from './ServerSidebar';
export default function Page() {
return (
<Layout
header={<Header />}
sidebar={<Sidebar />} // 각각이 독립적인 서버 컴포넌트!
content={<main>...</main>}
/>
);
}
이 패턴을 쓰면, 복잡한 레이아웃 상호작용(사이드바 토글 등)은 클라이언트에서 처리하면서도,
각 구획의 컨텐츠는 여전히 서버 컴포넌트로 유지하여 DB 조회가 가능합니다.
Suspense와 결합하면 각 구획을 스트리밍(Streaming)으로 독립적으로 로딩할 수도 있죠.
"그냥 다 클라이언트로 짜면 안 되나요?" 라고 물으신다면, 성능 차이가 압도적이기 때문입니다.
useEffect로 데이터를 가져오면 3배 느립니다. 서버에서는 병렬로 동시에 가져옵니다.Next.js App Router를 쓴다는 건, 이 아키텍처적 이점을 누리겠다는 뜻입니다.
Next.js App Router의 핵심 철학은 "Lego Assembly (레고 조립)"입니다.
클라이언트 컴포넌트 안에서 import ServerComponent를 타이핑하려 할 때마다 멈추세요.
그리고 스스로에게 물어보세요.
"이걸 props(children)로 넘길 수는 없을까?"
이 질문 하나가 여러분의 앱 성능을 2배 빠르게 만듭니다.
I was working with Next.js 13+ App Router.
I created a UserList (Server Component) that fetches users from the DB directly.
I wanted to display this list inside a pretty Modal (Client Component).
Reasoning simply, I imported UserList inside the Modal file.
/* ❌ Bad Example: Importing Server Component in Client Component */
'use client'; // This directive infects everything imported below
import UserList from './UserList'; // You are now a Client Component too
export default function Modal() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="modal">
<UserList />
</div>
);
}
The result? Disaster.
UserList contained server-only code like db.query(), and the browser console exploded with errors.
Module not found: Can't resolve 'fs' Module not found: Can't resolve 'pg'
The browser screamed, "How am I supposed to use the File System and DB Drivers?"
Next.js has a scary rule regarding the boundary: "Anything imported by a Client Component becomes a Client Component."
Once a file is imported into a 'use client' file, it gets bundled into the JavaScript sent to the browser.
My precious UserList was forcibly demoted to a Client Component.
Carrying its secret DB logic into the insecure browser environment, keeping bundle sizes huge and security weak.
So how do we put a Server Component (Data) inside a Client Component (Interaction)?
Don't import it directly. Pass it as a children prop.
I call this the "Donut Pattern."
The Modal is the dough (the wrapper), and the hole (children) is left empty.
The Parent (Server Component) decides what fills that hole.
/* ✅ Good Example: Passing via Children */
// 1. Modal.tsx (Client) - The Dough
'use client';
export default function Modal({ children }) {
// Can use useState, useEffect
return <div className="modal">{children}</div>;
}
// 2. page.tsx (Server) - The Baker
import Modal from './Modal';
import UserList from './UserList';
export default async function Page() {
// We compose them here.
return (
<Modal>
<UserList /> {/* ✅ Stays a Server Component! */}
</Modal>
);
}
In this setup, Modal doesn't know UserList exists. It just renders a slot called children.
Thanks to this, UserList executes on the server, and only the resulting HTML is slotted into the Modal.
JS bundle size shrinks, and security is preserved.
The most critical use case for this is Context Providers.
To apply a global ThemeProvider, you need to wrap the app in RootLayout. But Context (createContext) is a client feature.
If you add 'use client' to the top of layout.tsx?
Your entire app becomes Client Rendered (CSR). You lose SEO benefits and initial load speed.
So you must separate them:
/* providers.tsx (Client) - Wrapper */
'use client';
export function Providers({ children }) {
return (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
);
}
/* layout.tsx (Server) - Injection */
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Providers is Client, but its children can still be Server Components */}
<Providers>
{children}
</Providers>
</body>
</html>
);
}
By doing this, RootLayout remains a Server Component, and its children (your pages) remain Server Components by default.
You only inject the specific client-side logic (Theme) where needed.
Who said you can only have one hole (children)?
You can have as many holes as you like! This is the "Named Slots" pattern.
/* Layout.tsx (Client) */
'use client';
export default function AppLayout({ header, sidebar, content }) {
const [isSidebarOpen, setOpen] = useState(true);
return (
<div className="layout">
<div className="top">{header}</div>
<div className="body">
{isSidebarOpen && <div className="side">{sidebar}</div>}
<div className="main">{content}</div>
</div>
</div>
);
}
/* page.tsx (Server) */
import AppLayout from './Layout';
import Header from './Header'; // Server Component
import Sidebar from './Sidebar'; // Server Component
import Feed from './Feed'; // Server Component
export default function Page() {
return (
<AppLayout
header={<Header />}
sidebar={<Sidebar />}
content={<Feed />}
/>
);
}
This is incredible. AppLayout manages the state (sidebar validation), but what gets rendered in those areas is entirely controlled by the Server.
You can fetch data in Sidebar, fetch different data in Feed, and run them in parallel on the server (using Suspense), then slot them into the Client layout.
Why go through all this trouble? Why not just make everything Client Components like in CRA?
moment, date-fns) or Markdown parsers stay on the server.Header, Sidebar, and Feed were Client Components, they would likely fetch data after they mount (Waterfall). As Server Components, the server starts fetching data immediately when the request hits.The Composition Pattern is not just a syntax trick; it's the fundamental architecture for building performant, secure apps in Next.js.
A common misconception is that "Parent Server Component fetches all data". Actually, checking the Prop Drilling anti-pattern applies to data too.
You can have a Server Component (Child) inside a Client Component (Parent) inside a Server Component (Grandparent), thanks to the children prop pattern.
Server Page (Fetches User)
└── Client Layout (Interactive Sidebar)
└── Server Feed (Fetches Posts) <-- Parallel Fetching!
└── Client Like Button
Wait, Server Feed is inside Client Layout, how can it fetch data?
Because in React Tree, Server Feed is passed as children to Client Layout. It effectively renders before Client Layout on the server.
So User and Posts are fetched in parallel on the server.
This is the "Interleaved" rendering capability of React Server Components. It allows you to colocate data fetching right where it's needed, without waterfall blocking.