Vue SSR 제대로 적용하기 (feat. Vanilla SSR)

조회수

안녕하세요, 줌인터넷 포털개발팀 프론트엔드 파트리더 황준일입니다 🙇‍♂️

오랜만에 기술블로그에 투고를 하네요. 어떤 글을 주제로 글을 작성해야 유익할까 꽤 오랜 시간 동안 고민을 했습니다. 이번에는 제가 실무를 하면서 생각보다 꽤 많은 삽질을 했던, SSR(Server Side Rendering)을 주제로 다뤄보도록 하겠습니다.

본 포스트는 모바일 줌 SpringBoot → NodeJS 전환기 (feat. VueJS SSR)을 먼저 읽어보면 더 유익합니다.

1. SSR(Server Side Rendering)이란?

이 글을 읽고 있는 많은 분들이 SSR에 대한 개념은 이미 숙지하고 있으리라 생각합니다. 그럼에도 불구하고 한 번 SSR의 개념적인 부분을 정리해보겠습니다.

(1) 무엇(What) 인가

저는 웹 개발을 PHP로 접했습니다. PHP는 Hypertext Preprocessor의 약자인데, 해석해보면 HTML 전처리기라는 의미입니다.

전처리기라는 단어가 익숙하지 않나요? 프론트엔드 개발자들이 많이 사용하는 SASS, LESS 등이 바로 CSS의 전처리기(Preprocessor)입니다. 대충 어떤 느낌인지 감이 오시나요? 반대로 PostCSS 같은 후처리기도 존재한답니다!

즉, PHP를 이용하여 HTML을 동적으로 만들어낼 수 있는 것입니다. 이와 비슷한 도구로 JSP(Java Server Page)ASP(Active Server Pages)도 존재합니다.

그리고 이것들이 약 10년전쯤에 웹 개발의 생태계를 주름잡고 있던 3대장이었습니다. 이들의 공통점은 HTML을 Server에서 정제하여 출력해주는 도구입니다.

더 쉽게 말해서 SSR(Server Side Rendering)을 해주는 도구라고 볼 수 있습니다.

조금 딴 길로 접어든 것 같은데, 정리하자면 SSR은 Server에서 HTML을 정제하여 출력해주는 기법이라고 볼 수 있습니다.

(2) 왜(Why) 필요한가

사실 이게 제일 중요한 주제입니다. 왜 SSR을 해야 할까요? 사실 앞서 언급한 것 처럼 전통적인 웹 개발은 이미 SSR을 이용했습니다.

그런데 브라우저가 발전하는 과정에서 Javascript 라는 언어 또한 점점 성숙해졌고, Javascript로 할 수 있는 일들이 점점 많아지게 되었습니다. 그러면서 DOM을 점점 더 정교하게 다뤄야했고, jQuery 같은 라이브러리로는 한계가 있었습니다. 그래서 구글에서 Angular를 만들었고, 이어서 페이스북에서는 Component 기반으로 개발할 수 있는 React를 만들었습니다. 다시 Angular와 React의 장점만 수용하여 만든 Vue도 등장했죠.

이런 프레임워크의 등장으로 모든 렌더링을 전부 브라우저(클라이언트)에게 위임했고, 이를 전문으로 하는 프론트엔드 개발자들이 필요해졌습니다.

프론트엔드 개발자들이 주로 하는 일이 바로 CSR(Client Side Rendering)인 것이죠. 그런데 CSR에는 서비스적인 한계가 존재합니다. 기본적으로 브라우저가 서버에서 받아오는 최초의 HTML은 고작 <div id="app"></div> 혹은 <div id="root"></div> 한 줄 인데, 이렇게 될 경우 구글 검색엔진이 사이트의 내용을 파악할 수 없다는 것입니다. 물론 meta 태그를 이용한다면 어느정도 극복할 수 있으나 역시 정교한 색인(indexing)에는 한계가 존재합니다.

그래서 다시 SSR(Server Side Rendering)이 필요해진 것입니다. 그렇다고 다시 PHP나 JSP를 사용할 순 없는 상황인데, 이를 어떻게 해결할 수 있을까요? 이에 대한 내용은 뒤에서 상세히 다뤄보도록 하겠습니다.

(3) 언제(When) 필요한가

모든 상황에 SSR을 적용할 필요는 없습니다. 검색 엔진의 색인이 필요할 때만 적용하면 되고, 혹은 사용자에게 비어있는 화면 없이 빠르게 페이지를 보여줘야할 때 필요할 수도 있습니다. 반대로 로그인한 사용자가 보고있는 페이지의 경우 SSR을 하면 오히려 위험합니다. 사용자의 정보가 노출될 수 있기 때문입니다. 마찬가지로 관리자 페이지의 경우에도 SSR을 하면 오히려 독이 될 수 있습니다.

대체로 콘텐츠와 관련된 내용을 보여주고 싶을 때 SSR을 사용합니다.

가령, 최근에 줌인터넷은 ZUM 금융 서비스를 런칭했습니다. 여기에서는 일부 페이지에만 SSR을 사용했는데요, 투자 정보를 제공하는 투자노트 상세페이지뉴스 상세페이지의 경우 SSR이 꼭 필요했습니다. 다른 페이지의 경우 사실 색인이 되지 않아도 크게 상관 없어서 CSR로만 처리한 상태입니다.

(4) 어떤 플랫폼(Where)에서 실행해야 하는가

우리는 Javascript로 만들어진 코드를 Server에서 HTML로 변환하는 작업이 필요하기 때문에, SSR 작업을 위해서 Node.js가 꼭 필요합니다. 조금 편법을 이용하면 Java에서도 Javascript 코드를 실행할 순 있으나 모바일 줌 SpringBoot → NodeJS 전환기 (feat. VueJS SSR)에서 언급된 것 처럼 퍼포먼스가 좋지 않거나, 호환성에 대한 문제가 있거나, 너무 많은 폴리필이 필요하거나 하는 등의 문제가 있습니다. 그래서 꼭 Node.js 가 필요합니다.

(5) 누가(Who) 해야하는가

당연한 이야기지만, 이는 프론트엔드 개발자의 영역이라고 생각합니다. 이를 위해선 Node.js에 대한 이해가 꼭 필요합니다. 그리고 브라우저 환경과 서버 환경에 대한 명확한 이해와 구분이 필요합니다.

2. 프레임워크 없이 적용해보기(Vanilla SSR)

일단 프레임워크 없이 SSR을 어떻게 적용할 수 있는지 살펴보겠습니다. 사실 이에 대해 제대로 다루는 포스트가 없어서 아쉬웠습니다. SSR에 대한 개념을 확실하게 짚고 넘어가야 문제가 발생했을 때 해결하기가 수월합니다. 특히 브라우저 환경과 서버 환경이 완전히 다르다는 것을 먼저 인지해야 합니다. 일단 구구절절 설명하기보단 우리는 개발자이므로 코드로 이야기해봅시다.

현재 섹션에 대한 전체 코드는 이 저장소에서 확인할 수 있습니다.

(1) 먼저 CSR을 만들어봅시다.

