Next.js 의 Rendering에 관하여

이현섭·2023년 6월 12일
1

1. 서버 컴포넌트

  • 리액트 서버 컴포넌트를 사용하게 되면 서버에서 렌더링하고 선택적으로 캐시할 수 있는 UI를 작성할 수 있음.

  • Next.js에서는 렌더링 작업을 경로 세그먼트별로 더 분할하여 스트리밍 및 렌더링을 가능하게 하며, 3가지의 서버 렌더링 전략 (Static Rendering, Dynamic Rendering, Streaming) 이 존재.

    • Next.js는 각 경로 세그먼트 별로 렌더링 작업을 분할하여 처리.

    • 이로써 특정 세그먼트만 변경되었을 때 전체 페이지를 재 렌더링하지 않고, 변경된 세그먼트에 해당하는 부분만 효율적으로 업데이트 가능함.

    • 스트리밍 : 스트리밍은 데이터를 작은 조각들로 나누어 순차적으로 전송하는 것을 의미. 웹에서는 HTML, CSS, JS등의 컨텐츠를 클라이언트에게 조금씩 전송하여 빠르게 화면에 보여줄 수 있음.

    • Next.js는 렌더링된 결과를 스트리밍 형식으로 클라이언트에게 전송할 수 있음. 이를 통해 사용자는 페이지의 일부 내용이 먼저 로딩되는 것을 볼 수 있으며, 전체 페이지가 로딩되기를 기다리지 않아도 됨.

서버 렌더링의 장점

1. Data Fetching

서버 컴포넌트를 사용하면 데이터 가져오기를 데이터 소스에서 더 가까운 서버로 옮길 수 있음. 이렇게 하면 렌더링에 필요한 데이터를 가져오는데 걸리는 시간과 클라이어느가 요청해야 하는 양을 줄여 성능을 향상 시킬 수 있음.

일반적인 CSR에서는 클라이언트(브라우저)가 데이터를 필요로 할 때 API서버에 요청을 보내고, 이 요청은 다시 DB나 다른 데이터 소스로 전달됨. 그 후 데이터가 클라이언트로 다시 반환되어 렌더링됨.

하지만, 서버 컴포넌트를 사용하면 렌더링 작업이 서버에서 직접 처리되기 때문에 클라이언트가 별도로 데이터를 요청하고 기다릴 필요가 없음. 즉, 서버 컴포넌트는 데이터 소스와 "더 가까운" 위치에 있게 되어, 데이터 획득과 렌더링을 훨씬 빠르게 수행이 가능.

2. Security

서버 컴포넌트를 사용하면 토큰 및 API 키와 같은 민감한 데이터와 로직을 클라이언트에 노출할 위험 없이 서버에 보관할 수 있음.

3. Caching

서버에서 렌더링하면 결과를 캐시하여 후속 요청 및 사용자 전체에서 재사용할 수 있음.
이렇게 하면 각 요청에서 수행되는 렌더링 및 데이터 페칭 양을 줄여서 성능을 개선하고 비용절감 가능함.

4. Bundle Sizes

서버 컴포넌트를 사용하면 이전에는 클라이언트 자바스크립트 번들 크기에 영향을 미쳤던 대규모 종속성을 서버에 유지할 수 있음.
클라이언트가 서버 컴포넌트용 JavaScript를 다운로드하고 실행할 필요가 없으므로 인터넷이 느리거나 성능이 안좋은 기기를 사용하는 사용자에게는 유용함.

5. Initial Page Load and FCP(First Contentful Paint)

서버에서는 클라이언트가 페이지를 렌더링하는 데 필요한 JavaScript를 다운로드, 파싱, 실행할 때까지 기다릴 필요 없이 사용자가 즉시 페이지를 볼 수 있도록 HTML을 생성할 수 있음.

6. Search Engine Optimization and Social Network Shareability

렌더링된 HTML은 검색 엔진 봇이 페이지 색인을 생성하는데 사용하고 소셜 네트워크 봇이 페이지에 대한 소셜 카드 미리보기를 생성하는 데 사용할 수 있음.

7. Streaming

렌더링 작업을 청크로 분할하여 준비되는 대로 클라이언트로 스트리밍 할 수 있음.
이를 통해 사용자는 서버에서 전체 페이지가 렌더링될 때까지 기다릴 필요 없이 페이지의 일부를 먼저 볼 수 있음.

서버 컴포넌트는 어떻게 렌더링이 될까?

Next.js는 서버에서 렌더링 프로세스를 관리하고 조율하기위해 React의 API들을 사용함.
렌더링 작업은 개별 route segment와 Suspense 경계에 따라 청크로 나뉨.

