공부블로그

19장 코드 스플리팅, 20강 서버 사이드 렌더링 본문

리액트/리액트를 다루는 기술

19장 코드 스플리팅, 20강 서버 사이드 렌더링

떠어영 2021. 2. 18. 11:46

19강. 코드 스플리팅 

리액트 프로젝트를 완성하여 제공할 때는 빌드 작업을 거쳐서 배포해야 한다. 이를 통해 파일 크기를 최소화하고, 문법이 원활하게 실행되도록 코드의 트랜스파일 작업도 할 수 있다.

이 작업은 웹팩이라는 도구가 담당한다. CRA( create react-app )의 기본 웹팩 설정에는 SplitChunks라는 기능이 적용되어 node-modules에서 불러온 파일, 일정 크기 이상의 파일, 여러 파일 간에 공유된 파일을 자동으로 따로 분리시켜서 캐싱의 효과를 누릴 수 있게 해준다. 

 

코드 비동기 로딩을 통해서는 자바스크립트 함수, 객체, 혹은 컴포넌트를 처음에 불러오지 않고 필요한 시점에 불러와서 사용한다. 

 

 

19. 1 ) 자바스크립트 함수 비동기 로딩

 

notify. js

export default function notify() {
  alert("안녕하세요!");
}

App. js

import logo from "./logo.svg";
import "./App.css";
import React from "react";
//import notify from "./notify";

function App() {
  const onClick = () => {
    //notify(); 이건 notify 코드가 main 파일 안에 들어감.
    import("./notify").then((result) => result.default());
    //import를 함수로 사용하면 Promise를 반환한다.
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p onClick={onClick}>Hello React!!</p>
      </header>
    </div>
  );
}

export default App;

 

따로 import함수를 사용하면 파일을 따로 분리시켜서 저장하고, 실제 함수가 필요한 지점에서 파일을 불러와 사용할 수 있다.

이렇게 작성한 후 빌드를 해주면 build/ static/ js의 디렉터리의 3으로 시작하는 파일 안에 notify 관련 코드가 들어간다.

 

 

19. 2 ) React. lazy와 Suspense를 통한 컴포넌트 코드 스플리팅

 

< React. lazy 유틸 함수없이 컴포넌트와 코드를 스플리팅 >

 

스플리팅할 간단한 컴포넌트를 만든다. ( SplitMe. js )

App 컴포넌트를 클래스형 컴포넌트로 바꿔서 handleClick메서드를 만들고 그 내부에서 SplitMe 컴포넌트를 불러와 state에 넣어준다. render 함수에서는 const { SplitMe } = this. state 를 작성하고 SplitMe 가 유효하다면 해당 컴포넌트를 렌더링한다.

 

< React. lazy와 Suspense 사용하기 >

 

React. lazy는 컴포넌트를 렌더링하는 시점에서 비동기적으로 로딩할 수 있게 해 주는 유틸함수이다. 