먼저 Client Side에서 만들어진 코드를 살펴봅시다. 최소한의 이해를 위해 정말 간단한 Todo List를 구성해보겠습니다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Client Side Rendering</title>
</head>
<body>
<div id="app"></div>
<script>
  // 필요한 데이터를 선언합니다.
  const todoItems = [
    { id: 1, content: 'CSR을 만들어보자', activation: true },
    { id: 2, content: 'CSR 코드 분할', activation: false },
    { id: 3, content: 'SSR을 만들어보자', activation: false },
  ];

  // 데이터(상태)를 기반으로 HTML로 변환해주는 컴포넌트 코드를 작성합니다.
  const TodoList = () => `
    <ul>
      ${todoItems.map(({ id, content, activation }) => `
        <li data-id="${id}">
          <input type="checkbox" ${activation ? 'checked' : ''} />
          <span ${activation ? ' style="text-decoration: line-through;"' : ''}>${content}</span>
        </li>
      `).join('')}
    </ul>
  `;

  // DOM에 렌더링을 해주는 코드를 작성합니다.
  const render = () => {
    const $app = document.querySelector('#app');
    $app.innerHTML = TodoList();
    $app.querySelectorAll('li').forEach(el => {
      el.addEventListener('click', () => {
        const { id } = el.dataset;
        const selectedItem = todoItems.find(v => v.id === Number(id));
        selectedItem.activation = !selectedItem.activation;
        render();
      })
    });
  }

  render();
</script>
</body>
</html>

0.gif

아마 이해하기 어려운 코드는 아니라고 생각합니다. 다만 이 코드에서 처음에 존재하는 HTML은 <div id="app"></div>밖에 없습니다. 따라서 검색엔진은 이 페이지의 내용을 해석할 때 무리가 있겠죠.

이제 위의 코드를 Server Side에서 실행하는 과정을 천천히 살펴봅시다. 먼저 HTML로 변환하는 부분을 추출해야 합니다.

(2) CSR 모듈화

위에서 작성한 코드를 다음과 같이 분리해봅시다.

.
├─ index.html
└─ src
    ├─ components.js  # 어플리케이션에 필요한 Component를 모아둠
    ├─ store.js       # 어플리케이션에 필요한 state를 모아둠
    └─ main.js        # 어플리케이션의 entry point

1) /index.html

먼저 브라우저 모듈을 이용하여 main.js를 불러오도록 구성합니다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Client Side Rendering</title>
</head>
<body>
<div id="app"></div>
<!-- 브라우저 모듈을 사용하여 main.js를 불러옴 -->
<script src="./src/main.js" type="module"></script>
</body>
</html>

이렇게 사용할 경우 webpack 같은 번들링 도구가 없어도 ESM을 사용할 수 있습니다.

2) /src/store.js

export const store = {
  state: {
    todoItems: [
      { id: 1, content: 'CSR을 만들어보자', activation: true },
      { id: 2, content: 'CSR 코드 분할', activation: false },
      { id: 3, content: 'SSR을 만들어보자', activation: false },
    ],
  },
  setState (newState) {
    this.state = { ...this.state, ...newState  }
  },
  toggleActivation (index) {
    const todoItems = [ ...this.state.todoItems ];
    todoItems[index].activation = !todoItems[index].activation;
    this.setState({ todoItems });
  }
};

아예 명시적으로 toggleActivation 이라는 메소드를 호출하여 state를 변경할 수 있도록 구성해봤습니다. 코드 자체는 많이 불완전하지만, Store의 역할을 수행하는 코드라고 할 수 있습니다.

3) /src/components.js

import { store } from "./store.js";

export const TodoList = () => `
  <ul>
    ${store.state.todoItems.map(TodoItem).join('')}
  </ul>
`;

export const TodoItem = ({ id, content, activation }) => `
  <li data-id="${id}">
    <input type="checkbox" ${activation ? 'checked' : ''} />
    <span ${activation ? ' style="text-decoration: line-through;"' : ''}>${content}</span>
  </li>
`;

store에 있는 todoItems를 기반으로 HTML 문자열 만들어서 반환해주는, 일종의 컴포넌트를 구성해봤습니다.

이렇게 작성한 storecomponents의 특징은 DOM에 종속적이지 않다는 것입니다. 즉, Server Side에서 사용해도 무방한 코드입니다. 이 점을 유의깊게 살펴보시면 좋을 것 같습니다.

4) /src/main.js

import { TodoList } from "./components.js";
import { store } from "./store.js";

// DOM을 직접적으로 다루는 코드입니다.
const render = () => {
  const $app = document.querySelector('#app');
  $app.innerHTML = TodoList();
  $app.querySelectorAll('li').forEach(el => {
    el.addEventListener('click', () => {
      const { id } = el.dataset;
      store.toggleActivation(store.state.todoItems.findIndex(v => v.id === Number(id)));
      render();
    })
  });
}

render();

main.js에서는 DOM에 직접 접근하는 코드가 존재합니다. 이벤트도 등록하고, #app 태그를 찾아서 렌더링을 해주는 역할을 수행합니다.

(3) SSR 구성해보기

이제 본격적으로 Server Side Rendering을 구현해보도록 하겠습니다.

먼저 폴더 구조는 다음과 같습니다.

.
├─ package.json
├─ app.js             # 어플리케이션의 entry point
└─ src
    ├─ components.js  # SSR에 필요한 컴포넌트를 관리함
    ├─ render.js      # HTML을 구성해주는 역할을 수행
    └─ store.js       # 어플리케이션에 필요한 state를 관리함

1) package.json 구성

코드를 작성하기 이전에, package.json을 구성해줘야 합니다.

# package.json 생성
> npm init -y

# express 설치
> npm install express

# 필요하다면 nodemon 설치
> npm install -D nodemon

그리고 ESM을 사용하기 위해서 package.json의 type을 module로 정의합니다.

{
  "name": "03-SSR",
  "version": "1.0.0",
  "description": "Vanilla JS로 만들어보는 SSR",
  "main": "app.js",
  "scripts": {
    "serve": "node app.js",
    "serve:watch": "nodemon app.js"
  },
  "type": "module", // 이렇게 module로 정의하면 ESM을 사용할 수 있습니다.
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "nodemon": "^2.0.13"
  }
}

2) /src/components.js

이 코드는 CSR에서 작성한 components와 완벽하게 동일합니다.

import { store } from "./store.js";

export const TodoList = () => `
  <ul>
    ${store.state.todoItems.map(TodoItem).join('')}
  </ul>
`;

export const TodoItem = ({ id, content, activation }) => `
  <li data-id="${id}">
    <input type="checkbox" ${activation ? 'checked' : ''} />
    <span ${activation ? ' style="text-decoration: line-through;"' : ''}>${content}</span>
  </li>
`;

3) /src/store.js

마찬가지로 CSR에서 작성한 store와 완벽하게 동일합니다.

export const store = {
  state: {
    todoItems: [
      { id: 1, content: 'CSR을 만들어보자', activation: true },
      { id: 2, content: 'CSR 코드 분할', activation: false },
      { id: 3, content: 'SSR을 만들어보자', activation: false },
    ],
  },
  setState (newState) {
    this.state = { ...this.state, ...newState  }
  },
  toggleActivation (index) {
    const todoItems = [ ...this.state.todoItems ];
    todoItems[index].activation = !todoItems[index].activation;
    this.setState({ todoItems });
  }
};

4) /src/render.js

핵심은 바로 render.js입니다. 코드 자체는 별게 없습니다.

export const render = (RootComponent) => `
  <!doctype html>
  <html lang="ko">
  <head>
    <meta charset="UTF-8">
    <title>Server Side Rendering</title>
  </head>
  <body>
    <div id="app">${RootComponent}</div>
  </body>
  </html>
`;

위에서 보여지는 것 처럼, html body에 component가 삽입될 수 있도록 작업해야합니다.

5) /app.js

