SPA 외 - PWA용 대체 아키텍처

건축에 대해 이야기해 볼까요?

중요하지만 오해의 소지가 있는 주제를 다루겠습니다. 웹 앱에 사용하는 아키텍처, 특히 프로그레시브 웹 앱을 빌드할 때 아키텍처 결정이 어떻게 작용하는지 살펴보겠습니다.

'아키텍처'는 모호하게 들릴 수 있으며 이것이 왜 중요한지 즉시 명확하지 않을 수 있습니다. 아키텍처를 생각하는 한 가지 방법은 다음과 같은 질문을 스스로에게 던지는 것입니다. 사용자가 내 사이트의 페이지를 방문할 때 어떤 HTML이 로드되는가? 그런 다음 다른 페이지를 방문하면 무엇이 로드되나요?

이러한 질문에 대한 답은 항상 간단하지는 않으며, 프로그레시브 웹 앱에 대해 생각하기 시작하면 훨씬 더 복잡해질 수 있습니다. 따라서 제 목표는 제가 효과적이라고 생각하는 한 가지 가능한 아키텍처를 안내하는 것입니다. 이 도움말 전체에서 프로그레시브 웹 앱을 빌드하는 '내 접근 방식'으로 내린 결정을 라벨로 지정하겠습니다.

자체 PWA를 빌드할 때 제 접근 방식을 자유롭게 사용할 수 있지만, 동시에 항상 다른 유효한 대안이 있습니다. 모든 요소가 어떻게 조화를 이루는지 확인하면서 영감을 얻고, 필요에 맞게 맞춤설정할 수 있다는 자신감을 얻으시기를 바랍니다.

Stack Overflow PWA

이 도움말과 함께 Stack Overflow PWA를 빌드했습니다. 저는 Stack Overflow를 읽고 참여하는 데 많은 시간을 보내고 있으며, 특정 주제에 대해 자주 묻는 질문을 쉽게 찾아볼 수 있는 웹 앱을 만들고 싶었습니다. 공개 Stack Exchange API를 기반으로 빌드됩니다. 오픈소스이며 GitHub 프로젝트를 방문하여 자세히 알아볼 수 있습니다.

다중 페이지 앱(MPA)

구체적인 내용을 살펴보기 전에 몇 가지 용어를 정의하고 기본 기술을 설명하겠습니다. 먼저, 제가 '멀티 페이지 앱' 또는 'MPA'라고 부르는 것에 대해 다루겠습니다.

MPA는 웹이 시작된 이래로 사용된 기존 아키텍처의 멋진 이름입니다. 사용자가 새 URL로 이동할 때마다 브라우저는 해당 페이지에 맞는 HTML을 점진적으로 렌더링합니다. 탐색 간에 페이지 상태나 콘텐츠를 보존하려는 시도는 없습니다. 새 페이지를 방문할 때마다 새로 시작합니다.

이는 사용자가 새 섹션을 방문할 때 브라우저가 JavaScript 코드를 실행하여 기존 페이지를 업데이트하는 웹 앱 빌드용 단일 페이지 앱(SPA) 모델과 대조됩니다. SPA와 MPA 모두 사용하기에 똑같이 유효한 모델이지만 이 게시물에서는 다중 페이지 앱의 컨텍스트 내에서 PWA 개념을 살펴보고자 했습니다.

안정적이고 빠른 속도

저를 비롯한 수많은 사람들이 '프로그레시브 웹 앱' 또는 PWA라는 표현을 사용하는 것을 들어보셨을 것입니다. 이 사이트의 다른 곳에서 일부 배경 자료를 이미 알고 있을 수 있습니다.

PWA는 최고 수준의 사용자 환경을 제공하고 사용자의 홈 화면에 자리 잡을 수 있는 웹 앱이라고 생각하면 됩니다. Fast(빠름), Integrated(통합), Reliable(안정적), Engaging(매력적)을 의미하는 약어 'FIRE'는 PWA를 빌드할 때 고려해야 할 모든 속성을 요약합니다.

이 도움말에서는 이러한 속성의 하위 집합인 빠른 모델과 신뢰할 수 있는 모델에 중점을 두겠습니다.

