앱스토어 없이 앱 배포하기 — PWA로 먼저, 스토어는 필요할 때

심사 없이 웹을 설치형 앱으로 만드는 PWA를 기본값으로 두고, 스토어 유통이 필요한 순간에만 TWA로 Google Play에 올린 온틀의 배포 판단을 정리했다.
English  ·  日本語  ·  한국어
← 목록으로
박
박지훈
2026년 09월 09일 수정 2026.10.17 읽기 20분 조회 11

앱 하나를 내보내려고 심사 줄에서 며칠을 기다려 본 사람은 안다. 버튼 색 하나 바꾸는 수정에도 다시 줄을 서야 하고, iOS와 안드로이드 코드를 두 벌 돌봐야 한다. 그런데 홈화면에 아이콘을 심고, 앱을 닫은 뒤에도 알림을 보내는 일은 꼭 스토어를 거쳐야만 되는 게 아니다.

온틀(ONTLE)이 app.ontle.kr을 배포하며 세운 원칙은 하나다. 웹으로 먼저 배포하고, 스토어 유통이 정말 필요해졌을 때 그 위에 한 겹을 얹는다. 웹을 설치형 앱으로 바꾸는 표준이 PWA(Progressive Web App), 그렇게 만든 앱을 안드로이드 스토어용 껍데기로 감싸는 방식이 TWA(Trusted Web Activity)다. PWA가 기본값이고, TWA는 선택지다.

왜 이 순서인지, 그리고 스토어로 넘어갈 때 실제로 어떤 대가를 치렀는지 — 온틀이 걸려 넘어진 자리까지 짚어가며 풀어본다.

동네 가게로 바꿔 생각해보면

새로 가게를 열었다고 하자. 배달앱에 입점하려면 심사와 수수료, 노출 경쟁을 거쳐야 한다. 그런데 이미 다녀간 손님에게 "저희 번호를 홈화면에 저장해 두시면 다음엔 바로 들어오실 수 있어요"라고 안내하면 어떨까. 손님은 배달앱을 거치지 않고 곧장 가게로 온다. 이게 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 핸들러는 "네트워크 먼저, 실패하면 저장해 둔 것"이라는 가장 단순한 형태다. 하지만 서비스워커의 진짜 쓸모는 자원마다 전략을 다르게 짜는 데 있다. 로고나 폰트처럼 좀처럼 바뀌지 않는 파일은 저장본 먼저 내주면 매번 서버를 다녀올 필요가 없어 화면이 빨리 뜬다. 반대로 실시간 피드처럼 늘 최신이 중요한 데이터는 네트워크 먼저 받아 신선도를 지킨다. 같은 서비스라도 로고와 뉴스피드의 전략이 달라야 하고, 그 판단을 전부 이 파일 안에서 설계한다. 서비스워커는 "접속 실패 처리기"가 아니라 체감 속도를 좌우하는 성능 설계 도구다.

사용자가 앱을 설치하기까지

매니페스트와 서비스워커가 기술적 토대라면, 설치는 사용자가 실제로 겪는 경험이다. 그리고 이 경험은 플랫폼마다 꽤 다르다.

크롬 계열 브라우저는 설치 조건을 갖춘 사이트에서 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다. 사파리는 이 신호 자체를 보내지 않는다. iOS에서 설치하려면 사용자가 공유 버튼을 눌러 "홈 화면에 추가"를 직접 골라야 한다. 그래서 iOS용 설치 유도는 "버튼 한 번"이 아니라 "방법을 그림으로 안내하는 화면"이어야 한다. 이 비대칭은 뒤에서 다룰 알림에서도 그대로 발목을 잡는다.

앱을 닫아도 오는 알림

웹 알림의 핵심은 "서버가 사용자 폰에 직접 꽂는 게 아니다"라는 점이다. 중간에 우체국이 하나 있다. 브라우저 회사가 운영하는 푸시 서비스다. 서버는 알림을 여기에 맡기고, 우체국이 사용자 기기로 배달한다.

