10분 만에 React로 SSR 구현하기 (feat. Next.js 소스코드)

Daren Kim·2024년 2월 12일
0

React18

목록 보기
1/2

최근들어 프론트엔드 생태계에서 가장 큰 트렌드를 이끄는건 아무래도 Next.js가 아닐까?
기존 SPA 기반의 클라이언트 사이드 렌더링을 구축해주던 React를 기반으로 운용되던 많은 웹페이지들은 이제 서버에서 완성된 HTML을 전달해주는 SSR 기반의 프레임워크(next.js, replay 등,,,)로 변화하고 있다.

그렇다면, SSR은 어떻게 동작하며 react로 SSR을 어떻게 구현할 수 있을까?
더하여 Next.js는 어떻게 이걸 가능하게 했을까? 한번 알아보자!

SSR?

React로 대표되는 CSR은 클라이언트에서 빈 html을 받아와 필요한 JS 코드들을 요청해 빠르게 그려낸다.
하나의 html파일을 통해 렌더링 되기 때문에 이를 통해 기존의 SSR에서 대두되던 문제인 페이지 변경시의 화면 깜빡임 등의 문제를 해결할 수 있었다.

CSR은 먼저 클라이언트가 요청을 보내면 빈 html파일을 보내주며 그 내부에 JS 파일을 불러와 화면을 그리고 화면이 그려지는 동시에 사용자는 Interaction 할 수 있게된다.

CSR의 장점

  • 빠른 인터랙션: 초기 로딩 후, 페이지 내에서의 사용자 인터랙션이 빠르다. 자원이 브라우저에 이미 로드되어 있기 때문에, 사용자가 다른 페이지로 이동할 때 서버로부터 새로운 HTML을 가져올 필요가 없다.
  • 부드러운 사용자 경험: 페이지를 새로 로딩할 필요 없이 UI를 동적으로 업데이트할 수 있어, 애플리케이션 사용이 더 부드럽고 반응이 빠른편.
  • 백엔드 부하 감소: 서버가 초기 요청에만 HTML을 제공하고, 이후의 동적 데이터 요청은 API를 통해 처리되므로 서버 부하의 감소가 가능

CSR의 단점

  • 초기 로딩 시간: 애플리케이션의 첫 페이지 로딩 시 모든 자원을 다운로드해야 하므로 초기에 로딩 시간이 길어질 수 있다.
  • SEO 문제: 전통적인 CSR은 검색 엔진 최적화(SEO)에 불리. 검색 엔진이 JavaScript를 실행하여 콘텐츠를 인덱싱하기 전에 빈 HTML 페이지만 볼 수 있기 때문.

다시 등장하게 된 SSR

실질적인 서비스에서 SEO는 특히 중요하다. 잘 만든 서비스여도 사용자들에게 노출되지 않는다면 의미가 없지 않을까? 그런측면에서 등장하게 된 SSR (Server-Side Rendering)은 웹 페이지의 초기 로딩 시, 서버에서 HTML, CSS, JavaScript 등을 미리 렌더링하여 클라이언트로 전송하는 방식이다.

SSR의 장점

  • 빠른 초기 로딩: 사용자가 처음 사이트를 방문할 때, 서버에서 미리 렌더링된 페이지를 바로 받아볼 수 있어 초기 콘텐츠의 로딩 시간 단축.
  • 검색 엔진 최적화(SEO) 향상: 서버에서 렌더링된 페이지는 즉시 검색 엔진에 의해 크롤링될 수 있어, JavaScript를 로드하고 실행하는 시간이 필요 없으므로 SEO 성능이 향상 및 노출 가능.

SSR의 단점

  • 서버 부하 증가: 모든 사용자 요청에 대해 서버에서 페이지를 새로 렌더링해야 하므로, 트래픽이 많은 사이트의 경우 서버 부하가 증가.
  • 페이지 전환 지연: 사용자가 사이트 내 다른 페이지로 이동할 때마다 서버로부터 새로운 HTML을 요청하고 받아야 하므로, 페이지 전환에 딜레이가 발생할 수 있다.

위에서 얘기했듯, SSR은 완성된 HTML을 보내주기 때문에 페이지를 이동할 때마다 서버로부터 새로운 HTML을 요청하고 받아야 하기에 화면 깜빡임과 로딩 속도 등 SPA가 갖고 있는 장점들을 포기하게 만든다. 그러나 최근엔 이러한 SSR의 장점과 CSR의 장점을 합쳐 Universal rendering 이라고 하는 방식으로 구현된다.

Universal Rendering

Universal Rendering은 두가지 방식을 결합해 최초 렌더링 시에 SSR 방식으로 완성된 HTML을 보내주지만 hydration 과정을 통해 전송되어진 HTML에 react가 입혀져 사용자 인터렉션 및 페이지 이동시에 CSR 처럼 동작하는걸 이야기한다.

