코린이의 개발 일지

[Next.js] Next 동작 원리를 알아보자 본문

웹 (web)/프론트엔드

[Next.js] Next 동작 원리를 알아보자

폴라민 2022. 12. 3. 21:05
반응형

Next.js란?

  • 서버사이드 렌더링, 정적 웹 페이지 생성 등 리액트 기반 웹 애플리케이션 기능들을 가능하게 하는
  • Node.js위에서 빌드된
  • 오픈소스 웹 개발 프레임워크

SSR을 이해해보자

  • 우선 SSR을 이해해보자

SSR이란?

  • 서버에서 사용자에게 보여줄 페이지를 모두 미리 구성한 뒤 페이지를 렌더링 하는 방식

SSR과 CSR의 차이점

  • 리액트는 CSR 기반이고 Next는 SSR 기반이라는건 알겠는데, 그럼 정확히 동작이 어떤식으로 흘러간다는 걸까?
  • 아래의 그림을 보면 좀 더 명확히 이해가 가능하다.

  • 가장 주요한 차이점은 SSR의 경우 브라우저에 대한 서버의 응답이 렌더링할 준비가 된 페이지이고, CSR은 자바스크립트만 연결된 거의 빈 페이지를 응답으로 받는다.
  • 즉 SSR은 자바스크립트가 다운로드되고 실행될 때까지 기다릴 필요없이 서버에서 Html 렌더링을 시작한다.
  • SSR, CSR모두 React를 다운로드하고, 가상돔 구축하고 이벤트 첨부해서 페이지를 interactable하게 만드는 process는 동일하게 거쳐야 한다.
  • 하지만 SSR의 경우 위의 작업이 진행되는 동안 페이지를 볼 수 있다.
  • CSR은 위의 프로세스를 모두 끝마친 후에, 가상돔을 브라우저 돔으로 옮겨야 비로소 페이지를 볼 수 있다.

그렇다면 SSR의 단점은 어떤것이 있을까?

  • 첫째는 단점이라기 보다는 그럼에도 불구하고 안되는 부분인데, 결국 React 실행이 완료될 때까지 interactive한 동작은 수행할 수 없다는 것이다.
  • TTFB (Time To First Byte)가 CSR보다 느리다. 서버가 페이지의 Html을 구성하는데 시간을 소비해야하기 때문에 서버 첫 응답이 상대적으로 느릴 수 밖에 없다.
  • next는 서버에서 HTML을 렌더링 할 때, ReactDOMServer.renderToString() 을 이용해 ReactNode를 HTML 문자열로 만든다. ReactDOMServer.renderToString() 는 동기식으로 작동하며 따라서 서버거 이 메소드를 완료할 때까지 다른 요청을 처리할 수 없다. 따라서 서버가 1초에 처리할 수 있는 처리량이 CSR보다 훨씬 적다.

그럼에도 불구하고 SSR을 쓰는 이유는 초기 화면이 완성되어 있다는 점이 서비스 측면에서 아주 큰 메리트이기 때문이다.

SEO 관점에서 봐보자.

검색엔진 봇이 웹 사이트의 콘텐츠를 확인하는 상황에서 아래와 같은 코드는 웹 사이트의 가시성이 떨어진다.

 

 

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>hihi</title>
    <link href="/styles.css" rel="stylesheet"></head>
  <body>
    <div id="app">
    </div>
  <script type="text/javascript" src="/app.js"></script></body>
</html>

 

 

봇이 보기에, 콘텐츠의 제목도, 이미지도 파악할 수 없다.

따라서 SSR을 사용하면 서버에서 전체 html문서를 받을 수 있고, 봇은 콘텐츠를 파악할 수 있으니 SEO 관점에서 유리하다 볼 수 있다.

좀더 구체적인 SSR과 CSR의 비교는 아래 링크를 통해 확인할 수 있다.

https://medium.com/walmartglobaltech/the-benefits-of-server-side-rendering-over-client-side-rendering-5d07ff2cefe8

https://polarlsm.notion.site/SSG-SSR-CSR-4b62ca823f4c46819ff98455cfe39c8a

 

 