빠름: '빠름'은 상황에 따라 다른 의미를 갖지만, 여기서는 네트워크에서 최대한 적게 로드할 때의 속도 이점을 다루겠습니다.

신뢰성: 하지만 속도만으로는 충분하지 않습니다. PWA처럼 느껴지려면 웹 앱이 안정적이어야 합니다. 네트워크 상태와 관계없이 항상 무언가를 로드할 수 있을 만큼 탄력적이어야 합니다. 맞춤설정된 오류 페이지라도 로드할 수 있어야 합니다.

안정적인 속도: 마지막으로 PWA 정의를 약간 바꾸어 안정적인 속도를 구축한다는 것이 무엇을 의미하는지 살펴보겠습니다. 지연 시간이 짧은 네트워크에서만 빠르고 안정적인 것으로는 충분하지 않습니다. 안정적으로 빠르다는 것은 기본 네트워크 상태와 관계없이 웹 앱의 속도가 일정하다는 것을 의미합니다.

지원 기술: 서비스 워커 + 캐시 스토리지 API

PWA는 속도와 복원력에 대한 높은 기준을 제시합니다. 다행히 웹 플랫폼은 이러한 유형의 성능을 실현할 수 있는 몇 가지 구성 요소를 제공합니다. 바로 서비스 워커와 Cache Storage API입니다.

수신 요청을 수신 대기하고, 일부를 네트워크에 전달하고, 나중에 사용할 수 있도록 응답 사본을 저장하는 서비스 워커를 Cache Storage API를 통해 빌드할 수 있습니다.

Cache Storage API를 사용하여 네트워크 응답의 사본을 저장하는 서비스 워커

다음에 웹 앱이 동일한 요청을 하면 서비스 워커가 캐시를 확인하고 이전에 캐시된 응답을 반환할 수 있습니다.

Cache Storage API를 사용하여 응답하고 네트워크를 우회하는 서비스 워커

가능한 한 네트워크를 피하는 것은 안정적이고 빠른 성능을 제공하는 데 중요한 부분입니다.

'동형' 자바스크립트

다루고 싶은 개념이 하나 더 있는데, 바로 '동형' 또는 '범용' JavaScript라고도 하는 것입니다. 간단히 말해, 동일한 JavaScript 코드를 여러 런타임 환경 간에 공유할 수 있다는 개념입니다. PWA를 빌드할 때 백엔드 서버와 서비스 워커 간에 JavaScript 코드를 공유하고 싶었습니다.

이러한 방식으로 코드를 공유하는 데는 여러 가지 유효한 접근 방식이 있지만 제 접근 방식은 ES 모듈을 최종 소스 코드로 사용하는 것이었습니다. 그런 다음 Babel과 Rollup을 조합하여 서버와 서비스 워커를 위해 이러한 모듈을 트랜스파일하고 번들했습니다. 내 프로젝트에서 파일 확장자가 .mjs인 파일은 ES 모듈에 있는 코드입니다.

서버

이러한 개념과 용어를 염두에 두고 Stack Overflow PWA를 실제로 빌드한 방법을 살펴보겠습니다. 먼저 백엔드 서버를 다루고 전체 아키텍처에 어떻게 적용되는지 설명하겠습니다.

동적 백엔드와 정적 호스팅을 결합하는 방법을 찾고 있었는데, Firebase 플랫폼을 사용하는 것이 제 접근 방식이었습니다.

Firebase Cloud Functions는 수신 요청이 있을 때 Node 기반 환경을 자동으로 가동하고, 이미 익숙한 인기 있는 Express HTTP 프레임워크와 통합됩니다. 또한 모든 사이트의 정적 리소스에 대한 즉시 사용 가능한 호스팅을 제공합니다. 서버가 요청을 처리하는 방법을 살펴보겠습니다.

브라우저가 서버에 탐색 요청을 하면 다음 흐름을 거칩니다.

서버 측에서 탐색 응답을 생성하는 방법에 관한 개요입니다.

서버는 URL을 기반으로 요청을 라우팅하고 템플릿 로직을 사용하여 완전한 HTML 문서를 만듭니다. Stack Exchange API의 데이터와 서버가 로컬에 저장하는 부분 HTML 프래그먼트를 조합하여 사용합니다. 서비스 워커가 응답 방법을 알게 되면 HTML을 웹 앱으로 다시 스트리밍할 수 있습니다.