전통적인 SSR과의 차이점은 hydration이라는 개념을 통해 미리 렌더링된 HTML에서 React가 동작 할 수 있도록 생명을 불어넣어 주는 것이다.

React로 직접 SSR 구현해보기

어느정도 기본적인 개념은 알았으니 우리가 사용하는 react로 직접 만들어보자!
일단 SSR이라면 당연히 응답을 해서 완성된 HTML을 전해줄 서버가 필요하다.
node / express 기반의 서버와 react 의 클라이언트로 구현하고자 한다.

기본설정

위와 같이 기본 폴더 구조를 잡아주고 설정을 마쳤다.
webpack을 통해 번들링이 완료된 react 파일을 hydration 하기 위해 웹팩과 기타 설정 모두 기본적인 설정으로 진행했다.

import { useState } from "react";
import Button from "./Button";

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      Count: {count}
      <button onClick={() => setCount((count) => count + 1)}>
        Increase count
      </button>
      <Button></Button>
    </div>
  );
};

export default App;

가장 많이 쓰이는 기본적인 counter 예제...
버튼을 누르면 counter가 증가하는 기본적인 예제이다.
여기까지는 react의 그것과 크게 다르지 않다.
그러나 SSR을 구현하기 위해선 위에서 말했던, 렌더링 된 HTML에 JS을 스며들게 하는 hydration 과정이 필요한데 최상위에서 root를 렌더링해주는 파일에서 진행하게 된다.

hydrateRoot

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

일반적인 경우엔 위와 같이 빈 HTML파일을 서버로부터 응답받고, render 함수를 이용해 컨텐츠를 생성하여 DOM을 채워넣는다. 이 과정에서 자연스럽게 React는 현재의 DOM을 알게 된다.
그러나 SSR을 통해 이미 완성된 HTML이 존재하는 상황에서 React의 render함수를 통해 다시 컨텐츠를 생성해내고 DOM을 채워넣는건 비효율적이다.
그래서 React는 hydrate라는 api를 제공해, 기존에 존재하는 HTML의 내용을 React가 흡수 할 수 있도록 해준다.
이제 React는 HTML내용을 기반으로 가상돔을 생성하고 이 가상돔으로 DOM을 조작해 사용자가 interaction 할 수 있도록 한다.

import ReactDOM from "react-dom/client";
import App from "./App";

ReactDOM.hydrateRoot(document.getElementById("root") as HTMLElement, <App />);

HTML이 완성되는 시점에는 차이가 있지만, 최초의 HTML은 빈 HTML 자체여야 하는건 변함이 없다. 마찬가지로 root 아이디를 가진 div 하나만 가진 HTML 역시 만들어주자!

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React SSR</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

현재 기존에 사용되던 hydrate 메소드가 아닌 hydrateRoot 메소드를 사용하도록 문서에서 권장되고 있다. 이에 관해선 React 18 부터 도입된 concucurrent Mode와 관련이 큰데 이와 관련해서 추후에 더 깊이 학습 후 글을 작성해보고자 한다.
deprecated 관련 공식 문서
hydrateRoot 공식문서

서버에서는?

클라이언트에서는 완성된 HTML을 받아 hydration 해줄 준비를 마쳤다!
그렇다면 서버에선 이제 미리 HTML을 완성해야만 한다.
기본적인 express 서버 세팅에 관련한 부분은 건너 뛰고 먼저 HTML파일을 읽어오도록 하자

const html = fs.readFileSync(
  path.resolve(__dirname, "../client/index.html"),
  "utf-8"
);

위와 같이 빈 html 파일을 먼저 읽어 온뒤 get 요청이 들어오면 아래와 같이 응답한다.

app.get("/", (req, res) => {
  const renderString = ReactDOMServer.renderToString(<App />);
  console.log(renderString);
  res.send(
    html.replace(
      '<div id="root"></div>',
      `<div id="root">${renderString}</div>`
    )
  );
});

여기서 눈여겨 봐야할 부분은 ReactDomServer.renderToString 메소드이다.
해당 메소드는 인자로 받은 HTML 요소를 문자열로 만들어준다.
이를 통해 문자열화 된 컴포넌트들을 root div 내부에 끼워넣어 주어 완성된 화면을 사용자에게 전달하게 된다.

import express from "express";
import fs from "fs";
import path from "path";
import ReactDOMServer from "react-dom/server";
import App from "../client/App";

const app = express();
const html = fs.readFileSync(
  path.resolve(__dirname, "../client/index.html"),
  "utf-8"
);

app.get("/", (req, res) => {
  const renderString = ReactDOMServer.renderToString(<App />);
  console.log(renderString);
  res.send(
    html.replace(
      '<div id="root"></div>',
      `<div id="root">${renderString}</div>`
    )
  );
});
app.use("/", express.static("dist/client"));

app.listen(3000, () => {
  console.log("Server is listening on port 3000");
});