Next 동작 원리

  • 자 그럼 SSR이 뭔지 알았는데 next는 이걸 어떤 방식으로 사용하고 있는지 알아야한다.
  • 한줄로 next가 하는 일을 설명해 보자면, 웹 페이지를 처음 방문할 때 서버에 요청해서 응답으로 전체 html 코드를 렌더링하고 그 이후에는 React SPA로 브라우저내에서 라우팅 처리를 한다.

좀더 구체적으로 얘기를 해보자면

  1. 사용자가 홈페이지에 접속하면(최초 접속) 클라이언트에서는 이를 확인하고 서버로 요청
  2. 서버에서는 미리 구성된 HTML, CSS 파일을 클라이언트에 전달 (Pre-rendering)
  3. 이 과정에서 동시에 클라이언트는 스크립트 파일을 수행 (Hydration)
    1. Hydration: js 파일을 html에 연결하는 과정
  4. 페이지 이동 및 동작이 발생하는 경우에, CSR 방식을 통해서 서버를 거치지 않고 브라우저에서 페이지를 이동

그럼 Next에서 내부적으로 언제 SSG가 적용되고 언제 SSR이 적용되고 언제 CSR이 적용되는 지 알아보자.

우선 Next는 기본적으로 SSG로 작동한다.

SSG는 빌드할때, html 파일을 생성하는 방식인데,

Next로 작업을 하면 Page 폴더에 생성한 tsx파일들은 모두 하나의 페이지가 된다.

빌드를하면 next는 기본적으로 모든 Page들을 html 파일로 생성한다.

  • 왼쪽은 프로젝트 개발할 때, 만든 페이지들
  • 오른쪽 두개는 빌드한 후에 .next > server > pages 폴더에 생긴 정적 파일들

 

 

 

 

일단 Next는 기본적으로 이 골격들을 가져다가, CSR로 js나 나머지, 스타일 등등을 덧붙이는 것이다.

즉 각각의 페이지들이 있고 각각의 페이지 내에서는 SPA처럼 동작하는 것이다.

아래는 Next.js로 만든 프로젝트의 가장 첫 응답으로 오는 html문서의 Preview이다. (정적으로 미리 생성된 html)

 

 

 

 

 

하지만 이 정적 파일들은 빌드할때 생성되기 때문에 새로고침을 해도 계속 같은 내용이다.

만약 새로고침시 새로운 데이터들을 반영하고 싶다면, useEffect를 써서 CSR로 새로 정보를 입혀주거나, SSR 방식을 사용할 수 있다.

 

 

 

SSR을 쓰려먼 어떻게?

만든 페이지에다가 getServerSideProps로 데이터를 요청해와서 props로 내려주면된다.

export async function getServerSideProps({ query: { pid } }: GetServerSidePropsContext) {
  const response = await httpGet(`/post/${pid}`);
  // Pass data to the page via props
  return {
    props: {
      response,
    },
  };
}

 

 

이런식으로 내려준 데이터들은 아래와 같이 서버에 요청 응답으로 받은 html 파일에 적용되어 오는 것을 확인 할 수 있다.

 

 

