서론

세상에나, 이게 가능하다니… 사실 예전에 V8Js 를 봤을 때에는 크게 기대를 안했는데, 왜냐하면 자바스크립트 프레임워크는 나날이 복잡해져가는데 Node로 V8 을 고유하게 사용하는 것도 아니고 PHP 에서 불러다 쓰면 잘 안될거라 생각했기 때문이다 (내가 잘 몰랐기 때문에… ㅎㅎㅎ). 그런데 이번에 회사에서 적용 시켜보니 잘 되더라. WOW :D

PHP 의 제일 유명한 Laravel Framework 와 Vue 의 만남! 사실 Laravel에서 Vue 는 기본으로 사용하도록 권장한다. 그런데 Server Side Rendering (SSR) 까지는 지원하지 않는다. 단지 Client Side 에서 사용할 수 있는 Web Component 기반의 Frontend Framework 중 하나로써, 기본적으로 Vue 를 쓰도록 권장할 뿐이지 이 또한 React 로 바꿀 수 있다 (php artisan preset react).

이론적으로는 React 의 경우도 이렇게 Laravel 과 연동하여 SSR 을 적용시킬 수 있을거라고 생각하지만, 직접 해보진 않았으므로 자세한 부분은 실제로 누군가가 하게 되면 말씀해주시기 바랍니다 :)

왜 Laravel 과 Vue SSR 인가?

Why? 를 묻는 분들이 있을 것 같다.

장점은 명확하다.

Vue의 CSR / SSR

Vue 와 Vue Router 의 조합으로 이미 Client Side Rendering 이 지원이 되고, HTML 이 브라우저 내에서 만들어지며 DOM 으로 렌더링 되기 때문에 사용자의 입장에서는 페이지가 새로고침 되지 않고도 더욱 쾌적하게 웹사이트를 돌아다닐 수 있음은 이미 Single Page Application 들의 특징이지만 (Angular 도 그렇고 React 도 그렇고), 거기에 SSR 까지 지원이 됨으로써 URL 이 변경이 되어도 서버에서 렌더링 되는 페이지나 클라이언트에서 렌더링 되는 페이지가 같다는 점은 정말이지… 최고다.

물론 이는 Nuxt.js 등의 Node.js 기반 서버를 사용해도 달성할 수 있는 일이기는 하다.

Laravel

Laravel 을 쓰는 것도 장점이라 생각한다.

Laravel 은 내 생각에 모던하게 사용할 수 있는 굉장히 아름다운 웹 프레임워크이다. Python 의 Django 처럼, 웹 개발에 필요한 왠만한 것들은 이미 다 포함이 되어있고, 더 나아가 코드가 PHP 답지 않게 굉장히 깔끔하다. Blade Template 은 진짜… 깔끔함 그 자체라고 생각한다.

나는 그냥 특징이라고 생각하지만 그나마 Laravel 의 단점을 꼽자면 Performance 가 아주 좋지는 않다는 점인데, 그것 또한 그만한 이유가 있기 때문에 그렇다 (많은 것들을 해주기 때문에). 어쨌든 웹 서버를 개발한다면 사용자의 HTTP 요청을 받아서 User Agent 별로 처리하고, 미들웨어 별로 HTTP 요청 관리도 해야하고, 세션 관리도 해야하고, HTML 렌더링도 해야하는 등의 많은 작업이 들어간다. 그러한 것들이 복합적으로 포함된 상태에서는 그만큼의 컴퓨팅이 더 들어가게 된다. 더 좋은 성능을 얻기 위해 더욱 많은 시간을 들여 다른 언어 (예를 들어서 Go!) 로 작성을 하는 것보다는, 이미 잘 만들어지고 성능도 PHP 7 부터는 꽤나 좋은 수준으로 사용할 수 있는, 그리고 코드도 깔끔하여 향후 유지보수에도 좋은 Laravel 을 고르는 것이 더 낫다고 생각한다 :) 개발자의 시간은 좋은 성능의 VM을 쓰는 것보다 비싸니깐 말이다.

그 외

그 외 장점을 꼽자면 Vue 개발자가 PHP 를 크게 신경쓰지 않아도 된다는 점이다. 업무의 분업이 명확해지는 것 역시 장점이라 생각한다 :)

Setup Guide

그러면 본격적으로 Laravel 과 Vue 를 이용하여 SSR 을 구축하는 방법을 알아보자.

