Next.js 이론 2강

Server Components와 Client Components

React 19의 핵심 기능인 Server Components와 Client Components의 차이점을 이해하고, 이커머스 애플리케이션에서 효과적으로 활용하는 방법을 학습합니다.

1. Server Components의 혁명

React 19와 Next.js 16에서 Server Components는 웹 개발의 패러다임을 바꾸는 혁신입니다. 컴포넌트가 서버에서만 실행되어 클라이언트로 JavaScript를 전송하지 않습니다. 이를 통해 번들 크기를 대폭 줄이고 초기 로딩 속도를 개선할 수 있습니다.

왜 서버에서 렌더링하는가?

기존 방식 (CSR)

  • • 빈 HTML + 큰 JS 번들 전송
  • • 브라우저에서 렌더링
  • • 데이터 페칭도 클라이언트에서
  • • 초기 로딩 느림

Server Components

  • • 완성된 HTML 전송
  • • JS 번들 크기 대폭 감소
  • • 서버에서 직접 DB 접근 가능
  • • 빠른 초기 로딩

번들 크기 비교

// 기존 방식: 클라이언트에 moment.js 전체 번들 전송 (300KB+)
import moment from 'moment';

export default function ProductDate({ date }) {
  return <span>{moment(date).format('YYYY-MM-DD')}</span>;
}

// Server Component: 서버에서만 실행, 클라이언트에 0KB
// 결과 HTML만 전송: <span>2026-01-31</span>

Server Components의 장점

🚀 성능

JavaScript 번들 크기 감소로 빠른 로딩

🔒 보안

API 키, DB 쿼리가 클라이언트에 노출되지 않음

⚡ 데이터 페칭

서버에서 직접 DB 접근, 워터폴 제거

💾 캐싱

서버 측 캐싱으로 효율적인 데이터 관리

이커머스에서의 활용

페이지Server Component 활용이점
상품 목록DB에서 직접 조회SEO + 빠른 로딩
상품 상세상품 정보 렌더링검색 엔진 노출
카테고리정적 데이터 표시번들 크기 0

Server Component 제약

  • • useState, useEffect 사용 불가
  • • onClick 등 이벤트 핸들러 불가
  • • 브라우저 API (localStorage 등) 접근 불가

핵심 개념

Next.js App Router에서 모든 컴포넌트는 기본적으로 Server Component입니다. 'use client' 지시어를 명시적으로 추가해야만 Client Component가 됩니다. 이 기본값 덕분에 자연스럽게 성능 최적화가 이루어집니다.

2. Client Components의 역할

Client Components는 브라우저에서 실행되어 사용자 상호작용을 처리합니다. 'use client' 지시어로 선언합니다.

언제 Client Component를 사용하는가?

🖱️ 이벤트 핸들러

onClick, onChange, onSubmit 등

🔄 상태 관리

useState, useReducer

⚡ 생명주기

useEffect, useLayoutEffect

🌐 브라우저 API

localStorage, window, navigator

'use client' 지시어

'use client';  // 파일 최상단에 선언

import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: string }) {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = async () => {
    setIsLoading(true);
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId }),
    });
    setIsLoading(false);
  };

  return (
    <button onClick={handleClick} disabled={isLoading}>
      {isLoading ? '추가 중...' : '장바구니 담기'}
    </button>
  );
}

주의사항

  • • 'use client'는 파일 최상단에 위치해야 함
  • • Client Component에서 import한 모든 모듈도 클라이언트 번들에 포함
  • • 불필요한 Client Component 사용은 번들 크기 증가

Server vs Client 비교

기능ServerClient
데이터 페칭
직접 DB 접근
useState/useEffect
이벤트 핸들러
브라우저 API
번들 크기 영향없음증가

이커머스 Client Component 예시

장바구니 버튼 - onClick 필요
수량 선택 - useState 필요
검색 입력 - onChange 필요
이미지 갤러리 - 상태 관리 필요

설계 원칙

기본적으로 Server Component를 사용하고, 상호작용이 필요한 부분만 Client Component로 분리하세요.

3. 컴포넌트 구성 패턴

