A Simple Pre-Rendered Web App Using Vue + Nuxt

Michael Lynch

Vue + Nuxt로 만드는 간단한 사전 렌더링 웹 앱

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

이번 글에서는 Vue와 Nuxt를 이용해 페이지를 사전 렌더링하는 방법을 보여드리겠습니다. 이 방식을 사용하면 Vue의 편리한 개발 경험을 그대로 유지하면서 소셜 공유나 검색 엔진 최적화 같은 핵심 기능을 포기하지 않을 수 있습니다.

이 튜토리얼은 Vue나 Nuxt 경험이 전혀 없는 분을 대상으로 합니다. 진행하면서 필요한 내용을 모두 설명해 드리겠습니다.

Vue의 문제점

Angular나 React와 마찬가지로 Vue도 단일 페이지 앱(SPA)을 만들기 위한 프레임워크입니다. 전통적인 웹사이트에서는 사용자가 사이트 내 링크를 클릭할 때마다 브라우저가 완전히 새로운 페이지를 다운로드해야 하지만, SPA는 모든 것을 하나의 페이지에 유지합니다. 사용자가 사이트를 이동할 때 JavaScript가 서버에서 모든 것을 다시 내려받지 않고 단순히 새로운 페이지를 그려줍니다. 이를 통해 사용자의 브라우저와 웹 서버 사이의 느린 네트워크 호출을 없애 빠르고 부드러운 사용자 경험을 제공합니다.

Vue의 빠른 반응성에는 대가가 따릅니다. 페이지의 초기 HTML을 제어하기가 더 어려워진다는 점입니다. 브라우저가 서버에서 SPA를 가져올 때 받는 HTML은 대략 다음과 같이 생겼습니다:

<html>
<head>
  <title>My Awesome Website</title>
</head>
<body>
  <div id="app"></div>
  <!-- app.js populates the rest of the page after the browser executes the script. -->
  <script type="text/javascript" src="app.js">
</body>
</html>

싱글 페이지 앱이기 때문에 이 HTML 스텁은 사이트의 모든 페이지에서 동일합니다. 즉, 사용자가 yoursite.com/about에 방문하든 yoursite.com/contact에 방문하든 서버는 두 페이지에 대해 동일한 HTML 스텁을 보냅니다. JavaScript가 사용자의 브라우저에서 실행된 뒤 경로를 파악하고 해당 페이지를 그리는 역할을 맡습니다.

동적 페이지 렌더링은 사이트 탐색을 더 빠르게 만드는 멋진 혁신이지만, 사이트를 소셜 네트워크나 검색 엔진과 연동할 때 문제를 일으킵니다.

SPA 문제 #1: 소셜 공유

제 블로그 글을 트위터에 공유하면 이렇게 보입니다:

풍부한 트위터 카드 예시

Open Graph 태그를 사용해 트위터가 제 글에 대해 풍부한 카드를 생성하도록 했습니다.

트위터는 제 페이지에 있는 Open Graph 표준을 따르는 HTML 태그를 기반으로 해당 카드를 생성합니다. 예를 들어 카드에 표시될 이미지를 지정하려면 다음과 같은 태그를 추가합니다:

<meta property="og:image" content="https://mtlynch.io/post-42/cover.jpg" />

사이트가 SPA라면 모든 페이지가 동일한 HTML 뼈대를 공유하고 따라서 동일한 Open Graph 태그를 갖게 됩니다. 트위터나 페이스북 같은 주요 소셜 네트워크는 JavaScript가 실행되기 전에 Open Graph 태그가 존재해야 합니다. 그 결과 사이트의 서로 다른 페이지마다 고유한 트위터 카드나 페이스북 카드를 만들 수 없게 됩니다.

SPA 문제 #2: 검색 엔진 최적화(SEO)

소셜 네트워킹 사이트와 달리 검색 엔진은 실제로 JavaScript를 이용해 웹사이트를 렌더링합니다. 문제는 완벽하게 해내지 못한다는 점입니다.

