[Next.js] 서버 / 클라이언트 컴포넌트

파이리·2023년 7월 27일
1

Next.js

목록 보기
2/18
post-thumbnail

서버 컴포넌트

서버 및 클라이언트 컴포넌트를 사용하면 개발자는 CSR 앱의 풍부한 상호작용과 SSR 앱의 향상된 성능을 결합하여 서버와 클라이언트를 아우르는 애플리케이션을 구축할 수 있습니다.

서버 컴포넌트에서 사고하기

React가 UI 구축에 대한 사고방식을 바꾼 것과 유사하게, React 서버 컴포넌트는 서버와 클라이언트를 활용하는 하이브리드 애플리케이션을 구축하기 위한 새로운 사고 모델을 도입합니다.

SPA의 경우처럼 전체 애플리케이션을 클라이언트에서 랜더링하는 대신, 컴포넌트의 목적에 따라 랜더링할 위치를 선택할 수 있는 유연성을 제공합니다.

왜 서버 컴포넌트인가?

서버 컴포넌트를 사용하면 개발자가 서버 인프라를 더 잘 활용할 수 있습니다. 예를 들어, 데이터 불러오기를 데이터 베이스에 더 가까운 서버로 옮길 수 있고, javaScript 번들 크기에 영향을 주던 대규모 종속성 패키지들을 서버에 유지하여 성능을 개선할 수도 있습니다. 서버 컴포넌트를 사용하면 React 애플리케이션을 작성하는 것이 아닌 PHP나 Ruby on Rails와 비슷하게 느껴질 수 있지만, React의 강력한 성능과 유연성, 컴포넌트 모델을 사용할 수 있습니다.

서버 컴포넌트를 사용하면 초기 페이지 로딩이 빨라지고 클라이언트 측 javaScript 번들 크기가 줄어듭니다. 기본 클라이언트 측 런타임은 캐싱이 가능하고 크기를 예측할 수 있으며 애플리케이션이 확장되고 증가하지 않습니다. 추가 javaScript는 클라이언트 컴포넌트를 통해 애플케이션에서 클라이언트 측 상호작용이 사용될 때만 추가됩니다.

Next.js로 route가 로드되면 초기 HTML이 서버에서 랜더링됩니다. 그런 다음 브라우저에서 이 HTML을 점진적으로 향상되고, 클라이언트에서 애플리리케이션을 가져오고 Next.js 및 React 클라이언트 측 런타임을 비동적으로 로드하여 상호 작용을 추가할 수 있습니다.

서버 컴포넌트르로 쉽게 전환할 수 있도록 특수 파일과 colocated 컴포넌트를 포함하여 App Router안에 있는 모든 컴포넌트들은 기본적으로 서버 컴포넌트입니다. 따라서 추가 작업 없이 자동으로 적용되어 뛰어난 성능을 얻을 수 있습니다. 또한, use client 지시문을 통해 클라언트 컴포넌트를 선택적으로 사용할 수 있습니다.


클라이언트 컴포넌트

클라이언트 컴포넌트를 사용하면 애플리케이션에 클라이언트 측 인터렉션을 추가할 수 있습니다. Next.js에서는 서버에서 pre-rendered 되고 클라이언트와 결합됩니다. 클라이언트 컴포넌트는 페이지 라우터의 컴포넌트 항상 작동하는 방식이라고 생각하면 됩니다.

use client 지시문

use client 지시문은 서버와 클라이언트 컴포넌트 모듈 그래프 사이의 경계를 선언하는 규칙입니다.

'use client'
 
import { useState } from 'react'
 
export default function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

use client는 서버 전용 코드와 클라이언트 코드 사이에 위치합니다. 파일 상단의 import 문 위에 위치하여 서버 전용에서 클라이언트 부분으로 경계를 넘나드는 컷오프 지점을 정의합니다. 파일에 use client가 정의되면 하위 컴포넌트를 포함하여 파일로 가져온 다른 모듈은 클라이언트 번들의 일부로 간주됩니다.

기본값은 서버 컴포넌트로 use client 지시어로 시작하는 모듈에서 정의되거나 가져오지 않는 모든 컴포넌트는 서버 컴포넌트 모둘 그래프의 부가 됩니다.


