icon
7장 : 서버, 클라이언트 컴포넌트

서버와 클라이언트 컴포넌트 조합하기

Next.js App Router의 가장 강력한 특징이자 종종 개발자들에게 혼란을 주는 부분이 바로 서버 컴포넌트와 클라이언트 컴포넌트의 조합입니다. 이 두 가지 유형의 컴포넌트를 효과적으로 활용하는 것은 애플리케이션의 성능을 최적화하고, 개발 복잡성을 줄이며, 뛰어난 사용자 경험을 제공하는 데 필수적입니다. 이전 절에서 각 컴포넌트의 역할과 기본적인 사용법을 알아보았습니다. 이제 이들을 어떻게 함께 사용하여 시너지를 낼 수 있는지, 그리고 흔히 발생하는 혼동 지점들을 명확히 정리해 보겠습니다.


서버-클라이언트 컴포넌트 조합의 중요성

Next.js App Router의 핵심 철학은 "기본은 서버, 필요할 때만 클라이언트" 입니다. 즉, 가능한 한 많은 작업을 서버에서 처리하여 클라이언트의 부담을 줄이고, 상호작용이 필수적인 부분만 클라이언트 컴포넌트로 분리하는 것입니다. 이러한 조합은 다음과 같은 이점을 제공합니다.

  • 초기 로딩 성능 최적화: 서버 컴포넌트는 JavaScript 번들에 포함되지 않으므로, 초기 페이지 로딩 시 클라이언트가 다운로드하고 파싱해야 할 JavaScript 양이 크게 줄어듭니다. 이는 특히 모바일 환경에서 Critical Rendering Path를 단축시켜 체감 로딩 속도를 향상시킵니다.
  • 향상된 SEO: 서버에서 미리 HTML이 렌더링되므로, 검색 엔진 크롤러가 쉽게 콘텐츠를 읽고 색인화할 수 있습니다.
  • 보안 강화: 민감한 데이터 페칭 로직이나 API 키는 서버 컴포넌트에서 안전하게 처리되며 클라이언트에 노출되지 않습니다.
  • 개발 편의성: 데이터베이스나 파일 시스템과 같은 백엔드 리소스에 직접 접근하여 데이터 페칭 로직을 더욱 단순하게 작성할 수 있습니다.
  • 하이드레이션 최적화: 서버에서 미리 렌더링된 HTML을 클라이언트 컴포넌트가 받아서 상호작용 가능한 상태로 만드는 하이드레이션 과정이 더욱 효율적입니다.

서버-클라이언트 경계 이해하기

서버 컴포넌트와 클라이언트 컴포넌트를 조합할 때 가장 중요한 개념은 바로 경계(Boundary) 입니다. "use client" 지시어가 있는 파일은 해당 파일부터 그 하위 모든 컴포넌트(자식)를 포함하여 클라이언트 경계를 형성합니다.

규칙 요약

  1. 기본은 서버 컴포넌트: "use client" 지시어가 없는 모든 .tsx (또는 .js, .jsx) 파일은 서버 컴포넌트입니다.
  2. 클라이언트 컴포넌트 선언: 파일의 맨 위에 "use client"를 추가하면 해당 파일은 클라이언트 컴포넌트가 됩니다.
  3. 서버 -> 클라이언트 (허용): 서버 컴포넌트는 클라이언트 컴포넌트를 import하여 렌더링할 수 있습니다. 이것이 가장 일반적이고 권장되는 패턴입니다.
    ServerComponent.tsx
    // ServerComponent.tsx (서버)
    import ClientComponent from './ClientComponent'; // 클라이언트 임포트 가능
    
    export default function ServerComponent() {
      return <ClientComponent />;
    }
  4. 클라이언트 -> 서버 (불가능, children 패턴 사용): 클라이언트 컴포넌트는 서버 컴포넌트를 직접 import할 수 없습니다. 대신, 서버 컴포넌트를 클라이언트 컴포넌트의 children prop으로 전달받아 렌더링해야 합니다.
    ClientComponent.tsx
    // ClientComponent.tsx (클라이언트)
    "use client";
    // import ServerComponent from './ServerComponent'; // ❌ 불가능!
    
    export default function ClientComponent({ children }: { children: React.ReactNode }) {
      return <div>{children}</div>; // children으로 서버 컴포넌트의 결과물을 받음
    }
    // ServerParent.tsx (서버)
    import ClientComponent from './ClientComponent';
    import ServerContent from './ServerContent'; // 서버 컴포넌트
    
    export default function ServerParent() {
      return (
        <ClientComponent>
          {/* ServerContent는 여기서 서버에서 렌더링되어 HTML로 ClientComponent의 children으로 전달됨 */}
          <ServerContent />
        </ClientComponent>
      );
    }
  5. props 직렬화: 서버 컴포넌트에서 클라이언트 컴포넌트로 전달되는 props직렬화 가능(Serializable) 해야 합니다. 함수, Date 객체, Map, Set 등은 직접 전달할 수 없으며, JSON으로 표현 가능한 원시 값, 객체, 배열 등이 전달되어야 합니다. 함수를 전달하려면 클라이언트 컴포넌트에서 정의해야 합니다.

