React Server Component

Y·2023년 5월 28일
0
post-thumbnail

React 컴포넌트의 data fetching

일반적으로, 한 페이지 내에서 여러 정보를 보여주는 컴포넌트를 개발할때 흔히 아래와 같이 구성한다.

부모 컴포넌트인 <ArtistDetails>와 자식 컴포넌트인 <TopTracks>, <Discography> 모두 다른 정보가 필요하고 필요한 정보는 서버 API를 호출하여 받아와야 한다. 따라서 아래의 방식 중에 하나를 적용할 수 있다.

1 ) 모든 정보를 부모 컴포넌트에서 하나의 거대한 API로 호출하여 자식으로 내려준다.

2 ) 컴포넌트에 필요한 API를 각 컴포넌트에서 호출한다.

첫번째 방법의 경우, 클라이언트에서 서버로 요청하는 API 요청수를 줄일 수 있지만, 그로 인해 부모와 자식 컴포넌트 간 결속되고 유지보수가 어려워지게 된다.

두 번째 방법은 각 컴포넌트가 렌더링될 때 필요한 데이터만 가져와 보여줄 수 있다는 장점이 있지만, high latency를 가진 클라이언트로부터의 서버 요청은 늘어나게 된다. 또한 부모 컴포넌트는 렌더링 된 후 필요한 데이터를 받아오기 시작하고 이 과정이 끝나기 전까지 자식 컴포넌트의 렌더링과 API 호출 또한 지연된다. 결국 연속된 client-server API 요청과 중첩된 컴포넌트 내 API 호출 지연으로 인한 waterfall은 사용자 경험을 떨어트릴 수 있다.

Relay와 GraphQL로 waterfall 문제를 어느정도 해결할 수 있지만, 이는 모든 리액트 애플리케이션에 적용하기에 적합하지 않을 수도 있다. 그리하여 Relay와 GraphQL의 도움 없이 리액트 애플리케이션의 waterfall을 줄일 수 있는 해결책으로 React Server Component를 소개하였다.

No Client-Server Waterfalls

저하된 성능은 보통 연속된 데이터 요청을 할 때 주로 발생한다. 예를 들어 데이터 패칭의 한 패턴으로, 초기에 placeholder를 렌더링 하고 useEffect 내에서 실제 데이터를 받아오는 것이다.

// Note.js
function Note(props) {
  const [note, setNote] = useState(null);

  useEffect(() => {
    // NOTE: loads *after* rendering, triggering waterfalls in children
    fetch(`https://api.example.com/notes/${props.id}`)
      .then(res => res.json())
      .then(
        (result) => {
          setNote(result);
        }
      )
  }, [props.id]])

  if (note == null) {
    return "Loading";
  } else {
    return (/* render note here... */);
  }
}

각 컴포넌트에서 데이터를 요청하는 경우 실제 컴포넌트가 렌더링 될 때 필요한 데이터만 가져와 보여줄 수 있다는 장점이 있지만, 클라이언트와 서버 사이의 API 요청은 늘어나게 된다. 또한 부모 컴포넌트는 컴포넌트 렌더링 후 필요한 데이터를 받아오기 시작하고 이 과정이 끝나기 전까지 자식 컴포넌트의 렌더링과 API 호출이 지연되며 불필요한 렌더링이 발생한다.

이와 같은 클라이언트 컴포넌트에서의 비동기 data fetching은 client-server waterfall을 야기하고 성능을 저하시키는 원인이 된다.

Server Component

서버 컴포넌트는 용어 그래도 서버에서 동작하는 리액트 컴포넌트다. 서버 컴포넌트를 사용하면 컴포넌트 렌더링을 클리언트가 아닌 서버에서 수행할 수 있다. 서버에서 렌더링을 수행하기 때문에 API를 통한 데이터 요청의 latency를 줄일 수 있고, 클라이언트에서의 연속된 API 호출을 제거하여 client-server waterfall을 막을 수 있다.

// Note.js - Server Component

async function Note(props) {
  // NOTE: loads *during* render, w low-latency data access on the server
  const note = await db.notes.get(props.id);
  if (note == null) {
    // handle missing note
  }
  return (/* render note here... */);
}

컴포넌트에서 필요한 데이터만 fetching하는 방식을 유지하면서 퍼포먼스를 향상시킬 수 있다.

그러나 아쉽게도 서버 컴포넌트에서 데이터 요청을 처리하여도 중첩된 컴포넌트에서의 여러 API 요청에 따른 네트워크 waterfall은 여전히 존재한다. 따라서 이를 향상시킬 수 있는 데이터 preload api를 제공할 예정이다.

서버 컴포넌트의 이점