마지막으로 app.js를 구성하고, render 함수를 통해 Component를 출력하는 작업을 진행하면 된답니다.

import express from "express";
import { TodoList } from "./src/components.js";
import { render } from "./src/render.js";

const app = express();

app.get("/", (req, res) => {
  res.send(
    render(TodoList())
  );
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000');
})

결과물은 다음과 같습니다.

3.png

소스코드는 다음과 같이 출력될 것입니다.

4.png

위의 과정의 핵심은 다음과 같습니다.

5.png JS 코드가 브라우저와 NodeJS에 양다리를 걸치는 느낌

app.get("/", (req, res) => {
  res.send(
    render(TodoList()) // HTML 문자열로 변환
  );
});

보통 프레임워크에서 제공하는 SSR 관련 서드파티 도구를 사용하더라도, 위와 같은 내용을 숙지하고 있어야 제대로 활용할 수 있습니다.

(4) SSR과 CSR 동시 적용 (Hydration)

앞서 다룬 SSR의 문제는, 바로 CSR이 생략되었다는 것입니다. 즉, 이벤트 등록과 같은 브라우저의 순 기능이 생략되고 오직 초기 HTML만 출력된 상태입니다.

1.gif 이벤트가 작동하지 않음

제가 SSR 관련 문서를 볼 때 제일 답답했던 것 중 하나가 바로 SSR과 CSR을 동시에 하는 방법(Hydration)에 대해 소개하는 내용이 무척 빈약하다는 것입니다. 덕분에 삽질을 굉장히 많이 했었죠 😂

어쨌든, Vanilla JS로 이를 구현해봅시다.

폴더 구조는 다음과 같습니다.

.
├─ package.json
├─ app.js                  # static 관련 코드 추가
└─ src
    ├─ main.js             # 브라우저(HTML)에 삽입될 스크립트. 즉, client의 entry point
    ├─ components.js       # 앞선 코드와 동일
    ├─ serverRenderer.js   # 기존에 render.js
    └─ store.js            # 이전과 동일

1) /app.js

먼저 static 관련 설정을 추가해야합니다.

import express from "express";
import { TodoList } from "./src/components.js";
import { serverRenderer } from "./src/ServerRenderer.js";

const app = express();

// static 관련 설정 추가
app.use("/src", express.static("./src"));

app.get("/", (req, res) => {
  res.send(
    serverRenderer(TodoList())
  );
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000');
})

2) /src/serverRenderer.js

그리고 기존에 render.jsserverRenderer.js로 이름을 변경하고, script 태그를 삽입합니다.

export const serverRenderer = (RootComponent) => `
  <!doctype html>
  <html lang="ko">
  <head>
    <meta charset="UTF-8">
    <title>Server Side Rendering</title>
  </head>
  <body>
  <div id="app">${RootComponent}</div>
  
  <!-- csr을 위한 script 태그 추가 -->
  <script src="./src/main.js" type="module"></script>
  <!-- / csr을 위한 script 태그 추가 -->
  
  </body>
  </html>
`;

이전과 달라진 점은, <script src="./src/main.js" type="module"></script>이 추가된 상태입니다. 즉, 렌더링이 완료된 다음에 브라우저에서 CSR을 실행할 수 있도록 만들면 됩니다.

3) /src/main.js

main.js는 브라우저에서 실행하는 어플리케이션의 entry point입니다.

import { TodoList } from "./components.js";
import { store } from "./store.js";

export const render = () => {
  // 컴포넌트를 렌더링합니다.
  const $app = document.querySelector('#app');
  $app.innerHTML = TodoList();

  // 이벤트를 등록합니다.
  $app.querySelectorAll('li').forEach(el => {
    el.addEventListener('click', () => {
      const { id } = el.dataset;
      store.toggleActivation(store.state.todoItems.findIndex(v => v.id === Number(id)));
      render();
    })
  });
}

render();

이러한 과정을 통해서 만들어진 html은 다음과 같습니다.

6.png

그리고 이벤트도 정상적으로 잘 등록된 상태입니다.

2.gif

(5) Router 적용해보기

사실 이정도만 소개해도 충분하지만, 실무에서는 Router도 사용하기 때문에, 간단하게 Router까지 적용해보도록 하겠습니다.

이번엔 폴더구조를 생략하고, 필요한 코드만 언급하도록 하겠습니다.

1) /app.js

import express from "express";
import { App } from "./src/components.js";
import { serverRenderer } from "./src/ServerRenderer.js";

const app = express();

app.use("/src", express.static("./src"));

// path가 `/`에서 `/*`로 변경되었습니다. 모든 route와 매칭하기 위함입니다.
app.get("/*", (req, res) => {
  res.send(
    serverRenderer(
      // 기존에는 TodoList만 렌더링했는데,
      // 이제 App 컴포넌트를 렌더링하도록 변경했습니다.
      App({
        path: req.path, // 렌더링을 할 때 path 정보를 같이 보냅니다.
      })
    )
  );
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000');
})

2) /src/components.js

import { store } from "./store.js";

export const TodoList = () => `
  <ul>
    ${store.state.todoItems.map(({ id, content, activation }) => `
      <li data-id="${id}">
        <input type="checkbox" ${activation ? 'checked' : ''} />
        <span ${activation ? ' style="text-decoration: line-through;"' : ''}>${content}</span>
      </li>
    `).join('')}
  </ul>
`;

// path에 따라 Rendering할 Component를 핸들링합니다.
const Router = (path) => {
  if (path === '/todo-list') {
    return TodoList();
  }

  // `/todo-list` 외의 path는 전부 Hello World를 렌더링하도록 구성합니다.
  return `Hello World`;
}

// header와 footer를 추가했습니다.
export const App = ({ path }) => {
  return `
    <header>
      <a href="/">SSR Tutorial</a>
      <nav>
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/todo-list">Todo List</a></li>
        </ul>
      </nav>
    </header>
    ${Router(path)}
    <footer>
      Copyright &copy; 황준일 All Right Reserved.
    </footer>
  `
}

사실 components도 다양한 코드로 분할할 수 있으나, 귀찮기 때문에 한 파일에 몰아놨습니다.

Router가 거창한게 아니라 path에 따라 Component 핸들링 정도만 해주면 된답니다. 물론 Client에서 SPA를 구성할 때는 이보다 더 복잡한 로직이 필요합니다.

결과물은 다음과 같습니다.

3.gif

(6) Server에서 만들어진 변수를 Client에 전달하기

이대로 끝내기에는 또 아쉬워서, 이번에는 Store의 내용을 client에 어떻게 전달할 수 있는지 살펴보도록 하겠습니다.

기존에는 client에서 작업한 내용이 server 반영되지 않았습니다.

4.gif

그래서 Client에서 작업한 내용을 바로 Server에서 반영하고, 반대로 Server에 있는 내용을 Client에 바로 전달할 수 있도록 만들어야 합니다. 즉, 동기화 작업이 필요합니다.

1) /app.js

import express from "express";
import { App } from "./src/components.js";
import { serverRenderer } from "./src/ServerRenderer.js";
import { store } from "./src/store.js";

const app = express();

app.use(express.json());
app.use("/src", express.static("./src"));

// state의 값을 수정할 수 있는 api를 만들어줍니다.
app.put('/api/state', (req, res) => {
  store.hydration(req.body);
  res.status(204).send();
})

app.get("/*", (req, res) => {
  res.send(
    // state를 Rendering 할 때 전달해야 합니다.
    serverRenderer(App({ path: req.path }), store.state)
  );
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000');
})

