앱에는 아무 문제가 없었다. 문제는 앱을 만든 방식이었다.

웹 서비스를 감싼 Android 앱이 두 개 있었다. 하나는 「식단서랍」이고, 다른 하나는 업무용 A/S 웹 서비스를 감싼 앱이다. 둘 다 Bubblewrap 으로 만든 TWA(Trusted Web Activity)였다. Play 에 정식으로 내보내려면 비공개 테스트를 거쳐야 하는데, 그 비공개 테스트의 정책이 바뀌어 있었다. TWA 앱은 신청을 일시 중단한다는 것이다. 받는 건 네이티브, 하이브리드, Flutter 앱이었다.

TWA 는 그 목록에 없었다. 웹 화면은 그대로인데, 그 웹을 담은 그릇이 받아 주지 않는 그릇이 됐다.

주소창 없는 Chrome 탭

TWA 가 뭔지부터 짚어야 이 일이 왜 작지 않은지 보인다.

TWA 앱은 화면을 직접 그리지 않는다. 앱을 실행하면 앱은 Chrome 에게 이 주소를 열어 달라고 부탁하고, 화면은 Chrome 이 그린다. 정확히는 Custom Tab 이다. 다른 앱에서 링크를 누르면 뜨는, 위에 주소와 닫기 버튼이 붙은 그 Chrome 탭이다. TWA 는 그 탭에서 주소창을 걷어낸 것이다.

Bubblewrap 이 만들어 준 LauncherActivity 는 그래서 거의 비어 있었다.

public class LauncherActivity
        extends com.google.androidbrowserhelper.trusted.LauncherActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 화면 방향 지정 정도만 한다. 나머지는 부모 클래스와 Chrome 몫
        ...
    }
}

부모 클래스는 androidbrowserhelper 라이브러리에 있다. 이게 Chrome 과 연결을 맺고, 매니페스트의 메타데이터에서 시작 주소, 상태바 색, 스플래시 이미지와 페이드아웃 시간을 읽어 Chrome 에 넘긴다. 알림도 마찬가지였다. 웹이 띄우는 알림은 DelegationService 를 거쳐 앱 이름으로 표시됐다. 위치 권한도 Chrome 이 받아 웹에 넘겨줬다. A/S 앱은 현장 처리 화면에서 저장할 때 GPS 좌표를 남기는데, 앱 쪽에서 한 일은 매니페스트에 위치 권한 두 줄을 선언한 것뿐이었다.

주소창을 숨기려면 그 사이트가 이 앱의 것이라는 증명이 있어야 한다. 그게 Digital Asset Links 다. 앱은 strings.xml 에 "나는 이 사이트와 한 몸이다"라고 적고, 사이트는 /.well-known/assetlinks.json 에 "이 패키지명, 이 서명 지문을 가진 앱은 내 것이다"라고 적는다. 양쪽이 맞아야 Chrome 이 주소창을 내린다. 하나라도 어긋나면 앱은 그냥 Custom Tab 으로 열리고, 화면 위에 주소창이 그대로 보인다. 설정에 fallbackType: 'customtabs' 라고 적혀 있던 게 그 뜻이다.

TWA 앱은 껍데기였다. 화면부터 쿠키, 알림, 권한, 파일 선택, 다운로드까지 Chrome 이 했고, 그만큼 앱 쪽 코드는 짧았다.

다시 만들 때마다 지워지는 수정

짧은 코드에도 대가는 있었다. 프로젝트를 Bubblewrap 이 생성한다는 점이다.

Bubblewrap 프로젝트에서 설정의 원본은 twa-manifest.json 이다. bubblewrap update 를 돌리면 이 파일과 Bubblewrap 이 가진 템플릿으로 app/build.gradle 과 매니페스트를 다시 만든다. 앱 이름이나 아이콘을 바꾸려면 이 흐름을 타야 했다. 그리고 다시 만든다는 건 손으로 고친 부분이 지워진다는 뜻이다.

