리액트 서버 컴포넌트를 이해하고 적용하여 성능 개선하기

dev_dam·2024년 10월 28일
13
post-thumbnail

들어가며

지금 하고 있는 프로젝트가 Next.js 14 버전을 사용중이어서, 리액트 서버 컴포넌트(React Server Component, RSC)가 무엇이고 어떻게 성능을 개선할 수 있는지 궁금하여 스터디하고 적용하여 성능 개선한 내용을 기록해보았습니다.

이번 블로그는 리액트 서버 컴포넌트를 학습하여 실제로 코드에 적용해보면서 이해한 내용을 다루고 있기 띠문에 리액트 서버 컴포넌트의 자세한 내용은 아래 레퍼런스가 정말 잘 나와있으니 여기를 참고해주세요.

React Server Component 등장 배경

자세한 내용은 여기를 확인해주세요

리액트의 경우 대표적으로 3가지의 문제점이 있었습니다.

  • 레이아웃 시프트 (Layout Shift)
  • 네트워크 워터폴 (Network Waterfall)
  • 유지보수 및 성능

레이아웃 시프트 (Layout Shift)

레이아웃 시프트란, 컴포넌트가 렌더링될 때 레이아웃이 갑자기 이동되는 현상을 말합니다.

  • A 부모 컴포넌트
    • B 자식 컴포넌트 ( API 호출 )
    • C 자식 컴포넌트 ( API 호출 )

이러한 컴포넌트 구조에서 C 컴포넌트는 B 컴포넌트의 아래에 위치해 있어야합니다.

하지만 C 컴포넌트의 네트워크 호출이 더 빠르게 된다면 사용자 입장에서는 C컴포넌트가 나타났다가 이후 B컴포넌트의 네트워크 호출이 완료되어 B컴포넌트가 보여지며 C컴포넌트가 아래로 이동되게 됩니다.

레이아웃 시프트는 사용자 입장에서 보기 불편하며, 웹사이트 성능 최적화의 지표에서 CLS (Cumulative Layout Shift )를 측정하는 만큼, 개선해야되는 문제입니다.

네트워크 워터폴 (Network Waterfall)

네트워크 워터폴이란, 여러개의 API를 순차적으로 요청하며 이전 요청이 완료될 때까지 다음 요청이 대기되는 현상을 말합니다.
리액트의 기본적인 렌더링 흐름은, 부모 컴포넌트의 렌더링이 완료된 이후 자식 컴포넌트들이 렌더링을 시작합니다.

  • A 부모 컴포넌트 ( API 호출 )
    • B 자식 컴포넌트 ( API 호출 )
    • C 자식 컴포넌트 ( API 호출 )

A 컴포넌트도 API를 호출한다고 하면, 렌더링이 완료될 때까지 B, C 컴포넌트는 렌더링 대기 상태에 놓이게 됩니다.

즉, 부모 컴포넌트인 A의 렌더링이 완료되어야 자식 컴포넌트들이 렌더링되어 API를 순차적으로 호출하기 때문에 네트워크 워터폴이 발생하게 됩니다.

유지보수 및 성능 문제

네트워크 워터폴 현상을 해결하기 위한 방법으로 B, C 컴포넌트에서 필요한 데이터를 A 컴포넌트에서 호출하여 props로 내려주는 방법도 있습니다.

function A컴포넌트() {
    // 실제로 네트워크를 호출할 땐 useEffect를 사용합니다
    const info = fetchAllDetails();
    
    return(
    	<A컴포넌트
        	ino={info.wrapperInfo} >
            <B컴포넌트
        		ino={info.listInfo} />
            <C컴포넌트
        		ino={info.testimonials} />
        </A컴포넌트>     
    )
 } 

이러한 방식으로 네트워크 워터폴 문제를 해결할 순 있지만, 유지보수에서 문제가 생깁니다.
만약 C컴포넌트의 기능을 더이상 사용하지 않아 삭제하기로 한다고 결정이 되면, 저희는 간단하게 C컴포넌트만 삭제하면 됩니다.