사용법 : const SplitMe = React. lazy(( ) => import( '. /SplitMe ));

 

Suspense는 리액트 내장 컴포넌트로서 코드 스플리팅된 컴포넌트를 로딩하도록 발동시킬 수 있고, 로딩이 끝나지 않았을 때 보여줄 UI를 설정할 수 있다.

사용법 : import React, { Suspense } from 'react';

           ...

           <Suspense fallback={<div>loading...<div>}> //fallback props를 통해 로딩 중에 보여줄 JSX를 지정할 수 있다.

                <SplitMe />

           </ Suspense>  

 

App. js

import logo from "./logo.svg";
import "./App.css";
import React, { Suspense, useState } from "react";

const SplitMe = React.lazy(() => import("./SplitMe"));
// 컴포넌트를 렌더링하는 시점에서 비동기적으로 로딩

function App() {
  const [visible, setVisible] = useState(false);
  const onClick = () => {
    setVisible(true);
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p onClick={onClick}>Hello React!</p>
        <Suspense
          fallback={<div>loading...</div>}
          //코드 스플리팅된 컴포넌트를 로딩, fallback을 통해 로딩이 끝나지 않았을 때 보여줄 UI설정
        >
          {visible && <SplitMe />}
        </Suspense>
      </header>
    </div>
  );
}

export default App;

< 코드 스플리팅을 편하게 해주는 서드파티 라이브러리 Loadable Component를 통한 코드 스플리팅 >

 

서버 사이드 렌더링을 지원하고 렌더링하기 전에 필요할 때 스플리팅된 파일을 미리 불러올 수 있다.

서버 사이드 렌더링을 사용하면 웹 서비스의 초기 렌더링을 사용자의 브라우저가 아닌 서버 쪽에서 처리한다. 

 

App. js 

import logo from "./logo.svg";
import "./App.css";
import React, { useState } from "react";
import loadable from "@loadable/component";

const SplitMe = loadable(() => import("./SplitMe"), {
  fallback: <div>loading...</div>, //로딩 중에 보여줄 UI는 loadable함수에서 선언
});
//React.lazy 대신 loadable

function App() {
  const [visible, setVisible] = useState(false);
  const onClick = () => {
    setVisible(true);
  };
  const onMouseOver = () => {
    SplitMe.preload(); //컴포넌트 미리 불러오기
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p
          onClick={onClick}
          onMouseOver={onMouseOver}
          //마우스를 올리기만 해도 로딩이 시작
        >
          Hello React!
        </p>
        {visible && <SplitMe />}
      </header>
    </div>
  );
}

export default App;

19장에서는 코드 스플리팅이 무엇인지, 컴포넌트를 어떻게 분리된 파일로 저장하고 또 비동기적으로 불러와서 사용하는지 알아보았다. React.lazy와 Suspense를 사용할 수도 있고 서버 사이드 렌더링을 지원하는 Loadable을 사용할 수도 있다.

 

 

20강. 서버 사이드 렌더링

서버 사이드 렌더링은 UI를 서버에서 렌더링하는 것이다. 앞에서 만든 리액트 프로젝트는 기본적으로 클라이언트 사이드 렌더링을 하고 있다. 즉, 처음엔 빈 페이지이지만 자바스크립트가 실행되고 리액트 컴포넌트가 렌더링되면서 우리에게 보이게 된다. 

서버 사이드 렌더링을 구현하면 사용자가 웹 서비스에 방문했을 때 서버쪽에서 초기 렌더링을 대신해 주고 사용자가 html을 전달받을 때 그 내부에 렌더링된 결과물이 보인다.

 

- 장점: 검색 엔진이 우리가 만든 웹 애플리케이션의 페이지를 원활하게 수집할 수 있다, 초기 렌더링 성능 개선( 자바스크립트 파일 다운로드가 완료되지 않은 시점에서도 html 콘텐츠 존재 )

 

- 단점: 원래 브라우저가 해야할 일을 서버가 대신 처리하는 것이므로 서버 리소스가 사용된다. → 서버 과부하가 발생할 수 있다. → 캐싱과 로드 밸런싱을 통해 성능 최적화 필요, 구조가 복잡해질 수 있다.

 

서버 사이드 렌더링과 코드 스플리팅을 함께 적용하면 작업이 까다롭다.

더보기

아래와 같은 흐름으로 작동하며 페이지에 깜빡임이 발생한다.

 

1. 서버 사이드 렌더링된 결과물이 브라우저에 나타남

2. 자바스크립트 파일 로딩 시작

3. 자바스크립트가 실행되면서 아직 불러오지 않은 컴포넌트를 null로 렌더링함

4. 페이지에서 코드 스플리팅된 컴포넌트들이 사라짐

5. 코드 스플리팅된 컴포넌트들이 로딩된 이후 제대로 나타남.

이러한 문제를 해결하려면 라우트 경로마다 코드 스플리팅된 파일 중에서 필요한 모든 파일을 브라우저에서 렌더링하기 전에 미리 불러와야 한다. 이 책에서는 Loadable Components 라이브러리에서 제공하는 기능을 써서 서버 사이드 렌더링 후 필요한 파일의 경로를 추출하여 렌더링 결과에 스크립트/ 스타일 태그를 삽입해준다.  

 

 

1 . 프로젝트 준비하기

 

react-router-dom을 설치하고 간단한 프로젝트를 만든다.

 

App. js

import React from "react";
import { Route } from "react-router-dom";
import Menu from "./components/Menu";
import RedPage from "./pages/RedPage";
import BluePage from "./pages/BluePage";

const App = () => {
  return (
    <div>
      <Menu />
      <hr />
      <Route path="/red" component={RedPage} />
      <Route path="/blue" component={BluePage} />
    </div>
  );
};
export default App;

 

2. 서버 사이드 렌더링 구현하기

 

웹팩 커스터마이징을 위해 yarn eject 명령어를 실행하여 밖으로 웹팩 관련 설정을 꺼내준다.

 

 

2. 1 ) 서버 사이드 렌더링용 엔트리 만들기

 

엔트리는 웹팩에서 프로젝트를 불러올 때 가장 먼저 불러오는 파일이다. 현재 프로젝트에서는 index. js 를 엔트리 파일로 사용하고 이 파일부터 시작하여 내부에 필요한 다른 컴포넌트와 모듈을 불러온다. 

서버 사이드 렌더링을 할 때는 서버를 위한 엔트리 파일을 따로 생성해야 한다. src/ index.server.js 파일을 생성한다.

import React from "react";
import ReactDOMServer from "react-dom/server";

const html = ReactDOMServer.renderToString(
  //서버에서 리액트 컴포넌트를 렌더링할 때 사용하는 renderToString함수
  //이 함수에 JSX를 넣어서 호출하면 렌더링 결과를 문자열로 반환
  <div>Hello Server Side Rendering</div>
);

console.log(html);

 

2. 2 ) 서버 사이드 렌더링 전용 웹팩 환경 설정 작성하기

 

