서버 컴포넌트(server component)와 클라이언트 컴포넌트(client component) 차이[nextjs 14 version]

김철준·2024년 9월 30일
1

Next.js 14의 App Router에서는 서버 컴포넌트가 도입되어 클라이언트 컴포넌트와 구분하여 사용할 수 있게 되었다.

그렇다면 서버 컴포넌트와 클라이언트 컴포넌트는 각각 무엇이며, 어떤 상황에서 사용하는 것이 적절할까?

이 글은 Next.js 14의 App Router에 대한 공식 문서를 바탕으로 작성되었다. 문서의 내용을 해석하고, 이해가 어렵거나 궁금한 부분에 대해 더 자세히 살펴보려고 한다.

서버 컴포넌트(server component)

서버 컴포넌트는 서버에서만 실행되는 React 컴포넌트입니다. 클라이언트(브라우저)로 전송되는 번들 크기를 줄이고, 초기 로딩 속도를 향상시키며, 보안 측면에서도 유리합니다. 서버 컴포넌트는 데이터 페칭, 인증, 비즈니스 로직 등 서버에서 처리해야 할 작업을 효율적으로 수행할 수 있습니다.

Next.js 14에서는 기본적으로 서버 컴포넌트(Server Components)를 사용합니다. 이를 통해 별도의 추가 설정 없이도 서버 사이드 렌더링(Server-Side Rendering, SSR)을 자동으로 구현할 수 있습니다.

// ServerComponent.jsx
import ClientComponent from './ClientComponent';

export default function ServerComponent() {
  return (
    <div>
      <h1>서버 컴포넌트</h1>
      <ClientComponent />
    </div>
  );
}

별도로 상단에 "use server"를 선언하지 않아도 기본적으로 서버 컴포넌트입니다.

React 서버 컴포넌트는 UI를 서버에서 렌더링하고 선택적으로 캐싱할 수 있도록 해줍니다. Next.js에서는 렌더링 작업을 경로 세그먼트(route segments)별로 나누어 스트리밍 및 부분 렌더링을 가능하게 합니다. 이를 위해 세 가지 다른 서버 렌더링 전략이 있습니다:

  • 정적 렌더링 (Static Rendering)
  • 동적 렌더링 (Dynamic Rendering)
  • 스트리밍 (Streaming)

서버 컴포넌트가 어떻게 동작하는지, 언제 사용해야 하는지, 그리고 각기 다른 서버 렌더링 전략에 대해 알아보자.

서버 렌더링의 장점 (Benefits of Server Rendering)

서버에서 렌더링 작업을 수행하는 것에는 여러 가지 장점이 있습니다. 그 중 몇 가지는 다음과 같습니다:

데이터 가져오기 (Data Fetching):
서버 컴포넌트를 사용하면 데이터를 가져오는 작업을 서버로 옮길 수 있습니다. 이는 데이터 소스에 더 가깝기 때문에 데이터를 가져오는 시간이 줄어들고, 클라이언트가 요청해야 할 횟수도 줄어들어 성능이 향상될 수 있습니다.

왜 성능이 향상될까?
클라이언트와 서버 간의 네트워크 통신은 일반적으로 인터넷을 통해 이루어지며, 클라이언트의 네트워크 상태나 인터넷 연결 속도에 영향을 받습니다. 반면, Next.js 서버와 백엔드 서버 간의 통신은 같은 데이터 센터 내에서 이루어지거나, 최소한 더 빠르고 안정적인 서버 간 네트워크(예: 내부 네트워크 또는 VPN)를 통해 이루어질 가능성이 큽니다.
이 경우 지연 시간이 훨씬 짧고, 네트워크 연결 품질이 더 나은 경우가 많기 때문에 성능이 더 좋습니다.

보안 (Security):
서버 컴포넌트를 사용하면 토큰이나 API 키 같은 민감한 데이터와 로직을 서버에 보관할 수 있으며, 이를 클라이언트에 노출시키지 않기 때문에 보안 위험이 줄어듭니다.

캐싱 (Caching):
서버에서 렌더링한 결과는 캐싱되어 이후 요청이나 여러 사용자에게 재사용될 수 있습니다. 이를 통해 성능이 향상되고, 각 요청마다 렌더링 및 데이터 가져오기 작업을 반복하지 않으므로 비용 절감에도 도움이 됩니다.

성능 (Performance):
서버 컴포넌트는 기본적인 성능 최적화 도구를 제공합니다. 예를 들어, 처음에 전체 앱이 클라이언트 컴포넌트로 구성된 경우, 상호작용이 필요 없는 UI 요소들을 서버 컴포넌트로 이동시키면 클라이언트 측에서 다운로드, 파싱, 실행해야 할 JavaScript 양이 줄어듭니다. 이는 느린 인터넷 환경이나 성능이 낮은 장치를 사용하는 사용자에게 특히 유리합니다.

초기 페이지 로드 및 첫 번째 의미 있는 페인트 (Initial Page Load and First Contentful Paint, FCP):
서버에서 HTML을 생성하면 사용자는 JavaScript가 다운로드, 파싱, 실행되기 전에 페이지를 즉시 볼 수 있습니다. 이를 통해 더 빠른 초기 페이지 로드를 제공할 수 있습니다.

Initial Page Load and First Contentful Paint, FCP)가 뭐지?

검색 엔진 최적화(SEO) 및 소셜 네트워크 공유 가능성:
서버에서 렌더링된 HTML은 검색 엔진 봇이 페이지를 인덱싱하는 데 사용할 수 있으며, 소셜 네트워크 봇이 페이지의 미리보기 카드를 생성하는 데도 활용될 수 있습니다.

