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

요즘에는 웹사이트 또는 웹 (원하는 경우)에서 다음 두 가지 탐색 기법 중 하나를 사용합니다.

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

각 접근 방식의 장점은 지지자들이 강조한 것입니다.

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

이 게시물에서는 위에서 설명한 두 접근 방식 사이에서 균형을 맞추는 세 번째 방법을 살펴보겠습니다. 서비스 워커를 사용하여 웹사이트의 일반적인 요소(예: 헤더 및 바닥글 마크업)를 사전 캐시하고, 스트림을 사용하여 브라우저의 기본 탐색 스키마를 계속 사용하면서 최대한 빠르게 클라이언트에 HTML 응답을 제공하는 것입니다.

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

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

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

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

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

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

일반적인 웹페이지 분석

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

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

web.dev를 예로 사용하는 경우 일반적인 요소의 분류는 다음과 같습니다.

web.dev 웹사이트의 일반적인 요소 분석 공통된 영역은 '머리글', '내용' 및 '바닥글'로 표시되어 있습니다.

페이지의 일부를 식별하는 것은 Google이 네트워크로 이동하지 않고도 사전 캐시되거나 검색될 수 있는 항목(예: 모든 페이지에 공통으로 적용되는 헤더 및 바닥글 마크업)과 항상 네트워크에서 가장 먼저 네트워크에 이동하는 페이지 부분(이 경우 콘텐츠)을 결정하는 것입니다.

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

그런 다음 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 전략은 콘텐츠 부분 요청을 처리하는 데 사용됩니다. 이 전략을 사용하면 콘텐츠 일부를 포함하도록 content의 맞춤 캐시 이름과 탐색 미리 로드를 지원하지 않아 Service-Worker-Navigation-Preload 헤더를 전송하지 않는 브라우저의 X-Content-Mode 요청 헤더 설정 여부를 처리하는 맞춤 플러그인도 지정됩니다. 또한 이 플러그인은 콘텐츠 일부의 마지막으로 캐시된 버전을 전송할지 아니면 현재 요청에 대해 캐시된 버전이 저장되지 않은 경우에 오프라인 대체 페이지를 전송할지를 파악합니다.
  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 브라우저에서)을 읽고 일부 연결 유형에서 <html> 요소에 slow 클래스를 추가할 수 있습니다.

<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 또는 다른 종류의 중간/공개 캐시를 사용합니다.
  • public 지시어와 함께 0이 아닌 max-age 또는 s-maxage 지시어가 있는 Cache-Control 헤더를 지정했습니다.

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

  • 헤더, 콘텐츠, 바닥글 마크업을 포함하는 전체 응답입니다.
  • 콘텐츠만 포함된 부분 응답입니다.

서비스 워커가 CDN 캐시에서 전체 응답을 가져와 사전 캐시된 헤더 및 바닥글 마크업과 결합할 수 있기 때문에, 이로 인해 일부 원치 않는 동작이 발생하여 머리글 및 바닥글 마크업이 두 배로 늘어날 수 있습니다.

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

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

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

결과

대부분의 로드 시간 성능 관련 조언은 '얻은 것을 보여주세요'로 요약됩니다. 망설이지 말고 모든 것을 갖출 때까지 기다리지 말고 사용자에게 무언가를 보여주세요.

제이크 아치볼드의 재미있는 콘텐츠를 위한 재미있는 해킹

브라우저는 거대한 HTML 응답 본문의 경우에도 탐색 요청에 대한 응답을 처리하는 데 탁월합니다. 기본적으로 브라우저는 긴 작업을 피하는 청크로 마크업을 점진적으로 스트리밍하고 처리합니다. 이는 시작 성능에 도움이 됩니다.

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

  • 탐색 요청에 대한 응답의 첫 바이트가 즉시 실행되므로 첫 바이트까지의 시간 (TTFB)은 종종 크게 단축됩니다.
  • 콘텐츠가 포함된 첫 페인트 (FCP)매우 빠름입니다. 사전 캐시된 헤더 마크업에는 캐시된 스타일 시트에 대한 참조가 포함되어 있어 페이지가 매우 빠르게 페인트되기 때문입니다.
  • 경우에 따라 최대 콘텐츠 렌더링 시간 (LCP)도 더 빠를 수 있으며, 특히 사전 캐시된 헤더 부분에서 가장 큰 화면 요소를 제공하는 경우에는 더욱 그러합니다. 그렇더라도 더 작은 마크업 페이로드와 함께 최대한 빨리 서비스 워커 캐시에서 특정 항목을 제공하면 LCP가 향상될 수 있습니다.

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

더 좋은 점은 Workbox로 이러한 아키텍처를 직접 구현할 때보다 훨씬 쉽게 구현할 수 있다는 것입니다. 직접 운영하는 웹사이트에서 이 기능을 사용해 보고 현장에서 사용자가 멀티 페이지 웹사이트를 얼마나 빠르게 실행할 수 있는지 확인해 보세요.

자료