하지만 fetchAllDetails 호출에서 C 컴포넌트에 필요한 데이터 삭제를 잊는다면, 불필요한 상태로 계속 남아 있게 되는 문제가 발생합니다.
그리고 리액트는 CSR이기 때문에 프로젝트가 커질 수록 JS번들 사이즈가 커지게 되고 클라이언트가 너무 많은 일을 한다는 문제점이 있었습니다.

그리고 이러한 문제점들을 해결하기 위해 리액트팀은 서버 컴포넌트를 만들었습니다.

React Server Components

서버 컴포넌트는 클라이언트 앱이나 SSR(서버 사이드 렌더링) 서버와는 별도의 환경에서 빌드 전에 미리 렌더링되는 새로운 유형의 컴포넌트입니다.
이 별도의 환경이 React Server Components에서 말하는 "서버”이며, 서버 컴포넌트는 CI서버에서 빌드할 때 한 번 실행될 수도 있고, 웹 서버를 통해 각 요청마다 실행될 수도 있습니다.

리액트 서버 컴포넌트는, 완전히 새로운 패러다임으로 오직 서버에서만 실행되는 컴포넌트입니다.

즉, 서버에서 데이터를 미리 로드하고 필요한 데이터를 클라이언트에 전달하여 네트워크 워터폴 문제를 해결하는데 도움을 주며 자바스크립트 번들에 포함되지 않기 때문에 하이드레이트 하거나 다시 렌더링되지 않습니다.

RSC의 특징

  • 리액트 서버 컴포넌트는 서버에서 렌더링하여 클라이언트에 전달하므로 하이드레이션이나 재렌더링이 발생하지 않습니다.
  • 서버 컴포넌트는 클라이언트 자바스크립트 번들에 포함되지 않기 때문에, 자바스크립트 번들 크기를 줄이고 초기 로딩 성능을 향상시키며, SEO 최적화에 유리합니다.
  • 서버에서만 필요한 데이터 패칭 로직과 정적인 콘텐츠를 렌더링하기 적합합니다.
  • Next.js와 같은 프레임워크와 결합하여 서버와 클라이언트를 최적의 방식으로 혼합하여 사용할 수 있습니다.

리액트 서버 컴포넌트가 등장하면서, 기존의 컴포넌트 방식은 클라이언트 컴포넌트라는 이름으로 불리게 됩니다. 서버 컴포넌트는 서버에서만 렌더링되지만, 클라이언트 컴포넌트는 서버와 클라이언트에서 렌더링 될 수 있습니다.

하지만 이러한 RSC는 서버에서 미리 빌드하여 결과물을 클라이언트에 전송하는 방식이기 때문에 한계점도 존재합니다.

RSC의 한계점

  • RSC는 서버에서 렌더링되고 서버에 남아있기 때문에, 사용자 인터렉션을 추가할 수 없습니다.
    • useState, useEffect 같은 리액트 훅과, 이벤트 핸들러 등을 사용할 수 없습니다.
  • localstorage 등과 같은 웹 API를 사용할 수 없습니다.
  • 클라이언트 상호 작용과 관련된 것들은 클라이언트 컴포넌트를 사용해야 합니다.

클라이언트 경계

서버 컴포넌트는 서버에서 빌드되어 클라이언트에게 특별한 형태(JSON과 유사한 형식)로 전달됩니다.

특별한 형태로 전달되는 서버 컴포넌트
서버 컴포넌트는 클라이언트에게 아래와 같은 형태의 데이터로 전달됩니다.
클라이언트는 이 JSON 객체를 기반으로 렌더링 트리를 재구성하여 UI를 보여주는데, HTML 대신에 JSON 구조를 전송함으로써 필요한 정보만 효율적으로 전송하여 리액트는 바로 UI로 변환하고, 네트워크 왕복 시간이 없으며 여러가지 최적화 기법으로 클라이언트에서 로딩 성능이 크게 개선됩니다.