스트리밍 (Streaming):
서버 컴포넌트를 사용하면 렌더링 작업을 여러 조각으로 나누어, 준비된 부분부터 클라이언트로 스트리밍할 수 있습니다. 이를 통해 사용자는 페이지의 일부를 더 빨리 볼 수 있으며, 서버에서 전체 페이지가 렌더링될 때까지 기다릴 필요가 없습니다.

서버 컴포넌트는 어떻게 렌더링되나요?

Next.js는 React의 API를 사용하여 서버 컴포넌트를 렌더링합니다. 이 렌더링 작업은 경로 세그먼트(route segments) Suspense Boundary(서스펜스 경계) 단위로 나누어져서 수행됩니다. 이는 성능을 최적화하기 위한 방식으로, 필요한 부분만을 분리해 병렬로 처리하거나 단계적으로 로딩할 수 있게 도와줍니다.

route segments와 suspense boundary가 뭐죠?

두 단계의 렌더링

서버에서 컴포넌트를 렌더링할 때, 두 가지 주요 단계를 거칩니다.

1. React가 서버 컴포넌트를 RSC Payload로 렌더링

React는 서버에서 컴포넌트를 렌더링할 때, 이 결과를 React Server Component Payload(RSC Payload)라는 특별한 데이터 형식으로 변환합니다. 이 페이로드는 클라이언트가 서버에서 렌더링된 컴포넌트를 이해하고, 브라우저의 DOM을 업데이트할 수 있도록 도와주는 데이터입니다.

2. Nextjs는 서버에서 HTML을 렌더하기위해 RSC Payload와 클라이언트 컴포넌트 자바스크립트 코드 지침(Client Component JavaScript instructions)을 사용합니다.

자바스크립트 코드 지침(Client Component JavaScript instructions)이 뭐죠?

RSC PAYLOAD란?

클라이언트에서의 처리 과정

서버에서 생성된 HTML과 RSC Payload는 클라이언트로 전송된 후 다음과 같은 과정을 거칩니다.

1. HTML을 이용한 빠른 미리보기

처음 페이지를 로드할 때, 서버에서 생성된 HTML이 먼저 브라우저에 전달되어 빠르게 비상호작용(non-interactive) 미리보기를 보여줍니다. 이로 인해 초기 로딩 속도가 빨라지지만, 이 시점에서는 아직 사용자가 페이지와 상호작용할 수 없습니다.

2. RSC Payload를 사용해 클라이언트와 서버 컴포넌트 트리 조정

이후 RSC Payload를 사용해 서버에서 렌더링된 컴포넌트 트리와 클라이언트에서 렌더링된 트리를 동기화하고 DOM을 업데이트합니다. 이 과정은 React가 자동으로 수행하여 클라이언트와 서버 컴포넌트 사이의 일관성을 유지합니다.

3. 자바스크립트 명령을 사용해 클라이언트 컴포넌트 활성화(하이드레이션)

마지막으로 자바스크립트 명령이 실행되어 클라이언트 컴포넌트들이 활성화되며, 페이지가 상호작용 가능한 상태(하이드레이션)로 변환됩니다. 하이드레이션은 서버에서 렌더링된 HTML을 클라이언트에서 React가 관리할 수 있도록 연결하는 과정입니다.

서버 렌더링 방법(Server Rendering Strategies)

서버 렌더링의 경우, 3가지 방법들이 있습니다.

  • 정적 렌더링(Static Rendering)
  • 동적 렌더링
  • 스트리밍

정적 렌더링(Static Rendering)

정적 렌더링은 페이지를 빌드 시점에 미리 생성하거나, 데이터 재검증(data revalidation) 후 백그라운드에서 렌더링하는 방식입니다. 생성된 페이지는 캐시에 저장되어 콘텐츠 전송 네트워크(Content Delivery Network)에 배포될 수 있습니다. 이를 통해 여러 사용자와 서버 요청 간에 렌더링 결과를 공유할 수 있습니다.

데이터 재검증(Data Revalidate)이란?

Content Delivery Network?

  • 빌드 타임 렌더링: 애플리케이션을 배포할 때 모든 정적 페이지가 미리 생성됩니다.
  • 백그라운드 재검증: 데이터가 변경될 경우, 백그라운드에서 페이지를 다시 렌더링하여 최신 데이터를 반영할 수 있습니다.
  • CDN 캐싱: 생성된 정적 페이지는 CDN에 캐시되어 전 세계 어디서나 빠르게 접근할 수 있습니다.
  • 공유 가능성: 동일한 렌더링 결과를 여러 사용자와 요청이 공유하므로 서버 부하가 감소합니다.

적용 사례:

  • 정적 콘텐츠: 사용자 개인화가 필요 없는 페이지, 예를 들어 블로그 게시물, 제품 소개 페이지, 마케팅 랜딩 페이지 등이 해당됩니다.
  • 변경이 적은 데이터: 데이터가 자주 변경되지 않아 빌드 시점에 미리 생성해도 문제가 없는 경우.

단점

빌드 시간 증가: 페이지 수가 많아지면 빌드 시간이 길어질 수 있습니다.
이에 대한 해결책으로 ISR로 특정 페이지에 대해서 지정하여 빌드하고 나머지 페이지는 사용자가 요청하면 전달하는 방식으로 해결할 수 있습니다.

동적 렌더링(Dynamic Rendering)

동적 렌더링은 각 사용자 요청 시점에 라우트를 렌더링하는 방식입니다. 이는 다음과 같은 경우에 유용합니다:

