React Server Component

작성일: 2025. 10. 09최종 수정: 2025. 10. 11. 01시 39분

React Server Component란

React Server Component(RSC)는 서버에서 렌더링되는 React 컴포넌트다. Next.js App Router는 기본적으로 모든 컴포넌트를 Server Component로 취급한다.

기존 React 앱은 모든 컴포넌트가 브라우저에서 실행되었다. Server Component는 서버에서 렌더링을 완료한 뒤 결과만 클라이언트로 보낸다. 이를 통해 JavaScript 번들 크기를 줄이고 초기 로딩 속도를 개선할 수 있다.

왜 필요한가

기존 React 앱에서는 데이터를 가져오기 위해 다음과 같은 과정을 거친다.

  1. 브라우저가 빈 HTML을 받는다
  2. JavaScript 번들을 다운로드하고 실행한다
  3. 컴포넌트가 마운트되면 useEffect로 API를 호출한다
  4. 데이터를 받아서 화면을 업데이트한다

이 과정에서 사용자는 로딩 상태를 보게 되고, JavaScript 번들 크기가 커질수록 초기 로딩이 느려진다.

Server Component는 이 문제를 해결한다. 서버에서 데이터를 가져오고 렌더링까지 완료한 뒤 결과만 보내므로, 브라우저는 즉시 컨텐츠를 표시할 수 있다.

Server Component의 장점

Server Component는 다음과 같은 장점을 제공한다.

  • 데이터 소스에 가까운 위치에서 데이터를 가져온다. 서버는 데이터베이스나 내부 API와 같은 네트워크에 있으므로 데이터 조회가 빠르다.
  • 클라이언트로 전송하는 JavaScript 크기를 줄인다. 서버에서만 실행되는 코드는 브라우저로 보내지 않는다.
  • 초기 페이지 로딩이 빠르다. HTML이 서버에서 생성되어 오므로 First Contentful Paint가 개선된다.
  • 보안이 강화된다. API 키나 토큰 같은 민감한 정보를 서버에만 두고 사용할 수 있다.

Server Component의 특징

Server Component에서는 다음과 같은 작업을 할 수 있다.

typescript
import { db } from '@/lib/database'