Server Component와 Client Component를 효과적으로 조합하는 패턴을 학습합니다. 핵심은 Server Component를 최대한 활용하고, 상호작용이 필요한 부분만 Client Component로 분리하는 것입니다.

컴포지션 패턴

// app/products/[id]/page.tsx (Server Component)
import { ProductInfo } from './ProductInfo';
import { AddToCartButton } from './AddToCartButton';

async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`);
  return res.json();
}

export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  const product = await getProduct(params.id);

  return (
    <div>
      {/* Server Component: 정적 정보 표시 */}
      <ProductInfo product={product} />
      
      {/* Client Component: 상호작용 처리 */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

경계 설계 원칙

✅ 좋은 패턴
// 상호작용 부분만 Client Component로 분리
<ProductPage>           {/* Server */}
  <ProductImage />      {/* Server */}
  <ProductDetails />    {/* Server */}
  <AddToCartButton />   {/* Client */}
</ProductPage>
❌ 피해야 할 패턴
// 전체를 Client Component로 만들면 번들 크기 증가
'use client';
<ProductPage>           {/* 전체가 Client */}
  <ProductImage />
  <ProductDetails />
  <AddToCartButton />
</ProductPage>

Props 전달 규칙

// Server → Client로 전달 가능한 props
// ✅ 직렬화 가능한 데이터만 전달 가능

// 가능: 문자열, 숫자, 배열, 객체
<ClientComponent 
  name="상품명"
  price={10000}
  tags={['신상품', '할인']}
/>

// 불가능: 함수, Date 객체, Map, Set 등
<ClientComponent 
  onClick={() => {}}  // ❌ 함수 전달 불가
  date={new Date()}   // ❌ Date 객체 전달 불가
/>

설계 팁

  • • Client Component는 트리의 가장 말단(leaf)에 배치
  • • 상위 컴포넌트를 Server Component로 유지
  • • 번들 크기 최소화를 위해 Client 영역 최소화

4. 데이터 페칭 전략

Server Components에서는 async/await를 직접 사용하여 데이터를 가져올 수 있습니다. 별도의 useEffect나 상태 관리 없이 깔끔하게 데이터를 로드할 수 있습니다.

Server Component에서 데이터 페칭

// app/products/page.tsx
// Server Component는 async 함수로 선언 가능
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache',  // 기본값: 캐시 사용
  });
  
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

병렬 데이터 페칭

// 병렬로 여러 데이터 동시 요청
async function getProduct(id: string) {
  const res = await fetch(`/api/products/${id}`);
  return res.json();
}

async function getReviews(productId: string) {
  const res = await fetch(`/api/products/${productId}/reviews`);
  return res.json();
}

export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  // Promise.all로 병렬 요청 - 워터폴 방지
  const [product, reviews] = await Promise.all([
    getProduct(params.id),
    getReviews(params.id),
  ]);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>리뷰 {reviews.length}개</p>
    </div>
  );
}

캐싱 옵션

// 캐시 사용 (기본값)
fetch(url, { cache: 'force-cache' });

// 캐시 사용 안 함 (항상 새로운 데이터)
fetch(url, { cache: 'no-store' });

// 시간 기반 재검증 (ISR)
fetch(url, { next: { revalidate: 3600 } });  // 1시간마다

// 태그 기반 재검증
fetch(url, { next: { tags: ['products'] } });

이커머스 적용

  • • 상품 목록: cache + revalidate (주기적 갱신)
  • • 재고 현황: no-store (실시간)
  • • 상품 상세: 태그 기반 재검증

5. Streaming과 Suspense

Streaming을 사용하면 페이지의 일부를 먼저 보여주고, 나머지는 준비되는 대로 점진적으로 렌더링할 수 있습니다. 사용자는 전체 페이지가 로드될 때까지 기다리지 않아도 됩니다.

Suspense로 로딩 상태 처리

import { Suspense } from 'react';

// 느린 컴포넌트 (데이터 페칭에 시간 소요)
async function ProductReviews({ productId }: { productId: string }) {
  const reviews = await fetch(`/api/products/${productId}/reviews`);
  return <ReviewList reviews={reviews} />;
}

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* 즉시 렌더링 */}
      <ProductInfo productId={params.id} />
      
      {/* 리뷰는 로딩 중일 때 스켈레톤 표시 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
    </div>
  );
}

loading.tsx 파일

// app/products/loading.tsx
// 해당 라우트 전체의 로딩 상태를 자동으로 처리

export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-full mb-2" />
      <div className="h-4 bg-gray-200 rounded w-2/3" />
    </div>
  );
}

// Next.js가 자동으로 Suspense로 감싸줌
// <Suspense fallback={<Loading />}>
//   <ProductsPage />
// </Suspense>

Streaming의 장점

⚡ TTFB 개선

첫 바이트까지의 시간 단축 - 빠른 콘텐츠 표시

🎯 점진적 렌더링

중요한 콘텐츠 먼저, 부가 정보는 나중에

🔄 병렬 처리

느린 데이터가 전체 페이지를 블로킹하지 않음

이커머스 활용

  • • 상품 기본 정보: 즉시 표시
  • • 리뷰, 추천 상품: Suspense로 분리
  • • 재고 현황: 별도 Suspense 경계
  • • 사용자 경험 대폭 개선

6. 이커머스 예제: 상품 상세 페이지

Server Component와 Client Component를 조합하여 실제 상품 상세 페이지를 구현합니다. 각 컴포넌트의 역할을 명확히 분리하는 것이 핵심입니다.

페이지 구조

// app/products/[id]/page.tsx (Server Component)
import { Suspense } from 'react';
import { ProductImage } from './ProductImage';
import { ProductInfo } from './ProductInfo';
import { AddToCartButton } from './AddToCartButton';
import { ProductReviews } from './ProductReviews';

async function getProduct(id: string) {
  const res = await fetch(`https://api.myshop.com/products/${id}`, {
    next: { tags: [`product-${id}`] }
  });
  return res.json();
}