config/ path. js 파일의 module. export 부분에 ssrIndexJs 와 ssrBuild 정보를 추가한다.

ssrIndexJs: resolveApp("src/index.server.js"), //서버 사이드 렌더링 엔트리 (= 불러올 파일의 경로)
ssrBuild: resolveApp("dist"), //웹팩 처리 후 저장 경로

 

 

웹팩 환경 설정 파일 작성  config/ webpack. config. server. js

const paths = require("./paths");

module.exports = {
  mode: "production", //프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, //엔트리 경로
  target: "node", //node환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, //빌드 경로
    filename: "server.js", //파일 이름
    chunkFilename: "js/[name].chunk.js", //정크 파일 이름
    publicPath: paths.publicUrlOrPath, //정적 파일이 제공될 경로
  },
};

빌드할 때 어떤 파일에서 시작해 파일들을 불러오는지, 또 어디에 결과물을 저장할 지 정해 줌.

 

다음으로는 로더를 설정한다. 웹팩의 로더는 파일을 불러올 때 확장자에 맞게 필요한 처리를 해준다.

( 코드에서 node_modules 내부의 라이브러리를 불러올 수 있게 설정한다.  ) 

 

 

2. 3 ) 빌드 스크립트 작성하기

 

위에서 만든 환경 설정을 사용하여 웹팩으로 프로젝트를 빌드하는 스크립트 build. server. js 작성하기 ( 클라이언트에서 사용할 빌드 파일을 만드는 scripts/ build. js 와 비슷 )

 

package. json에서 스크립트를 생성하여 매번 빌드하고 실행할 때마다 파일 경로를 입력하는 것을 생략할  있다.

yarn build:server, yarn start:server 명령어로 서버를 빌드하고 실행할 수 있다.

 

 

2. 4 ) 서버 코드 작성하기 

 

서버 사이드 렌더링을 처리할 서버를 작성한다. Express 라는 Node. js 웹 프레임워크를 사용하여 웹 서버를 만든다.

 

index. server. js

import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom";
import App from "./App";

const app = express();

// 서버사이드 렌더링을 처리 할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
  // 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버사이드 렌더링을 해줍니다.

  const context = {};
  const jsx = (
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 합니다.
  res.send(root); // 결과물을 응답합니다.
};

app.use(serverRender);

// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
  console.log("Running on http://localhost:5000");
});

코드를 작성하고 yarn build:server 와 yarn start:server 명령어를 입력해주면 http://loacalhost:5000/ 경로에 화면 이 표시된다.

 

 

2. 5 ) 정적 파일 제공하기

 

Express에 내장되어 있는 static 미들웨어를 사용하여 서버를 통해 build에 있는 JS, CSS 정적 파일들에 접근할 수 있도록 해 준다. 

그 다음에는 JS와 CSS 파일을 불러오도록 html에 코드를 삽입해 준다. 불러와야 하는 파일 이름은 매번 빌드할 때마다 바뀌기 때문에 빌드하고 나서 만들어지는 asset-manifest. json 파일을 참고하여 불러오도록 작성한다.

 

index. server. js

import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom";
import App from "./App";
import path from "path";
import fs from "fs";

// asset-manifest.json 에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
  fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);