이 그림에서 더 자세히 살펴볼 가치가 있는 두 가지 요소는 라우팅과 템플릿입니다.

라우팅

라우팅의 경우 Express 프레임워크의 기본 라우팅 문법을 사용하는 방식을 취했습니다. 간단한 URL 프리픽스는 물론 경로의 일부로 파라미터를 포함하는 URL과 일치할 만큼 유연합니다. 여기에서는 일치시킬 기본 Express 패턴의 경로 이름 간에 매핑을 만듭니다.

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

그런 다음 서버 코드에서 이 매핑을 직접 참조할 수 있습니다. 지정된 Express 패턴과 일치하는 항목이 있으면 적절한 핸들러가 일치하는 경로와 관련된 템플릿 로직으로 응답합니다.

import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
  // Templating logic.
});

서버 측 템플릿

템플릿 로직은 어떤 모습일까요? 저는 부분적인 HTML 프래그먼트를 하나씩 차례로 연결하는 방식을 사용했습니다. 이 모델은 스트리밍에 적합합니다.

서버는 일부 초기 HTML 상용구를 즉시 다시 전송하고 브라우저는 해당 부분 페이지를 즉시 렌더링할 수 있습니다. 서버는 나머지 데이터 소스를 조합하여 문서가 완성될 때까지 브라우저로 스트리밍합니다.

무슨 뜻인지 이해하기 위해 경로 중 하나에 대한 Express 코드를 살펴보겠습니다.

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

response 객체의 write() 메서드를 사용하고 로컬에 저장된 부분 템플릿을 참조하면 외부 데이터 소스를 차단하지 않고도 응답 스트림을 즉시 시작할 수 있습니다. 브라우저는 이 초기 HTML을 가져와 의미 있는 인터페이스와 로드 메시지를 즉시 렌더링합니다.

페이지의 다음 부분에서는 Stack Exchange API의 데이터를 사용합니다. 이 데이터를 가져오려면 서버에서 네트워크 요청을 해야 합니다. 웹 앱은 응답을 받아 처리할 때까지 다른 항목을 렌더링할 수 없지만, 적어도 사용자는 기다리는 동안 빈 화면을 보지 않아도 됩니다.

웹 앱이 Stack Exchange API에서 응답을 받으면 맞춤 템플릿 함수를 호출하여 API의 데이터를 해당 HTML로 변환합니다.

템플릿 언어

템플릿은 놀라울 정도로 논쟁의 여지가 있는 주제일 수 있으며, 제가 선택한 것은 여러 접근 방식 중 하나일 뿐입니다. 특히 기존 템플릿 프레임워크에 대한 기존 연결이 있는 경우 자체 솔루션을 대체하는 것이 좋습니다.

제 사용 사례에 적합한 방법은 JavaScript의 템플릿 리터럴을 사용하고 일부 로직을 도우미 함수로 분리하는 것이었습니다. MPA를 빌드할 때의 장점 중 하나는 상태 업데이트를 추적하고 HTML을 다시 렌더링할 필요가 없다는 점입니다. 따라서 정적 HTML을 생성하는 기본 접근 방식이 효과적이었습니다.

다음은 웹 앱 색인의 동적 HTML 부분을 템플릿화하는 방법의 예입니다. 경로와 마찬가지로 템플릿 로직은 서버와 서비스 워커 모두로 가져올 수 있는 ES 모듈에 저장됩니다.

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