서버-클라이언트 조합 패턴 예시

실제 애플리케이션에서 서버 컴포넌트와 클라이언트 컴포넌트가 어떻게 함께 사용되는지 구체적인 시나리오를 통해 살펴보겠습니다.

시나리오 1

사용자별 대시보드 (초기 데이터는 서버, 상호작용은 클라이언트) 형태로 조합하는 것은 가장 흔한 패턴으로, 대시보드의 주요 데이터는 서버에서 가져와 빠르게 렌더링하고, 특정 위젯의 상호작용은 클라이언트에서 처리합니다.

src/app/dashboard/page.tsx
// src/app/dashboard/page.tsx (서버 컴포넌트) - 대시보드 메인 페이지

import ChartWidget from './ChartWidget'; // 클라이언트 컴포넌트
import UserProfileCard from './UserProfileCard'; // 클라이언트 컴포넌트

interface UserData {
  name: string;
  totalSales: number;
  // ... 기타 대시보드 초기 데이터
}

async function getDashboardData(): Promise<UserData> {
  // ⛔️ 민감한 DB 쿼리나 내부 API 호출은 여기서 안전하게! ⛔️
  console.log('Server: Fetching initial dashboard data...');
  await new Promise(resolve => setTimeout(resolve, 800)); // 지연 시뮬레이션
  return {
    name: '김넥스트',
    totalSales: 1234567,
    // ...
  };
}

export default async function DashboardPage() {
  const data = await getDashboardData(); // 서버에서 초기 데이터 페칭

  return (
    <div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
      <h1 style={{ color: '#333' }}>환영합니다, {data.name}님의 대시보드!</h1>
      <p style={{ fontSize: '1.1em', color: '#666' }}>
        총 매출: <strong style={{ color: '#28a745' }}>{data.totalSales.toLocaleString()}</strong>
      </p>

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginTop: '30px' }}>
        <div style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '15px' }}>
          <h2>매출 차트</h2>
          {/* ChartWidget은 클라이언트에서 동적으로 데이터 업데이트 및 차트 상호작용 */}
          <ChartWidget initialSales={data.totalSales} />
        </div>
        <div style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '15px' }}>
          <h2>사용자 프로필</h2>
          {/* UserProfileCard는 프로필 수정 등 클라이언트 상호작용이 있음 */}
          <UserProfileCard initialName={data.name} />
        </div>
      </div>
    </div>
  );
}
src/app/dashboard/ChartWidget.tsx
// src/app/dashboard/ChartWidget.tsx (클라이언트 컴포넌트)
"use client";

import React, { useState, useEffect } from 'react';
// import { Line } from 'react-chartjs-2'; // 실제 차트 라이브러리 가정

interface ChartWidgetProps {
  initialSales: number;
}