export default async function ProductList() {
  // 데이터베이스 직접 조회
  const products = await db.query('SELECT * FROM products')

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

위 코드에서 컴포넌트를 async 함수로 선언하고 데이터베이스를 직접 조회한다. 이 코드는 서버에서만 실행되므로 데이터베이스 연결 정보가 클라이언트에 노출되지 않는다.

Server Component는 컴포넌트 함수 자체를 async로 만들 수 있다. 별도의 데이터 페칭 함수 없이 컴포넌트 내부에서 바로 비동기 작업을 수행할 수 있다.

Server Component의 제약사항

Server Component는 서버에서만 실행되기 때문에 클라이언트 전용 기능을 사용할 수 없다. 다음과 같은 것들은 Server Component에서 사용할 수 없다.

  • 상태 관리 훅: useState, useReducer
  • 생명주기 훅: useEffect, useLayoutEffect
  • 이벤트 핸들러: onClick, onChange
  • 브라우저 전용 API: window, localStorage, document
  • 사용자 정의 훅: 위 기능들을 사용하는 커스텀 훅
  • React Context: useContext

예를 들어 Server Component에서 useEffect를 사용하려고 하면 다음과 같은 에러가 발생한다.

plain
Error: useEffect only works in Client Components. Add the "use client" directive at the top of the file to use it.

이런 기능이 필요하다면 해당 컴포넌트를 Client Component로 만들어야 한다. 파일 최상단에 'use client'를 추가하면 된다.

typescript
// 에러 발생
export default function ProductList() {
  useEffect(() => {
    console.log('마운트됨')
  }, [])

  return <div>상품 목록</div>
}
typescript
// ✓ 정상 작동
'use client'

export default function ProductList() {
  useEffect(() => {
    console.log('마운트됨')
  }, [])

  return <div>상품 목록</div>
}

Server Component는 HTML을 생성하는 데 집중하고, 인터랙션이 필요한 부분은 Client Component로 분리하는 것이 권장된다.

Client Component란

하지만 모든 것을 Server Component로 만들 수는 없다. 사용자와의 상호작용, 상태 관리, 브라우저 API 사용 등은 클라이언트에서만 가능하다.

Client Component는 브라우저에서 실행되는 React 컴포넌트다. 다음과 같은 기능을 사용할 때는 Client Component가 필요하다.

  • useState, useReducer 같은 상태 관리
  • useEffect, useLayoutEffect 같은 생명주기 훅
  • onClick, onChange 같은 이벤트 핸들러
  • window, localStorage 같은 브라우저 전용 API
  • 사용자 정의 훅

'use client' 디렉티브

Client Component를 만들려면 파일 최상단에 'use client' 디렉티브를 추가한다.

typescript
'use client'

import { useState } from 'react'

export default function LikeButton() {
  const [likes, setLikes] = useState(0)

  return (
    <button onClick={() => setLikes(likes + 1)}>
      좋아요 {likes}
    </button>
  )
}

'use client'는 서버와 클라이언트 코드 사이의 경계를 선언한다. 이 디렉티브가 있는 파일과 그 파일이 import하는 모든 모듈은 클라이언트 번들에 포함된다.

'use client'를 추가하지 않으면 기본적으로 Server Component로 동작한다. Next.js App Router는 Server Component를 기본값으로 사용한다.

언제 무엇을 사용할지

다음 표는 어떤 상황에 어떤 컴포넌트를 사용해야 하는지 정리한 것이다.

작업Server ComponentClient Component
데이터 가져오기
백엔드 리소스 직접 접근
민감한 정보를 서버에 보관
큰 의존성을 서버에 유지
상태 관리 (useState, useReducer)
생명주기 훅 (useEffect)
이벤트 핸들러
브라우저 전용 API
사용자 정의 훅

기본적으로 Server Component를 사용하고, 인터랙션이 필요한 부분만 Client Component로 분리하는 것이 권장된다.

Server Component와 Client Component 조합하기

Server Component와 Client Component는 함께 사용할 수 있다.

Server Component에서 Client Component로 데이터 전달하기

Server Component는 Client Component에 props로 데이터를 전달할 수 있다. 단, props는 직렬화 가능한 값이어야 한다.

typescript
// app/page.tsx (Server Component)
import LikeButton from './like-button'

export default async function PostPage({ params }) {
  const post = await getPost(params.id)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton initialLikes={post.likes} />
    </article>
  )
}
typescript
// app/like-button.tsx (Client Component)
'use client'

import { useState } from 'react'

export default function LikeButton({ initialLikes }) {
  const [likes, setLikes] = useState(initialLikes)

  return (
    <button onClick={() => setLikes(likes + 1)}>
      좋아요 {likes}
    </button>
  )
}

위 코드에서 Server Component인 PostPage는 서버에서 포스트 데이터를 가져온다. 그리고 초기 좋아요 수를 LikeButton에 전달한다. LikeButton은 Client Component이므로 브라우저에서 상태를 관리하며 사용자의 클릭에 반응한다.

Client Component 안에 Server Component 넣기

Client Component는 직접적으로 Server Component를 import할 수 없다. 하지만 children prop을 통해 Server Component를 받을 수 있다.

typescript
// app/modal.tsx (Client Component)
'use client'

export default function Modal({ children }) {
  return (
    <div className="modal">
      {children}
    </div>
  )
}
typescript
// app/page.tsx (Server Component)
import Modal from './modal'
import ServerContent from './server-content'

export default function Page() {
  return (
    <Modal>
      <ServerContent />
    </Modal>
  )
}

위 패턴에서 Modal은 Client Component지만 children으로 무엇을 받을지는 모른다. ServerContent는 Server Component로서 서버에서 렌더링된다. Modal은 단지 렌더링된 결과를 받아서 표시할 뿐이다.

이 패턴을 사용하면 Client Component와 Server Component를 독립적으로 렌더링할 수 있다. Client Component가 재렌더링되어도 Server Component는 서버에서 이미 렌더링되었기 때문에 다시 렌더링되지 않는다.