Play 가 대상 API 수준을 36 으로 올리라고 했을 때 이게 드러났다. build.gradle 의 targetSdkVersion 을 35 에서 36 으로 고쳤는데, bubblewrap update 를 한 번 돌리면 35 로 돌아가 있었다. 당시 쓰던 Bubblewrap CLI 템플릿에 targetSdkVersion 35 가 박혀 있었기 때문이다. 템플릿이 그렇게 생겼으니 생성 결과도 늘 35 였다.

A/S 앱에서는 하나가 더 날아갔다. 매니페스트에 직접 넣어 둔 위치 권한 두 줄이다. TWA 설정에는 그 권한을 적을 자리가 없어서 생성된 매니페스트에 손으로 넣었는데, update 가 매니페스트를 새로 쓰면서 그 두 줄이 빠졌다. 현장 처리 화면의 GPS 가 조용히 안 되는 앱이 나올 뻔했다.

결국 손댄 곳은 프로젝트가 아니라 Bubblewrap 자신이었다. 전역으로 설치된 npm 패키지 안의 템플릿 build.gradle 에서 targetSdkVersion 35 한 줄을 36 으로 고쳤다. 그 뒤로는 update 를 돌려도 36 이 나왔다. 대신 README 에 경고를 적었다. Bubblewrap 을 다시 설치하면 템플릿이 원래대로 돌아오니 이 패치를 다시 해야 한다고. 위치 권한은 update 뒤에 남아 있는지 확인하라는 줄도 같이 넣었다. 빌드 결과가 저장소가 아니라 내 PC 의 node_modules 에 달려 있는 셈이었다.

Chrome 이 하던 일을 직접 하기로 했다

정책이 TWA 를 막았을 때 고를 수 있는 건 많지 않았다. 받아 주는 목록에 있는 형태로 바꾸는 것. 웹은 그대로 두고 앱 안에 WebView 를 띄우는 하이브리드가 가장 가까웠다.

WebView 도 Chromium 엔진이다. 그래서 웹 화면은 거의 그대로 뜬다. 다른 건 누가 주인이냐다. TWA 에서 화면의 주인은 Chrome 이었고, WebView 에서는 내 앱이다. 앱 안의 뷰 하나일 뿐이라 Chrome 이 해 주던 일은 하나도 따라오지 않는다.

목록을 적어 보니 길었다. 쿠키는 Chrome 과 따로 쓰니, 업데이트 직후에는 Chrome 에서 유지되던 로그인이 앱으로 넘어오지 않을 수 있다. 뒤로가기는 웹 히스토리를 직접 타게 해야 한다. 외부 링크를 앱 안에서 열지, 밖으로 보낼지 정해야 한다. <input type="file"> 은 아무것도 하지 않는다. 파일 선택기와 카메라를 내가 띄우고 결과를 돌려줘야 한다. 다운로드도 그냥은 받아지지 않는다. 웹 알림은 WebView 가 지원하지 않는다. 위치 권한은 WebView 의 요청을 Android 런타임 권한에 내가 이어 줘야 한다. 네트워크가 끊기면 WebView 는 기본 오류 페이지를 보여 주니 그것도 가려야 한다. targetSdk 35 부터 강제되는 edge-to-edge 에서 상태바와 내비게이션 바, 키보드 높이만큼 여백을 주는 일도 Chrome 이 하던 일이었다.

그래도 이쪽을 골랐다. 두 앱 모두 웹이 본체였다. 웹을 네이티브로 다시 짜는 건 말이 안 되고, 앱이 할 일은 웹을 잘 담는 것이다. 그렇다면 그릇을 Chrome 에서 WebView 로 바꾸고, Chrome 이 하던 일을 하나씩 내 코드로 옮기면 된다. 길지만 끝이 보이는 목록이었다.