개인화된 데이터: 사용자마다 다르게 표시되어야 하는 데이터가 있을 때 (예: 사용자 맞춤 정보).
요청 시점에만 알 수 있는 정보: 쿠키, URL의 검색 매개변수 등 요청 시점에만 접근 가능한 정보가 있을 때.

캐시된 데이터와 함께 사용하는 동적 라우트(Dynamic Routes with Cached Data)

대부분의 웹사이트는 라우트가 완전히 정적(static) 또는 완전히 동적(dynamic)인 경우가 드뭅니다. 대신 스펙트럼상의 중간 형태를 가집니다. 예를 들어:

  • 캐시된 데이터: 주기적으로 재검증되는 제품 데이터와 같은 캐시된 데이터.
  • 캐시되지 않은 데이터: 개인화된 고객 데이터와 같이 실시간으로 필요한 데이터.

Next.js에서는 RSC Payload와 데이터가 별도로 캐시되기 때문에, 캐시된 데이터와 캐시되지 않은 데이터를 동시에 사용하는 동적 렌더링 라우트를 구현할 수 있습니다. 이를 통해 모든 데이터를 요청 시점에 가져오는 성능 부담 없이 동적 렌더링을 선택할 수 있습니다.

동적 렌더링으로 전환(Switching to Dynamic Rendering)

렌더링 과정에서 동적 함수(dynamic function)나 캐시되지 않은 데이터 요청이 발견되면, Next.js는 해당 라우트를 동적으로 렌더링으로 전환합니다. 다음 표는 동적 함수와 데이터 캐싱이 라우트의 정적(static) 또는 동적(dynamic) 렌더링에 미치는 영향을 요약한 것입니다:

데이터 캐싱동적 함수 사용 여부라우트 렌더링 방식
모두 캐시됨사용하지 않음정적 렌더링
일부 캐시됨사용함동적 렌더링
모두 캐시되지 않음사용함동적 렌더링
모두 캐시되지 않음사용하지 않음동적 렌더링

표에서 볼 수 있듯이, 라우트가 완전히 정적으로 렌더링되려면 모든 데이터가 캐시되어야 합니다. 그러나 Next.js는 일부 데이터는 캐시하고 일부는 캐시하지 않는 동적 렌더링 라우트를 지원합니다.

개발자 관점에서:

  • 정적 렌더링과 동적 렌더링 중 선택할 필요 없이, Next.js는 사용된 기능과 API에 따라 각 라우트에 가장 적합한 렌더링 전략을 자동으로 선택합니다.
  • 대신, 특정 데이터를 캐시하거나 재검증할 시점을 선택하고, UI의 일부를 스트리밍(streaming)할지 여부를 결정할 수 있습니다.

동적 함수(Dynamic Functions)

동적 함수는 요청 시점에만 알 수 있는 정보(예: 사용자 쿠키, 현재 요청 헤더, URL의 검색 매개변수 등)에 의존하는 함수입니다. Next.js에서 이러한 동적 API는 다음과 같습니다:

  • cookies()
  • headers()
  • unstable_noStore()
  • unstable_after()
  • searchParams prop

이러한 함수 중 하나라도 사용하면, 해당 라우트는 전체적으로 동적 렌더링으로 전환됩니다. 즉, 요청 시점에 모든 데이터를 가져와 렌더링하게 됩니다.

스트리밍(Streaming)

스트리밍의 동작 방식

  1. 라우트 세그먼트의 병렬화:
    라우트(route)의 여러 세그먼트가 병렬로 처리됩니다.
    각 세그먼트는 데이터 페칭(data fetching), 렌더링(rendering), 하이드레이션(hydration) 등의 단계를 거칩니다.
    이러한 작업들이 개별 청크로 나누어져 클라이언트로 스트리밍됩니다.

  2. 부분 렌더링 및 로딩 UI:
    클라이언트 측에서는 페이지의 일부가 렌더링되는 동시에, 스트리밍 중인 청크에 대해 로딩 UI가 표시됩니다.
    이는 사용자가 페이지의 일부를 빠르게 확인할 수 있게 하여, 전반적인 사용자 경험을 향상시킵니다.

스트리밍의 장점

  • 초기 로딩 성능 향상:
    전체 페이지가 모두 렌더링되기를 기다릴 필요 없이, 준비된 부분부터 즉시 표시되므로 초기 로딩 속도가 빨라집니다.

  • 느린 데이터 페칭에 대한 대응:
    전체 라우트의 렌더링을 차단할 수 있는 느린 데이터 페칭 작업이 있을 경우, 해당 부분만 로딩 UI로 대체하여 전체 페이지의 렌더링을 지연시키지 않습니다.
    예를 들어, 제품 페이지의 리뷰 섹션처럼 데이터 로딩이 상대적으로 느린 부분을 별도로 처리할 수 있습니다.

구현 방법

  • Next.js App Router 기본 내장:
    스트리밍 기능은 Next.js의 App Router에 기본적으로 내장되어 있습니다. 별도의 설정 없이도 스트리밍을 활용할 수 있습니다.

  • loading.js와 React Suspense 사용:
    loading.js 파일을 사용하여 특정 라우트 세그먼트의 로딩 상태를 정의할 수 있습니다.
    React의 Suspense 컴포넌트를 활용하여 비동기적으로 로딩되는 UI를 관리할 수 있습니다.
    이를 통해 개발자는 스트리밍 중인 청크에 대해 사용자 정의 로딩 UI를 쉽게 구현할 수 있습니다.

  • 예시
    제품 페이지의 리뷰 섹션:
    제품 상세 정보를 먼저 렌더링하여 사용자가 즉시 제품 정보를 확인할 수 있도록 하고, 리뷰 섹션은 데이터 페칭이 완료되는 대로 스트리밍하여 표시합니다.
    이 경우, 리뷰 섹션이 로딩 중일 때는 로딩 스피너나 플레이스홀더 UI를 보여줄 수 있습니다.

