[React] Deep Dive 모던 리액트(29) Next.js 서버 컴포넌트
FrontEnd/Deep Dive

[React] Deep Dive 모던 리액트(29) Next.js 서버 컴포넌트

728x90

 

 

 

리액트 서버 컴포넌트

리액트 18에서 도입된 리액트 서버 컴포넌트는 서버 사이드 렌더링과는 완전히 다른 개념이다. 둘다 '서버'라는 단어가 들어있는데 어떤게 다른 것인지 알아보자!

 

 

리액트의 컴포넌트라는 개념에 대해 한번 더 생각해보자. 리액트의 모든 컴포넌트는 클라이언트에서 작동하며, 브라우저에서 JS 코드 처리가 이루어진다.

 

웹사이트를 방문하면 리액트 실행항 코드를 다운로드하고 리액트 컴포넌트 트리를 만든 후 DOM에 렌더링한다. 서버 사이드 렌더링의 경우 서버에서 DOM을 만들어오고, 클라이언트에서 만들어진 DOM을 기준으로 하이드레이션을 진행한다.

 

이런 구조는 지금까지 Next.js,리액트에서 제공하는 서버사이드 렌더링 흐름이었다. 해당 구조의 한계점은 무엇일까?

 

 

1. JS번들크기가 0인 컴포넌트를 만들 수 없음

 

게시판 등 사용자가 작성한 HTML에 위험한 태그를 제거하기 위한 라이브러리인 sanitize-html을 리액트 컴포넌트에서 사용한다고 생각해보자.

 

import sanitizenHtml from 'sanitize-html'

function Board({text} : {text:string}) {
	const html = useMemo(() => sanitizeHtml(text),[text])
    
    return <div dangerouslySetInnerHtml={{__html : html}}/>
}

 

위컴포넌트는 매우 자연스러운 컴포넌트 구조를 가지고 있다. 하지만 이는 63.3kB에 달아흔 sanitize-html을 필요로 한다. 이는 사용자 기기의 부담으로 다가올 수밖에 없다. 만약 해당 컴포넌트를 서버에서만 렌더링하고 클라이언트가 결과만 받는다고 생각해 보자.

클라이언트는 무거운 sanitize-html라이브러리를 다운받지 않아도 컴포넌트를 렌더링할 수 있다.

 

 

2. 백엔드 리소스에 대한 직접적인 접근 불가

 

클라이언트에서 백엔드 데이터에 접근하려면 REST API와 같은 방법을 사용한다. 이는 백엔드에서 항상 클라이언트에서 데이터를 접근하기 위한 방법을 제공해야 한다는 불편함이 있다. 만약 클라이언트에서 직접 백엔드에 접근해서 원하는 데이터를 가져올 수 있다면?

 

import db from 'db'

async function Board({id} : {id : string}) {
	const text = await db.board.get(id)
    return <>{text}</>
}

 

이렇게 DB에 직접 액세스한다면 백엔드의 부담이 줄어들 것이다.

 

 

3. 자동 코드 분할 불가능

 

코드 분할이란 거대한 코드 번들 대신 코드를 여러단위로 나누어 필요할때만 동적으로 로딩하는 기법이다. 리액트에서는 일반적으로 lazy를 사용하여 구현해 왔다.

 

// PhotoRenderer.js
// NOTE: *before* Server Components

import { lazy } from 'react';

// one of these will start loading *when rendered on the client*:
const OldPhotoRenderer = lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = lazy(() => import('./NewPhotoRenderer.js'));

function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />; 
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

 

해당기법은 훌륭하지만 컴포넌트를 일일히 lazy로 감싸야 한다는 불편함이 있다. 즉 개발자의 역량에 따라 코드분할이 되는 컴포넌트인지 유념하고 개발해야 하기 때문에 이를 누락하는 경우가 생길 수 있다. 

 

또, 이러한 방식은 연쇄적으로 발생하는 클라이언트와 서버의 요청을 대응하기 어렵다. 

