審査なしでアプリを配信する — まずPWA、ストアは必要になったときだけ

審査なしでウェブをインストール型アプリにするPWAをデフォルトに据え、ストア流通が本当に必要になった瞬間だけTWAでGoogle Playに載せた、ONTLEの配信判断を整理した。
← 목록으로
박
박지훈
2026년 10월 02일 更新 2026.10.16 読了 20分 閲覧 2

アプリをひとつ世に出すために、審査の列で何日も待った経験のある人なら分かるはずだ。ボタンの色をひとつ変える修正でもまた列に並び直さなければならず、iOSとAndroidのコードを二つ抱え込んで面倒を見ることになる。けれど、ホーム画面にアイコンを置き、アプリを閉じたあとでも通知を送ることは、必ずしもストアを通さなければできないわけではない。

ONTLEがapp.ontle.krを配信するにあたって立てた原則はひとつだ。まずウェブで配信し、ストア流通が本当に必要になったとき、その上に一枚かぶせる。 ウェブをインストール型アプリに変える標準がPWA(Progressive Web App)で、そうやって作ったアプリをAndroidストア用の殻で包む方式がTWA(Trusted Web Activity)だ。PWAがデフォルトで、TWAは選択肢である。

なぜこの順番なのか、そしてストアへ移るときに実際どんな代償を払ったのか — ONTLEがつまずいた場所まで辿りながら解きほぐしていく。

近所の店に置き換えて考えてみると

新しく店を開いたとしよう。デリバリーアプリに出店するには審査と手数料、そして露出競争をくぐり抜けなければならない。ところが、すでに一度来てくれた客に「うちの番号をホーム画面に保存しておけば、次はすぐ来られますよ」と案内したらどうだろう。客はデリバリーアプリを介さず、まっすぐ店に来る。これがPWAのインストールだ。そのうち本当に人通りの多い商圏にまで露出したくなったら、そのときはじめてデリバリーアプリに出店すればいい。これがTWAとストア登録である。

最初から出店を前提に店を設計する必要はない。この一点さえ頭に掛けておけば、あとはついてくる。これから出てくる技術はすべて「デリバリーアプリなしで常連を作る方法」、そして「本当に必要なときに出店する方法」の具体的な部品だ。

PWAの二本柱 — マニフェストとサービスワーカー

PWAは、すでにあるウェブサービスにファイルを二つ足すところから始まる。ひとつはマニフェスト、もうひとつはサービスワーカーだ。

マニフェスト — スマホに刻まれるアプリの顔

マニフェスト(manifest.json)は、ブラウザに「このサイトはアプリのようにインストールできる」と知らせる小さな設定ファイルだ。名前、アイコン、起動画面、色、実行方式を収める。インストールした瞬間、この情報がユーザーのスマホにそのまま刻み込まれる。

{
  "name": "온틀 앱",
  "short_name": "ONTLE",
  "start_url": "/app?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2550db",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

ここで最も重要な値が display: standalone だ。これがあってこそ、インストールされたアプリがブラウザのアドレスバーなしで独立したウィンドウとして立ち上がる。ネイティブアプリと見分けがつかない第一印象は、ここで決まる。接続はHTMLに <link rel="manifest" href="/manifest.json"> の一行で済む。

サービスワーカー — アプリが切れても起きている働き手

サービスワーカー(Service Worker)は、画面とは別にバックグラウンドで回るスクリプトだ。ネットワークリクエストを横取りして保存(キャッシュ)しておき、インターネットが切れたときに代替画面を見せ、通知の信号を受け取って画面に表示する。ページが閉じられても、この働き手はブラウザの中に残って自分の仕事をする。

// sw.js — 개념 예시
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("ontle-shell-v1").then((cache) =>
      cache.addAll(["/", "/offline.html", "/icons/icon-192.png"])
    )
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request).catch(async () => {
      const cached = await caches.match(event.request);
      return cached || caches.match("/offline.html");
    })
  );
});

登録は navigator.serviceWorker.register("/sw.js") の一行。この瞬間から、オフライン対応もキャッシュも通知の受信も、すべてこのファイル一つを中心に回りだす。

上の例の fetch ハンドラは「ネットワーク優先、失敗したら保存しておいたもの」という最もシンプルな形だ。だがサービスワーカーの本当の値打ちは、リソースごとに戦略を変えて組み立てるところにある。ロゴやフォントのようにめったに変わらないファイルは保存したものを先に返せば、毎回サーバーまで往復する必要がなくなり画面が速く出る。逆にリアルタイムのフィードのように常に最新であることが大事なデータはネットワークを先に受けて鮮度を保つ。同じサービスでもロゴとニュースフィードでは戦略が違うべきで、その判断を全部このファイルの中で設計する。サービスワーカーは「接続失敗の処理係」ではなく、体感速度を左右する性能設計のツールだ。