서버 컴포넌트와 클라이언트 컴포넌트는 언제 사용하나요?

서버 컴포넌트와 클라이언트 컴포넌트 중 어떤 것을 사용할지 결정을 단순화하기 위해 클라이언트 컴포넌트 사용에 대한 필요가 있을 때까지는 서버 컴포넌트를 사용하는 것이 좋습니다.

아래는 서버 컴포넌트와 클라이언트 컴포넌트를 사용하는 사례입니다.

서버 컴포넌트

  • 데이터 가져오기

  • 백엔드 리소스에 직접 접

  • 서버에 민감한 정보 보관 ( 엑세스 토큰, API 키 등 )

  • 서버에 대한 대규모 종속성 유지 / 클라이언트 측 자바스크립트 감소

클라이언트 컴포넌트

  • 사용 작용 및 이벤트 리스너 추가

  • 상태와 라이프 사이클 이펙트 사용 ( useState(), seEffect() )

  • 브라우저 전용 API 사용

  • state, effect 혹은 브라우저 전용 API에 의존하는 커스텀 훅 사용

  • React의 클래스 컴포넌트 사용


패턴

클라이언트 컴포넌트를 Leaves로 이동시키기

애플리케이션의 성능을 개선하려면 가능한 경우 클라이언트 컴포넌트를 컴포넌트 트리의 leaves로 이동하는 것이 좋습니다.

예를 들어 정적 요소가 있는 레이아웃과 상태가 있는 대화형 검색창이 있다고 가정합니다. 전체 레이아웃을 클라이언트 컴포넌트로 만드는 대신, 상호작용을 하는 로직을 클라이언트 컴포넌트로 이동하고 레이아웃을 서버 컴포넌트로 유지합니다. 즉, 레이아웃의 모든 컴포넌트를 클라이언트로 보낼 필요가 없습니다.

// SearchBar is a Client Component
import SearchBar from './searchbar'
// Logo is a Server Component
import Logo from './logo'
 
// Layout is a Server Component by default
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

클라이언트 컴포너트와 서버 컴포넌트 결합

서버 컴포넌트와 클라이언트 컴포넌트는 동일한 컴포넌트 트리에서 결합할 수 있습니다. 백그라운드에서 React는 다음과 같이 랜더링을 처리합니다.

서버에서 React는 결과를 클라이언트로 보내기 전에 모든 서버 컴포넌트를 랜더링합니다.

여기에는 클라이언트 컴포넌트 안에 중첩된 서버 컴포넌트도 포함됩니다.

이 단계에서는 클라이언트 컴포넌트 랜더링은 건너뜁니다.

클라이언트에서는 React는 클라이언트 컴포넌트를 랜더링하고 서버 컴포넌트의 랜더링 결가를 슬롯에 삽입하여 서버와 클라이언트에서 수행한 작업을 결합합니다.

만약 서버 컴포넌트가 클라이언트 컴포넌트 안에 중첩되어 잇는 경우, 랜더링된 콘텐츠는 클라이언트 컴포넌트 안에 올바르게 배치됩니다.

Next.js에서는 초기 페이지를 로드하는 동안 위 단계의 서버 컴포넌트와 클라이언트 컴포넌트의 랜더링 결과가 모두 서버에서 HTML로 미리 랜더링되어 초기 페이지 로딩 속도가 빨라집니다.


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

위에서 설명한 랜더링 흐름을 고려할 때, 서버 컴포넌트를 클라이언트 컴포넌트로 import하는 데는 제한이 있는데, 이 접근 방식은 추가적인 서버 왕복이 필요하기 때문입니다.

따라서 서버 컴포넌트를 클라이언트 컴포넌트로 import하는 것은 지원하지 않는 패턴입니다.

'use client'
 
// This pattern will **not** work!
// You cannot import a Server Component into a Client Component.
import ExampleServerComponent from './example-server-component'
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ExampleServerComponent />
    </>
  )
}

서버 컴포넌트를 클라이언트 컴포넌트에 props로 전달하는 대신 클라이언트 컴포넌트를 디자인할 때 React 프로퍼티를 사용하여 서버 컴포넌트의 slot을 표시할 수 있습니다.

일반적인 패턴은 React의 children 프로퍼티를 사용해 'slot'을 만드는 것입니다.

'use client'
 