이러한 템플릿 함수는 순수 JavaScript이며, 적절한 경우 로직을 더 작은 도우미 함수로 분할하는 것이 유용합니다. 여기서는 API 응답에서 반환된 각 항목을 이러한 함수 중 하나에 전달합니다. 이 함수는 모든 적절한 속성이 설정된 표준 HTML 요소를 만듭니다.

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url="${questionUrl(id)}">${title}</a>`;
}

특히 주목할 점은 각 링크에 추가하는 데이터 속성인 data-cache-url가 해당 질문을 표시하는 데 필요한 Stack Exchange API URL로 설정된다는 것입니다. 이 점에 유의하시기 바랍니다. 나중에 다시 살펴보겠습니다.

경로 핸들러로 돌아가서 템플릿 작성이 완료되면 페이지 HTML의 마지막 부분을 브라우저로 스트리밍하고 스트림을 종료합니다. 이는 브라우저에 점진적 렌더링이 완료되었다는 신호입니다.

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

지금까지 제 서버 설정을 간단히 살펴보았습니다. 내 웹 앱을 처음 방문하는 사용자는 항상 서버로부터 응답을 받지만, 방문자가 내 웹 앱으로 돌아오면 내 서비스 워커가 응답을 시작합니다. 자세히 살펴보겠습니다.

서비스 워커

서비스 워커에서 탐색 응답을 생성하는 방법에 관한 개요입니다.

이 다이어그램은 익숙할 것입니다. 이전에 다룬 많은 부분이 약간 다른 배열로 여기에 있습니다. 서비스 워커를 고려하여 요청 흐름을 살펴보겠습니다.

서비스 워커는 특정 URL에 대한 수신 탐색 요청을 처리하며, 서버와 마찬가지로 라우팅 및 템플릿 로직을 조합하여 응답 방법을 파악합니다.

접근 방식은 이전과 동일하지만 fetch() 및 Cache Storage API와 같은 다른 하위 수준 프리미티브를 사용합니다. 이러한 데이터 소스를 사용하여 HTML 응답을 구성하고, 서비스 워커는 이를 웹 앱에 다시 전달합니다.

Workbox

하위 수준 프리미티브를 사용하여 처음부터 시작하는 대신 Workbox라는 상위 수준 라이브러리 집합을 기반으로 서비스 워커를 빌드하겠습니다. 이는 모든 서비스 워커의 캐싱, 라우팅, 응답 생성 로직을 위한 견고한 기반을 제공합니다.

라우팅

서버 측 코드와 마찬가지로 서비스 워커는 수신 요청을 적절한 응답 로직과 일치시키는 방법을 알아야 합니다.

제 접근 방식은 regexparam라는 유용한 라이브러리를 사용하여 각 Express 경로를 상응하는 정규 표현식으로 변환하는 것이었습니다. 변환이 수행되면 Workbox의 기본 제공 정규 표현식 라우팅 지원을 활용할 수 있습니다.

정규 표현식이 있는 모듈을 가져온 후 각 정규 표현식을 Workbox의 라우터에 등록합니다. 각 경로 내에서 맞춤 템플릿 로직을 제공하여 응답을 생성할 수 있습니다. 서비스 워커의 템플릿은 백엔드 서버보다 약간 더 복잡하지만 Workbox는 많은 작업을 처리하는 데 도움이 됩니다.

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

정적 애셋 캐싱

템플릿 스토리의 핵심 부분은 부분 HTML 템플릿이 Cache Storage API를 통해 로컬에서 사용 가능하고 웹 앱에 변경사항을 배포할 때 최신 상태로 유지되도록 하는 것입니다. 캐시 유지관리는 수동으로 수행할 때 오류가 발생하기 쉬우므로 Workbox를 사용하여 빌드 프로세스의 일부로 사전 캐싱을 처리합니다.

구성 파일을 사용하여 Workbox에 미리 캐시할 URL을 알려주고, 일치시킬 패턴 집합과 함께 모든 로컬 애셋이 포함된 디렉터리를 가리킵니다. 이 파일은 사이트를 다시 빌드할 때마다 실행되는 Workbox의 CLI에서 자동으로 읽습니다.

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

Workbox는 각 파일 콘텐츠의 스냅샷을 생성하고 URL 및 버전 목록을 최종 서비스 워커 파일에 자동으로 삽입합니다. 이제 Workbox에는 사전 캐시된 파일을 항상 사용할 수 있고 최신 상태로 유지하는 데 필요한 모든 것이 있습니다. 결과는 다음과 유사한 내용이 포함된 service-worker.js 파일입니다.

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

더 복잡한 빌드 프로세스를 사용하는 사용자를 위해 Workbox에는 명령줄 인터페이스 외에도 webpack 플러그인과 일반 노드 모듈이 모두 있습니다.

스트리밍

다음으로 서비스 워커가 미리 캐시된 부분 HTML을 웹 앱으로 즉시 다시 스트리밍하도록 합니다. 이는 '안정적인 속도'를 유지하는 데 중요한 부분입니다. 항상 의미 있는 콘텐츠가 화면에 즉시 표시됩니다. 다행히 서비스 워커 내에서 Streams API를 사용하면 이 작업이 가능합니다.

Streams API에 대해 들어보신 적이 있을 것입니다. 제 동료인 Jake Archibald는 수년 동안 이 기능을 극찬해 왔습니다. 그는 2016년이 웹 스트림의 해가 될 것이라는 대담한 예측을 했습니다. Streams API는 2년 전과 마찬가지로 지금도 훌륭하지만, 한 가지 중요한 차이점이 있습니다.

당시에는 Chrome만 스트림을 지원했지만, 이제는 Streams API가 더 널리 지원됩니다. 전반적으로 긍정적인 상황이며, 적절한 대체 코드를 사용하면 지금 바로 서비스 워커에서 스트림을 사용할 수 있습니다.

하지만 Streams API가 실제로 어떻게 작동하는지 이해하는 데 어려움이 있을 수 있습니다. 매우 강력한 기본 요소 집합을 노출하며, 이를 편안하게 사용하는 개발자는 다음과 같은 복잡한 데이터 흐름을 만들 수 있습니다.

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

하지만 이 코드가 미치는 영향을 완전히 이해하는 것은 쉽지 않을 수 있습니다. 이 로직을 파싱하는 대신 서비스 워커 스트리밍에 대한 제 접근 방식에 관해 이야기해 보겠습니다.

새로운 상위 수준 래퍼인 workbox-streams를 사용하고 있습니다. 이를 통해 캐시와 네트워크에서 가져올 수 있는 런타임 데이터 모두에서 스트리밍 소스를 혼합하여 전달할 수 있습니다. Workbox는 개별 소스를 조정하고 이를 단일 스트리밍 응답으로 결합합니다.

또한 Workbox는 Streams API가 지원되는지 여부를 자동으로 감지하고, 지원되지 않는 경우 이에 상응하는 비스트리밍 응답을 만듭니다. 즉, 스트림이 브라우저 지원 100% 에 가까워지므로 대체 작성에 대해 걱정할 필요가 없습니다.

런타임 캐싱

서비스 워커가 Stack Exchange API의 런타임 데이터를 처리하는 방법을 살펴보겠습니다. 만료와 함께 stale-while-revalidate 캐싱 전략에 대한 Workbox의 기본 제공 지원을 사용하여 웹 앱의 스토리지가 무제한으로 증가하지 않도록 합니다.

스트리밍 응답을 구성하는 다양한 소스를 처리하기 위해 Workbox에서 두 가지 전략을 설정했습니다. 몇 가지 함수 호출과 구성만으로 Workbox를 사용하면 수백 줄의 수기 코드가 필요한 작업을 수행할 수 있습니다.

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

첫 번째 전략은 부분 HTML 템플릿과 같이 미리 캐시된 데이터를 읽습니다.

다른 전략은 stale-while-revalidate 캐싱 로직을 구현하며, 항목이 50개에 도달하면 가장 오랫동안 사용되지 않은 캐시가 만료됩니다.

이제 이러한 전략을 마련했으므로 남은 것은 Workbox에 이를 사용하여 완전한 스트리밍 응답을 구성하는 방법을 알려주는 것입니다. 소스 배열을 함수로 전달하면 각 함수가 즉시 실행됩니다. Workbox는 각 소스의 결과를 가져와 순서대로 웹 앱으로 스트리밍하며, 배열의 다음 함수가 아직 완료되지 않은 경우에만 지연됩니다.

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'}),
  () => cacheStrategy.makeRequest({request: '/navbar.html'}),
  async ({event, url}) => {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, data.items);
  },
  () => cacheStrategy.makeRequest({request: '/foot.html'}),
]);

처음 두 소스는 Cache Storage API에서 직접 읽은 사전 캐시된 부분 템플릿이므로 항상 즉시 사용할 수 있습니다. 이렇게 하면 서비스 워커 구현이 서버 측 코드와 마찬가지로 요청에 안정적으로 빠르게 응답할 수 있습니다.

다음 소스 함수는 Stack Exchange API에서 데이터를 가져오고 응답을 웹 앱에서 예상하는 HTML로 처리합니다.

stale-while-revalidate 전략은 이 API 호출에 대해 이전에 캐시된 응답이 있는 경우 이를 페이지에 즉시 스트리밍할 수 있으며, 다음에 요청될 때를 위해 '백그라운드에서' 캐시 항목을 업데이트할 수 있음을 의미합니다.

마지막으로 바닥글의 캐시된 사본을 스트리밍하고 최종 HTML 태그를 닫아 응답을 완료합니다.

공유 코드로 동기화 유지

서비스 워커 코드의 특정 부분이 익숙해 보일 것입니다. 서비스 워커에서 사용하는 부분 HTML 및 템플릿 로직은 서버 측 핸들러에서 사용하는 것과 동일합니다. 이 코드 공유를 통해 사용자는 웹 앱을 처음 방문하든 서비스 워커가 렌더링한 페이지로 돌아가든 일관된 환경을 이용할 수 있습니다. 이것이 바로 동형 JavaScript의 장점입니다.

동적, 점진적 개선

PWA의 서버와 서비스 워커를 모두 살펴봤지만 마지막으로 다룰 로직이 하나 있습니다. 각 페이지가 완전히 스트리밍된 후 실행되는 소량의 JavaScript가 있습니다.

이 코드는 사용자 환경을 점진적으로 개선하지만 중요하지는 않습니다. 실행되지 않아도 웹 앱은 계속 작동합니다.

페이지 메타데이터

내 앱은 클라이언트 측 JavaScript를 사용하여 API 응답을 기반으로 페이지의 메타데이터를 업데이트합니다. 각 페이지에 동일한 초기 캐시 HTML 비트를 사용하므로 웹 앱은 문서 헤드에 일반 태그를 사용하게 됩니다. 하지만 템플릿과 클라이언트 측 코드 간의 조정을 통해 페이지별 메타데이터를 사용하여 창 제목을 업데이트할 수 있습니다.

템플릿 코드의 일부로, 적절하게 이스케이프 처리된 문자열이 포함된 스크립트 태그를 포함하는 방식을 사용합니다.

const metadataScript = `<script>
  self._title = '${escape(item.title)}';