2) /src/serverRenderer.js

앞선 내용 처럼, 렌더링 시점에 state를 넘겨주기 위해선 window 객체를 활용해야 합니다. 즉, HTML 내부에서 직접적으로 state를 출력해줘야 전달할 수 있습니다.

export const serverRenderer = (RootComponent, state) => `
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Server Side Rendering</title>
  <script>
    window.state = ${JSON.stringify(state)}
  </script>
</head>
<body>
<div id="app">${RootComponent}</div>
<script src="./src/main.js" type="module"></script>
</body>
</html>
`;

3) /src/store.js

즉, globalThis를 사용하면 런타임 환경과 관계없이 전역변수를 사용할 수 있습니다.

export const store = {
  // window를 직접적으로 사용하면 server side에서 오류가 발생합니다.
  // 그래서 이렇게 globalThis를 통해서 변수를 가져와야 합니다.
  state: globalThis.state || {
    todoItems: [
      { id: 1, content: 'CSR을 만들어보자', activation: true },
      { id: 2, content: 'CSR 코드 분할', activation: false },
      { id: 3, content: 'SSR을 만들어보자', activation: false },
    ],
  },

  // 이 메소드는 server side에서 사용됩니다.
  hydration (state) {
    this.state = state;
  },

  // setState를 실행하면 server에서 관리중인 state에도 반영할 수 있도록 작업합니다.
  setState (newState) {
    this.state = { ...this.state, ...newState  };
    fetch('/api/state', {
      method: 'put',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(this.state),
    })
  },
  toggleActivation (index) {
    const todoItems = [ ...this.state.todoItems ];
    todoItems[index].activation = !todoItems[index].activation;
    this.setState({ todoItems });
  }
};

위의 코드는 앞서 app.js에서 작성한 PUT /api/state과 상호작용을 합니다.

app.put('/api/state', (req, res) => {
  store.hydration(req.body); // store의 state를 req.body로 대체합니다.
  res.status(204).send();
})

결과물은 다음과 같습니다.

5.gif 새로고침을 해도 변경된 내용이 보존됩니다


여기까지 Vanilla Javascript를 이용하여 Server Side Rendering을 하는 방법에 대해 알아봤습니다. 위에서 다룬 모든 코드는 이 저장소에서 확인할 수 있습니다.


3. Vue SSR

앞선 과정을 통해서 우리는 SSR을 하는 방법에 대해 자세히 알아봤습니다. 다시 이를 토대로 Vue.js에서는 어떻게 SSR을 적용하는지 살펴보면 될 것 같습니다.

현재 섹션에 사용된 코드는 이 저장소에서 확인할 수 있습니다.

(1) 프로젝트 구성

먼저 Vue Project를 구성해야합니다. 간단하게 vue-cli로 프로젝트를 구성해봅시다.

폴더 구조는 다음과 같습니다.

vue-simple-ssr
├─ client   # vue 프로젝트
└─ server   # express 프로젝트

client에서 프로젝트를 구성하고, build를 하면 server에 정적파일이 생성되는 형태입니다. 지금은 별로 와닿지 않을 수 있으니 일단 코드를 통해서 살펴봅시다.

# vue cli를 전역으로 설치해야합니다.
> npm install -g @vue/cli

# 풀스택으로 작업해야하기 때문에, 최상위 프로젝트를 구성해야합니다.
> mkdir vue-simple-ssr

# 폴더로 이동 후 vue project를 구성합니다.
> cd vue-simple-ssr
> vue create client

#################################################################
# 본문에 사용되는 환경은 babel + vue2 + vuex + vue-router 입니다 #
#################################################################

# 서버 환경도 구성합니다.
# 일단 express만 설치하고, 자세한 코드는 서버사이드 섹션에서 다룰 예정입니다.
> cd ..
> mkdir server
> yarn init -y
> npm install express

아마 vue.js의 경우 기본적으로 다음과 같은 형태로 구성될 것입니다. 앞서 언급한 것 처럼 vue 2.6 + vuex 3 + vue-router 3 + babel 등으로 구성됩니다.

Vue 3에 대한 SSR은 그냥 공식문서 보시는걸 추천드립니다. 공식문서에 설명이 디테일하게 되어있답니다!

{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build --no-clean"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vue-router": "^3.2.0"
    "vuex": "^3.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "cross-env": "^7.0.3",
    "vue-template-compiler": "^2.6.11"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

(2) 컴포넌트 만들기

앞선 실습 처럼 간단한 todoList를 구성해보도록 하겠습니다. 처음에 구성된 프로젝트에서 About.vue를 삭제하고, TodoList.vue를 만들어야 합니다.

client
├─ package.json
├─ babel.config.js        # babel 설정입니다. 생성된 형태 그대로 두면 됩니다.
├─ public                 # static 파일 
└─ src
    ├─ main.js            # Vue Application의 entry point
    ├─ App.vue            # Vue Applicaiton의 Root Instance
    ├─ router/index.js    # router 설정 파일
    ├─ store/index.js     # store 설정 파일
    ├─ assets             # css, image 등의 assets
    ├─ components         # application에서 사용되는 component를 모아둠
    └─ views              # router로 사용되는 component를 모아둠

1) /src/main.js

main.js는 만들어진 상태 그대로 두면 됩니다. 그래도 entry point이기 때문에 코드만 소개해보도록 하겠습니다.

import Vue from 'vue';
import App from './App.vue';
import router from './router'; // cli로 만들어진 router
import store from './store'; // cli로 만들어진 store

Vue.config.productionTip = false;

// Vue App을 만듭니다.
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

2) /src/store/index.js

일단 TodoList를 만들 때 필요한 state를 초기화하는 과정이 필요합니다.

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    todoItems: [
      { id: 1, content: 'CSR을 만들어보자', activation: true },
      { id: 2, content: 'CSR 코드 분할', activation: false },
      { id: 3, content: 'SSR을 만들어보자', activation: false },
    ],
  },
  mutations: {
    SET_TODO_ITEMS (state, todoItems) {
      state.todoItems = todoItems;
    }
  },
});

3) /src/views/TodoList.vue

앞서 작성한 store 코드를 기준으로 다음과 같이 TodoList를 구성합니다. 사실 코드는 vanilla js 섹션에서 작성한 것과 크게 다르지 않습니다.

<template>
  <ul>
    <li
      v-for="{ id, content, activation } in todoItems"
      @click="toggle(id)"
    >
      <input type="checkbox" :checked="activation" />
      <span
        :style="{
          textDecoration: activation ? 'line-through' : 'none'
        }"
        v-html="content"
      />
    </li>
  </ul>
</template>

<script>
import { mapState, mapMutations } from "vuex";

const pageTitle = "TodoList | Vue SSR";

export default {
  name: "TodoList",

  computed: {
    ...mapState(['todoItems']),
  },

  methods: {
    ...mapMutations(['SET_TODO_ITEMS']),

    toggle (id) {
      const todoItems = [ ...this.todoItems ];
      const selectedItem = todoItems.find(v => v.id === id);
      selectedItem.activation = !selectedItem.activation;
      this.SET_TODO_ITEMS(todoItems);
    }
  },
}
</script>

4) /src/router/index.js

그리고 위에서 작성한 TodoList를 router에 등록하는 과정이 필요합니다.

import Vue from 'vue';
import VueRouter from 'vue-router';

import Home from "../views/Home.vue";
import TodoList from "../views/TodoList";

Vue.use(VueRouter);

