스트림이 포함된 더 빠른 다중 페이지 애플리케이션

요즘에는 웹사이트 또는 원하는 경우 웹 에서 다음 두 가지 탐색 스킴 중 하나를 사용하는 경향이 있습니다.

  • 탐색 스킴은 브라우저에서 기본적으로 제공합니다. 즉, 브라우저의 주소 표시줄에 URL을 입력하면 탐색 요청이 응답으로 문서를 반환합니다. 그런 다음 링크를 클릭하면 현재 문서가 다른 ad infinitum인 문서를 언로드됩니다.
  • 단일 페이지 애플리케이션 패턴으로, 애플리케이션 셸을 로드하기 위한 초기 탐색 요청이 포함되고, JavaScript를 사용하여 애플리케이션 셸을 각 '탐색'에 대한 백엔드 API의 콘텐츠로 클라이언트가 렌더링한 마크업으로 채웁니다.

이들의 지지자들은 각 접근방식의 이점을 다음과 같이 강조했습니다.

  • 브라우저에서 기본적으로 제공하는 탐색 스킴은 탄력적입니다. 경로에 JavaScript에 액세스할 필요가 없기 때문입니다. 또한 자바스크립트를 통해 마크업을 클라이언트 렌더링하는 작업은 비용이 많이 드는 프로세스일 수 있습니다. 즉, 기기가 콘텐츠를 제공하는 스크립트 처리를 차단하여 저사양 기기에서 콘텐츠가 지연되는 상황이 발생할 수 있습니다.
  • 반면 단일 페이지 애플리케이션 (SPA)은 초기 로드 후 더 빠르게 탐색할 수 있습니다. 완전히 새로운 문서를 언로드하기 위해 브라우저에 의존하고 모든 탐색에 대해 이 작업을 반복하는 대신에 더 빠르고 '앱 같은' 느낌을 제공할 수 있습니다. 사용할 수 있습니다.

이 게시물에서는 위에서 설명한 두 접근 방식 간의 균형을 이루는 세 번째 방법에 대해 이야기해 보겠습니다. 즉, 서비스 워커를 사용하여 웹사이트의 공통 요소(예: 머리글 및 바닥글 마크업)를 사전 캐시하고 스트림을 사용하여 브라우저의 기본 탐색 구성표를 계속 사용하면서 최대한 빨리 클라이언트에 HTML 응답을 제공하는 것입니다.

서비스 워커에서 HTML 응답을 스트리밍해야 하는 이유

스트리밍은 웹브라우저에서 요청할 때 이미 수행하는 작업입니다. 탐색 요청의 컨텍스트에서 이는 브라우저가 문서 마크업을 파싱하고 페이지를 렌더링하기 전에 전체 응답을 기다리는 것을 차단하지 않도록 하기 때문에 매우 중요합니다.

비 스트리밍 HTML과 스트리밍 HTML을 비교하여 보여주는 다이어그램 전자의 경우, 전체 마크업 페이로드는 도착할 때까지 처리되지 않습니다. 후자의 경우 마크업이 네트워크에서 청크로 도착하면 점진적으로 처리됩니다.

서비스 워커의 경우 스트리밍은 JavaScript Streams API를 사용하므로 약간 다릅니다. 서비스 워커가 수행하는 가장 중요한 작업은 내비게이션 요청을 포함한 요청을 가로채고 응답하는 것입니다.

이러한 요청은 여러 가지 방법으로 캐시와 상호작용할 수 있지만, 마크업의 일반적인 캐싱 패턴은 먼저 네트워크 응답을 사용하고 이전 사본을 사용할 수 있는 경우 캐시로 대체하는 것입니다. 사용 가능한 응답이 캐시에 없는 경우에는 선택적으로 일반 대체 응답을 제공합니다.

이는 오랫동안 검증된 마크업 패턴으로, 효과가 뛰어나지만 오프라인 액세스 측면에서 안정성에 도움이 되지만 네트워크 우선 또는 네트워크 전용 전략에 의존하는 탐색 요청에는 어떠한 고유한 성능 이점도 제공하지 않습니다. 이때 스트리밍이 필요합니다. Workbox 서비스 워커에서 Streams API 기반 workbox-streams 모듈을 사용하여 멀티 페이지 웹사이트의 탐색 요청 속도를 높이는 방법을 살펴보겠습니다.

