1. 10일 전

    Next - 기본편

    Next.js 기본 다루기

  2. 1달 전

    React 에서 SSR 고급

    효율적인 SSR 처리방법

  3. 1달 전

    React 에서 SSR 설정하기

    SSR 설정을 하면서 원리 이해해보기

  4. 4달 전

    React Hooks - 기본편

    Hooks에 대한 기본적인 이해

  5. 5달 전

    OAuth 2.0

    OAuth 2 개념

  6. 6달 전

    Naver Login in React

    React에서 네이버 로그인 사용하기

  7. 7달 전

    NPM에 React 모듈 배포하기

    NPM에 리액트에서 사용할 수 있는 모듈 배포하기

  8. 7달 전

    JS 의존성 관리 - 모듈 시스템과 NPM

    전통적인 JS의 의존성 관리 방식과 한계, CommonJS, AMD 등장. JS의 패키지 관리자인 NPM

  9. 10달 전

    Next.js SSR Styled-component (.feat TS)

    Styled-Components로 컴포넌트를 작성해보자.

  10. 10달 전

    Next.js 9

    7월에 발표된 Next.js 9에서 어떤점이 변경되었는지를 중심으로

  11. 11달 전

    Facebook Login in React

    React에서 페이스북 로그인 사용하기

  12. 11달 전

    Google Login in React

    React에서 구글 로그인 사용하기

  13. 11달 전

    Kakao Login in React

    React에서 카카오 로그인 사용하기

  14. 1년 전

    18 우버 클론 코딩 (nomad coders)

    우버 코딩 강의 로그 2.13 ~ 2.17

  15. 1년 전

    17 우버 클론 코딩 (nomad coders)

    우버 코딩 강의 로그 2.7 ~ 2.12

  16. 1년 전

    NEXT.JS

    공식 문서 튜토리얼을 따라 하며 기록

  17. 1년 전

    16 우버 클론 코딩 (nomad coders)

    우버 코딩 강의 로그 2.0 ~ 2.6

  18. 1년 전

    gatsby 블로그 만들기

    gatsby + Netlify 사용해서 블로그 만들기

Tamm자바스크립트 웹 개발 환경을 좋아하고 사람들에게 재미를 주는 것에 관심이 많은 개발자 입니다.

React 에서 SSR 고급

효율적인 SSR 처리방법

featured image thumbnail for post React 에서 SSR 고급

정적인 페이지를 서버에서 정적 페이지로 내려주면 빠르게 보여줄 수 있어서 좋다. 그런데 매번 서버에서 렌더링할 필요가 있을까? 매번 하는 것은 자원의 낭비이므로 빌드 타임에 한 번 렌더링하도록 설정해보자.

대부분의 페이지는 정적인 페이지로만 이루어져 있지 않고 서버로 부터 데이터를 가져와서 일부분을 재 렌더링 하도록 되어 있다. 그렇다면 서버로 부터 데이터를 받는 부분을 제외한 곳을 미리 렌더링 할 것이다.

페이지에서 일부분을 클라이언트에서 재렌더링 하도록 페이지를 수정하자.

  • src/App.js

    ...
    import Icon from './kangaroo-c.png';
    
    function fetchUsername() {
    const usernames = ['mike', 'june', 'jamie'];
    return new Promise((resolve) => {
      const username = usernames[Math.floor(Math.random() * 3)];
      setTimeout(() => resolve(username), 100);
    })
    }
    
    const Container = styled.div`
    background-color: #aaa;
    border: 1px solid blue;
    `;
    
    ...
    
    class App extends React.Component {
    state = {
      page: this.props.page,
    }
    
    componentDidMount() {
      window.onpopstate = event => {
        this.setState({ page: event.state });
      };
      fetchUsername().then(username => this.setState({ username }));
    }
    
    onChangePage = e => {
            ...
    };
    
    render() {
      const { page, username } = this.state;
      const PageComponent = page === 'home' ? Home : About;
      return (
        <Container className="container">
          ...
          <img src={Icon}/>
          <PageComponent username={username}/>
        </Container>
      )
    }
    }
    
    export default App;
    
  • src/pages/Home.js

    import React from 'react';
    
    export default function Home({ username}) {
    return (
      <div>
        <h3>This is home page</h3>
        {username && <p>{`${username} 님 안녕하세요`}</p>}
      </div>
    )
    }
    

이렇게 하면 사용자 이름은 서버에서 렌더링 하지 않고 클라이언트에서 렌더링 할 것이다.

