본문 바로가기

FrontEnd

Vue3과 서버사이드 렌더링(SSR)

반응형

Vue3과 같은 라이프사이클이 존재하는 프레임워크를 SSR과 같이 사용할 때 어떤 이슈가 있을까요?

공식 문서의 번역입니다.

https://vuejs.org/guide/scaling-up/ssr.html

 

Server-Side Rendering (SSR) | Vue.js

 

vuejs.org

SSR이란?

Vue.js는 클라이언트 측 애플리케이션을 구축하기 위한 프레임워크입니다.
기본적으로 Vue 컴포넌트는 브라우저에서 출력으로 DOM을 생성하고 조작합니다.
그러나 동일한 컴포넌트를 서버에서 HTML 문자열로 렌더링하고 브라우저로 직접 보낸 다음
최종적으로 정적 마크업을 클라이언트의 완전한 대화형 앱으로 "수화(hydrate)"하는 것도 가능합니다.
 
서버에서 렌더링된 Vue.js 앱은 대부분의 앱 코드가 서버와 클라이언트 모두에서 실행된다는 점에서
"동형(isomorphic)" 또는 "범용(universal)"으로 간주될 수도 있습니다.
 

SSR을 사용하는 이유

클라이언트 측 SPA(Single-Page Application)와 비교할 때 SSR의 장점은 주로 다음과 같습니다.

더 빠른 콘텐츠 경험:

느린 인터넷이나 느린 장치에서 더 두드러집니다.

서버 렌더링 마크업은 모든 JavaScript가 다운로드되고 실행되어 표시될 때까지 기다릴 필요가 없으므로

사용자는 완전히 렌더링된 페이지를 더 빨리 볼 수 있습니다.

또한 데이터 가져오기는 클라이언트보다 데이터베이스에 더 빠르게 연결할 수 있는 서버 측에서 수행됩니다.

이는 일반적으로 Core Web Vitals 지표를 개선하고 사용자 경험을 향상시키며

콘텐츠 도달 시간이 전환율과 직접적으로 연관되는 애플리케이션에 중요할 수 있습니다.

통합된 멘탈 모델:

백엔드 템플릿 시스템과 프런트엔드 프레임워크 사이를 오가는 대신
전체 앱을 개발하기 위해 동일한 언어와 동일한 선언적 구성 요소 지향 멘탈 모델을 사용할 수 있습니다.

더 나은 SEO:

검색 엔진 크롤러는 완전히 렌더링된 페이지를 직접 볼 수 있습니다.
사실 현재 Google과 Bing은 동기식 JavaScript 애플리케이션을 잘 인덱싱할 수 있습니다.
핵심 단어는 동기식입니다.
앱이 로딩 스피너로 시작한 다음 Ajax를 통해 콘텐츠를 가져오면 크롤러는 완료될 때까지 기다리지 않습니다.
즉, SEO가 중요한 페이지에서 비동기적으로 가져온 콘텐츠가 있는 경우 SSR이 필요할 수 있습니다.
SSR을 사용할 때 고려해야 할 몇 가지 절충 사항도 있습니다.

개발 제약

  • 특정 브라우저에서만 사용할 수 있는 코드는 특정 라이프사이클 훅에서만 사용 가능합니다.
  • 일부 외부 라이브러리는 서버 렌더링 앱에서 실행할 수 있도록 특별한 처리가 필요할 수 있습니다.

복잡한 빌드 설정 및 배포 요구 사항

든 정적 파일 서버에 배포할 수 있는 완전한 정적 SPA와 달리 서버 렌더링 앱에는 Node.js 서버를 실행할 수 있는 환경이 필요합니다.

서버 측 부하

Node.js에서 전체 앱을 렌더링하는 것은 정적 파일을 제공하는 것보다 CPU를 더 많이 사용하므로
높은 트래픽이 예상되는 경우 해당 서버 로드에 대비하고 캐싱 전략을 현명하게 사용하는 것이 좋습니다.
 