ユーザーがアプリをインストールするまで

マニフェストとサービスワーカーが技術的な土台なら、インストールはユーザーが実際に体験することだ。そしてこの体験は、プラットフォームごとにかなり違う。

Chrome系のブラウザは、インストール条件を満たしたサイトで beforeinstallprompt という信号を送る。「このサイト、インストールしてもよさそうだぞ?」とブラウザのほうから先に知らせてくれるわけだ。条件はHTTPS、必須項目を備えたマニフェスト、まだインストールされていない状態など。ただし細かい基準はブラウザのバージョンによって変わってきた。正確な最新条件は公式ドキュメントを基準に確認するほうが安全だ。

この信号を捕まえて、自前の「アプリをインストール」ボタンを出すのが標準だ。ブラウザのデフォルトバナーだけに任せると、いつ出るか制御できないからである。

let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

installButton.addEventListener("click", async () => {
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  // outcome: "accepted" | "dismissed"
});

タイミングがすべてだ。初回訪問の直後にインストールのポップアップを突きつければ、ユーザーはそのまま出て行ってしまう。二度目の訪問や、主要な機能を一度使ってみたあとのように、ユーザーがサービスの価値を感じた時点で、自然なボタンとして見せるべきだ。

問題はiOSだ。Safariはこの信号自体を送らない。iOSでインストールするには、ユーザーが共有ボタンを押して「ホーム画面に追加」を自分で選ばなければならない。だからiOS向けのインストール誘導は「ボタンひと押し」ではなく「やり方を図で案内する画面」でなければならない。この非対称は、後で扱う通知でもそのまま足を引っ張る。

アプリを閉じても届く通知

ウェブ通知の核心は「サーバーがユーザーのスマホに直接差し込むわけではない」という点だ。間に郵便局がひとつある。ブラウザの会社が運営するプッシュサービスだ。サーバーは通知をここに預け、郵便局がユーザーの端末まで配達する。

ところが郵便局がどんな手紙でも受け取ってしまえば、スパムがあふれる。だからサーバーは手紙を預けるとき「私だ、登録された差出人だ」と自分の身元を証明しなければならない。この身元証明の約束の名前がVAPIDだ。名前は覚えなくていい。覚えるべきはひとつ — これは誰が送ったかを証明する署名であって、手紙の中身を施錠する鍵ではない。

この区別が実務で重要になる。差出人の証明と中身の暗号化は、まったく別の仕事だ。サーバーは手紙の封筒に身元の署名を押し(VAPID)、手紙の中身は別途暗号化したうえで、両方をまとめて郵便局に預ける。郵便局は封筒の署名だけ確認して配達し、施錠された中身は開けられない。すると端末で眠っていたサービスワーカーが目を覚まし、通知を表示する。

タブを閉じても通知が届く理由はここにある。サービスワーカーはページから切り離され、ブラウザが直接管理する領域にあるので、タブが消えても手紙が届けばブラウザがサービスワーカーだけを別に起こせる。ただし「タブを閉じたこと」と「ブラウザを完全に切ったこと」は違う。Androidはシステムの通知チャンネルを使うため、ブラウザが立ち上がっていなくてもおおむね届く。一方デスクトップは組み合わせに左右される。Firefoxは完全に切ると接続が切れるが、WindowsのEdgeはOSの通知体系と絡んでいて、切っていても受け取る。「デスクトップユーザーには常に届く」と仮定しないほうが安全だ。

// 클라이언트 — 구독 생성
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY, // base64 → Uint8Array 변환 필요
});
// subscription을 서버로 전송해 저장

iPhoneという例外

プラットフォーム 通知 条件
Android Chrome 対応 ブラウザ未起動時でもおおむね届く
デスクトップ Chrome/Edge/Firefox 対応 ブラウザ・OSの組み合わせごとに到達条件が異なる
iOS Safari(タブから直接) 非対応 インストールなしでは通知不可
iOSホーム画面インストールのPWA 対応 iOS 16.4以上 + ホーム画面インストール + 権限許可