요청 -> 컴포넌트 렌더링 -> 다른 컴포넌트 렌더링 하는 시나리오가 있다고 하면 최초 컴포넌트의 요청과 렌더링이 끝나기 전까지는 하위 컴포넌트의 요청과 렌더링이 끝나지 않는다는 크나큰 단점이 있다.

 

또, 추상화에 드는 비용이 증가하게 된다. 리액트는 템플릿 언어로 설계되지 않았다. 이는 개발자에게 많은 자유를 주지만 이러한 추상화가 복잡해질수록 코드가 복잡해지게 된다. 쉽게 생각해서 코드의 양에 비해서 사용자가 보게되는 화면은 매우 단순하게 주어질 때가 있다.

 

 

이러한 코드 분할을 서버에서 자동으로 해준다면 개발자 입장에서 매우 편해지면서도 코드분할의 이점을 100% 활용 할 수 있다.

 

이렇게 서버 사이드 렌더링의 한계점들을 쭉 보면 리액트가 클라이언트 중심으로 돌아가기 때문에 발생하는 문제가 원인임을 알 수 있다.

만약 PHP와 같은 정적방식의 서버 사이드 렌더링을 사용했다면 이러한 문제는 해결할 수 있지만 사용자 경험을 다양하게 해줄 수 없다.

 

이 두 구조의 장점을 모두 취하고자 하는 것이 바로 리액트 서버 컴포넌트이다.

 

 

서버 컴포넌트

서버 컴포넌트란 하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법을 의미한다. 

 

서버에서 할 수 있는 일은 서버에서 처리하며, 나머지 작업을 브라우저에서 수행하게 한다. 주의해야 할 점은 클라이언트 컴포넌트는 서버 컴포넌트를 import할 수 없다는 점을 알아두자. ( 서버 환경이 클라이언트한테 없으므로 )

 

그럼 리액트는 어떻게 리액트 트리 내부의 컴포넌트를 서버 컴포넌트와 클라이언트 컴포넌트를 만들어서 관리할 수 있을까?

 

서버 컴포넌트와 클라이언트 컴포넌트의 트리 구조

 

 

 

서버 컴포넌트의 이론에 따르면 모든 컴포넌트는 서버 컴포넌트가 될 수도 있고 클라이언트 컴포넌트가 될 수도 있다. 이게 가능한 비밀은 ReactNode에 달려있다.

 

 

//ClientComponent.jsx
"use client";
import ServerComponent from "./ServerComponent.server"; //불가능!!!!!!!!
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  );
}

//ClientComponent.jsx
("use client");
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>클라이언트 컴포넌트</h1>
      {children}
    </div>
  );
}

//ServerComponent.jsx
export default function ServerComponent() {
  return <span>ServerComponent</span>;
}

//ParentServerComponent.jsx
//서버컴포넌트일수도, 클라이언트 컴포넌트일 수도 있다.
import ClienntComponent from "./ClientComponent";
import ServerComponent from "./ServerComponent";

export default function ParentServerComponent() {
  return (
    <ClienntComponent>
      <ServerComponent />
    </ClienntComponent>
  );
}

 

 

위 코드는 서버 컴포넌트를 기반으로 리액트 컴포넌트 설계할 때 어떤 제한이 있는지를 알려준다. 잘 보면 서버컴포넌트, 클라이언트 컴포넌트, 공용 컴포넌트가 존재함을 알 수 있다.

 

서버 컴포넌트

- 요청이 오면 서버에서 딱 한번 실행되며 상태를 가질수 없다. (useState, useReducer 등의 훅 사용 X )

- 렌더링 생명주기를 사용할 수 없고 effect나 state에 의존하는 훅 또한 사용이 불가능하다.

- 브라우저에서 실행되지 않고 서버에서만 실행되기 때문에 DOM API & document & window 사용이 불가능하다.

- DB,내부서비스 등 서버에만 있는 데이터를 async/await으로 접근할 수 있다.

- 다른 서버 컴포넌트를 렌더링하거나 div,span, p 같은 요소를 렌더링하거나 클라이언트 컴포넌트를 렌더링할 수 있다.

 

클라이언트 컴포넌트