그런데 우체국이 아무 편지나 받아주면 스팸이 넘칠 것이다. 그래서 서버는 편지를 맡길 때 "나야, 등록된 발송자야"라고 자기 신원을 증명해야 한다. 이 신원 증명 약속의 이름이 VAPID다. 이름은 몰라도 된다. 기억할 건 하나다 — 이건 누가 보냈는지를 증명하는 서명이지, 편지 내용을 잠그는 자물쇠가 아니다.

이 구분이 실무에서 중요하다. 발송자 증명과 내용 암호화는 완전히 별개의 일이다. 서버는 편지 봉투에 신원 서명을 찍고(VAPID), 편지 내용은 따로 암호화한 뒤, 둘을 함께 우체국에 맡긴다. 우체국은 봉투의 서명만 확인하고 배달하며, 잠겨 있는 내용은 열어보지 못한다. 그러면 기기에서 잠들어 있던 서비스워커가 깨어나 알림을 띄운다.

탭을 닫아도 알림이 오는 이유가 여기 있다. 서비스워커는 페이지와 분리되어 브라우저가 직접 관리하는 공간이라, 탭이 사라져도 편지가 도착하면 브라우저가 서비스워커만 따로 깨울 수 있다. 다만 "탭을 닫은 것"과 "브라우저를 완전히 끈 것"은 다르다. 안드로이드는 시스템 알림 채널을 쓰기 때문에 브라우저가 떠 있지 않아도 대체로 도착한다. 반면 데스크톱은 조합을 탄다. 파이어폭스는 완전히 끄면 연결이 끊기지만, 윈도우의 엣지는 OS 알림 체계와 엮여 꺼져 있어도 받는다. "데스크톱 사용자에게는 항상 도달한다"고 가정하지 않는 편이 안전하다.

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

아이폰이라는 예외

플랫폼 알림 조건
안드로이드 Chrome 지원 브라우저 미실행 시에도 대체로 도달
데스크톱 Chrome/Edge/Firefox 지원 브라우저·OS 조합마다 도달 조건이 다름
iOS Safari (탭에서 바로) 미지원 설치 없이는 알림 불가
iOS 홈화면 설치 PWA 지원 iOS 16.4 이상 + 홈화면 설치 + 권한 허용

iOS는 웹 알림을 16.4부터 지원하기 시작했고, 그마저도 브라우저 탭이 아니라 홈화면에 설치된 앱에서만 동작한다. 아이폰 사용자에게 알림을 보내려면 "설치"라는 한 단계가 반드시 먼저 와야 한다는 뜻이다. "웹 푸시 붙였는데 아이폰에서 안 온다"는 실무에서 가장 흔한 착오가 여기서 나온다. 온틀도 초기에는 설치를 마친 안드로이드 사용자 위주로 알림을 돌리고, iOS 사용자에게는 먼저 설치를 안내하는 쪽으로 전략을 나눴다.

스토어로 가는 길 — TWA, 그리고 사라지지 않던 주소창

여기까지가 "배달앱 없이 단골 만들기"다. 이제 입점 차례다.

TWA는 크롬 엔진을 전체 화면으로 띄우되 주소창을 완전히 숨겨, 안드로이드에서 네이티브 앱처럼 보이게 만드는 표준이다. 속은 여전히 웹이지만 사용자 눈에는 일반 앱과 구분되지 않는다. 구글이 공개한 명령줄 도구 Bubblewrap이 매니페스트를 읽어 안드로이드 프로젝트를 자동으로 만들어 준다.

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

여기까지는 쉽다. 진짜 함정은 그다음에 있었다.

온틀이 app.ontle.kr을 Bubblewrap으로 빌드해 Google Play에 올리고 기기에서 앱을 열었을 때, 분명 TWA로 감쌌는데도 상단에 주소창이 그대로 떠 있었다. "네이티브 앱처럼"이 TWA의 존재 이유인데, 바로 그 핵심이 안 되는 상태였다.

원인은 검증 실패였다. 주소창을 숨기려면 먼저 "이 안드로이드 앱과 이 웹 도메인은 한 주인"이라는 걸 증명해야 한다. 웹사이트와 앱이 서로 "우리는 한 가족"이라고 보증을 서주는 절차이고, 그 보증서가 도메인에 올려두는 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 지문"]
  }
}]

