프로그레시브 웹 앱을 점진적으로 개선하기

최신 브라우저용으로 빌드하고 2003년처럼 점진적으로 개선

2003년 3월에 Nick FinckSteve Champeon점진적 개선이라는 개념으로 웹 디자인계에 큰 놀라움을 선사했습니다. 점진적 개선은 핵심 웹페이지 콘텐츠를 먼저 로드하는 것을 강조한 다음 콘텐츠 위에 보다 미묘하고 기술적으로 엄격한 표시 및 기능을 점진적으로 추가하는 전략입니다. 하지만 2003년에는 점진적인 개선을 위해 당시에는 최신 CSS 기능, 눈에 거슬리지 않는 JavaScript, 심지어 Scalable Vector Graphics를 사용해야만 했습니다. 2020년 이후의 점진적인 개선은 최신 브라우저 기능의 사용에 관한 것입니다.

점진적인 개선을 통해 미래에 대비하는 포용적인 웹 디자인 Finck와 Champeon의 원본 프레젠테이션의 제목 슬라이드입니다.
슬라이드: 점진적 개선을 통한 미래 지향적인 포용적 웹 디자인 (출처)

최신 자바스크립트

JavaScript의 경우 최신 핵심 ES 2015 JavaScript 기능에 대한 브라우저 지원 상황은 매우 좋습니다. 새 표준에는 프로미스, 모듈, 클래스, 템플릿 리터럴, 화살표 함수, letconst, 기본 매개변수, 생성기, 디스트럭처링 할당, REST 및 스프레드, Map/Set, WeakMap/WeakSet 등이 포함됩니다. 모두 지원됩니다.

모든 주요 브라우저에서 지원되는 ES6 기능에 관한 CanIUse 지원 표
ECMAScript 2015 (ES6) 브라우저 지원 표입니다. (출처)

비동기 함수는 ES 2017 기능이자 개인적으로 가장 좋아하는 기능 중 하나이며, 모든 주요 브라우저에서 사용할 수 있습니다. asyncawait 키워드를 사용하면 비동기 프로미스 기반 동작을 더 깔끔한 스타일로 작성할 수 있으므로 프로미스 체인을 명시적으로 구성할 필요가 없습니다.

모든 주요 브라우저에서 지원되는 비동기 함수를 위한 CanIUse 지원 표
비동기 함수 브라우저 지원 표입니다. (출처)

또한 선택적 체이닝nullish 합병과 같은 최근 ES 2020 언어 추가 기능도 매우 빠르게 지원되었습니다. 아래에서 코드 샘플을 확인할 수 있습니다. 핵심 JavaScript 기능의 경우 잔디가 지금보다 더 친환경적일 수는 없습니다.

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah',
  },
};
console.log(adventurer.dog?.name);
// Expected output: undefined
console.log(0 ?? 42);
// Expected output: 0
상징적인 Windows XP 녹색 잔디 배경 이미지입니다.
핵심 JavaScript 기능은 녹색입니다. (권한과 함께 사용되는 Microsoft 제품 스크린샷)

샘플 앱: Fugu Greetings

이 문서에서는 Fugu Greetings(GitHub)라는 간단한 PWA를 사용합니다. 이 앱의 이름은 웹에 Android/iOS/데스크톱 애플리케이션의 모든 기능을 부여하려는 노력의 일환으로 Project Fugu 🐡를 만들었습니다. 프로젝트에 대한 자세한 내용은 방문 페이지에서 확인할 수 있습니다.

Fugu Greetings는 가상 인사말을 만들어 소중한 사람에게 보낼 수 있는 그리기 앱입니다. PWA의 핵심 개념을 보여줍니다. 안정적이며 완전히 오프라인으로 사용 설정되므로 네트워크가 없어도 계속 사용할 수 있습니다. 또한 기기의 홈 화면에 설치할 수 있으며 독립형 애플리케이션으로 운영체제와 원활하게 통합됩니다.

PWA 커뮤니티 로고와 유사한 그림이 표시된 Fugu 인사말 PWA
Fugu Greetings 샘플 앱

점진적 개선

이제 점진적 개선에 대해 이야기해 보겠습니다. MDN 웹 문서 용어집에서는 개념을 다음과 같이 정의합니다.