덤도 있었다. Bubblewrap 이 빠지니 twa-manifest.json, androidbrowserhelper, DelegationService, TWA 메타데이터가 사라지고 평범한 Gradle 프로젝트가 됐다. targetSdk 는 이제 내가 적은 값이 그대로 간다.

이름은 바꾸지 않았다

새로 짓는다고 해서 새 앱을 내는 게 아니었다. 이미 설치한 사용자에게는 업데이트로 가야 했다. 그래서 바꾸지 않은 것들이 있다.

먼저 패키지명이다. Play 는 패키지명으로 앱을 구분한다. 이걸 바꾸면 Play 에게는 완전히 다른 앱이고, 기존 사용자는 업데이트를 받지 못한다. 서명도 TWA 시절과 같은 업로드 키로 했다. 같은 이름, 같은 키여야 같은 앱이다.

그다음은 LauncherActivity 라는 클래스 이름이다. WebView 호스트로 속을 통째로 다시 썼으니 이름을 새로 지어도 이상하지 않았다. 그런데 홈 화면 아이콘과 바로가기는 앱을 "패키지명 + 액티비티 클래스명"으로 가리킨다. 업데이트 뒤에 그 클래스가 없으면 사용자가 홈 화면에 둔 아이콘이 갈 곳을 잃는다. 그래서 부모만 바꿨다. androidbrowserhelper 의 LauncherActivity 대신 AppCompatActivity 를 상속하고, 이름은 그대로 뒀다.

<!-- 클래스명은 기존 TWA 시절과 동일하게 유지 → 홈화면 아이콘/바로가기 호환 -->
<activity
    android:name=".LauncherActivity"
    android:exported="true"
    android:launchMode="singleTask"
    android:windowSoftInputMode="adjustResize">

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:host="@string/hostName" android:scheme="https" />
    </intent-filter>
</activity>

Digital Asset Links 도 남겼다. WebView 에는 숨길 주소창이 없으니 TWA 때의 쓸모는 사라졌다. 하지만 같은 증명이 다른 일을 한다. autoVerify="true" 인텐트 필터는 이 도메인의 https 링크를 앱이 직접 받겠다는 선언인데, Android 는 사이트의 assetlinks.json 을 확인하고 나서야 그걸 인정한다. 인정받으면 메신저에서 식단서랍 링크를 눌렀을 때 "어떤 앱으로 열까요"를 묻지 않고 바로 앱이 열린다. 패키지명과 서명이 그대로라 서버의 assetlinks.json 은 한 글자도 고치지 않았다. strings.xml 의 assetStatements 도 주석만 바꿔 그대로 옮겼다.

첫 화면이 뜨기 전

TWA 시절 스플래시는 androidbrowserhelper 가 그렸다. 메타데이터로 이미지와 배경색, 300ms 페이드아웃을 넘기면 Chrome 이 첫 페이지를 그릴 때까지 띄워 줬다. 이것도 직접 해야 했다.

방법은 단순하게 잡았다. 앱 테마의 windowBackground 에 배경색 위에 로고를 얹은 layer-list 를 둔다. 액티비티 창이 뜨는 순간부터 보이니 따로 화면을 만들 필요가 없다. WebView 는 레이아웃에서 invisible 로 시작한다. 하얀 빈 WebView 가 스플래시를 덮는 걸 막기 위해서다. 그리고 첫 내용이 그려질 때 꺼낸다.

@Override
public void onPageCommitVisible(WebView view, String url) {
    revealWebView();
}

private void revealWebView() {
    if (webView.getVisibility() != View.VISIBLE) {
        webView.setVisibility(View.VISIBLE);
        // 스플래시 이미지는 더 이상 필요 없으므로 단색 배경으로 교체해 오버드로우를 줄인다.
        getWindow().setBackgroundDrawable(
                new ColorDrawable(ContextCompat.getColor(this, R.color.backgroundColor)));
    }
}