온틀이 찍어둔 도장이 틀렸던 것이다. 로컬에서 빌드할 때 쓴 업로드 키의 지문을 넣어뒀는데, 실제 사용자 기기에 설치되는 파일은 그게 아니었다.

여기서 Play App Signing의 구조를 알아야 한다. 요즘 Google Play는 개발자가 올린 앱을 다시 서명해서 사용자에게 배포한다. 그래서 키가 두 개다. 하나는 개발자가 구글에 앱을 제출할 때 쓰는 업로드 키, 다른 하나는 구글이 사용자에게 내보내기 직전 최종으로 찍는 배포용 앱 서명 키다. 보증서에 등록해야 하는 건 사용자 손에 실제로 가는 쪽, 즉 배포용 앱 서명 키의 지문이다. 온틀은 업로드 키 지문을 넣는 바람에, 크롬 입장에서 "앱과 도메인이 한 가족"이라는 보증이 성립하지 않았고, 그래서 주소창을 도로 띄운 것이다.

해결은 간단했다. Play Console의 앱 무결성(App integrity) 화면에서 앱 서명 키 인증서의 SHA-256 지문을 복사해 assetlinks.json을 교체했다. 로컬 테스트 빌드도 검증되도록 업로드 키 지문을 배열에 함께 넣었다. 파일을 도메인에 반영하고 앱을 다시 열자 주소창이 사라졌고, 배포본과 테스트본 양쪽 모두 네이티브 앱과 구분되지 않았다.

교훈은 분명하다. TWA에서 주소창이 안 사라지면 십중팔구 코드가 아니라 지문 불일치를 먼저 의심해야 한다. 그리고 그 전제는 "내가 서명한 키 ≠ 구글이 실제 배포에 찍는 키"라는 사실을 아느냐에 달려 있다. 이것 하나가 온틀의 스토어 진입에서 가장 오래 발목을 잡은 지점이었다.

참고로 이 길은 안드로이드 전용이다. iOS에는 TWA에 대응하는 공식 경로가 없다. 애플이 막는 건 "웹사이트를 포장만 한 앱"이지 웹 기반 앱 전부가 아니라서, 앱으로서의 완성도를 갖추면 웹뷰 기반이라도 App Store 배포 자체는 가능하다. 다만 그 길은 별도의 네이티브 패키징과 심사 대응이라는 투자를 요구한다. 그 투자를 하지 않는다면 iOS에서는 홈화면 설치 PWA가 현실적인 답이다.

그래서, 온틀은 어떻게 결정했나

지문 사고가 말해주는 건 분명하다. 스토어로 가는 순간, 웹에는 없던 비용이 생긴다. 키 관리, 서명 체계, 그리고 심사. 그렇다면 그 비용을 언제 치를지가 전략의 전부다.

온틀의 판단 기준은 세 축이었다 — 비용, 속도, 신뢰도.

PWA만으로 충분한 경우. 링크를 눌러 들어오는 서비스, 빠른 반복 배포가 생명인 초기 단계, iOS·안드로이드·데스크톱을 코드 한 벌로 커버해야 하는 팀, 심사 지연이 사업에 치명적인 경우. 여기서는 스토어가 오히려 짐이다.

스토어 등록까지 필요한 경우. "앱스토어에서 검색돼야 믿는다"는 사용자층이 뚜렷한 서비스, Play 스토어 검색·추천이 중요한 유입 통로인 경우, 사내 기기관리(MDM) 배포처럼 설치 파일 자체가 요구되는 환경.

항목 PWA만 PWA + TWA(Google Play)
개발 유지 비용 웹 코드 한 벌 웹 코드 + 안드로이드 패키징·서명 관리
배포 속도 즉시 반영(심사 없음) Play 심사 절차 필요
검색 노출 웹 검색·URL 공유 위 + Play 스토어 검색
iOS 홈화면 설치로 가능 해당 없음(안드로이드 전용)
업데이트 서버 배포로 반영(서비스워커 갱신 전략에 좌우) 콘텐츠는 왼쪽과 동일, 껍데기 변경 시에만 스토어 업데이트
결제 웹 결제 수단 자유 앱 내 디지털 상품 판매 시 Play 정책 검토

