Next.js 13 버전 이상(App Router)을 도입해 개발을 시작하면 가장 먼저 마주치는 거대한 벽이 있습니다. 바로 컴포넌트 파일 맨 상단에 습관처럼 붙이게 되는 'use client'; 선언입니다.

저 역시 초기에는 컴포넌트마다 에러(예: useState can not be used in Server Component)가 뜰 때마다 귀찮다는 이유로 파일마다 상단에 무지성으로 'use client';를 붙여가며 개발했습니다.

하지만 나중에 프로젝트 번들 사이즈(Bundle Size)를 측정해 보니, 서버에서 가볍게 처리되어야 할 정적 UI들까지 전부 클라이언트(브라우저)로 자바스크립트 코드가 내려가면서 초기 로딩 속도가 뚝 떨어지는 심각한 부작용을 마주쳤습니다.

오늘은 서버 컴포넌트(RSC)와 클라이언트 컴포넌트(RCC)의 근본적인 차이점과, 실무에서 어떤 기준으로 분리해야 하는지 명확한 가이드를 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 모든 컴포넌트에 'use client'를 붙이면 안 될까?"

Next.js App Router의 기본 원칙은 모든 컴포넌트가 기본적으로 서버 컴포넌트(RSC)라는 점입니다.

  • 서버 컴포넌트 (RSC): HTML 렌더링과 데이터베이스(DB) 조회를 서버에서 끝내고, 가벼운 HTML 결과물만 브라우저로 보냅니다. (자바스크립트 번들 사이즈 0KB!)
  • 클라이언트 컴포넌트 (RCC): 'use client';를 선언하는 순간, 해당 컴포넌트와 그 하위에 딸린 모든 자식 코드가 브라우저로 다운로드되어 실행됩니다.

만약 페이지 전체를 감싸는 최상단 부모나 단순 마크업 컴포넌트에 무심코 클라이언트 선언을 해버리면, 서버의 강력한 성능 이점을 전혀 살리지 못하고 브라우저 과부하를 유발하게 됩니다.

2. 언제 '서버 컴포넌트(RSC)'를 써야 할까? (기본값)

대부분의 일반적인 컴포넌트는 서버 컴포넌트로 두는 것이 가장 이상적입니다.

  • 가장 적합한 곳: 백엔드 API나 DB에서 데이터를 직접 가져와야 하는 경우, 민감한 시크릿 키(API Key)를 다뤄야 하는 경우, 무거운 서드파티 라이브러리를 서버에서 미리 렌더링하고 싶을 때.
TypeScript
 
// ⭕ 서버 컴포넌트 예시 (async/await로 서버에서 직접 데이터 페칭 가능)
// 'use client' 선언이 없음 = 기본 서버 컴포넌트
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  // 서버 내부에서 직접 안전하게 DB 또는 API 호출
  const res = await fetch(`https://api.example.com/products/${params.id}`);
  const product = await res.json();

  return (
    <div className="p-6">
      <h1 className="text-xl font-bold">{product.title}</h1>
      <p className="text-slate-400">{product.description}</p>
    </div>
  );
}
 

클라이언트 브라우저가 자바스크립트를 다운로드하고 실행할 필요 없이, 서버가 이미 완성된 HTML을 보내주므로 SEO 최적화와 초기 로딩 속도(FCP)가 극적으로 빨라집니다.

'use client' 선언이 없고 async/await로 데이터를 바로 가져오는 서버 컴포넌트 구조

3. 언제 '클라이언트 컴포넌트(RCC)'를 써야 할까?

브라우저의 동적 기능이나 사용자 인터랙션이 반드시 필요한 순간에만 제한적으로 'use client';를 붙여야 합니다.

  • 가장 적합한 곳: useState, useEffect, useContext 등의 훅을 써야 할 때, onClick, onChange 같은 브라우저 이벤트 리스너가 필요할 때, 브라우저 전용 API(localStorage, window)를 쓸 때.
TypeScript
 
'use client'; // 👈 브라우저 상호작용이 필요할 때만 명시!

import { useState } from 'react';