각 청크들은 2가지의 단계로 렌더링됨.

  1. 리액트는 서버 컴포넌트를 React Server Component Payload (RSC Payload) 라는 특수 데이터 형식으로 렌더링

  2. Next.js는 RSC Payload 및 클라이언트 컴포넌트 자바스크립트 명령어를 사용해서 서버에서 HTML을 렌더링함.

이렇게 서버에서 처리된 후 클라이언트 에서는 다음과 같은 단계를 거침

  1. HTML은 non-interactive한 상태의 미리보기를 즉시 표시해줌 => 이는 초기 페이지 로드에만 사용

  2. RSC Payload는 클라이언트 및 서버 컴포넌트 트리를 조정하고, DOM을 업데이트 하는데 사용됨.

  3. 자바스크립트는 클라이언트 컴포넌트에 hydrate 처리되고 애플리케이션을 인터렉티브하게 만들어줌.

RSC (React Server Component) Payload 란?

렌더링된 리액트 서버 컴포넌트 트리의 압축된 바이너리 표현. 클라이언트에서 리액트가 브라우저의 DOM을 업데이트 하는데 사용됨. 그리고 이것은 다음과 같은 것들을 포함

  • 서버 컴포넌트의 렌더링 결과
  • 클라이언트 컴포넌트가 렌더링될 위치에 대한 placeholder와 해당 자바스크립트 파일에 대한 참조
  • 서버 컴포넌트에서 클라이언트 컴포넌트로 전달된 모든 프로퍼티

서버 렌더링의 3가지 전략

1. Static Rendering (Default)

정적 렌더링을 하면 빌드 시 또는 data revalidation 후 백그라운드에서 렌더링됨.
결과는 캐시되어서 CDN으로 푸시됨.
이 최적화를 통해 사용자와 서버 요청간에 렌더링 작업 결과를 공유할 수 있음.

정적 렌더링은 정적 블로그 게시물이나 제품 페이지와 같이 경로에 사용자에게 맞춤화되지 않고 빌드 시점에 알 수 있는 데이터가 있는 경우에 유용함.

2. Dynamic Rendering

동적 렌더링을 하면 요청 시마다 렌더링됨.

동적 렌더링은 사용자에게 맞춤화된 데이터가 있거나 쿠키 또는 URL의 검색 매개변수와 같이 요청 시점에만 알 수 있는 정보가 있는 경우에 유용함.

렌더링 중에 dynamic function 또는 캐시되지 않은 데이터 요청이 발견되면 Next.js는 전체 route를 동적으로 렌더링하도록 전환.

Dynamic Functions

dynamic function 은 사용자의 쿠키, 현재 요청 헤더 또는 URL의 검색 매개변수와 같이 요청 시점에만 알 수 있는 정보에 의존함. dynamic function 의 종류는 아래와 같음

  • cookies(), headers() : 이 함수들을 서버 컴포넌트에서 사용하면 동적 렌더링으로 선택됨.

  • useSearchParams() : 클라이언트 컴포넌트에서는 Static Rendering을 건너뛰고 대신 클라이언트에서 가장 가까운 부모 Suspense 경계 까지 모든 클라이언트 컴포넌트를 렌더링. useSearchParams() 를 사용하는 클라이언트 컴포넌트를 Suspense로 감싸는것이 좋음. 이렇게 하면 그 위에 있는 모든 클라이언트 컴포넌트가 정적으로 렌더링 될 수 있음.

// app/dashboard/search-bar.tsx

'use client'
 
import { useSearchParams } from 'next/navigation'
 
export default function SearchBar() {
  const searchParams = useSearchParams()
 
  const search = searchParams.get('search')
 
  // This will not be logged on the server when using static rendering
  console.log(search)
 
  return <>Search: {search}</>
}

// app/dashboard/page.tsx

import { Suspense } from 'react'
import SearchBar from './search-bar'
 
// This component passed as a fallback to the Suspense boundary
// will be rendered in place of the search bar in the initial HTML.
// When the value is available during React hydration the fallback
// will be replaced with the `<SearchBar>` component.
function SearchBarFallback() {
  return <>placeholder</>
}
 
export default function Page() {
  return (
    <>
      <nav>
        <Suspense fallback={<SearchBarFallback />}>
          <SearchBar />
        </Suspense>
      </nav>
      <h1>Dashboard</h1>
    </>
  )
}
  • searchParams : Pages prop을 사용하면 요청 시 페이지가 dynamic rendering으로 전환됨.