OS 는 현재 Ubuntu 18.04 를 이용하고 있다.

PHP 설치

먼저 PHP 7 이상의 버전을 사용하도록 한다. 여기서는 7.2 버전을 사용하였다. 2019년 3월 현재 기준, bionic apt repo 에서 PHP 는 기본으로 7.2 를 사용할 수 있다 :D

나는 PHP 를 사용할 때 Apache 와의 조합 보다 Nginx 를 더 선호하기 때문에, 여기서는 PHP-FPM 과 Nginx 를 설치한다.

# 업데이트 할 패키지가 있으면 먼저 업데이트 한다.
sudo apt update -y
sudo apt upgrade -y

# Nginx 와 PHP-FPM 을 설치한다.
sudo apt install nginx php-fpm -y

Nginx와 PHP-FPM 이 설치된 다음에는 둘을 연동해준다. 간단하다. 다음을 Nginx configuration 에 추가해주자.

# /etc/nginx/sites-enabled/default

server {
  listen 80 default_server;

  server_name _;
  server_tokens off;

  location / {
    try_files $uri /index.php?$args;
  }

  location ~ \.php$ {
    try_files $uri =404;

    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/run/php/php7.2-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
  }
}

그 다음으로는 V8Js 를 빌드해보자. V8Js 의 Github 레포 내의 가이드에 잘 나와있다.

Ubuntu 18.04 에서 사용할 수 있는 prebuilt library 를 Github 내의 내 레포 에 올려놓았다. 빌드 하기 싫으신 분은 바로 다음 단계 로 넘어가시면 된다.

V8 빌드

V8Js 를 빌드하기 이전에 V8 을 먼저 빌드해야 한다.

가이드 대로 하면 잘 된다.

# dependencies 설치
sudo apt-get install build-essential curl git python libglib2.0-dev -y

# build toolchain 다운로드
mkdir ~/tmp && cd ~/tmp
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=`pwd`/depot_tools:"$PATH"

# V8 다운로드
fetch v8  # fetch 는 위에서 받은 depot_tools 내의 Python CLI 로 존재
cd v8

# V8 버전 고정
git checkout 6.4.388.18
gclient sync

# 빌드 파일 생성 (cmake 처럼)
# 참조: https://chromium.googlesource.com/chromium/src/tools/gn/+/48062805e19b4697c5fbd926dc649c78b6aaa138/README.md
tools/dev/v8gen.py -vv x64.release -- is_component_build=true

# V8 빌드
ninja -C out.gn/x64.release/

# /opt/v8 경로에 빌드한 파일들 이동
sudo mkdir -p /opt/v8/{lib,include}
sudo cp out.gn/x64.release/lib*.so out.gn/x64.release/*_blob.bin \
  out.gn/x64.release/icudtl.dat /opt/v8/lib/
sudo cp -R include/* /opt/v8/include/

# V8 라이브러리 간의 shared library relative link 맞춰주기 위한 patchelf
sudo apt install patchelf -y
for A in /opt/v8/lib/*.so; do sudo patchelf --set-rpath '$ORIGIN' $A; done

patchelf 까지 해주면 relative link 가 잘 잡힌다.

[email protected]:/opt/v8/lib$ ldd libv8.so
        linux-vdso.so.1 (0x00007ffcb4378000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f85b31f6000)
        libicui18n.so => not found
        libicuuc.so => not found
        libv8_libbase.so => not found
        libc++.so => not found
        librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f85b2fee000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f85b2c50000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f85b285f000)

[email protected]:/opt/v8/lib$ for A in *.so; do sudo patchelf --set-rpath '$ORIGIN' $A; done
[email protected]:/opt/v8/lib$
[email protected]:/opt/v8/lib$ ldd libv8.so
        linux-vdso.so.1 (0x00007ffcf1dc2000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f6c8ac03000)
        libicui18n.so => /opt/v8/lib/./libicui18n.so (0x00007f6c8a952000)
        libicuuc.so => /opt/v8/lib/./libicuuc.so (0x00007f6c8b996000)
        libv8_libbase.so => /opt/v8/lib/./libv8_libbase.so (0x00007f6c8b975000)
        libc++.so => /opt/v8/lib/./libc++.so (0x00007f6c8a864000)
        librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f6c8a65c000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f6c8a2be000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6c89ecd000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f6c8b92a000)
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f6c89cb5000)

V8Js 빌드

V8Js 빌드를 위해서는 PHP 개발 도구를 설치해주어야한다.

sudo apt install php-dev -y

그리고 V8Js 를 빌드해준다. 놀랍게도 여기서 공식 가이드 대로 하면 빌드가 안된다. pecl 로 설치해야 깔끔하게 설치가 된다.

cd ~/tmp
git clone https://github.com/phpv8/v8js.git
cd v8js
phpize

# 한번 V8 경로를 물어본다.
echo '/opt/v8' | sudo pecl install v8js

이후에 /usr/lib/php/20170718/v8js.so 파일을 이용하면 된다.

sudo cp /usr/lib/php/20170718/v8js.so /opt/v8/lib/
sudo chmod +x /opt/v8/lib/v8js.so

php.ini 에 등록하기

빌드한 v8js.so 파일을 php.ini 에 등록해주면 된다.

; PHP-FPM 을 설치하였으니, FPM 쪽의 ini 파일을 수정!
; /etc/php/7.2/fpm/php.ini
; ...
extension=/opt/v8/lib/v8js.so

그러면 V8Js 가 잘 된다 :)

phpinfo() 내용의 일부

간단한 Test Code 를 실행시켜보자. PHP V8Js 공식 홈페이지에 나와있는 예제이다.

<?php

$v8 = new V8Js();

/* basic.js */
$JS = <<< EOT
len = print('Hello' + ' ' + 'World!' + "\\n");
len;
EOT;