주의사항

Client Component는 양쪽에서 실행된다

Server Component는 서버에서만 실행되지만, Client Component는 서버와 클라이언트 양쪽에서 실행된다.

  • 서버: 초기 HTML 생성(사전 렌더링)을 위해 1번 실행
  • 클라이언트: 하이드레이션을 위해 1번 실행

따라서 Client Component에는 브라우저 전용 API(window, localStorage 등)를 바로 사용하면 안 된다. 서버에서 실행될 때 에러가 발생한다. useEffect 안에서 사용하거나 조건부로 체크해야 한다.

typescript
'use client'

export default function MyComponent() {
  // 에러: 서버에서 실행 시 window가 없음
  const width = window.innerWidth

  // useEffect는 클라이언트에서만 실행됨
  useEffect(() => {
    const width = window.innerWidth
  }, [])
}

Client Component에서 Server Component를 import할 수 없다

Client Component는 다른 Client Component만 직접 import할 수 있다. Server Component를 사용하려면 children이나 props로 받아야 한다.

typescript
// 불가능
'use client'
import ServerComponent from './server-component'

export default function ClientComponent() {
  return <ServerComponent />
}
typescript
// children으로 받기
'use client'

export default function ClientComponent({ children }) {
  return <div>{children}</div>
}

직렬화 가능한 값만 전달할 수 있다

Server Component에서 Client Component로 props를 전달할 때는 직렬화 가능한 값만 보낼 수 있다. 함수, 클래스 인스턴스, Symbol 같은 값은 전달할 수 없다.

typescript
// 불가능
<ClientComponent
  onClick={() => {}}  // 함수 전달 불가
  date={new Date()}   // Date 객체 전달 불가
/>

// 가능
<ClientComponent
  count={42}              // 숫자
  name="hello"            // 문자열
  data={{ id: 1 }}        // 객체
  items={[1, 2, 3]}       // 배열
  timestamp="2024-01-01"  // ISO 문자열
/>

이는 서버에서 컴포넌트를 렌더링한 결과를 JSON 형태로 클라이언트에 전송하기 때문이다. JSON으로 변환할 수 없는 값은 전달할 수 없다.

컴포넌트 계층 구조 설계

Client Component를 최대한 말단에 배치하는 것이 좋다. 상위에 Client Component가 있으면 그 하위의 모든 컴포넌트가 JavaScript 번들에 포함되어 용량이 커진다.

typescript
// 비효율적인 구조
'use client'

export default function Page() {
  const [open, setOpen] = useState(false)

  return (
    <div>
      <Header />        {/* JavaScript 번들에 포함 */}
      <Content />       {/* JavaScript 번들에 포함 */}
      <Footer />        {/* JavaScript 번들에 포함 */}
      <Modal open={open} setOpen={setOpen} />
    </div>
  )
}

위 코드는 Modal의 상태 관리 때문에 전체를 Client Component로 만들었다. 이렇게 하면 Header, Content, Footer 같은 정적 컴포넌트까지 모두 JavaScript 번들에 포함되어 파일 크기가 커진다.

typescript
// 효율적인 구조
export default function Page() {
  return (
    <div>
      <Header />        {/* Server Component */}
      <Content />       {/* Server Component */}
      <Footer />        {/* Server Component */}
      <ModalButton />   {/* Client Component */}
    </div>
  )
}
typescript
// modal-button.tsx
'use client'

export default function ModalButton() {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button onClick={() => setOpen(true)}>열기</button>
      <Modal open={open} setOpen={setOpen} />
    </>
  )
}

위 코드는 상태가 필요한 부분만 Client Component로 분리했다. Header, Content, Footer는 Server Component로 남아있어 JavaScript 번들에 포함되지 않는다. 이렇게 하면 번들 크기가 줄어들고 Time to Interactive(TTI)가 개선된다.

핵심은 인터랙션이 필요한 최소한의 부분만 Client Component로 만드는 것이다. 페이지 전체를 Client Component로 만들지 말고, 버튼이나 입력 폼처럼 실제로 상호작용이 필요한 부분만 분리한다.

참고 자료