import { useState } from 'react'
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      {children}
    </>
  )
}

이제 ExampleClientComponentchildren 요소가 무엇인지 전혀 알지 못합니다. 사실, 이 컴포넌트 관점에서는 자식들이 서버 컴포넌트로 채워질 것이라는 사실조차 알지 못합니다.

ExampleClientComponent가 할 수 있는 유일한 일은 자식을 어디에 배치할지 결정하는 것입니다.

이제 부모 컴포넌트에서 ExampleClientComponentExampleServerComponent을 임포트하고 ExampleClientComponent 의 자식요소로 ExampleServerComponent을 전달할 수 있습니다.

// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ExampleClientComponent from './example-client-component'
import ExampleServerComponent from './example-server-component'
 
// Pages in Next.js are Server Components by default
export default function Page() {
  return (
    <ExampleClientComponent>
      <ExampleServerComponent />
    </ExampleClientComponent>
  )
}

이 접근 방식을 이용하면 클라이언트 컴포넌트와 서버 컴포넌트의 랜더링이 분리되어 독립적으로 랜더링될 수 있으며, 클라이언트 컴포넌트보다 먼저 서버에서 랜더링되는 서버 컴포넌트에 맞춰 조정됩니다.

  • 이 패턴은 children 프로퍼티가 있는 layoutpage에 이미 적용되어 있으므로 추가 래퍼 컴포넌트를 만들 필요가 없습니다.

  • 이 전략은 props를 받는 컴포넌트가 props가 무엇이지 알지 못하기 때문에 서버 및 클라이언트 컴포넌트에서 작동합니다. 전달받은 props를 어디에 배치할지 만 결정할 수 있습니다.

    이 전략을 이용하면 props가 컴포넌트가 클라이언트에서 랜더링되기 훨씬 저에 서버에서 독립적으로 랜더링할 수 있습니다.

    가져온 중첩된 자식 컴포넌트를 다시 랜더링하는 부모 컴포넌트의 상태 변경을 피하기 위해 'lifting content up' 전략과 동일한 전략이 사용되었습니다.


서버 컴포넌트에서 클라이언트 컴포넌트로 props 전달하기

서버 컴포넌트에서 클라이언트 컴포넌트로 전달되는 프로퍼티는 직렬화가 가능해야 합니다. 즉, 함수, 날짜 등과 같은 값은 클라이언트 컴포넌트에 직접 전달할 수 없습니다.

서버 전용 코드를 클라이언트 컴포넌트에서 제외하기

자비스크립트 모듈은 서버와 클라이언트 컴포넌트 모두에서 공유할 수 있기 때문에 서버에서만 실행되어야 하는 코드가 클라이언트에 침투할 가능성이 있습니다.

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

위 코드는 서버와 클라이언트 모두 동작이 가능할 것처럼 보입니다. 그러나 환경 변수 API_KEY는 서버에서만 접근할 수 있는 비공개 변수입니다. Next.js 는 보안 정보 유출을 막기 위해 클라이언트에서 비공개 환경 변수를 빈 문자열로 대체합니다

따라서 위 코드는 클라이언트에서 실행할 수 있지만 예상대로 동작하지 않습니다. 이는 위 코드는 의도적으로 서버에서만 예상한 대로 동작하도록 작성된 것입니다.


'Sever Only' 패키지

의도치 않게 서버 코드를 클라이언트에서 사용을 금지하기 위해 'sever-only' 패키지를 사용하면 다른 개발자가 서버에서 실행되는 모듈 중 하나를 클라이언트 컴포넌트로 임포트하는 경우 빌드 타임에 오류를 발생 시킬 수 있습니다.

import 'server-only'
 
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

이제 위 모듈을 임포트하는 클라이언트 컴포넌트는 위 모듈이 서버에서만 사용할 수 있다는 빌트 타임 오류를 발생하게 됩니다.

해당 패키지의 'client-only'를 사용하면 클라이언트 전용 코드로 만들 수 있습니다.


데이터 불러오기

클라이언트 컴포넌트에서 데이터를 가져올 수 있지만, 그래야만 할 특별한 이유가 없다면 서버 컴포넌트에서 데이터를 가져오는 것이 좋습니다. 데이터 가져오기를 서버로 옮기면 성능과 사용자 경험이 향상됩니다.