export default new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home
    },
    {
      path: '/todo-list',
      name: 'TodoList',
      component: TodoList,
    }
  ]
});

5) /src/App.vue

기존에 있던 /about메뉴를 제거하고, /todo-list를 추가합니다.

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/todo-list">TodoList</router-link>
    </div>
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
  padding: 10px;
}

#nav {
  margin-bottom: 20px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

이제 개발 서버를 띄워서 잘 작동하는지 확인해봐야 합니다.

> yarn serve

6.gif

(3) SSR 관련 작업 진행

이제 본격적으로 SSR을 위한 다음과 같은 작업이 필요합니다.

  1. SSR 관련 패키지 설치
  2. build 환경 설정
    • vue.config.js 파일 추가
    • server side rendering에 필요한 bundle을 만들수 있도록 설정
  3. ssr entry 파일(main-ssr.js) 추가
  4. store, router 코드를 SSR에 적합하게 변경
  5. App.vue에 id값 확인
  6. npm script 작성

내용이 좀 많을 수 있지만, 그래도 차근차근 실습해봅시다

1) 패키지 설치

# vue ssr을 도와주는 서드파티 도구
> yarn add vue-server-renderer

# ssr 작업시 불필요한 내용을 bundle하지 않도록 해주는 서드파티
> yarn add webpack-node-externals

# 환경변수 설정을 위해서 cross-env 설치
> yarn add -D cross-env

2) build 환경 설정 작업

그리고 vue.config.js를 만든 후에 다음과 같이 작성해줍니다.

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const nodeExternals = require('webpack-node-externals');
const isSSR = Boolean(process.env.SSR);

module.exports = {
  // server에서 rendering을 할 때 사용해야 하기 때문에,
  // build된 파일이 server쪽 디렉토리에 위치시켜야 합니다.
  outputDir: '../server/public',

  pages: {
    // ssr로 빌드할 땐 다른 main-ssr.js를 entry로 사용할 수 있도록 합니다.
    index: `src/main${isSSR ? '-ssr' : '' }.js`,
  },

  chainWebpack: config => {
    // 일단 ssr로 빌드하는 경우에만 설정을 적용해야합니다.
    if (!isSSR) return;

    config
      // webpack이 node.js에 적합한 방식으로 dynamic import를 처리할 수 있으며
      // Vue를 컴파일할 때 `vue-loader`가 서버 지향 코드를 내보내도록 합니다.
      .target('node')
      
      // 공식문서에는 나와있지 않지만, splitChunks를 제거해야 정상적으로 빌드됩니다.
      // server side에서는 어차피 js를 split하여 사용할 필요가 없기 때문입니다.
      // 이 설정이 제외되면 다음과 같은 오류가 발생합니다.
      // Error: Server-side bundle should have one single entry file.
      .optimization
        .delete('splitChunks')
        .end()
      
      // commonjs 방식의 module 구문을 사용할 수 있도록 합니다.
      .output
        .libraryTarget('commonjs2')
        .end()
      
      // css와 scss에는 번들할 때 포함하지 않도록 합니다.
      // 그리고 공식문서에는 `whitelist`로 나와있는데,
      // `allowlist`로 해야 정상작동 합니다. (depatched됨)
      .externals(nodeExternals({ allowlist: /\.css|\.scss$/ }))
      
      // 이 플러그인을 통해서 server side에서 사용 가능한 bundle을 만들어줍니다.
      .plugin('ssr').use(new VueSSRServerPlugin());
  },
}

3) SSR Entry 파일 작업

그리고 /src/main-ssr.js를 추가하고 다음과 같이 작성해야 합니다.

만약에 router와 store가 없다면 다음과 같은 코드가 될 것입니다.

import Vue from 'vue';
import App from './App.vue';

export default function () {
  return new Vue({
    render: h => h(App);
  });
}

그런데 보통 router와 store 모두 사용하기 때문에 다음과 같이 작성해야 합니다.

import Vue from 'vue';
import App from './App.vue';

// router와 store가 instance를 반환하는게 아니라,
// instance를 생성하는 함수를 반환하도록 수정해야합니다.
// csr은 기본적으로 브라우저에서 렌더링 되기 때문에 router와 store가 1개씩만 필요합니다.
// 그런데 ssr은 다수의 사용자를 대상으로 하기 때문에
// 사용자마다 서로 다른 router와 store가 필요합니다.
// 그래서 router와 store를 생성하여 반환하도록 만들어야합니다.
import createRouter from './router';
import createStore from './store';

// 1. 이 파일을 entry point로 하여 server side에서 실행할 script를 번들링합니다.
// 2. Vue application을 만들어내는 함수를 반환합니다.
// 3. Promise를 반환할 수도 있으며, 단순하게 Vue instance를 반환해도 무관합니다.
// 4. server에서 rendering에 필요한 context를 매개변수 건내줍니다.
export default (context) => new Promise(async (resolve, reject) => {

  const router = createRouter();
  const store = createStore();
  const { url } = context;
  
  // server에서 보내준 url을 기준으로 router를 변경하고,
  // 해당 router를 기준으로 app을 rendering하여 문자열로 반환합니다.
  await router.push(url);

  // router에 반영이 된 시점에 App instance를 만들어서 반환합니다.
  // 그래서 Promise가 사용됩니다.
  router.onReady(() => resolve(
    new Vue({
      router,
      store,
      render: h => h(App)
    }))
  );
})

4) store와 router를 SSR에 적합한 코드로 수정

주석에서 언급한 것 처럼 storerouter 또한 수정이 필요합니다.

/src/router/index.js

import Vue from 'vue';
import VueRouter from 'vue-router';

import Home from "../views/Home.vue";
import TodoList from "../views/TodoList";

Vue.use(VueRouter);

// createRouter를 실행하면, router instance를 반환하도록 작성해야합니다.
// 사용자(client)마다 서로 다른 Router를 가지기 위함입니다.
export default function createRouter () {
  return new VueRouter({
    mode: 'history',
    base: process.env.BASE_URL,
    routes: [
      {
        path: '/',
        name: 'Home',
        component: Home
      },
      {
        path: '/todo-list',
        name: 'TodoList',
        component: TodoList,
      }
    ]
  });
}

/src/store/index.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

// createStore를 실행하면, store instance를 반환하도록 작성합니다.
// 사용자(client)마다 서로 다른 Store를 가지기 위함입니다.
export default function createStore () {
  return new Vuex.Store({
    state: {
      todoItems: [
        { id: 1, content: 'CSR을 만들어보자', activation: true },
        { id: 2, content: 'CSR 코드 분할', activation: false },
        { id: 3, content: 'SSR을 만들어보자', activation: false },
      ],
    },
    mutations: {
      SET_TODO_ITEMS (state, todoItems) {
        state.todoItems = todoItems;
      }
    },
  })
};

5) App.vue의 id값 확인

그리고 보통 cli을 이용하여 프로젝트가 초기화 되었다면 /src/App.vue의 root 태그에 id값이 지정되었는지 확인해봐야 합니다. 만약에 id가 제대로 지정되지 않았다면, ssr을 했을 때 id가 누락되기 때문에 CSR을 할 때 root tag를 찾을 수 없어서 문제가 됩니다.

<template>
  <!-- 이렇게 `id="app"` 처럼 표기되어있지 않으면 문제가 발생할 수 있습니다 --> 
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/todo-list">TodoList</router-link>
    </div>
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
  padding: 10px;
}

#nav {
  margin-bottom: 20px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

6) npm script 작성