앱에 SSR을 사용하기 전에 먼저 정말 SSR이 필요한지 물어보세요.
대부분 앱에서 콘텐츠까지 걸리는 시간이 얼마나 중요한지에 따라 달라집니다.
예를 들어, 초기 로드 시 추가 몇 백 밀리초가 걸리는 내부 대시보드를 구축하는 경우 SSR은 과합니다.
그러나 콘텐츠 도달 시간이 절대적으로 중요한 경우 SSR은 최상의 초기 로드 성능을 달성하는 데 도움이 될 수 있습니다.
주 : nuxt를 쓰냐 next를 쓰냐랑은 다른 차원의 이야기입니다. (nuxt generate) 쓸 수 있으면 쓰세요

SSR vs SSG

사전 렌더링이라고도 하는 정적 사이트 생성(SSG)은 빠른 웹 사이트를 구축하는 데 널리 사용되는 또 다른 기술입니다.
페이지를 서버 렌더링하는 데 필요한 데이터가 모든 사용자에게 동일하다면 요청이 들어올 때마다 페이지를 렌더링하는 대신
빌드 프로세스 중에 미리 한 번만 렌더링할 수 있습니다.
미리 렌더링된 페이지가 생성되어 정적 HTML 파일로 제공됩니다.
SSG는 SSR 앱과 동일한 성능 특성을 유지합니다. 즉, 뛰어난 콘텐츠 도달 시간 성능을 제공합니다.
동시에 출력이 정적 HTML 및 자산이기 때문에 SSR 앱보다 배포가 저렴하고 쉽습니다.
여기서 키워드는 정적입니다.
SSG는 정적 데이터, 즉 빌드 시 알려지고 배포 간에 변경되지 않는 데이터를 소비하는 페이지에만 적용할 수 있습니다.
데이터가 변경될 때마다 새로운 배포가 필요합니다.
소수의 마케팅 페이지(예: /, /about, /contact 등)의 SEO를 개선하기 위해 SSR를 조사하는 경우 SSR 대신 SSG가 좋습니다.)
SSG는 문서 사이트나 블로그와 같은 콘텐츠 기반 웹사이트에도 적합합니다.
(ex: vue 공식문서)
주 : 그러니까 nuxt 쓰셈
 

Vue3 SSR 튜토리얼 

앱 렌더링

동작 중인 Vue SSR의 가장 기본적인 예를 살펴보겠습니다.
  1. 새 디렉토리를 만들고 cd
  2. npm init -y 실행
  3. Node.js가 ES 모듈 모드에서 실행되도록 package.json에 "type": "module"을 추가합니다.
  4. npm install vue
  5. example.js 파일을 만듭니다.
