클라이언트 컴포넌트 사용법
이전 절에서 서버 컴포넌트가 Next.js App Router의 기본이자 성능 최적화의 핵심임을 배웠습니다. 하지만 React 애플리케이션의 본질은 상호작용(Interactivity) 에 있으며, 서버 컴포넌트만으로는 사용자 입력에 반응하거나 브라우저 전용 기능을 활용할 수 없습니다. 이때 필요한 것이 바로 클라이언트 컴포넌트(Client Components) 입니다.
이 절에서는 클라이언트 컴포넌트가 무엇인지, 어떻게 정의하고 사용하며, 서버 컴포넌트와 어떻게 함께 작동하여 강력한 애플리케이션을 구축하는지 구체적으로 알아보겠습니다.
클라이언트 컴포넌트란 무엇인가요?
클라이언트 컴포넌트는 전통적인 React 컴포넌트와 동일하게 브라우저(클라이언트) 환경에서 렌더링되고 실행되는 컴포넌트입니다. 사용자의 클릭, 입력 등 이벤트에 반응하고, useState
, useEffect
와 같은 React 훅을 사용하여 상태를 관리하거나 사이드 이펙트를 처리하며, 브라우저의 전역 객체(예: window
, document
)에 접근할 수 있습니다.
클라이언트 컴포넌트의 핵심 특징
- 명시적 선언: 모든
.tsx
,.jsx
파일은 기본적으로 서버 컴포넌트이므로, 클라이언트 컴포넌트로 만들려면 파일 상단에 반드시"use client"
지시어를 추가해야 합니다. 이 지시어는 파일의 맨 위에 위치해야 합니다.// 파일의 맨 위 "use client";
- React 훅 사용 가능:
useState
,useEffect
,useRef
,useContext
등 모든 React 훅을 사용할 수 있습니다. - 이벤트 핸들러:
onClick
,onChange
,onSubmit
등 사용자 상호작용을 처리하는 이벤트 핸들러를 정의하고 사용할 수 있습니다. - 브라우저 API 접근:
window
,document
,localStorage
,navigator
등 브라우저 환경에서만 사용 가능한 전역 객체와 API에 접근할 수 있습니다. - 클라이언트 번들에 포함: 클라이언트 컴포넌트의 코드는 사용자가 다운로드해야 하는 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
로 접속합니다.
- 터미널(서버 콘솔)에 "InteractivePage (Server Component) rendering..." 로그가 먼저 찍힙니다.
- 브라우저 콘솔에 "Counter (Client Component) mounted or updated!" 로그가 찍힙니다.
- "증가", "감소" 버튼을 클릭하면
count
값이 변경되고, 브라우저 콘솔에 업데이트 로그가 찍히는 것을 확인할 수 있습니다. - 페이지를 새로고침하면
count
값이 다시 0으로 초기화됩니다. 이는 클라이언트 컴포넌트가 페이지 로드 시 새로 마운트되기 때문입니다.
서버 컴포넌트와 클라이언트 컴포넌트의 경계
Next.js App Router에서 서버 컴포넌트와 클라이언트 컴포넌트가 함께 작동하는 방식은 매우 중요합니다.
규칙
- 서버 컴포넌트는 클라이언트 컴포넌트를
import
할 수 있습니다. (위 예시처럼InteractivePage
가Counter
를 임포트) - 클라이언트 컴포넌트는 서버 컴포넌트를
import
할 수 없습니다. 클라이언트 컴포넌트 내에서 서버 컴포넌트를 사용하고 싶다면,children
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@example.com' };
}
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>
);
}
이 패턴은 초기 로딩 시 필요한 데이터는 서버에서 가져와 최적의 성능과 SEO를 확보하고, 사용자 상호작용은 클라이언트에서 효율적으로 처리하도록 하여 양쪽의 장점을 모두 취할 수 있게 합니다.
클라이언트 컴포넌트에서 서버 컴포넌트 사용
위에서 클라이언트 컴포넌트는 서버 컴포넌트를 직접 임포트할 수 없다고 언급했습니다. 하지만 서버 컴포넌트를 클라이언트 컴포넌트 안에 렌더링해야 하는 경우가 있을 수 있습니다. 이때는 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 from 'react';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
// 이 클라이언트 컴포넌트는 children을 받아 렌더링합니다.
// children은 서버에서 렌더링된 결과 (HTML)이거나, 다른 클라이언트 컴포넌트일 수 있습니다.
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>
);
}
이 패턴에서는 ServerContent
가 ClientWrapper
에 children
으로 전달되기 전에 이미 서버에서 렌더링되어 HTML로 변환됩니다. ClientWrapper
는 클라이언트에서 이 HTML을 받아 자신의 children
위치에 삽입합니다. 즉, 클라이언트 컴포넌트는 서버 컴포넌트의 "HTML 출력"을 렌더링하는 것이지, 서버 컴포넌트 자체를 실행하는 것이 아닙니다.
클라이언트 컴포넌트는 React 애플리케이션의 상호작용성을 담당하는 필수적인 요소입니다. "use client"
지시어를 통해 명확하게 정의하고, 서버 컴포넌트와 적절히 조합하여 사용함으로써 Next.js App Router의 성능 이점을 최대한 활용하면서도 풍부한 사용자 경험을 제공하는 애플리케이션을 구축할 수 있습니다.