일반적인 웹페이지 분석

구조적으로 보면 웹사이트의 모든 페이지에 공통적인 요소가 존재하는 경향이 있습니다. 페이지 요소의 일반적인 배열은 다음과 같습니다.

  • 헤더.
  • 콘텐츠.
  • 바닥글

web.dev를 예로 들어 보면, 일반적인 요소의 분류는 다음과 같습니다.

web.dev 웹사이트의 공통 요소에 대한 분석입니다. 구분되는 공통 영역은 '머리글', '콘텐츠', '바닥글'로 표시됩니다.

페이지의 일부를 식별하는 이면의 목표는 네트워크로 이동하지 않고도 미리 캐시되고 검색할 수 있는 항목(즉, 모든 페이지에 공통된 헤더 및 바닥글 마크업)과 항상 네트워크에 먼저 이동할 페이지 부분(이 경우 콘텐츠)을 결정하는 것입니다.

페이지의 일부를 세분화하고 공통 요소를 식별하는 방법을 알면 네트워크의 콘텐츠 요청하면서 항상 캐시에서 헤더 및 바닥글 마크업을 즉시 가져오는 서비스 워커를 작성할 수 있습니다.

그런 다음 workbox-streams를 통해 Streams API를 사용하여 이러한 모든 부분을 함께 병합하고 네트워크에서 필요한 최소한의 마크업을 요청하면서 탐색 요청에 즉시 응답할 수 있습니다.

스트리밍 서비스 워커 빌드

서비스 워커에서 부분 콘텐츠를 스트리밍하는 데에는 움직이는 부분이 많지만, 웹사이트를 구성하는 방법부터 시작하여 프로세스의 각 단계를 자세히 살펴봅니다.

웹사이트를 부분적으로 분할하기

스트리밍 서비스 워커를 작성하기 전에 다음 세 가지 작업을 실행해야 합니다.

  1. 웹사이트의 헤더 마크업만 포함하는 파일을 만듭니다.
  2. 웹사이트의 바닥글 마크업만 포함하는 파일을 만듭니다.
  3. 각 페이지의 주요 콘텐츠를 별도의 파일로 가져오거나 백엔드를 설정하여 HTTP 요청 헤더를 기반으로 조건부로 페이지 콘텐츠만 제공하도록 설정합니다.
를 통해 개인정보처리방침을 정의할 수 있습니다.

예상하시겠지만, 특히 웹사이트가 정적인 경우 마지막 단계가 가장 어렵습니다. 이 경우 각 페이지에 두 가지 버전을 생성해야 합니다. 한 버전에는 전체 페이지 마크업이 포함되고 다른 버전에는 콘텐츠만 포함됩니다.

스트리밍 서비스 워커 작성

workbox-streams 모듈을 설치하지 않았다면 현재 설치한 Workbox 모듈 외에 추가로 설치해야 합니다. 이 예에서는 다음 패키지가 포함됩니다.

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

여기서 다음 단계는 새 서비스 워커를 만들고 머리글 및 바닥글 부분을 미리 캐시하는 것입니다.

부분 사전 캐싱

가장 먼저 할 일은 프로젝트의 루트에 sw.js (또는 선호하는 파일 이름)이라는 서비스 워커를 만드는 것입니다. 여기에서는 다음과 같이 시작합니다.

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

이 코드는 다음과 같은 몇 가지 작업을 수행합니다.

  1. 탐색 미리 로드를 지원하는 브라우저에서 탐색 미리 로드를 사용 설정합니다.
  2. 머리글 및 바닥글 마크업을 미리 캐시합니다. 즉, 모든 페이지의 머리글 및 바닥글 마크업이 네트워크에 의해 차단되지 않으므로 즉시 검색됩니다.
  3. injectManifest 메서드를 사용하는 __WB_MANIFEST 자리표시자에서 정적 애셋을 사전 캐시합니다.