온틀이 처음부터 TWA를 준비하지 않은 이유가 이 표에 있다. 초기엔 고객이 링크로 들어와 홈화면에 설치하는 흐름이 전부였다. 매니페스트와 서비스워커만으로 설치·오프라인·알림이 다 돌았고, 수정은 서버에 올리는 즉시 반영됐다. 심사 줄에 설 이유가 없었다.

스토어 카드를 꺼낸 건 안드로이드 사용자에게 Play 스토어라는 유통 경로가 실제로 필요해진 시점이었다. 그제서야 이미 돌아가던 매니페스트를 그대로 Bubblewrap에 물려 TWA로 감쌌다 — 네이티브 코드베이스를 새로 떠안지 않고. 대가로 치른 게 바로 그 지문 사고였고, 한 번 겪고 나니 다음부터는 같은 자리에서 넘어지지 않았다.

업무 장면 하나로 보면

온틀의 AI PM이 고객 프로젝트를 받으면 이 판단을 가장 먼저 한다. 가령 사내 현장 직원들이 쓸 점검 도구를 만들어 달라는 요청이 들어왔다고 하자. 사용자는 전부 안드로이드, 유입은 관리자가 링크를 뿌리는 방식, 요구사항은 "현장에서 인터넷이 끊겨도 입력이 날아가면 안 된다"였다.

여기엔 스토어가 필요 없다. 매니페스트로 홈화면 설치를 열고, 서비스워커의 오프라인 전략으로 끊긴 구간의 입력을 저장했다가 복구되면 올리게 설계한다. 심사도, 서명 키 관리도 없이 당일 수정이 당일 반영된다. 만약 나중에 이 도구를 협력사까지 배포하면서 "사내 기기관리로 설치 파일을 밀어넣어야 한다"는 조건이 붙으면, 그때 같은 매니페스트를 TWA로 감싸 Play에 올리면 된다. 순서를 지키면 처음부터 무거운 짐을 질 일이 없다.

FAQ

PWA를 설치하면 앱스토어 앱과 실제로 뭐가 다른가요?

겉보기 실행은 비슷하다. 홈화면 아이콘으로 독립 창이 뜨고, 캐시와 알림도 된다. 진짜 차이는 기기 깊숙한 기능에 있다. 블루투스, 정밀한 백그라운드 작업, 특정 센서처럼 OS 안쪽을 건드리는 영역은 네이티브 앱이 더 넓게 접근한다. PWA가 쓸 수 있는 기능의 폭은 브라우저·OS 조합에 따라 달라진다.

서비스워커를 바꿔 배포했는데 왜 바로 반영이 안 되나요?

새 서비스워커는 즉시 교체되지 않고 "대기" 상태로 들어가, 기존 탭이 모두 닫힌 뒤에야 활성화되는 게 기본 동작이다. 이걸 모르면 "분명히 올렸는데 안 바뀐다"는 혼란을 겪는다. 급한 갱신이 필요하면 설치 단계에서 대기를 건너뛰도록 처리하는 전략을 따로 넣어야 한다.

TWA로 Google Play에 올리면 심사가 없나요?

있다. TWA도 Play에 등록되는 정식 안드로이드 앱이라 정책 검토를 거친다. 네이티브 코드를 새로 짤 필요가 없어 준비가 가볍다는 것이지, 심사 자체가 생략되는 건 아니다.

안드로이드 하나만 쓰는 사내용인데도 PWA부터 가는 게 맞나요?

그렇다. 사용자가 전부 안드로이드라도, 스토어 유통이 필요 없다면 PWA가 더 빠르고 싸다. 심사와 서명 키 관리라는 비용을 치르는 건 Play 스토어 검색이나 기기관리 배포처럼 "설치 파일이 실제로 필요한" 조건이 생겼을 때로 미뤄도 늦지 않다.

참고자료