본문으로 건너뛰기

안동민 개발노트

본문 시작

클라이언트 컴포넌트 사용법

상태·이벤트·브라우저 API가 필요한 UI를 클라이언트 컴포넌트로 만들고 서버 경계에서 직렬화 가능한 값을 전달합니다.

이전 절에서 서버 컴포넌트가 Next.js App Router의 기본이자 성능 최적화의 핵심임을 배웠습니다.

하지만 React 애플리케이션의 본질은 상호작용(Interactivity)에 있으며, 서버 컴포넌트만으로는 사용자 입력에 반응하거나 브라우저 전용 기능을 활용할 수 없습니다.

이때 필요한 것이 바로 클라이언트 컴포넌트(Client Components)입니다.

이 절에서는 클라이언트 컴포넌트가 무엇인지, 어떻게 정의하고 사용하며, 서버 컴포넌트와 어떻게 함께 작동하는지 구체적으로 다룹니다.

클라이언트 컴포넌트 사용법

React 애플리케이션의 본질은 상호작용(Interactivity)에 있으며, 서버 컴포넌트만으로는 사용자 입력에 반응하거나 브라우저 전용 기능을 활용할 수 없습니다.

  1. 클라이언트 컴포넌트 사용법 흐름

    클라이언트 컴포넌트는 브라우저에서 렌더링되고 실행됩니다. 2 일반 React 코드에 "use client" 지시어만 더합니다. 3 서버 컴포넌트와 클라이언트 컴포넌트의 경계에 따라 번들 크기, 상호작용 처리 위치, 서버 전용 코드 노출 여부가 달라집니다. 4 위에서 클라이언트 컴포넌트는 서버 컴포넌트를 직접 임포트할 수 없다고 언급했습니다.

  2. 클라이언트 컴포넌트 개념

    클라이언트 컴포넌트는 브라우저에서 렌더링되고 실행됩니다.

  3. 클라이언트 컴포넌트 작성 및 사용법

    일반 React 코드에 "use client" 지시어만 더합니다.

  4. 서버 컴포넌트와 클라이언트 컴포넌트의 경계

    에 따라 번들 크기, 상호작용 처리 위치, 서버 전용 코드 노출 여부가 달라집니다.

  5. 클라이언트 컴포넌트에서 서버 컴포넌트 사용

    위에서 클라이언트 컴포넌트는 서버 컴포넌트를 직접 임포트할 수 없다고 언급했습니다.

  6. 클라이언트 컴포넌트 기준

    전 절에서 서버 컴포넌트가 Next.js App Router의 기본이자 성능 최적화의 핵심임을 확인했습니다. 사용자 입력, 브라우저 API, 즉시 반응하는 UI는 클라이언트 컴포넌트가 담당해야 합니다. 이때 필요한 것이 바로 클라이언트 컴포넌트(Client Components)입니다.


클라이언트 컴포넌트란 무엇인가요?

클라이언트 컴포넌트는 브라우저에서 상태와 이벤트를 실행할 수 있는 React 컴포넌트입니다.

첫 방문에는 Next.js가 서버에서 초기 HTML을 미리 렌더링하고 브라우저가 JavaScript로 하이드레이션하며, 후속 탐색과 상태 변경은 클라이언트에서 렌더링합니다.

사용자의 클릭, 입력 등 이벤트에 반응하고, 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" 지시어가 없으므로 서버 컴포넌트입니다.

이 서버 컴포넌트 안에서 클라이언트 컴포넌트를 임포트하여 사용합니다.

src/app/interactive/page.tsx
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" 지시어가 있으므로 클라이언트 컴포넌트입니다.

src/app/interactive/Counter.tsx
"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으로 초기화됩니다. 이는 클라이언트 컴포넌트가 페이지 로드 시 새로 마운트되기 때문입니다.

서버 컴포넌트와 클라이언트 컴포넌트의 경계