마지막으로 package.jsonnpm script를 추가해야합니다.

{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    // 기존에 `build`를 `build:csr`로 변경합니다.
    "build:csr": "vue-cli-service build",
    // `build:ssr`을 추가하고, SSR을 위한 환경변수를 설정합니다.
    "build:ssr": "cross-env SSR=1 vue-cli-service build"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vue-router": "^3.2.0",
    "vue-server-renderer": "^2.6.14",
    "vuex": "^3.4.0",
    "webpack-node-externals": "^3.0.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "cross-env": "^7.0.3",
    "vue-template-compiler": "^2.6.11"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

npm script도 작성이 완료되었다면, 이제 build를 실행해봅시다.

> yarn build:ssr

정상적으로 실행되었다면, 다음과 같이 bundle 파일(vue-ssr-server-bundle.json)이 만들어집니다.

8.png

9.png 파일의 내용은 다음과 같이 bundling된 script입니다.

즉, 다음과 같은 작업을 한 것입니다. 7.png

Server Side Rendering은 이렇게 bundle 된 파일을 불러와서 실행시키는 것입니다.

(4) 서버 사이드 작업하기

이제 server쪽 코드를 작성해보겠습니다. 일단, server 프로젝트에 vue-server-renderer 패키지를 추가해야합니다.

# server 폴더로 이동합니다.
> cd ../server

# 패키지 설치
> yarn add vue-server-renderer

그리고 /server/app.js에 ssr 관련 코드를 추가해야합니다.

// express application을 초기화합니다.
const express = require("express");
const app = express();

// ssr에 필요한 패키지를 가져옵니다.
const { createBundleRenderer } = require("vue-server-renderer");

// SSR로 만들어진 html이 <!--vue-ssr-outlet--> 위치에 삽입됩니다.
const template = `
  <!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>Vue Simple SSR</title>
  </head>
  <body>
  <!--vue-ssr-outlet-->
  </body>
  </html>
`;

// template과 bundle된 파일을 이용하여 renderer를 만듭니다.
const renderer = createBundleRenderer(
  // main-ssr.js를 기준으로 bundle된 파일을 파싱하여 실행합니다.
  require('./public/vue-ssr-server-bundle.json'),

  // 렌더링된 코드는 template의 <!--vue-ssr-outlet--> 위치에 삽입됩니다.
  { template }
);

// 모든 페이지에 대해 매칭합니다.
app.get("/*", async ({ url }, res) => {
  // renderToString에 넘겨지는 파라미터가 context입니다.
  // 즉, renderToString이 main-ssr.js를 실행하며 반환하는 것이라고 볼 수 있습니다.
  res.send(await renderer.renderToString({ url }));
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000')
})

10.png

결과는 다음과 같습니다.

7.gif

위에서 확인한 것 처럼, 지금은 오직 Server Side Rendering만 실행된 상태입니다.

그래서 다음과 같은 문제점을 가지고 있습니다.

이를 해결하기 위해서 CSR 코드를 build하고, SSR template에 연동할 수 있도록 작업이 필요합니다.

(5) CSR 관련 작업 추가하기

공식문서에서 안내하고 있는 방법은 client-manifest를 활용하는 것입니다. manifest를 이용하여 SSR 시점에 어떤 script와 style을 가져와야 하는지 명시하는거죠. 마찬가지로 코드를 통해서 살펴보도록 하겠습니다.

먼저 CSR 빌드를 위해서 vue.config.js 파일을 수정해야합니다.

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const nodeExternals = require('webpack-node-externals');
const isSSR = Boolean(process.env.SSR);

// client 플러그인을 추가해야합니다.
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');

// production 모드일 때만, 즉, 빌드를 할 때만 추가 설정이 붙도록 해야합니다.
const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  outputDir: '../server/public',
  pages: {
    index: {
      entry: `src/main${isSSR ? '-ssr' : '' }.js`,
      template: 'public/index.html',

      // 빌드를 할 땐 /server/template/index.html을 생성하고,
      // 개발서버를 실행할 땐 /client/public/index.html을 읽어올 수 있도록
      // 핸들링이 필요합니다.
      filename: isProduction ? '../template/index.html' : 'index.html',
    }
  },

  chainWebpack: config => {
    // 빌드하는게 아니라면 추가 설정을 할 필요가 없으므로 바로 return 합니다.
    if (!isProduction) return;

    // CSR 관련 빌드 설정입니다.
    if (!isSSR) {

      return config
              // 한 개의 bundle 파일을 사용하는게 아닌,
              // 여러 개의 파일로 분할하도록 설정합니다.
              .optimization
                .splitChunks({ name: "manifest", minChunks: Infinity, })
                .end()
              // 이 플러그인을 사용하여 csr을 위한 client manifest를 만들어냅니다.
              .plugin('ssr').use(new VueSSRClientPlugin())
    }

    // SSR 관련 빌드 설정
    config
      .target('node')
      .optimization
        .delete('splitChunks')
        .end()
      .output
        .libraryTarget('commonjs2')
        .end()
      .externals(nodeExternals({ allowlist: /\.css|\.scss$/ }))
      .plugin('ssr').use(new VueSSRServerPlugin());
  },
}

그리고 package.json에 csr build를 위한 npm script를 추가합니다.

{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    // csr 빌드와 ssr 빌드를 동시에 할 수 있는 명령입니다.
    // csr 빌드시에는 public 폴더를 지워주고,
    // ssr 빌드시에는 `--no-clean` 옵션을 통해서 public 폴더를 보존하도록 합니다.
    "build:all": "cross-env NODE_ENV=production vue-cli-service build && cross-env NODE_ENV=production SSR=1 vue-cli-service build --no-clean",

    // ssr 시점에도 NODE_ENV를 production으로 설정합니다.
    "build:ssr": "cross-env NODE_ENV=production SSR=1 vue-cli-service build",
    
    // csr 빌드를 위한 명령어를 추가합니다. `NODE_ENV=production` 으로 설정합니다.
    "build:csr": "cross-env NODE_ENV=production vue-cli-service build"
  },
  "dependencies": {
    "axios": "^0.21.4",
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vue-router": "^3.2.0",
    "vue-server-renderer": "^2.6.14",
    "vuex": "^3.4.0",
    "webpack-node-externals": "^3.0.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "cross-env": "^7.0.3",
    "vue-template-compiler": "^2.6.11"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

위에서 유의깊게 봐야 하는 부분은 다음과 같습니다.

테스트를 위해 csr build만 우선 실행해봅시다.

> yarn build:csr

11.png 일반적인 build와 다르게 vue-ssr-client-manifest.json이 포함됩니다.

12.png

이제 build:all 명령을 실행해봅시다.

> yarn build:all

13.png

이렇게 vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json이 있어야 정상적으로 build가 된 것입니다.

이제 Server Side에서 client manifest를 이용하여 static file을 불러올 수 있도록 작업해야 합니다.

const express = require("express");
const { createBundleRenderer } = require("vue-server-renderer");

const app = express();

// `/server/public` 폴더의 파일들을 `static` 파일로 등록합니다.
app.use(express.static('./public'));

// SSR로 만들어진 html이 <!--vue-ssr-outlet--> 위치에 삽입됩니다.
const template = `
  <!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>Vue Simple SSR</title>
  </head>
  <body>
  <!--vue-ssr-outlet-->
  </body>
  </html>
`;

const ssrBundle = require('./public/vue-ssr-server-bundle.json');
const clientManifest = require('./public/vue-ssr-client-manifest.json');

// clientManifest를 매개변수로 넘겨줄 경우, style, script, state 등을 자동으로 삽입합니다.
const renderer = createBundleRenderer(ssrBundle, {
  template,
  clientManifest, 
});

// 모든 페이지에 대해 매칭합니다.
app.get("/*", async ({ url }, res) => {
  // renderToString에 넘겨지는 파라미터가 context입니다.
  // 즉, renderToString이 main-ssr.js를 실행하며 반환하는 것이라고 볼 수 있습니다.
  res.send(await renderer.renderToString({ url }));
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000')
});

이렇게 작성한 후에 서버를 실행시키고, 소스보기를 하면 다음과 같은 결과물을 확인할 수 있습니다.

14.png

결과물은 다음과 같습니다.

8.gif

(6) State 동기화

마지막으로, 간단하게 state를 동기화 하는 방법에 대해 살펴보겠습니다. 따로 DB나 API를 사용하는게 아니라 server에서 로컬에 선언된 변수를 조작하는 방식으로 알아보도록 하겠습니다.

1) /server/src/store.js

