Vue와 Nuxt를 사용한 간단한 사전 렌더링 웹 앱
이번 글에서는 Vue와 Nuxt를 사용해 페이지를 사전 렌더링하는 방법을 소개합니다. 이 방법을 사용하면 Vue의 편리한 개발 경험을 누리면서도 소셜 공유나 검색 엔진 최적화(SEO) 같은 중요한 기능을 포기하지 않아도 됩니다.
이 튜토리얼은 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: 소셜 공유
제가 Twitter에 블로그 글을 공유하면 다음과 같이 표시됩니다.

Open Graph 태그를 사용해 Twitter가 글에 대한 풍부한 카드를 생성하도록 한 모습입니다.
Twitter는 Open Graph 표준을 따르는 페이지의 HTML 태그를 바탕으로 카드를 생성합니다. 예를 들어 카드에 표시할 이미지를 지정하려면 다음과 같은 태그를 추가합니다.
<meta property="og:image" content="https://mtlynch.io/post-42/cover.jpg" />사이트가 SPA라면 모든 페이지가 같은 HTML 뼈대와 같은 Open Graph 태그를 공유합니다. Twitter와 Facebook 같은 주요 소셜 네트워크는 JavaScript가 실행되기 전에 Open Graph 태그가 존재해야 합니다. 따라서 사이트의 페이지마다 서로 다른 Twitter 카드나 Facebook 카드를 만들 수 없습니다.
SPA의 문제 2: 검색 엔진 최적화(SEO)
소셜 네트워킹 사이트와 달리 검색 엔진은 JavaScript를 사용해 웹사이트를 실제로 렌더링합니다. 문제는 완벽하게 렌더링하지는 못한다는 점입니다.
많은 웹사이트가 사용자가 페이지를 보는 동안 JavaScript로 페이지의 콘텐츠를 계속 업데이트합니다. Google의 관점에서 페이지 렌더링이 “완료”되어 색인을 생성할 준비가 되는 시점은 언제일까요? 일반적인 SPA에서 Google은 페이지의 색인을 생성하려고 시도하지만, 페이지를 올바르게 색인에 반영하리라는 보장은 없습니다.
Nuxt가 해결책입니다
현대 웹에서 소셜 네트워크와 SEO는 상당히 중요합니다. 따라서 Vue를 사용한다는 이유로 앱을 이런 서비스와 제대로 통합할 수 없다면 정말 곤란할 것입니다.

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 generatedist/ 폴더를 확인하면 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/LICENSESPython 2의 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 page” 링크를 클릭하면 오른쪽 버전이 표시됩니다.
왜 페이지가 두 가지 버전으로 표시될까요? 답은 asyncData 훅에 있습니다. 이 함수는 다음 두 시점에 실행됩니다.
- (서버 측) Nuxt가 페이지를 사전 렌더링할 때
- (클라이언트 측) 브라우저가 사이트의 다른 곳에서 이 페이지로 이동할 때
정의 부분을 다시 보겠습니다.
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이 정의되지 않았을 때의 페이지 템플릿을 렌더링합니다.
<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 devhttps://localhost:3600/about으로 이동하면 빌드 시간과 로드 시간이 대략 일치하는 것을 확인할 수 있습니다. npm run dev를 실행하면 Nuxt가 서버 사이드 렌더링을 사용해 페이지를 필요한 순간에 생성하기 때문입니다.

npm run dev는 사용자가 요청할 때 페이지를 렌더링하므로 빌드 시간과 로드 시간이 일치합니다.
페이지를 한 번 생성한 뒤 같은 페이지를 계속 제공하는 사전 렌더링과 달리, 서버 사이드 렌더링은 사용자가 방문할 때마다 페이지의 새 버전을 생성합니다.
앱 배포하기
사전 렌더링을 사용하면 앱을 호스팅하는 데 Node.js 서버가 필요하지 않습니다. 정적 파일 호스팅을 지원하는 호스팅 서비스만 있으면 됩니다.
다음은 널리 사용되는 몇몇 제공업체에서 정적 파일을 배포하는 방법을 안내하는 문서입니다.
- Google Firebase(저는 이 서비스를 사용합니다)
- Netlify
- Amazon S3
- Google Cloud Storage
소스 코드
이 예제의 전체 코드는 MIT 라이선스로 GitHub에서 확인할 수 있습니다.
기능이 더 풍부한 예제
Vue와 Nuxt로 실제 앱을 만들고 있다면 사전 렌더링된 페이지 두 개만으로는 부족할 것입니다. 저는 SEO와 소셜 공유에 필요한 모든 기본 코드를 포함한 템플릿 프로젝트 pre-vue를 만들었습니다.
- https://github.com/mtlynch/pre-vue(소스 코드)
- https://pre-vue.web.app/(실제 동작 데모)
다음과 같은 기능이 포함되어 있습니다.
robots.txt파일 생성- 사이트맵 생성
- 각 페이지에 고유한
<title>태그와 SEO에 관련된 기타<meta>태그 지원 - 각 페이지에 고유한 Open Graph 태그 추가
- Google Analytics 지원 추가
- 파비콘 추가
- 404 오류 처리
이전에 Angular SPA였던 Zestful 데모 사이트를 다시 작성할 때 pre-vue 템플릿을 사용했습니다. README에 pre-vue 사용 방법을 설명해 두었지만, 관심을 가져 주시는 분이 있다면 자세한 내용을 다루는 블로그 글을 따로 게시하겠습니다.
글을 무작위로 읽기