// this runs in Node.js on the server.
import { createSSRApp } from 'vue'
// Vue's server-rendering API is exposed under `vue/server-renderer`.
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`
})

renderToString(app).then((html) => {
  console.log(html)
})
> node example.js
명령줄에 다음 라인이 나타나야 합니다.
<button>1</button>

renderToString()은 Vue 앱 인스턴스를 가져오고

앱의 렌더링된 HTML로 resolve되는 Promise를 반환합니다.
Node.js Stream API 또는 Web Streams API를 사용하여 스트리밍 렌더링도 가능합니다.
자세한 내용은 SSR API Reference를 확인하십시오.

 

그런 다음 Vue SSR 코드를 전체 페이지 HTML로 애플리케이션 마크업을 래핑하는 서버 요청 핸들러로 옮길 수 있습니다.
다음 단계에서는 익스프레스를 사용합니다.

  • npm install express 실행
  • server.js 파일을 만듭니다.
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const server = express()

server.get('/', (req, res) => {
  const app = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })

  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('ready')
})

마지막으로 node server.js를 실행하고 http://localhost:3000을 방문합니다.
버튼과 함께 동작하는 페이지가 표시되어야 합니다.

Try it on StackBlitz


클라이언트 측 수화

버튼을 클릭하면 숫자가 변경되지 않는 것을 알 수 있습니다.
브라우저에서 Vue를 로드하지 않기 때문에 HTML은 클라이언트에서 완전히 정적입니다.

 

클라이언트 측 앱을 대화형으로 만들려면 Vue가 수화 단계를 수행해야 합니다.
수화 중에 서버에서 실행된 것과 동일한 Vue 애플리케이션을 만들고
각 컴포넌트를 제어해야 하는 DOM 노드에 일치시키고
DOM 이벤트 리스너를 연결합니다.
 
수화 모드에서 앱을 마운트하려면 createApp() 대신 createSSRApp()을 사용해야 합니다.
 
// this runs in the browser.
import { createSSRApp } from 'vue'

const app = createSSRApp({
  // ...same app as on server
})

// mounting an SSR app on the client assumes
// the HTML was pre-rendered and will perform
// hydration instead of mounting new DOM nodes.
app.mount('#app')

코드 구조

서버에서와 동일한 앱 구현을 어떻게 재사용할까요?
여기에서 SSR 앱의 코드 구조에 대해 생각해야 합니다.
서버와 클라이언트 간에 동일한 애플리케이션 코드를 공유하는 방법은 무엇일까요?

 

가장 기본적인 설정을 봅시다.
먼저 앱 생성 로직을 전용 파일인 app.js로 분할해 보겠습니다.

// app.js (shared between server and client)
import { createSSRApp } from 'vue'

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })
}

이 파일과 해당 종속성은 서버와 클라이언트 간에 공유되며 이를 universal code라고 합니다.
아래에서 논의하겠지만 범용 코드를 작성할 때 주의해야 할 사항이 많이 있습니다.

 

클라이언트 항목은 범용 코드를 가져오고 앱을 만들고 마운트를 수행합니다.
// client.js
import { createApp } from './app.js'

createApp().mount('#app')
그리고 서버는 리퀘스트 핸들러에서 동일한 앱 생성 논리를 사용합니다.
// server.js (irrelevant code omitted)
import { createApp } from './app.js'

server.get('/', (req, res) => {
  const app = createApp()
  renderToString(app).then(html => {
    // ...
  })
})
또한 브라우저에서 클라이언트 파일을 로드하려면 다음도 수행해야 합니다.
  1. server.js에 server.use(express.static('.'))를 추가하여 client.js 파일을 제공합니다.
  2. <script type="module" src="/client.js"></script>를 HTML에 추가하여 client.js 항목을 로드합니다.
  3. Import Map을 HTML 셸에 추가하여 브라우저에서 import * from 'vue'와 같은 사용을 지원합니다.

동작하는 예제 코드 보기


더 수준 높은 솔루션

프로덕션 배포용 SSR 앱을 위해선 훨씬 더 많은 작업이 필요합니다. 다음을 수행해야 합니다.
  • Vue SFC 및 기타 빌드 단계 요구 사항을 지원합니다.
  • 사실상 동일한 앱에 대해 두 개의 빌드를 조정해야 합니다. 하나는 클라이언트용이고 다른 하나는 서버용입니다.
Vue 컴포넌트는 SSR에 사용될 때 다르게 컴파일됩니다.
템플릿은 보다 효율적인 렌더링 성능을 위해 가상 DOM 렌더 함수 대신 문자열로 컴파일됩니다.
  • 서버 요청 핸들러에서 올바른 클라이언트 측 자산 링크 및 최적의 리소스 힌트를 사용하여 HTML을 렌더링합니다.
  • 또한 SSR과 SSG 모드 사이를 전환하거나 동일한 앱에서 둘을 혼합해야 할 수도 있습니다.
  • 범용 방식으로 라우팅, 데이터 가져오기 및 상태 관리 저장소를 관리합니다.
완전한 구현은 매우 복잡하며 작업하기로 선택한 빌드 도구 체인에 따라 다릅니다.
따라서 복잡성을 추상화하는 더 높은 수준의 독창적인 솔루션을 사용하는 것이 좋습니다.
 
 

GitHub - vitejs/vite-plugin-vue: Vite Vue Plugins

Vite Vue Plugins. Contribute to vitejs/vite-plugin-vue development by creating an account on GitHub.

github.com


SSR 친화적인 코드 작성

빌드 설정 또는 더 높은 수준의 프레임워크 선택에 관계없이 모든 Vue SSR 애플리케이션에 적용되는 몇 가지 원칙이 있습니다.

서버에서의 반응성

SSR 중에 각 요청 URL은 애플리케이션의 원하는 상태에 매핑됩니다.
사용자 상호 작용 및 DOM 업데이트가 없으므로 서버에서는 반응성이 필요하지 않습니다.
기본적으로 성능 향상을 위해 SSR 중에는 반응성이 비활성화됩니다.

컴포넌트 생명주기

동적 업데이트가 없기 때문에 onMounted 또는 onUpdated와 같은 라이프사이클 훅은
SSR 중에 호출되지 않으며 클라이언트에서만 실행됩니다.

 

setup() 또는 <script setup>의 루트 범위에서 정리가 필요한 부작용을 생성하는 코드를 피해야 합니다.
이러한 부작용의 예는 setInterval로 타이머를 설정하는 것입니다.
클라이언트 측 전용 코드에서는 타이머를 설정한 다음 onBeforeUnmount 또는 onUnmounted에서 해제할 수 있습니다.
그러나 마운트 해제 훅은 SSR 중에는 호출되지 않으므로 타이머는 영원히 유지됩니다.
이를 방지하려면 부작용 코드를 대신 onMounted로 이동하십시오.
 
플랫폼 종속적인 API

범용 코드는 플랫폼별 API에 대한 액세스를 가정할 수 없으므로
코드가 window 또는 document와 같은 브라우저 전용 전역을 직접 사용하는 경우 Node.js에서 실행될 때 오류가 발생하고
그 반대의 경우도 마찬가지입니다.

서버와 클라이언트 간에 공유되지만 다른 플랫폼 API를 사용하는 작업의 경우
범용 API 내에서 플랫폼별 구현을 래핑하거나 이를 수행하는 라이브러리를 사용하는 것이 좋습니다.
예를 들어 node-fetch를 사용하여 서버와 클라이언트 모두에서 동일한 fetch API를 사용할 수 있습니다.
 
브라우저 전용 API의 경우 일반적인 접근 방식은 onMounted와 같은
클라이언트 전용 생명 주기 훅 내에서 lazy하게 액세스하는 것입니다.
 
타사 라이브러리가 보편적인 사용을 염두에 두고 작성되지 않은 경우 이를 서버 렌더링 앱에 통합하기가 까다로울 수 있습니다.
전역 변수 중 일부를 모킹하여 동작하게 할 수 있지만 해킹 될 수 있으며 다른 라이브러리의 환경 감지 코드를 방해할 수 있습니다.

Cross-Request State Pollution(교차 요청 상태 오염)

SSR 컨텍스트에서 애플리케이션 모듈은 일반적으로 서버가 부팅될 때 서버에서 한 번만 초기화됩니다.
동일한 모듈 인스턴스가 여러 서버 요청에서 재사용되며 싱글톤 상태 객체도 마찬가지입니다.
공유 싱글톤 상태를 한 사용자에 특정한 데이터로 변경하면 실수로 다른 사용자의 요청으로 유출될 수 있습니다.
이를 교차 요청 상태 오염이라고 합니다.

브라우저에서와 마찬가지로 요청마다 모든 JavaScript 모듈을 기술적으로 다시 초기화할 수 있습니다.

하지만 JavaScript 모듈을 초기화하는 데 비용이 많이 들 수 있으므로 서버 성능에 상당한 영향을 미칩니다.

 

권장 솔루션은 각 요청에 대해 라우터 및 글로벌 저장소를 포함하여 전체 애플리케이션의 새 인스턴스를 생성하는 것입니다.
그런 다음 컴포넌트에서 직접 가져오는 대신 앱 수준 provide(app-level provide)을 사용하여 공유 상태를 제공하고
이를 필요로 하는 컴포넌트에 주입합니다.
// app.js (shared between server and client)
import { createSSRApp } from 'vue'
import { createStore } from './store.js'

// called on each request
export function createApp() {
  const app = createSSRApp(/* ... */)
  // create new instance of store per request
  const store = createStore(/* ... */)
  // provide store at the app level
  app.provide('store', store)
  // also expose store for hydration purposes
  return { app, store }
}

 

수화 불일치

사전 렌더링된 HTML의 DOM 구조가 클라이언트측 앱의 예상 출력과 일치하지 않으면 수화 불일치 오류가 발생합니다.
수화 불일치는 다음과 같은 원인에 의해 가장 일반적으로 발생합니다.

1. 템플릿에 잘못된 HTML 중첩 구조가 포함되어 있고 렌더링된 HTML이 브라우저의 기본 HTML 구문 분석 동작에 의해 "수정"되었습니다.

  • 예를 들어 일반적인 문제는 <div>가 <p> 안에 배치될 수 없다는 것입니다.
<p><div>hi</div></p>
서버 렌더링 HTML에서 이것을 생성하면 브라우저는 <div>를 만났을 때 첫 번째 <p>를 종료하고 다음 DOM 구조로 구문 분석합니다.
<p></p>
<div>hi</div>
<p></p>

 

2. 렌더링 중에 사용되는 데이터에 임의로 생성된 값이 포함됩니다.

동일한 애플리케이션이 두 번(서버에서 한 번, 클라이언트에서 한 번) 실행되기 때문에
무작위 값이 두 실행 간에 동일하다고 보장할 수 없습니다.
무작위 값으로 인한 불일치를 방지하는 두 가지 방법이 있습니다.
 
1. v-if + onMounted를 사용하여 클라이언트에서만 임의의 값에만 의존하는 부분을 렌더링합니다.
프레임워크에는 VitePress의 <ClientOnly> 컴포넌트와 같이 이 작업을 더 쉽게 해주는 내장 기능이 있을 수도 있습니다.

2. 시드를 사용한 생성을 지원하는 난수 생성기 라이브러리를 사용
서버 실행과 클라이언트 실행이 동일한 시드를 사용하도록 보장합니다

(예: 직렬화된 상태의 시드를 포함하고 클라이언트에서 해당 시드 이용)

 

3. 서버와 클라이언트가 다른 시간대에 있습니다.

경우에 따라 타임스탬프를 사용자의 현지 시간으로 변환해야 할 수 있습니다.
그러나 서버가 실행되는 동안의 시간대와 클라이언트가 실행되는 동안의 시간대는 항상 동일하지 않으며
서버가 실행되는 동안에는 사용자의 시간대를 확실하게 알 수 없습니다.
이러한 경우 현지 시간 변환도 클라이언트 전용 작업으로 수행해야 합니다.
Vue가 수화 불일치를 발견하면 클라이언트 측 상태와 일치하도록 미리 렌더링된 DOM을 자동으로 복구하고 조정하려고 시도합니다.
이로 인해 잘못된 노드가 삭제되고 새 노드가 마운트되어 일부 렌더링 성능 손실이 발생하지만
대부분의 경우 앱은 예상대로 계속 작동해야 합니다.
즉, 개발 중에 수화 불일치를 제거하는 것이 여전히 가장 좋습니다.

사용자 정의 디렉티브

대부분의 사용자 지정 지시문은 직접 DOM 조작을 포함하므로 SSR 중에 무시됩니다.
그러나 사용자 지정 지시문을 렌더링하는 방법(즉, 렌더링된 요소에 추가해야 하는 속성)을 지정하려는 경우
getSSRProps 디렉티브 혹을 사용할 수 있습니다.
const myDirective = {
  mounted(el, binding) {
    // client-side implementation:
    // directly update the DOM
    el.id = binding.value
  },
  getSSRProps(binding) {
    // server-side implementation:
    // return the props to be rendered.
    // getSSRProps only receives the directive binding.
    return {
      id: binding.value
    }
  }
}

텔레포트

텔레포트는 SSR 중에 특별한 처리가 필요합니다.
렌더링된 앱에 텔레포트가 포함된 경우 텔레포트된 콘텐츠는 렌더링된 문자열의 일부가 아닙니다.
더 쉬운 해결책은 Teleport를 마운트 시 조건부로 렌더링하는 것입니다.

텔레포트된 콘텐츠를 수화해야 하는 경우 ssr 컨텍스트 개체의 teleports 속성 아래에 노출됩니다.

const ctx = {}
const html = await renderToString(app, ctx)

console.log(ctx.teleports) // { '#teleported': 'teleported content' }​
기본 앱 마크업을 삽입하는 방법과 유사하게 최종 페이지 HTML의 올바른 위치에 텔레포트 마크업을 삽입해야 합니다.

주의 : Teleport와 SSR을 함께 사용할 시, body를 target으로 하지 마세요.

 

일반적으로 <body>에는

Teleports가 수화를 위한 올바른 시작 위치를 결정하는 것을 불가능하게 만드는 다른 서버 렌더링 콘텐츠가 포함됩니다.
대신 텔레포트 전용 컨테이너를 사용하세요.

  • ex) 텔레포트된 콘텐츠만 포함하는 <div id="teleported"></div>.
반응형