try {
  var_dump($v8->executeString($JS, 'basic.js'));
} catch (V8JsException $e) {
  var_dump($e);
}

다음과 같이 나오면 성공적이다.

Hello World! int(13)

Laravel 설치하기

Laravel 설치는 간단하다.

위 단계에서 설치 안한 몇 가지 PHP 모듈들을 마저 설치해주자.

sudo apt install php-bcmath php-mbstring php-zip -y
sudo apt install composer -y

그리고 Laravel 을 설치해주자. Laravel Github Repo 에서 특정 버전을 다운로드 받아서 web root 에 놓아도 되고, 아니면 installer 를 이용해도 된다. 여기서는 특정 버전을 다운로드 받도록 하자.

# web root 디렉토리로 이동한다.
cd /var/www/html

# 5.7 버전을 받는다.
git clone https://github.com/laravel/laravel.git .
git checkout 5.7
rm -rf .git/

# Laravel 포함, PHP dependencies 를 설치한다.
composer install

# .env 파일을 복사하고 app key를 생성한다.
cp .env.example .env
php artisan key:generate

# log 파일 등이 저장되는 디렉토리의 권한 설정.
chmod 777 storage/ -R

현재 Laravel 의 root 경로를 /var/www/html 으로 설치하였으니, nginx 설정도 그에 맞게 바꾸어준다.

root 경로를 <WEBROOT>/public 으로 설정해주어야 한다.

# /etc/nginx/sites-enabled/default

server {
  listen 80 default_server;

  server_name _;
  server_tokens off;

  root /var/www/html/public;

  location / {
    try_files $uri /index.php?$args;
  }

  location ~ \.php$ {
    try_files $uri =404;

    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/run/php/php7.2-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
  }
}

그리고 nginx 를 reload 해주면, Laravel 이 설치가 완료된다.

systemctl reload nginx

Laravel과 Vue 연동

일단 기본적으로 Laravel Project 에는 Vue 가 설정되어 있음을 확인할 수 있다.