만약 부모 컴포넌트가 클라이언트 컴포넌트이고 자식이 서버 컴포넌트로 구성되어 있을 때, 자식 컴포넌트는 부모의 값을 props로 보여주는 역할만 합니다.

이러한 상황에서 부모 컴포넌트의 state 값이 변경되어 props로 전달되어야 하는 값이 변경되어야 하는데, 서버 컴포넌트는 미리 렌더링된 결과물을 전송해주어 다시 리렌더링이 발생하지 않기 때문에 정적인 컨텐츠가 됩니다.

이렇게 말로 하면 이해가 어렵기 때문에 카운터 예제로 코드를 작성해보겠습니다.

  • page.tsx (부모 컴포넌트)
    import React from 'react';
    import ClientComponent from './ClientComponent';
    
    export default function Page() {
      return (
        <section>
           <h2 className="font-label-18m">카운터 예제로 이해하는 클라이언트 경계</h2>
          <ClientComponent />
        </section>
      );
    }
  • ClientComponent.tsx
    'use client'; // 클라이언트 컴포넌트를 사용하기 위한 지시문
    
    import React, { useState } from 'react';
    
    export default function ClientComponent() {
      const [count, setCount] = useState(0);
    
      return (
        <div>
          <button onClick={() => setCount(count + 1)}></button>
          <button onClick={() => setCount(count - 1)}></button>
           <ServerComponent count={count} />
        </div>
      );
    }
  • ServerComponent.tsx
    import React from 'react';
    
    export default function ServerComponent({ count }: { count: number }) {
      return (
        <div>
          <p>'use client' 지시문이 없어요.</p>
          <span>{count}</span>
        </div>
      );
    }

리액트 서버 컴포넌트에서는 기본적으로 모두 서버 컴포넌트로 동작하며, 클라이언트 컴포넌트를 사용하기 위해서는 'use client' 지시어를 사용해야합니다.

위의 컴포넌트 구조를 보면 ClientComponent.tsx 에는 'use client' 지시어를 사용했고,
ServerComponent.tsx 에는 지시어가 없는 것을 알 수 있습니다.

저는 당연히 ServerComponent.tsx'use client' 지시어가 없기 때문에 서버 컴포넌트로 동작한다고 이해했고, 그렇다면 count의 값이 변하지 않아야 된다고 이해했습니다.

위 이미지를 보면, ServerComponentcount 값이 변하며 리렌더링이 일어난다는 것을 알 수 있습니다.
부모 컴포넌트의 값이 변했으니, 자식 컴포넌트의 값도 변해야 하는데 서버 컴포넌트로 작성한다면 변하지 않는 정적인 컨텐츠가 되겠죠

리액트는 이러한 문제점을 해결하기 위해서 서버 컴포넌트를 사용할 때 특별한 규칙이 존재합니다.

리액트 클라이언트 컴포넌트는 서버 컴포넌트를 렌더링 할 수 없다. 는 규칙인데요.
하지만 이 규칙은 이상합니다.

'use client'; // 클라이언트 컴포넌트를 사용하기 위한 지시문

import React, { useState } from 'react';

export default function ClientComponent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}></button>
      <button onClick={() => setCount(count - 1)}></button>
       <ServerComponent count={count} />
    </div>
  );
}

저는 여기서 서버 컴포넌트를 렌더링 했기 때문이죠.
어째서 에러도 발생하지 않고 ServerComponent의 count의 값이 변하게 되는 걸까요?

그건 바로, ServerComponent 는 서버 컴포넌트로 동작하지 않고, 클라이언트 컴포넌트로 동작하기 때문에 가능합니다!

이렇게 use client로 선언한 컴포넌트가 렌더링하는 컴포넌트들은 클라이언트 경계에 속하게 되어, 암묵적으로 클라이언트 컴포넌트로 변환되는 것입니다.

이렇게 암묵적으로 변환되는 부분 때문에 처음에 헷갈렸지만, 일일이 use client 선언해주지 않아도 된다는 편리함이 있기 때문에 이 클라이언트 경계의 개념을 잘 이해해야합니다.

