Puppeteer API를 사용하여 Express 웹 서버에 서버 측 렌더링 (SSR) 기능을 추가하는 방법을 알아봅니다. 가장 좋은 점은 앱의 코드를 아주 조금만 변경하면 된다는 것입니다. 헤드리스가 모든 작업을 실행합니다.
몇 줄의 코드로 페이지를 SSR하고 최종 마크업을 가져올 수 있습니다.
import puppeteer from 'puppeteer';
async function ssr(url) {
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
await page.goto(url, {waitUntil: 'networkidle0'});
const html = await page.content(); // serialized HTML of page DOM.
await browser.close();
return html;
}
Headless Chrome을 사용해야 하는 이유
다음과 같은 경우 헤드리스 Chrome을 사용하는 것이 좋습니다.
- 검색엔진에 색인이 생성되지 않는 웹 앱을 빌드했습니다.
- JavaScript 성능을 최적화하고 첫 번째 의미 있는 페인트를 개선하기 위해 빠른 해결책을 찾고 있습니다.
Preact와 같은 일부 프레임워크는 서버 측 렌더링을 처리하는 도구와 함께 제공됩니다. 프레임워크에 사전 렌더링 솔루션이 있는 경우 Puppeteer와 Headless Chrome을 워크플로에 가져오는 대신 이를 사용하세요.
최신 웹 크롤링
검색엔진 크롤러, 소셜 공유 플랫폼, 브라우저는 이전부터 정적 HTML 마크업을 사용하여 웹 색인을 생성하고 콘텐츠를 표시해 왔습니다. 오늘날의 웹은 훨씬 다른 모습으로 진화했습니다. JavaScript 기반 애플리케이션은 계속 사용될 예정이므로 많은 경우 크롤링 도구에 콘텐츠가 표시되지 않을 수 있습니다.
Google의 검색 크롤러인 Googlebot은 사이트를 방문하는 사용자의 환경에 방해가 되지 않도록 하면서 JavaScript를 처리합니다. 크롤러가 콘텐츠에 액세스하고 렌더링하는 방법을 수용하려면 페이지와 애플리케이션을 설계할 때 몇 가지 차이점과 제한사항을 고려해야 합니다.
페이지 미리 렌더링
모든 크롤러는 HTML을 이해합니다. 크롤러가 JavaScript 색인을 생성할 수 있도록 하려면 다음과 같은 도구가 필요합니다.
- 최신 JavaScript의 모든 유형을 실행하고 정적 HTML을 생성하는 방법을 알고 있습니다.
- 웹에 기능이 추가될 때마다 최신 상태로 유지됩니다.
- 애플리케이션의 코드 업데이트가 거의 또는 전혀 없이 실행됩니다.
좋을 것 같죠? 바로 브라우저가 그 도구입니다. 헤드리스 Chrome은 사용하는 라이브러리, 프레임워크 또는 도구 체인을 고려하지 않습니다.
예를 들어 애플리케이션이 Node.js로 빌드된 경우 Puppeteer는 헤드리스 Chrome을 사용하는 간단한 방법입니다.
JavaScript로 HTML을 생성하는 동적 페이지로 시작합니다.
public/index.html
<html>
<body>
<div id="container">
<!-- Populated by the JS below. -->
</div>
</body>
<script>
function renderPosts(posts, container) {
const html = posts.reduce((html, post) => {
return `${html}
<li class="post">
<h2>${post.title}</h2>
<div class="summary">${post.summary}</div>
<p>${post.content}</p>
</li>`;
}, '');
// CAREFUL: this assumes HTML is sanitized.
container.innerHTML = `<ul id="posts">${html}</ul>`;
}
(async() => {
const container = document.querySelector('#container');
const posts = await fetch('/posts').then(resp => resp.json());
renderPosts(posts, container);
})();
</script>
</html>
SSR 함수
그런 다음 앞의 ssr()
함수를 가져와 약간 보강합니다.
ssr.mjs
import puppeteer from 'puppeteer';
// In-memory cache of rendered pages. Note: this will be cleared whenever the
// server process stops. If you need true persistence, use something like
// Google Cloud Storage (https://firebase.google.com/docs/storage/web/start).
const RENDER_CACHE = new Map();
async function ssr(url) {
if (RENDER_CACHE.has(url)) {
return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
}
const start = Date.now();
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
// networkidle0 waits for the network to be idle (no requests for 500ms).
// The page's JS has likely produced markup by this point, but wait longer
// if your site lazy loads, etc.
await page.goto(url, {waitUntil: 'networkidle0'});
await page.waitForSelector('#posts'); // ensure #posts exists in the DOM.
} catch (err) {
console.error(err);
throw new Error('page.goto/waitForSelector timed out.');
}
const html = await page.content(); // serialized HTML of page DOM.
await browser.close();
const ttRenderMs = Date.now() - start;
console.info(`Headless rendered page in: ${ttRenderMs}ms`);
RENDER_CACHE.set(url, html); // cache rendered page.
return {html, ttRenderMs};
}
export {ssr as default};
주요 변경사항은 다음과 같습니다.
- 캐싱을 추가했습니다. 렌더링된 HTML을 캐시하면 응답 속도를 높이는 데 가장 큰 도움이 됩니다. 페이지가 다시 요청되면 헤드리스 Chrome을 실행하지 않아도 됩니다. 다른 최적화는 나중에 설명합니다.
- 페이지 로드에 시간이 초과되는 경우 기본 오류 처리를 추가합니다.
page.waitForSelector('#posts')
호출을 추가합니다. 이렇게 하면 직렬화된 페이지를 덤프하기 전에 게시물이 DOM에 존재하게 됩니다.- 과학을 추가합니다. 헤드리스가 페이지를 렌더링하는 데 걸리는 시간을 기록하고 HTML과 함께 렌더링 시간을 반환합니다.
- 코드를
ssr.mjs
라는 모듈에 붙여넣습니다.
웹 서버 예시
마지막으로 모든 것을 하나로 모으는 작은 Express 서버가 있습니다. 기본 핸들러는 URL http://localhost/index.html
(홈페이지)를 미리 렌더링하고 결과를 응답으로 제공합니다. 이제 정적 마크업이 응답의 일부가 되므로 사용자가 페이지를 방문하면 게시물이 즉시 표시됩니다.
server.mjs
import express from 'express';
import ssr from './ssr.mjs';
const app = express();
app.get('/', async (req, res, next) => {
const {html, ttRenderMs} = await ssr(`${req.protocol}://${req.get('host')}/index.html`);
// Add Server-Timing! See https://w3c.github.io/server-timing/.
res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
return res.status(200).send(html); // Serve prerendered page as response.
});
app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));
이 예시를 실행하려면 종속 항목 (npm i --save puppeteer express
)을 설치하고 Node 8.5.0 이상 및 --experimental-modules
플래그를 사용하여 서버를 실행합니다.
다음은 이 서버에서 전송한 응답의 예입니다.
<html>
<body>
<div id="container">
<ul id="posts">
<li class="post">
<h2>Title 1</h2>
<div class="summary">Summary 1</div>
<p>post content 1</p>
</li>
<li class="post">
<h2>Title 2</h2>
<div class="summary">Summary 2</div>
<p>post content 2</p>
</li>
...
</ul>
</div>
</body>
<script>
...
</script>
</html>
새 Server-Timing API의 완벽한 사용 사례
Server-Timing API는 서버 성능 측정항목 (예: 요청 및 응답 시간 또는 데이터베이스 조회)을 브라우저에 다시 전달합니다. 클라이언트 코드는 이 정보를 사용하여 웹 앱의 전반적인 성능을 추적할 수 있습니다.
Server-Timing의 완벽한 사용 사례는 헤드리스 Chrome이 페이지를 미리 렌더링하는 데 걸리는 시간을 보고하는 것입니다. 이렇게 하려면 서버 응답에 Server-Timing
헤더를 추가하면 됩니다.
res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);
클라이언트에서 Performance API 및 PerformanceObserver를 사용하여 다음 측정항목에 액세스할 수 있습니다.
const entry = performance.getEntriesByType('navigation').find(
e => e.name === location.href);
console.log(entry.serverTiming[0].toJSON());
{
"name": "Prerender",
"duration": 3808,
"description": "Headless render time (ms)"
}
실적 결과
다음 결과에는 나중에 설명하는 대부분의 성능 최적화가 반영되어 있습니다.
예시 앱에서 헤드리스 Chromium이 서버에서 페이지를 렌더링하는 데 약 1초가 걸립니다. 페이지가 캐시되면 DevTools 3G 느린 에뮬레이션으로 인해 FCP가 클라이언트 측 버전보다 8.37초 더 빠르게 표시됩니다.
첫 페인트 (FP) | First Contentful Paint (FCP) | |
---|---|---|
클라이언트 측 앱 | 4초 | 11초 |
SSR 버전 | 2.3초 | ~2.3초 |
이러한 결과는 매우 긍정적입니다. 서버 측에서 렌더링된 페이지가 더 이상 JavaScript를 사용하여 게시물을 로드하고 표시하지 않으므로 사용자가 의미 있는 콘텐츠를 훨씬 더 빠르게 볼 수 있습니다.
재수화 방지
'클라이언트 측 앱의 코드를 변경하지 않았습니다'라고 말씀드렸던 것을 기억하시나요? 거짓말이야.
Express 앱은 요청을 수신하고 Puppeteer를 사용하여 페이지를 헤드리스로 로드한 후 결과를 응답으로 제공합니다. 하지만 이 설정에는 문제가 있습니다.
서버에서 헤드리스 Chrome에서 실행되는 동일한 JavaScript가 사용자의 브라우저가 프런트엔드에서 페이지를 로드할 때 다시 실행됩니다. 마크업을 생성하는 위치는 두 곳입니다. #doublerender!
이 문제를 해결하려면 페이지에 HTML이 이미 적용되었다고 알리세요.
한 가지 해결책은 페이지 JavaScript가 로드 시 <ul id="posts">
가 이미 DOM에 있는지 확인하도록 하는 것입니다. 페이지가 SSR 처리되었다면 게시물을 다시 추가하지 않아도 됩니다. 👍
public/index.html
<html>
<body>
<div id="container">
<!-- Populated by JS (below) or by prerendering (server). Either way,
#container gets populated with the posts markup:
<ul id="posts">...</ul>
-->
</div>
</body>
<script>
...
(async() => {
const container = document.querySelector('#container');
// Posts markup is already in DOM if we're seeing a SSR'd.
// Don't re-hydrate the posts here on the client.
const PRE_RENDERED = container.querySelector('#posts');
if (!PRE_RENDERED) {
const posts = await fetch('/posts').then(resp => resp.json());
renderPosts(posts, container);
}
})();
</script>
</html>
최적화
렌더링된 결과를 캐시하는 것 외에도 ssr()
에 적용할 수 있는 흥미로운 최적화 방법이 많이 있습니다. 일부는 빠른 실적을 올릴 수 있는 반면, 다른 일부는 더 추측적인 방법일 수 있습니다. 실제로 얻을 수 있는 성능 이점은 궁극적으로 미리 렌더링하는 페이지 유형과 앱의 복잡도에 따라 달라질 수 있습니다.
비필수 요청 중단
현재 전체 페이지 (및 요청하는 모든 리소스)가 헤드리스 Chrome에 무조건 로드됩니다. 하지만 여기서는 두 가지 사항에만 관심이 있습니다.
- 렌더링된 마크업입니다.
- 해당 마크업을 생성한 JS 요청
DOM을 생성하지 않는 네트워크 요청은 낭비입니다. 이미지, 글꼴, 스타일시트, 미디어와 같은 리소스는 페이지의 HTML 빌드에 참여하지 않습니다. 페이지의 구조에 스타일을 지정하고 보완하지만 명시적으로 만들지는 않습니다. 브라우저에 이러한 리소스를 무시하도록 지시해야 합니다. 이렇게 하면 헤드리스 Chrome의 워크로드가 줄고 대역폭이 절약되며 더 큰 페이지의 미리 렌더링 시간이 단축될 수 있습니다.
DevTools 프로토콜은 브라우저에서 요청을 실행하기 전에 요청을 수정하는 데 사용할 수 있는 강력한 기능인 네트워크 가로채기를 지원합니다.
Puppeteer는 page.setRequestInterception(true)
를 사용 설정하고 페이지의 request
이벤트를 수신 대기하여 네트워크 가로채기를 지원합니다.
이를 통해 특정 리소스에 대한 요청을 중단하고 다른 리소스에 대한 요청은 계속 진행할 수 있습니다.
ssr.mjs
async function ssr(url) {
...
const page = await browser.newPage();
// 1. Intercept network requests.
await page.setRequestInterception(true);
page.on('request', req => {
// 2. Ignore requests for resources that don't produce DOM
// (images, stylesheets, media).
const allowlist = ['document', 'script', 'xhr', 'fetch'];
if (!allowlist.includes(req.resourceType())) {
return req.abort();
}
// 3. Pass through all other requests.
req.continue();
});
await page.goto(url, {waitUntil: 'networkidle0'});
const html = await page.content(); // serialized HTML of page DOM.
await browser.close();
return {html};
}
중요한 리소스 인라인 처리
일반적으로 별도의 빌드 도구 (예: gulp
)를 사용하여 앱을 처리하고 빌드 시간에 중요한 CSS 및 JS를 페이지에 인라인 처리합니다. 이렇게 하면 브라우저가 초기 페이지 로드 중에 더 적은 요청을 하므로 첫 번째 의미 있는 페인트 속도가 빨라질 수 있습니다.
별도의 빌드 도구 대신 브라우저를 빌드 도구로 사용하세요. Puppeteer를 사용하여 페이지의 DOM, 인라인 스타일, JavaScript 또는 페이지를 미리 렌더링하기 전에 페이지에 추가하려는 기타 항목을 조작할 수 있습니다.
이 예에서는 로컬 스타일시트의 응답을 가로채고 이러한 리소스를 <style>
태그로 페이지에 인라인하는 방법을 보여줍니다.
ssr.mjs
import urlModule from 'url';
const URL = urlModule.URL;
async function ssr(url) {
...
const stylesheetContents = {};
// 1. Stash the responses of local stylesheets.
page.on('response', async resp => {
const responseUrl = resp.url();
const sameOrigin = new URL(responseUrl).origin === new URL(url).origin;
const isStylesheet = resp.request().resourceType() === 'stylesheet';
if (sameOrigin && isStylesheet) {
stylesheetContents[responseUrl] = await resp.text();
}
});
// 2. Load page as normal, waiting for network requests to be idle.
await page.goto(url, {waitUntil: 'networkidle0'});
// 3. Inline the CSS.
// Replace stylesheets in the page with their equivalent <style>.
await page.$$eval('link[rel="stylesheet"]', (links, content) => {
links.forEach(link => {
const cssText = content[link.href];
if (cssText) {
const style = document.createElement('style');
style.textContent = cssText;
link.replaceWith(style);
}
});
}, stylesheetContents);
// 4. Get updated serialized HTML of page.
const html = await page.content();
await browser.close();
return {html};
}
This code:
- Use a
page.on('response')
handler to listen for network responses. - Stashes the responses of local stylesheets.
- Finds all
<link rel="stylesheet">
in the DOM and replaces them with an equivalent<style>
. Seepage.$$eval
API docs. Thestyle.textContent
is set to the stylesheet response.
Auto-minify resources
Another trick you can do with network interception is to modify the responses returned by a request.
As an example, say you want to minify the CSS in your app but also want to
keep the convenience having it unminified when developing. Assuming you've
setup another tool to pre-minify styles.css
, one can use Request.respond()
to rewrite the response of styles.css
to be the content of styles.min.css
.
ssr.mjs
import fs from 'fs';
async function ssr(url) {
...
// 1. Intercept network requests.
await page.setRequestInterception(true);
page.on('request', req => {
// 2. If request is for styles.css, respond with the minified version.
if (req.url().endsWith('styles.css')) {
return req.respond({
status: 200,
contentType: 'text/css',
body: fs.readFileSync('./public/styles.min.css', 'utf-8')
});
}
...
req.continue();
});
...
const html = await page.content();
await browser.close();
return {html};
}
렌더링 전반에서 단일 Chrome 인스턴스 재사용
프리렌더링할 때마다 새 브라우저를 실행하면 오버헤드가 많이 발생합니다. 대신 단일 인스턴스를 실행하고 이를 여러 페이지 렌더링에 재사용하는 것이 좋습니다.
Puppeteer는 puppeteer.connect()
를 호출하고 인스턴스의 원격 디버깅 URL을 전달하여 기존 Chrome 인스턴스에 다시 연결할 수 있습니다. 장기 실행 브라우저 인스턴스를 유지하려면 Chrome을 실행하는 코드를 ssr()
함수에서 Express 서버로 이동하면 됩니다.
server.mjs
import express from 'express';
import puppeteer from 'puppeteer';
import ssr from './ssr.mjs';
let browserWSEndpoint = null;
const app = express();
app.get('/', async (req, res, next) => {
if (!browserWSEndpoint) {
const browser = await puppeteer.launch();
browserWSEndpoint = await browser.wsEndpoint();
}
const url = `${req.protocol}://${req.get('host')}/index.html`;
const {html} = await ssr(url, browserWSEndpoint);
return res.status(200).send(html);
});
ssr.mjs
import puppeteer from 'puppeteer';
/**
* @param {string} url URL to prerender.
* @param {string} browserWSEndpoint Optional remote debugging URL. If
* provided, Puppeteer's reconnects to the browser instance. Otherwise,
* a new browser instance is launched.
*/
async function ssr(url, browserWSEndpoint) {
...
console.info('Connecting to existing Chrome instance.');
const browser = await puppeteer.connect({browserWSEndpoint});
const page = await browser.newPage();
...
await page.close(); // Close the page we opened here (not the browser).
return {html};
}
예: 주기적으로 미리 렌더링하는 크론 작업
여러 페이지를 한 번에 렌더링하려면 공유 브라우저 인스턴스를 사용하면 됩니다.
import puppeteer from 'puppeteer';
import * as prerender from './ssr.mjs';
import urlModule from 'url';
const URL = urlModule.URL;
app.get('/cron/update_cache', async (req, res) => {
if (!req.get('X-Appengine-Cron')) {
return res.status(403).send('Sorry, cron handler can only be run as admin.');
}
const browser = await puppeteer.launch();
const homepage = new URL(`${req.protocol}://${req.get('host')}`);
// Re-render main page and a few pages back.
prerender.clearCache();
await prerender.ssr(homepage.href, await browser.wsEndpoint());
await prerender.ssr(`${homepage}?year=2018`);
await prerender.ssr(`${homepage}?year=2017`);
await prerender.ssr(`${homepage}?year=2016`);
await browser.close();
res.status(200).send('Render cache updated!');
});
또한 ssr.js에 clearCache()
내보내기를 추가합니다.
...
function clearCache() {
RENDER_CACHE.clear();
}
export {ssr, clearCache};
기타 고려사항
페이지의 신호를 만듭니다. '헤드리스에서 렌더링 중입니다'
페이지가 서버에서 헤드리스 Chrome으로 렌더링되는 경우 페이지의 클라이언트 측 로직에서 이를 알면 유용할 수 있습니다. 앱에서는 이 후크를 사용하여 게시물 마크업을 렌더링하는 데 관여하지 않는 페이지 부분을 '사용 중지'했습니다. 예를 들어 firebase-auth.js를 지연 로드하는 코드를 사용 중지했습니다. 로그인할 사용자가 없습니다.
렌더링 URL에 ?headless
매개변수를 추가하면 페이지에 후크를 간단하게 추가할 수 있습니다.
ssr.mjs
import urlModule from 'url';
const URL = urlModule.URL;
async function ssr(url) {
...
// Add ?headless to the URL so the page has a signal
// it's being loaded by headless Chrome.
const renderUrl = new URL(url);
renderUrl.searchParams.set('headless', '');
await page.goto(renderUrl, {waitUntil: 'networkidle0'});
...
return {html};
}
페이지에서 해당 매개변수를 찾을 수 있습니다.
public/index.html
<html>
<body>
<div id="container">
<!-- Populated by the JS below. -->
</div>
</body>
<script>
...
(async() => {
const params = new URL(location.href).searchParams;
const RENDERING_IN_HEADLESS = params.has('headless');
if (RENDERING_IN_HEADLESS) {
// Being rendered by headless Chrome on the server.
// e.g. shut off features, don't lazy load non-essential resources, etc.
}
const container = document.querySelector('#container');
const posts = await fetch('/posts').then(resp => resp.json());
renderPosts(posts, container);
})();
</script>
</html>
애널리틱스 페이지 조회수 부풀림 방지
사이트에서 애널리틱스를 사용하는 경우 주의하세요. 페이지를 미리 렌더링하면 페이지뷰가 부풀려질 수 있습니다. 구체적으로 히트 수가 2배로 표시됩니다. 헤드리스 Chrome에서 페이지를 렌더링할 때 하나의 히트가 발생하고 사용자의 브라우저에서 페이지를 렌더링할 때 하나의 히트가 발생합니다.
해결 방법은 무엇인가요? 네트워크 가로채기를 사용하여 애널리틱스 라이브러리를 로드하려는 모든 요청을 중단합니다.
page.on('request', req => {
// Don't load Google Analytics lib requests so pageviews aren't 2x.
const blockist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
if (blocklist.find(regex => req.url().match(regex))) {
return req.abort();
}
...
req.continue();
});
코드가 로드되지 않으면 페이지 조회가 기록되지 않습니다. 쾅 💥.
또는 애널리틱스 라이브러리를 계속 로드하여 서버에서 실행하는 사전 렌더링 수를 파악할 수 있습니다.
결론
Puppeteer를 사용하면 웹 서버에서 헤드리스 Chrome을 컴패니언으로 실행하여 페이지를 서버 측에서 쉽게 렌더링할 수 있습니다. 이 접근 방식에서 가장 좋아하는 '기능'은 코드를 크게 변경하지 않고도 앱의 로드 성능과 색인 생성 가능성을 개선할 수 있다는 점입니다.
여기에 설명된 기법을 사용하는 작동하는 앱을 확인하려면 devwebfeed 앱을 확인하세요.
부록
선행 기술에 관한 논의
서버 측에서 클라이언트 측 앱을 렌더링하는 것은 어렵습니다. 얼마나 어려운가요? 이 주제에 관해 사람들이 작성한 npm 패키지 수를 살펴보세요. JS 앱의 SSR을 지원하는 수많은 패턴, 도구, 서비스가 있습니다.
동종형 / 범용 JavaScript
범용 JavaScript의 개념은 서버에서 실행되는 동일한 코드가 클라이언트 (브라우저)에서도 실행된다는 것을 의미합니다. 서버와 클라이언트 간에 코드를 공유하면 모두가 평온함을 느낍니다.
헤드리스 Chrome은 서버와 클라이언트 간에 '동종 JS'를 사용 설정합니다. 서버 (Node)에서 라이브러리가 작동하지 않는 경우에 적합한 옵션입니다.
사전 렌더링 도구
Node 커뮤니티는 SSR JS 앱을 처리하기 위한 수많은 도구를 빌드했습니다. 놀랄 일도 없습니다. 개인적으로는 이러한 도구 중 일부가 YMMV(일부 사용자에게는 유용하고 일부 사용자에게는 그렇지 않음)인 것으로 확인했으므로 도구를 사용하기 전에 숙제를 해 두는 것이 좋습니다. 예를 들어 일부 SSR 도구는 이전 버전이며 헤드리스 Chrome (또는 헤드리스 브라우저)을 사용하지 않습니다. 대신 PhantomJS (이전 Safari라고도 함)를 사용하므로 최신 기능을 사용하는 경우 페이지가 제대로 렌더링되지 않습니다.
주목할 만한 예외 중 하나는 사전 렌더링입니다. Prerender는 헤드리스 Chrome을 사용하고 드롭인 Express용 미들웨어와 함께 제공된다는 점에서 흥미롭습니다.
const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();
Prerender는 다양한 플랫폼에서 Chrome을 다운로드하고 설치하는 세부정보를 생략한다는 점에 유의하세요. 하지만 이 작업을 올바르게 수행하는 것은 상당히 까다롭습니다. Puppeteer가 이를 대신하는 이유 중 하나가 바로 이 때문입니다.