1 ) 자유로운 서버 리소스 접근
서버 컴포넌트는 서버에서 동작하기 때문에 데이터베이스, 파일 시스템 등의 서버 사이드 데이터 소스에 직접 접근할 수 있다.

import db from 'db';

async function Note({id}) {
  const note = await db.notes.get(id);
  return <NoteWithMarkdown note={note} />;
}

또한, 애플리케이션을 만들면서 데이터를 어디에 저장할지 고민이라면 초반에 파일 시스템을 활용할 수도 있다.

import fs from 'fs';

async function Note({id}) {
  const note = JSON.parse(await fs.readFile(`${id}.json`));
  return <NoteWithMarkdown note={note} />;
}

2 ) 제로 번들 사이즈 컴포넌트
애플리케이션을 개발하면서 많은 third-party 패키지들을 사용하게 된다. 마크다운 렌더링하거나, 날짜를 포맷하는 패키지들은 매우 유용하겠지만, 추가하다보면 번들 사이즈가 늘어나고 퍼포먼스에도 영향을 끼치게 된다. 요즘 tree-shaking과 같이 확장된 기능을 제공하는 라이브러리도 많지만, 결국 번들 사이즈가 늘어나는 것을 막을 수는 없다.

아래와 같은 예시가 있다. 클라이언트의 노트 컴포넌트를 렌더링하기 위해서 관련 코드와 marked, sanitize-html 패키지 또한 번들에 추가되어야한다.

// NoteWithMarkdown.client.jsx (클라이언트 컴포넌트 = 기존의 리액트 컴포넌트)

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}

하지만 서버 컴포넌트는 브라우저에 다운로드되지 않고 서버에서 미리 렌더링된 static content를 전달하기 때문에 패키지를 추가해도 번들 사이즈에 영향을 끼치지 않는다. 위 클라이언트 컴포넌트처럼 유저 인터랙션이 없는 컴포넌트들을 서버 컴포넌트로 구현한다면, 동일한 뷰를 제공함과 동시에 번들 사이즈와 초기 로딩 시간을 감소시킬 수 있을 것이다.

// NoteWithMarkdown.server.jsx - 서버 컴포넌트

import marked from 'marked'; // zero bundle size
import sanitizeHtml from 'sanitize-html'; // zero bundle size

function NoteWithMarkdown({text}) {
  // same as before
}


-> Data Fetching with React Server Components 영상 참고

-> 실제 sources 탭을 통해서 server component는 번들에 포함되지 않은 것을 확인할 수 있다.

3 ) 자동 코드 분할
우선 코드 분할(Code Splitting)이란 하나의 거대한 자바스크립트 번들을 여러 개의 작은 번들로 나누는 것을 말한다. 클라이언트 컴포넌트에서는 React.lazy와 dynamic import를 사용하여 렌더링에 필요한 컴포넌트를 동적으로 불러왔었다.

// PhotoRenderer.js (서버 컴포넌트 사용 전)
import React from 'react';

// 이 중 하나는 *클라이언트 렌더링 될 때* 로딩을 시작한다.
const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));

function Photo(props) {
  // 로그인, 로그아웃, 컨텐츠 유형 등 기능 플래그 켜기
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />; 
  } else {
    return <PhotoRenderer {...props} />;
  }
}

코드 분할에는 몇 가지 문제가 있다.

  • lazy loading이 필요한 컴포넌트마다 일일이 React.lazy와 dynamic import를 적용해야한다.
  • 부모 컴포넌트가 렌더링 된 이후 로딩을 시작하기 때문에 화면에 보이기 전 어느 정도의 딜레이가 존재할 수 있다. (사용자 경험에 영향)

서버 컴포넌트는 클라이언트 컴포넌트의 모든 import를 코드 분할 가능 지점으로 처리하는 자동 코드 분할을 도입한다. 따라서 더 이상 React.lazy로 메뉴얼 하게 명시하지 않아도 된다. 또한 서버에서 미리 필요한 컴포넌트를 선택하기 때문에 클라이언트는 렌더링 프로세스 초기에 더 일찍 가져올 수 있게 된다.

// PhotoRenderer.server.js - 서버 컴포넌트
import React from 'react';

// 이 중 하나가 *렌더링 되고 클라이언트로 스트리밍 되면* 로드를 시작한다.
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
import NewPhotoRenderer from './NewPhotoRenderer.client.js';

function Photo(props) {
  // 로그인, 로그아웃, 컨텐츠 유형 등 기능 플래그 켜기
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <PhotoRenderer {...props} />;
  }
}

서버 컴포넌트와 클라이언트 컴포넌트

Next.js에서는 서버 컴포넌트가 디폴트이다. 따라서 클라이언트 컴포넌트를 만들기 위해서는 파일 최상단에 "use client" 지시어를 추가해야한다.