리액트 서버 컴포넌트 적용하여 성능 개선하기

이제 RSC에 대한 학습을 했으니 직접 적용해보며 성능 개선이 얼마나 되는지 눈으로 직접 확인해보고 싶어졌습니다.

기존의 프로젝트에서 CSR로 불러오던 리뷰들을 서버 컴포넌트로 리팩토링을 해보겠습니다.

  • page.tsx
    import React from 'react';
    
    export default async function Page() {
      const queryClient = new QueryClient();
      await queryClient.prefetchQuery({
        queryKey: ["key를 넣어주세요"],
        queryFn: async () => {
          const response = await fetch(`/api/reviews`, {
            cache: 'no-store',
          });
          return response.json();
        },
      });
    
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <Reviews />
        </HydrationBoundary>
      );
    }
  • route.ts (API 엔드포인트)
    export const dynamic = 'force-dynamic';
    export const GET = async () => {
      try {
        const items = await fetchReviews();
        const response = NextResponse.json(items, {
          status: 200,
          headers: {
    				...
          },
        });
        return response;
      } catch (error) {
        return NextResponse.json(String(error), { status: 500 });
      }
    };
    • route.ts 파일은 Next.js에서 API 경로를 설정하는 파일로, 서버에서 데이터를 가져와 클라이언트로 전달하는 역할을 수행합니다.
    • 서버 컴포넌트가 API 호출을 통해서만 데이터를 가져오도록 설계되어 있어서 API 엔드 포인트가 필요하며, 이 API 엔드포인트가 route.ts에 설정됩니다. route.ts 파일을 통해 GET 요청을 정의하고, 데이터를 처리하여 응답을 구성합니다.

이렇게 서버 컴포넌트를 구성하여, 기존의 클라이언트 컴포넌트와 렌더링 성능을 비교해봅니다.

성능 비교하기

CSR

RSC로 리팩토링


서버 컴포넌트로 리팩토링을 한 이후 로딩 속도가 훨씬 빠르다는 것을 알 수 있습니다.

새로고침을 해도 여전히 속도가 빠른데 그 이유는,
SSR은 새로고침을 할 때마다 네트워크를 다시 요청하고 HTML을 새로 생성하여 클라이언트에게 전송하는 과정이 필요합니다.

하지만 RSC는 데이터에 큰 변화가 없다면 처음 렌더링된 HTML을 그대로 사용하고 캐싱되기 때문에, 새로고침을 해도 서버에 다시 요청할 필요없이, 처음 받아온 결과물을 그대로 보여주기 때문에 페이지가 매우 빠르게 로드됩니다.

이번에는 수치로 CSR과, RSC를 비교해보겠습니다.

CSR

RSC


클라이언트 컴포넌트에서는 49.31 밀리초가 걸리지만 서버 컴포넌트는 13.62 밀리초로 약 35.69밀리초(약 72%)의 성능 향상이 된다는 것을 알 수 있습니다.

Lighthouse로 비교하기

CSR

RSC


이렇게 RSC를 사용하면 페이지 로드 시간과 사용자 경험이 크게 향상된다는 것을 알 수 있습니다.

마무리

확실히 서버 컴포넌트를 직접 적용해보고 예제를 만들면서 공부하니까 클라이언트 경계가 무엇이고 왜 필요한지 이해할 수 있어서 좋았습니다.

그리고 현재 프로젝트에 직접 적용해보면서 RSC는 CSR, SSR과 어떤 차이점이 있는지, 정말로 빠른 렌더링이 되는지 직접 눈으로 확인해보니 서버 컴포넌트가 얼마나 더 대단하고 사용자에게 더 나은 경험을 제공할 수 있는지 이해가되었습니다.

앞으로도 서버 컴포넌트의 개념을 이해하고 리팩토링하면서 사용자에게 더 나은 경험을 제공하는 개발자가 될 수 있도록 노력해야겠습니다.

profile
병아리에서 닭이 될 때까지

0개의 댓글

관련 채용 정보