- 브라우저 환경에서만 실행되므로 서버 컴포넌트를 부를 수 없음

- 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하는경우 클라이언트 컴포넌트가 자식으로 서버 컴포넌트를 갖는 것은 가능하다. 

클라이언트 : 이미 서버에서 만들어진 트리가 있네? 그걸 삽입하면 되겠군.. 이란 생각을 할 수 있다.

- 위 예외사항을 빼면 우리가 알고있던 리액트 컴포넌트와 같다.

 

공용 컴포넌트

서버&클라이언트에서 모두 사용 가능하며 두 컴포넌트의 제약을 모두 받는다.

 

기본적으로 리액트는 모든 것을 다 공용 컴포넌트로 판단한다. 다른 말로 모든 컴포넌트를 서버에서 실행 가능한 컴포넌트로 분류한다. 이를 클라이언트 컴포넌트로 명시적 선언을 하려면 "use client"라고 작성해두면 된다.

 

 

지금 내용들을 보면 알 수 있듯이 서버 컴포넌트의 개발이 쉽지 않다. 따라서 서버사이드 렌더링을 구현하는 경우 프레임워크를 사용하는 것이 일반적이다. 리액트 팀에서 제공하는 공식 예제에서도 웹팩과 자체적인 서버 번들링을 위한 react-server-dom-webpack을 만들어서 활용했으며 서버 컴포넌트 제안 문서에서는 노골적으로 Next.js와 협업하고 있음을 알렸다.

 

 

 

 

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

이제 서버 컴포넌트에 대해서는 어느정도 알아봤다! 그렇다면 서버 사이드 렌더링과 서버 컴포넌트의 차이는 무엇일까? 먼저 서버사이드 렌더링에 대해 한번 더 짚어보자.

 

서버 사이드 렌더링
응답받은 페이지 전체를 HTML로 렌더링 하는 과정을 서버에서 수행 후 그 결과를 클라이언트에 내려주고 클라이언트에서 하이드레이션 과정을 거쳐 이벤트를 붙이는 등의 작업을 수행한다. 즉, 여전히 초기 HTML 로딩 이후에는 클라이언트에서 JS코드를 다운하고 파싱하고 실행한다.

 

 

이후에는 서버 사이드 렌더링과 서버 컴포넌트를 모두 채택하는 것 또한 가능해질지 모른다. 서버 컴포넌트를 활용하여 서버에서 렌더링할 수 있는 컴포넌트는 서버에서 제공받고, 클라이언트 컴포넌트는 서버사이드 렌더링을 활용하여 초기 HTML을 받을 수 있다.

 

두 개념은 대체제가 아닌 상호보완하는 개념이 될 수 있음을 알면 좋을 것이다.

 

 

서버 컴포넌트의 작동 원리

리액트 서버 컴포넌트를 렌더링 하기 위해 일어나는 일들을 간단하게만 살펴 보자. 

 

https://github.com/prisma/server-components-demo

 

GitHub - prisma/server-components-demo: Demo app of React Server Components.

Demo app of React Server Components. Contribute to prisma/server-components-demo development by creating an account on GitHub.

github.com

 

리액트 팀에서 2021년 공식적으로 제공한 레포를 prisma에서 포크한 예제를 보자. 해당 포크를 사용하면 리액트팀의 예제를 사용하려면 필요한 DB설치&설정 작업을 건너띌 수 있다.

 

// server-components-demo/server/api.server.js

app.get(
  '/',
  handleErrors(async function(_req, res) {
    await waitForWebpack();
    const html = readFileSync(
      path.resolve(__dirname, '../build/index.html'),
      'utf8'
    );
    // Note: this is sending an empty HTML shell, like a client-side-only app.
    // However, the intended solution (which isn't built out yet) is to read
    // from the Server endpoint and turn its response into an HTML stream.
    res.send(html);
  })
);

 

위 코드는 서버사이드 렌더링이 수행되지 않는다. 코드를 잘 보면 단순하게 사용자가 최초진입 시 index.html을 제공하는 역할만 하고 있음을 알 수 있다.

 

