HTML의 PWA: 하나의 파일에서 설치 가능한 앱으로

PWA는 일반 웹 페이지와 두 개의 작은 파일, 즉 이름을 지정하는 매니페스트와 이를 캐시하는 서비스 워커로 구성됩니다. 앱 자체는 단일 HTML 파일로 유지될 수 있습니다.

영문 원문 보기

**PWA(프로그레시브 웹 앱)**은 브라우저가 앱으로 설치할 수도 있는 웹 페이지입니다. 아이콘이 생기고 자체 창에서 열리며, 서비스 워커를 추가하면 네트워크 없이 열립니다.

앱은 하나의 HTML 파일에 머물 수 있습니다. 설치에는 작은 매니페스트 파일과 아이콘이 필요하고, 오프라인 다시 로드에는 추가 JavaScript 파일이 하나 필요합니다.

지금 당장 페이지가 실행되는 곳에서 페이지가 무엇을 할 수 있는지 물어보세요. 이 검사는 아래 예제 프레임에서 실행됩니다.

실행 예제직접 써 보고 코드를 복사하세요
링크로 공유하기
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PWA readiness check</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h1 { font-size: 17px; margin: 0 0 10px; }
  ul { list-style: none; margin: 0; padding: 0; background: #fff; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
  li { display: flex; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid #eef0f3; font-size: 14px; }
  li:first-child { border-top: 0; }
  .v { font-weight: 600; text-align: right; }
  .yes { color: #0f7a3d; } .no { color: #b45309; }
  button { margin-top: 12px; font: inherit; padding: 9px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #log { margin-top: 10px; font: 13px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
<h1>What can this page do right now?</h1>
<ul>
  <li>Secure context (HTTPS) <span class="v" id="secure"></span></li>
  <li>Service worker API <span class="v" id="sw"></span></li>
  <li>Manifest linked <span class="v" id="manifest"></span></li>
  <li>Display mode <span class="v" id="mode"></span></li>
  <li>Network <span class="v" id="net"></span></li>
</ul>
<button id="try">Try to register sw.js</button>
<div id="log"></div>

<script>
  function show(id, ok, text) {
    const el = document.getElementById(id);
    el.textContent = text;
    el.className = 'v ' + (ok ? 'yes' : 'no');
  }

  show('secure', window.isSecureContext, window.isSecureContext ? 'yes' : 'no');

  // Reading navigator.serviceWorker can throw in a sandboxed frame
  try {
    const ok = 'serviceWorker' in navigator && !!navigator.serviceWorker;
    show('sw', ok, ok ? 'available' : 'missing');
  } catch (e) {
    show('sw', false, 'blocked (' + e.name + ')');
  }

  const link = document.querySelector('link[rel="manifest"]');
  show('manifest', !!link, link ? 'yes' : 'no');

  // display-mode tells you if the page was opened as an installed app
  const modes = ['standalone', 'minimal-ui', 'fullscreen'];
  const mode = modes.find(m => matchMedia('(display-mode: ' + m + ')').matches) || 'browser';
  show('mode', mode !== 'browser', mode);

  function net() { show('net', navigator.onLine, navigator.onLine ? 'online' : 'offline'); }
  net();
  window.addEventListener('online', net);
  window.addEventListener('offline', net);

  document.getElementById('try').addEventListener('click', async () => {
    const log = document.getElementById('log');
    try {
      const reg = await navigator.serviceWorker.register('sw.js');
      log.textContent = 'Registered. Scope: ' + reg.scope;
    } catch (e) {
      log.textContent = 'Failed: ' + e.name + '\n' + e.message;
    }
  });
</script>
</body>
</html>
PWA가 사용하는 기능을 실시간으로 확인합니다. 여기에서 버튼을 눌러 서비스 워커 등록을 시도해 보세요.

기사의 예제 프레임에서 서비스 작업자 행은 차단됨이라고 표시되어 있습니다. 프레임은 allow-same-origin 없이 샌드박스 처리되므로 페이지에는 자체 출처가 없으며 서비스 작업자는 꺼집니다.

디스크에서 열면 다른 오류로 인해 버튼이 작동하지 않습니다. 두 가지 모두 아래에서 다룹니다.

하나의 HTML 파일로 할 수 있는 것과 할 수 없는 것

인라인 CSS와 JavaScript가 포함된 단일 파일은 이미 작동하는 앱입니다. 일단 로드되면 네트워크가 필요하지 않으므로 버튼과 입력이 오프라인으로 계속 작동합니다. 혼자서는 할 수 없는 일은 연결 없이 설치하거나 다시 로드하는 것입니다.

하나의 파일과 동일한 앱이며, HTTPS 호스트에 4개의 추가 파일이 있는 설치 가능한 PWA입니다.
하나의 파일과 동일한 앱이며, HTTPS 호스트에 4개의 추가 파일이 있는 설치 가능한 PWA입니다.
특징 HTML 파일 1개 요구 사항
브라우저 탭에서 실행 예 아무것도
로드된 후에도 오프라인으로 계속 작업하세요 예 인라인 CSS 및 JS
자체 아이콘으로 설치 아니요 매니페스트, 아이콘, HTTPS
네트워크 없이 새로고침 아니요 서비스 워커, HTTPS
앱으로 열렸다는 걸 알아요 예 display-mode 미디어 쿼리

매니페스트: 이름, 아이콘 및 디스플레이

웹 앱 매니페스트는 브라우저에 앱 이름, 아이콘, 앱 여는 방법을 알려주는 JSON 파일입니다. 양식을 작성하여 빌드한 후 다운로드하세요.

실행 예제직접 써 보고 코드를 복사하세요
링크로 공유하기
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Manifest builder</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; background: #fff; padding: 14px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
  label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; font-weight: 600; min-width: 0; }
  input, select { font: inherit; font-weight: 400; padding: 7px 8px; border: 1px solid #cfd4dc; border-radius: 7px; min-width: 0; }
  input[type=color] { padding: 2px; height: 36px; }
  .icon { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #4b5563; margin: 12px 0 6px; }
  .icon img { width: 48px; height: 48px; border-radius: 12px; }
  pre { margin: 0; background: #1d2330; color: #e5e7eb; padding: 12px; border-radius: 10px; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word; }
  a { display: inline-block; margin-top: 10px; font-size: 14px; color: #2563eb; }
</style>
</head>
<body>
<form id="f">
  <label>Name <input name="name" value="Score Keeper"></label>
  <label>Short name <input name="short_name" value="Score" maxlength="12"></label>
  <label>Theme color <input type="color" name="theme_color" value="#2563eb"></label>
  <label>Display
    <select name="display">
      <option>standalone</option><option>minimal-ui</option><option>fullscreen</option><option>browser</option>
    </select>
  </label>
</form>
<div class="icon"><img id="preview" alt="Icon preview"> Icon preview, drawn as an inline SVG</div>
<pre id="out"></pre>
<a id="dl" download="manifest.webmanifest">Download manifest.webmanifest</a>

<script>
  const form = document.getElementById('f');
  let blobUrl = '';

  function build() {
    const d = Object.fromEntries(new FormData(form));
    const letter = (d.short_name || d.name || '?').trim().charAt(0).toUpperCase();
    // A square icon as SVG, so the preview needs no image file
    const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">' +
      '<rect width="100" height="100" rx="22" fill="' + d.theme_color + '"/>' +
      '<text x="50" y="68" font-size="52" text-anchor="middle" fill="#fff" font-family="sans-serif">' + letter + '</text></svg>';
    document.getElementById('preview').src = 'data:image/svg+xml,' + encodeURIComponent(svg);

    const manifest = {
      name: d.name,
      short_name: d.short_name,
      start_url: '.',
      display: d.display,
      theme_color: d.theme_color,
      background_color: '#ffffff',
      icons: [
        { src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
        { src: 'icon-512.png', sizes: '512x512', type: 'image/png' }
      ]
    };
    const json = JSON.stringify(manifest, null, 2);
    document.getElementById('out').textContent = json;

    // Offer the JSON as a file download
    if (blobUrl) URL.revokeObjectURL(blobUrl);
    blobUrl = URL.createObjectURL(new Blob([json], { type: 'application/manifest+json' }));
    document.getElementById('dl').href = blobUrl;
  }

  form.addEventListener('input', build);
  form.addEventListener('submit', (e) => e.preventDefault());
  build();
</script>
</body>
</html>
이름을 입력하고 표시 모드를 선택합니다. JSON이 업데이트되고 링크가 이를 파일로 다운로드합니다.

페이지의 <head>에서 연결하세요.

<link rel="manifest" href="manifest.webmanifest">

MDN은 Chromium 기반 브라우저가 설치를 제안하기 전에 필요한 사항을 나열합니다: name 또는 short_name, 192px 및 512px 이미지가 있는 icons, start_url, display 또는 display_override. prefer_related_applications는 false이거나 누락되어야 합니다.

페이지는 개발하는 동안 HTTPS를 통해 또는 localhost 또는 127.0.0.1를 통해 제공되어야 합니다.

start_url 및 아이콘 src의 상대 경로는 페이지가 아닌 매니페스트의 URL에 대해 확인됩니다. index.html 옆에 매니페스트를 유지하면 놀라움을 피할 수 있습니다.

서비스 워커: 오프라인에서 작동하는 이유

서비스 워커는 브라우저가 사이트의 백그라운드에서 실행하는 스크립트입니다. 페이지에서 보내는 모든 요청을 포착하고 캐시에서 응답할 수 있습니다. 이것이 바로 네트워크 없이 앱을 다시 로드할 수 있는 이유입니다.

첫 번째 방문에서 작업자는 파일을 캐시합니다. 나중에 방문하면 해당 캐시의 요청에 응답합니다.
첫 번째 방문에서 작업자는 파일을 캐시합니다. 나중에 방문하면 해당 캐시의 요청에 응답합니다.

이것은 작은 앱을 위한 완전한 sw.js입니다. 파일을 변경할 때마다 score-v1를 score-v2로 변경하면 새 버전이 캐시되고 이전 캐시가 삭제됩니다.

const CACHE = 'score-v1';
const FILES = ['./', 'index.html', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png'];

// Install: download every file into the cache
self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(FILES)));
});

// Activate: delete caches from older versions
self.addEventListener('activate', (e) => {
  e.waitUntil(caches.keys().then((keys) =>
    Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))));
});

// Fetch: answer from the cache first, then the network
self.addEventListener('fetch', (e) => {
  e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request)));
});

