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

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

지난 2003년 3월, 닉 핑크스티브 샴페온은 핵심 웹페이지 콘텐츠를 먼저 로드하는 것을 강조한 다음 콘텐츠 위에 더 미묘하고 기술적으로 엄격한 표현 및 기능을 추가하는 웹 디자인 전략인 점진적 개선이라는 개념으로 웹 디자인 업계를 놀라게 했습니다. 2003년에는 점진적 개선이란 당시 최신 CSS 기능, 눈에 잘 띄지 않는 JavaScript, 확장형 벡터 그래픽을 사용하는 것을 의미했습니다. 2020년 이후의 프로그레시브 개선은 최신 브라우저 기능을 사용하는 것입니다.

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

최신 JavaScript

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

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

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

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

또한 선택적 체이닝무효 병합과 같은 최근의 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 Greetings PWA
Fugu Greetings 샘플 앱

점진적 개선

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

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

기능 감지는 일반적으로 브라우저가 더 최신 기능을 처리할 수 있는지 확인하는 데 사용되는 반면, 폴리필은 자바스크립트로 누락된 기능을 추가하는 데 자주 사용됩니다.

[…]

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

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();
  });
};

가져오기 기능이 있는 경우 사용자가 크리스마스 카드를 로컬에 저장할 수 있는 내보내기 기능도 있어야 합니다. 파일을 저장하는 기존 방법은 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 웹 검사기
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 Greetings 앱
파일 열기 대화상자입니다.
이제 가져온 이미지가 포함된 Fugu Greetings 앱
가져온 이미지입니다.
수정된 이미지가 있는 Fugu Greetings 앱
수정된 이미지를 새 파일에 저장

Web Share 및 Web Share Target API

영원히 보관하는 것 외에도 실제로 크리스마스 카드를 공유하고 싶을 수 있습니다. 이는 Web Share APIWeb Share Target API를 사용하면 할 수 있는 작업입니다. 모바일 및 최근에는 데스크톱 운영체제에 내장된 공유 메커니즘이 도입되었습니다. 예를 들어 아래는 내 블로그의 도움말에서 트리거된 macOS의 데스크톱 Safari 공유 시트입니다. 도움말 공유 버튼을 클릭하면 macOS 메시지 앱을 통해 친구와 도움말 링크를 공유할 수 있습니다.

도움말의 공유 버튼에서 트리거된 macOS의 데스크톱 Safari 공유 시트
macOS의 데스크톱 Safari에서 Web Share API

이를 실행하는 코드는 매우 간단합니다. navigator.share()를 호출하여 객체의 선택적 title, text, url를 전달합니다. 이미지를 첨부하려면 어떻게 해야 하나요? Web Share API의 수준 1은 아직 이를 지원하지 않습니다. 다행히 웹 공유 수준 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 인사말 카드 애플리케이션에서 이 작업을 실행하는 방법을 보여드리겠습니다. 먼저 하나의 블롭으로 구성된 files 배열이 포함된 data 객체를 준비한 다음 titletext를 준비해야 합니다. 다음으로 권장사항에 따라 이름에서 알 수 있듯이 공유하려는 data 객체를 브라우저에서 기술적으로 공유할 수 있는지 알려주는 새 navigator.canShare() 메서드를 사용합니다. 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과 같은 지원 브라우저에서 Share 버튼을 탭하면 내장된 공유 시트가 열립니다. 예를 들어 Gmail을 선택하면 이미지가 첨부된 이메일 작성기 위젯이 표시됩니다.

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

Contact Picker API

다음으로 연락처, 즉 기기의 주소록 또는 연락처 관리자 앱에 대해 이야기하겠습니다. 기념 카드를 작성할 때 누군가의 이름을 정확하게 쓰는 것이 항상 쉬운 것은 아닙니다. 예를 들어, 세르게이라는 친구가 키릴 문자로 그의 이름을 쓰고 싶어 해요. 독일어 QWERTZ 키보드를 사용하고 있으며 이름을 입력하는 방법을 모르겠습니다. 이 문제는 Contact Picker API로 해결할 수 있습니다. 휴대전화의 연락처 앱에 친구가 저장되어 있으므로 연락처 선택 도구 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 Greeting에서 연락처 버튼을 탭하고 가장 친한 친구인 Сергей Михайлович Брин劳伦斯·爱德华·"拉里"·佩奇를 선택하면 연락처 선택 도구가 이름만 표시하고 이메일 주소나 전화번호와 같은 다른 정보는 표시하지 않는 것을 볼 수 있습니다. 그러면 내 크리스마스 카드에 그들의 이름이 그려집니다.

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