[서버 컴포넌트] : 서버에서만 렌더링되는 컴포넌트

  • useState(), useReducer()와 같은 state 사용 불가
  • useEffect(), useLayoutEffect()와 같은 라이프사이클(effect) 사용 불가
  • ❌ DOM과 같은 브라우저 api 사용 불가
  • ❌ state, effects, 브라우저 api를 사용하는 custom hook 사용 불가
  • ✅ 데이터베이스, 내부 서비스, 파일시스템과 같은 server-only 데이터 사용 가능
  • ✅ 서버 컴포넌트, 클라이언트 컴포넌트, native elements (예: div, span) 렌더링 가능
  • ✅ 클라이언트 컴포넌트 props로 serializable한 데이터 전달 가능 ( serialize : 객체가 파일 시스템, DB 또는 메모리에 저장될 수 있도록 하기 위해서 객체를 바이트 스트림으로 변환하는 과정 )

[클라이언트 컴포넌트] : 서버 컴포넌트 도입 전 리액트 컴포넌트

  • ❌ 서버 컴포넌트 import 불가능
    그러나 클라인언트 컴포넌트의 자식으로 또 다른 서버 컴포넌트를 넘겨주는 것은 가능 <ClientComponent><ServerComponent /></ClientComponent>
  • ❌ server-only 데이터 사용 불가능
  • ✅ state, effects, browser-only API 등 사용 가능
  • ✅ 유저 인터랙션 사용 가능

[When To Use]

-> Building Your Application: Rendering 참고

서버 사이드 렌더링

기본적인 리액트 애플리케이션은 아래와 같이 클라이언트 사이드 렌더링 (CSR)으로 동작한다.

CSR은 페이지 진입 시 HTML, Javascript 그리고 모든 데이터가 로드되고 컴포넌트 렌더링이 끝나기 전까지 사용자는 빈 화면만 보게 된다. 자바스크립트 번들 용량이 크거나, 사용자의 네트워크 속도가 느릴수록 빈 화면을 마주하는 시간은 길어지고 이는 사용자 경험을 저하시킨다.

Next.js의 Server Side Rendering(SSR)의 경우, 클라이언트 애플리케이션의 자바스크립트 파일을 서버에서 먼저 HTML로 렌더링한다. 페이지가 정상적으로 동작하기 위해서는 자바스크립트 번들이 모두 다운로드되고 hydration이 완료되어야하지만 빈 화면 대신 데이터가 존재하는 HTML을 제공함으로써 무거운 자바스크립트 파일이 다운로드되는 동안 사용자에게 의미 있는 컨텐츠를 제공할 수 있다.

서버 사이드 렌더링과 서버 컴포넌트의 차이

서버 컴포넌트와 서버 사이드 렌더링은 서버에서 렌더링 된다는 유사점이 있지만 아래와 같은 차이점이 있다.

  • 서버 컴포넌트의 코드는 클라이언트에 전송되지 않는다. 대부분 React SSR 구현들은 컴포넌트 코드가 자바스크립트 번들에 포함되어 클라이언트에 전송된다. 이는 인터렉션을 지연시킨다.
  • 서버 컴포넌트는 컴포넌트 트리 내 아무 곳에서든 서버에 접근 가능하다. 그러나 Next.js의 경우 페이지 단위로 제한되어 getServerSideProps()로 서버에 접근 가능하다.
  • 서버 컴포넌트는 컴포넌트 트리 내 아무 곳에서든 서버에 접근 가능하다. 그러나 Next.js의 경우 페이지 단위로 제한되어 getServerSideProps()로 서버에 접근 가능하다.
    서버 컴포넌트는 클라이언트 상태를 유지하며 refetch 될 수 있다. 서버 컴포넌트는 HTML이 아닌 특별한 형태로 컴포넌트를 전달한다. 따라서 검색 결과 텍스트, 포커스, 텍스트 선택 등의 클라이언트 상태를 날리지 않은 상태로 검색 결과 목록과 같은 서버렌더링 영역이 refetch 될 수 있다. 하지만 SSR의 경우 HTML로 전달되기 때문에 새로운 refetch가 필요한 경우 HTML 전체를 리렌더링해야하며 이로 인해 클라이언트 상태를 유지 할 수 없다.

서버 컴포넌트는 SSR을 대체하지 않는다. 하지만 SSR과 서버컴포넌트가 함께 사용된다면, SSR로 초기 HTML 페이지를 빠르게 보여주고, 서버 컴포넌트로는 클라이언트로 전송되는 자바스크립트 번들 사이즈를 감소시킨다면 사용자에게 기존보다 훨씬 빠르게 인터랙팅한 페이지를 제공할 수 있을 것이다.


참고 자료

profile
기록중

0개의 댓글