페이지 스크립트 끝부분에 등록하세요.

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js');
}

서비스 워커가 HTML 파일 내부에 존재할 수 없는 이유

작업자는 실제 파일이어야 합니다. register()는 페이지와 동일한 출처의 http: 또는 https: URL만 허용하므로 HTML 내부에 내장된 data: 또는 blob: URL은 거부됩니다. 또한 페이지는 보안 컨텍스트여야 합니다.

디스크의 파일, 샌드박스 프레임 및 HTTPS 사이트의 동일한 등록 호출.
디스크의 파일, 샌드박스 프레임 및 HTTPS 사이트의 동일한 등록 호출.
  • 파일을 두 번 클릭한 경우: file: 프로토콜이 지원되지 않기 때문에 Chromium은 TypeError로 이를 거부합니다. file:// 프로토콜은 로컬 파일의 다른 변경 사항을 설명합니다.
  • 샌드박스 처리된 프레임: allow-same-origin가 없으면 프레임에 원본이 없으며 navigator.serviceWorker를 읽으면 SecurityError가 발생합니다. 샌드박스 속성을 참조하세요.
  • HTTPS 또는 localhost: 등록됩니다. HTTPS란 무엇입니까에서는 브라우저에서 이를 요구하는 이유를 설명합니다.

완성된 앱, 두 개의 추가 파일 준비