를 통해 개인정보처리방침을 정의할 수 있습니다.

스트리밍 응답

서비스 워커가 연결된 응답을 스트리밍하도록 하는 것은 이 모든 노력의 가장 큰 부분입니다. 그럼에도 불구하고 Workbox와 workbox-streams를 사용하면 이 모든 작업을 직접 해야 하는 경우보다 훨씬 더 간결하게 작업할 수 있습니다.

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

이 코드는 다음 요구사항을 충족하는 세 가지 주요 부분으로 구성됩니다.

  1. NetworkFirst 전략은 콘텐츠 부분 요청을 처리하는 데 사용됩니다. 이 전략을 사용하면 탐색 미리 로드를 지원하지 않아 Service-Worker-Navigation-Preload 헤더를 전송하지 않는 브라우저의 X-Content-Mode 요청 헤더 설정 여부를 처리하는 커스텀 플러그인과 콘텐츠 부분을 포함하도록 content의 커스텀 캐시 이름이 지정됩니다. 또한 이 플러그인은 콘텐츠 부분의 마지막으로 캐시된 버전을 전송할지, 아니면 현재 요청에 대해 저장된 버전이 없는 경우 오프라인 대체 페이지를 전송할지 여부를 파악합니다.
  2. workbox-streamsstrategy 메서드 (여기서는 composeStrategies로 별칭 지정)는 네트워크에서 요청된 콘텐츠 부분과 함께 사전 캐시된 머리글 및 바닥글 부분을 연결하는 데 사용됩니다.
  3. 전체 스킴은 탐색 요청을 위해 registerRoute를 통해 설정됩니다.

이 로직을 사용하여 스트리밍 응답을 설정했습니다. 그러나 네트워크의 콘텐츠가 사전 캐시된 부분과 병합할 수 있는 부분 페이지가 되도록 하려면 백엔드에서 몇 가지 작업을 수행해야 할 수도 있습니다.

웹사이트에 백엔드가 있는 경우

탐색 미리 로드가 사용 설정되어 있으면 브라우저에서 값이 trueService-Worker-Navigation-Preload 헤더를 전송합니다. 하지만 위의 코드 샘플에서는 브라우저에서 지원되지 않는 이벤트 탐색 미리 로드의 맞춤 헤더 X-Content-Mode를 전송했습니다. 백엔드에서는 이러한 헤더가 있는지에 따라 응답을 변경합니다. PHP 백엔드의 경우 주어진 페이지에 대해 다음과 같을 수 있습니다.

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

위의 예에서 콘텐츠 부분은 함수로 호출되며 $isPartial 값을 사용하여 부분이 렌더링되는 방식을 변경합니다. 예를 들어 content 렌더기 함수는 부분적으로 가져온 경우에만 조건에 특정 마크업이 포함될 수 있습니다. 이 내용에 관해서는 곧 다룹니다.

고려사항

서비스 워커를 배포하여 일부를 스트리밍하고 함께 병합하기 전에 고려해야 할 몇 가지 사항이 있습니다. 이러한 방식으로 서비스 워커를 사용한다고 해서 브라우저의 기본 탐색 동작이 근본적으로 변경되지는 않겠지만, 해결해야 할 몇 가지 사항이 있습니다.

탐색 시 페이지 요소 업데이트

이 접근 방식에서 가장 까다로운 부분은 클라이언트에서 일부 항목을 업데이트해야 한다는 것입니다. 예를 들어 헤더 마크업을 미리 캐시하면 페이지의 <title> 요소에 동일한 콘텐츠가 포함되거나 탐색 항목의 사용/사용 중지 상태 관리도 각 탐색에서 업데이트해야 합니다. 각 탐색 요청에 대해 클라이언트에서 이러한 항목을 업데이트해야 할 수도 있습니다.

이 문제를 해결하는 방법은 네트워크에서 가져오는 콘텐츠 부분에 인라인 <script> 요소를 배치하여 몇 가지 중요한 사항을 업데이트하는 것입니다.

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