점진적인 개선은 가능한 한 많은 사용자에게 필수 콘텐츠와 기능의 기준을 제공하는 동시에 필요한 모든 코드를 실행할 수 있는 최신 브라우저 사용자에게만 가능한 최상의 환경을 제공하는 디자인 철학입니다.

기능 감지는 일반적으로 브라우저가 최신 기능을 처리할 수 있는지 여부를 확인하는 데 사용되는 반면 polyfill은 JavaScript로 누락된 기능을 추가하는 데 자주 사용됩니다.

[…]

점진적 개선은 웹 개발자가 여러 알 수 없는 사용자 에이전트에서 작동하는 동시에 최상의 웹사이트를 개발하는 데 집중할 수 있는 유용한 기법입니다. 단계적 성능 저하는 관련이 있지만 동일한 것은 아니며 점진적 개선과 반대 방향으로 진행되는 경우가 많습니다. 실제로 두 접근 방식 모두 유효하며 종종 서로를 보완할 수 있습니다.

MDN 기여자

카드를 처음부터 새로 시작하는 것은 매우 번거로울 수 있습니다. 그렇다면 사용자가 이미지를 가져오고 거기서부터 시작할 수 있는 기능은 어떨까요? 기존 접근 방식에서는 <input type=file> 요소를 사용하여 이를 구현했을 것입니다. 먼저 요소를 만들고 type'file'로 설정하고 MIME 유형을 accept 속성에 추가한 다음 프로그래매틱 방식으로 요소를 '클릭'하여 변경사항을 수신 대기합니다. 이미지를 선택하면 캔버스로 바로 가져옵니다.

const importImage = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

import 기능이 있는 경우 사용자가 인사말 카드를 로컬에 저장할 수 있도록 import 기능이 있어야 할 수 있습니다. 파일을 저장하는 일반적인 방법은 download 속성이 있고 blob URL을 href로 사용하여 앵커 링크를 만드는 것입니다. 또한 프로그래매틱 방식으로 '클릭'하여 다운로드를 트리거하고 메모리 누수를 방지하기 위해 blob 객체 URL을 취소하는 것을 잊지 마세요.