export default function ChartWidget({ initialSales }: ChartWidgetProps) {
  const [currentSales, setCurrentSales] = useState(initialSales);
  const [loading, setLoading] = useState(false);

  // 3초마다 새로운 매출 데이터 페칭 시뮬레이션
  useEffect(() => {
    const interval = setInterval(async () => {
      setLoading(true);
      console.log('Client: Fetching updated sales data...');
      await new Promise(resolve => setTimeout(resolve, 500));
      setCurrentSales(prev => prev + Math.floor(Math.random() * 10000 - 5000)); // 무작위 증감
      setLoading(false);
    }, 3000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <p>실시간 매출: <strong style={{ color: '#007bff' }}>{currentSales.toLocaleString()}</strong></p>
      {loading ? <p>업데이트 중...</p> : null}
      {/* <Line data={...} /> */} {/* 실제 차트 렌더링 */}
      <button onClick={() => alert('차트 클릭 이벤트 처리 (클라이언트)')} style={{ marginTop: '10px' }}>
        차트 데이터 상세 보기
      </button>
    </div>
  );
}
src/app/dashboard/UserProfileCard.tsx
// src/app/dashboard/UserProfileCard.tsx (클라이언트 컴포넌트)
"use client";

import React, { useState } from 'react';

interface UserProfileCardProps {
  initialName: string;
}

export default function UserProfileCard({ initialName }: UserProfileCardProps) {
  const [userName, setUserName] = useState(initialName);
  const [isEditing, setIsEditing] = useState(false);

  const handleSave = () => {
    // 클라이언트에서 사용자 이름 업데이트 API 호출 등
    alert(`이름 저장: ${userName}`);
    setIsEditing(false);
  };

  return (
    <div>
      {isEditing ? (
        <div>
          <input
            type="text"
            value={userName}
            onChange={(e) => setUserName(e.target.value)}
            style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
          />
          <button onClick={handleSave} style={{ marginRight: '10px' }}>저장</button>
          <button onClick={() => setIsEditing(false)}>취소</button>
        </div>
      ) : (
        <div>
          <p>이름: <strong>{userName}</strong></p>
          <button onClick={() => setIsEditing(true)}>편집</button>
        </div>
      )}
    </div>
  );
}

실습 확인: src/app/dashboard 폴더에 위 파일들을 생성하고 http://localhost:3000/dashboard로 접속합니다.

  • 초기 로딩 시 서버 콘솔에 Server: Fetching initial dashboard data...가 찍히고, 빠르게 페이지가 표시됩니다.
  • 페이지가 로드된 후 3초마다 브라우저 콘솔에 Client: Fetching updated sales data...가 찍히면서 매출 숫자가 자동으로 변하는 것을 볼 수 있습니다.
  • "차트 데이터 상세 보기" 버튼이나 프로필 "편집" 버튼을 클릭하면 클라이언트 컴포넌트에서 상호작용이 일어납니다.

이 예시에서 DashboardPage (서버)는 ChartWidgetUserProfileCard (클라이언트)에 정적인 초기 데이터props로 전달합니다. 이 클라이언트 컴포넌트들은 각자 필요한 동적인 데이터 업데이트사용자 상호작용을 자체적으로 처리합니다.


시나리오 2

클라이언트 컴포넌트 내부에 서버 컴포넌트 렌더링 (children 패턴)을 하는 것은 클라이언트 컴포넌트가 특정 로직(예: 조건부 렌더링, 이벤트 핸들링)을 수행하면서 그 내부에 서버에서 렌더링된 콘텐츠를 포함해야 할 때 사용합니다.

src/app/conditional-content/page.tsx
// src/app/conditional-content/page.tsx (서버 컴포넌트)
import ClientConditionalWrapper from './ClientConditionalWrapper';
import ImportantServerMessage from './ImportantServerMessage'; // 서버 컴포넌트

export default function ConditionalContentPage() {
  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto' }}>
      <h1>조건부 콘텐츠 페이지</h1>
      <p>아래는 클라이언트 컴포넌트가 제어하는 서버 콘텐츠입니다.</p>
      <ClientConditionalWrapper>
        {/* ImportantServerMessage는 서버에서 렌더링되어 ClientConditionalWrapper의 children으로 전달됩니다. */}
        <ImportantServerMessage />
      </ClientConditionalWrapper>
    </div>
  );
}
src/app/conditional-content/ClientConditionalWrapper.tsx
// src/app/conditional-content/ClientConditionalWrapper.tsx (클라이언트 컴포넌트)
"use client";

import React, { useState } from 'react';

export default function ClientConditionalWrapper({ children }: { children: React.ReactNode }) {
  const [showContent, setShowContent] = useState(false);

  return (
    <div style={{ border: '1px dashed orange', padding: '15px', marginTop: '20px', borderRadius: '8px' }}>
      <h2>클라이언트 조건부 래퍼</h2>
      <button onClick={() => setShowContent(!showContent)}>
        {showContent ? '숨기기' : '중요 메시지 보기'}
      </button>
      {showContent && (
        <div style={{ marginTop: '15px', padding: '10px', backgroundColor: '#fffbe6' }}>
          {children} {/* 서버에서 렌더링된 ImportantServerMessage가 여기에 삽입됨 */}
        </div>
      )}
    </div>
  );
}
src/app/conditional-content/ImportantServerMessage.tsx
// src/app/conditional-content/ImportantServerMessage.tsx (서버 컴포넌트)
export default async function ImportantServerMessage() {
  // 이 메시지는 서버에서 미리 생성됩니다.
  console.log('Server: ImportantServerMessage rendered.');
  await new Promise(resolve => setTimeout(resolve, 300)); // 시뮬레이션
  return (
    <div>
      <h3 style={{ color: '#d9534f' }}>🚨 서버에서 전달된 중요한 공지 🚨</h3>
      <p>이 내용은 서버에서 미리 준비되었으며, 클라이언트 상호작용에 따라 나타납니다.</p>
      <p>렌더링 시간: {new Date().toLocaleTimeString()}</p>
    </div>
  );
}

실습 확인: src/app/conditional-content 폴더에 위 파일들을 생성하고 http://localhost:3000/conditional-content로 접속합니다.

  • 터미널(서버 콘솔)에 Server: ImportantServerMessage rendered. 로그가 찍힙니다. 이는 ImportantServerMessage가 페이지 로드 시점에 이미 서버에서 렌더링되었음을 의미합니다.
  • "중요 메시지 보기" 버튼을 클릭하면 메시지가 토글됩니다. 이 토글은 클라이언트에서만 일어나며, 메시지의 내용은 서버에서 받은 HTML 그대로입니다.

이 패턴은 클라이언트 컴포넌트가 서버 컴포넌트의 "결과물"(미리 렌더링된 HTML)을 다루는 것이지, 서버 컴포넌트 코드를 클라이언트에서 실행하는 것이 아님을 명확히 보여줍니다.


언제 어떤 컴포넌트를 사용할 것인가?

두 가지 컴포넌트 유형 중 어떤 것을 선택할지는 컴포넌트의 역할과 필요한 기능에 따라 신중하게 결정해야 합니다.

  • 기본은 서버 컴포넌트: 특별한 이유가 없다면 모든 컴포넌트를 서버 컴포넌트로 시작하세요. 이는 Next.js의 성능 이점을 최대한 활용하는 방법입니다.
  • 클라이언트 컴포넌트가 필요한 경우
    • useState, useEffect 등의 React 훅을 사용해야 할 때.
    • onClick, onChange이벤트 핸들러를 사용해야 할 때.
    • window, document브라우저 전용 API에 접근해야 할 때.
    • 클라이언트 측 애니메이션 라이브러리를 사용해야 할 때.
    • 클라이언트 측 상태 관리 라이브러리 (Redux, Zustand 등)와 연동해야 할 때.
    • localStorage, indexedDB브라우저 저장소를 사용해야 할 때.

서버 컴포넌트와 클라이언트 컴포넌트를 올바르게 조합하는 것은 Next.js App Router 아키텍처를 마스터하는 핵심 단계입니다. 각 컴포넌트의 장점을 이해하고, 적절한 시점에 올바른 컴포넌트를 사용함으로써 강력하고 효율적인 웹 애플리케이션을 구축할 수 있습니다.