iOSはウェブ通知を16.4から対応しはじめ、それすらブラウザのタブではなくホーム画面にインストールされたアプリでのみ動く。iPhoneユーザーに通知を送るには「インストール」という一段階が必ず先に来なければならない、という意味だ。「ウェブプッシュを付けたのにiPhoneで届かない」は実務で最もよくある勘違いで、ここから生まれる。ONTLEも初期にはインストールを済ませたAndroidユーザー中心に通知を回し、iOSユーザーにはまずインストールを案内するほうへ戦略を分けた。

ストアへの道 — TWA、そして消えなかったアドレスバー

ここまでが「デリバリーアプリなしで常連を作る」話だ。いよいよ出店の番である。

TWAは、Chromeエンジンを全画面で立ち上げつつアドレスバーを完全に隠し、Androidでネイティブアプリのように見せる標準だ。中身は相変わらずウェブだが、ユーザーの目には普通のアプリと見分けがつかない。Googleが公開したコマンドラインツールBubblewrapが、マニフェストを読んでAndroidプロジェクトを自動で作ってくれる。

npx @bubblewrap/cli init --manifest https://app.ontle.kr/manifest.json
npx @bubblewrap/cli build

ここまでは簡単だ。本当の落とし穴はその先にあった。

ONTLEがapp.ontle.krをBubblewrapでビルドしてGoogle Playに載せ、端末でアプリを開いたとき、確かにTWAで包んだはずなのに、上部にアドレスバーがそのまま出ていた。「ネイティブアプリのように」がTWAの存在理由なのに、まさにその核心が成り立たない状態だった。

原因は検証の失敗だった。アドレスバーを隠すには、まず「このAndroidアプリとこのウェブドメインは同じ持ち主だ」ということを証明しなければならない。ウェブサイトとアプリが互いに「私たちは一家です」と保証を立て合う手続きで、その保証書がドメインに置く assetlinks.json ファイルだ。保証書には印鑑が押されるのだが、その印鑑がアプリ署名証明書のSHA-256フィンガープリントである。

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "kr.ontle.app",
    "sha256_cert_fingerprints": ["앱 서명 인증서의 SHA-256 지문"]
  }
}]

ONTLEが押しておいた印鑑が間違っていたのだ。ローカルでビルドするときに使ったアップロード鍵のフィンガープリントを入れておいたのに、実際にユーザーの端末にインストールされるファイルは、それではなかった。

ここでPlay App Signingの仕組みを知っておく必要がある。いまのGoogle Playは、開発者が上げたアプリを署名し直してユーザーに配信する。だから鍵が二つある。ひとつは開発者がGoogleにアプリを提出するときに使うアップロード鍵、もうひとつはGoogleがユーザーに送り出す直前に最終的に押す配信用アプリ署名鍵だ。保証書に登録すべきなのは、ユーザーの手に実際に渡るほう、つまり配信用アプリ署名鍵のフィンガープリントである。ONTLEはアップロード鍵のフィンガープリントを入れてしまったせいで、Chromeから見ると「アプリとドメインが一家」という保証が成り立たず、だからアドレスバーを出し戻したのだ。

解決は簡単だった。Play Consoleのアプリの完全性(App integrity)画面でアプリ署名鍵証明書のSHA-256フィンガープリントをコピーし、assetlinks.json を差し替えた。ローカルのテストビルドも検証されるよう、アップロード鍵のフィンガープリントも配列に一緒に入れた。ファイルをドメインに反映してアプリを開き直すと、アドレスバーが消え、配信版もテスト版も両方ともネイティブアプリと見分けがつかなくなった。

教訓ははっきりしている。TWAでアドレスバーが消えなければ、十中八九はコードではなくフィンガープリントの不一致をまず疑うべきだ。そしてその前提は、「自分が署名した鍵 ≠ Googleが実際の配信で押す鍵」という事実を知っているかどうかにかかっている。これ一つが、ONTLEのストア参入で最も長く足を引っ張った地点だった。

ちなみにこの道はAndroid専用だ。iOSにはTWAに対応する公式の経路がない。Appleが塞ぐのは「ウェブサイトを包装しただけのアプリ」であってウェブベースのアプリ全部ではないので、アプリとしての完成度を備えればウェブビューベースでもApp Storeへの配信そのものは可能だ。ただしその道は、別途のネイティブパッケージングと審査対応という投資を要求する。その投資をしないなら、iOSではホーム画面インストールのPWAが現実的な答えになる。

で、ONTLEはどう決めたか

フィンガープリント事故が物語るのははっきりしている。ストアへ行った瞬間、ウェブにはなかったコストが生まれる。鍵の管理、署名体系、そして審査。だとすれば、そのコストをいつ払うかが戦略のすべてだ。