RSC props는 실행 코드가 아니라 데이터 모양만 건넨다

서버 값을 안정적인 전송 표현으로 바꾼 뒤 client UI가 그 값을 상호작용에 사용한다.

  1. 1
    Server data

    DB·fetch 결과와 정책을 서버에서 확정

  2. 2
    Normalize

    Date는 문자열, class는 plain object로 변환

  3. 3
    Serialize

    문자열·숫자·배열·plain object만 경계 통과

  4. 4
    Client props

    받은 값을 UI와 지역 상호작용에 사용

Next.js App Router에서 서버 컴포넌트와 클라이언트 컴포넌트가 함께 작동하는 방식은 매우 중요합니다.

규칙
  • 서버 컴포넌트는 클라이언트 컴포넌트를 import 할 수 있습니다. (위 예시처럼 InteractivePageCounter를 임포트)
  • 클라이언트 컴포넌트는 서버 컴포넌트를 import 할 수 없습니다. 클라이언트 컴포넌트 내에서 서버 컴포넌트를 사용하고 싶다면, children prop으로 전달받아야 합니다.
서버 컴포넌트 -> 클라이언트 컴포넌트 전달 (권장 패턴)

이것은 Next.js가 권장하는 패턴이며, 리프까지 내려가기(Passing Props to Client Components)라고도 합니다.

서버에서 렌더링되는 부모 컴포넌트가 클라이언트 컴포넌트 자식을 렌더링하고, 필요한 데이터를 props로 전달하는 방식입니다.

src/app/dashboard/profile/page.tsx (서버 컴포넌트)
// 이 페이지는 서버에서 사용자 정보를 가져옵니다.
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
// 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를 확보하고, 사용자 상호작용은 클라이언트에서 효율적으로 처리하도록 하여 양쪽의 장점을 모두 취할 수 있게 합니다.

아래 다이어그램은 클라이언트 컴포넌트를 도입할 때 파일 경계, props, 하이드레이션, 번들 크기를 함께 점검하는 흐름을 정리한 것입니다.

클라이언트 컴포넌트는 상호작용이 시작되는 경계에 둔다

브라우저 기능이 필요한 가장 작은 subtree를 찾고 서버에서 계산한 결과를 props로 전달한다.

  1. 아니오
    Server Component

    data fetch와 정적 표현을 서버에 유지

  2. 한 위젯
    Leaf Client

    버튼·검색 입력 등 최소 컴포넌트만 전환

  3. 공유 상호작용
    Client island

    관련 위젯의 공통 state 경계만 감쌈

  4. 앱 전역
    재검토

    provider를 좁히거나 server/client 조합으로 분해


클라이언트 컴포넌트에서 서버 컴포넌트 사용

위에서 클라이언트 컴포넌트는 서버 컴포넌트를 직접 임포트할 수 없다고 언급했습니다.

하지만 서버 컴포넌트를 클라이언트 컴포넌트 안에 렌더링해야 하는 경우가 있을 수 있습니다.

이때는 children prop을 활용하는 패턴을 사용합니다.

패턴
src/app/some-page/page.tsx (서버 컴포넌트)
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent'; // 서버 컴포넌트

export default function SomePage() {
  return (
    <ClientWrapper>
      {/* ClientWrapper의 children으로 ServerContent를 전달 */}
      <ServerContent />
    </ClientWrapper>
  );
}
src/app/some-page/ClientWrapper.tsx (클라이언트 컴포넌트)
"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
// 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를 서버가 렌더링하고, 그 결과를 나타내는 RSC Payload 참조가 ClientWrapperchildren으로 전달됩니다.

브라우저의 React는 초기 HTML과 RSC Payload를 사용해 전체 트리를 맞추고, ClientWrapper의 상태가 바뀌면 준비된 children 노드를 표시하거나 숨깁니다.

즉, 클라이언트 컴포넌트는 서버에서 계산된 Server Component 결과를 조합할 뿐, Server Component 모듈 자체를 브라우저에서 실행하지 않습니다.

