클라이언트 컴포넌트 사용법
상태·이벤트·브라우저 API가 필요한 UI를 클라이언트 컴포넌트로 만들고 서버 경계에서 직렬화 가능한 값을 전달합니다.
이전 글에서 서버 컴포넌트가 Next.js App Router의 기본이자 성능 최적화의 핵심이라는 것을 배웠습니다. 하지만 React 애플리케이션의 본질은 상호작용(interactivity)에 있으며, 서버 컴포넌트만으로는 사용자 입력에 반응하거나 브라우저 전용 기능을 쓸 수 없습니다. 이때 필요한 것이 클라이언트 컴포넌트(Client Components)입니다. 이 글에서는 클라이언트 컴포넌트가 무엇인지, 어떻게 정의하고 사용하는지, 서버 컴포넌트와 어떻게 함께 동작하는지 다룹니다.
클라이언트 컴포넌트란 무엇인가요?
클라이언트 컴포넌트는 브라우저에서 상태와 이벤트를 실행할 수 있는 React 컴포넌트입니다. 첫 방문에는 Next.js가 서버에서 초기 HTML을 미리 렌더링하고 브라우저가 JavaScript로 하이드레이션하며, 이후의 탐색과 상태 변경은 클라이언트에서 렌더링합니다. 클라이언트 컴포넌트는 사용자의 클릭이나 입력 같은 이벤트에 반응하고, useState, useEffect 같은 React 훅으로 상태를 관리하거나 부수 효과(side effect)를 처리하며, window, document 같은 브라우저의 전역 객체에도 접근할 수 있습니다.
클라이언트 컴포넌트는 명시적으로 선언합니다. 서버 쪽에서 처음 가져오는 클라이언트 진입 파일은 모든 import보다 앞의 파일 맨 위에 "use client"를 둡니다. 그 파일에서 import하는 하위 모듈마다 지시어를 반복할 필요는 없습니다.
// 파일의 맨 위
"use client";클라이언트 컴포넌트에서는 useState, useEffect, useRef, useContext 등 모든 React 훅을 쓸 수 있고, onClick, onChange, onSubmit 같은 이벤트 핸들러를 정의해 사용자 상호작용을 처리할 수 있습니다. window, document, localStorage, navigator 같은 브라우저 API는 Effect나 이벤트 핸들러처럼 브라우저에서 실행되는 시점에 씁니다. 초기 서버 프리렌더링 중의 모듈 평가나 렌더링 함수에서도 언제나 접근할 수 있다는 뜻은 아닙니다. 그리고 클라이언트 컴포넌트의 코드는 사용자가 내려받아야 하는 JavaScript 번들에 포함됩니다.
클라이언트 컴포넌트가 필요한 경우는 다음과 같습니다. 카운터, 토글 버튼, 폼 입력, 캐러셀, 모달, 드롭다운 메뉴처럼 사용자 입력에 따라 바뀌어야 하는 UI를 만들 때 씁니다. useState로 로컬 상태를 관리하거나 Zustand, Jotai 같은 클라이언트 상태 관리 라이브러리를 쓸 때, useEffect로 마운트와 언마운트 시점에 로직을 실행할 때도 필요합니다. Google Analytics나 Sentry처럼 클라이언트에서 동작하는 분석과 모니터링 라이브러리를 연동할 때, Geolocation API, Web Speech API, WebGL처럼 브라우저 환경에 묶인 기능을 쓸 때도 클라이언트 컴포넌트를 씁니다.
클라이언트 컴포넌트 작성 및 사용법
클라이언트 컴포넌트는 일반적인 React 컴포넌트와 똑같이 작성하며, 파일 맨 위에 "use client" 지시어를 추가한다는 점만 다릅니다. 가장 흔한 예시인 카운터 컴포넌트로 사용법을 익혀 봅시다.
먼저 src/app/interactive/page.tsx 파일을 만듭니다. 이 파일은 맨 위에 "use client" 지시어가 없으므로 서버 컴포넌트이며, 그 안에서 클라이언트 컴포넌트를 가져와 씁니다.
import Counter from './Counter'; // 클라이언트 컴포넌트 임포트
export default function InteractivePage() {
// 이 부분은 서버에서 렌더링됩니다.
console.log('InteractivePage (Server Component) rendering...');
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', margin: '20px auto', maxWidth: '600px' }}>
<h1>상호작용 페이지 (서버 컴포넌트)</h1>
<p>이 페이지는 서버에서 초기 렌더링됩니다.</p>
<hr style={{ margin: '20px 0' }} />
{/* Counter 컴포넌트는 클라이언트에서 하이드레이션됩니다. */}
<Counter initialValue={0} />
</div>
);
}다음으로 src/app/interactive/Counter.tsx 파일을 만듭니다. 이 파일은 맨 위에 "use client" 지시어가 있으므로 클라이언트 컴포넌트입니다.
"use client"; // 클라이언트 컴포넌트임을 명시
import React, { useState, useEffect } from 'react';
interface CounterProps {
initialValue: number;
}
export default function Counter({ initialValue }: CounterProps) {
const [count, setCount] = useState(initialValue);
// useEffect 훅 사용 (클라이언트에서만 실행됨)
useEffect(() => {
console.log('Counter (Client Component) mounted or updated!', count);
return () => {
console.log('Counter (Client Component) unmounted!');
};
}, [count]); // count가 변경될 때마다 실행
// 이벤트 핸들러 사용
const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
return (
<div style={{ padding: '15px', border: '2px dashed #007bff', borderRadius: '5px', marginTop: '15px' }}>
<h2 style={{ color: '#007bff' }}>클라이언트 카운터</h2>
<p>현재 값: <strong style={{ fontSize: '1.5em' }}>{count}</strong></p>
<button onClick={increment} style={{ marginRight: '10px', padding: '8px 15px' }}>증가</button>
<button onClick={decrement} style={{ padding: '8px 15px' }}>감소</button>
<p style={{ marginTop: '10px', fontSize: '0.9em', color: '#555' }}>
새로고침 시 카운터는 {initialValue}로 초기화됩니다.
</p>
</div>
);
}개발 서버(npm run dev)를 실행한 뒤 http://localhost:3000/interactive에 접속합니다. 서버의 렌더링 로그는 터미널에, 브라우저 Effect의 로그는 브라우저 콘솔에 찍히므로 두 위치를 비교해 봅니다. 개발 모드의 추가 실행이나 로그 전달 방식 때문에 화면에 보이는 정확한 횟수와 순서는 고정되어 있지 않습니다.
브라우저 콘솔에는 Counter (Client Component) mounted or updated! 로그가 찍히고, 증가나 감소 버튼을 누르면 count 값이 바뀌면서 업데이트 로그가 다시 찍힙니다. 페이지 전체를 새로고침해 다시 마운트하면 count는 0으로 초기화됩니다. 이 Effect의 unmounted! 로그는 count가 바뀌기 전의 정리(cleanup)에서도 출력되므로, 실제로 언마운트될 때만 찍히는 로그는 아닙니다.
서버 컴포넌트와 클라이언트 컴포넌트를 함께 쓰는 규칙
Next.js App Router에서는 서버 컴포넌트와 클라이언트 컴포넌트가 함께 동작하는 방식을 정확히 아는 것이 중요합니다. 서버 컴포넌트는 클라이언트 컴포넌트를 import할 수 있으며, 위 예시에서 InteractivePage가 Counter를 가져온 것이 그 경우입니다.
반대로 서버에서만 실행되어야 하는 컴포넌트를 클라이언트 모듈에서 직접 import하지 않습니다. 클라이언트 모듈이 공유 가능한 모듈을 import하면 그 모듈은 클라이언트 그래프에서 평가됩니다. 서버가 만든 결과를 클라이언트 컴포넌트 안에 넣으려면, 서버 부모가 조합해 children 같은 React 노드 prop으로 넘깁니다.
서버 컴포넌트에서 클라이언트 컴포넌트로 props 넘기기
Next.js가 권장하는 기본 패턴은 서버에서 렌더링되는 부모 컴포넌트가 클라이언트 컴포넌트 자식을 렌더링하고, 필요한 데이터를 props로 넘기는 방식입니다. 다음 서버 페이지는 사용자 프로필을 가져와 표시하고, 같은 데이터를 편집기에 넘깁니다.
// 이 페이지는 서버에서 사용자 정보를 가져옵니다.
import ProfileEditor from './ProfileEditor'; // 클라이언트 컴포넌트 임포트
interface UserProfile {
name: string;
email: string;
// ...
}
async function getUserProfile(): Promise<UserProfile> {
// 서버에서 사용자 프로필 데이터 페칭 (DB 직접 접근 등)
return { name: '이름', email: '[email protected]' };
}
export default async function ProfilePage() {
const userProfile = await getUserProfile(); // 서버에서 데이터 페칭
return (
<div>
<h1>내 프로필 정보</h1>
<p>이름: {userProfile.name}</p>
<p>이메일: {userProfile.email}</p>
{/* 서버에서 가져온 데이터를 클라이언트 컴포넌트의 prop으로 전달 */}
<ProfileEditor initialProfile={userProfile} />
</div>
);
}서버 페이지는 await getUserProfile()로 받은 객체를 먼저 그대로 화면에 그리고, 같은 객체를 initialProfile prop으로 편집기에 넘깁니다. 이 객체는 문자열 필드만 가진 일반 객체라서 서버에서 클라이언트로 그대로 전달할 수 있습니다. 편집기는 받은 프로필을 상태의 초기값으로 쓰고, 입력에 따라 상태를 고칩니다. handleChange는 입력 요소의 name 속성(name 또는 email)을 키로 써서, 함수 하나로 두 입력란을 모두 처리합니다.
// src/app/dashboard/profile/ProfileEditor.tsx (클라이언트 컴포넌트)
"use client";
import React, { useState } from 'react';
interface UserProfile {
name: string;
email: string;
}
interface ProfileEditorProps {
initialProfile: UserProfile;
}
export default function ProfileEditor({ initialProfile }: ProfileEditorProps) {
const [profile, setProfile] = useState(initialProfile);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setProfile({ ...profile, [e.target.name]: e.target.value });
};
const handleSubmit = async () => {
// 클라이언트에서 사용자 입력에 따라 프로필 업데이트 API 호출 등
alert('프로필 업데이트 시도: ' + JSON.stringify(profile));
};
return (
<div style={{ border: '1px dashed purple', padding: '15px', marginTop: '20px', borderRadius: '8px' }}>
<h2>프로필 편집 (클라이언트 컴포넌트)</h2>
<div>
<label>
이름:
<input type="text" name="name" value={profile.name} onChange={handleChange} />
</label>
</div>
<div style={{ marginTop: '10px' }}>
<label>
이메일:
<input type="email" name="email" value={profile.email} onChange={handleChange} />
</label>
</div>
<button onClick={handleSubmit} style={{ marginTop: '15px', padding: '8px 15px' }}>저장</button>
</div>
);
}이 예제의 프로필은 고정 데이터이며 저장 버튼은 alert만 부릅니다. 실제 저장 API와 인증은 구현하지 않았습니다. initialProfile은 useState의 초기값이므로, 이후 서버의 props가 바뀌어도 편집 중인 상태를 저절로 덮어쓰지 않습니다.
서버 컴포넌트에서 클라이언트 컴포넌트로는 React가 직렬화할 수 있는 값만 넘깁니다. 문자열, 숫자, 일반 객체, 배열 외에 Date, Map, Set도 지원되지만, 일반 함수와 사용자 정의 클래스의 인스턴스는 넘길 수 없습니다. 서버의 동작을 클라이언트에서 부르게 하려면 명시적인 서버 함수(Server Function)를 씁니다.
더 깊이: 직렬화할 수 있는 props의 전체 목록
React 문서의 'use client'는 서버 컴포넌트가 클라이언트 컴포넌트에 넘길 수 있는 값을 구체적으로 나열합니다. 원시값으로는 문자열, 숫자, bigint, 불리언, undefined, null과 Symbol.for로 전역 레지스트리에 등록한 심볼을 넘길 수 있습니다. 직렬화할 수 있는 값을 담은 반복 가능한 객체인 Array, Map, Set, 형식화 배열(TypedArray)과 ArrayBuffer, 그리고 Date와 객체 리터럴로 만든 일반 객체도 됩니다. 여기에 서버 함수, 클라이언트·서버 컴포넌트의 JSX 요소, Promise가 더해집니다.
넘길 수 없는 값도 정해져 있습니다. 클라이언트 모듈에서 내보내지 않았거나 'use server'로 표시하지 않은 함수, 클래스 자체, 앞에 나열한 내장 객체가 아닌 클래스의 인스턴스, 프로토타입이 null인 객체, Symbol('설명')처럼 전역에 등록하지 않은 심볼이 그렇습니다. 이 목록을 보면 다음 절의 children 조합이 가능한 이유도 드러납니다. JSX 요소가 직렬화 대상이므로 서버에서 렌더링한 결과를 children으로 넘길 수 있습니다. Promise도 직렬화 대상이어서, 아직 끝나지 않은 서버 요청을 넘긴 뒤 클라이언트에서 React의 use로 읽게 할 수도 있습니다.
클라이언트 컴포넌트에서 서버 컴포넌트 사용
클라이언트 모듈에서 서버 전용 코드를 직접 import하지 않고도, 서버 부모에서 둘을 조합할 수 있습니다. 서버 컴포넌트의 결과를 클라이언트 컴포넌트 안에 렌더링해야 할 때는 children prop을 활용합니다. 먼저 서버 컴포넌트인 페이지가 두 컴포넌트를 각각 가져와, ServerContent를 ClientWrapper의 children으로 넘깁니다.
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent'; // 서버 컴포넌트
export default function SomePage() {
return (
<ClientWrapper>
{/* ClientWrapper의 children으로 ServerContent를 전달 */}
<ServerContent />
</ClientWrapper>
);
}페이지는 ServerContent를 직접 배치하지 않고 ClientWrapper의 여는 태그와 닫는 태그 사이에 둡니다. 그래서 ServerContent는 서버에서 렌더링된 결과로 ClientWrapper에 전달됩니다. ClientWrapper는 받은 children을 상태에 따라 보여 주거나 숨깁니다. show의 초기값이 false이므로 처음에는 버튼만 보이고, 버튼을 누르면 서버에 다시 요청하지 않고 이미 받아 둔 결과를 보여 줍니다.
"use client";
import React, { useState } from 'react';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
// 이 클라이언트 컴포넌트는 children을 받아 렌더링합니다.
// children은 RSC Payload가 참조하는 서버 렌더 결과나 다른 React 노드일 수 있습니다.
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(!show)}>토글 서버 콘텐츠</button>
{show && children} {/* children을 조건부 렌더링 */}
</div>
);
}ServerContent는 지시어가 없는 서버 컴포넌트이며, 클라이언트 모듈에서 직접 가져오지 않습니다. 세 파일은 한 폴더에 함께 준비합니다.
// src/app/some-page/ServerContent.tsx (서버 컴포넌트)
// 이 컴포넌트는 클라이언트에서 직접 임포트되지 않습니다.
export default function ServerContent() {
return (
<div style={{ border: '1px solid green', padding: '10px', marginTop: '10px' }}>
<h3>이것은 서버에서 렌더링된 콘텐츠입니다.</h3>
<p>클라이언트 컴포넌트에 의해 조건부로 보여질 수 있습니다.</p>
</div>
);
}NEXT · CH7-2
서버 부모가 import하고 결과를 children으로 넘깁니다
SomePage는 ServerContent와 ClientWrapper를 각각 import합니다. 실선은 import, 점선은 서버에서 계산한 React 노드를 children으로 합성하는 관계입니다.
ClientWrapper의 show=false여도 서버 결과는 미리 준비됩니다. 버튼은 받은 노드를 표시·숨김 처리하며 서버 함수를 새로 호출하지 않습니다. ClientWrapper가 ServerContent 모듈을 직접 import하는 구조가 아닙니다.
위 그림은 서버 부모가 두 컴포넌트를 import하고, 서버에서 계산한 결과를 children으로 넘기는 관계를 보여 줍니다. 실선은 page.tsx가 ServerContent와 ClientWrapper를 각각 가져오는 import 관계이고, 점선은 서버에서 계산한 ServerContent의 결과 노드를 ClientWrapper의 children으로 합성하는 렌더링 결과의 전달입니다. 클라이언트 래퍼가 서버 모듈을 직접 import하는 구조가 아니라는 점이 핵심입니다.
/some-page에 들어가면 처음에는 버튼만 보이고, 버튼을 누르면 준비된 콘텐츠를 보여 주거나 숨길 수 있습니다. 그림의 설명처럼 show가 처음에 false여도 서버는 ServerContent를 미리 계산합니다. 토글은 받은 노드를 보여 주거나 숨길 뿐이며, 클릭할 때마다 서버 코드를 새로 실행해 조회하는 방식이 아닙니다.
브라우저의 React는 초기 HTML과 RSC Payload로 전체 트리를 맞추고, ClientWrapper의 상태가 바뀌면 준비된 children 노드를 보여 주거나 숨깁니다. 즉 클라이언트 컴포넌트는 서버에서 계산된 서버 컴포넌트의 결과를 조합할 뿐, 서버 컴포넌트 모듈 자체를 브라우저에서 실행하지는 않습니다.
정리
- 클라이언트 컴포넌트는 파일 맨 위의
"use client"로 선언하며, 상태, 효과, 이벤트 핸들러, 브라우저 API를 쓸 수 있습니다. - 클라이언트 컴포넌트도 첫 방문에는 서버에서 초기 HTML이 만들어지고, 브라우저에서 하이드레이션된 뒤 상호작용을 맡습니다.
- 서버 컴포넌트는 클라이언트 컴포넌트를 import해 props로 데이터를 넘길 수 있으며, 넘기는 값은 React가 직렬화할 수 있어야 합니다.
- 서버 컴포넌트의 결과를 클라이언트 컴포넌트 안에 넣으려면 서버 부모가
children같은 prop으로 조합해 넘깁니다. useState의 초기값으로 받은 props는 이후에 props가 바뀌어도 저절로 상태에 반영되지 않습니다.
연습 문제
1. 초기값과 현재값을 나누어 확인하기
Counter 코드는 그대로 두고, 비교할 때만 페이지에서 넘기는 initialValue를 2로 바꾼다고 가정합니다. 같은 마운트에서 증가를 두 번, 감소를 한 번 처리한 뒤의 값, 서버에서 만들어진 시작 화면의 값, 페이지 전체를 새로고침해 새로 마운트한 뒤의 값은 각각 무엇일까요? 다른 원인으로 언마운트되거나 값이 바뀌지 않는다고 가정합니다.
해설 보기
초기값 2에서 브라우저 이벤트를 처리하면 2, 3, 4, 3 순서로 바뀌어 3이 됩니다. 서버가 만든 시작 HTML은 그 뒤의 클릭을 미리 계산한 결과가 아니므로 시작값인 2를 보여 줍니다. 페이지 전체를 새로고침하면 새 인스턴스가 마운트되고 부모가 다시 2를 넘기므로 2로 시작합니다.
값의 변화는 이렇게 정해지지만, 개발 모드의 Strict Mode와 HMR까지 포함한 콘솔 로그의 횟수는 같은 숫자로 고정할 수 없습니다.
2. 클라이언트 컴포넌트에 함수 넘기기
서버 컴포넌트인 ProfilePage에서 <ProfileEditor initialProfile={userProfile} onSave={(p) => console.log(p)} />처럼 일반 화살표 함수를 prop으로 넘기려고 합니다. 이 코드는 동작할까요? 저장 동작을 서버에서 처리하려면 어떻게 해야 할까요?
해설 보기
동작하지 않습니다. 서버 컴포넌트에서 클라이언트 컴포넌트로 넘기는 props는 React가 직렬화할 수 있어야 하는데, 일반 함수는 직렬화할 수 없어 오류가 납니다.
저장을 서버에서 처리하려면 "use server"로 표시한 서버 함수를 만들어 그 참조를 넘기거나, 클라이언트 컴포넌트에서 직접 import해 부릅니다. 반대로 저장이 브라우저 안의 동작이라면 함수를 클라이언트 컴포넌트 안에서 정의합니다.
3. 클라이언트 래퍼에서 서버 컴포넌트를 직접 가져오기
ClientWrapper.tsx 안에서 import ServerContent from './ServerContent';로 직접 가져와 {show && <ServerContent />}로 렌더링하도록 바꾸면, ServerContent는 어디에서 평가될까요? children 패턴과 무엇이 달라지는지 설명해 보세요.
해설 보기
"use client" 파일에서 import한 모듈은 클라이언트 그래프에 들어가므로, ServerContent는 클라이언트 컴포넌트처럼 평가되고 그 코드도 브라우저 번들에 포함됩니다. 이 예제의 ServerContent는 서버 전용 기능을 쓰지 않아 화면은 그대로 보이지만, 만약 데이터베이스 접근이나 비밀 값을 쓰는 코드였다면 브라우저에서 동작하지 않거나 노출 위험이 생깁니다.
children 패턴에서는 서버 부모가 ServerContent를 서버에서 계산해 결과 노드만 넘기므로, 그 모듈의 코드는 번들에 들어가지 않고 클라이언트 래퍼는 보여 줄지 말지만 정합니다.
클라이언트 컴포넌트와 props 직렬화 규칙은 Next.js 공식 문서의 Server and Client Components와 React 문서의 'use client'에서 확인할 수 있습니다.
다음 글 서버·클라이언트 컴포넌트 조합에서는 서버 컴포넌트와 클라이언트 컴포넌트를 한 화면에서 섞어 쓰는 패턴을 더 자세히 알아봅니다.