위와 같은 함수를 사용하면 요청 시 전체 경로가 dynamic route로 전환됨.

3. Streaming

스트리밍을 사용하면 요청 시점에 서버에서 경로가 렌더링됨.
청크로 분할되어 준비되는 대로 클라이언트로 스트리밍됨.
이를 통해 사용자는 페이지가 완전히 렌더링 되기 전에 미리 보기를 볼 수 있음.

스트리밍은 우선순위가 낮은 UI 또는 데이터 페칭이 느려서 전체 경로의 렌더링을 차단할 수 있는 UI에 유용함. ex : 제품 페이지의 리뷰

2. 클라이언트 컴포넌트

클라이언트 컴포넌트를 사용하면 요청 시점에 클라이언트에서 렌더링할 수 있는 Interactive한 UI를 작성할 수 있음.

Next.js에서는 서버 컴포넌트가 기본이므로 사용하려면 "use client" 옵션으로 해줘야함.

클라이언트 렌더링의 장점

  • Interactivity : 클라이언트 컴포넌트는 state, effects, 이벤트리스너를 사용할 수 있으므로 사용자에게 즉각적으로 피드백을 제공하고 UI를 업데이트 할 수 있음.

  • Browser APIs : 클라이언트 컴포넌트는 geoloaction이나 로컬 스토리지와 같은 브라우저 API에 접근할 수 있음.

클라이언트 컴포넌트는 어떻게 렌더링 될까?

Next.js에서 클라이언트 컴포넌트는 요청이 전체 페이지의 로드의 일부인지 ( 애플리케이션에 대한 초기 방문 또는 브라우저 새로 고침에 의해 트리거된 페이지 재로드 ) 또는 후속 탐색의 일부인지에 따라 다르게 렌더링

Full page load

초기 페이지 로드를 최적화하기 위해 Next.js는 React의 API를 사용하여 클라이언트 및 서버 컴포넌트 모두에 대해 서버에서 정적 HTML 미리보기를 렌더링함.
즉, 사용자가 애플리케이션에 처음 방문하면 클라이언트가 클라이언트 컴포넌트 자바스크립트 번들을 다운로드, 파싱, 실행할때까지 기다릴 필요없이 페이지의 컨텐츠를 즉시 볼 수 있음.

서버에서 일어나는 일

    1. React는 서버 컴포넌트를 클라이언트 컴포넌트에 대한 참조를 포함하는 RSC Payload라는 특수 데이터 포맷으로 렌더링.
    1. Next.js는 RSC Payload 및 클라이언트 컴포넌트 자바스크립트 명령어를 사용하여 서버에서 route에 대한 HTML을 렌더링

그리고나서 클라이언트에서는

    1. non-interactive한 HTML이 미리보기를 위해 즉시 표시됨.
    1. RSC Payload는 클라이언트 및 서버 컴포넌트 트리를 조정하고 DOM을 업데이트 하는데 사용됨.
    1. 자바스크립트는 클라이언트 컴포넌트에 hydrate 하고 인터렉티브하게 만듬.

Subsequent Navigations

subsequent navigations 에서는 클라이언트 컴포넌트는 서버에서 렌더링되는 HTML 없이 전적으로 클라이언트에서 렌더링됨.

이는 클라이언트 컴포넌트 자바스크립트 번들을 다운로드하고 파싱한다는 의미.
번들이 준비되면 React는 RSC 페이로드를 사용해서 클라이언트 및 서버 컴포넌트 트리를 조정하고 DOM을 업데이트 함.

클라이언트 컴포넌트를 사용하기 위해 "use client"를 사용해서 경계를 선언한 후 서버환경을 사용하고 싶을때가 있음.
ex) 클라이언트 번들 크기를 줄이거나, 서버에서 데이터를 가져오거나, 서버에서만 사용할수 있는 API를 사용할 때

=> Composition Pattern

Server and Client Composition Pattern

앱을 만들때, 어떤 것을 클라이언트 컴포넌트를 할지 어떤 것을 서버 컴포넌트를 할지 고려해야함.
아래는 Next.js 공식문서에서 권장하는 기준

서버 컴포넌트 패턴

클라이언트 측 렌더링을 선택하기 전에 데이터 페칭, DB 또는 백엔드 서비스 엑세스 등 서버에서 몇가지 작업을 수행해야 할 수 있음.

아래는 서버 컴포넌트로 작업할 때 흔히 볼 수 있는 패턴들임.

1. 컴포넌트 간 데이터 공유

서버에서 데이터를 가져올 때 여러 컴포넌트에서 데이터를 공유해야 하는 경우가 있을 수 있음.
예를 들면 동일한 데이터에 의존하는 레이아웃과 페이지.