// app/products/[id]/page.js

import React from 'react';
import Reviews from './reviews/page';

const fetchProduct = async (id) => {
  // 제품 데이터를 가져오는 API 호출 (예시)
  const res = await fetch(`https://api.example.com/products/${id}`);
  if (!res.ok) {
    throw new Error('Failed to fetch product');
  }
  return res.json();
};

export default async function ProductPage({ params }) {
  const { id } = params;
  const product = await fetchProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* 리뷰 섹션을 포함 */}
      <React.Suspense fallback={<div>Loading reviews...</div>}>
        <Reviews productId={id} />
      </React.Suspense>
    </div>
  );
}

클라이언트 컴포넌트(client component)

클라이언트 컴포넌트는 서버에서 사전 렌더링(prerendered)된 인터랙티브한 사용자 인터페이스(UI)를 작성할 수 있게 해줍니다. 클라이언트 자바스크립트를 사용하여 브라우저에서 실행되며, 서버에서 렌더링된 후 클라이언트 측에서 동작합니다.

클라이언트 컴포넌트라고 하여 클라이언트 사이드에서 렌더링되는 것이 아닙니다!
클라이언트 컴포넌트로 선언한 UI에 대해서는 서버에서 렌더링해주고 UI의 인터렉티브 코드(onClick 등)이 클라이언트측으로 넘어갔을 때, hyration됩니다.

클라이언트 렌더링의 장점

클라이언트 렌더링을 사용하는 주요 장점은 다음과 같습니다:

  • 인터랙티비티(Interactivity): 클라이언트 컴포넌트는 상태(state), 효과(effects), 이벤트 리스너(event listeners)를 사용할 수 있어 사용자에게 즉각적인 피드백을 제공하고 UI를 업데이트할 수 있습니다.

  • 브라우저 API 접근: 클라이언트 컴포넌트는 지리 위치(geolocation)나 로컬 스토리지(localStorage)와 같은 브라우저 API에 접근할 수 있습니다.

Next.js에서 클라이언트 컴포넌트 사용 방법

Next.js에서 클라이언트 컴포넌트를 사용하려면, 파일 상단에 React의 "use client" 디렉티브를 추가합니다. 이 디렉티브는 서버 컴포넌트와 클라이언트 컴포넌트 간의 경계를 선언하는 역할을 합니다. 즉, "use client"를 선언한 파일과 그 파일이 가져오는 모든 모듈 및 자식 컴포넌트는 클라이언트 번들로 간주됩니다.

app/counter.tsx

'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" 디렉티브가 선언되어 있기 때문에, Counter 컴포넌트는 클라이언트 컴포넌트로 처리됩니다. 만약 "use client"가 선언되지 않은 상태에서 useState나 onClick과 같은 클라이언트 전용 API를 사용하면 오류가 발생합니다.

클라이언트 디렉티브와 네트워크 경계
Next.js에서는 여러 개의 "use client" 진입점을 정의할 수 있습니다. 이를 통해 애플리케이션을 여러 클라이언트 번들로 분할할 수 있습니다. 그러나 한 번 "use client" 경계를 정의하면, 그 아래의 모든 자식 컴포넌트와 가져온 모듈은 클라이언트 번들에 포함되므로, 모든 컴포넌트마다 "use client"를 선언할 필요는 없습니다.

클라이언트 컴포넌트의 렌더링 방식

Next.js에서 클라이언트 컴포넌트는 전체 페이지 로드(초기 방문 또는 브라우저 새로고침)에 대한 요청과 이후 네비게이션에 따라 다르게 렌더링됩니다.

전체 페이지 로드 시

서버 측:

React는 서버 컴포넌트를 React Server Component Payload(RSC Payload)라는 특수한 데이터 형식으로 렌더링하며, 클라이언트 컴포넌트에 대한 참조를 포함합니다.
Next.js는 RSC Payload와 클라이언트 컴포넌트의 자바스크립트 지침을 사용하여 서버에서 해당 경로의 HTML을 렌더링합니다.

클라이언트 측:

서버에서 렌더링된 HTML을 사용하여 빠른 비인터랙티브 초기 미리보기를 즉시 표시합니다.
RSC Payload를 사용하여 클라이언트 및 서버 컴포넌트 트리를 조정하고 DOM을 업데이트합니다.
자바스크립트 지침을 사용하여 클라이언트 컴포넌트를 하이드레이션(hydration)하여 UI를 인터랙티브하게 만듭니다.
하이드레이션이란?
하이드레이션은 정적 HTML에 이벤트 리스너를 연결하여 인터랙티브하게 만드는 과정입니다. 이는 hydrateRoot React API를 통해 백그라운드에서 수행됩니다.

이후 네비게이션 시

이후 페이지 이동에서는 클라이언트 컴포넌트가 완전히 클라이언트에서 렌더링됩니다. 서버에서 렌더링된 HTML을 사용하지 않고, 클라이언트 컴포넌트의 자바스크립트 번들을 다운로드하고 파싱한 후, RSC Payload를 사용하여 컴포넌트 트리를 조정하고 DOM을 업데이트합니다.

Question Point!

server component에서 서비스의 모든 API 통신을 하는 것이 옳은 방법일까?