<script id="__NEXT_DATA__" type="application/json">
   {"props": {"pageProps":{"response":{//우리가 넣어준 데이터}}}
</script>

 

 

그리고 이 데이터들은 SSR이라서 새로고침 할때마다, 새로 요청을 보내서 html 에 적용시켜준다.

만약 SSG로 데이터 요청은 하되 새로고침시 매번 데이터 갱신 해줄 필요가 없다면?

(빌드할 때만 반연되면 된다면)

getStaticProps 메소드를 사용하면된다.

자 그럼 서버에서 html이 어떤식으로 넘어오는지 알았다. 이제 이 html에 CSR로 추가적인 데이터, 이벤트 등을 붙여줄 차례다 (javaScript)

 

 

 

받아온 HTML에 CSR이 동작하는 방식

Next 소스코드를 좀 살펴봤다.

// client/next.js

initialize({})
  .then(() => hydrate())
  .catch(console.error);

 

 

보면 initialize를 거친 후에 hydrate()되는 것을 확인할 수 있는데,

initialize는 보면 서버에서 렌더링한 html에서 NEXT_DATA를 브라우저의 전역 객체 (window.NEXT_DATA)로 저장하는 것을 확인할 수 있다.

즉 우리가 getServerSideProps메소드로 요청 받아온 데이터를 이제 브라우저에서 가져다 쓸 수 있는 상태가 되었다.

 

 

// next.js/packages/next/client/index.tsx

export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{
  assetPrefix: string;
}> {
  initialData = JSON.parse(document.getElementById('__NEXT_DATA__')!.textContent!);
  window.__NEXT_DATA__ = initialData;

  const prefix: string = initialData.assetPrefix || '';

  appElement = document.getElementById('__next');
  return { assetPrefix: prefix };
}

 

 

그 다음 hydrate 함수를 실행한다.

여기서는 실행하려는 페이지에 에러가 있는지 확인하고 없으면 렌더링할 때 필요한 context들을 render()의 인자로 넘겨준다. (initialData.props가 우리가 서버에서 미리 api 요청해서 응답받은 데이터이다.)

 

 

// next.js/packages/next/client/index.tsx

export async function hydrate(opts?: { beforeRender?: () => Promise<void> }) {
  // ...
  const renderCtx: RenderRouteInfo = {
    App: CachedApp,
    initial: true,
    Component: CachedComponent,
    props: initialData.props,
    err: initialErr,
  };

  render(renderCtx);
}

 

 

render 함수 내에서 context를 받아 doRender함수로 넘겨주고, doRender함수에서 React.StrictMode로 감싼뒤 renderReactElement 함수를 호출한다.

 

 

async function render(renderingProps: RenderRouteInfo): Promise<void> {
  // ...
  await doRender(renderingProps);
}

function doRender(input: RenderRouteInfo): Promise<any> {
  // ...
  renderReactElement(appElement!, (callback) => (
    <Root callbacks={[callback, onRootCommit]}>
      {process.env.__NEXT_STRICT_MODE ? <React.StrictMode>{elem}</React.StrictMode> : elem}
    </Root>
  ));
}

 

그리고 renderReactElement()에서 ReactDOM을 render한다.

 

let shouldHydrate: boolean = true; // 첫 렌더에서는 항상 true이다

function renderReactElement(domEl: HTMLElement, fn: (cb: () => void) => JSX.Element): void {
  //...
  const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete);

  if (!reactRoot) {
    // Unlike with createRoot, you don't need a separate root.render() call here
    reactRoot = ReactDOM.hydrateRoot(domEl, reactEl)
    // TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing
    shouldHydrate = false
  } else {
    const startTransition = (React as any).startTransition
    startTransition(() => {
      reactRoot.render(reactEl)
    })
  }
}

 

 

여기서 Next.js는 서버에서 HTML을 문자열로 가져온 후에 html을 hydrate()혹은 render()하여 브라우저에 렌더링되는데 이과정을 hydration이라고 한다.

React에서는 client 렌더링만 있어서 유저에게 보여줄 HTML, CSS그리고 자바스크립트 모두 render()함수를 이용해 생성한다.

그러나 Next에서는 서버에서 보여줄 HTML 컨텐츠를 가져오기 때문에 다시 render()함수를 호출하여 root부터 DOM을 그리는 일은 비효율적이다.

따라서 hydrate()함수로 서버에서 받아온 HTML에 React를 붙이는 방식으로 동작한다.

아래는 React 공식문서에 나온 hydrate메소드를 사용하는 방식이다.

hydrate(reactNode, domNode);

이렇게 호출하면 React는 reactNode안에 있는 HTML에 붙고, 그 안에 있는 DOM을 관리한다.

즉 reactNode는 서버에서 renderToString()으로 render 한 (즉 서버에서 보내준) html이고 여기안에 domNode를 붙여준다. 따라서 원래 CSR로 생각하면 domNode는 root이다.

ReactDOM.render(<App />, document.getElementById('root'));

따라서 정리하자면 서버에서 props가 있으면 그 데이터를 넣어서 첫 html 정적 파일을 생성해주고,

그 html이 클라이언트로 넘어오면 첫 렌더는 shouldHydrate가 true이니까 (넘어온 html이 있으니까) 여기에 hydrate방식으로 추가할 dom만 넣어주고,

해당 페이지 내에서 페이지가 바뀌는게 아닌 컴포넌트만 바뀌는 경우 shouldHydrate가 false로 되고, ReactDOM.render로 CSR방식으로 동작한다.

Next.js의 장점을 살리기 위해서는 빌드시 정적파일로 생성되는 html파일들을 효율적으로 잘 활용하는 것이다.

 

반응형
Comments