많은 웹사이트가 JavaScript를 사용해 사용자가 페이지를 보는 동안에도 콘텐츠를 계속 업데이트합니다. 구글 입장에서는 페이지가 언제 렌더링이 ‘완료’되어 색인할 준비가 되었다고 판단해야 할까요? 일반적인 SPA의 경우 구글이 페이지를 색인하려고 시도하지만, 제대로 색인될 것이라는 보장은 없습니다.

Nuxt가 해결책이 됩니다

현대 웹에서 소셜 네트워크와 SEO는 상당히 중요하기 때문에, Vue를 사용한다는 이유만으로 앱이 이런 서비스와 완전히 통합될 수 없다면 매우 아쉬운 일일 것입니다.

Nuxt.js 로고

Nuxt는 이 문제를 해결하는 프레임워크입니다. Vue 위에 한 겹을 더해 브라우저가 하던 작업 일부를 서버로 옮깁니다. 빈 HTML 스텁을 내려보내고 클라이언트 측 JavaScript가 모든 것을 렌더링할 때까지 기다리는 대신, Nuxt는 서버 측에서 페이지를 미리 처리해 더 완전히 렌더링된 HTML을 생성합니다.

서버 사이드 렌더링의 문제점

대부분의 사람들은 웹 서버에서 Nuxt를 실행합니다. 이를 ‘서버 사이드 렌더링’이라고 합니다. 사용자가 서버에 페이지를 요청하면 Nuxt는 이를 사용자의 브라우저로 보내기 전에 서버 측에서 즉석으로 페이지를 만들어냅니다.

서버 사이드 렌더링은 서버가 브라우저의 작업을 일부 흡수하므로 앱의 초기 페이지 로드 시간을 줄여줍니다. 하지만 소셜 공유와 SEO를 위해 몇 개의 HTML 태그를 채우는 것이 전부라면, 기술 스택에 Nuxt와 Node.js 서버 전체를 추가하는 것은 지나친 일입니다.

SPA의 가장 큰 장점 중 하나는 그저 정적인 HTML, CSS, JavaScript일 뿐이라 애플리케이션 서버가 전혀 필요 없다는 점입니다. Google Cloud Storage나 Amazon S3 같은 단순한 파일 호스팅으로도 표준 SPA를 호스팅할 수 있습니다. 서버 사이드 렌더링을 사용하면 정적 파일 호스팅에서 완전한 앱 서버로 넘어가야 하며, 이는 더 비싸고 복잡합니다.

다행히 서버 사이드 렌더링의 대안이 있습니다. 바로 사전 렌더링입니다. HTTP 요청에 응답해 페이지를 주문형으로 렌더링하는 대신, Nuxt는 사이트의 모든 페이지를 미리 렌더링합니다. 이 과정은 정적 파일을 생성하므로 일반 SPA를 호스팅할 수 있는 곳이라면 어디서든 앱을 호스팅할 수 있습니다.

사전 렌더링을 사용해야 할까?

사전 렌더링이 모든 상황에 적합한 것은 아닙니다. 앱에 무엇이 필요한지, 사전 렌더링이 필요한지 서버 사이드 렌더링이 필요한지 아니면 그냥 일반 Vue를 유지해야 하는지 결정해야 합니다. 아래에 사전 렌더링을 언제 사용해야 할지 판단하는 데 도움이 되도록 장단점을 정리해 두었습니다.

사전 렌더링의 장점

  • 사이트의 각 페이지마다 고유한 소셜 공유 카드를 가질 수 있습니다
  • 페이지 로드 시간을 개선합니다
    • 일반적인 SPA는 브라우저가 JavaScript를 다운로드하고 실행할 때까지 기다려야 페이지를 렌더링하기 시작합니다. 사전 렌더링을 사용하면 사용자는 브라우저가 JavaScript를 실행하기 전에도 페이지를 볼 수 있습니다.

