알림 메뉴의 '푸시 알림 받기'를 눌렀다. 권한을 묻는 창도, 켜졌다는 안내도 뜨지 않았다. 버튼은 눌린 채 조용했다.
업무용 A/S 웹 서비스에는 원래 웹 푸시가 있었다. 접수가 들어오거나 처리 상태가 바뀌면 담당자 폰에 알림이 갔다. TWA 시절에는 그게 당연하게 동작했다. TWA 는 결국 Chrome 이 띄우는 화면이고, 알림도 Chrome 이 대신 받아 앱 이름으로 보여 줬다. 앱을 WebView 로 옮기고 나서 그 기능만 사라졌다.
브라우저가 해 주던 일
웹 푸시는 서비스 워커가 PushManager 로 구독을 만들고, 브라우저 회사의 푸시 서버가 그 구독으로 메시지를 밀어 넣는 구조다. 메시지를 받아 서비스 워커를 깨우는 건 브라우저 몫이다. Android 의 WebView 는 페이지를 그리는 엔진일 뿐이라 그 몫을 하지 않는다. 앱 안의 WebView 에서 구독을 만들 수도, 메시지를 받을 수도 없다.
앱이 직접 받아야 했다. Android 앱이 푸시를 받는 길은 FCM(Firebase Cloud Messaging) 이다. 그런데 알림을 켜고 끄는 화면, 누가 어떤 알림을 받을지 정하는 규칙은 전부 웹에 있다. 앱은 껍데기라 화면을 새로 만들고 싶지 않았다. 그래서 역할을 이렇게 나눴다. 앱은 FCM 토큰을 발급하고 알림을 그린다. 웹은 그 토큰을 받아 서버에 등록한다. 둘 사이는 JS 브리지로 잇는다.
서버는 기존 구독 테이블에 앱 토큰을 같이 넣고, 발송할 때 웹 푸시와 FCM 으로 갈라 보내게만 바꿨다.
이 구조는 A/S 관리 앱에서 먼저 만들었고, 며칠 뒤 「식단서랍」 Android 앱에 브리지 이름만 바꿔 옮겼다. 식단서랍 웹에는 웹 푸시가 아예 없었으니, 달력 공유 초대를 알려 주는 앱 알림이 처음 생긴 셈이다. 아래 이름은 식단서랍 쪽 것을 쓴다.
토큰을 건네주는 다리
WebView 에 addJavascriptInterface 로 자바 객체를 넣으면 페이지에서 window.RecipeDrawerApp 으로 보인다. 메서드는 넷이다. requestPushToken() 은 토큰 발급을 시작하고, getPushToken() 은 마지막으로 받은 토큰을, getNotificationPermission() 은 알림 권한 상태를 돌려준다. deletePushToken() 은 끄기다.
토큰 발급은 바로 끝나지 않는다. Android 13 부터는 알림에도 런타임 권한(POST_NOTIFICATIONS)이 있어서, 권한이 없으면 먼저 시스템 대화상자를 띄워야 한다. 허용하면 FirebaseMessaging.getToken() 을 부르고, 그것도 비동기로 돌아온다. 그래서 requestPushToken() 은 아무것도 돌려주지 않고, 결과는 앱이 나중에 웹의 함수를 불러 알려 준다. 성공이면 _onAppToken(token), 실패면 _onAppTokenError(reason) 이다. 이유는 둘로 줄였다. 사용자가 거절했으면 denied, Firebase 가 없거나 토큰을 못 받았으면 unavailable.
웹의 push.js 는 요청할 때 Promise 를 걸어 두고 콜백이 오면 푼다. 30초 안에 답이 없으면 시간 초과다. 토큰을 받으면 서버에 등록한다.
앱이 웹을 부를 때는 evaluateJavascript 로 스크립트를 넣는데, 인자는 JSONObject.quote() 로 감싸 따옴표 하나에 스크립트가 깨지지 않게 했다.
권한 상태는 웹의 Notification.permission 과 같은 세 값으로 맞췄다. 알림이 켜져 있으면 granted, Android 13 이상에서 아직 권한을 받지 않았으면 default, 그 밖에 꺼져 있으면 denied 다. 웹이 브라우저에서 쓰던 판단을 그대로 쓸 수 있다.
토큰은 앱이 모르는 사이에도 바뀐다. onNewToken 이 불릴 때 페이지가 열려 있으면 _onAppTokenRefreshed(token) 로 알리고, 아니면 캐시에 넣어 두었다가 웹이 다음 페이지에서 서버와 맞춘다. 서버는 이전 토큰이 내 구독일 때만 새 토큰으로 옮겨 준다. 서버 쪽 구독 API 는 식단서랍 개발기에서 다뤘다.
알림은 앱이 그린다
서버는 FCM 메시지를 data 전용으로 보낸다. 내용은 {title, body, url, tag} 네 개다. notification 필드를 쓰면 앱이 백그라운드일 때 시스템이 알림을 대신 그려 버리고, 탭했을 때 어디로 갈지 앱이 정하지 못한다. data 만 보내면 앱이 어떤 상태든 onMessageReceived 로 들어온다. 같은 tag 면 같은 알림 ID 로 덮어써서 웹 푸시의 tag 처럼 쌓이지 않게 했다.
탭했을 때 열 주소는 서버가 준 url 이다. 여기서 한 번 걸렀다.
static Uri resolveTarget(String url) {
if (url == null || url.trim().isEmpty()) {
return Uri.parse(BuildConfig.LAUNCH_URL);
}
String u = url.trim();
if (u.startsWith("/")) {
return Uri.parse("https://" + BuildConfig.HOST_NAME + u);
}
Uri parsed = Uri.parse(u);
String host = parsed.getHost();
if ("https".equals(parsed.getScheme()) && host != null
&& (host.equalsIgnoreCase(BuildConfig.HOST_NAME)
|| host.toLowerCase(Locale.ROOT).endsWith("." + BuildConfig.HOST_NAME))) {
return parsed;
}
return Uri.parse(BuildConfig.LAUNCH_URL);
}
상대 경로면 앱 도메인을 붙이고, 앱 도메인이나 그 하위 도메인의 https 주소만 그대로 연다. 그 밖의 주소는 시작 화면으로 보낸다. 알림은 의심 없이 누르는 곳이라 엉뚱한 사이트를 열면 안 된다. 알림의 PendingIntent 는 FLAG_IMMUTABLE 로 만들고, 앱이 이미 떠 있으면 onNewIntent 에서 그 주소가 앱 도메인인지 한 번 더 보고 WebView 에 연다.
Firebase 는 google-services Gradle 플러그인 없이 붙였다. 콘솔에서 받는 google-services.json 은 저장소에 올리지 않는데, 플러그인은 그 파일이 없으면 빌드를 멈춘다. 대신 app/build.gradle 이 파일이 있을 때만 JSON 을 읽어 프로젝트 ID, 앱 ID, API 키, 발신자 ID 를 BuildConfig 상수로 넣는다. 파일이 없으면 경고만 남기고 상수는 빈 문자열이 된다. 앱은 시작할 때 이 값이 다 차 있을 때만 FirebaseOptions 로 FirebaseApp.initializeApp() 을 직접 부른다. 하나라도 비어 있으면 초기화를 건너뛰고, 웹이 토큰을 달라고 하면 곧바로 unavailable 을 돌려준다. 웹은 "이 기기에서는 앱 알림을 사용할 수 없어요" 를 띄운다. 파일 하나 없다고 앱 빌드 전체가 멈추지 않고, 푸시만 꺼진 채로 나머지는 그대로 돈다.
에뮬레이터에서는 설정 없이 빌드하면 unavailable 이 즉시 오는 것, 더미 설정이면 권한 대화상자를 거쳐 실패가 정상 회신되는 것까지 확인했다.
모든 프레임에 놓인 다리
앱을 다시 읽다가 브리지 클래스의 조건문에서 멈췄다. 메서드마다 맨 앞에서 "지금 페이지가 앱 도메인인가" 를 확인한다. 그 판단은 페이지 로드가 시작될 때 메인 프레임의 URL 로 정해진다.
addJavascriptInterface 는 프레임을 가리지 않는다. 메인 프레임뿐 아니라 그 안의 모든 iframe 에도 같은 객체가 들어간다. 앱 도메인 페이지가 지도나 동영상 같은 외부 출처 iframe 을 품고 있으면, 그 iframe 의 스크립트도 window.RecipeDrawerApp 을 본다. 메인 프레임은 앱 도메인이니 조건문도 통과한다. 남의 프레임이 내 사용자의 FCM 토큰을 읽고, 알림 권한 대화상자를 띄울 수 있었다.
A/S 관리 앱에서 먼저 막았다. 브리지는 그대로 두고, 문서가 시작될 때 모든 프레임에서 페이지 스크립트보다 먼저 도는 스크립트를 하나 넣었다. androidx.webkit 의 addDocumentStartJavaScript 다. 자기 출처가 앱 도메인이 아니면 브리지를 지운다. 이름만 바꿔 적었다.
private static final String BRIDGE_SCRUB_SCRIPT = "(function(){"
+ "var h='" + HOST + "',o=location.origin||'';"
+ "if(o==='https://'+h||(o.indexOf('https://')===0&&o.slice(-h.length-1)==='.'+h))return;"
+ "try{delete window.AppBridge;}catch(e){}"
+ "try{Object.defineProperty(window,'AppBridge',{value:undefined});}catch(e){}"
+ "})();";
webView.addJavascriptInterface(new PushBridge(this), "AppBridge");
WebViewCompat.addDocumentStartJavaScript(webView, BRIDGE_SCRUB_SCRIPT, Collections.singleton("*"));
지우는 것으로 끝나지 않을 경우에 대비해 같은 이름을 undefined 로 못 박는다. 페이지의 첫 스크립트가 돌 때는 이미 브리지가 없다. 웹 쪽 API 는 하나도 바뀌지 않는다. 에뮬레이터에서 외부 사이트 iframe 을 띄워 보니 그 안에서는 undefined, 메인 프레임에서는 객체였다.
식단서랍으로 옮길 때는 한 걸음 더 갔다. 넣어 놓고 지우는 대신, 처음부터 앱 도메인 프레임에만 넣는 길이 있었다. WebMessageListener 는 채널을 등록할 때 출처 허용 목록을 받는다. 목록에 맞는 프레임에만 window.RecipeDrawerAppChannel 이 생기고, 메시지에는 보낸 출처와 메인 프레임 여부가 붙어 온다.
private void installWebBridge() {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
&& WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
try {
Set<String> origins = new HashSet<>(Arrays.asList("https://" + HOST, "https://*." + HOST));
WebViewCompat.addWebMessageListener(webView, BRIDGE_CHANNEL, origins, this::onWebMessage);
WebViewCompat.addDocumentStartJavaScript(webView, BRIDGE_SHIM_JS, origins);
secureBridge = true;
return;
} catch (Exception e) {
Log.w(TAG, "보안 브리지 설치 실패 — 레거시 브리지로 대체", e);
}
}
webView.addJavascriptInterface(new PushBridge(this), "RecipeDrawerApp");
}
남은 문제는 웹이었다. push.js 는 window.RecipeDrawerApp.getPushToken() 을 부르고 바로 문자열을 받는다. 메시지 채널은 보내기만 있고 돌려받는 값이 없다. 그래서 같은 허용 목록으로 작은 심(shim) 스크립트를 함께 넣었다. 이 스크립트가 채널 위에 window.RecipeDrawerApp 을 똑같은 모양으로 만든다. requestPushToken() 과 deletePushToken() 은 채널로 메시지를 보내는 것으로 바뀌고, getPushToken() 과 getNotificationPermission() 은 앱이 미리 밀어 넣어 둔 상태를 돌려준다. 심은 시작하자마자 init 을 보내고, 앱은 그 답장 통로로 캐시 토큰과 권한 상태를 보낸다. 토큰이나 권한이 바뀔 때마다 다시 보낸다. 앱은 메인 프레임이 아닌 메시지를 버린다.
push.js 는 한 줄도 고치지 않았다. 채널을 지원하지 않는 오래된 WebView 에서는 예전 브리지로 내려가고, 웹이 보는 이름과 메서드는 어느 길이든 같다. 에뮬레이터의 WebView 113 에서 외부 출처 iframe 에 채널도 브리지도 생기지 않는 것을 확인했다.
새 폰으로 따라온 토큰
다리 끝에 하나가 더 걸려 있었다. 앱은 마지막 토큰을 SharedPreferences 의 push.xml 에 둔다. Android 의 자동 백업과 기기 간 이전은 이 파일도 옮긴다.
FCM 토큰은 기기마다 다르다. 새 폰에 옛 폰의 토큰이 복원되면 웹은 캐시 토큰을 보고 서버에 물어 "구독 중" 이라고 표시한다. 서버가 아는 건 옛 폰이니 알림은 옛 폰으로 간다. 새 폰에서는 켜져 있다고 나오는데 아무것도 오지 않는다.
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="push.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="push.xml" />
</device-transfer>
</data-extraction-rules>
식단서랍은 토큰 파일만 뺐다. Android 12 이상은 dataExtractionRules, 그 아래는 fullBackupContent 에 같은 규칙을 넣었다. A/S 관리 앱은 더 크게 잘랐다. 로그인 쿠키까지 옛 폰 것이 따라오면 곤란해서 allowBackup="false" 로 백업을 끄고, 그것만으로는 Android 12 이상의 기기 간 이전이 막히지 않으니 추출 규칙에서 모든 영역을 뺐다. 앱 데이터는 전부 서버에서 다시 받으면 된다.
이미 복원된 폰도 있을 수 있다. 앱이 시작할 때 캐시 토큰이 있으면 FCM 에 실제 토큰을 물어, 다르면 캐시를 바로잡게 했다. 구독한 적 없는 폰, 캐시가 빈 폰은 건드리지 않는다. 처음에는 이것으로 끝난 줄 알았는데, 캐시를 고치면 웹의 표시가 "꺼짐" 으로 바로잡힐 뿐 새 토큰이 서버에 등록되지는 않았다. 바뀐 토큰을 옮겨 등록하는 일은 웹이 페이지를 열 때 하도록 웹 쪽에서 마무리했다. 앱은 맞는 토큰을 쥐고 있고, 웹은 그 토큰이 바뀐 걸 알아채면 서버에 옮겨 적는다.
브리지를 놓을 때는 웹이 부르면 앱이 대답하는 것만 생각했다. 실제로는 누가 부를 수 있는지, 대답이 어디까지 흘러가는지, 대답해 둔 값이 언제까지 맞는지가 더 오래 걸렸다. 다리는 짓는 것보다 양쪽 끝을 지키는 게 일이었다.
다음 편은 이 연재의 마지막이다. 렌더러가 죽어도, 네트워크가 끊겨도 앱이 꺼지지 않게 버티는 일. Chrome 이 조용히 해 주던 목록의 마지막 줄을 다룬다.