서버에서 사용할 수 없는 React Context를 사용하거나 prop으로 데이터를 전달하는 대신, 동일한 데이터를 중복 요청할 걱정 없이 fetch 또는 React의 cache 함수를 사용하여 필요한 컴포넌트에서 동일한 데이터를 가져올 수 있음. React는 fetch를 확장하여 데이터 요청을 자동으로 메모화하여, fetch를 사용할 수 없을 때 캐시함수를 사용할 수 있기 때문.

요청 메모이제이션

React는 동일한 URL과 옵션을 가진 요청을 자동으로 메모화하도록 fetch API를 확장함.
즉, React 컴포넌트 트리의 여러 위치에서 동일한 데이터에 대한 fetch 함수를 한번만 실행하면서 호출할 수 있음.

async function getItem() {
  // The `fetch` function is automatically memoized and the result
  // is cached
  const res = await fetch('https://.../item/1')
  return res.json()
}
 
// This function is called twice, but only executed the first time
const item = await getItem() // cache MISS
 
// The second call could be anywhere in your route
const item = await getItem() // cache HIT

2. 서버 전용코드를 클라이언트 환경에 노출시키지 않기.

자바스크립트 모듈은 서버와 클라이언트 컴포넌트 모듈 모두에서 공유할 수 있으므로 서버에서만 실행되어야 하는 모듈일지라도 클라이언트로 들어갈 수도 있음.

아래와 같은 코드가 있다고 해보자

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_PUBLIC 이 붙지 않았기 때문에 서버에서만 엑세스할 수 있는 비공개 변수임.
환경 변수가 클라이언트에 유출되는 것을 방지하기 위해 Next.js는 비공개 환경 변수를 빈 문자열로 대체하게됨.

그 결과 클라이언트에서 getData라는 함수가 실행될지라도 환경 변수가 빈 문자열이기 때문에 예상대로 동작하지 않음.
환경변수를 공개로 설정한다면 제대로 작동이야 하겠지만 민감한 정보를 클라이언트에 노출하고 싶어하지 않을 수도있음.

위와 같은 의도치 않은 클라이언트에서의 서버 코드 사용을 방치하기 위해
server-only 패키지를 사용한다면 위와 같은 경우에 빌드 시 오류를 발생시키기 때문에 예방할 수 있음.

비슷하게 client-only 패키지도 존재함.

3. 서드파티 패키지와 Provider 사용

서버 컴포넌트는 새로운 React의 기능이기 때문에, 서드파티 패키지나 Provider들은 이제 막 useState, useEffect, createContext 와 같은 클라이언트 전용 기능을 사용하는 컴포넌트에 "use client" 옵션을 추가하기 시작함.

클라이언트 전용 기능을 사용하는 npm 패키지의 많은 컴포넌트에는 아직 위 옵션이 없는 경우가 흔함.
그래서 클라이언트 컴포넌트에서 패키지를 사용한다면 잘 작동하겠지만, 서버 컴포넌트에서는 에러가 발생함.

위와 같은 현상을 해결하기 위해 서드파티 컴포넌트를 자체 클라이언트 컴포넌트로 래핑하자


// app/carousel.tsx

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

위와 같이 하면 이제 서버컴포넌트에서도 해당 컴포넌트를 오류 없이 사용 가능.

대부분의 서드파티 컴포넌트는 클라이언트 컴포넌트 내에서 사용할 가능성이 높기 때문에 위와 같이 래핑할 필요가 없을 것으로 예상.
하지만 Provider는 React State 와 Context에 의존하여 일반적으로 애플리케이션의 루트에 필요함.

Context Providers

Context provider는 일반적으로 현재 테마와 같은 글로벌 관심사를 공유하기 위해 앱의 루트 근처에 렌더링됨.
서버 컴포넌트에서는 React Context가 지원되지 않기 때문에 앱의 루트에서 컨텍스트를 생성하려고 하면 오류 발생.

이를 해결하기 위해 컨텍스트를 생성하고 클라이언트 컴포넌트 내에서 해당 프로바이더를 렌더링.


// app/theme-provider.tsx

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

// app/layout.tsx

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

주의할점!

Provider를 트리에서 가능한 한 깊숙이 렌더링해야 함.
이렇게 하면 Next.js가 서버 컴포넌트의 정적 부분을 더 쉽게 최적화 할 수 있음.

클라이언트 컴포넌트

1. 클라이언트 컴포넌트를 트리 아래로 이동