[email protected]:/var/www/html$ tree -L 1
.
|-- CHANGELOG.md
|-- app
|-- artisan
|-- bootstrap
|-- composer.json
|-- composer.lock
|-- config
|-- database
|-- index.php
|-- package.json
|-- phpunit.xml
|-- public
|-- readme.md
|-- resources
|-- routes
|-- server.php
|-- storage
|-- tests
|-- vendor
`-- webpack.mix.js

10 directories, 10 files

package.json 파일을 살펴보면, Laravel 에서 Frontend 개발에 사용하도록 설정된 Laravel Mix와 함께 Vue 설정이 되어 있다.

[email protected]:/var/www/html$ cat package.json
{
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
        "watch": "npm run development -- --watch",
        "watch-poll": "npm run watch -- --watch-poll",
        "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
        "prod": "npm run production",
        "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
    },
    "devDependencies": {
        "axios": "^0.18",
        "bootstrap": "^4.0.0",
        "cross-env": "^5.1",
        "jquery": "^3.2",
        "laravel-mix": "^4.0.7",
        "lodash": "^4.17.5",
        "popper.js": "^1.12",
        "resolve-url-loader": "^2.3.1",
        "sass": "^1.15.2",
        "sass-loader": "^7.1.0",
        "vue": "^2.5.17"
    }
}

Vue SSR 연동을 위해서 핵심적으로 필요한 모듈인, vue-server-renderer 를 설치해준다. 중요한 점은 Vue 버전과 Renderer 버전이 동일해야 한다.

npm install
npm install -D [email protected]=2.5.17

아니면 package.json 파일에서 직접 버전을 일치 시킨 후 npm install 을 해도 된다 :)

...
        "vue": "2.5.17",
        "vue-server-renderer": "2.5.17"
...

그 다음에는 Vue가 제대로 동작하는지 확인해보자. 다음의 경로에 App.vue 라는 파일을 만들고, 간단한 Vue 파일을 만들어보자.

resources/js/components/App.vue

<template>
  <div id="app">
    {{ message }}
  </div>
</template>

<script>
  export default {
    data() {
      return {
        message: 'Hello World'
      }
    }
  }
</script>

그리고 Vue 가 HTML 내에 마운트 될 수 있도록 하는 index 파일을 만들자. 여기서는 app.js 라는 이름의 파일을 만들도록 하자.

resources/js/app.js

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

new Vue({
  el: '#app',
  render: h => h(App)
});

Vue 파일은 Laravel Mix 와 함께 컴파일된다.

기본 설정만으로 문제 없이 컴파일 된다.

[email protected]:/var/www/html$ npm run dev
> @ dev /var/www/html
> npm run development


> @ development /var/www/html
> cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js
...

 DONE  Compiled successfully in 4363ms

       Asset     Size   Chunks             Chunk Names
/css/app.css  174 KiB  /js/app  [emitted]  /js/app
  /js/app.js  344 KiB  /js/app  [emitted]  /js/app

그리고 Vue 코드가 Laravel 에서 렌더링 되도록 Blade Template 을 만들자.

<div id="app"> 에서 렌더링 될 것임을 알 수 있다.

resources/views/app.blade.php

<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Vue/Laravel SSR App</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="{{ asset('js/app.js') }}" type="text/javascript"></script>
  </body>
</html>

다음으로는 Laravel에서 Blade Template 을 렌더링 하도록 Controller 를 만들고, Route 을 설정해주자.

[email protected]:/var/www/html$ php artisan make:controller AppController
Controller created successfully.

그리고 Controller 에서 app.blade.php 를 렌더링 하도록 설정해주고,

app/Http/Controllers/AppController.php

<?php

namespace App\Http\Controllers;

class AppController extends Controller
{
  public function get() {
    return view('app');
  }
}

Route 를 설정해주면 된다!

routes/web.php

Route::get('/', '[email protected]');

이제 / 경로로 접속하면 위에서 만든 Vue 앱이 정상적으로 실행된다.

Vue SSR (Server Side Rendering)

SSR 에 경험이 있으신 분은 아시겠지만, SSR은 Browser 에서 렌더링하는 것과는 확연히 다르다. 일단 기본적으로 Rendering Engine 과 JavaScript Engine 에 대해 이해해야한다.

Browser 아키텍처

다음의 그림은 Browser 의 구성 요소들을 나타낸다.

Browser Architecture

다른 부분은 다 제쳐놓고, 여기서는 딱 두 개만 살펴보자. ‘Rendering Engine’ 과 ‘JavaScript Interpreter’.

Rendering Engine은 Browser Engine 으로부터 Browser 내에서 HTML 을 읽어서 해석한 데이터를 넘겨 받아 DOM 을 만들어서 화면에 표출해주는 역할을 담당 하고,

HTML 내에 존재하는 JavaScript 를 해석하고 그에 맞게 동작하는 역할을 JavaScript Engine, 여기서는 JavaScript Interpreter 가 담당한다.


SSR 에서는 이 Rendering Engine 이 없다. JavaScript Engine인 V8 만을 이용하므로, DOM 을 건드릴 수가 없다.

그래서 두 개의 진입점이 존재하게 된다. Client, 즉 Browser 에서 동작하는 JavaScript 와 Server 에서 동작하는 JavaScript.

그리고 Vue 가 HTML 내에 마운트 될 수 있도록 하는 index 파일인 app.js 파일에서는 el: '#app' 이 빠져야 한다.

resources/js/app.js

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

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

entry-client.js 에서는 DOM 에 직접 mount 할 것이므로, el: '#app' 부분을 작성해준다.

resources/js/entry-client.js

import app from './app'

app.$mount('#app');

entry-server.js 에서는 DOM 이 없으니 HTML 을 출력하도록 renderVueComponentToString 메서드를 호출한다.

resources/js/entry-server.js

import app from './app'

renderVueComponentToString(app, (err, res) => {
  print(res);
});

이 두 entry 파일들도 빌드될 수 있도록 Laravel Mix 를 업데이트 해 준다.

webpack.mix.js

conet mix = require('laravel-mix');

mix.js('resources/js/entry-client.js', 'public/js')
   .js('resources/js/entry-server.js', 'public/js')
   .sass('resources/sass/app.scss', 'public/css');

그리고 Blade Template 도 entry-client 파일을 렌더링 하도록 업데이트 해 준다.

resources/views/app.blade.php

<script src="{{ asset('js/entry-client.js') }}" type="text/javascript"></script>

이제 SSR 을 연동해보자.

AppController 를 다음과 같이 수정해준다.

app/Http/Controllers/AppController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\File;

class AppController extends Controller
{
  private function render() {
    $renderer_source = File::get(base_path('node_modules/vue-server-renderer/basic.js'));
    $app_source = File::get(public_path('js/entry-server.js'));

    $v8 = new \V8Js();

    ob_start();

    $js =
<<<EOT
var process = { env: { VUE_ENV: "server", NODE_ENV: "production" } };
this.global = { process: process };
EOT;

    $v8->executeString($js);
    $v8->executeString($renderer_source);
    $v8->executeString($app_source);

    return ob_get_clean();
  }

  public function get() {
    $ssr = $this->render();
    return view('app', ['ssr' => $ssr]);
  }
}

그리고 해당하는 Blade 템플릿 내용도 같이 수정해준다.

resources/views/app.blade.php

<body>
  {!! $ssr !!}
  <script src="{{ asset('js/entry-client.js') }}" type="text/javascript"></script>
</body>

구조가 굉장히 직관적이다. SSR 이 동작하는 순서를 살펴보면 다음과 같다.

  1. 사용자의 HTTP 요청이 Nginx 에 도달하고, location / 에서 index.php 로 넘어간다.
  2. /var/www/html/public 하위의 index.php, 즉 Laravel 의 초기 진입점으로 넘어간다.
  3. Laravel 이 Booting 되고, Kernel과 Middleware 를 지나서 HTTP 요청의 Path ( / )에 맞는 route 을 탐색한다.
  4. routes/web.php 에 정의된 [email protected] 으로 넘어간다.
  5. node_modules/vue-server-renderer/basic.js 로부터 Vue SSR 코드를 읽어오고, js/entry-server.js 로부터 사용자의 Vue 어플리케이션 코드를 읽어온 후, $js 변수에 일부 환경 변수를 선언한 뒤에 V8Js 인스턴스 ($v8) 를 이용해 JavaScript 를 실행한다.
  6. ob_get_clean() 에서 V8Js 인스턴스에서 실행한 JavaScript 를 저장하고 있는 Buffer의 내용을 반환하고, 다음 요청에 다시 사용할 수 있도록 해당 Buffer를 비운다.
  7. 6에서 반환된 내용을 $ssr 변수에서 받고, app.blade.php 블레이드 템플릿의 내용에서 렌더링 될 수 있도록 한다 (view('app', ['ssr' => $ssr])).

그래서 결론적으로 페이지의 소스를 보면 다음처럼, 잘 렌더링 된 것을 확인할 수 있다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Vue/Laravel SSR App</title>
  </head>
  <body>
	  <div id="app" data-server-rendered="true">
  Hello World
</div>
	  <script src="http://localhost/js/entry-client.js" type="text/javascript"></script>
  </body>
</html>

이제 Vue 를 개발하고, Laravel 과 연동할 수 있다 :)


레퍼런스