export default function CounterButton() {
  const [count, setCount] = useState<number>(0);

  return (
    <button
      onClick={() => setCount((prev) => prev + 1)}
      className="px-4 py-2 bg-indigo-600 text-white rounded-xl"
    >
      클릭 횟수: {count}
    </button>
  );
}

4. 실무 아키텍처 패턴: "서버 컴포넌트 안에 클라이언트 컴포넌트 배치하기"

실무에서 가장 권장되는 패턴은 큰 틀(뼈대와 데이터 페칭)은 서버 컴포넌트로 만들고, 클릭 이벤트가 발생하는 아주 작은 버튼 조각만 클라이언트 컴포넌트로 분리하는 것입니다.

TypeScript
 
// 1. 부모 페이지 (서버 컴포넌트 - 데이터 로딩 담당)
import CounterButton from './CounterButton';

export default async function Page() {
  const data = await fetch('...').then((res) => res.json());

  return (
    <div>
      <h1>{data.title}</h1>
      {/* 2. 인터랙션이 필요한 작은 조각만 클라이언트 컴포넌트로 주입 */}
      <CounterButton initialCount={data.count} />
    </div>
  );
}

이렇게 하면 전체 페이지의 90%는 서버에서 가볍게 렌더링되고, 오직 버튼 컴포넌트만 클라이언트에서 작동하여 번들 크기를 최소화할 수 있습니다.

서버 데이터와 클라이언트 인터랙션 버튼이 함께 동작

5. 결론 및 느낀 점

Next.js App Router를 다룰 때 'use client';는 마법의 열쇠가 아니라 성능을 깎아먹을 수 있는 양날의 검입니다.

  1. 데이터 조회와 정적 마크업은 기본 서버 컴포넌트(RSC)로 유지할 것
  2. 상태(State)나 이벤트(Event)가 필요한 최소한의 하위 조각에만 클라이언트 컴포넌트(RCC)를 적용할 것

이 원칙을 지키는 것만으로도 웹 사이트의 번들 사이즈를 대폭 줄이고 구글 성능 점수를 크게 끌어올릴 수 있습니다!

Next.js App Router의 핵심은 "모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Components)다"라는 선언입니다. 과거 Pages Router 시절에는 모든 컴포넌트가 브라우저로 전송되어 하이드레이션(Hydration) 과정을 거쳐야 했지만, 이제는 서버에서만 실행되고 결과물인 HTML만 브라우저로 전달되는 컴포넌트를 만들 수 있게 되었습니다.

하지만 개발을 하다 보면 인터랙션(클릭, 상태 관리)이 필요한 시점이 오고, 자연스럽게 'use client'를 선언하게 됩니다. 이때 가장 중요한 역량은 "어디까지를 서버 영역으로 두고, 어디서부터 클라이언트 영역으로 나눌 것인가"를 결정하는 설계 능력입니다.


1. 서버 컴포넌트(RSC) vs 클라이언트 컴포넌트(RCC)

먼저 두 컴포넌트의 역할과 제약을 명확히 구분해야 합니다.

1.1 서버 컴포넌트 (Default)

  • 실행 위치: 서버에서만 실행됩니다.
  • 장점: 브라우저로 전송되는 자바스크립트 번들 크기가 줄어들며, 서버 자원(DB, 파일 시스템)에 직접 접근할 수 있습니다. 보안에 민감한 API 키 등을 숨기기에도 유리합니다.
  • 제약: useState, useEffect 같은 훅이나 onClick 같은 이벤트 핸들러를 사용할 수 없습니다. 브라우저 API(window, localStorage)에도 접근할 수 없습니다.

1.2 클라이언트 컴포넌트 ('use client')

  • 실행 위치: 서버에서 프리렌더링된 후 브라우저에서 하이드레이션됩니다.
  • 장점: 사용자와의 인터랙션이 가능하고 브라우저 API를 모두 사용할 수 있습니다.
  • 제약: 자바스크립트 번들 용량을 차지하며, 데이터 패칭 시 서버 컴포넌트보다 네트워크 비용이 더 발생할 수 있습니다.

2. 효율적인 경계 설계 전략 (Component Pattern)

성능을 최적화하기 위해서는 "클라이언트 컴포넌트를 컴포넌트 트리의 가장 말단으로 밀어내는 것"이 핵심입니다.

