Vue Server Side Rendering

    Vue Server Side Rendering


    이번 포스팅에서는 Universal Server Side Rendering에 이어서 VueJS의 공식 라이브러리인 vue-server-rendererExpress를 사용하여 SSR(Server Side Rendering) 어플리케이션을 개발한 과정과 운영 환경에서 생겼던 문제, 그리고 그 문제를 어떻게 해결했는지 적어보려고 한다.

    필자는 Frontend 개발자로 일하면서 Backend 프레임워크를 건드릴 일이 사실 거의 없었다. 그러나 필자의 현 직장에 SSR 서버를 필자가 도입하자고 주장하였고, 따라서 오너쉽도 필자에게 있었기 때문에 클라이언트 환경과 전혀 다른 서버의 작동방식과 여러 문제점에 대해서 상세하게 알고 있어야 할 필요가 있었다.

    보통 Frontend 개발자는 클라이언트에서 작동하는 어플리케이션을 개발하기 때문에 서버에서 작동하는 어플리케이션에서 발생할 수 있는 (조금만 생각해보면 당연한)문제에 대해서 의외로 쉽게 놓치고 지나갈 수 있다고 생각한다.

    그래서 두번 다시 이런 실수를 반복하지 않도록 문서로 정리를 하고 회고하려고 한다.

    먼저 Vue SSR의 렌더링 과정을 전체적으로 살펴본 후, 서버단 렌더링과 클라이언트단 렌더링을 나눠서 다시 살펴본다.

    이 포스팅에 예제로 나와있는 코드는 현 직장의 비즈니스 로직 때문에 생략된 부분이 있기 때문에 코드를 복사붙혀넣기해도 작동하지않을 수 있습니다.

    Vue Server Side Rendering의 구조

    필자는 Nuxt.js를 사용하지 않고 보일러플레이트를 사용해서 약간 개선해서 구현했다. 처음에는 ‘그냥 Nuxt쓸걸…’이라고 후회하기도 했지만 그래도 덕분에 Universal SSR의 실행 과정을 더 깊게 알아볼 수 있는 좋은 기회였다고 생각한다.(라고 삽질을 포장해본다)

    해당 포스팅에서는 필자가 작성했던 SSR 어플리케이션의 초기화 과정에 대해서 함수단위까지 자세하게 기재하려고 한다. 먼저 어플리케이션의 렌더링 과정은 다음과 같다. 이후 각 과정에 대한 자세한 설명을 후술하도록 하겠다.


    1. 클라이언트가 서버에 리소스 요청
    2. nginx가 Express가 띄워져있는 포트로 요청을 서빙
    3. Express 라우팅 시작
    4. server-entry.js 실행
    5. 서버의 vue-router 라우팅 진행
    6. vue-server-renderer를 사용하여 HTML 렌더링
    7. 서버가 클라이언트로 응답
    8. client-entry.js 실행
    9. 클라이언트 어플리케이션 초기화 함수 실행
    10. 클라이언트의 vue-router 라우팅 진행
    11. app.$mount

    1번 요청과 7번 응답을 제외한 2~6번 까지는 서버에서 일어나는 과정이고 8~10번 까지는 클라이언트에서 일어나는 과정이다. 특이한 점은 서버와 클라이언트의 엔트리 포인트가 다르다는 것이다.

    그리고 후술하겠지만 이 엔트리 포인트들은 몇가지 같은 함수를 공유하며 사용한다. router.onReadycreateApp같은 함수들이 그렇다. 애초에 Universal SSR은 기본적으로 첫 요청만 서버 사이드 렌더링하고 이후는 SPA처럼 작동하게 하자. 그리고 코드는 서버랑 클라이언트에서 재사용가능하게 하자!라는 개념이다. 그래서 편한 면도 있지만 실행 타이밍이나 환경이 같은 함수라도 완전 달라질 수 있기 때문에 별도의 예외처리를 해줘야 하는 등 헷갈리는 부분도 많았다.

    그리고 이 두개의 엔트리포인트가 서버와 클라이언트에서 실행될 때 서로 다른 초기화과정을 거치는데, 서버에서 초기화를 하고 클라이언트에서 싹 다 처음부터 다시 초기화를 진행하게 되면 비효율적이므로 몇가지 방법을 사용하여 최대한 효율적으로 렌더를 수행한다.

    먼저 서버사이드렌더링부터 살펴보자.

    Server Side Rendering

    클라이언트가 서버에 리소스 요청

    클라이언트에서 서버로 요청을 보낸다.

    nginx가 Express가 띄워져있는 포트로 요청을 서빙

    보통 nodeJS를 사용하여 서버를 개발할 때 node server.js와 같은 명령어로 바로 서버를 띄우는 경우는 드물고 보통 nginxapache와 같은 서버 엔진을 같이 사용한다. 그 이유는 다음과 같다.


    1. 서버 엔진 소프트웨어의 특성 상 nodeJS보다 더 빠른 Static file serving이 가능하다. 그리고 그런 요청을 nodeJS까지 보내지 않고 엔진단에서 처리되므로 백엔드의 부하가 분산된다.
    2. Node.js의 창시자인 Ryan Dahl이 “You just may be hacked when some yet-unknown buffer overflow is discovered. Not that that couldn’t happen behind nginx, but somehow having a proxy in front makes me happy” 라는 말을 한 적이 있음. 즉, 아직 발견되지 않은 취약점에 의한 공격을 어느 정도 방지할 수 있다는 뜻이다.

    그래서 대략 다음과 같은 nginx config를 작성하였다.

    server {
      listen 80;
      server_name example.com;
    
      location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;
    
        proxy_pass http://127.0.0.1:3000/;
        proxy_redirect off;
      }
    
      gzip on;
      gzip_comp_level 2;
      gzip_proxied any;
      gzip_min_length  1000;
      gzip_disable     "MSIE [1-6]\."
      gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
    }

    요청이 80포트로 들어오면 node server.js로 실행된 nodeJS 서버가 대기하고 있는 3000포트로 포워딩 해준다.

    그리고 사실 nodeJS 서버를 실행시킬 때 pm2forever와 같은 프로세스 관리자를 사용해서 실행시키는 편이 좋은데, 이 내용은 다음 포스팅에 언젠가 작성하겠다. 필자는 pm2를 사용 중.

    Express 라우팅 시작

    이렇게 들어온 요청은 nodeJS서버인 server.js에서 처리하게 된다. server.js에는 Vue 코드는 없고 nodeJS로 작성된 Express 프레임워크의 코드가 작성되어있다.

    const fs = require('fs');
    const express = require('express');
    
    const createRenderer = (bundle, template) => {
     return require('vue-server-renderer').createBundleRenderer(bundle, {
       template,
       runInNewContext: 'once',
     });
    };
    
    const bundle = require('./dist/vue-ssr-bundle.json');
    const template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8');
    const renderer = createRenderer(bundle, template);
    
    const app = express();
    app.set('views', './src/express/views');
    app.set('view engine', 'ejs');
    
    app.get('/ping', (req, res) => {
      debug(`health check from ELB`);
      res.render('healthCheck');
    });
    
    const bundle = require('./dist/vue-ssr-bundle.json');
    const template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8');
    const renderer = createRenderer(bundle, template);
    app.get('*', (req, res) => {
      if (!renderer) {
        return res.end('<pre>렌더링 중 입니다 뿜뿜</pre>');
      }
      res.setHeader('Content-Type', 'text/html');
    
      const context = { url: req.url, cookie: req.cookies };
      if (!context.url) {
        errorLog('[ERR] context url is not exist!!', context);
      }
    
      // 렌더 스트림 진행
      const stream = renderer.renderToStream(context);
    });

    필자는 Express를 사용했기 때문에 당연히 Express의 라우터를 사용했다. 그러나 실질적인 라우팅은 Vue가 진행하기 때문에 Express에서는 app.get('*')과 같이 와일드카드를 사용하여 모든 요청에 대한 콜백 함수를 실행하도록 한다.

    중간에 보면 app.get('/ping')이라는 코드도 있는데 저건 AWS의 Elastic Beanstalk의 Health Check 때문에 별도로 작성한 라우터이다. ELB에서는 현재 환경이 제대로 작동하고 있는지를 체크하려고 그 환경에 속한 인스턴스들의 특정 URL로 주기적으로 ping을 날린다. 이 URL은 ELB의 설정에서 바꿔줄 수 있고, 필자는 /ping이라는 URL로 설정했다.

    굳이 이 라우터를 따로 나눈 이유는 vue-ssr-renderer의 render 함수가 많이 실행될 수록 메모리에 올라가는 HTML 템플릿이 많아질 것이고 그렇게 됨으로써 결국 렌더 과정에 병목이 발생하기 때문에 vue-ssr-renderer를 실행시키지 않고 Express만으로 간단한 페이지를 응답으로 보내주게 해놓은 것이다.

    Express라우팅과 밑에서 설명할 vue-router의 라우팅이랑 헷갈릴 수 있는데, 방금 설명한 대로 실질적인 라우팅은 Vue에서 진행하게 되지만 요청을 Express에서 먼저 받아 처리한 후에 Vue로 넘겨주는 순서이기 때문에 Express에서도 라우팅을 해줘야한다. 라우팅 후 마지막 줄의 renderToStream 메소드가 실행되고나면 Vue에서 진행되는 라우팅과 렌더링을 시작하게 된다.

    이제 app.get('*') 라우터 내부를 자세하게 설명한다.

    app.get('*', (req, res) => {
      if (!renderer) {
        return res.end('<pre>렌더링 중 입니다 뿜뿜</pre>');
      }
      res.setHeader('Content-Type', 'text/html');
    
      const context = { url: req.url, cookie: req.cookies };
      if (!context.url) {
        errorLog('[ERR] context url is not exist!!', context);
      }
    
      const stream = renderer.renderToStream(context);
      stream.on('data', () => {
        /* @desc
         * vue-meta 플러그인을 사용하면 컴포넌트에 선언되어있는 metaInfo 메소드에서 반환한 값을 받아올 수 있다.
         * https://github.com/declandewet/vue-meta 참고할 것
         */
        const {
          title, link, style, script, noscript, meta,
        } = context.meta.inject();
        context.head = `
          ${title.text()}
          ${meta.text()}
          ${link.text()}
          ${style.text()}
          ${script.text()}
          ${noscript.text()}
        `;
      })
      .on('error', err => {
        debug(`렌더 중 에러 발생`);
        // 에러 페이지를 보여주는 등의 에러 핸들링 로직이 위치한다.
      })
      .on('end', () => {
        debug(`렌더링 종료`);
      })
      .pipe(res);
    });

    이 라우팅에서 가장 중요한 부분은 renderToStream 메소드의 역할이다.

    vue-ssr-rendererrenderToStringrenderToStream이라는 2가지 렌더 함수를 가지고 있다.

    renderToString은 모든 렌더가 끝나면 렌더된 HTML을 string의 형태로 반환하고 그 이후 클라이언트로 HTML을 한번에 반환한다. 때문에 렌더 속도가 오래 걸리게 되면 유저는 빈 화면을 보고 있을 수 밖에 없다. 또한 데이터를 한번에 내려주기 때문에 HTML 렌더를 진행할 때 내용을 전부 다 메모리에 올려야한다는 단점이 있다. HTML의 크기가 작으면 문제가 되지 않겠지만 파일의 크기가 커질 수록 매 렌더링 시 메모리 공간을 많이 잡아먹는다.

    renderToStream은 한 이벤트가 끝날때마다 nodeJS의 ReadableStream객체를 반환한다. stream은 데이터를 일정한 chunk단위로 불러오고 on메소드를 사용한 이벤트 콜백 호출로 stream을 관리할 수 있는 nodeJS의 기능이다. data이벤트는 각 chunk가 readable상태가 될때마다 호출되며 모든 데이터를 불러왔다면 end이벤트가 호출된다. 이 stream에 관한 내용은 추후 다른 포스트에서 언젠가 설명하도록 하겠다.

    server-entry.js 실행

    renderToStream함수가 실행되면 vue-server-renderer는 서버 쪽 엔트리 파일인 server-entry.js파일을 찾게된다. 이 파일에서는 app.js에 있는 팩토리 함수를 사용하여 app 객체를 생성하고 몇가지 초기화 과정을 거친 뒤 라우팅을 한다.

    import { createApp } from './app'; // 팩토리 함수 import
    
    export default context => {
      return new Promise(async (resolve, reject) => {
        // 해당 프로미스에서 resolve되면 router.push가 호출된 후에도 stream이 계속 진행되고
        // 해당 프로미스에서 reject되면 stream의 error이벤트가 호출된다.
      });
    };

    이 파일의 코드를 설명하기 위해서는 상단에 import된 createApp 함수에서 반환된 app, store, router가 뭔지 알고 있는 게 좋으므로, 자세히 살펴보기 전에 맨 위에서 import된 createApp 팩토리 함수를 먼저 살펴보자.

    createApp 함수는 Vue 인스턴스, vue-router의 VueRouter 인스턴스, Vuex의 Store 인스턴스를 반환하는 팩토리 함수이다. 이후 이 팩토리 함수는 client-entry에도 재사용되어 초기화를 진행하게 된다.

    import Vue from 'vue';
    import App from './App.vue';
    import Store from './stores';
    import { Router } from './router';
    
    export function createApp() {
      const store = Store();
      const router = Router();
      const app = new Vue({ router, store, render: h => h(App) });
      return {
          app, router, store,
      }
    }

    일반적으로 클라이언트에서 Vue를 초기화하는 코드와 비슷하지만 다른 부분이 하나 있는데, storerouter 인스턴스를 팩토리 함수를 사용해서 생성한다는 점이다. 보통 SPA 어플리케이션에서는

    export default new Vue({
      el: '#app',
      components: { App },
      template: '<App/>',
      router: new VueRouter({ ... }),
      store: new Vuex.Store({ ... }),
    });

    이런 식으로 Vue인스턴스를 생성한다. 하지만 이 로직을 그대로 서버에서 사용하기엔 문제가 하나 있다.

    문제는 export defaultcall by reference 평가전략을 사용하는 자료형을 반환하게 될 때 발생한다. new Vue()에서 호출하는 Vue는 인스턴스를 반환하는 클래스같이 작동하기 때문에 해당 코드는 최종적으로 Vue 인스턴스가 올라간 메모리 주소를 반환하게 되는데, 이 로직은 클라이언트에서는 딱히 문제가 없지만 서버에서는 문제가 발생할 수 있다.

    클라이언트와 다르게 서버는 한번 올라가면 오랜 시간동안 계속 돌아가는 프로그램이라는 것이다. 현재 서버에 접속해있는 유저들이 Store의 상태를 공유하면 안되기 때문에 서버는 각 요청에 대해서 새로운 StoreVue 인스턴스를 생성해야한다.

    하지만 위의 코드에서 export하는 것은 결과적으로 Vue인스턴스의 메모리 포인터이고 위 모듈이 import 될때 이 모듈은 처음 한번만 Vue인스턴스를 생성하고 이후는 참조해야하는 메모리 포인터, 즉 같은 인스턴스를 반환하게 된다.

    그렇기 때문에 서버 사이드 렌더링 때는 상태오염을 피하기 위해, 인스턴스의 메모리 포인터가 아닌 팩토리 함수를 노출시키고 매번 새로운 인스턴스를 생성해 반환하는 방법으로 작성하여야 한다.

    필자는 이 사실을 놓쳐서 유저들이 Store내부의 세션을 공유하게 되서 내 계정으로 로그인했지만 다른 사람 계정으로 로그인되버리는 버그를 생성한 적이 있다. 지금 생각해도 아찔한 순간이다.

    A Node.js server is a long-running process. When our code is required into the process, it will be evaluated once and stays in memory. … So, instead of directly creating an app instance, we should expose a factory function that can be repeatedly executed to create fresh app instances for each request.

    Vue SSR Guide Avoid Stateful Singletons

    심지어 이렇게 공식 문서에도 버젓히 적혀있는 걸 놓쳐서 엄청난 버그를 내고 말았다. 공식 문서를 반드시 읽읍시다! 두번세번 읽읍시다!

    자, 이제 createApp를 살펴보았으니 다시 server-entry.js로 돌아와서 해당 파일에 대한 설명을 계속 이어가겠다.

    import { createApp } from './app'; // 팩토리 함수 import
    import { TOKEN_KEY } from 'src/constants';
    import { SET_TOKEN, DESTROY_TOKEN } from 'src/stores/auth/config';
    import APIAuth from 'src/api/auth';
    
    export default context => {
      return new Promise(async (resolve, reject) => {
        const { router, store } = createApp(); // 새로운 앱 생성
        const cookies = context.cookie;
        const authToken = cookies[TOKEN_KEY]; // 요청을 보낸 클라이언트의 쿠키에 있는 토큰
        const next = () => {
          router.push(context.url);
        };
    
        if (authToken) {
          try {
            await APIAuth.isValidToken(authToken); // valid하면 200, invalid하면 400
            store.dispatch(SET_TOKEN, authToken);
          }
          catch (e) {
            console.error(e); // throw하면 렌더 실패로 간주된다. 하지만 토큰이 invalid하다고 렌더 자체를 실패시키면 안된다.
            store.dispatch(DESTROY_TOKEN);
          }
        }
    
        router.onReady(() => {
          // 라우팅 로직이 위치
        }, reject);
    
        next();
      });
    };

    이 파일의 메인 로직은 크게 2가지로 나누어 진다.


    1. 요청을 보낸 클라이언트의 쿠키에 토큰이 저장되어있을 경우 store.dispatch(SET_TOKEN, authToken)로 Store에 인증상태를 저장
    2. router.onReady로 선언된 서버 측 라우팅 로직 및 예외처리

    먼저 1번부터 살펴보자. 왜 굳이 인증된 토큰을 Store에 담아야 할까? 먼저 이 서버는 렌더링만을 수행하는 렌더서버이기 때문에 세션의 유효성 검사는 외부에 있는 API서버와 통신을 해서 수행해야한다.

    인증상태는 서버에서도 필요할 수 있고 클라이언트에서도 필요할 수도 있는데, 그럼 서버에서 한번 통신해서 토큰을 검사하고 클라이언트에서도 또 통신을 해서 토큰을 검사해야한다. 하지만 이런 방식은 비효율적이기 때문에 보통 이런 유니버셜 SSR을 지원하는 프레임워크에서는 서버의 상태를 클라이언트로 반환해주는 방법으로 window객체에 서버의 상태를 직렬화해서 렌더 시 <script>태그 안에 선언해주는 방식을 사용한다.

    vue-server-renderer에서는 클라이언트에 반환할 서버의 상태를 Vue의 Flux아키텍처 라이브러리인 Vuex를 사용하여 선언한다.

    그렇게 서버의 상태는 렌더 시 JSON.stringify를 사용하여 직렬화되어 window.__INITIAL_STATE__라는 프로퍼티에 담기게 되고, 이후 클라이언트 초기화 시 해당 프로퍼티에 접근해 JSON.parse를 사용하여 Object형으로 형변환 후 Vuex Store의 replaceState메소드를 사용해 Store를 업데이트하게 된다.

    initial state 브라우저 콘솔에서 이렇게 확인해볼 수 있다

    서버의 vue-router 라우팅 진행

    다음 2번이었던 라우팅 로직을 살펴보자. Universal SSR 어플리케이션은 맨 처음 사용자가 페이지를 열었을 때는 서버 쪽에서 라우팅을 진행하고 그 이후 사용자가 페이지를 이동할때는 클라이언트에서 라우팅을 진행하게된다.

    server-entry.js 내부의 라우팅 로직은 맨 처음 사용자가 어플리케이션을 초기 실행시킬 때 딱 한번 실행되는 로직이라는 의미이다. 필자는 서버에서는 이 라우터에 연결된 컴포넌트가 있는지에 대한 검사만 진행하고 클라이언트에 라우터 인증 관련 로직을 작성했기 때문에 서버 쪽 엔트리의 라우팅 로직은 간단하게 작성했다.

    이 파일에서 사용된 router객체는 createApp 팩토리 함수에서 생성되어 반환된 vue-router 라이브러리 내 VueRouter클래스의 인스턴스이다. 필자는 이 클래스의 getMatchedComponents 메소드를 사용해서 현재 라우트가 유효한 라우트인지만 검사하기로 했다.

    VueRouter의 멤버변수와 메소드의 의미는 vue-router공식 문서에도 나와있지만 가끔씩 라이브러리는 업데이트가 되었으나 공식 문서는 업데이트가 늦는 경우도 있으므로 필자는 직접 vue-router의 코드를 살펴봤다.

    node_modules/vue-router/types/router.d.ts 파일을 살펴보면 VueRouter 클래스의 멤버 변수와 메소드를 확인할 수 있다.

    declare class VueRouter {
      constructor (options?: RouterOptions);
    
      app: Vue;
      mode: RouterMode;
      currentRoute: Route;
    
      beforeEach (guard: NavigationGuard): Function;
      beforeResolve (guard: NavigationGuard): Function;
      afterEach (hook: (to: Route, from: Route) => any): Function;
      push (location: RawLocation, onComplete?: Function, onAbort?: Function): void;
      replace (location: RawLocation, onComplete?: Function, onAbort?: Function): void;
      go (n: number): void;
      back (): void;
      forward (): void;
      getMatchedComponents (to?: RawLocation | Route): Component[];
      onReady (cb: Function, errorCb?: Function): void;
      onError (cb: Function): void;
      addRoutes (routes: RouteConfig[]): void;
      resolve (to: RawLocation, current?: Route, append?: boolean): {
        location: Location;
        route: Route;
        href: string;
        // backwards compat
        normalizedTo: Location;
        resolved: Route;
      };
    
      static install: PluginFunction<never>;
    }

    getMatchedComponent메소드는 RawLocation타입이나 Route타입을 인자로 받아서 Component 리스트를 반환해주는 메소드라는 것을 확인할 수 있다. 그럼 이제 node_modules/vue-router/dist/vue-router.common.js파일에서 getMatchedComponent이 어떻게 구현되어있는지 확인해보자.

    VueRouter.prototype.getMatchedComponents = function getMatchedComponents (to) {
      var route = to
        ? to.matched
          ? to
          : this.resolve(to).route
        : this.currentRoute;
      if (!route) {
        return []
      }
      return [].concat.apply([], route.matched.map(function (m) {
        return Object.keys(m.components).map(function (key) {
          return m.components[key]
        })
      }))
    };

    VueRouter클래스의 getMatchedComponent라는 메소드는 to 인자를 받으면 해당 라우트와 매치된 컴포넌트를 반환하고, 인자가 주어지지 않는다면 현재 라우트에 매치된 컴포넌트를 반환하도록 되어있다. VueRouter 클래스의 타입 선언부에서 확인한 대로 to인자에는 optional을 의미하는 ?가 붙어있었기 때문에 필요한 경우가 아니면 굳이 인자를 넘겨줄 필요는 없을 것 같다. 이제 router.onReady이벤트훅 내부를 한번 작성해보자.

    router.onReady(() => {
      /**
      * @desc 현재 라우터에 연결되어 있는 컴포넌트가 없다면 reject함으로써
      * nodeJS stream의 error이벤트가 호출되고 별도로 작성해놓은 errorHandler가 404페이지가 렌더 될 것이다.
      */
      const matchedComponents = router.getMatchedComponents();
      if (!matchedComponents.length) {
        return reject({
          code: 404,
          msg: `${router.currentRoute.fullPath} is not found`,
        });
      }
      else {
        resolve(app);
      }
    }, reject);

    얼추 된 것 같다. 하지만 필자의 어플리케이션은 asyncData라는 프로퍼티를 사용하여 라우팅을 진행하기 전에 비동기로직을 기다릴 수 있도록 작성이 되어있다. Vue의 SSR 라이브러리인 Nuxt에서도 비슷한 방식을 사용했던 것 같은데 이 부분은 잘 기억이 나지않는다.

    어쨌든 현재 라우트에 매치된 컴포넌트리스트 중 asyncData를 가지고 있는 컴포넌트가 있다면 Promise를 사용해서 기다리도록 만들어주면 되는 간단한 로직이기 때문에 Promise.all을 사용하여 다음과 같이 작성하였다.

    router.onReady(() => {
      /**
      * @desc 현재 라우터에 연결되어 있는 컴포넌트가 없다면 404페이지를 렌더한다.
      */
      const matchedComponents = router.getMatchedComponents();
      if (!matchedComponents.length) {
        return reject({
          code: 404,
          msg: `${router.currentRoute.fullPath} is not found`,
        });
      }
      // start: 추가된 부분
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({ route: router.currentRoute, store, });
        }
      })).then(() => {
        /** @desc
         * context에 state를 넘겨주고 렌더러에`template` 옵션을 사용하면 context.state를 직렬화하여 `window .__ INITIAL_STATE__`로 HTML에 주입해준다.
         */
        context.state = store.state;
        resolve(app);
      }).catch(reject);
      // end: 추가된 부분
    }, reject);

    그리고 모든 라우팅이 완료되었을 때 context.state = store.state처럼 context.statestore상태를 담아주면 vue-server-renderer가 알아서 window.__INITIAL_STATE__에 상태를 주입해준다.

    vue-server-renderer를 사용하여 HTML 렌더링

    stream
    .on('error', err => {
      return errorHandler(req, res, err, bugsnag);
    })
    .on('end', () => {
      debug(`render stream end ==============================`);
      debug(`${Date.now() - s}ms`);
      debug('================================================');
    })
    .pipe(res);

    이렇게 server-entry.js에서 Promise.resolve가 호출되어 초기화가 끝나면 아까 선언해놓았던 server.js의 stream의 end이벤트가 실행되고나서 체이닝되어있는 pipe메소드가 실행된다.

    서버가 클라이언트로 응답

    위 과정을 거친 후 렌더가 끝난 HTML을 클라이언트로 전송한다.

    Client Rendering

    client-entry.js 실행

    클라이언트에서 서버 렌더링이 완료된 HTML과 entry.js를 받아온 후 클라이언트 렌더링이 시작된다. 이때 웹팩이 컴파일할때 클라이언트단 엔트리 포인트로 잡는 파일은 client-entry.js이다. 먼저 client-entry.js파일의 init 함수를 살펴보자.

    클라이언트 어플리케이션 초기화 함수 실행

    import { createApp } from './app';
    import { LOGIN } from 'src/stores/auth/config';
    
    const { app, router, store } = createApp();
    const init = async function () {
    
      /** @desc 서버의 스토어의 클라이언트 스토어의 동기화 */
      if (window.__INITIAL_STATE__) {
        store.replaceState(window.__INITIAL_STATE__);
      }
    
      /** @desc 토큰 존재 여부 확인 후 로그인 처리  */
      const hasToken = store.state.auth.authToken;
      if (hasToken) {
        try {
          await store.dispatch(LOGIN);
        }
        catch (e) {
          // 쿠키 내 토큰을 삭제하는 등의 별도 예외처리
        }
      }
    
      return Promise.resolve();
    };

    현 직장의 코드다 보니까 전체를 적지는 못했지만 init함수가 수행하는 로직은 서버의 스토어 상태를 클라이언트에 반영사용자 인증처리이다. 서버의 스토어 상태를 받아오는 원리는 4. server-entry.js 실행에서 설명했으니, 이번에는 왜 로그인처리를 서버에서 하지않고 클라이언트에서 하는가?에 대해서 설명해보려고 한다.

    그 이유는 이 서버가 별도의 인증 로직을 가지고 있지 않은 렌더 서버이기 때문에 유저 정보를 가져오거나 인증 여부를 확인하거나 하는 작업은 모두 외부의 API 서버에 의존하고 있기 때문이다. 처음에 이 사실을 간과하고 서버 렌더링 시 API 통신을 한 후 유저데이터를 클라이언트로 내려주는 방법을 택했는데, 다음과 같은 문제가 발생했다.


    1. HTML 템플릿 렌더시간에 API 통신시간이 포함되었다. (렌더가 끝나면 서버렌더링의 라이프사이클도 같이 끝나기 때문에 렌더 중간에 await를 사용하여 API 통신을 동기처리할 수 밖에 없다. 심지어 인증된 유저 정보 GET API가 꽤 느린 편)
    2. vue-ssr-renderer의 render 메소드의 수행시간이 늘어났다.
    3. render메소드의 수행시간이 늘어나면서 한번에 메모리에 올라가는 템플릿이 많아졌다.
    4. 메모리가 꽉 차서 더 이상 렌더링을 수행하지 못한다.
    5. 서버가 응답을 하지 못한다.
    6. Fail

    그래서 이 렌더 서버를 구축할 때 가장 집중했던 부분은 render 메소드의 수행시간 단축이었고, 그 결과 유저 데이터를 받아오는 로직을 클라이언트로 내리게 되었다. 나중에 생각해보니 현재 인증된 사용자의 데이터가 필요한 뷰는 SEO가 필요없는 부분이라서 굳이 서버에서 할 필요가 없었다. 이제 마지막으로 클라이언트의 라우팅을 살펴보자.

    클라이언트의 vue-router 라우팅 진행

    client-entry.js에는 클라이언트 사이드의 전역 라우터도 같이 선언이 되어있다.

    import { createApp } from './app';
    import { LOGIN } from 'src/stores/auth/config';
    
    const { app, router, store } = createApp();
    const init = async function () {...};
    
    router.onReady(async () => {
      await init();
    
      router.beforeEach((to, from, next) => {
        const matched = router.getMatchedComponents(to);
        const prevMatched = router.getMatchedComponents(from);
        let diffed = false;
        const activated = matched.filter((c, i) => {
          return diffed || (diffed = (prevMatched[i] !== c));
        });
        if (!activated.length) {
          next();
        }
        Promise.all(activated.map(c => {
          if (c.asyncData) {
            return c.asyncData({ store, route: to });
          }
        })).then(() => {
          /* LOADING INDICATOR */
          next();
        }).catch(next);
      });
    
      app.$mount('#app');
    });

    사실 라우터 부분은 sever-entry.js에 있던 라우팅 로직 부분과 별로 다르지 않다. 그러나 한 가지 차이점이 있다면 client-entry.js의 라우팅에서는 현재 라우터의 컴포넌트와 이전 라우터의 컴포넌트를 비교하는 로직이 있다는 것이다.

    첫 요청 시 라우팅이 단 한번 일어나는 서버 렌더링과 다르게 클라이언트의 라우팅은 사용자의 액션에 따라서 여러 번 일어나게된다. 클라이언트 렌더링은 라우터가 변경되었을 때 컴포넌트가 변경된 부분만 새로 렌더하고 나머지는 그대로 유지하기 때문에 다음 라우터에는 현재 라우터에 있던 컴포넌트를 그대로 사용하고 있을 수 있다.

    중요한 점은 클라이언트에서도 asyncData를 서버와 마찬가지로 라우팅이 완료되기 전에 데이터를 fetch해오는 용도로 사용되고 있다는 점이다.

    즉, 현재 라우터에 존재하는 컴포넌트가 다음 라우터에도 존재한다면 굳이 그 컴포넌트의 asyncData에서 중복되는 로직을 수행할 필요가 없기 때문에 라우터가 변경될 때 컴포넌트를 비교하는 로직을 수행한 후, 달라진 컴포넌트의 asyncData만 수행하도록 로직을 작성해야한다.

    app.$mount -> 렌더 종료. Vue 라이프사이클 시작

    그 후 마지막에 app을 #app DOM에 직접 마운트하면 클라이언트 사이드의 Vue 라이프사이클이 시작된다. 마지막으로 해당 프로젝트 보일러 플레이트의 Github 링크를 첨부한다.

    이상으로 Vue SSR 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.