본문 바로가기

frontend

SSR 페이지 캐싱 알아보기

개요

SSR 에서 사용가능한 페이지 레벨 캐싱에 대해 알아봅니다.

예제코드는 vue2 기준으로 작성된 코드입니다.

배경

웹사이트 개발시 SEO와 페이지 로드시간 단축을 위해 SSR 을 사용할 수 있습니다.

사용자의 컴퓨터 자원을 사용하는 CSR 과 다르게 SSR에서는 서버의 컴퓨팅 자원을 사용합니다. 렌더 서버는 클라이언트 요청에 의해 페이지를 서버에서 렌더링합니다. 그리고 html 을 생성하여 클라이언트로 서빙하는 역할을 담당합니다.

따라서 클라이언트의 새로운 요청이 있을 때마다 렌더링 연산이 동작해야 하고, 이는 사용자가 많아질 수록 서버의 오버헤드를 유발할 수 있습니다.

서버의 부담을 줄이기 방법으로, 페이지 레벨 캐싱을 적용해볼 수 있습니다. 페이지 캐싱은 짧은 시간동안 응답을 캐싱하는 마이크로 캐싱(micro caching) vue SSR guide 에는 자바스크립트로 구현하는 방법을 소개하고 있습니다.

웹페이지를 포함한 정적자원에 대한 캐싱은 nginx 에서도 구현될 수 있지만, node 에서 구현함으로서 페이지 캐싱의 책임을 렌더서버에 부여합니다.

캐싱은 분명 성능적인 이점을 가져오지만, 잘못된 데이터를 사용자에게 전달하게 되는 리스크도 가지고 있습니다.

그렇기 때문에 캐싱이 적용되는 방식을 잘 이해하고 있어야 안전하게 서비스를 최적화할 수 있습니다.

페이지 레벨 캐싱을 적용하는 이유

사용자마다 다른 프로세스로 실행되는 클라이언트(브라우저)와 달리, 서버 프로세스는 계속해서 유지됩니다.

cross-request state polution 을 막기위해 SSR 에서는, 렌더링 시마다 새로운 앱 인스턴스와 Virtual DOM 노드를 생성합니다.

// app.js
const Vue = require('vue')

module.exports = function createApp (context) {
  return new Vue({
    data: {
      url: context.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  })
}

// server.js
const createApp = require('./app')

server.get('*', (req, res) => {
  const context = { url: req.url }
  const app = createApp(context)

  renderer.renderToString(app, (err, html) => {
    // handle error...
    res.end(html)
  })
})

따라서 렌더 서버 번들은 팩토리 함수를 export 하여 호출시마다 새로운 뷰 앱 인스턴스를 생성하도록 개발해야합니다.

하지만 랜딩 페이지와 같이 항상 같은 콘텐츠를 보여주는 페이지의 경우, 모든 사용자 요청마다 렌더링 연산을 반복하는 것은 비효율적일 수 있습니다. 이러한 경우, 새로운 페이지를 생성하지 않고 캐싱된 페이지를 사용하면 응답 시간 감소와 서버 부하 감소의 성능 개선을 가져올 수 있습니다.

주의점

반대로, 유저에 종속된 페이지(마이페이지, 채팅 등)를 캐싱할 경우 사용자가 다른 사용자가 보아야할 화면을 보게되는 경우가 생깁니다. 따라서 유저에 종속된 페이지는 캐싱하면 안됩니다.

페이지 캐싱 동작 방식

SSR 가이드에 따라 페이지 레벨 캐싱 코드를 작성하는 방법을 알아보겠습니다.

캐시 구현

cache.js

const LRU = require('lru-cache')

const microCache = LRU({
  max: 100,
  maxAge: 1000 
})

위 예시코드에서 캐시는 LRU 캐시로 구현되며, 최대 100개까지 캐싱 가능하고 캐시는 1초동안 유지됩니다.

즉 한번 캐시된 페이지는 1초동안 렌더링연산이 수행되지 않고 빠르게 유저에게 전달 될 수 있습니다.

하지만 해당 페이지가 1초동안은 신선하지 않다는 의미이기도 하여, 프로덕트 상황에 따라 적절한 값을 설정하는 것이 필요합니다.

캐시 가능 여부

SSR guide 에서는 다음과 같이 user specific 한 요청에 필터링하기 위한 코드를 작성하라고 안내하는데요.

const isCacheable = req => {
  // implement logic to check if the request is user-specific.
  // only non-user-specific pages are cache-able
}

모든 페이지가 캐싱 될수는 없습니다. 로그인 여부에 따라 혹은 AB Test 그룹에 따라 서로 다른 화면을 볼 수 있기 때문입니다.

다음과 같은 경우에는 페이지 캐싱을 제외할 수 있습니다.

  1. AB Test 중인 페이지는 캐싱 제한

그 이유는 서로 다른 그룹의 캐싱된 페이지가 사용자에게 서빙되어 DOM Mismatch 가 발생할 수 있기 때문입니다.

A 그룹의 사용자가 특정 페이지 방문 /page ⇒ /page 에 해당하는 페이지가 캐싱됨 ⇒ maxAge 로 설정한 값 (ex 1초) 안에 B 그룹 사용자가 /page 방문 ⇒ A그룹 DOM으로 서버렌더링되어 B그룹 사용자의 브라우저로 서빙 ⇒ SSR DOM 과 CSR DOM 미스매치 발생

따라서 AB Testing 이 진행중인 페이지가 있다면 필터링 되도록 처리가 필요합니다. (혹은 그룹정보가 캐시키로 들어가도록)

cache.js

const isCacheable = req => {
	if(isABTestOngoing(req)) {
		return false
	}
	return true;
}

2. 세션이 있는 요청은 캐싱 제한

로그인된 유저의 화면은 유저마다 다르게 렌더링되는 것이 일반적입니다. 따라서 로그인된 유저의 클라이언트에서 받은 요청에 대해서는 캐싱을 제한합니다.

cache.js

const isCacheable = req => {
	if(isABTestOngoing(req) || hasSession(req)) {
		return false
	}
	return true;
}

여기까지 캐시 인스턴스와 캐시 가능여부를 확인하는 유틸함수를 작성해봤습니다.

캐시 적용

렌더서버에서 클라이언트 요청을 어떻게 처리하는 지 알아봅니다.

결론: 캐싱 가능한 요청에 대해서만 캐시를 확인하고, 캐싱 불가한 요청은 정상적으로 렌더링 연산을 수행합니다.

server.js

server.get('*', (req, res) => {
  const cacheable = isCacheable(req)
  if (cacheable) {
    const hit = microCache.get(req.url)
    if (hit) {
      return res.end(hit)
    }
  }

  renderer.renderToString((err, html) => {
    res.end(html)
    if (cacheable) {
      microCache.set(req.url, html)
    }
  })
})

server.get(*, ~~ : 서버는 모든 요청에 대해 캐시를 사용할 수 있는지 여부를 확인합니다.

캐싱되면 안되는 페이지는 위 cache.js 에서 구현된 isCachable 유틸함수로 필터링합니다.

캐싱이 불가능한 요청은 정상적으로 렌더링 연산이 수행됩니다.

const cacheable = isCacheable(req) : 캐싱이 가능한 요청인 경우

  1. 캐시에 저장된 페이지가 있는지 확인하고
  2. 있다면 캐싱되어 있던 페이지를 즉시 응답합니다.
  3. 없다면 렌더링 연산 후에 캐시에 새로 페이지를 저장합니다.

참고