nextjs 서버가 API 통신 역할(fetch data)까지 한다면 부담이 되는 것은 아닐까?

클라이언트가 페이지에 대한 요청을 하면 nextjs 서버는 해당 페이지에 대한 렌더링 작업도 해야하고 그 페이지에 백엔드에 API 요청하는 부분이 있다면 백엔드 서버로 API 요청 응답도 처리를 해야하는데 nextjs서버가 기존보다 부담이 있는것 아닐까하는 의구심이 들었다.

API가 통신이 적은 페이지라면 괜찮겠지만

  • 실시간 채팅
  • 지도 API 통신(확대 축소할 때마다 API 통신)
  • 무한 스크롤(스크롤 내릴때마다 API 통신)

API 통신이 빈번히 일어나는 경우, 사용자 접속량이 많아지면 Next.js 서버에 부담이 커지게 된다. Next.js 서버는 요청된 페이지를 렌더링하여 응답해야 할 뿐만 아니라, 클라이언트의 API 요청에도 대응해야 하기 때문이다. 여기서 API 요청이 단발적이라면 크게 문제가 없겠지만 초단위로 API 요청이 가게 된다면 서버는 분명 부담이 될 것이다.

따라서 위와 같이 빈번히 발생하는 API 유형의 경우에는 클라이언트 컴포넌트에서 통신하는 것이 적절한 방법이라고 생각한다. 다만 이때 통신할 때에도 서버 액션(server action)이나 라우터 핸들러(router handler)를 사용하지 않고 통신해야 한다. 서버 액션과 라우터 핸들러는 모두 Next.js 서버에 API 통신 역할을 위임하기 때문이다.

Fetching data on the client

'use client'
 
import { useState, useEffect } from 'react'
 