클라이언트 자바스크립트 번들 크기를 줄이려면 클라이언트 컴포넌트를 컴포넌트 트리 아래로 이동하는 것이 좋음.

예를 들어, 정적 요소 (로고, 링크 등등) 가 있는 레이아웃과 state를 사용하고 interactive 한 검색 바가 있을 수 있음.

전체 레이아웃을 클라이언트 컴포넌트로 만드는 대신 interactive한 로직을 클라이언트 컴포넌트 로 이동하고 레이아웃을 서버 컴포넌트로 유지시켜야 함. 즉, 레이아웃의 모든 컴포넌트 자바스크립트를 클라이언트로 보낼 필요가 없음.

아래와 같이 작성하자


// app/layout.tsx

// 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>
    </>
  )
}

2. 서버에서 클라이언트 컴포넌트로 props 전달하기 => Serialization (직렬화)

서버 컴포넌트에서 데이터를 가져오는 경우, 클라이언트 컴포넌트로 데이터를 prop으로 전달하고 싶을 수 있음. 서버에서 클라이언트 컴포넌트로 전달되는 props는 React에서 직렬화 할 수 있어야 함.

클라이언트 컴포넌트가 직렬화할 수 없는 데이터에 의존하는 경우, 서드파티라이브러리를 사용하여 클라이언트에서 데이터를 가져오거나 라우트 핸들러를 통해 서버에서 데이터를 가져올 수 있음.

서버 컴포넌트와 클라이언트 컴포넌트의 교차 사용.

  • 요청과 응답의 라이프사이클 동안 코드는 서버에서 클라이언트로 이동함.
    클라이언트에서 서버의 데이터나 리소스에 접근해야하는 경우 앞뒤로 전환하는 것이 아니라 새 요청을 하게됨
    쉽게 말해, 한번 웹페이지가 클라이언트에서 로드되고 나면, 추가적인 서버의 자원이나 데이터가 필요할 때마다 새로운 요청을 서버에 보내야함. 코드는 서버와 클라이언트 사이를 왔다갔다 하지 않는다는 의미.

  • 서버에 새 요청이 들어오면 클라이언트 컴포넌트 안에 중첩된 컴포넌트를 포함한 모든 서버 컴포넌트가 먼저 렌더링됨. 렌더링된 결과 (RSC Payload) 에는 클라이언트 컴포넌트의 위치에 대한 참조가 포함됨.
    그 후 클라이언트에서 React는 RSC Payload를 사용해서 서버와 클라이언트 컴포넌트를 단일 트리로 조정함.

  • 클라이언트 컴포넌트는 서버 컴포넌트 이후에 렌더링되므로 서버 컴포넌트를 클라이언트 컴포넌트 모듈로 임포트 할 수 없음. ( 서버에 다시 요청해야하므로 )
    대신 서버 컴포넌트를 클라이언트 컴포넌트에 prop으로 전달할 수 있음.

1. 지원되지 않은 패턴 : 서버 컴포넌트를 클라이언트 컴포넌트에서 import


// app/client-component.tsx
 
'use client'
 
// You cannot import a Server Component into a Client Component.
import ServerComponent from './Server-Component'
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  )
}

2. 지원되는 패턴 : 서버 컴포넌트를 클라이언트 컴포넌트에 prop으로 전달

이 패턴은 React의 children prop을 사용해서 클라이언트 컴포넌트에 '슬롯'을 만드는것


// app/client-component.tsx

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

위와 같이 하게 되면 ClientComponent 가 할 수 있는 유일한 책임은 children이 최종적으로 배치될 위치를 결정하는 것뿐.

부모 서버 컴포넌트에서는 클라이언트컴포넌트와 서버컴포넌트 모두 import 하고 서버컴포넌트를 클라이언트컴포넌트의 자식으로 전달 가능.


// app/page.tsx

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

위와 같은 접근방식을 사용하면 클라이언트컴포넌트와 서버컴포넌트가 분리되어 독립적으로 렌더링될 수 있음. 이 경우 자식인 서버컴포넌트는 클라이언트컴포넌트가 렌더링되기 전에 서버에서 렌더링될 수 있음.

또한, 서버컴포넌트는 이미 서버에서 렌더링 되었기 때문에 클라이언트에서 다시 렌더링되지 않음.
클라이언트컴포넌트가 리렌더링되어도 서버컴포넌트 내의 내용은 이미 정적 HTML로 결정되었기 때문에 다시 렌더링되거나 변경되지 않음. 따라서 부모가 리렌더 되어도 자식이 리렌더 되지 않음.

profile
안녕하세요. 프론트엔드 개발자 이현섭입니다.

0개의 댓글