사전 렌더링의 단점

  • 일반 Vue보다 복잡도가 증가합니다
    • 사전 렌더링은 Node 서버에서 Nuxt를 실행하는 것보다는 덜 복잡하지만, 완전히 클라이언트 측에서 동작하는 Vue 앱을 실행하는 것보다는 더 복잡합니다.
    • 사전 렌더링을 사용하면 코드가 서버 측에서 실행되는지 클라이언트 측에서 실행되는지, 그리고 코드가 실행되는 시점에 어떤 컨텍스트를 사용할 수 있는지를 머릿속으로 계속 추적해야 합니다.
  • 사용자 생성 페이지를 만들 수 없습니다
    • 사전 렌더링은 페이지를 빌드할 때 모든 페이지 경로를 알고 있어야 합니다. 사전 렌더링은 동적 라우트를 명시적으로 지원하지 않습니다.
    • 사이트에 사용자 생성 콘텐츠가 있고 예를 들어 가입 후 사용자에게 고유한 URL(예: yoursite.com/users/michael123)을 제공하고 싶다면, 사전 렌더링으로는 이를 할 수 없습니다.
    • 경로의 일부를 URL 쿼리로 옮긴 뒤(예: yoursite.com/users?id=michael123) 동적 데이터를 클라이언트 측에서 불러오는 방식으로 우회할 수는 있지만, 그래도 해당 페이지들에 대해 별개의 소셜 공유 태그를 생성할 수는 없습니다.

사전 렌더링된 “Hello, world”

사전 렌더링을 보여드리기 위해 단 세 개의 파일로 구성된 기본적인 사전 렌더링 “Hello, world!” 앱을 만들어 보겠습니다.

유일한 사전 요구사항은 Node.js입니다. 저는 이 글을 쓰는 시점에 최신 안정 버전인 Node v12.13.1을 사용했습니다.

pages/index.vue

첫 번째 파일은 웹 앱의 페이지를 정의합니다. pages/ 폴더는 Nuxt에서 특별한 의미를 가집니다. Nuxt는 pages/ 폴더에서 찾은 각 .vue 파일마다 별도의 페이지를 사전 렌더링합니다. index.vue라는 이름은 이 파일이 루트 페이지임을 나타내므로, 사용자가 아무 경로도 지정하지 않았을 때 보게 되는 페이지입니다.

index.vue는 환영 메시지와 버튼을 표시하는 간단한 “Hello, world!” 페이지를 생성합니다. Vue의 클라이언트 측 기능 중 일부를 보여주기 위해 버튼은 사용자가 클릭할 때마다 텍스트를 업데이트합니다.

<template>
  <div>
    <h1>Hello, world!</h1>
    <p>I'm an example of a pre-rendered Vue webpage.</p>
    <button v-on:click="count++">I have been clicked {{ count }} times</button>
  </div>
</template>

<script>
  export default {
    data: function () {
      return {
        count: 0,
      };
    },
  };
</script>

package.json

package.json 파일은 Node.js에게 이 앱을 어떻게 빌드해야 하는지 알려줍니다:

{
  "name": "hello-world-vue-pre-rendered",
  "dependencies": {
    "nuxt": "latest"
  },
  "scripts": {
    "dev": "nuxt --port 3600",
    "generate": "nuxt generate"
  }
}

nuxt.config.js

마지막으로 Nuxt는 내용이 비어 있더라도 설정 파일이 필요합니다:

// Even though we have no Nuxt settings, this file is required.

“Hello, world” 실행하기

이 앱은 Codesandbox에서 실행할 수 있습니다:

또는 다음 명령어로 로컬 머신에서 앱을 실행할 수도 있습니다:

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered
git checkout step-1

npm install
npm run dev

앱은 http://localhost:3600에서 실행됩니다.

앱 사전 렌더링하기

npm run dev를 실행하면 서버 사이드 렌더링을 사용하는 것입니다. Node가 로컬 개발 서버를 실행하고 요청할 때마다 페이지를 주문형으로 생성합니다.

하지만 제가 약속한 것은 사전 렌더링된 페이지입니다. 사전 렌더링된 페이지는 정적 파일들의 모음이므로 웹 서버조차 필요하지 않습니다.