이는 이 서비스 워커 설정을 사용하기로 결정한 경우 수행해야 하는 작업의 한 예에 불과합니다. 예를 들어 사용자 정보가 포함된 더 복잡한 애플리케이션의 경우 localStorage와 같은 웹 스토어에 관련 데이터를 저장하고 거기에서 페이지를 업데이트해야 할 수 있습니다.

느린 네트워크 처리

사전 캐시의 마크업을 사용하는 스트리밍 응답의 한 가지 단점은 네트워크 연결이 느릴 때 발생할 수 있습니다. 문제는 사전 캐시의 헤더 마크업이 즉시 도착하지만, 네트워크 일부의 콘텐츠가 헤더 마크업의 최초 페인트 이후에 도착하는 데는 상당한 시간이 걸릴 수 있다는 점입니다.

이로 인해 혼란스러운 환경이 생길 수 있고, 네트워크가 매우 느리면 페이지가 깨져 더 이상 렌더링되지 않는 것처럼 느껴질 수도 있습니다. 이 경우 콘텐츠 부분의 마크업에 로드 아이콘이나 메시지를 넣도록 선택할 수 있습니다. 이 마크업은 콘텐츠가 로드되면 숨길 수 있습니다.

이렇게 하는 한 가지 방법은 CSS를 사용하는 것입니다. 헤더 일부가 여는 <article> 요소로 끝나고 콘텐츠 부분이 채워질 때까지 비어 있다고 가정해 보겠습니다. 다음과 비슷한 CSS 규칙을 작성할 수 있습니다.

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

작동하지만 네트워크 속도에 관계없이 클라이언트에 로드 메시지가 표시됩니다. 메시지가 이상하게 표시되지 않도록 하려면 위 스니펫의 선택기를 slow 클래스 내에 중첩하여 다음 방법을 시도해 보세요.

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

여기에서 헤더 부분의 JavaScript를 사용하여 유효 연결 유형 (적어도 Chromium 브라우저)을 읽고 slow 클래스를 일부 연결 유형의 <html> 요소에 추가할 수 있습니다.

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

이렇게 하면 4g 유형보다 느린 유효한 연결 유형에 로드 메시지가 표시됩니다. 그런 다음 콘텐츠 부분에 인라인 <script> 요소를 배치하여 HTML에서 slow 클래스를 삭제하여 로드 메시지를 삭제할 수 있습니다.

<script>
  document.documentElement.classList.remove('slow');
</script>
드림

대체 응답 제공

콘텐츠 부분에 네트워크 우선 전략을 사용하고 있다고 가정해 보겠습니다. 사용자가 오프라인 상태이고 이미 방문한 페이지로 이동하는 경우에도 충돌이 발생합니다. 하지만 아직 방문하지 않은 페이지로 이동하면 아무것도 표시되지 않습니다. 이를 방지하려면 대체 응답을 제공해야 합니다.

대체 응답을 달성하는 데 필요한 코드는 이전 코드 샘플에 나와 있습니다. 이 프로세스에는 두 단계가 필요합니다.

  1. 오프라인 대체 응답을 사전 캐시합니다.
  2. 네트워크 우선 전략을 위해 플러그인에서 handlerDidError 콜백을 설정하여 캐시에서 마지막으로 액세스한 페이지 버전을 확인합니다. 페이지에 액세스한 적이 없다면 workbox-precaching 모듈matchPrecache 메서드를 사용하여 사전 캐시에서 대체 응답을 검색해야 합니다.

캐싱 및 CDN

서비스 워커에서 이 스트리밍 패턴을 사용하는 경우 다음 사항이 상황에 적용되는지 평가하세요.

  • CDN 또는 다른 종류의 중간/공개 캐시를 사용합니다.
  • 0이 아닌 max-age 또는 s-maxage 지시어를 public 지시어와 함께 사용하여 Cache-Control 헤더를 지정했습니다.