완성된 서버 파일은 위와 같다.

이제 이렇게 완성된 파일을 localhost에서 띄워보면..?

결과

해당 클라이언트 요청에 서버에서 완성된(내부가 잘 채워진) HTML파일이 응답으로 넘어오는걸 확인할 수 있었다.
여기서 물론 사전 data fetching이나 다소 복잡한 로직들은 넣지 않은채 진행해보았지만 대략 어떤 식으로 돌아가는지 알 수 있어 꽤 재밌는 시도였다.
여기서 문득 드는 궁금증은... 가장 인기 있다는 next.js는 어떻게 이런 흐름을 구현했을까?
궁금하면 구경해보자!

Next.js는?

일단 키워드는

  • 서버가 html을 완성해야 한다는것.
  • hydration을 통해 react가 가상돔을 생성해야 한다는것.

이 두가지에 기반하여 Next.js의 소스코드를 구경해보았다.
물론 아직 모르는 부분이 많고 Next.js의 소스코드가 워낙 방대하여 모든 내용을 전부 다 알진 못했지만 그럼에도 불구하고 그 흐름을 파악해보고자 했다.

packages/next/src/server/render.tsx

소스코드에서 확인해보니, 내가 사용했던 renderToString 메소드와는 약간 다른 부분을 찾을 수 있었다. 바로 renderToString이 아닌 renderToReadableStream을 사용하는것! 뭐가 다른걸까?

renderToString vs renderToReadableStream

renderToReadableStream은 react18에 새로 추가된 API이다.
가장 큰 차이점은 동기와 비동기 방식으로 동작하는 메소드라는것.
공식문서

동기와 비동기

아무래도 렌더링해야 하는 앱의 규모가 크면 클 수록 동기적으로 실행되는 renderToString은 다음 코드 실행을 막게 된다. 그러나 renderToReadableStream은 프로미스 객체를 반환하는 api로, 비동기적으로 실행되어 코드실행을 막지 않는다.

스트리밍 방식

예전에 네트워크의 TCP와 UDP의 차이를 공부하며 동영상 스트리밍 방식에 대하여 학습한 적이 있다. 이때 보다 자세하게 스트리밍의 동작방식에 대해 알게 되었는데, 데이터를 잘게 나누어 버킷에 담아 동시적으로 영상을 재생하며 다음에 재생할 영상 조각들을 버킷에 담는것이라고 공부했었다. renderToReadableStream 역시 마찬가지로 HTML을 작은 조각(청크)로 나누어 클라이언트로 전송한다.
이 역시 react에서 얘기하는 동시성 모드와 관련이 있다고 생각한다.
이를 통해 사용자는 더 먼저 렌더링 되는 컴포넌트를 빠르게 화면에서 볼 수 있으며 이를통해 사용자 경험을 상승 시킬 수 있다.

suspense 지원 여부

renderToString does not fully support Suspense.
renderToString은 Suspense를 완전히 지원하지 않습니다.

해당 내용을 학습하며 가장 많이 봤던 내용은 react의 concurrent 모드와 suspense에 관한 내용이다.
renderToReadableStream은 suspense를 지원한다.
아직은 suspense가 fallback을 통해 컴포넌트를 부분적으로 렌더링 가능하게 해준다는것 정도로만 알고 있기에 추후에 이 부분에 대해 깊이 학습하여 글을 써보고자 한다.

hydrateRoot


packages/next/src/client/index.tsx

next.js도 위에서 사용했던, hydrateRoot를 통해 전달받은 요소를 hydrate 해주는걸 확인할 수 있었다.

마치며

이제까지 CSR 방식으로만 개발을 해왔기에 SSR에 대하여 지식이 전무한 상태였다. next.js라는 프레임워크가 각광받고 인기를 끌고 있지만 결국 그 핵심적인 방식이 어떻게 흘러가는지 알고 싶어 이렇게 조촐하지만 직접 구현해보고 next.js가 어떻게 구현했는지 알아보는 과정을 거쳤다.
언제나 기술은 빠르게 변화하고 라이브러리나, 프레임워크나 결국은 도구라고 생각한다.
보다 코어한 부분에 대하여 이해하고 있다면 어떤 도구를 사용하던, 빠르게 적응 할 수 있으리라고 믿는다.

아무래도 당장에 직면한 과제는 지속적으로 나왔던 suspense, 그리고 react concurrent mode. 더하여 서버컴포넌트까지! 사용되는 메소드들이 변화하는 과정엔 저 개념들이 배경으로 깊게 자리하고 있는듯 하다.
해당 내용들을 하나하나 깊이 공부해 블로그 글을 추가해보고 싶다.

참고자료

next.js github
react 공식문서
next.js 공식문서
milban.dev
howdy-mj.me

profile
안녕하세요!여기저기관심많은FE개발자지망생입니다.

0개의 댓글