export function Posts() {
  const [posts, setPosts] = useState(null)
 
  useEffect(() => {
    async function fetchPosts() {
      let res = await fetch('https://api.vercel.app/blog')
      let data = await res.json()
      setPosts(data)
    }
    fetchPosts()
  }, [])
 
  if (!posts) return <div>Loading...</div>
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

First Contentful Paint (FCP)와 Initial Page Load

Initial Page Load (초기 페이지 로드)

초기 페이지 로드는 사용자가 URL을 요청한 시점부터 첫 번째 페이지가 완전히 로드되는 시점까지의 전체 과정을 의미합니다. 이 과정은 페이지에 포함된 HTML, CSS, JavaScript, 이미지, 글꼴 등 다양한 리소스를 다운로드하고 처리하는 시간이 포함됩니다.

Initial Page Load는 사용자가 페이지에 처음 방문할 때 얼마나 빠르게 모든 콘텐츠를 로드할 수 있는지에 영향을 미칩니다. 이 시간이 길어질수록 사용자는 페이지가 느리게 로드된다고 느끼며, 이로 인해 사용자 경험이 나빠질 수 있습니다.

Next.js에서는 정적 페이지 생성(Static Site Generation, SSG) 또는 서버 사이드 렌더링(SSR)을 통해 초기 페이지 로드 시간을 단축할 수 있습니다. 서버에서 미리 렌더링된 HTML을 제공하기 때문에 클라이언트 측에서 데이터를 기다릴 필요 없이 페이지의 기본 구조를 빠르게 볼 수 있습니다.

First Contentful Paint (FCP)

First Contentful Paint (FCP)는 사용자가 페이지를 요청한 이후 첫 번째 의미 있는 콘텐츠(텍스트, 이미지, 비디오 등)가 화면에 표시되는 시점을 의미합니다. 즉, 페이지가 로드되는 동안 사용자가 실제로 무언가를 시각적으로 인지할 수 있는 첫 번째 순간을 측정하는 성능 지표입니다.

FCP가 빠르다는 것은 페이지가 로드될 때, 처음으로 눈에 보이는 내용이 빠르게 나타났음을 의미합니다. 이는 사용자가 페이지가 제대로 로드되고 있다는 확신을 주는 중요한 시점입니다.
FCP를 지연시키는 요소로는 클라이언트 측에서 처리해야 할 많은 JavaScript 파일, 무거운 이미지 파일, 폰트 등의 리소스들이 있습니다. 이러한 리소스들이 모두 로드되기 전까지 페이지의 의미 있는 콘텐츠가 표시되지 않으면 FCP 시간이 느려집니다.

Next.js와 FCP 최적화

Next.js와 같은 프레임워크는 서버 사이드 렌더링(SSR) 또는 정적 사이트 생성(SSG)을 통해 FCP를 개선하는 데 큰 도움을 줍니다. 그 이유는 다음과 같습니다:

SSR(Server-Side Rendering):

서버에서 HTML을 미리 렌더링하여 클라이언트에 보내기 때문에, 사용자는 JavaScript가 로드되고 실행될 때까지 기다리지 않고도 페이지의 기본 콘텐츠를 빠르게 볼 수 있습니다. 즉, 서버에서 미리 렌더링된 HTML을 제공하므로, 클라이언트가 해당 HTML을 받아서 브라우저에 표시하는 데 걸리는 시간이 줄어듭니다. 이로 인해 FCP가 빠르게 이루어집니다.

SSG(Static Site Generation):

정적 페이지를 미리 생성하여 서버나 CDN에서 클라이언트로 제공하는 방식입니다. 페이지가 미리 생성되어 있으므로 API 요청이나 데이터 처리 없이 즉시 HTML을 제공할 수 있습니다. 이 방법 역시 FCP를 크게 단축시킵니다.

이미지 최적화:

Next.js는 이미지 최적화 기능을 제공하여, 브라우저에 맞는 크기로 이미지를 로드하거나 필요한 시점에 이미지를 불러오는 방식을 사용합니다. 이러한 최적화는 이미지로 인해 FCP가 느려지는 문제를 해결할 수 있습니다.

JavaScript 로드 최적화:

클라이언트가 필요하지 않은 JavaScript는 로드하지 않거나, 비동기적으로 로드하는 방식으로 FCP를 개선할 수 있습니다. Next.js는 자동으로 필요한 JavaScript만 로드하도록 최적화할 수 있습니다.

FCP와 사용자 경험

FCP가 빠를수록 사용자는 페이지가 즉시 반응하는 것처럼 느끼게 되며, 이를 통해 긍정적인 사용자 경험을 제공합니다.

반면, FCP가 느리면 페이지가 "로딩 중"이라는 인상을 주며, 사용자는 페이지가 제대로 동작하지 않는다고 느낄 수 있습니다. 특히 모바일 사용자의 경우, 느린 네트워크 환경에서 FCP가 느려지는 것은 큰 문제로 다가올 수 있습니다.

정리

결론적으로, FCP와 초기 페이지 로드 시간을 최적화하는 것은 웹 성능에서 매우 중요한 요소입니다. 특히 Next.js의 서버 사이드 렌더링과 정적 페이지 생성을 사용하면 페이지가 빠르게 로드되고, 첫 번째 의미 있는 콘텐츠가 사용자에게 더 빨리 표시되어 긍정적인 사용자 경험을 제공할 수 있습니다. FCP가 빠르면 사용자에게 페이지가 빨리 로드되고 있다는 확신을 주며, 이는 이탈률을 줄이고 사이트의 성능 지표를 크게 개선하는 데 도움을 줍니다.

React Server Component Payload(RSC Payload)란?

RSC Payload는 서버에서 렌더링된 React 컴포넌트 트리의 압축된 이진 표현입니다. 클라이언트는 이 데이터를 사용하여 브라우저의 DOM을 업데이트합니다. RSC Payload는 다음과 같은 요소를 포함합니다:

서버 컴포넌트의 렌더링 결과

이 부분은 서버에서 렌더링된 React 컴포넌트의 최종적인 결과입니다.
예를 들어, 서버에서 데이터를 가져와 렌더링해야 할 부분들이 여기에 포함됩니다. 이는 일반적으로 HTML이나 JSON 같은 결과일 수 있으며, 서버 컴포넌트의 구조와 그 안에 포함된 데이터를 클라이언트로 전달하게 됩니다.

클라이언트 컴포넌트가 렌더링될 위치에 대한 자리표시자

클라이언트 컴포넌트가 렌더링되어야 할 위치와 그들이 참조하는 자바스크립트 파일들이 포함됩니다.

서버에서 렌더링된 결과에는 클라이언트에서 실행될 컴포넌트에 대한 정보가 포함되어야 합니다. 서버는 클라이언트 컴포넌트를 직접 렌더링할 수 없기 때문에, 클라이언트가 그 컴포넌트를 어디에서 실행해야 하는지에 대한 자리표시자(placeholder)를 남깁니다. 이 자리표시자는 클라이언트가 해당 위치에 클라이언트 컴포넌트를 로드하고 실행할 수 있도록 돕습니다.

예를 들어, 페이지의 일부가 버튼과 같이 클라이언트에서만 동작해야 한다면, 서버는 그 버튼을 직접 렌더링하지 않고, "여기에 버튼을 렌더링할 것"이라는 자리표시자를 남깁니다. 클라이언트는 이 정보를 바탕으로 나중에 해당 버튼을 로드하고 상호작용할 수 있게 합니다.

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

서버 컴포넌트가 클라이언트 컴포넌트와 함께 작동할 때, props가 전달될 수 있습니다. 이 props는 클라이언트 컴포넌트가 렌더링될 때 필요한 데이터입니다. 예를 들어, 서버에서 데이터를 가져와 컴포넌트에 넘겨주어야 하는 경우가 있는데, 이때 props로 해당 데이터가 클라이언트 컴포넌트에 전달됩니다. RSC Payload는 이러한 props를 포함하여, 클라이언트 컴포넌트가 올바르게 렌더링될 수 있도록 합니다.

Client Component JavaScript Instructions란?

"Client Component JavaScript instructions"는 서버에서 렌더링된 React Server Components와 클라이언트에서 실행되는 React Client Components 간의 상호 작용을 관리하기 위해 클라이언트 측에 전달되는 JavaScript 코드 지침을 의미합니다. 이 지침들은 클라이언트 측에서 다음과 같은 역할을 수행합니다:

Server Side:
+-------------------------+
| ServerComponent         |
| (Renders to HTML + RSC) |
+-----------+-------------+
            |
            v
+-------------------------+          +-------------------------------+
| RSC Payload             |          | Client Component JavaScript   |
| (HTML + Data)           |--------->| Instructions (JS Bundles)     |
+-------------------------+          +-------------------------------+

Client Side:
+-------------------------+
| Receives HTML + JS      |
| Renders Non-interactive |
| Preview                 |
+-----------+-------------+
            |
            v
| Executes JS Instructions to hydrate Client Components |
+------------------------------------------------------+

수화(Hydration):

  • 클라이언트 컴포넌트에 대한 서버에서 렌더링된 HTML을 클라이언트에서 인터랙티브하게 만들기 위해 필요한 JavaScript 코드를 실행합니다.
    이를 통해 사용자는 페이지가 로드된 후 즉시 상호작용할 수 있습니다.

컴포넌트 트리의 동기화:

  • 서버에서 생성된 React Server Components 트리와 클라이언트에서 동작하는 React Client Components 트리를 일치시키기 위해 사용됩니다.
    이는 React의 reconciliation 과정에서 이루어지며, 클라이언트와 서버 간의 상태를 동기화하여 일관된 사용자 경험을 제공합니다.

이벤트 핸들러 및 상태 관리:

  • 클라이언트 측에서 필요한 이벤트 핸들러(예: 클릭, 입력 등)를 등록하고 관리합니다.
    컴포넌트의 상태 변화를 처리하여 사용자와의 상호작용에 실시간으로 반응할 수 있게 합니다.

동적 기능 로딩:

  • 필요한 경우, 클라이언트 측에서 추가적인 JavaScript 코드를 동적으로 로드하여 특정 기능을 활성화합니다.
    이는 초기 로드 시간을 줄이고, 필요한 시점에만 추가적인 자원을 로드함으로써 성능을 최적화합니다.

전체 렌더링 과정에서의 역할

서버 측 렌더링:

서버에서는 React Server Components를 RSC Payload 형식으로 렌더링합니다.
이와 함께 Client Component JavaScript instructions도 생성되어 클라이언트로 전달됩니다.

클라이언트 측 렌더링:

클라이언트는 서버로부터 받은 HTML을 즉시 표시하여 빠른 비인터랙티브 프리뷰를 제공합니다.
동시에 전달된 JavaScript 지침을 실행하여 페이지를 인터랙티브하게 만듭니다.
React는 Server Components와 Client Components 트리를 재조정(reconcile)하여 최종적으로 완전한 인터랙티브 페이지를 구성합니다.

데이터 재검증(Data Revalidate)이란?

데이터 재검증은 정적 렌더링(Static Rendering) 방식에서 사용되는 개념으로, 정적으로 생성된 페이지의 데이터를 주기적으로 또는 특정 조건 하에서 업데이트하여 최신 상태를 유지하는 프로세스를 의미합니다. 이는 빌드 시점에 생성된 정적 페이지가 시간이 지나면서 변경될 수 있는 데이터를 반영할 수 있도록 돕습니다.

Next.js에서의 데이터 재검증

Next.js에서는 Incremental Static Regeneration (ISR)이라는 기능을 통해 데이터 재검증을 구현합니다. ISR을 사용하면 정적 페이지를 생성한 후에도 특정 시간 간격마다 또는 데이터가 변경될 때마다 페이지를 재생성할 수 있습니다. 이를 통해 정적 페이지의 성능 이점을 유지하면서도 최신 데이터를 반영할 수 있습니다.

빌드 시점 생성과 재생성:

페이지는 처음 빌드할 때 정적으로 생성됩니다.
설정된 재검증 주기(예: revalidate: 60은 60초마다)를 기준으로, 해당 시간이 지나면 다음 요청 시 페이지가 백그라운드에서 재생성됩니다.

백그라운드 재생성:

페이지가 재검증되어야 할 때, 현재 요청은 기존의 정적 페이지를 제공하고, 동시에 백그라운드에서 페이지를 재생성합니다.
재생성이 완료되면, 이후의 요청은 최신 버전의 페이지를 받게 됩니다.

CDN 캐싱과 호환:

정적으로 생성된 페이지는 CDN에 캐시되어 빠르게 제공됩니다.
재검증을 통해 최신 데이터가 반영된 페이지가 CDN에 업데이트됩니다.

적용 예시:

  • 블로그 사이트: 블로그 게시물이 자주 업데이트되지 않지만, 새로운 게시물이 추가될 때마다 최신 상태를 유지하고 싶을 때.
  • 제품 페이지: 제품 정보가 주기적으로 업데이트되지만, 모든 변경 사항이 실시간일 필요는 없을 때.
  • 마케팅 페이지: 캠페인 정보나 프로모션 내용이 주기적으로 변경될 때.

CDN(Content Delivery Network)이란?

CDN은 콘텐츠 전송 네트워크(Content Delivery Network)의 약자로, 전 세계에 분산된 서버 네트워크를 통해 사용자에게 웹 콘텐츠(HTML, CSS, JavaScript, 이미지, 비디오 등)를 빠르고 효율적으로 전달하는 시스템입니다. CDN은 사용자의 지리적 위치와 가까운 서버에서 콘텐츠를 제공함으로써 로딩 속도를 향상시키고, 서버 부하를 분산시키며, 전반적인 사용자 경험을 개선합니다.

CDN의 주요 구성 요소 및 작동 원리

엣지 서버(Edge Servers):

전 세계 여러 지역에 분산 배치된 서버입니다.
사용자가 요청한 콘텐츠를 가장 가까운 엣지 서버에서 제공하여 지연 시간을 최소화합니다.

원본 서버(Origin Server):

원래의 콘텐츠가 호스팅되는 서버입니다.
엣지 서버가 요청한 콘텐츠를 캐싱하지 못할 경우, 원본 서버에서 콘텐츠를 가져옵니다.

캐싱(Cache):

자주 요청되는 콘텐츠를 엣지 서버에 저장하여 빠르게 제공할 수 있도록 합니다.
캐시된 콘텐츠은 TTL(Time To Live) 설정에 따라 일정 시간 동안 유효하게 유지됩니다.

라우팅(Routing):

사용자의 요청을 가장 적합한 엣지 서버로 라우팅하여 빠른 응답을 보장합니다.
DNS(Domain Name System) 기반 라우팅을 통해 사용자의 지리적 위치에 따라 최적의 서버로 연결됩니다.

CDN의 주요 기능 및 장점

빠른 콘텐츠 전달:

사용자의 지리적 위치와 가까운 엣지 서버에서 콘텐츠를 제공하므로 로딩 속도가 빨라집니다.
웹 페이지의 초기 로딩 시간과 전체 페이지 로딩 시간을 줄일 수 있습니다.

서버 부하 분산:

트래픽을 여러 엣지 서버에 분산시켜 원본 서버의 부하를 줄입니다.
대량의 트래픽이나 갑작스러운 트래픽 증가에도 안정적으로 대응할 수 있습니다.

높은 가용성 및 안정성:

여러 서버가 분산되어 있어 일부 서버에 문제가 생겨도 다른 서버가 콘텐츠를 제공할 수 있습니다.
DDoS(Distributed Denial of Service) 공격과 같은 위협에 대한 방어력을 강화합니다.

캐싱을 통한 효율성 향상:

자주 요청되는 콘텐츠를 캐싱하여 서버의 응답 시간을 줄이고, 대역폭 사용을 최적화합니다.
정적 콘텐츠뿐만 아니라, 특정 조건 하에서 동적 콘텐츠도 캐싱할 수 있습니다.

보안 강화:

SSL/TLS 암호화를 지원하여 데이터 전송 시 보안을 강화합니다.
웹 애플리케이션 방화벽(WAF)과 같은 보안 기능을 통합하여 악의적인 트래픽을 차단합니다.

Next.js와 CDN의 통합

Next.js와 같은 현대적인 웹 프레임워크는 CDN과의 통합을 통해 애플리케이션의 성능을 극대화할 수 있습니다. 특히, Next.js의 정적 렌더링(Static Rendering)과 데이터 재검증(Data Revalidate) 기능을 활용하면 다음과 같은 이점을 누릴 수 있습니다:

정적 페이지의 CDN 캐싱:

빌드 시 생성된 정적 페이지는 CDN에 캐시되어 전 세계 어디서나 빠르게 제공됩니다.
이는 사용자 경험을 향상시키고, 서버의 부하를 줄이는 데 기여합니다.

효율적인 업데이트:

데이터 재검증을 통해 백그라운드에서 페이지를 재생성하고, 최신 데이터를 CDN에 업데이트할 수 있습니다.
이를 통해 정적 페이지의 성능과 최신성 두 가지를 동시에 유지할 수 있습니다.

스케일링 및 성능 최적화:

CDN을 통해 대규모 트래픽을 효율적으로 처리할 수 있으며, 트래픽 급증 시에도 안정적인 서비스를 유지할 수 있습니다.

Next.js와 CDN의 관계

Next.js는 정적 렌더링을 통해 생성된 페이지를 CDN에 캐싱함으로써 전 세계 사용자에게 빠르게 전달할 수 있도록 설계되었습니다. 하지만 이 과정은 호스팅 환경에 따라 다르게 동작합니다.

  • Vercel과 같은 전용 호스팅 플랫폼: Vercel은 Next.js를 만든 회사로, Next.js 애플리케이션을 배포할 때 자동으로 CDN과 통합됩니다. 빌드 시 생성된 정적 파일은 Vercel의 CDN에 자동으로 푸시되어 전 세계적으로 빠르게 제공됩니다.

  • AWS EC2와 같은 일반 서버: EC2는 단순히 가상 서버 인스턴스를 제공할 뿐, 별도의 CDN 서비스를 자동으로 통합하지 않습니다. 따라서 Next.js가 생성한 정적 파일은 EC2 서버 자체에서 제공되며, CDN의 이점을 누리기 위해서는 별도로 설정해야 합니다.

server component에서 data fetching을 한다면 해당 UI들은 data fetching이후에 클라이언트로 전달이되는건가요?

맞습니다. Server Component에서 데이터 패칭(data fetching)을 수행하면 해당 UI는 데이터 패칭이 완료된 후 서버에서 렌더링되어 클라이언트로 전달됩니다.

// ServerComponent.jsx
import React from 'react';

async function fetchData() {
  const res = await fetch('https://api.example.com/data');
  return res.json();
}

export default async function ServerComponent() {
  const data = await fetchData();
  return (
    <div>
      <h1>서버에서 패칭된 데이터</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

위 서버 컴포넌트에서는 데이터 패칭을 하고 그에 대한 데이터를 보여주고 있습니다.
h1 태그 내용이 먼저 렌더링 되는 것이 아닌 pre 태그,즉 fetch한 데이터의 내용도 server에서 렌더링되어 브라우저에 보여집니다.

client component에 대한 UI도 결국에 server side에서 rendering 된다면 웬만한 컴포넌트들은 client component로 선언해서 사용하면 되는거 아닌가요?

인터렉티브하지 않은 UI에 대해서도 client component로 선언한다면 불필요한 JavaScript 번들 포함됩니다.

인터랙티브하지 않은 컴포넌트에도 JavaScript 번들이 포함되기 때문에, 클라이언트로 전송되는 JavaScript의 양이 증가합니다. 만약 서비스 규모가 커지고 그만큼 위처럼 선언한 클라이언트 컴포넌트가 방대하다면 JavaScript 번들이 많아지게 되고 이는 페이지 로딩 속도를 저하시킬 수 있습니다, 특히 대규모 애플리케이션에서 더욱 두드러집니다.

  • 성능 저하: 클라이언트에서 불필요한 JavaScript 처리가 추가되면, 렌더링 성능이 저하될 수 있습니다. 모바일 기기나 저사양 기기에서 더욱 큰 영향을 미칠 수 있습니다.
profile
FE DEVELOPER

0개의 댓글