const exportImage = async (blob) => {
  const a = document.createElement('a');
  a.download = 'fugu-greeting.png';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

하지만 잠깐만요. 즉, 카드를 '다운로드'한 것이 아니라 '저장'한 것입니다. 파일을 저장할 위치를 선택할 수 있는 '저장' 대화상자가 표시되지 않고 브라우저에서 사용자 상호작용 없이 바로 인사말 카드를 다운로드하고 다운로드 폴더에 바로 넣었습니다. 좋지 않네요.

더 나은 방법이 있다면 어떨까요? 로컬 파일을 열어 수정한 다음 수정사항을 새 파일로 저장하거나 처음에 열었던 원래 파일로 다시 저장할 수 있다면 어떨까요? 그럴 수 있습니다. File System Access API를 사용하면 파일과 디렉터리를 열고 만들고 수정하고 저장할 수 있습니다 .

API를 특성 감지하려면 어떻게 해야 할까요? File System Access API는 새 메서드 window.chooseFileSystemEntries()를 노출합니다. 따라서 이 방법을 사용할 수 있는지에 따라 다른 가져오기 및 내보내기 모듈을 조건부로 로드해야 합니다. 방법은 아래에 나와 있습니다.

const loadImportAndExport = () => {
  if ('chooseFileSystemEntries' in window) {
    Promise.all([
      import('./import_image.mjs'),
      import('./export_image.mjs'),
    ]);
  } else {
    Promise.all([
      import('./import_image_legacy.mjs'),
      import('./export_image_legacy.mjs'),
    ]);
  }
};

하지만 File System Access API에 관해 자세히 알아보기 전에 점진적 개선 패턴을 간단히 살펴보겠습니다. 현재 File System Access API를 지원하지 않는 브라우저에서 기존 스크립트를 로드합니다. 아래에서 Firefox와 Safari의 네트워크 탭을 볼 수 있습니다.

로드되는 기존 파일을 보여주는 Safari Web Inspector
Safari Web Inspector 네트워크 탭
기존 파일이 로드되는 모습을 보여주는 Firefox 개발자 도구
Firefox 개발자 도구의 네트워크 탭입니다.

하지만 API를 지원하는 브라우저인 Chrome에서는 새 스크립트만 로드됩니다. 이는 모든 최신 브라우저가 지원하는 동적 import() 덕분에 원활하게 가능합니다. 앞서 말했듯이, 요즘에는 잔디가 꽤 푸른하네요.

최신 파일이 로드되는 모습을 보여주는 Chrome DevTools
Chrome DevTools 네트워크 탭입니다.

File System Access API입니다.

지금까지 이 문제를 해결했으므로 File System Access API를 기반으로 하는 실제 구현을 살펴보겠습니다. 이미지를 가져오기 위해 window.chooseFileSystemEntries()를 호출하고 이미지 파일을 원한다고 하는 accepts 속성에 전달합니다. 파일 확장자와 MIME 유형이 모두 지원됩니다. 그러면 파일 핸들이 생성되며 getFile()를 호출하여 실제 파일을 가져올 수 있습니다.

const importImage = async () => {
  try {
    const handle = await window.chooseFileSystemEntries({
      accepts: [
        {
          description: 'Image files',
          mimeTypes: ['image/*'],
          extensions: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
        },
      ],
    });
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

이미지를 내보내는 방법은 거의 동일하지만 이번에는 유형 매개변수 'save-file'chooseFileSystemEntries() 메서드에 전달해야 합니다. 여기에서 파일 저장 대화상자가 표시됩니다. 파일을 연 경우에는 'open-file'가 기본값이므로 이 작업이 필요하지 않습니다. accepts 매개변수를 이전과 비슷하게 설정하지만 이번에는 PNG 이미지로만 제한됩니다. 다시 파일 핸들을 가져오지만 파일을 가져오는 대신 이번에는 createWritable()를 호출하여 쓰기 가능한 스트림을 만듭니다. 그런 다음 내 연하장 이미지인 blob을 파일에 작성합니다. 마지막으로 쓰기 가능한 스트림을 닫습니다.

모든 것이 항상 실패할 수 있습니다. 디스크 공간이 부족하거나 쓰기 또는 읽기 오류가 있거나 단순히 사용자가 파일 대화상자를 취소할 수 있습니다. 이러한 이유로 항상 try...catch 문으로 호출을 래핑합니다.

const exportImage = async (blob) => {
  try {
    const handle = await window.chooseFileSystemEntries({
      type: 'save-file',
      accepts: [
        {
          description: 'Image file',
          extensions: ['png'],
          mimeTypes: ['image/png'],
        },
      ],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

File System Access API로 점진적 개선을 사용하면 이전처럼 파일을 열 수 있습니다 가져온 파일은 캔버스에 바로 그려집니다. 수정한 다음 파일의 이름과 저장소 위치를 선택할 수 있는 실제 저장 대화상자를 사용하여 저장할 수 있습니다. 이제 파일을 영원히 보존할 준비가 되었습니다.

파일 열기 대화상자가 있는 Fugu 인사말 앱
파일 열기 대화상자입니다.
이제 Fugu Greetings 앱에 가져온 이미지가 추가됩니다.
가져온 이미지
수정된 이미지가 적용된 Fugu Greetings 앱
수정된 이미지를 새 파일에 저장합니다.

Web Share 및 Web Share Target API

영원히 저장하는 것 외에는 나는 카드를 공유하고 싶을지도 모릅니다. Web Share APIWeb Share Target API를 통해 이 작업을 수행할 수 있습니다. 모바일, 그리고 최근의 데스크톱 운영체제에는 공유 메커니즘이 내장되어 있습니다. 예를 들어 다음은 제 블로그의 기사에서 트리거된 macOS의 데스크톱 Safari 공유 시트입니다. Share Article 버튼을 클릭하면 예를 들어 macOS Messages 앱을 통해 기사 링크를 친구와 공유할 수 있습니다.

기사의 공유 버튼으로 트리거된 macOS의 데스크톱 Safari 공유 시트
macOS용 데스크톱 Safari의 Web Share API

이를 위한 코드는 매우 간단합니다. navigator.share()를 호출하고 객체에 title, text, url(선택사항)을 전달합니다. 하지만 이미지를 첨부하고 싶다면 어떻게 해야 하나요? Web Share API의 레벨 1에서는 아직 이 기능을 지원하지 않습니다. 다행히 Web Share Level 2에 파일 공유 기능이 추가되었습니다.

try {
  await navigator.share({
    title: 'Check out this article:',
    text: `"${document.title}" by @tomayac:`,
    url: document.querySelector('link[rel=canonical]').href,
  });
} catch (err) {
  console.warn(err.name, err.message);
}

Fugu 인사말 카드 애플리케이션으로 이 작업을 수행하는 방법을 보여드리겠습니다. 먼저 blob 한 개로 구성된 files 배열과 titletextdata 객체를 준비해야 합니다. 다음으로, 이름에서 알 수 있는 대로 새로운 navigator.canShare() 메서드를 사용하는 것이 좋습니다. 이 메서드는 공유하려는 data 객체를 브라우저에서 기술적으로 공유할 수 있는지 알려줍니다. navigator.canShare()에서 데이터를 공유할 수 있음을 알리면 이전과 같이 navigator.share()를 호출할 준비가 된 것입니다. 모든 것이 실패할 수 있으므로 다시 try...catch 블록을 사용합니다.

const share = async (title, text, blob) => {
  const data = {
    files: [
      new File([blob], 'fugu-greeting.png', {
        type: blob.type,
      }),
    ],
    title: title,
    text: text,
  };
  try {
    if (!(navigator.canShare(data))) {
      throw new Error("Can't share data.", data);
    }
    await navigator.share(data);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

이전과 마찬가지로 점진적 개선을 사용합니다. 'share''canShare'가 모두 navigator 객체에 있는 경우에만 계속 진행하여 동적 import()를 통해 share.mjs를 로드합니다. 두 조건 중 하나만 충족하는 모바일 Safari 같은 브라우저에서는 기능을 로드하지 않습니다.

const loadShare = () => {
  if ('share' in navigator && 'canShare' in navigator) {
    import('./share.mjs');
  }
};

Fugu Greetings에서 Android용 Chrome과 같은 지원 브라우저에서 공유 버튼을 탭하면 내장된 공유 시트가 열립니다. 예를 들어 Gmail을 선택하면 이메일 작성 도구 위젯이 이미지와 함께 표시됩니다.

이미지를 공유할 다양한 앱을 보여주는 OS 수준 공유 시트
파일을 공유할 앱을 선택합니다.
이미지가 첨부된 Gmail의 이메일 작성 위젯입니다.
파일은 Gmail 작성기의 새 이메일에 첨부됩니다.

연락처 선택 도구 API

다음으로 기기의 주소록이나 연락처 관리자 앱을 의미하는 연락처에 대해 이야기하겠습니다. 연하장을 작성할 때 사람 이름을 정확하게 쓰는 것이 어려울 수도 있습니다. 예를 들어, 키릴 문자 철자를 선호하는 친구 Sergey가 있습니다. 독일어 QWERTZ 키보드를 사용하고 있고 이름을 입력하는 방법을 모릅니다. 이 문제는 Contact Picker API로 해결할 수 있습니다. 내 휴대전화의 연락처 앱에 친구가 저장되어 있으므로 Contacts Picker API를 통해 웹에서 연락처를 활용할 수 있습니다.

먼저 액세스하려는 속성 목록을 지정해야 합니다. 이 경우에는 이름만 필요하지만 다른 사용 사례에서는 전화번호, 이메일, 아바타 아이콘, 실제 주소가 유용할 수 있습니다. 이제 두 개 이상의 항목을 선택할 수 있도록 options 객체를 구성하고 multipletrue로 설정합니다. 마지막으로 navigator.contacts.select()를 호출하여 사용자가 선택한 연락처에 대해 원하는 속성을 반환합니다.

const getContacts = async () => {
  const properties = ['name'];
  const options = { multiple: true };
  try {
    return await navigator.contacts.select(properties, options);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

이제 API가 실제로 지원되는 경우에만 파일을 로드한다는 패턴을 배웠을 것입니다

if ('contacts' in navigator) {
  import('./contacts.mjs');
}

Fugu 인사말에서 연락처 버튼을 탭하고 가장 친한 친구 두 명인 сер\tей 센터를 선택했을 때, 劳伦조정·爱둔 챌린지·"拉里"·佩奇에서는 연락처 정보가 전화번호나 다른 주소 등만 표시되는 방법을 확인할 수 있습니다. 연락처 선택 도구에는 전화번호와 같은 정보만 표시됩니다. 그러면 친구의 이름이 제 연하장에 그려집니다.

주소록에 있는 두 연락처의 이름을 보여주는 연락처 선택도구
주소록에서 연락처 선택 도구로 두 이름을 선택합니다.
이전에 선택한 두 연락처의 이름이 인사말 카드에 그려져 있습니다.
그러면 두 이름이 인사말 카드에 그려집니다.

비동기 Clipboard API

다음 단계는 복사하여 붙여넣기입니다. 소프트웨어 개발자가 가장 좋아하는 작업 중 하나는 복사하여 붙여넣기입니다. 카드 작성자로서, 때때로 그렇게 하고 싶을 때가 있습니다. 작업 중인 연하장에 이미지를 붙여넣거나 카드를 복사하여 다른 곳에서 계속 수정할 수 있습니다. Async Clipboard API는 텍스트와 이미지를 모두 지원합니다. Fugu 인사말 앱에 복사 및 붙여넣기 지원을 어떻게 추가했는지 살펴보겠습니다.

시스템의 클립보드에 무언가를 복사하려면 여기에 써야 합니다. navigator.clipboard.write() 메서드는 클립보드 항목의 배열을 매개변수로 사용합니다. 각 클립보드 항목은 기본적으로 blob이 값으로, blob 유형이 키로 지정된 객체입니다.

const copy = async (blob) => {
  try {
    await navigator.clipboard.write([
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

붙여넣으려면 navigator.clipboard.read()를 호출하여 가져온 클립보드 항목을 반복해야 합니다. 그 이유는 여러 클립보드 항목이 클립보드에 서로 다른 표현으로 있을 수 있기 때문입니다. 각 클립보드 항목에는 사용 가능한 리소스의 MIME 유형을 알려주는 types 필드가 있습니다. 클립보드 항목의 getType() 메서드를 호출하여 이전에 가져온 MIME 유형을 전달합니다.

const paste = async () => {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      try {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          return blob;
        }
      } catch (err) {
        console.error(err.name, err.message);
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
};

그리고 지금 당장은 말할 필요도 없습니다. 지원되는 브라우저에서만 사용합니다.

if ('clipboard' in navigator && 'write' in navigator.clipboard) {
  import('./clipboard.mjs');
}

그렇다면 이는 실제로 어떻게 작동할까요? macOS Preview 앱에서 이미지를 열고 클립보드로 복사합니다. 붙여넣기를 클릭하면 Fugu Greetings 앱에서 클립보드에 있는 텍스트와 이미지를 보도록 허용할 것인지 묻습니다.

클립보드 권한 메시지가 표시된 Fugu Greetings 앱
클립보드 권한 메시지

마지막으로 권한을 수락한 후에는 이미지를 애플리케이션에 붙여넣습니다. 반대로 카드를 클립보드에 복사하겠습니다. 그런 다음 미리보기를 열고 파일클립보드에서 새로 만들기를 차례로 클릭하면 인사말 카드가 제목이 없는 새 이미지에 붙여 넣어집니다.

제목 없이 방금 붙여넣은 이미지가 있는 macOS 미리보기 앱
macOS 미리보기 앱에 붙여넣은 이미지입니다.

Badging API

또 다른 유용한 API는 Badging API입니다. 설치 가능한 PWA로서 Fugu Greetings에는 사용자가 앱 도크 또는 홈 화면에 배치할 수 있는 앱 아이콘이 있습니다. 이 API를 쉽고 재미있게 설명하는 방법은 Fugu Greetings에서 펜 스트로크 카운터로 (악용) 사용하는 것입니다. pointerdown 이벤트가 발생할 때마다 펜 획 카운터를 증가시킨 다음 업데이트된 아이콘 배지를 설정하는 이벤트 리스너를 추가했습니다. 캔버스가 삭제될 때마다 카운터가 재설정되고 배지가 삭제됩니다.

let strokes = 0;

canvas.addEventListener('pointerdown', () => {
  navigator.setAppBadge(++strokes);
});

clearButton.addEventListener('click', () => {
  strokes = 0;
  navigator.setAppBadge(strokes);
});

이 기능은 점진적으로 개선되므로 로드 로직은 평소와 같습니다.

if ('setAppBadge' in navigator) {
  import('./badge.mjs');
}

이 예에서는 숫자당 하나의 펜 획으로 1에서 7까지 숫자를 그렸습니다. 아이콘의 배지 카운터가 이제 7입니다.

카드에 1에서 7까지의 숫자가 각각 펜으로 한 번만 그려져 있습니다.
7개의 펜 획을 사용하여 1에서 7까지 숫자를 그리세요.
숫자 7이 표시된 Fugu Greetings 앱의 배지 아이콘
앱 아이콘 배지 형태의 펜 획 카운터입니다.

Periodic Background Sync API

새로운 정보로 하루를 새로 시작하고 싶으신가요? Fugu Greetings 앱의 멋진 기능은 매일 아침 인사말 카드를 시작하는 새로운 배경 이미지로 영감을 줄 수 있다는 점입니다. 앱에서는 Periodic Background Sync API를 사용하여 이 작업을 실행합니다.

첫 번째 단계는 서비스 워커 등록에서 주기적 동기화 이벤트를 register하는 것입니다. 'image-of-the-day'라는 동기화 태그를 리슨하고 최소 간격이 1일이므로 사용자는 24시간마다 새 배경 이미지를 가져올 수 있습니다.

const registerPeriodicBackgroundSync = async () => {
  const registration = await navigator.serviceWorker.ready;
  try {
    registration.periodicSync.register('image-of-the-day-sync', {
      // An interval of one day.
      minInterval: 24 * 60 * 60 * 1000,
    });
  } catch (err) {
    console.error(err.name, err.message);
  }
};

두 번째 단계는 서비스 워커에서 periodicsync 이벤트를 수신 대기하는 것입니다. 이벤트 태그가 'image-of-the-day'(이전에 등록된 태그)이면 getImageOfTheDay() 함수를 통해 오늘의 이미지가 검색되고 결과가 모든 클라이언트에 전파되므로 캔버스와 캐시를 업데이트할 수 있습니다.

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        const blob = await getImageOfTheDay();
        const clients = await self.clients.matchAll();
        clients.forEach((client) => {
          client.postMessage({
            image: blob,
          });
        });
      })()
    );
  }
});

이번에도 진정으로 점진적인 개선사항이므로 브라우저에서 API를 지원하는 경우에만 코드가 로드됩니다. 이는 클라이언트 코드와 서비스 워커 코드 모두에 적용됩니다. 지원되지 않는 브라우저에서는 둘 다 로드되지 않습니다. 참고로 서비스 워커에서는 동적 import()(서비스 워커 컨텍스트에서는 아직 지원되지 않음) 대신 기본 importScripts()를 사용합니다.

// In the client:
const registration = await navigator.serviceWorker.ready;
if (registration && 'periodicSync' in registration) {
  import('./periodic_background_sync.mjs');
}
// In the service worker:
if ('periodicSync' in self.registration) {
  importScripts('./image_of_the_day.mjs');
}

Fugu Greetings에서 Wallpaper 버튼을 누르면 Periodic Background Sync API를 통해 매일 업데이트되는 오늘의 인사말 카드 이미지가 표시됩니다.

오늘의 새로운 카드 이미지가 포함된 Fugu Greetings 앱
배경화면 버튼을 누르면 오늘의 이미지가 표시됩니다.

알림 트리거 API

많은 영감이 든다면 끝내줄 인사 카드를 끝내야 할 때가 있습니다. 이 기능은 Notification Triggers API를 통해 사용 설정됩니다. 사용자는 카드를 끝내고 싶은 시간을 입력할 수 있습니다. 그 시간이 되면 연하장이 대기 중이라는 알림을 받게 됩니다.

대상 시간을 표시한 후 애플리케이션은 showTrigger를 사용하여 알림을 예약합니다. 이전에 선택한 목표 날짜가 포함된 TimestampTrigger일 수 있습니다. 리마인더 알림은 로컬에서 트리거되며 네트워크나 서버 측이 필요하지 않습니다.

const targetDate = promptTargetDate();
if (targetDate) {
  const registration = await navigator.serviceWorker.ready;
  registration.showNotification('Reminder', {
    tag: 'reminder',
    body: "It's time to finish your greeting card!",
    showTrigger: new TimestampTrigger(targetDate),
  });
}

지금까지 살펴본 다른 모든 항목과 마찬가지로 이 기능은 점진적인 개선 사항이므로 코드는 조건부로만 로드됩니다.

if ('Notification' in window && 'showTrigger' in Notification.prototype) {
  import('./notification_triggers.mjs');
}

Fugu Greetings에서 알림 체크박스를 선택하면 카드를 완성하라는 알림을 언제 받을지 묻는 메시지가 표시됩니다.

Fugu Greetings 앱에는 사용자에게 카드를 완성하라는 알림이 언제 표시될지 묻는 메시지가 표시되어 있습니다.
인사말 카드를 완료하라는 로컬 알림을 예약합니다.

Fugu Greetings에서 예약된 알림이 트리거되면 다른 알림과 동일하게 표시되지만 이전에 쓴 것처럼 네트워크 연결이 필요하지 않습니다.

Fugu Greetings에서 트리거된 알림을 보여주는 macOS 알림 센터
트리거된 알림이 macOS 알림 센터에 표시됩니다.

Wake Lock API

Wake Lock API도 포함하고 싶습니다. 영감이 떠오를 때까지 화면을 충분히 오래 바라보아야 할 때도 있습니다. 이때 발생할 수 있는 최악의 상황은 화면이 꺼지는 것입니다. Wake Lock API는 이를 방지할 수 있습니다.

첫 번째 단계는 navigator.wakelock.request method()로 wake lock을 가져오는 것입니다. 'screen' 문자열을 전달하여 화면 wake lock을 가져옵니다. 그런 다음 wake lock이 해제되면 알림을 받을 이벤트 리스너를 추가합니다. 예를 들어 탭 공개 상태가 변경될 때 이러한 상황이 발생할 수 있습니다. 이 경우 탭이 다시 표시될 때 wake lock을 다시 획득할 수 있습니다.

let wakeLock = null;
const requestWakeLock = async () => {
  wakeLock = await navigator.wakeLock.request('screen');
  wakeLock.addEventListener('release', () => {
    console.log('Wake Lock was released');
  });
  console.log('Wake Lock is active');
};

const handleVisibilityChange = () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    requestWakeLock();
  }
};

document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('fullscreenchange', handleVisibilityChange);

예. 이는 점진적인 개선 버전이므로 브라우저에서 API를 지원할 때만 로드하면 됩니다.

if ('wakeLock' in navigator && 'request' in navigator.wakeLock) {
  import('./wake_lock.mjs');
}

Fugu Greetings에는 불면증 체크박스가 있는데, 이 체크박스를 선택하면 화면이 켜진 상태로 유지됩니다.

불면증 체크박스를 선택하면 화면이 켜진 상태로 유지됩니다.
불면증 체크박스를 선택하면 앱이 켜진 상태로 유지됩니다.

유휴 감지 API

때로는 몇 시간 동안 화면을 바라봐도 쓸모가 없어져서 카드를 어떻게 해야 할지 떠오르지 못할 때가 있습니다. 유휴 감지 API를 사용하면 앱에서 사용자 유휴 시간을 감지할 수 있습니다. 사용자가 너무 오랫동안 유휴 상태이면 앱이 초기 상태로 재설정되고 캔버스가 지워집니다. 유휴 감지의 프로덕션 사용 사례 중 상당수가 알림과 관련되어 있으므로 이 API는 현재 알림 권한을 통해 관리됩니다. 예를 들어 사용자가 현재 사용 중인 기기에만 알림을 전송하는 경우가 있기 때문입니다.

알림 권한이 부여되었는지 확인한 후 유휴 감지기를 인스턴스화합니다. 사용자와 화면 상태를 포함한 유휴 변경사항을 수신 대기하는 이벤트 리스너를 등록합니다. 사용자는 활성 또는 유휴 상태일 수 있으며 화면은 잠금 해제되거나 잠길 수 있습니다. 사용자가 유휴 상태이면 캔버스가 지워집니다. 유휴 감지기의 임계값을 60초로 지정합니다.

const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', () => {
  const userState = idleDetector.userState;
  const screenState = idleDetector.screenState;
  console.log(`Idle change: ${userState}, ${screenState}.`);
  if (userState === 'idle') {
    clearCanvas();
  }
});

await idleDetector.start({
  threshold: 60000,
  signal,
});

그리고 항상 그렇듯이 이 코드는 브라우저에서 지원할 때만 로드합니다.

if ('IdleDetector' in window) {
  import('./idle_detection.mjs');
}

Fugu Greetings 앱에서 임시 체크박스가 선택되어 있고 사용자가 너무 오랫동안 유휴 상태이면 캔버스가 지워집니다.

사용자가 너무 오랫동안 유휴 상태이면 지워진 캔버스가 표시되는 Fugu Greetings 앱
임시 체크박스가 선택되어 있고 사용자가 너무 오랫동안 유휴 상태이면 캔버스가 지워집니다.

마무리

휴, 정말 멋졌어요. 샘플 앱 하나에 엄청나게 많은 API가 들어 있습니다. 그리고 사용자가 브라우저에서 지원하지 않는 기능의 다운로드 비용을 지불하도록 해서는 안 됩니다. 점진적 개선을 사용하여 관련 코드만 로드되도록 합니다. HTTP/2에서는 요청 비용이 저렴하므로 이 패턴은 많은 애플리케이션에서 잘 작동하지만 매우 큰 앱에는 번들러를 사용하는 것이 좋을 수 있습니다.

현재 브라우저에서 지원하는 코드가 있는 파일에 대한 요청만 보여주는 Chrome DevTools Network 패널
현재 브라우저에서 지원하는 코드가 있는 파일에 대한 요청만 표시되는 Chrome DevTools의 네트워크 탭

모든 플랫폼에서 모든 기능을 지원하는 것은 아니므로 앱은 각 브라우저에서 약간 다르게 보일 수 있지만 핵심 기능은 항상 존재하며 특정 브라우저의 기능에 따라 점진적으로 개선됩니다. 이러한 기능은 앱이 설치된 앱으로 실행되는지 아니면 브라우저 탭에서 실행되는지에 따라 동일한 브라우저에서도 변경될 수 있습니다.

Android Chrome에서 실행되며 사용 가능한 여러 기능을 보여주는 Fugu 인사말
Android Chrome에서 실행되는 Fugu Greetings입니다.
Fugu Greetings가 데스크톱 Safari에서 실행 중이며 사용 가능한 기능이 더 적습니다.
Fugu Greetings는 데스크톱 Safari에서 실행됩니다.
데스크톱 Chrome에서 실행되는 Fugu Greetings에 사용 가능한 여러 기능이 표시되어 있습니다.
데스크톱 Chrome에서 실행되는 Fugu Greetings

Fugu Greetings 앱에 관심이 있다면 GitHub에서 찾아 보세요.

GitHub의 Fugu Greetings 저장소
GitHub의 Fugu Greetings

Chromium팀은 고급 Fugu API를 개발하기 위해 최선을 다하고 있습니다. 앱 개발에 점진적인 개선을 적용함으로써 모든 사용자에게 우수하고 안정적인 기준 환경을 제공하는 동시에 더 많은 웹 플랫폼 API를 지원하는 브라우저를 사용하는 사용자에게 더 나은 환경을 제공하고자 합니다. 여러분께서 앱의 점진적인 개선을 통해 이룬 성과를 기대합니다.

감사의 말

Fugu Greetings에 도움을 주신 Christian Liebel님과 Hemanth HM에게 감사드립니다. 이 문서는 Joe MedleyKayce Basques가 검토했습니다. 제이크 아치볼드의 도움을 받아 서비스 워커 컨텍스트에서 동적 import()를 사용하여 상황을 파악할 수 있었습니다.