이제 미리 렌더링할 페이지를 그리는 설정을 할 것이다. 페이지를 그리는 스크립트를 생성하고 webpack에서 스크립트를 실행해서 정적 페이지를 미리 생성하도록 할 것이다.

페이지 미리 렌더링


  • src/common.js

    import fs from "fs";
    import path from "path";
    import { renderToString } from "react-dom/server";
    import React from "react";
    import App from "./App";
    import { ServerStyleSheet } from "styled-components";
    
    const html = fs.readFileSync(
    path.resolve(__dirname, "../dist/index.html"),
    "utf8"
    );
    
    export const prerenderPages = ["home"];
    
    export function renderPage(page) {
    const sheet = new ServerStyleSheet();
    const renderString = renderToString(
      sheet.collectStyles(<App page={page} />)
    );
    const styles = sheet.getStyleTags();
    const result = html
      .replace('<div id="root"></div>', `<div id="root">${renderString}</div>`)
      .replace("__STYLE_FROM_SERVER__", styles);
    return result;
    }
    

    html로 부터 렌더링할 페이지를 가져와 style을 주입하는 것을 역할을 별도의 페이지로 분리했다.

  • src/prerender.js

    import fs from 'fs';
    import path from 'path';
    import { renderPage, prerenderPages } from './common';
    
    for (const page of prerenderPages) {
    const result = renderPage(page);
    fs.writeFileSync(path.resolve(__dirname, `../dist/${page}.html`), result);
    }
    

    미리 렌더링할 페이지를 html 파일로 저장하는 스크립트

  • src/server.js

    import express from "express";
    import fs from "fs";
    import path from "path";
    import url from "url";
    import { renderPage, prerenderPages } from "./common";
    
    const app = express();
    
    const prerenderHtml = {};
    for (const page of prerenderPages) {
    const pageHtml = fs.readFileSync(
      path.resolve(__dirname, `../dist/${page}.html`),
      "utf8"
    );
    prerenderHtml[page] = pageHtml;
    }
    
    app.use("/dist", express.static("dist"));
    app.get("/favicon.ico", (req, res) => res.sendStatus(204));
    app.get("*", (req, res) => {
    const parseURL = url.parse(req.url, true);
    const page = parseURL.pathname ? parseURL.pathname.substr(1) : "home";
    const initialData = { page };
    
    const pageHtml = prerenderPages.includes(page)
      ? prerenderHtml[page]
      : renderPage(page);
    
    const result = pageHtml.replace(
      "__DATA_FROM_SERVER__",
      JSON.stringify(initialData)
    );
    res.send(result);
    });
    
    app.listen(3000);
    

    페이지를 렌더링하는 부분을 common.js로 분리했고, 요청 받은 페이지가 미리 렌더링 되어있으면, 미리 렌더링한 페이지를 사용하도록 했다. 여기서는 서버에서 클라이언트에게 내려줄 값만 처리하도록 했다.

  • webpack.config.js

    ...
    
    function getConfig(isServer, entry) {
    return {
      entry: {
        [entry]: path.resolve(__dirname, `src/${entry}.js`),
      },
            ...
    }
    }
    
    module.exports = [ getConfig(false, 'index'), getConfig(true, 'server'), getConfig(true, 'prerender')];
    

    이제 미리 렌더링할 페이지도 webpack을 통해 빌드 하도록 설정을 추가 했다.

  • package.json

    ...
    "scripts": {
    "build": "webpack && node dist-server/prerender.bundle.js",
    "start": "node dist-server/server.bundle.js"
    },
    ...
    

    yarn build 를 하면 dist-server에 prerender.bundle.js 가 생성되고 스크립트가 실행되어 dist/home.html 이 생성된다. 파일을 보면 __DATA_FROM_SERVER__ 값이 없고, 사용자 이름은 렌더링되지 않았다. 사용자 이름은 클라이언트에서 렌더링 된다.

서버사이드 렌더링 캐싱하기


페이지를 미리 렌더링할 수도 있고, 만약 내용이 자주 바뀌는 페이지라면 미리 렌더링하는 것은 어렵지만 페이지를 캐싱할수는 있다. 1분만 캐싱해도 매우 많은 자원을 절약할 수 있다.