먼저 서버에서 간단하게 사용할 store를 만들어줍니다.

const store = {
  state: {
    todoItems: [
      { id: 1, content: 'CSR을 만들어보자', activation: true },
      { id: 2, content: 'CSR 코드 분할', activation: true },
      { id: 3, content: 'SSR을 만들어보자', activation: false },
    ],
  },
  setState (newState) {
    this.state = { ...this.state, ...newState };
  }
}

module.exports = store;

2) /server/app.js

그리고 state를 수정하고 가져올 수 있는 api endpoint를 만들어야 하며, ssr을 하는 시점에 state를 넘겨줄 수 있도록 만들어야 합니다.

const express = require("express");
const { createBundleRenderer } = require("vue-server-renderer");

// store를 가져옵니다.
const store = require("./src/store");

const app = express();

app.use(express.static('./public'));

// SSR 관련 코드
const template = /** 템플릿 코드 생략 **/;
const ssrBundle = require('./public/vue-ssr-server-bundle.json');
const clientManifest = require('./public/vue-ssr-client-manifest.json');
const renderer = createBundleRenderer(ssrBundle, { template, clientManifest });

// request body를 받아올 수 있도록 미들웨어를 등록합니다.
app.use(express.json());

// store의 state를 가져오는 endpoint
app.get("/api/state", (req, res) => {
  res.json(store.state);
})

// store의 state를 업데이트하는 endpoint
app.put("/api/state", (req, res) => {
  store.setState(req.body);
  res.status(204).send();
})

app.get("/*", async ({ url }, res) => {
  // context에 state를 넘겨줄 수 있도록 합니다.
  const { state } = store;
  res.send(await renderer.renderToString({ url, state }));
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000')
});

이렇게 contextstate가 있을 경우, 다음과 같이 window.__INITIAL_STATE__ 변수가 html에 삽입됩니다.

15.png

이제 SSR을 하는 시점과, CSR을 하는 시점에 해당 state를 반영할 수 있도록 작업해야합니다.

3) /client/src/main-ssr.js

먼저 ssr을 하는 시점에 서버에서 제공하는 state를 기반으로 렌더링할 수 있도록 해야합니다.

import Vue from 'vue';
import App from './App.vue';
import createRouter from './router';
import createStore from './store';

export default (context) => new Promise(async (resolve, reject) => {

  const router = createRouter();
  const store = createStore();

  // context에서 state를 가져옵니다.
  const { url, state } = context;
  
  // store에 반영합니다.
  store.commit('SET_TODO_ITEMS', state.todoItems);

  await router.push(url);

  router.onReady(() => resolve(
    new Vue({
      router,
      store,
      render: h => h(App)
    }))
  );
})

4) /client/src/main.js

csr을 하는 시점에 window.__INITIAL_STATE__를 읽어와서 반영하는 작업이 필요합니다.

import Vue from 'vue';
import App from './App.vue';
import createRouter from './router';
import createStore from './store';

Vue.config.productionTip = false

const router = createRouter();
const store = createStore();

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

5) /client/vue.config.js

개발서버에서 api 요청을 할 땐 proxy를 사용해야합니다.

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const nodeExternals = require('webpack-node-externals');
const isSSR = Boolean(process.env.SSR);
const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  outputDir: '../server/public',

  pages: { /** 생략 **/ },

  devServer: {
    proxy: {
      // `/api/**` 처럼 요청할 때 3000 port에 있는 endpoint를 proxy로 사용합니다.
      "^/api": {
        target: "http://localhost:3000"
      }
    }
  },

  chainWebpack: config => { /** 생략 **/ },
}

6) /client/src/views/TodoList.vue

이제 api 요청을 위해 axios를 설치합니다. (사실 fetch를 사용해도 상관 없습니다)

# 현재 폴더 조회 후 client 폴더인지 확인합니다.
> pwd
/f/projects/simple-vue-ssr/client

# client 프로젝트에 axios를 설치합니다.
> yarn add axios

이제 TodoList에서 toggle을 하면 server에 있는 todoItems에 반영할 수 있도록 작업이 필요합니다.

<template><!-- 생략 --></template>

<script>
import { mapState, mapMutations } from "vuex";
// axios를 import합니다.
import axios from "axios";

const pageTitle = "TodoList | Vue SSR";

export default {
  name: "TodoList",

  computed: {
    ...mapState(['todoItems']),
  },

  methods: {
    ...mapMutations(['SET_TODO_ITEMS']),

    toggle (id) {
      const todoItems = [ ...this.todoItems ];
      const selectedItem = todoItems.find(v => v.id === id);
      selectedItem.activation = !selectedItem.activation;
      this.SET_TODO_ITEMS(todoItems);

      // axios로 서버에 api 요청을 하여 server쪽 state를 수정합니다.
      axios.put("/api/state", { todoItems });
    }
  },
}
</script>

<style scoped></style>

결과물을 확인하는 방법은 일단 개발서버에서 확인하는 방법이 있고, 빌드 후에 확인하는 방법이 있습니다.

개발서버를 이용할 경우 client와 server 모두 서버를 실행해야합니다.

# client 프로젝트에서 개발 서버 실행
> yarn serve

# server 프로젝트로 이동 후 서버 실행
> cd ../server
> yarn serve

그래야 client에서 proxy를 이용하여 api 요청이 가능합니다.

9.gif 개발서버이므로 8080 port를 사용하고, 소스코드를 확인해보면 <div id="app"></div> 으로 표기됩니다.

전체 빌드 후에 확인할 경우 client를 build 후에 server에서 확인이 필요합니다.

# client 프로젝트 build
> yarn build:all
  
# server 프로젝트로 이동 후 서버 실행
> cd ../server
> yarn serve

10.gif express server이기 때문에 3000 port를 사용하고, 소스코드를 확인해보면 SSR이 적용된걸 볼 수 있습니다.

(7) SSR 실패시 CSR을 실행하도록 작업하기

먼저 기존에 변수로 관리되고 있던 ssr template을 파일로 분리해야합니다.

1) /server/template/ssr_index.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>Vue Simple SSR</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>

2) /server/src/ssr.service.js

SSR을 위한 service 파일을 만들고, 기존에 app.js에 존재하던 로직을 위임해야합니다. 그리고 build를 할 때 포함된 template/index.html은 CSR을 할 때 사용할 수 있습니다.

const { createBundleRenderer } = require("vue-server-renderer");
const fs = require('fs');
const { join } = require('path');