앱을 사전 렌더링하려면 다음 명령어를 실행하세요:

npm run generate

dist/ 폴더를 확인해 보면 Nuxt가 페이지를 사전 렌더링한 것을 볼 수 있습니다:

$ find ./dist/ -type f
./dist/.nojekyll
./dist/200.html
./dist/index.html
./dist/_nuxt/7cef7880379068a94897.js
./dist/_nuxt/b10d0692e6306468ee9f.js
./dist/_nuxt/cae55ee8b1125819f113.js
./dist/_nuxt/ee10340617a3beab9da2.js
./dist/_nuxt/LICENSES

Python2의 SimpleHTTPServer 같은 간단한 HTTP 서버를 통해 이 파일들을 확인할 수 있습니다:

cd dist
python -m SimpleHTTPServer 8123

그러면 Python이 웹 서버를 실행해 http://localhost:8123에서 사전 렌더링된 앱을 확인할 수 있게 됩니다. 나중에 이 앱을 정적 파일 호스팅 서비스에 게시하는 방법을 보여드리겠습니다.

About 페이지 추가하기

조금 더 흥미롭게 만들기 위해 이 앱에 두 번째 페이지를 추가해 보겠습니다.

pages/about.vue

이 페이지는 Vue 훅을 사용해 페이지가 어떻게 렌더링되었는지에 대한 정보를 표시합니다. 코드에 대해서는 아래에서 더 자세히 설명하겠습니다.

<template>
  <div>
    <h1>About this Build</h1>
    <p v-if="buildTime">
      Nuxt pre-rendered this page at
      <b>{{ buildTime }}</b> (before the browser ever saw it).
    </p>
    <template v-else>
      <p>
        Vue generated this page client-side because you navigated here from
        another route on the same site.
      </p>
      <p>
        <a href="/about">Refresh the page</a> to see the pre-rendered version.
      </p>
    </template>
    <p>
      The browser loaded this page at
      <b>{{ loadTime }}</b>.
    </p>
    <p><nuxt-link to="/">Home</nuxt-link></p>
  </div>
</template>

<script>
  export default {
    asyncData() {
      // Don't re-evaluate buildTime when the client loads this page in the
      // browser.
      if (!process.client) {
        return {
          buildTime: new Date().toUTCString(),
        };
      }
    },
    // Vue evaluates data variables at page render time and again every time the
    // browser loads this page.
    data: function () {
      return {
        loadTime: new Date().toUTCString(),
      };
    },
  };
</script>

About 페이지의 라이브 버전은 다음과 같습니다:

About 페이지의 두 가지 버전 이해하기

About 페이지는 Nuxt와 Vue가 함께 동작해 사전 렌더링된 페이지를 만드는 과정을 보여줍니다. 사이트를 탐색하는 방법에 따라 두 가지 버전의 페이지를 보게 될 것입니다.

About 페이지의 서로 다른 버전을 보여주는 스크린샷

About 페이지는 어떻게 페이지에 도달했는지에 따라 다른 정보를 표시합니다.

/about 페이지에서 시작하면 왼쪽 버전을 보게 될 것입니다. 루트 페이지에서 시작해 ‘about page’ 링크를 클릭하면 오른쪽 버전을 보게 됩니다.

왜 두 가지 다른 버전의 페이지가 보이는 걸까요? 답은 asyncData에 있습니다. 이 함수는 두 시점에서 실행됩니다:

  1. (서버 측) Nuxt가 페이지를 사전 렌더링할 때
  2. (클라이언트 측) 브라우저가 사이트 내 다른 곳에서 이 페이지로 이동할 때

정의를 다시 살펴보겠습니다:

asyncData() {
  // Don't re-evaluate buildTime when the client loads this page in the
  // browser.
  if (!process.client) {
    return {
      buildTime: new Date().toUTCString(),
    };
  }
},