2.1 리프 컴포넌트 패턴 (Leaf Component Pattern)

페이지 전체를 'use client'로 만드는 대신, 상태가 필요한 작은 조각만 별도의 클라이언트 컴포넌트로 분리하세요.

  • Bad: 검색 결과 페이지 전체를 클라이언트 컴포넌트로 선언 (검색 결과 리스트까지 모두 JS 번들에 포함됨)
  • Good: 검색어 입력을 받는 SearchBar만 클라이언트 컴포넌트로 만들고, 결과 리스트는 서버 컴포넌트로 유지
TypeScript
 
// SearchPage.tsx (Server Component)
import SearchBar from './SearchBar'; // 'use client'
import SearchResults from './SearchResults'; // Server Component

export default function SearchPage() {
  return (
    <main>
      <h1>검색 페이지</h1>
      {/* 인터랙션이 필요한 부분만 클라이언트 영역 */}
      <SearchBar /> 
      {/* 데이터 렌더링은 서버 영역 */}
      <SearchResults />
    </main>
  );
}

3. 서버 컴포넌트 안에 클라이언트 컴포넌트 넣기 (Composition)

서버 컴포넌트에서 클라이언트 컴포넌트를 불러오는 것은 쉽지만, 그 반대는 불가능하다고 생각하는 경우가 많습니다. 하지만 컴포넌트 합성(Composition)을 이용하면 클라이언트 컴포넌트 '내부'에 서버 컴포넌트를 배치할 수 있습니다.

TypeScript
 
// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>토글</button>
      {/* children으로 들어온 서버 컴포넌트는 여전히 서버에서 실행됨 */}
      {isOpen && children}
    </div>
  );
}

// Page.tsx (Server Component)
export default function Page() {
  return (
    <ClientWrapper>
      <HeavyServerComponent /> {/* 클라이언트 컴포넌트 안에서도 서버 컴포넌트의 이점 유지 */}
    </ClientWrapper>
  );
}

이 패턴을 활용하면 레이아웃이나 상태를 관리하는 래퍼(Wrapper)는 클라이언트 쪽에 두면서도, 실제 무거운 데이터 렌더링은 서버 컴포넌트로 처리하는 고도화된 설계가 가능해집니다.


4. 데이터 패칭의 최적 위치

App Router에서는 데이터 패칭을 서버 컴포넌트에서 수행하는 것을 강력히 권장합니다.

  • 이유: 서버에서 직접 DB에 접근하므로 속도가 빠르고, 클라이언트-서버 간의 Waterfall 현상을 방지할 수 있습니다. 또한 패칭된 데이터를 하위 클라이언트 컴포넌트로 Props를 통해 쉽게 전달할 수 있습니다.

5. 실무 체크리스트: 언제 'use client'를 쓸까?

필요한 기능 서버 컴포넌트 클라이언트 컴포넌트
데이터 패칭 (Fetching Data) ✅ ❌ (권장 안 함)
백엔드 자원 직접 접근 (DB, API) ✅ ❌
보안 정보 유지 (API Keys, Tokens) ✅ ❌
상태 관리 및 생명주기 (useState, useEffect) ❌ ✅
브라우저 API 사용 (window, localStorage) ❌ ✅
커스텀 훅 사용 (대부분의 UI 관련 훅) ❌ ✅

6. 결론: "서버가 먼저, 클라이언트는 나중에"

Next.js App Router 설계의 미학은 '자바스크립트 다이어트'에 있습니다. 최대한 많은 로직을 서버로 옮기고, 클라이언트는 오직 사용자의 손가락이 닿는 곳(Click, Type, Swipe)에만 집중하게 하세요.

오늘의 요약:

  • 기본은 서버: 모든 파일은 일단 서버 컴포넌트로 시작하세요.
  • 말단 분리: 상태가 필요한 부분만 핀셋으로 집어내듯 클라이언트 컴포넌트로 만드세요.
  • 합성 활용: 클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받을 수 있음을 잊지 마세요.
  • 데이터는 상단 서버에서: 데이터 패칭은 가능한 트리 상단의 서버 컴포넌트에서 해결하세요.

+ Recent posts