</script>`;

그런 다음 페이지가 로드되면 해당 문자열을 읽고 문서 제목을 업데이트합니다.

if (self._title) {
  document.title = unescape(self._title);
}

자체 웹 앱에서 업데이트하려는 다른 페이지별 메타데이터가 있는 경우 동일한 접근 방식을 따를 수 있습니다.

오프라인 UX

제가 추가한 또 다른 점진적 개선은 오프라인 기능에 대한 관심을 유도하는 데 사용됩니다. 안정적인 PWA를 빌드했으며, 사용자가 오프라인 상태에서도 이전에 방문한 페이지를 로드할 수 있다는 사실을 알리고 싶습니다.

먼저 Cache Storage API를 사용하여 이전에 캐시된 모든 API 요청의 목록을 가져오고 이를 URL 목록으로 변환합니다.

앞서 설명한 특별한 데이터 속성을 기억하시나요? 각 속성에는 질문을 표시하는 데 필요한 API 요청의 URL이 포함되어 있습니다. 이러한 데이터 속성을 캐시된 URL 목록과 교차 참조하고 일치하지 않는 모든 질문 링크의 배열을 만들 수 있습니다.

브라우저가 오프라인 상태가 되면 캐시되지 않은 링크 목록을 반복하고 작동하지 않는 링크를 어둡게 표시합니다. 이는 해당 페이지에서 예상되는 사항에 대한 사용자에게 시각적인 힌트일 뿐이며, 실제로 링크를 사용 중지하거나 사용자가 탐색하지 못하도록 하는 것은 아닙니다.

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

일반적인 문제

지금까지 다중 페이지 PWA를 빌드하는 접근 방식을 살펴보았습니다. 자체 접근 방식을 고안할 때 고려해야 할 요소가 많으며, 저와 다른 선택을 할 수도 있습니다. 이러한 유연성은 웹용으로 빌드할 때의 장점 중 하나입니다.

자체 아키텍처를 결정할 때 발생할 수 있는 몇 가지 일반적인 문제가 있으며, 이러한 문제를 방지하는 데 도움이 되고자 합니다.

전체 HTML을 캐시하지 않음

캐시에 전체 HTML 문서를 저장하지 않는 것이 좋습니다. 우선 공간 낭비입니다. 웹 앱이 각 페이지에 동일한 기본 HTML 구조를 사용하는 경우 동일한 마크업의 사본을 계속해서 저장하게 됩니다.

더 중요한 것은 사이트의 공유 HTML 구조에 변경사항을 배포하면 이전에 캐시된 모든 페이지가 여전히 이전 레이아웃에 고정된다는 것입니다. 재방문자가 이전 페이지와 새 페이지가 혼합된 것을 볼 때의 불만을 상상해 보세요.

서버/서비스 워커 드리프트

피해야 할 또 다른 문제는 서버와 서비스 워커가 동기화되지 않는 것입니다. 제 접근 방식은 동형 JavaScript를 사용하여 두 위치에서 동일한 코드를 실행하는 것이었습니다. 기존 서버 아키텍처에 따라서는 이것이 불가능할 수도 있습니다

어떤 아키텍처를 결정하든 서버와 서비스 워커에서 동등한 라우팅 및 템플릿 코드를 실행하기 위한 전략이 있어야 합니다.

최악의 시나리오

일관되지 않은 레이아웃/디자인

이러한 문제를 무시하면 어떻게 될까요? 모든 종류의 오류가 발생할 수 있지만, 최악의 시나리오는 재방문자가 오래된 레이아웃(예: 오래된 헤더 텍스트가 있거나 더 이상 유효하지 않은 CSS 클래스 이름을 사용하는 레이아웃)이 있는 캐시된 페이지를 방문하는 것입니다.

최악의 시나리오: 라우팅 중단

또는 사용자가 서비스 워커가 아닌 서버에서 처리하는 URL을 발견할 수도 있습니다. 좀비 레이아웃과 막다른 골목으로 가득한 사이트는 신뢰할 수 있는 PWA가 아닙니다.

성공을 위한 도움말

하지만 혼자서 이 모든 것을 할 필요는 없습니다. 다음 도움말은 이러한 문제를 방지하는 데 도움이 될 수 있습니다.

다국어 구현이 있는 템플릿 및 라우팅 라이브러리 사용

JavaScript 구현이 있는 템플릿 및 라우팅 라이브러리를 사용해 보세요. 모든 개발자가 현재 웹 서버와 템플릿 언어에서 마이그레이션할 수 있는 여유가 있는 것은 아닙니다.

하지만 많이 사용되는 템플릿 및 라우팅 프레임워크는 여러 언어로 구현되어 있습니다. 자바스크립트와 현재 서버의 언어에서 모두 작동하는 템플릿 엔진을 찾을 수 있다면 서비스 워커와 서버를 동기화하는 데 한 걸음 더 가까워집니다.

중첩된 템플릿보다는 순차적인 템플릿을 선호함

다음으로, 순차적으로 스트리밍할 수 있는 일련의 순차적 템플릿을 사용하는 것이 좋습니다. HTML의 초기 부분을 최대한 빨리 스트리밍할 수 있다면 페이지의 뒷부분에서 더 복잡한 템플릿 로직을 사용해도 괜찮습니다.

서비스 워커에서 정적 및 동적 콘텐츠 캐시

최상의 성능을 위해서는 사이트의 모든 중요한 정적 리소스를 미리 캐시해야 합니다. 또한 API 요청과 같은 동적 콘텐츠를 처리하기 위해 런타임 캐싱 로직을 설정해야 합니다. Workbox를 사용하면 처음부터 모든 것을 구현하는 대신 충분히 테스트되고 프로덕션에 즉시 사용 가능한 전략을 기반으로 빌드할 수 있습니다.

꼭 필요한 경우에만 네트워크에서 차단

이와 관련하여 캐시에서 응답을 스트리밍할 수 없는 경우에만 네트워크에서 차단해야 합니다. 캐시된 API 응답을 즉시 표시하면 새로운 데이터를 기다리는 것보다 더 나은 사용자 환경을 제공할 수 있습니다.

리소스