use client 지시어 활용
use client가 만드는 모듈 경계와 하위 의존성의 번들 포함 범위를 이해하고 선언 위치를 최소화합니다.
Next.js App Router에서 컴포넌트가 서버와 클라이언트 가운데 어디에서 렌더링될지를 정하는 가장 중요한 기준은 "use client" 지시어입니다. 이 문자열을 파일 맨 위에 선언하면 그 파일과 정적으로 import하는 의존성이 클라이언트 번들에 들어갑니다. Effect와 이벤트는 브라우저에서 실행되지만, 클라이언트 컴포넌트의 첫 HTML을 만드는 데는 서버도 참여합니다. 따라서 지시어를 붙였다고 모듈을 평가하거나 렌더링하는 도중에 브라우저 전용 API를 바로 읽어도 된다는 뜻은 아닙니다. 이 글에서는 "use client"의 정확한 역할과 사용법, 그리고 내부에서 어떻게 동작하는지 실무 관점에서 살펴봅니다.
'use client' 지시어의 역할과 중요성
App Router의 페이지와 레이아웃은 기본적으로 서버 컴포넌트이며, 그 아래의 모듈이 어디에서 평가되는지는 어느 쪽에서 import되는지에 따라 달라집니다. 서버 컴포넌트를 기본으로 하는 방식은 초기 성능, SEO, 보안에서 장점이 있습니다. 다만 상호작용, 브라우저 API 접근, 클라이언트 상태 관리처럼 React의 핵심 기능 일부는 브라우저 환경에서만 가능합니다. "use client" 지시어는 서버와 클라이언트가 나뉘는 지점을 분명히 하여, 필요한 컴포넌트만 클라이언트 번들에 들어가게 만듭니다.
이 지시어는 먼저 컴파일러를 위한 표시입니다. Next.js 컴파일러와 번들러(Webpack, Turbopack)에게 그 파일이 클라이언트 컴포넌트 그래프의 시작점이라는 것을 알리며, 지시어가 없는 모듈도 이 시작점에서 import되면 클라이언트 그래프에서 평가될 수 있습니다. 그래서 "use client"가 선언된 파일이 import하는 모듈은 클라이언트 번들에 들어갑니다. 서버에서 렌더링한 UI를 클라이언트 컴포넌트 안에 넣어야 한다면 직접 import하지 않고 children이나 슬롯(slot) 형태의 prop으로 넘깁니다.
서버에서 렌더링한 HTML이 브라우저에 도착하면, "use client"로 표시된 컴포넌트들은 클라이언트의 JavaScript로 하이드레이션되어 상호작용할 수 있는 상태가 됩니다. 그리고 그 파일 안에서는 useState, useEffect, window, document 같은 클라이언트 전용 기능을 쓸 수 있습니다.
'use client' 사용법
"use client" 지시어는 컴포넌트 파일의 맨 위, 다른 import 문이나 코드보다 앞에 둡니다. 아래 버튼은 useState로 클릭 여부를 기억하고 onClick으로 클릭을 처리하는데, 두 가지 모두 브라우저에서 동작해야 하므로 파일에 지시어가 필요합니다. 클릭하면 먼저 알림 창이 뜨고, 창을 닫으면 clicked가 true로 바뀐 결과가 그려져 배경색이 초록색으로, 글자가 클릭됨!으로 바뀝니다.
"use client"; // 🚨 이 지시어는 항상 파일 맨 위에 와야 합니다.
import React, { useState } from 'react';
export default function InteractiveButton() {
const [clicked, setClicked] = useState(false);
const handleClick = () => {
setClicked(true);
alert('버튼이 클릭되었습니다!');
};
return (
<button
onClick={handleClick}
style={{
padding: '10px 20px',
fontSize: '1em',
backgroundColor: clicked ? '#28a745' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
transition: 'background-color 0.3s'
}}
>
{clicked ? '클릭됨!' : '클릭하세요'}
</button>
);
}지시어는 주석을 뺀 파일의 첫 줄이어야 하며, 'use client'나 "use client"처럼 작은따옴표나 큰따옴표로 감싸야 합니다. 한 번 선언하면 그 파일이 서버 모듈 그래프와 클라이언트 모듈 그래프가 나뉘는 지점이 됩니다. 이 파일에서 import하는 하위 모듈은 모두 클라이언트 번들 대상이 되므로, 서버 컴포넌트로 남길 UI는 부모 서버 컴포넌트에서 렌더링해 children이나 props로 넘기는 구조를 씁니다.
모듈 그래프와 실행 시점 구분
"use client"는 모듈이 어느 그래프에 속하는지를 정할 뿐, 그 코드가 언제 실행되는지까지 정하지는 않습니다. 다음 표는 사용 위치에 따라 어떻게 판단하는지 정리한 것입니다.
| 사용 위치 | 판단 |
|---|---|
| 서버 그래프 | 서버 컴포넌트는 빌드할 때나 요청할 때 실행됩니다. 컴포넌트 자체의 코드는 브라우저 번들에 들어가지 않습니다. |
| 클라이언트 그래프 | use client 파일과 그 import 의존성은 브라우저 코드 대상입니다. 첫 HTML은 서버에서 프리렌더링될 수 있습니다. |
| 공유 모듈 | 지시어가 없어도 양쪽 그래프에서 import하면 각 환경에서 평가될 수 있습니다. |
| 브라우저 API | Effect나 이벤트처럼 브라우저에서 실행되는 시점에 씁니다. 지시어가 초기 서버 렌더링을 없애지는 않습니다. |
| 전달값 | 서버 부모는 React가 직렬화할 수 있는 공개 데이터와 React 노드를 props로 넘깁니다. |
'use client' 사용 시 고려사항 및 최적화
"use client"를 남용하면 Next.js App Router의 성능 이점을 잃을 수 있습니다. 가능한 한 적은 컴포넌트에만 "use client"를 선언해 클라이언트 번들을 작게 유지하는 것이 중요하며, 이를 위한 전략은 세 가지입니다.
클라이언트 컴포넌트를 리프로 내리기
첫째 전략은 클라이언트 컴포넌트를 리프로 내리는(Move Client Components to the Leaves) 것입니다. 상호작용이 필요한 클라이언트 컴포넌트를 컴포넌트 트리에서 가능한 한 깊은 곳, 즉 리프 노드로 옮깁니다. 예를 들어 헤더에서 토글 버튼만 상태가 필요하다면 "use client"를 토글 버튼에만 둡니다.
다음 코드 블록에는 바꾸기 전과 바꾼 뒤, 그리고 별도 파일을 한데 모은 발췌가 들어 있습니다. 그대로 한 파일에 붙이면 default export가 중복되므로, 파일을 나누고 생략된 ToggleButton과 useState의 import 등을 준비해야 합니다.
// Bad (전체 헤더가 클라이언트)
"use client";
export default function Header() {
// ... 복잡한 정적 콘텐츠와 작은 토글 버튼
return <header>... <ToggleButton /> ...</header>;
}
// Good (토글 버튼만 클라이언트)
// components/Header.tsx (서버 컴포넌트)
import ToggleButton from './ToggleButton';
export default function Header() {
return <header>... <ToggleButton /> ...</header>;
}
// components/ToggleButton.tsx
"use client";
export default function ToggleButton() {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen(!open)}>Toggle</button>;
}이렇게 하면 헤더의 정적인 HTML 대부분은 서버에서 렌더링되고, Header 자체의 코드는 서버에 남으며, ToggleButton과 그 의존성만 클라이언트 그래프에 들어갑니다. Next.js 런타임 같은 다른 JavaScript까지 없어지는 것은 아닙니다.
children prop 활용
둘째 전략은 children prop을 활용하는 것입니다. 서버 부모는 서버 컴포넌트의 렌더링 결과를 나타내는 React 노드를 children으로 합성합니다. RSC Payload가 이 결과를 전송하며, 서버 컴포넌트 모듈 자체를 클라이언트 번들로 바꾸지는 않습니다. 이 패턴을 쓰면 클라이언트 컴포넌트가 서버 컴포넌트를 직접 import할 수 없다는 제약을 피하면서, 서버에서 만든 콘텐츠를 클라이언트에서 보여 주거나 숨기는 식으로 다룰 수 있습니다.
import ClientWrapper from './ClientWrapper';
import ServerOnlyContent from './ServerOnlyContent';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ClientWrapper>
<ServerOnlyContent /> {/* 서버에서 렌더링되어 children으로 전달됨 */}
{children} {/* 페이지 콘텐츠도 children으로 전달될 수 있음 */}
</ClientWrapper>
);
}레이아웃은 ServerOnlyContent와 자기가 받은 children을 함께 ClientWrapper의 태그 사이에 넣으므로, ClientWrapper 쪽에서는 둘이 합쳐진 하나의 children이 됩니다. ClientWrapper는 받은 children을 렌더링하며, 여기에 조건부 렌더링이나 다른 상호작용을 더할 수 있습니다.
"use client";
// 이 파일은 클라이언트 컴포넌트입니다.
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
// children은 서버 렌더 결과나 다른 React 노드를 나타냅니다.
return (
<div>
{/* 여기서 children을 조건부 렌더링하거나 다른 상호작용 추가 */}
{children}
</div>
);
}ClientWrapper 파일은 ServerOnlyContent를 import하지 않으므로 그 모듈은 클라이언트 번들에 들어가지 않습니다. 이 래퍼에 useState를 더해 children을 접었다 펼치게 바꿔도, 서버에서 만든 결과를 받아 보여 주기만 하는 구조는 그대로입니다.
서버 전용 코드 분리
셋째 전략은 서버 전용 코드를 분리하는 것입니다. 클라이언트 컴포넌트에 서버 전용 코드가 실수로 들어가지 않도록 주의해야 합니다. 예를 들어 클라이언트 컴포넌트에 민감한 API 키를 직접 적거나 데이터베이스 연결 로직을 넣어서는 안 됩니다. 데이터가 필요하다면 클라이언트에서 부를 수 있는 Route Handler(/api 경로 등)를 호출하거나, 서버 컴포넌트에서 데이터를 받아 props로 넘깁니다.
더 깊이: 실수로 클라이언트에 들어간 서버 코드를 빌드에서 막기
지시어가 없는 공유 모듈은 양쪽 그래프에서 모두 가져올 수 있으므로, process.env.API_KEY로 외부 API를 부르는 함수가 클라이언트 컴포넌트에 들어가는 실수도 일어날 수 있습니다. 이때 Next.js는 NEXT_PUBLIC_ 접두사가 없는 환경 변수를 클라이언트 번들에서 빈 문자열로 바꾸므로, 키 자체가 브라우저에 노출되지는 않습니다. 대신 그 함수는 클라이언트에서 실행은 되지만 빈 문자열을 인증 값으로 보내게 되어 기대한 대로 동작하지 않습니다(Next.js 문서의 서버·클라이언트 컴포넌트).
이런 실수를 실행 전에 잡으려면 서버 전용 모듈의 맨 위에 import 'server-only'를 적습니다. 그러면 그 모듈을 클라이언트 컴포넌트에서 가져올 때 빌드 오류가 나므로, 잘못된 배치가 배포되기 전에 드러납니다. 반대로 window처럼 브라우저에서만 쓰는 코드가 담긴 모듈에는 client-only를 같은 방식으로 씁니다. Next.js는 두 import를 내부에서 직접 처리하고 npm 패키지의 내용은 쓰지 않으므로 패키지 설치는 선택 사항이며, 린트 규칙이 선언되지 않은 의존성을 지적할 때만 설치하면 됩니다.
파일을 나누어 따라 만들기
앞의 합쳐진 발췌를 한 파일로 실행하는 대신, 같은 원리를 확인할 별도의 /client-boundary 경로를 만들 수 있습니다. 기존 dashboard 파일은 바꾸지 않고, 다음 세 파일을 같은 폴더에 준비합니다. Header는 서버에 두고 ToggleButton에만 "use client"를 두는 구성입니다. 먼저 페이지가 Header를 가져와 렌더링합니다.
import Header from './Header';
export default function ClientBoundaryPage() {
return <main><Header /><p>본문은 서버에서 구성한 UI입니다.</p></main>;
}페이지는 Header와 본문 문장을 렌더링할 뿐 상태가 없으므로 서버 컴포넌트로 둡니다. Header도 지시어가 없는 서버 컴포넌트이며 ToggleButton을 가져옵니다. 서버 컴포넌트는 클라이언트 컴포넌트를 import해 렌더링할 수 있으므로, Header 자체는 서버에 남고 ToggleButton 자리에만 클라이언트 컴포넌트가 들어갑니다.
import ToggleButton from './ToggleButton';
export default function Header() {
return <header><h1>경계 확인</h1><ToggleButton /></header>;
}Header의 <h1>은 서버에서 HTML로 만들어지며, Header의 코드는 브라우저로 보내지지 않습니다. ToggleButton만 "use client"로 선언하고 열림 상태를 관리합니다. setOpen(value => !value)는 직전 값을 뒤집으므로 버튼을 누를 때마다 열림과 닫힘이 번갈아 바뀌고, aria-expanded는 화면 낭독기에 지금 메뉴가 열려 있는지를 알려 줍니다.
"use client";
import { useState } from 'react';
export default function ToggleButton() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(value => !value)} aria-expanded={open}>
{open ? '메뉴 닫기' : '메뉴 열기'}
</button>
{open && <p>이 문장의 표시는 브라우저 상태가 결정합니다.</p>}
</div>
);
}페이지를 처음 열면 버튼에 "메뉴 열기"가 보이고 아래 문장은 숨겨져 있으며, 버튼을 누르면 버튼의 문구와 문장의 표시가 함께 바뀝니다. 다만 화면이 이렇게 동작한다는 사실만으로는 Header의 서버 모듈 코드가 브라우저에 들어갔는지 알 수 없으므로, 실제로 어느 파일에서 무엇을 import하는지를 확인해야 합니다.
다음 그림은 Header와 ToggleButton에 각각 "use client"를 두었을 때 어느 모듈이 클라이언트 그래프에 들어가는지 import 관계로 비교합니다. 화살표는 import를 나타내며, 화면의 DOM에서 무엇이 무엇을 감싸는지와는 다릅니다.
NEXT · CH7-4
토글 버튼에 경계를 두면 Header 코드는 서버에 남습니다
본문의 두 대안을 import 관계로 비교합니다. Header에 use client를 두면 그 의존성도 Client 그래프에 들어가며, 버튼에 선언하면 Header 자체는 서버에 남습니다.
화살표는 import이며 DOM 포함 관계가 아닙니다. 각 대안은 별도 파일 구성입니다. ToggleButton과 의존성·Next 런타임의 JavaScript는 필요하며 실제 전체 번들 크기는 빌드 결과로 확인합니다.
Header에 "use client"를 두면 Header가 클라이언트 그래프의 시작점이 되어, 그 의존성인 ToggleButton도 클라이언트 그래프에 들어갑니다. 반대로 Header를 서버에 두고 ToggleButton에 선언하면 Header 자체의 코드는 서버에 남고 ToggleButton만 클라이언트 의존성이 됩니다. 그림의 설명처럼 두 대안은 서로 다른 파일 구성이며, ToggleButton과 그 의존성, Next.js 런타임의 JavaScript는 어느 쪽이든 필요하므로 실제 전체 번들 크기는 빌드 결과로 확인합니다.
정리
"use client"는 주석을 뺀 파일의 첫 줄에 두며, 그 파일을 클라이언트 모듈 그래프의 시작점으로 표시합니다.- 지시어가 붙은 파일이 import하는 모듈은 지시어가 없어도 클라이언트 번들에 들어갑니다.
- 지시어는 모듈이 속한 그래프를 정할 뿐 초기 서버 렌더링을 없애지 않으므로, 브라우저 API는 Effect나 이벤트 핸들러에서 씁니다.
- 번들을 작게 유지하려면 클라이언트 컴포넌트를 리프로 내리고, 서버 결과는
children으로 넘기며, 서버 전용 코드를 클라이언트 파일에 넣지 않습니다.
연습 문제
1. 공유 모듈을 더하면 판단이 달라질까요?
ToggleButton이 지시어가 없는 순수한 문자열 포매터(formatter) 모듈을 import하고, 서버 컴포넌트인 Header도 같은 모듈을 import한다고 가정합니다. 포매터에 use client가 없다는 이유로 서버에서만 평가된다고 말할 수 있을까요? 그 포매터를 서버 전용인 DB 조회로 바꾸면 같은 방식으로 브라우저에서 실행할 수 있는지도 구분해 보세요.
해설 보기
말할 수 없습니다. 순수한 포매터는 ToggleButton을 통해 클라이언트 의존 그래프에도 들어가고 서버에서도 쓰이므로, 각 환경에서 평가될 수 있습니다. 지시어가 없다는 사실만으로 서버 전용이라고 보장되지는 않습니다.
DB 자원에 접근하는 것처럼 서버에서만 가능한 코드는 클라이언트에서 직접 import해서는 그대로 동작하게 할 수 없으며, 서버 부모에서 필요한 공개 결과만 넘기는 구조가 필요합니다. 서버 전용 모듈의 맨 위에 import 'server-only';를 두면, 클라이언트 쪽에서 그 모듈을 잘못 import했을 때 빌드 오류로 알아챌 수 있습니다. 이 실습에는 실제 DB나 번들 크기 측정은 들어 있지 않습니다.
2. 지시어의 위치
다음 파일은 import 문 아래에 "use client"를 두었습니다. 이 파일은 클라이언트 컴포넌트로 처리될까요?
import { useState } from 'react';
"use client";
export default function Toggle() {
const [on, setOn] = useState(false);
return <button onClick={() => setOn(!on)}>{on ? 'ON' : 'OFF'}</button>;
}해설 보기
처리되지 않습니다. "use client"는 주석을 뺀 파일의 첫 줄, 즉 모든 import보다 앞에 있어야 지시어로 인식됩니다. JavaScript 문법으로 보면 이 위치의 문자열은 아무 효과가 없는 표현식일 뿐이며, Next.js의 컴파일러는 다른 코드 뒤에 놓인 "use client"를 발견하면 파일 맨 위로 옮기라는 오류를 보여 줍니다. 지시어를 모든 import보다 앞으로 옮기면 해결됩니다.
3. 지시어를 어디에 둘까요?
블로그 글 페이지가 PostLayout 안에 제목, 본문, 작성자 정보, 그리고 "좋아요" 버튼을 렌더링합니다. 좋아요 버튼만 클릭 수를 상태로 관리합니다. "use client"를 PostLayout에 두는 것과 좋아요 버튼에 두는 것은 무엇이 다를까요?
해설 보기
PostLayout에 두면 그 파일이 클라이언트 그래프의 시작점이 되어, PostLayout이 import하는 제목, 본문, 작성자 정보 컴포넌트까지 모두 클라이언트 번들에 들어갑니다. 이 컴포넌트들이 서버에서 직접 데이터를 가져오던 코드였다면 더 이상 그렇게 할 수 없습니다.
좋아요 버튼에만 두면 PostLayout과 나머지 컴포넌트는 서버에 남고, 클릭 수를 관리하는 버튼과 그 의존성만 브라우저로 보내집니다. 상호작용이 필요한 리프에 지시어를 두는 쪽이 번들을 작게 유지합니다.
"use client" 지시어의 규칙은 Next.js 공식 문서의 'use client' 지시어 레퍼런스와 React 문서의 'use client'에서 확인할 수 있습니다.
이것으로 7장을 마칩니다. 7장에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할을 나누고, 두 컴포넌트를 조합하는 방법과 "use client"를 둘 위치를 정리했습니다. 다음 장의 React 상태 관리 기초부터는 클라이언트 컴포넌트에서 상태를 관리하는 방법을 알아봅니다.