클라이언트 컴포넌트를 추가할 때는 "use client" 선언, 직렬화 가능한 props, 하이드레이션 범위를 함께 확인해야 합니다.

아래 다이어그램은 서버 컴포넌트와 클라이언트 컴포넌트를 연결할 때의 점검 흐름을 정리합니다.

서버 부모가 children으로 서버 콘텐츠를 클라이언트 래퍼에 합성한다

클라이언트 컴포넌트가 서버 컴포넌트를 직접 import하는 대신, 서버 부모가 둘을 조합해 이미 렌더링된 서버 결과를 슬롯으로 전달한다.

  1. SERVER PARENT
    서버에서 둘을 조합

    page.tsx 가 서버 컴포넌트와 클라이언트 래퍼의 관계를 결정한다.

  2. SERVER RESULT
    서버 콘텐츠를 먼저 렌더링

    ServerContent 는 서버에서 해석되고, 그 결과가 RSC payload를 통해 children 자리에 전달된다.

  3. CLIENT WRAPPER
    브라우저에서는 슬롯만 제어

    useState 와 이벤트가 보이기 여부를 바꾸지만 서버 컴포넌트 코드를 브라우저에서 실행하지는 않는다.

클라이언트 컴포넌트는 파일 단위 경계이므로 "use client"를 붙인 순간 하위 import, 브라우저 API, props 직렬화 가능성을 함께 확인해야 합니다.

use client는 필요성·위치·전달값·번들 범위를 모두 통과해야 한다

지시어 한 줄이 하위 import 트리를 클라이언트 실행 영역으로 바꾸므로 선언 전 네 경계를 확인한다.

  1. 상호작용 필요

    state·event·browser API가 실제로 있는가

  2. 가장 작은 파일

    page가 아니라 버튼·폼·토글 leaf에 선언했는가

  3. 직렬화 가능한 props

    함수·class 대신 plain data와 children을 받는가

  4. import 경계

    server-only 코드와 무거운 라이브러리가 끌려오지 않는가

클라이언트 컴포넌트는 React 애플리케이션의 상호작용성을 담당하는 필수적인 요소입니다.

"use client" 지시어를 통해 명확하게 정의하고, 서버 컴포넌트와 적절히 조합하여 사용함으로써 Next.js App Router의 성능 이점을 최대한 활용하면서도 풍부한 사용자 경험을 제공하는 애플리케이션을 구축할 수 있습니다.

클라이언트 컴포넌트 사용법 적용 전에는 서버/클라이언트 경계, 캐싱 조건, 배포 영향을 함께 확인해야 합니다.

클라이언트 경계는 네 조건을 통과한 리프에만 둔다

상호작용 이유가 분명해도 import 범위와 전달값이 안전한지까지 확인해야 한다.

  1. 브라우저 실행 필요

    state·event·window API가 실제로 있는가

  2. 가장 작은 리프

    page가 아니라 버튼·폼 영역만 분리했는가

  3. 직렬화 가능한 props

    plain data만 서버 경계를 건너는가

  4. 안전한 import tree

    DB·secret·큰 서버 의존성이 끌려오지 않는가

마지막으로 클라이언트 컴포넌트를 선언해야 하는 조건과 서버 컴포넌트와의 경계를 정리합니다.

서버 트리 안에 작은 클라이언트 섬을 끼워 넣는다

서버가 데이터와 큰 구조를 소유하고 브라우저 반응이 필요한 leaf만 props 또는 children으로 연결한다.

  1. server
    Data shell

    DB·권한·초기 HTML과 큰 렌더 작업

  2. props
    Serialized data

    문자열·숫자·plain object처럼 경계를 건널 값

  3. children
    Server result slot

    서버가 만든 결과를 client wrapper가 표시만 제어

  4. client
    Interactive leaf

    입력·토글·브라우저 API와 지역 상태