waitForWebpack()
단순하게 개발 환경에서 웹팩이 빌드 경로에 indext.html을 만들때까지 기다리는 코드

 

자, 본격적으로 과정을 알아보자.

server-component-demo 구조

 

1. 서버가 렌더링 요청을 받는다. 서버가 렌더링을 수행하므로 리액트 서버 컴포넌트를 사용하는 모든 페이지는 서버에서 시작한다. 즉, 루트에 있는 컴포넌트는 항상 서버 컴포넌트이다.

 

2. 서버는 받은 요청에 따라 컴포넌트를 JSON으로 직렬화한다.서버에서 렌더링할 수 있는 부분은 직렬화하고 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 플레이스홀더 형식으로 비워둔다.

 

3. 브라우저가 리액트 컴포넌트 트리를 구성한다. 브라우저가 서버로 스트리밍으로 받은 JSON 구문을 다시 파싱하여 트리를 재구성한다. 클라이언트 컴포넌트를 받았다면 클라이언트에서 렌더링을 진행하고 서버에서 만들어진 결과물을 받았다면 이를 기반으로 리액트 트리를 그대로 만든다.

 

결론적으로 이 리액트 서버 컴포넌트는 완전히 새롭게 나온 개념이며 기존 리액트 컴포넌트가 가지고 있던 한계를 극복하기 위해 나왔다.

 

 

 

Next.js에서의 리액트 서버 컴포넌트

 

Next.js도 13버전에 들어가며 서버 컴포넌트를 도입하였고 이 서버컴포넌트는 /app 디렉토리에 구현되어 있다. 서버컴포넌트의 제약 자체는 동일하다. 서버 컴포넌트는 클라이언트 컴포넌트를 불러올 수 없고 서버 컴포넌트를 children props로만 받을 수 있다. 위에서 루트 컴포넌트를 무조건 서버 컴포넌트여야 한다고 했다. 즉, page.js와 layout.js는 반드시 서버 컴포넌트여야 한다.

 

 

이 외에는  대부분 리액트 서버 컴포넌트에서 제공하는 내용과 별다른 차이가 없으며 Next.js에서 서버컴포넌트를 도입하며 생긴 몇가지 변화가 있다.

 

새로운 fetch 도입, getServerSideProps, getStaticProps, getInitialProps 삭제

과거 Next.js의 서버사이드 렌더링과 정적 페이지 제공을 위해 사용되던 해당 메서드들이 삭제되었으며 모든 데이터 요청은 fetch를 기반으로 이뤄지고 있다.

 

async function getData(){
  //데이터 불러오기

  const result = await fetch("https://api.example.com")

  if(!result.ok){
    //에러가 던져지면 가장 가까운 에러 바운더리로 전달
    throw new Error('실패')
  }

  return result.json()
}

//async 서버 컴포넌트 페이지
export default async function Page(){
  const data= await getData()

  return (
    <main><Children data={data}></Children></main>
  )
}

 

 

위처럼 컴포넌트가 비동기적으로 작동하는게 가능해진다. 이제 서버 컴포넌트는 데이터가 불러오기 전까지 기다렸다가 데이터가 불러와지면 페이지가 렌더링되어 클라이언트로 전달되게 된다. 

 

2023년 5월 기준으로는 TS가 비동기 컴포넌트를 정식으로 지원하지 않는다. 

 

 

리액트팀은 이에 더불어 fetch API를 확장하여 서버 컴포넌트 트리 내에 동일한 요청이 있는 경우 재요청이 발생하지 않도록 요청 중복을 방지했다. 해당 fetch 요청에 대한 내용을 서버에서 렌더링이 한번 끝날때까지 캐싱하며 클라이언트에서는 별도 지시자나 요청이 없는 이상 해당 데이터를 최대한 캐싱하여 중복된 요청을 방지한다.

 

 

정적 렌더링과 동적 렌더링

과거 Next.js에서는 getStaticProps를 활용하여 정적으로 페이지를 만들어서 제공했었다. 해당 기능을 활용하면 주소에 들어오는 결과물이 변하지 않기 때문에 기존 서버사이드 렌더링보다 더 빠르게 데이터를 제공할 수 있었다.

 