const chunks = Object.keys(manifest.files)
  .filter((key) => /chunk\js$/.exec(key)) //chunk.js로 끝나는 키를 찾아서
  .map((key) => `<script src="${manifest.files[key]}"></script>`) //스크립트 태그로 변환하고
  .join(""); //합침

function createPage(root) {
  return `<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1,shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <title>React App</title>
    <link href="${manifest.files["main.css"]}" rel="stylesheet"/>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root">
      ${root}
    </div>
    <script src="${manifest.files["runtime~main.js"]}"></script>
    ${chunks}
    <script src="${manifest.files["main.js"]}"></script>
  </body>
  </html>
    `;
}
const app = express();

// 서버사이드 렌더링을 처리 할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
  // 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버사이드 렌더링을 해줍니다.

  const context = {};
  const jsx = (
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 합니다.
  res.send(createPage(root)); // 결과물을 응답합니다.
};

const serve = express.static(path.resolve("./build"), {
  index: false,
});

app.use(serve); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);

// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
  console.log("Running on http://localhost:5000");
});

서버사이드 렌더링을 구현하면 첫 번째 렌더링은 서버를 통해 하지만, 그 이후에는 브라우저에서 처리한다.

 

 

3. 데이터 로딩

 

데이터 로딩을 한다는 것은 API 요청을 의미한다. 페이지에서 필요로 하는 데이터가 있다면 API 를 요청해서 응답을 받아와야 한다.  서버의 경우 renderToString 함수를 한번 더 호출해 주어야 한다.

이 책에서는 redux-thunk 혹은 redux-saga 미들웨어를 사용하여 API 를 호출하는 환경에서 서버 사이드 렌더링을 한다.

 

 

3. 1 ) redux-thunk 코드 준비하기

 

라이브러리를 설치하고 리덕스 모듈을 작성한다. 

 

modules/ users. js

 

이 모듈은 getUsers라는 thunk함수를 만들고, 이와 관련된 액션 GET_USERS_PENDING, GET_USERS_SUCCESS, SET_USERS_FAILURE를 사용하여 상태 관리를 해주고 있다. 로딩 상태에는 loading과 error라는 객체가 들어 있다.

이 모듈에서 관리하는 API는 한 개 이상이므로 loading이라는 객체에 넣어준다.

 

다음으로 루트 리듀서를 만들고 ( modules/ index. js ), Provider 컴포넌트를 사용하여 프로젝트에 리덕스를 적용한다. ( src/ index. js )

 

 

3. 2 ) Users, UsersContainer 컴포넌트 준비하기

 

components 디렉터리에 Users 컴포넌트를 작성한다.

containers 디렉터리에 UsersContainer 컴포넌트를 작성한다. 

// 서버 사이드 렌더링을 할 때는 이미 있는 정보를 재요청하지 않게 처리하는 작업이 중요하다.

 

UsersPage. js 페이지 컴포넌트에서 만든 컨테이너 컴포넌트를 보여주고, App. js 에서 라우트 설정을 해준다.

Menu. js 파일을 수정해 /users 경로로 이동할 수 있도록 해준다.

 

 

3. 3 ) Preload Context 만들기

 

현재 getUsers 함수는 Userscontainer의 useEffect 부분에서 호출된다. 서버 사이드 렌더링을 할 때는 렌더링하기 전에 API를 요청한 뒤 스토어에 데이터를 담아야 하는데 이 작업을 PreloadContext를 만들고 이를 사용하는 Preloader 컴포넌트를 만들어 처리한다.

 

PreloadContext 는 서버 사이드 렌더링을 하는 과정에서 처리해야 할 작업들을 실행하고, 만약 기다려야 하는 프로미스가 있다면 프로미스를 수집한다. 그 다음 렌더링을 하면 데이터가 채워진 상태로 컴포넌트들이 나타나게 된다.

 

Preloader 컴포넌트는 resolve라는 함수를 props 로 받아 오며, 컴포넌트가 렌더링될 때 서버 환경에서만 resolve 함수를 호출해준다.

 

 

3. 4 ) 서버에서 리덕스 설정 및 PreloadContext 사용하기

 

3. 5 ) 스크립트로 초기 상태 주입하기

 

서버에서 만들어준 상태를 브라우저에서 재사용하려면 현재 스토어 상태를 문자열로 변환한 뒤 스크립트로 주입해야 한다. // JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환 처리

 