// csr과 ssr에 사용되는 template의 경로를 정의합니다.
const ssrTemplatePath = join(process.cwd(), 'template/ssr_index.html');
const csrTemplatePath = join(process.cwd(), 'template/index.html');

// ssr template을 읽어옵니다.
const template = fs.readFileSync(ssrTemplatePath, 'utf-8');

// csr template을 읽어옵니다.
const csrTemplate = fs.readFileSync(csrTemplatePath, 'utf-8');

// renderer를 정의합니다.
const bundle = require("../public/vue-ssr-server-bundle.json");
const clientManifest = require("../public/vue-ssr-client-manifest.json");
const renderer = createBundleRenderer(bundle, { clientManifest, template });

module.exports = {
  async getHtml (context) {
    try {
      return await renderer.renderToString(context);
    } catch (e) {
      // ssr이 정상적으로 이루어지지 않았을때, 즉 오류가 있을 땐 csr을 할 수 있도록 합니다.
      return csrTemplate;
    }
  }
}

3) /server/app.js

ssr 관련 로직을 분리했기 때문에 app.js에 있는 코드도 정리해야합니다.

const express = require("express");
const ssrService = require("./src/ssr.service");
const store = require("./src/store");

const app = express();

app.use(express.json());
app.use(express.static('./public'));

app.get("/api/state", (req, res) => {
  res.json(store.state);
})

app.put("/api/state", (req, res) => {
  store.setState(req.body);
  res.status(204).send();
})

app.get("/*", async ({ url }, res) => {
  const { state } = store;
  res.send(await ssrService.getHtml({ url, state }));
});

app.listen(3000, () => {
  console.log('listen to http://localhost:3000')
})

이렇게 작성하면 SSR 과정에서 오류가 발생하더라도 UI는 사용자들에게 정상적으로 보여지게 될 것입니다.

4. SSR을 작업할 때 유의할 점

앞선 과정을 통해서 SSR을 적용하는 방법에 대해 상세히 알아봤습니다. 아마 내용이 많기 때문에 전체적인 내용을 전부 이해하기는 어려울 수 있습니다.

그래도 다음과 같은 내용은 SSR을 할 때 반드시 알아야합니다.

(1) 브라우저 환경과 Node.js 환경을 구분하기

그런데 어쩔 수 없이 window나 document가 SSR 빌드 시점에 말려들어가는 경우가 있습니다. 모든 라이브러리나 패키지가 SSR을 고려하고 만들진 않기 때문입니다.

이럴 땐 jsdom 같은 패키지를 사용하여 가상의 window, document 객체를 만들어서 사용하면 된답니다.

(2) api 요청이 필요할 땐 axios를 사용하기

본문에서는 특별한 이유 없이 axios를 사용한 것 처럼 보이지만, axios의 경우 node.js 환경에서도 정상적으로 작동하는 패키지입니다. 따라서 SSR 시점에 api 요청이 필요하다면 가급적 axios를 사용해야합니다. 혹은 아예 CSR 시점에 api를 요청할 수 있도록 작업이 필요합니다.

(3) CSR은 One Client, SSR은 Multi Client

CSR은 기본적으로 브라우저 환경에서 렌더링을 하기 때문에 다수의 사용자를 고려할 필요가 없습니다. 하지만 SSR의 경우 특정 Request에 대한 Response를 반환하는 것이기 때문에 사용자마다 Store도 다르고 Router도 다를 수 있습니다.

실습한 코드를 예로들자면 다음과 같습니다.

만약에 store와 router를 request마다 생성하지 않을경우 다음과 같은 상황이 벌어질 수 있습니다.

쉽게 말해서 A가 로그인하면, 모든 사용자가 A가 로그인한 내용이 반영된 store를 기준으로 렌더링된 응답값을 받게 되는 것입니다. 생각만해도 끔찍한 장애가 아닌가요?

(4) Server가 꼭 필요함

당연하지만 Server Side Rendering 이기 때문에 렌더링을 해주는 Server가 꼭 필요합니다.

줌인터넷은 front server와 api server를 구분하여 관리하고 있습니다.

front server까지 관리하는게 프론트엔드 개발자의 영역이고, api server의 경우 백엔드 개발자들이 관리하고 있습니다.

(5) SSR은 무조건 build를 해서 확인해야함

앞선 과정에서 살펴본 것 처럼 vue 프로젝트를 build하면 bundle 파일이 생성됩니다. 해당 파일이 있어야 SSR이 가능합니다.

하지만 server에서도 webpack을 사용하고, vue-loader를 붙인다면 꼭 빌드하지 않아도 사용이 가능합니다. 추천하는 방법은 아닙니다.

어쨌든 이러한 이유 때문에 SSR 이 잘 되었는지 확인하고 디버그 하는 과정은 무척 고통스럽습니다. 프로젝트의 규모가 클 수록 빌드 시간이 길어지기 때문이죠!

그리고 SSR 과정에서 발생하는 오류는 특히 디버그하기가 힘들답니다.

(6) 선택적 SSR

모든 페이지에 대해서 SSR을 적용할 필요는 없습니다.

이건 최근에 오픈한 ZUM 금융의 일부 코드인데요

import { Controller, Get, Post, Req, Res } from "@zum-portal-core/backend";
import {Request, Response} from "express";

import {BaseDataFacade} from "@/modules/base-data/base.data.facade";
import { SsrService, CsrService } from "@/modules/renderer/services";

@Controller()
export class HomeController {
  constructor(
    private readonly baseDataFacade: BaseDataFacade,
    private readonly ssrService: SsrService,
    private readonly csrService: CsrService,
  ) {}

  @Get([
    "/news/article/*", // 뉴스상세페이지
    "/internal/item/*", // 종목상세페이지
    "/internal/etf-detail/*", // etf 종목상세페이지
    "/internal/index/*", // 지수상세페이지
    "/investment/view/*", // 투자노트
  ])
  public async getSSRPage (
    @Req() req: Request,
    @Res() res: Response
  ): Promise<void> {
    const renderedHtml = process.env.NODE_ENV.startsWith('production')
                          ? await this.renderByServer(req)
                          : await this.renderByClient(req)

    res.send(renderedHtml);
  }

  @Post("/investment/view-preview")
  public getInvestmentPreview (@Req() req: Request) {
    return this.renderByClient(req);
  }

  @Get(["/*"])
  public async getCSRPage(@Req() req: Request) {
    return this.renderByClient(req);
  }

  /** Client Side 렌더링 작업 **/
  private async renderByClient (req: Request) {
    const pageData = await this.baseDataFacade.fetchDataByPath(req);
    return this.csrService.getRenderedHtml(pageData);
  }

  /** Server Side 렌더링 작업 **/
  private async renderByServer (req: Request) {
    const pageData = await this.baseDataFacade.fetchDataByPath(req);
    return this.ssrService.getRenderedHtml(req.path, pageData);
  }

}

코드에서 확인할 수 있는 것 처럼, 특정 페이지를 제외하곤 전부 CSR을 사용합니다. 그리고 SSR 과정에서 오류가 발생할 수도 있기 때문에, 이럴 경우 CSR을 사용할 수 있도록 작업해주면 더욱 좋습니다.

글을 마치며

생각했던 것 보다 글이 무척 길어졌습니다. 그래도 하고 싶은 이야기를 많이 담지 못한 것 같아서 아쉽네요.

SSR 때문에 고생하는 모든 이들을 위해 이 글을 바칩니다 🙇‍♂️

전체 코드 확인해보기