onPageFinished 는 리소스가 다 받아질 때까지 기다리지만, onPageCommitVisible 은 첫 그림이 나갈 준비가 된 시점이라 체감이 빠르다. 한 번 꺼낸 뒤에는 창 배경을 로고 없는 단색으로 바꾼다. WebView 뒤에 계속 깔려 있을 이유가 없는 그림이다. 배경색은 웹 manifest 의 background_color 와 같은 값이라 스플래시에서 웹으로 넘어갈 때 색이 튀지 않는다.

하나 더 붙인 게 User-Agent 다.

settings.setUserAgentString(
        settings.getUserAgentString() + " RecipeDrawerApp/" + BuildConfig.VERSION_NAME);

TWA 에서는 웹이 앱 안에 있다는 걸 알기 어려웠다. Chrome 이 그리니 UA 도 Chrome 그대로다. WebView 는 내 것이니 UA 끝에 앱 이름과 버전을 붙였다. 웹은 서버에서든 스크립트에서든 이 문자열로 앱 안인지, 몇 버전인지 알 수 있다. A/S 앱도 같은 방식으로 자기 이름을 붙였다.

이미 쓴 번호

식단서랍이 먼저 옮겼다. TWA 마지막 버전이 versionCode 5 였고, WebView 로 다시 지은 첫 버전을 6 으로 올렸다. 같은 패키지명, 같은 클래스명이니 Play 에는 그냥 다음 버전이다.

A/S 앱은 그 구조를 그대로 가져왔다. LauncherActivity, 레이아웃, 테마, 스플래시, 파일 선택과 카메라, Custom Tabs 로 보내는 외부 링크, 재시도 화면까지. 확인할 것도 같았다. 업데이트 설치 뒤에 홈 화면 아이콘이 그대로 있는지, 앱이 로그인 화면이나 유지된 세션으로 열리는지, 외부 링크가 Chrome 탭으로 나가는지. 그 앱에만 있던 위치 권한은 이제 매니페스트 두 줄로 끝나지 않았다. WebView 의 위치 요청을 Android 런타임 권한에 이어 주는 코드를 새로 넣었다. 가져오다 식단서랍의 빈틈도 보였다. Android 11 부터는 매니페스트에 <queries> 로 카메라 앱 조회를 선언하지 않으면 카메라 앱을 찾는 결과가 비어서, 사진 첨부에서 촬영 선택지가 사라진다. 식단서랍에도 그대로 있던 문제라 식단서랍 쪽에도 같은 선언을 넣었다.

번호에서 한 번 걸렸다. A/S 앱은 TWA 시절 versionCode 가 6 이었고, WebView 첫 버전이 7 이었다. 그다음 앱 푸시를 넣은 빌드를 8 로 만들었는데 Play Console 이 받지 않았다. 8 은 이미 올라가 있는 번호였다. Play 는 한 번 쓴 versionCode 를 다시 받지 않는다. 9 로 올려 다시 빌드했고, 릴리스 노트에 8 은 건너뛰었다고 적었다. TWA 시절에도 4 와 5 가 배포되지 않고 지나간 적이 있다. versionCode 는 내가 기억하는 번호가 아니라 Play 가 기억하는 번호를 기준으로 올려야 한다. 그 뒤로 버전 규칙에는 "Play 에 마지막으로 올라간 번호 +1"이라고 적어 두었다.

그릇을 바꾸고 나니 Chrome 이 하던 일의 목록이 고스란히 남았다. 이 연재는 그 목록을 따라간다. 파일 선택과 카메라, 앱 안에서 받는 다운로드, WebView 가 지원하지 않는 웹 알림 대신 붙인 앱 푸시, 웹과 앱을 잇는 브리지를 남의 프레임에서 지키는 일, 그리고 렌더러가 죽거나 새 창이 열리는 순간까지. 다음 편은 아무 반응이 없던 파일 선택 버튼에서 시작한다.