export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  const product = await getProduct(params.id);

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid md:grid-cols-2 gap-8">
        {/* Server: 이미지 (최적화) */}
        <ProductImage src={product.image} alt={product.name} />
        
        <div>
          {/* Server: 상품 정보 */}
          <ProductInfo product={product} />
          
          {/* Client: 장바구니 버튼 (상호작용) */}
          <AddToCartButton 
            productId={product.id} 
            price={product.price} 
          />
        </div>
      </div>

      {/* Streaming: 리뷰 (느린 데이터) */}
      <Suspense fallback={<div>리뷰 로딩 중...</div>}>
        <ProductReviews productId={params.id} />
      </Suspense>
    </div>
  );
}

Client Component: 장바구니 버튼

// app/products/[id]/AddToCartButton.tsx
'use client';

import { useState } from 'react';

interface Props {
  productId: string;
  price: number;
}

export function AddToCartButton({ productId, price }: Props) {
  const [quantity, setQuantity] = useState(1);
  const [isLoading, setIsLoading] = useState(false);

  const handleAddToCart = async () => {
    setIsLoading(true);
    try {
      await fetch('/api/cart', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ productId, quantity }),
      });
      alert('장바구니에 추가되었습니다!');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-4">
        <button 
          onClick={() => setQuantity(q => Math.max(1, q - 1))}
          className="px-3 py-1 border rounded"
        >
          -
        </button>
        <span>{quantity}</span>
        <button 
          onClick={() => setQuantity(q => q + 1)}
          className="px-3 py-1 border rounded"
        >
          +
        </button>
      </div>
      
      <p className="text-xl font-bold">
        {(price * quantity).toLocaleString()}원
      </p>
      
      <button
        onClick={handleAddToCart}
        disabled={isLoading}
        className="w-full py-3 bg-blue-600 text-white rounded-lg"
      >
        {isLoading ? '추가 중...' : '장바구니 담기'}
      </button>
    </div>
  );
}

구조 요약

ProductPage - Server (데이터 페칭)
ProductImage - Server (이미지 최적화)
ProductInfo - Server (정적 정보)
AddToCartButton - Client (상호작용)
ProductReviews - Server + Suspense

핵심 포인트

  • • 대부분의 컴포넌트는 Server Component로 유지
  • • 상호작용이 필요한 부분만 Client Component로 분리
  • • 느린 데이터는 Suspense로 스트리밍
  • • 번들 크기 최소화로 빠른 초기 로딩