비동기 Clipboard API

다음은 복사 및 붙여넣기입니다. 소프트웨어 개발자가 가장 좋아하는 작업 중 하나는 복사 및 붙여넣기입니다. 저는 크리에이터로서 때때로 같은 일을 하고 싶을 때가 있습니다. 작업 중인 인사말 카드에 이미지를 붙여넣거나 다른 곳에서 계속 수정할 수 있도록 인사말 카드를 복사하고 싶을 수 있습니다. Async Clipboard API는 텍스트와 이미지를 모두 지원합니다. Fugu Greetings 앱에 복사 및 붙여넣기 지원을 추가한 방법을 안내해 드리겠습니다.

시스템 클립보드에 항목을 복사하려면 클립보드에 써야 합니다. 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 필드가 있습니다. 이전에 가져온 MIME 유형을 전달하여 클립보드 항목의 getType() 메서드를 호출합니다.

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 미리보기 앱에서 이미지를 열고 클립보드에 복사합니다. 붙여넣기를 클릭하면 Fugu Greetings 앱에서 클립보드의 텍스트와 이미지를 앱이 보도록 허용할지 묻는 메시지가 표시됩니다.

클립보드 권한 메시지를 보여주는 Fugu Greetings 앱
클립보드 권한 메시지

마지막으로, 권한을 수락하고 나면 이미지를 애플리케이션에 붙여넣습니다. 반대로도 작동합니다. 클립보드에 인사말 카드를 복사하겠습니다. 그런 다음 미리보기를 열고 File(파일), New from Clipboard(클립보드에서 새로 만들기)를 차례로 클릭하면 제목이 없는 새 이미지에 인사말 카드를 붙여넣습니다.

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

Badging API

또 다른 유용한 API는 Badging API입니다. 물론 설치 가능한 PWA인 Fugu Greetings에는 사용자가 앱 도크나 홈 화면에 배치할 수 있는 앱 아이콘이 있습니다. API를 보여주는 재미있고 쉬운 방법은 Fugu Greetings에서 펜 획 카운터로 API를 (오)용하는 것입니다. 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에서 배경화면 버튼을 누르면 주기적 백그라운드 동기화 API를 통해 매일 업데이트되는 오늘의 인사말 카드 이미지가 표시됩니다.

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

Notification Triggers 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을 다시 가져올 수 있습니다.

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에는 Insomnia 체크박스가 있습니다. 이 체크박스를 선택하면 화면을 켜진 상태로 유지합니다.

불면증 체크박스가 선택된 경우 화면이 꺼지지 않습니다.
Insomnia 체크박스를 선택하면 앱이 깨어 있는 상태로 유지됩니다.

Idle Detection API

화면을 몇 시간 동안 쳐다봐도 아무 소용이 없고, 인사말 카드로 무엇을 해야 할지 전혀 생각이 나지 않는 경우가 있습니다. Idle Detection 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 Greetings. 사용 가능한 여러 기능을 보여줍니다.
Android Chrome에서 실행 중인 Fugu Greetings
데스크톱 Safari에서 실행 중인 Fugu Greetings. 사용 가능한 기능이 더 적게 표시됩니다.
Fugu 인사말(데스크톱 Safari에서 실행)
데스크톱 Chrome에서 실행되는 Fugu 인사말로, 사용 가능한 여러 기능을 보여줍니다.
데스크톱 Chrome에서 실행 중인 Fugu Greetings

Fugu Greetings 앱에 관심이 있다면 앱을 찾아 GitHub에서 포크하세요.

GitHub의 Fugu Greetings 저장소
GitHub의 Fugu Greetings

Chromium팀은 고급 Fugu API와 관련하여 잔디를 더 푸르게 만들기 위해 노력하고 있습니다. 앱 개발 시 프로그레시브 개선을 적용하면 모든 사용자가 안정적인 기본 환경을 누릴 수 있지만 더 많은 웹 플랫폼 API를 지원하는 브라우저를 사용하는 사용자는 훨씬 더 나은 환경을 누릴 수 있습니다. 여러분께서 앱을 점진적으로 개선하여 어떤 성과를 거두실지 기대하고 있습니다.

감사의 말씀

Fugu Greetings에 기여해 주신 크리스티안 리벨님과 헤만트 HM님께 감사드립니다. 이 문서는 Joe MedleyKayce Basques가 검토했습니다. Jake Archibald님이 서비스 워커 컨텍스트에서 동적 import()의 상황을 파악하는 데 도움을 주었습니다.