3. 6 ) redux - saga 코드 준비하기

 

users 리덕스 모듈에서 redux-saga를 사용하여 특정 사용자의 정보를 가져오는 작업을 관리한다.

모듈을 수정한 뒤, 루트 사가를 만들고 스토어를 생성할 때 미들웨어를 적용한다.

 

3. 7 ) User, UserContainer 컴포넌트 준비하기

 

컨테이너 컴포넌트에서 API를 요청할 때 사용할 id값은 props를 통해 받아온다. useSelector와 useDispatch Hooks를 사용한다.

유효성 검사를 할 때 Preload 컴포넌트를 렌더링하여 반환한다. 이렇게 하면 데이터가 없을 경우 GET_USER 액션을 발생시킨다. 

 

3. 8 ) redux-saga를 위한 서버 사이드 렌더링 작업

 

엔트리 파일에 redux-saga 미들웨어를 적용한다.

toPromise는 sagaMiddleware.run을 통해 만든 Task를 Promise로 변환한다. 별도의 작업이 없으면 루트 사가에서 액션을 끊임없이 모니터링 하므로 끝나지 않는다. redux-saga의 END라는 액션을 발생시키면 이 Promise를 끝낼 수 있고 리덕스 스토어에는 우리가 원하는 데이터가 채워진다.

 

3. 9 ) usePreloader Hook 만들어서 사용하기

 

usePreloader이라는 커스텀 Hook 함수를 만들어서 이 작업을 더욱 편하게 처리할 수 있다.

 

 

4. 서버 사이드 렌더링과 코드 스플리팅

 

서버 사이드 렌더링과 코드 스플리팅을 함께 사용할 때는 Loadable Components를 사용할 것을 권장한다. Loadable Components에서는 서버 사이드 렌더링을 할 때 필요한 서버 유틸 함수와 웹팩 플러그인, babel 플러그인을 제공해준다.

 

4. 1 ) 라우트 컴포넌트 스플리팅하기

 

현재 사용하고 있는 BluePage, RedPage, UserPage 를 스플리팅해준다. 

( const RedPage = loadable(( ) => import( './pages/RedPage' )); )

 

 

4. 2 ) 웹팩과 babel 플러그인 적용

 

깜빡임 현상을 해결할 수 있다. 

package. json 을 열어서 babel을 찾은 뒤, 그 안에 plugins 를 설정한다.

webpack. config. js 를 열어서 상단에 LoadablePlugin 을 불러오고 하단에는 plugins 를 찾아서 해당 플러그인을 적용한다.

yarn build 명령어를 입력하면 build 디렉터리에 loadable-stats. json 이라는 파일이 만들어진다. 이 파일은 각 컴포넌트의 코드가 어떤 청크 파일에 들어가 있는지에 대한 정보를 가지고 있으므로 이를 참고하여 어떤 파일들을 사전에 불러와야 할지 설정할 수 있다.

 

 

4. 3 ) 필요한 청크 파일 경로 추출하기

 

Loadable Components에서 제공하는 chunkExtractor 와 chunkExtractorManager 를 사용한다. Loadable Components를 통해 파일 경로를 조회하므로 기존에 asset-manifest.json을 확인하던 코드는 지워준다.

 

 

4. 4 ) loadableReady와 hydrate

 

모든 스크립트가 로딩되고 나서 렌더링하도록 처리하기 위해서는 loadableReady라는 함수를 사용해주어야 한다. 추가로 hydrate라는 함수는 기존에 서버 사이드 렌더링된 결과물이 이미 있을 경우 새로 렌더링하지 않고 기존에 존재하는 UI에 이벤트만 연동하여 애플리케이션을 초기 구동할 때 필요한 리소스를 최소화함으로써 성능을 최적화 해준다.

 

 

5. 서버 사이드 렌더링의 환경 구축을 위한 대안

 

서버 사이드 렌더링을 할 때 데이터 로딩, 코드 스플리팅 작업이 너무 번거롭다면 다른 대안도 있다. 

Next. js 라는 리액트 프레임 워크를 사용하면 간단하게 처리할 수 있지만, 리액트 라우터와 호환되지 않는다는 단점이 있다.

Razzle 또한 서버 사이드 렌더링을 쉽게 할 수 있도록 도와주는 도구이며, 프로젝트 구성이 CRA와 유사하다는 장점이 있지만 깜빡임 현상을 해결하기 어렵다는 단점이 있다.