ONTLEの判断基準は三つの軸だった — コスト、スピード、信頼度。

PWAだけで十分な場合。 リンクを押して入ってくるサービス、素早い反復配信が生命線の初期段階、iOS・Android・デスクトップをコード一式でカバーしなければならないチーム、審査の遅延が事業に致命的な場合。ここではストアはむしろ荷物だ。

ストア登録まで必要な場合。 「アプリストアで検索できてこそ信頼する」というユーザー層がはっきりしているサービス、Playストアの検索・おすすめが重要な流入経路である場合、社内の端末管理(MDM)配信のようにインストールファイルそのものが要求される環境。

項目 PWAのみ PWA + TWA(Google Play)
開発・維持コスト ウェブコード一式 ウェブコード + Androidパッケージング・署名管理
配信スピード 即時反映(審査なし) Play審査の手続きが必要
検索露出 ウェブ検索・URL共有 上記 + Playストア検索
iOS ホーム画面インストールで可能 該当なし(Android専用)
更新 サーバー配信で反映(サービスワーカーの更新戦略に左右される) コンテンツは左と同じ、殻の変更時のみストア更新
決済 ウェブ決済手段が自由 アプリ内でデジタル商品を販売する場合はPlayポリシーの検討

ONTLEが最初からTWAを用意しなかった理由が、この表にある。初期は顧客がリンクで入ってきてホーム画面にインストールする流れがすべてだった。マニフェストとサービスワーカーだけでインストール・オフライン・通知が全部回り、修正はサーバーに上げた瞬間に反映された。審査の列に並ぶ理由がなかった。

ストアというカードを切ったのは、Androidユーザーにとってplayストアという流通経路が実際に必要になった時点だった。そのときはじめて、すでに回っていたマニフェストをそのままBubblewrapに食わせてTWAで包んだ — ネイティブのコードベースを新たに抱え込むことなく。その代償として払ったのが、まさにあのフィンガープリント事故であり、一度経験してみると、次からは同じ場所でつまずくことはなかった。

業務の一場面で見ると

ONTLEのAI PMが顧客プロジェクトを受け取ると、この判断を真っ先に行う。たとえば、現場の社員が使う点検ツールを作ってほしいという依頼が来たとしよう。ユーザーは全員Android、流入は管理者がリンクをまく方式、要求は「現場でインターネットが切れても入力が飛んではいけない」だった。

ここにはストアは要らない。マニフェストでホーム画面インストールを開き、サービスワーカーのオフライン戦略で、切れた区間の入力を保存しておいて復旧したら上げるように設計する。審査も、署名鍵の管理もなく、当日の修正が当日反映される。もし後にこのツールを協力会社まで配信しながら「社内の端末管理でインストールファイルを押し込まなければならない」という条件が付けば、そのときに同じマニフェストをTWAで包んでPlayに載せればいい。順番を守れば、最初から重い荷物を背負うことはない。

FAQ

PWAをインストールすると、アプリストアのアプリと実際に何が違うのですか?

見た目の起動は似ている。ホーム画面のアイコンから独立したウィンドウが立ち上がり、キャッシュも通知もできる。本当の違いは端末の奥深い機能にある。Bluetooth、精密なバックグラウンド処理、特定のセンサーのようにOSの内側に触れる領域は、ネイティブアプリのほうが広くアクセスできる。PWAが使える機能の幅は、ブラウザ・OSの組み合わせによって変わる。

サービスワーカーを変えて配信したのに、なぜすぐ反映されないのですか?

新しいサービスワーカーは即座に入れ替わらず「待機」状態に入り、既存のタブがすべて閉じられたあとでようやく有効化されるのがデフォルトの動作だ。これを知らないと「確かに上げたのに変わらない」という混乱に陥る。急ぎの更新が必要なら、インストール段階で待機をスキップするよう処理する戦略を別途入れなければならない。

TWAでGoogle Playに載せれば審査はないのですか?

ある。TWAもPlayに登録される正式なAndroidアプリなので、ポリシー審査を経る。ネイティブコードを新たに書く必要がないので準備が軽いというだけで、審査そのものが省略されるわけではない。

Android一つしか使わない社内用でも、PWAから行くのが正しいのですか?

そうだ。ユーザーが全員Androidでも、ストア流通が要らないならPWAのほうが速くて安い。審査と署名鍵の管理というコストを払うのは、Playストアの検索や端末管理の配信のように「インストールファイルが実際に必要な」条件が生じたときに先送りしても遅くない。

参考資料