이 두 경우에 모두 해당한다면 중간 캐시는 탐색 요청에 대한 응답을 보유할 수 있습니다. 그러나 이 패턴을 사용하면 주어진 URL에 대해 두 가지 다른 응답을 제공할 수 있습니다.

  • 머리글, 콘텐츠, 바닥글 마크업을 포함하는 전체 응답입니다.
  • 내용만 포함하는 부분 응답입니다.

이로 인해 원치 않는 동작이 발생하여 헤더와 푸터 마크업이 두 배가 될 수 있습니다. 서비스 워커가 CDN 캐시에서 전체 응답을 가져와서 사전 캐시된 헤더 및 푸터 마크업과 결합할 수 있기 때문입니다.

이 문제를 해결하려면 요청에 있는 하나 이상의 헤더에 캐시 가능한 응답을 입력하여 캐싱 동작에 영향을 주는 Vary 헤더를 사용해야 합니다. 탐색 요청에 대한 응답은 Service-Worker-Navigation-Preload 및 커스텀 X-Content-Mode 요청 헤더에 따라 달라지므로 응답에서 이 Vary 헤더를 지정해야 합니다.

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

이 헤더를 사용하면 브라우저에서 탐색 요청에 대해 완전한 응답과 부분 응답을 구분하므로 중간 캐시와 마찬가지로 헤더 및 바닥글 이중 마크업 마크업과 관련된 문제를 피할 수 있습니다.

결과

로드 시간에 관한 대부분의 조언은 '결과를 보여준다'는 의미로 요약됩니다. 망설이지 말고, 망설이지 말고 모든 것이 준비될 때까지 기다리지 말고 사용자에게 무언가를 보여주세요.

<ph type="x-smartling-placeholder"></ph> 제이크 아치볼드의 재미있는 콘텐츠를 위한 빠른 콘텐츠

브라우저는 대규모 HTML 응답 본문의 경우에도 탐색 요청에 대한 응답을 처리하는 데 뛰어납니다. 기본적으로 브라우저는 긴 작업을 피하는 청크로 마크업을 점진적으로 스트리밍하고 처리하므로 시작 성능에 좋습니다.

이는 스트리밍 서비스 워커 패턴을 사용할 때 유리합니다. 서비스 워커 캐시의 요청에 처음부터 응답할 때마다 응답의 시작이 거의 즉시 도착합니다. 사전 캐시된 머리글 및 바닥글 마크업을 네트워크의 응답과 병합하면 다음과 같은 주목할 만한 성능상의 이점을 얻을 수 있습니다.

  • 탐색 요청에 대한 응답의 첫 바이트가 즉시 전송되므로 TTFB (Time to First Byte)가 크게 단축되는 경우가 많습니다.
  • 콘텐츠가 포함된 첫 페인트 (FCP)매우 빠릅니다. 사전 캐시된 헤더 마크업에는 캐시된 스타일 시트에 대한 참조가 포함되어 페이지가 매우 빠르게 페인트된다는 의미입니다.
  • 경우에 따라 최대 콘텐츠 페인트 (LCP)도 더 빨라질 수 있습니다. 특히 화면의 가장 큰 요소가 사전 캐시된 헤더 부분에 의해 제공되는 경우 더욱 그렇습니다. 그렇더라도 더 작은 마크업 페이로드와 함께 최대한 빨리 서비스 워커 캐시의 무언가를 제공하기만 하면 LCP가 향상될 수 있습니다.

스트리밍 멀티 페이지 아키텍처는 설정하고 반복하기가 약간 까다로울 수 있지만 관련 복잡성은 이론적으로 SPA보다 큰 부담이 되지 않는 경우가 많습니다. 주요 이점은 브라우저의 기본 탐색 스키마를 대체하지 않고 향상하고 있다는 것입니다.

더 좋은 점은 Workbox를 통해 이 아키텍처를 실현할 수 있을 뿐만 아니라 개발자가 직접 구현하는 경우보다 쉽게 이 아키텍처를 구현할 수 있다는 것입니다. 자신의 웹사이트에서 사용해 보고 현장 사용자가 멀티 페이지 웹사이트를 얼마나 더 빠르게 사용할 수 있는지 확인해 보세요.

리소스