다음은 단일 파일 앱 자체입니다. 점수, 재설정 버튼 및 온라인 칩이 모두 이 하나의 파일에서 실행됩니다. 로드된 후 연결을 끄면 계속 계산됩니다.

실행 예제직접 써 보고 코드를 복사하세요
링크로 공유하기
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#2563eb">
<title>Score Keeper</title>
<!-- When hosted, add: <link rel="manifest" href="manifest.webmanifest"> -->
<style>
  * { box-sizing: border-box; }
  body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; display: flex; flex-direction: column; }
  header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #2563eb; color: #fff; }
  header b { font-size: 17px; }
  .chip { font-size: 12px; padding: 4px 9px; border-radius: 99px; background: rgba(255,255,255,.2); }
  .chip.off { background: #f59e0b; color: #1d2330; }
  main { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
  .team { background: #fff; border-radius: 14px; box-shadow: 0 2px 10px rgba(0,0,0,.08); padding: 12px; text-align: center; }
  .team input { width: 100%; font: 600 15px system-ui, sans-serif; text-align: center; border: 0; border-bottom: 1px dashed #cfd4dc; padding: 4px; }
  .score { font-size: 56px; font-weight: 700; margin: 10px 0; font-variant-numeric: tabular-nums; }
  .btns { display: flex; gap: 8px; }
  .btns button { flex: 1; font-size: 22px; padding: 10px 0; border: 0; border-radius: 10px; cursor: pointer; background: #e8eefc; color: #1e40af; }
  .btns button.plus { background: #2563eb; color: #fff; }
  footer { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 0 14px 14px; font-size: 13px; color: #4b5563; }
  footer button { font: inherit; padding: 7px 12px; border: 1px solid #cfd4dc; border-radius: 8px; background: #fff; cursor: pointer; }
</style>
</head>
<body>
<header><b>Score Keeper</b><span class="chip" id="net">online</span></header>
<main>
  <div class="team" data-team="0"><input value="Home" aria-label="Team name"><div class="score">0</div>
    <div class="btns"><button class="minus" aria-label="Minus one">&minus;</button><button class="plus" aria-label="Plus one">+</button></div></div>
  <div class="team" data-team="1"><input value="Away" aria-label="Team name"><div class="score">0</div>
    <div class="btns"><button class="minus" aria-label="Minus one">&minus;</button><button class="plus" aria-label="Plus one">+</button></div></div>
</main>
<footer><span id="mode"></span><button id="reset">Reset</button></footer>

<script>
  // All code and styles live in this one file: nothing else to download
  const scores = [0, 0];

  document.querySelectorAll('.team').forEach((team) => {
    const i = Number(team.dataset.team);
    const out = team.querySelector('.score');
    team.querySelector('.plus').addEventListener('click', () => { scores[i]++; out.textContent = scores[i]; });
    team.querySelector('.minus').addEventListener('click', () => { scores[i] = Math.max(0, scores[i] - 1); out.textContent = scores[i]; });
  });

  document.getElementById('reset').addEventListener('click', () => {
    scores.fill(0);
    document.querySelectorAll('.score').forEach((s) => (s.textContent = '0'));
  });

  // A hint only: navigator.onLine can say "online" without real internet
  const chip = document.getElementById('net');
  function net() {
    chip.textContent = navigator.onLine ? 'online' : 'offline';
    chip.classList.toggle('off', !navigator.onLine);
  }
  window.addEventListener('online', net);
  window.addEventListener('offline', net);
  net();

  const installed = matchMedia('(display-mode: standalone)').matches;
  document.getElementById('mode').textContent = installed ? 'Running as an installed app' : 'Running in a browser tab';
</script>
</body>
</html>
하나의 파일에 점수 기록원이 있습니다. 탭에서 실행 중인지, 설치된 앱으로 실행 중인지 표시됩니다.

두 가지 세부 사항을 통해 PWA를 사용할 수 있습니다. display-mode 미디어 쿼리를 확인하여 설치된 앱으로 열렸는지 확인합니다.

const installed = matchMedia('(display-mode: standalone)').matches;

그리고 MDN은 true가 인터넷 액세스를 보장하지 않는다고 경고하기 때문에 navigator.onLine를 힌트로만 취급합니다.

설치 가능한 PWA로 바꾸려면 이 파일을 하나의 폴더에 넣고 해당 폴더를 GitHub 페이지와 같은 정적 HTTPS 호스트에 업로드하세요.

index.html            the app, plus <link rel="manifest"> and register('sw.js')
manifest.webmanifest  name, start_url, display, icons
sw.js                 the service worker above
icon-192.png          192 x 192 icon
icon-512.png          512 x 512 icon

사람들이 설치하는 방법은 브라우저에 따라 다릅니다. MDN은 Chromium 기반 데스크탑 브라우저의 주소 표시줄에 있는 설치 아이콘, macOS의 Safari에서 파일 > Dock에 추가, iOS 16.4 이상의 공유 메뉴에 대해 설명합니다.

작동하지 않을 때

당신이 보는 것 원인 수정
TypeError: URL 프로토콜이 지원되지 않습니다. file:// 파일로 열린 페이지 localhost 또는 HTTPS 호스트에서 제공
SecurityError navigator.serviceWorker 읽기 allow-same-origin가 없는 샌드박스 iframe 자신의 원본에 있는 자체 탭에서 페이지를 엽니다.
설치 옵션이 나타나지 않습니다 필수 멤버 또는 아이콘 크기가 누락된 매니페스트 이름, start_url, 디스플레이 및 두 아이콘 크기를 모두 추가하세요.
등록된 서비스 워커가 없습니다. cache.addAll의 한 경로가 오류를 반환합니다. 나열된 모든 파일이 존재하고 철자가 정확하게 작성되도록 합니다.
사용자에게는 여전히 이전 버전이 표시됩니다. 편집 후 동일한 캐시 이름 score-v2와 같은 캐시 이름을 범프합니다.
Wi-Fi를 통한 전화가 아닌 로컬 호스트에서 작동 LAN http:// 주소가 안전하지 않습니다. HTTPS 호스트에서 테스트
아이콘 또는 시작 페이지가 잘못되었습니다. 매니페스트 폴더에 대해 확인된 경로 index.html 옆에 매니페스트를 유지합니다.

링크로 공유하세요

호스팅을 설정하기 전에 사람들이 앱 자체를 사용해 보도록 할 수 있습니다. 스크린샷을 탭할 수 없으며 첨부된 .html 파일이 휴대폰에서 일반 코드로 열릴 수 있습니다.

단일 파일 페이지를 NOS 문서에 붙여넣고 공유 링크 생성을 선택합니다. 링크할 HTML이 이를 안내합니다. 페이지는 작성된 대로 렌더링되고 해당 스크립트가 실행되므로 링크가 있는 사람은 누구나 계정 없이 버튼을 누를 수 있습니다.

나중에 코드를 변경하면 동일한 링크에 새 버전이 표시됩니다. 설치 아이콘과 오프라인 다시 로드를 원할 경우 위의 5개 파일을 자체 HTTPS 호스트에 배치하세요.

자주 묻는 질문

PWA는 무엇을 의미하나요?

프로그레시브 웹 앱. 일반 웹 기술로 구축된 웹 앱으로, 기기에 설치하여 플랫폼 앱처럼 실행하고 서비스 워커를 사용하여 오프라인으로 사용할 수도 있습니다.

단일 HTML 파일이 PWA가 될 수 있나요?

앱은 하나의 HTML 파일에 존재할 수 있지만 앱을 설치하려면 해당 페이지에 연결된 매니페스트 파일과 아이콘 파일이 필요합니다. 오프라인 다시 로드에는 동일한 HTTPS 원본에 있는 별도의 JavaScript 파일이어야 하는 서비스 워커가 필요합니다. data: 또는 blob: URL은 서비스 워커로 등록할 수 없습니다.

PWA를 설치하려면 서비스 워커가 필요합니까?

MDN에서는 서비스 워커가 설치 가능성을 위한 요구 사항이 아니라고 말합니다. Chromium 기반 브라우저는 HTTPS 또는 localhost를 통해 제공되는 name 또는 short_name, 192px 및 512px 아이콘, start_url 및 디스플레이가 포함된 매니페스트를 요청합니다. 서비스 워커는 네트워크 없이 앱을 열게 만드는 역할을 합니다.

내 PWA가 로컬 호스트에서는 작동하지만 Wi-Fi를 통한 휴대폰에서는 작동하지 않는 이유는 무엇입니까?

localhost 및 127.0.0.1은 안전한 것으로 간주되므로 서비스 작업자 및 설치 작업이 그곳에서 수행됩니다. LAN IP와 같은 일반 http:// 주소는 보안 컨텍스트가 아닙니다. 휴대폰에서 테스트하려면 HTTPS 호스트에 파일을 저장하세요.

페이지가 설치된 앱으로 실행되고 있는지 어떻게 확인하나요?

디스플레이 모드 미디어 쿼리를 사용하세요. matchMedia('(display-mode:standalone)').matches는 디스플레이가 독립형으로 설정된 설치된 앱의 경우와 마찬가지로 브라우저가 독립형 모드로 페이지를 열 때 true입니다.

함께 읽어 보기