전체 목록
Next.jsMedium#41

Next.js App Router에서 서버 컴포넌트와 클라이언트 컴포넌트의 차이는?

#Next.js#서버컴포넌트#App Router
힌트

'use client' 지시어와 각 컴포넌트에서 할 수 있는 작업을 생각해보세요.

정답 및 해설

Next.js App Router에서 서버 컴포넌트와 클라이언트 컴포넌트의 차이는?

Next.js 13부터 도입된 App Router는 React Server Components(RSC)를 기반으로 동작합니다. 서버 컴포넌트와 클라이언트 컴포넌트는 실행 환경이 다르며, 각각의 특성에 맞게 적절히 선택해야 합니다. App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트이며, 클라이언트 컴포넌트는 명시적으로 선언해야 합니다.

서버 컴포넌트 (Server Component)

서버 컴포넌트는 서버에서만 렌더링되며, 그 결과(HTML)가 클라이언트로 전송됩니다.

특징

  • 기본값: App Router에서 별도 선언 없이 작성하면 서버 컴포넌트
  • 서버 환경에서만 실행: Node.js 런타임에서 동작
  • 브라우저 API 사용 불가: window, document, localStorage 등 접근 불가
  • React Hooks 사용 불가: useState, useEffect, useRef 등 사용 불가
  • 이벤트 핸들러 사용 불가: onClick, onChange 등 사용 불가
  • 번들 크기에 미포함: 서버 컴포넌트 코드는 클라이언트 JS 번들에 포함되지 않음
  • 직접 데이터 접근 가능: DB, 파일 시스템, 환경변수 등에 직접 접근 가능

서버 컴포넌트 예시

// app/posts/page.tsx - 서버 컴포넌트 (기본값)
import { db } from '@/lib/database'

// async 함수 사용 가능
async function PostsPage() {
  // 직접 DB 접근 가능 (서버에서만 실행되므로 안전)
  const posts = await db.query('SELECT * FROM posts ORDER BY created_at DESC')

  return (
    <div>
      <h1>게시글 목록</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  )
}

export default PostsPage
// app/components/UserProfile.tsx - 서버 컴포넌트
import { cookies } from 'next/headers'
import { getUserFromDB } from '@/lib/auth'

async function UserProfile() {
  // 서버 전용 API 사용 가능
  const cookieStore = cookies()
  const token = cookieStore.get('auth-token')

  const user = await getUserFromDB(token?.value)

  // 민감한 정보를 서버에서 처리 (클라이언트로 노출 안 됨)
  const sensitiveData = process.env.INTERNAL_API_KEY

  return (
    <div>
      <h2>{user?.name}</h2>
      <p>{user?.email}</p>
    </div>
  )
}

클라이언트 컴포넌트 (Client Component)

클라이언트 컴포넌트는 브라우저에서 실행되며, 상호작용(인터랙션)이 필요한 UI에 사용됩니다.

특징

  • 'use client' 지시어 필요: 파일 최상단에 선언
  • 브라우저에서 실행: 클라이언트 JS 번들에 포함됨
  • React Hooks 사용 가능: useState, useEffect, useContext 등 모두 사용 가능
  • 브라우저 API 사용 가능: window, document, localStorage 등 접근 가능
  • 이벤트 핸들러 사용 가능: 사용자 상호작용 처리 가능
  • 서버 전용 API 사용 불가: fs, db 직접 접근 불가
  • SSR도 가능: 초기 렌더링은 서버에서도 실행됨 (Hydration)

클라이언트 컴포넌트 예시

// app/components/Counter.tsx
'use client'

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
      <button onClick={() => setCount(count - 1)}>감소</button>
    </div>
  )
}

export default Counter
// app/components/SearchBar.tsx
'use client'

import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'

function SearchBar() {
  const [query, setQuery] = useState('')
  const [suggestions, setSuggestions] = useState<string[]>([])
  const router = useRouter()

  useEffect(() => {
    if (query.length > 2) {
      // 브라우저에서 API 호출
      fetch(`/api/search/suggestions?q=${query}`)
        .then((res) => res.json())
        .then((data) => setSuggestions(data))
    }
  }, [query])

  const handleSearch = () => {
    router.push(`/search?q=${encodeURIComponent(query)}`)
  }

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색어를 입력하세요"
      />
      <button onClick={handleSearch}>검색</button>
      {suggestions.map((s) => (
        <div key={s}>{s}</div>
      ))}
    </div>
  )
}

서버/클라이언트 컴포넌트 조합 패턴

실제 앱에서는 두 컴포넌트를 조합하여 사용합니다.

서버 컴포넌트 안에 클라이언트 컴포넌트 포함

// app/dashboard/page.tsx - 서버 컴포넌트
import { fetchDashboardData } from '@/lib/data'
import InteractiveChart from '@/components/InteractiveChart' // 클라이언트 컴포넌트
import StatCard from '@/components/StatCard' // 서버 컴포넌트