Nuxt가 사이트를 사전 렌더링할 때 서버가 asyncData 메서드를 실행합니다. 서버 환경에서는 process.client가 null이므로 현재 시각으로 buildTime을 설정하고, 페이지의 HTML을 사전 렌더링할 때 이 변수를 사용합니다.

사이트 내 다른 페이지에서 /about 경로로 이동하면 브라우저가 페이지 로드 시 asyncData 메서드를 실행합니다. 이제 코드가 클라이언트 측에서 실행되므로 process.client는 null이 아니며, 따라서 메서드는 buildTime을 정의하지 않고 Vue는 buildTime이 undefined일 때의 페이지 템플릿을 렌더링합니다:

<p v-if="buildTime">...</p>
<template v-else>
  <p>
    Vue generated this page client-side because you navigated here from another
    route on the same site.
  </p>
  <p><a href="/about">Refresh the page</a> to see the pre-rendered version.</p>
</template>

사전 렌더링은 첫 페이지에만 적용됩니다

About 페이지는 사전 렌더링의 미묘한 점 중 하나를 보여줍니다. Nuxt는 사용자가 방문하는 첫 번째 페이지만 사전 렌더링한다는 것입니다. 그 이후에는 Vue가 일반 SPA처럼 동작하며 사용자가 사이트 내에서 이동할 때마다 페이지를 클라이언트 측에서 다시 그립니다. 이는 앱이 서버 사이드 렌더링을 요구하는 서비스와의 호환성을 희생하지 않으면서도 Vue의 즉각적인 페이지 간 이동을 그대로 유지한다는 점에서 좋은 일입니다.

About 페이지를 로컬에서 실행하기

About 페이지를 직접 실험해 보려면 다음 명령어를 실행하세요

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered

npm install
npm run dev

https://localhost:3600/about으로 이동하면 빌드 시간과 로드 시간이 거의 일치한다는 것을 알 수 있습니다. npm run dev를 실행하면 Nuxt가 서버 사이드 렌더링을 사용해 페이지를 즉석에서 생성하기 때문입니다.

서버 사이드 렌더링으로 렌더링된 About 페이지 스크린샷

npm run dev는 사용자가 요청할 때마다 페이지를 렌더링하므로 빌드 시간과 로드 시간이 일치합니다.

페이지를 한 번 생성해 계속 동일한 페이지를 제공하는 사전 렌더링과 달리, 서버 사이드 렌더링은 사용자가 방문할 때마다 새로운 버전의 페이지를 생성합니다.

앱 게시하기

사전 렌더링을 사용하면 앱을 호스팅하는 데 Node.js 서버가 필요하지 않습니다. 정적 파일 호스팅을 지원하는 호스팅 서비스만 있으면 됩니다.

아래는 몇 가지 인기 있는 제공업체에서 정적 파일을 게시하는 방법입니다:

소스 코드

이 예제의 모든 코드는 MIT 라이선스로 GitHub에서 제공됩니다:

더 많은 기능을 갖춘 예제

Vue와 Nuxt로 실제 앱을 구축한다면 단순히 두 개의 사전 렌더링된 페이지보다 더 많은 기능이 필요할 것입니다. 저는 SEO와 소셜 공유에 필요한 모든 보일러플레이트를 포함한 템플릿 프로젝트인 pre-vue를 만들었습니다:

다음과 같은 기능이 포함되어 있습니다:

  • robots.txt 파일을 생성합니다
  • 사이트맵을 생성합니다
  • 각 페이지마다 고유한 <title> 태그와 기타 SEO 관련 <meta> 태그를 지원합니다
  • 각 페이지에 고유한 Open Graph 태그를 추가합니다
  • Google Analytics 지원을 추가합니다
  • 파비콘을 추가합니다
  • 404 오류를 처리합니다

저는 이전에 Angular SPA였던 Zestful 데모 사이트를 pre-vue 템플릿을 이용해 다시 작성했습니다. README에 pre-vue 사용 방법이 설명되어 있지만, 관심이 있다면 자세한 내용을 설명하는 블로그 글을 추가로 게시하겠습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글