13버전에서는 빌드 타임에 렌더링을 미리 해두고 캐싱하여 재사용할 수 있게끔 해두었으며 동적인 라우팅에 대해서는 서버에 요청이 올때마다 컴포넌트를 렌더링하도록 변경했다.

 

// Nest.js app/Page.tsx

async function fetchData() {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts`);
  const data = await res.json();
  return data;
}

export default async function Page() {
  const data: Array<any> = await fetchData();

  return (
    <ul>
      {data.map((item, key) => (
        <li key={key}>{item.id}</li>
      ))}
    </ul>
  );
}

 

 

Next.js 로 위 코드를 Page.tsx에 넣고 빌드시킨 후 빌드된 파일을 봐보면 아래와 같이 api요청의 결과가 미리 만들어져 있음을 알 수 있다. 주소가 정적으로 결정되어 있기 때문에 빌드 시 해당 주소로 요청을 해서 레더링한 결과를 빌드에 넣어두었다.

 

 

해당 주소를 정적으로 캐싱하지 않는 방법도 있다.

 

async function fetchData() {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts`, {
    cache: "no-cache", //캐시하지 않음
  });
  const data = await res.json();
  return data;
}

export default async function Page() {
  const data: Array<any> = await fetchData();

  return (
    <ul>
      {data.map((item, key) => (
        <li key={key}>{item.id}</li>
      ))}
    </ul>
  );
}

 

이 경우 Next.js는 해당 요청을 미리 빌드해서 대기시켜주지 않고 요청이 올때마다 fetch 요청 후에 렌더링을 수행하게 된다.

 

 

 

캐시와 mutating, revalidating

Next.js는 fetch의 기본 작동을 재정의하여 {next : {revalidate? : number | false}} 를 제공한다. 해당 데이터의 유효시간을 정해두고 시간이 지나면 페이지를 렌더링 하는 것이다. 

 

//app/page.tsx

export const revalidate = 60

 

루트에 위 코드를 선언해두면 하위에 있는 모든 라우팅에서는 페이지를 60초 간격으로 갱신하여 새로 렌더링하게 된다. 

 

1. 최초로 라우트로 요청이 오는 경우 정적으로 캐시해둔 데이터 표시

2. 캐시된 요청은 revalidate 선언된 값만큼 유지

3. 시간이 지나도 일단은 캐시된 데이터 표시

4. Next.js는 캐시된 데이터를 보여주면서 백그라운드에서 다시 데이터를 불러옴

5. 4번 작업이 끝나면 캐시된 데이터를 갱신

 

 

 

스트리밍을 활용한 점진적 페이지 불러오기

과거 서버 사이드 렌더링을 보자. 요청받은 페이지를 모두 렌더링 하기 전까지는 사용자에게 아무것도 보여줄 수 없다. 또한 페이지를 다 받아도 해당 페이지는 사용자가 인터랙션할 수 없는 페이지이며 하이드레이션 과정이 끝나야지만 온전한 페이지가 된다.

 

가장 큰 문제는 해당 과정이 순차적인 방법으로 이루어진다는 점이다. 하나의 페이지가 완료될 때까지 기다리는 것이 아닌 HTML을 쪼개서 완성되는 대로 클라이언트로 점진적으로 보내는 스트리밍이 도입되었다. 이를 활용하면 사용자가 일부라도 페이지와 인터렉션 하는 것이 가능해진다.

 

모든 컴포넌트를 기다리는것이 아닌, 컴포넌트가 완성되는 대로 클라이언트에게 내려주면 사용자는 페이지가 로딩중이라는 인식을 더 명확하게 심어질 수 있다.

 

이 스트리밍을 활용할 수 있는 방법은 두가지가 있다.

 

1. 경로에 loading.tsx배치

2. Suspense 배치

 

suspense를 배치하면 loading보다 좀 더 세분화된 제어를 할 수 있다.

 

 

 

728x90