클라이언트 컴포넌트 사용법
상태·이벤트·브라우저 API가 필요한 UI를 클라이언트 컴포넌트로 만들고 서버 경계에서 직렬화 가능한 값을 전달합니다.
이전 절에서 서버 컴포넌트가 Next.js App Router의 기본이자 성능 최적화의 핵심임을 배웠습니다.
하지만 React 애플리케이션의 본질은 상호작용(Interactivity)에 있으며, 서버 컴포넌트만으로는 사용자 입력에 반응하거나 브라우저 전용 기능을 활용할 수 없습니다.
이때 필요한 것이 바로 클라이언트 컴포넌트(Client Components)입니다.
이 절에서는 클라이언트 컴포넌트가 무엇인지, 어떻게 정의하고 사용하며, 서버 컴포넌트와 어떻게 함께 작동하는지 구체적으로 다룹니다.
클라이언트 컴포넌트란 무엇인가요?
클라이언트 컴포넌트는 브라우저에서 상태와 이벤트를 실행할 수 있는 React 컴포넌트입니다.
첫 방문에는 Next.js가 서버에서 초기 HTML을 미리 렌더링하고 브라우저가 JavaScript로 하이드레이션하며, 후속 탐색과 상태 변경은 클라이언트에서 렌더링합니다.
사용자의 클릭, 입력 등 이벤트에 반응하고, useState, useEffect 같은 React 훅을 사용해 상태를 관리하거나 사이드 이펙트를 처리하며, 브라우저의 전역 객체(예: window, document)에도 접근할 수 있습니다.
- 명시적 선언: 서버에서 진입하는 클라이언트 경계 파일은 import보다 앞에
"use client"를 둡니다. 그 파일에서 import하는 하위 모듈마다 지시어를 반복할 필요는 없습니다.// 파일의 맨 위 "use client"; - React 훅 사용 가능:
useState,useEffect,useRef,useContext등 모든 React 훅을 사용할 수 있습니다. - 이벤트 핸들러:
onClick,onChange,onSubmit등 사용자 상호작용을 처리하는 이벤트 핸들러를 정의하고 사용할 수 있습니다. - 브라우저 API 접근:
window,document,localStorage,navigator등은 Effect·이벤트 핸들러처럼 브라우저에서 실행되는 시점에 사용합니다. 초기 서버 프리렌더의 모듈 평가나 렌더 함수에서 무조건 접근할 수 있다는 뜻은 아닙니다. - 클라이언트 번들에 포함: 클라이언트 컴포넌트의 코드는 사용자가 다운로드해야 하는 JavaScript 번들에 포함됩니다.
- 상호작용이 필요한 UI: 카운터, 토글 버튼, 폼 입력, 캐러셀, 모달, 드롭다운 메뉴 등 사용자 입력에 따라 동적으로 변해야 하는 UI.
- 상태 관리:
useState를 사용하여 로컬 상태를 관리하거나, Zustand, Jotai 등 클라이언트 상태 관리 라이브러리를 사용해야 할 때. - 클라이언트 라이프사이클:
useEffect를 사용하여 컴포넌트 마운트/언마운트 시점에 특정 로직을 실행해야 할 때. - 성능 측정 및 추적: Google Analytics, Sentry 등 클라이언트 사이드에서 작동하는 분석/모니터링 라이브러리를 연동할 때.
- 브라우저 전용 API 활용: 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으로 전달합니다.
이것은 Next.js가 권장하는 패턴이며, 리프까지 내려가기(Passing Props to Client Components)라고도 합니다.
서버에서 렌더링되는 부모 컴포넌트가 클라이언트 컴포넌트 자식을 렌더링하고, 필요한 데이터를 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>
);
}// 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을 사용합니다.
클라이언트 컴포넌트에서 서버 컴포넌트 사용
클라이언트 모듈에서 서버 전용 코드를 직접 임포트하지 않고, 서버 부모에서 조합할 수 있습니다.
하지만 서버 컴포넌트를 클라이언트 컴포넌트 안에 렌더링해야 하는 경우가 있을 수 있습니다.
이때는 children prop을 활용하는 패턴을 사용합니다.
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent'; // 서버 컴포넌트
export default function SomePage() {
return (
<ClientWrapper>
{/* ClientWrapper의 children으로 ServerContent를 전달 */}
<ServerContent />
</ClientWrapper>
);
}"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>
);
}// 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>
);
}page.tsx가 ServerContent와 ClientWrapper를 각각 import합니다. ServerContent는 서버에서 계산되며 결과 노드가 ClientWrapper의 children으로 합성됩니다. 실선은 import 관계, 점선은 렌더 결과 전달입니다. 클라이언트 래퍼가 서버 모듈을 직접 import하는 구조가 아닙니다.
show가 처음에 false여도 서버가 ServerContent를 미리 계산합니다. 토글은 받은 노드를 표시·숨김 처리하며 클릭 시 서버 코드를 새로 실행하는 지연 조회가 아닙니다.
브라우저의 React는 초기 HTML과 RSC Payload를 사용해 전체 트리를 맞추고, ClientWrapper의 상태가 바뀌면 준비된 children 노드를 표시하거나 숨깁니다.
즉, 클라이언트 컴포넌트는 서버에서 계산된 Server Component 결과를 조합할 뿐, Server Component 모듈 자체를 브라우저에서 실행하지 않습니다.