$ yarn add lru-cache
  • src/server.js

    ...
    import { renderPage, prerenderPages } from './common';
    import LruCache from 'lru-cache';
    
    const ssrCache = new LruCache({
    max: 100,
    maxAge: 1000 * 60,
    });
    const app = express();
    
    ...
    
    app.get('*', (req, res) => {
    const parseURL = url.parse(req.url, true);
    const cacheKey = parseURL.path;
    if (ssrCache.has(cacheKey)) {
      console.log('캐시 사용');
      res.send(ssrCache.get(cacheKey));
      return;
    }
    const page = parseURL.pathname ? parseURL.pathname.substr(1) : 'home';
    const initialData = { page };
    
    const pageHtml = prerenderPages.includes(page)
      ? prerenderHtml[page]
      : renderPage(page);
    
    const result = pageHtml
      .replace('__DATA_FROM_SERVER__', JSON.stringify(initialData))
    ssrCache.set(cacheKey, result);
    res.send(result);
    });
    
    app.listen(3000);
    

이렇게 하고 다시 빌드한 뒤 페이지를 열어보자. 처음에는 캐시된 페이지가 그려지지 않지마 새로 고침하면 서버쪽에 '캐시 사용'이라고 로그가 뜰 것이다.

서버사이드 렌더링 - renderToNodeStream


캐시 까지 적용하였다면, 일반적으로 속도의 문제는 거의 없을 것이다. 여기서 renderToString 대신 rederToNodeStream을 사용하면 바로 응답을 내려주기 때문에 속도를 더 향상 시킬 수 있다.

Stream을 사용하면 큰 파일을 읽을 때도 메모리를 효율적으로 사용할 수 있게 된다.

  • src/server.js

    ...
    import { ServerStyleSheet } from 'styled-components';
    import React from 'react';
    import App from './App';
    import { renderToNodeStream } from 'react-dom/server';
    
    ...
    
    const html = fs
    .readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf8')
    .replace('__STYLE_FROM_SERVER__', '');
    
    app.use('/dist', express.static('dist'));
    app.get('/favicon.ico', (req, res) => res.sendStatus(204));
    app.get('*', (req, res) => {
        ...
    
    const page = parseURL.pathname ? parseURL.pathname.substr(1) : 'home';
    const initialData = { page };
    
    const isPrerender = prerenderPages.includes(page);
    const result = (isPrerender ? prerenderHtml[page] : html).replace('__DATA_FROM_SERVER__', JSON.stringify(initialData),);
    
    if (isPrerender) {
      ssrCache.set(cacheKey, result);
      res.send(result);
    } else {
      const ROOT_TEXT = '<div id="root">';
      const prefix = result.substr(0, result.indexOf(ROOT_TEXT) + ROOT_TEXT.length,);
      const postfix = result.substr(prefix.length);
      res.write(prefix);
      const sheet = new ServerStyleSheet();
      const reactElement = sheet.collectStyles(<App page={page} />);
      const renderStream = sheet.interleaveWithNodeStream(renderToNodeStream(reactElement),);
      renderStream.pipe(res, { end: false });
      renderStream.on('end', () => {
        res.end(postfix);
      });
    }
    });
    
    app.listen(3000);
    

    rederToString 에서는 <div id="root"></div> 사이에 렌더링된 문자열을 삽입하는 형태로 구현했다. renderToStream도 마찬가지로 <div id="root"> 전을 스트림으로 보내고, rederToNodeStream의 결과를 스트림에 보내고 <div id="root"> 이후를 스트림으로 보내는 형태로 구현된다.

서버사이드 렌더링 - Stream Cache


스트림으로 전송된 데이터는 캐싱하지 않고 있다. 스트림을 캐싱하기 위해서는 읽기 스트림과 쓰기 스트림 사이에 캐싱을 위한 스트림을 끼워 넣어야 한다.

renderStream → cacheStream → res

  • src/server.js

    ...
    import { Transform } from 'stream';
    
    function createCacheStream(cacheKey, prefix, postfix) {
    const chunks = [];
    return new Transform({
      transform(data, _, callback) {
        chunks.push(data);
        callback(null, data);
      },
      flush(callback) {
        const data = [prefix, Buffer.concat(chunks).toString(), postfix];
        ssrCache.set(cacheKey, data.join(''));
        callback();
      },
    })
    }
    
    app.get('*', (req, res) => {
            ...
      const cacheStream = createCacheStream(cacheKey, prefix, postfix);
      cacheStream.pipe(res);
      renderStream.pipe(cacheStream, { end: false });
      renderStream.on('end', () => {
        res.end(postfix);
      });
    }
    });
    
    app.listen(3000);
    

    캐싱을 위한 스트림 객체를 생성했다. transformchunk단위로 데이터 조각이 들어올 때마다 호출된다. 이때 chunkchunks[] 배열에 넣는다. flush는 모든 chunk가 읽혀졌을 때 호출된다. 그 동안 모은 chunks[] 배열의 값들을 prefixpostfix를 합쳐서 문자열로 만들어 캐시 객체에 넣는다.