서드파티 패키지

서버 컴포넌트가 새로 추가되었기 때문에 환경 시스템의 서드파티 패키지는 이제 막 'use client' 지시문을 추가하기 시작해 useState, useEffect, createContext 등을 사용하는 컴포넌트에 추가하도록 했습니다.

현재 클라이언트 전용 기능을 사용하는 npm 패키지의 많은 컴포넌트에는 아직 이러한 지시어가 없습니다. 대신 이러한 서드파티 컴포넌트들은 'use client' 지시어가 있기 때문에 클라이언트 컴포넌트 내에서는 예상대로 동작합니다. 그러나 서버 컴포넌트 내에서는 동작하지 않습니다.

예를 들어 <Carousel /> 컴포넌트를 포함한 패키지를 설치했다고 가정해보겠습니다. 이 컴포넌트는 useState를 사용하지만 아직 'use client' 지시어가 없습니다.

그래도 클라이언트 컴포넌트 내에서는 예상대로 잘 동작합니다.

'use client'
 
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
 
export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false)
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>
 
      {/* Works, since Carousel is used within a Client Component */}
      {isOpen && <Carousel />}
    </div>
  )
}

그러나 서버 컴포넌트 내에서 직접 사용하려고 하면 오류가 표시됩니다.

import { Carousel } from 'acme-carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/* Error: `useState` can not be used within Server Components */}
      <Carousel />
    </div>
  )
}

이는 Next.js에서는 <Carousel /> 이 클라이언트 전용 기능을 사용하고 있다는 것을 알지 못하기 때문입니다. 이 문제를 해결하기 위해서는 클라이언트 전용 기능을 사용하는 서드파티 컴포넌트를 자체적으로 만든 클라이언트 컴포넌트로 래핑하면 됩니다.

'use client'
 
import { Carousel } from 'acme-carousel'
 
export default Carousel

이제 예상대로 <Carousel /> 컴포넌트를 사용할 수 있습니다.

import Carousel from './carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/*  Works, since Carousel is a Client Component */}
      <Carousel />
    </div>
  )
}

대부분의 서드파티 컴포넌트는 클라이언트 컴포넌트 내에서 사용할 가능성이 높기 때문에 이런 식으로 래핑할 필요가 없을 것으로 보입니다. 하지만 예외가 있는데, Provider 컴포넌트는 React 상태와 컨텍스트에 의존하며 일반적으로 애플리케이션의 루트에 위치하게 됩니다.


컨텍스트

대부분의 React 애플리케이션은 컨텍스트에 의존해 컴포넌트 간에 데이터를 공유하는데, 이는 cerateContext를 통해 직접적으로 또는 서버파티 라이브러리에서 가져온 provider 컴포넌트를 통해 간접적으로 이루어집니다.

Next.js 13에서 컨텍스트는 클라이언트 컴포넌트 내에서는 완벽하게 지원되지만, 서버 컴포넌트에서는 직접 생성하거나 소비할 수 없습니다. 그 이유는 서버 컴포넌트에는 React 상태가 없는데, 컨텍스트는 주로 일부 React 상태가 업데이트된 후 트리 깊숙한 곳에 있는 인터렉티브 컴포넌트를 다시 랜더링하는 데 사용되기 때문입니다.

서버 컴포넌트 간에 데이터를 공유하는 대안을 알아보기 전에 먼저, 클라이언트 컴포넌트 내에서 컨텍스트를 사용하는 방법을 살펴봅시다.

클라이언트 컴포넌트에서 컨텍스트 사용하기

모든 컨텍스트 API는 클라이언트 컴포넌트 내에서 완벽하게 지원합니다.

'use client'
 
import { createContext, useContext, useState } from 'react'
 
const SidebarContext = createContext()
 
export function Sidebar() {
  const [isOpen, setIsOpen] = useState()
 
  return (
    <SidebarContext.Provider value={{ isOpen }}>
      <SidebarNav />
    </SidebarContext.Provider>
  )
}
 
function SidebarNav() {
  let { isOpen } = useContext(SidebarContext)
 
  return (
    <div>
      <p>Home</p>
 
      {isOpen && <Subnav />}
    </div>
  )
}