async function DashboardPage() {
  // 서버에서 데이터 페칭
  const data = await fetchDashboardData()

  return (
    <div>
      <h1>대시보드</h1>
      {/* 정적인 통계는 서버 컴포넌트로 */}
      <StatCard title="총 사용자" value={data.userCount} />
      {/* 인터랙티브 차트는 클라이언트 컴포넌트로 */}
      <InteractiveChart data={data.chartData} />
    </div>
  )
}

Props를 통한 데이터 전달

// 서버 컴포넌트에서 클라이언트 컴포넌트로 데이터 전달
// app/products/page.tsx (서버)
import ProductFilter from '@/components/ProductFilter' // 클라이언트

async function ProductsPage() {
  const categories = await db.getCategories()

  // 직렬화 가능한 데이터만 props로 전달 가능
  // 함수, Date 객체, 클래스 인스턴스는 직렬화 불가
  return <ProductFilter categories={categories} />
}

children 패턴으로 서버 컴포넌트를 클라이언트 컴포넌트에 전달

// components/Modal.tsx - 클라이언트 컴포넌트
'use client'

import { useState } from 'react'

function Modal({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(true)}>열기</button>
      {isOpen && (
        <div className="modal">
          {children} {/* 서버 컴포넌트를 children으로 받을 수 있음 */}
          <button onClick={() => setIsOpen(false)}>닫기</button>
        </div>
      )}
    </>
  )
}

// app/page.tsx - 서버 컴포넌트
import Modal from '@/components/Modal'
import UserList from '@/components/UserList' // 서버 컴포넌트

async function Page() {
  return (
    <Modal>
      <UserList /> {/* 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 */}
    </Modal>
  )
}

렌더링 방식 비교

서버 컴포넌트 렌더링 흐름

1. 서버에서 컴포넌트 실행 (DB 접근, API 호출 등)
2. HTML 생성
3. 클라이언트로 HTML + RSC Payload 전송
4. 클라이언트에서 HTML 표시 (JS 없이도 즉시 표시)
5. 클라이언트 컴포넌트만 Hydration

클라이언트 컴포넌트 렌더링 흐름

1. 서버에서 초기 HTML 생성 (SSR - Hydration을 위해)
2. 클라이언트로 HTML + JS 번들 전송
3. HTML 즉시 표시
4. JS 로드 후 Hydration (이벤트 핸들러 연결)
5. 이후 상태 변화는 클라이언트에서 처리

컴포넌트 선택 기준

서버 컴포넌트를 선택해야 하는 경우

  • 데이터베이스, 파일 시스템에 직접 접근할 때
  • 민감한 정보(API 키, 토큰 등)를 다룰 때
  • 큰 의존성을 가진 라이브러리를 사용할 때 (번들 크기 절약)
  • SEO가 중요한 정적 콘텐츠를 렌더링할 때
  • 사용자 상호작용이 없는 UI를 그릴 때

클라이언트 컴포넌트를 선택해야 하는 경우

  • useState, useReducer 등으로 상태 관리가 필요할 때
  • useEffect로 사이드 이펙트를 처리할 때
  • 브라우저 이벤트를 처리할 때 (onClick, onChange 등)
  • localStorage, sessionStorage 등 브라우저 API를 사용할 때
  • 실시간 업데이트가 필요한 UI를 구현할 때
  • 써드파티 클라이언트 라이브러리를 사용할 때

주의사항

클라이언트 컴포넌트에서 서버 컴포넌트 import 불가

// ❌ 잘못된 사용 - 클라이언트 컴포넌트에서 서버 컴포넌트 직접 import
'use client'
import ServerComponent from './ServerComponent' // 이렇게 하면 ServerComponent도 클라이언트 번들에 포함됨

// ✅ 올바른 사용 - children props 패턴 활용
'use client'
function ClientComponent({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}

직렬화 가능한 데이터만 Props로 전달

// ❌ 잘못된 사용 - 함수는 직렬화 불가
async function ServerPage() {
  const handleClick = () => console.log('clicked')
  return <ClientComponent onClick={handleClick} /> // 에러 발생
}

// ✅ 올바른 사용 - 직렬화 가능한 데이터만 전달
async function ServerPage() {
  const data = await fetchData()
  return <ClientComponent data={data} /> // 문자열, 숫자, 배열, 객체 등 가능
}

정리 표

구분서버 컴포넌트클라이언트 컴포넌트
선언 방식기본값 (별도 선언 없음)'use client' 지시어 필요
실행 환경서버 (Node.js)브라우저 (+ SSR 시 서버)
React Hooks사용 불가사용 가능
이벤트 핸들러사용 불가사용 가능
브라우저 API사용 불가사용 가능
DB/파일 접근가능불가
번들 크기 영향없음있음
환경변수 접근모든 변수NEXT_PUBLIC_ 접두사만
async/await컴포넌트 레벨에서 가능컴포넌트 레벨에서 불가
주요 사용처데이터 페칭, 정적 UI인터랙티브 UI, 상태 관리