
"use client"를 도대체 어디에 써야 할까? (Next.js 13+ 가이드)
Next.js 13 App Router를 처음 쓸 때 가장 많이 하는 실수인 'use client 남발'을 막는 방법을 소개합니다. 서버 컴포넌트와 클라이언트 컴포넌트의 경계(Boundary)를 명확히 이해하고, 성능을 지키면서 인터랙션을 구현하는 실제 패턴을 다룹니다.

Next.js 13 App Router를 처음 쓸 때 가장 많이 하는 실수인 'use client 남발'을 막는 방법을 소개합니다. 서버 컴포넌트와 클라이언트 컴포넌트의 경계(Boundary)를 명확히 이해하고, 성능을 지키면서 인터랙션을 구현하는 실제 패턴을 다룹니다.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

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

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

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

Next.js 13 App Router를 처음 도입했을 때, 저는 좀 억울했습니다.
기존 React 코드(useState, useEffect)를 그대로 복사해서 넣었는데, 화면이 빨갛게 물들었거든요.
Error: useState only works in Client Components. Add the "use client" directive at the top of the file.
"아니, 내가 쓰던 React가 왜 안 돼?"
급한 마음에 저는 모든 파일의 맨 윗줄에 "use client"를 붙이기 시작했습니다.
그러자 에러가 사라지고 앱이 잘 돌아갔습니다. 저는 천재인 줄 알았죠.
하지만 며칠 뒤, 빌드 결과물을 보고 경악했습니다. 자바스크립트 번들 사이즈가 이전보다 2배나 커진 것입니다. Next.js의 강력한 기능인 "서버 컴포넌트"를 제 손으로 모조리 꺼버린 덕분이었죠.
이 문제를 해결하려면 먼저 "렌더링이 어디서 일어나는가"를 이해해야 합니다.
Next.js App Router(app/ 폴더)의 모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Component)입니다.
즉, 브라우저가 아니라 Node.js 서버에서 실행되고 HTML로 변환되어 전송됩니다.
특정 파일에 "use client"를 적는다는 건, Next.js에게 이렇게 말하는 것과 같습니다.
"이 파일은 브라우저에서 실행돼야 해. 자바스크립트 번들에 포함시켜서 사용자의 컴퓨터로 보내줘."
브라우저로 보내야 하는 경우는 딱 2가지뿐입니다.
onClick, onChange 같은 이벤트가 필요할 때.useState, useEffect, window, document를 써야 할 때.이 외에는? 무조건 서버에 남겨두는 게 이득입니다.
가장 중요한 규칙은 "최대한 트리의 끝부분(Leaf Node)으로 미루라"는 것입니다.
페이지 전체를 클라이언트 컴포넌트로 만들면, 그 안의 모든 자식들도 강제로 클라이언트 컴포넌트가 됩니다.
// app/page.tsx
'use client'; // 😱 최악의 선택
import Header from './Header';
import PostList from './PostList'; // 거대한 라이브러리 포함
import Footer from './Footer';
export default function Page() {
const [count, setCount] = useState(0); // 고작 이거 때문에...
return (
<div>
<Header />
<PostList /> {/* 얘도 덩달아 클라이언트 번들에 포함됨 */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Footer />
</div>
);
}
고작 버튼 하나 때문에 PostList 같은 무거운 컴포넌트까지 전부 브라우저로 전송됩니다. 성능 낭비죠.
인터랙션이 필요한 부분만 별도 파일로 분리하고, 거기에만 "use client"를 붙이세요.
// app/Counter.tsx
'use client'; // 👈 여기만!
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// app/page.tsx
// (기본적으로 Server Component)
import Header from './Header';
import PostList from './PostList'; // ✅ 서버에 남아서 JS 0KB!
import Counter from './Counter';
export default function Page() {
return (
<div>
<Header />
<PostList />
<Counter /> {/* 인터랙션이 필요한 곳만 쏙 끼워넣기 */}
<Footer />
</div>
);
}
이제 PostList는 서버에서 HTML로만 렌더링되므로, 사용자는 Counter의 작은 JS 코드만 다운로드하면 됩니다.
하지만 실제로는 이렇게 단순하지 않습니다. 서버 컴포넌트 안에 클라이언트 컴포넌트가 있고, 그 안에 다시 서버 컴포넌트를 넣고 싶다면요?
클라이언트 컴포넌트 안에서 서버 컴포넌트를 직접 import하면 에러가 납니다 (또는 서버 컴포넌트가 클라이언트로 오염됩니다).
이럴 때는 children prop을 이용해 "구멍"을 뚫어주세요.
// ClientComponent.tsx
'use client';
export default function Card({ children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="card">
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* 서버 컴포넌트가 들어올 자리 */}
</div>
);
}
// ServerPage.tsx
import Card from './ClientComponent';
import ServerData from './ServerData';
export default function Page() {
// 서버에서 렌더링 된 HTML을 Card의 children으로 전달
return (
<Card>
<ServerData />
</Card>
);
}
이렇게 하면 ServerData는 여전히 서버에 머물 수 있습니다. 이것이 Next.js 최적화의 핵심입니다.
"클라이언트 컴포넌트에서 폼(Form)을 처리하려면 useState를 써야 하잖아요?"
옛날(Next.js 12)에는 그랬습니다. API Route를 만들고 fetch를 날려야 했죠.
하지만 이제는 Server Actions가 있습니다.
서버에서 실행되는 함수를 만들고, 클라이언트 컴포넌트에 prop으로 넘기거나 import 할 수 있습니다.
// actions.ts
'use server'; // 이 함수는 서버에서만 실행됨
export async function saveData(formData) {
await db.users.create(formData);
}
// Form.tsx
'use client';
import { saveData } from './actions';
export default function Form() {
return (
// useState 없이도 데이터 전송 가능!
<form action={saveData}>
<input name="email" />
<button>가입</button>
</form>
);
}
이렇게 하면, Form 컴포넌트는 브라우저에서 돌지만, 핵심 로직은 서버 액션이 담당하므로 JS 코드를 획기적으로 줄일 수 있습니다.
"use client"를 쓴다고 해서 무조건 useState로 범범이 될 필요는 없다는 뜻입니다.
Next.js App Router의 성능 최적화 비결은 "게으름"입니다. 브라우저에게 일을 시키지 마세요. 최대한 서버에서 미리 해서 보내주세요.
"use client"를 타이핑하기 전에, 3초만 고민해 보세요.
window)가 필요한가?여러분의 사용자는 가벼운 앱을 원합니다.
When I first migrated to Next.js 13 App Router, I felt attacked.
I copied my existing React code (useState, useEffect), and the screen turned red.
Error: useState only works in Client Components. Add the "use client" directive at the top of the file.
"Why isn't my React working?"
In a panic, I started adding "use client" to the top of every single file.
The errors vanished, and the app worked. I thought I was a genius.
But a few days later, checking the build output, I was horrified. The JavaScript bundle size had doubled. I had single-handedly turned off Next.js's most powerful feature: "Server Components."
To fix this, we must understand "Where does rendering happen?"
In Next.js App Router (app/ folder), every component is a Server Component by default.
Basically, they run on the Node.js server, render to HTML, and are sent to the browser.
Writing "use client" at the top of a file is like telling Next.js:
"This file needs to run in the browser. Bundle it into JavaScript and ship it to the user's computer."
We only need to ship code to the browser in 2 specific cases:
onClick, onChange, onSubmit.useState, useEffect, window, localStorage.For everything else? Keep it on the server. It's free performance.
The golden rule is "Push it to the Leaf Nodes (tips of the tree)."
If you make the entire Page a Client Component, all its children become Client Components too.
// app/page.tsx
'use client'; // 😱 Worst choice
import Header from './Header';
import PostList from './PostList'; // Huge library included
import Footer from './Footer';
export default function Page() {
const [count, setCount] = useState(0); // Just for this button...
return (
<div>
<Header />
<PostList /> {/* Forced to be bundled into Client JS */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Footer />
</div>
);
}
Because of one button, heavy components like PostList are sent to the browser unnecessarily. This is a waste of bandwidth.
Isolate the interactive part into its own file and add "use client" only there.
// app/Counter.tsx
'use client'; // 👈 Only here!
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// app/page.tsx
// (Server Component by default)
import Header from './Header';
import PostList from './PostList'; // ✅ Stays on Server (0KB JS)
import Counter from './Counter';
export default function Page() {
return (
<div>
<Header />
<PostList />
<Counter /> {/* Slot in the interactive part */}
<Footer />
</div>
);
}
Now PostList renders as static HTML on the server, and the user only downloads the tiny JS code for Counter.
In reality, apps are complex. What if you have a Server Component inside a Client Component, inside another Server Component?
You cannot import a Server Component directly into a Client Component. It causes an error (or worse, silent pollution).
Instead, pass it as a children prop.
// ClientComponent.tsx
'use client';
export default function Card({ children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="card">
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* Slot for Server Component */}
</div>
);
}
// ServerPage.tsx
import Card from './ClientComponent';
import ServerData from './ServerData';
export default function Page() {
// Server renders ServerData to HTML and passes it to Card
return (
<Card>
<ServerData />
</Card>
);
}
By doing this, ServerData stays on the server. This is the secret to Next.js optimization.
"Wait, if I use use client for a form, how do I send data to the server?"
In the old days (Next.js 12), you had to create an API Route (pages/api/...) and fetch() it.
But now, we have Server Actions.
You can define a function that runs on the server, and pass it directly to a Client Component.
// actions.ts
'use server'; // This function runs on the server
export async function submitData(formData) {
await db.create(formData);
}
// Form.tsx
'use client'; // This component runs in the browser
import { submitData } from './actions';
export default function Form() {
return (
<form action={submitData}>
<input name="email" />
<button type="submit">Sign Up</button>
</form>
);
}
This is the beauty of the Boundary.
The Form captures user input in the browser, and hands it over to submitData which executes securely on the server.
You don't need useState or onSubmit handlers for simple forms anymore.
This further reduces the amount of Client-Side JavaScript you need to write.
Be careful when exporting huge utility files.
If you have a utils.ts that imports a heavy library (like moment.js or lodash), and you import a tiny function from it into a Client Component...
The entire utils.ts and all its dependencies might get bundled into the client chunk.
Webpack and Turbopack are smart, but they aren't perfect. If you have a file that mixes Server-only logic (DB connection) and Client-safe logic (Date formatting), split them!
utils/db.ts (Server only - install server-only package to enforce this)utils/format.ts (Shared)This discipline prevents accidental bundle bloating.