그러나 컨텍스트 provider는 일반적으로 테마같은 전역에서 사용되는 데이터를 공유하기 위해 애플리케이션 루트 근처에 랜더링합니다. 컨텍스트는 서버 컴포넌트에서 지원되지 않으므로 애플리케이션 루트에서는 컨텍스트를 만들려하면 에러가 발생합니다.

import { createContext } from 'react'
 
//  createContext is not supported in Server Components
export const ThemeContext = createContext({})
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

이 문제를 해결하기 위해 클라이언트 컴포넌트 내에서 해당 provider를 생성하고 랜더링합니다.

'use client'
 
import { createContext } from 'react'
 
export const ThemeContext = createContext({})
 
export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

이제 서버 컴포넌트는 provider를 바로 랜더링할 수 있게되었습니다.

import ThemeProvider from './theme-provider'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

provider가 루트에서 랜더링되면 앱의 다론 모든 클라이언트 컴포넌트가 이 컨텍스트를 사용할 수 있습니다.

서버 컴포넌트에서 서드파티 컨텍스트 provider 랜더링하기

서드파티 npm 패키지에는 애플리케이션 루트 근처에 랜더링해야하는 provider가 포함되어 있는 경우가 많습니다. 이러한 공급자에 'use client' 지시어가 포함되어 있으면 서버 컴포넌트 내부에서 직접 랜더링할 수 있습니다. 그러나 아직까지는 서드파티 공급자가 이 지시문을 추가하지 않은 경우가 많습니다.

따라서 서드파티 provider를 랜더링하려하면 에러가 발생할 가능성이 높습니다.

import { ThemeProvider } from 'acme-theme'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {/*  Error: `createContext` can't be used in Server Components */}
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

이를 고치기 위해서 서드파티 provider를 자체 제작한 클라이언트 컴포넌트로 감싸야 합니다.

'use client'
 
import { ThemeProvider } from 'acme-theme'
import { AuthProvider } from 'acme-auth'
 
export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>{children}</AuthProvider>
    </ThemeProvider>
  )
}

이제 <Provider />를 임포트해서 랜더링할 수 있습니다.

import { Providers } from './providers'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

루트에서 랜더링된 provider를 사용하면 이러한 라이브러리의 모든 컴포넌트와 hook들이 클라이언트 컴포넌트 내에서 예상대로 작동합니다.

서버 컴포넌트 간에 데이터 공유하기

서버 컴포넌트는 상호작용이 발생하지 않아 React 상태를 읽지 않으므로 데이터를 공유하기 위해 React 컨텍스트가 필요하지 않습니다. 대신 여러 서버 컴포넌트가 엑세스해야 하는 공통 데이터에 네이티브 자바스크립트 패턴을 사용할 수 있습니다. 예를 들어 모듈을 사용해 여러 컴포넌트에서 데이터베이스 연결을 공유할 수 있습니다.

export const db = new DatabaseConnection()

import { db } from '@utils/database'
 
export async function UsersLayout() {
  let users = await db.query()
  // ...
}

import { db } from '@utils/database'
 
export async function DashboardPage() {
  let user = await db.query()
  // ...
}

위 예시에서 레이아웃과 패키지 모두 데이터베이스 쿼리를 수행해야 합니다. 각 컴포넌트는 모듈에서 데이터베이스에 대한 엑세스를 임포트하여 공유합니다. 이 자바스크립트 패턴을 'global sigletons'라고 합니다.

서버 컴포넌트 간에 데이터 가져오기 요청 공유

데이터를 가져올 때 페이지나 레이아웃과 일부 하위 컴포넌트 간에 가져오기 결과를 공유하고 싶을 수 있습니다. 이렇게 하면 컴포넌트 간에 불필요한 연결이 발생하고 컴포넌트간에 props를 주고받게 될 수 있습니다.

대신 데이터를 소비하는 컴포넌트와 함께 데이터 가져오기를 배치하는 것이 좋습니다. 가져오기 요청은 서버 컴포넌트에서 자동으로 중복 제거되므로 각 경로의 세그먼트는 중복 요청에 대한 걱정 없이 필요한 데이터를 정확히 요청할 수 있습니다. Next.js 가져오기 캐시에서 동일한 값을 읽습니다.

profile
프론트엔드 개발자

0개의 댓글