본문으로 건너뛰기

로그인이 풀립니다 - 웹뷰는 브라우저가 아니다 ep.07

브라우저에서는 로그인이 유지된다. 같은 URL을 앱에서 열면 로그인 화면이다. 같은 서버를 보고 같은 이름의 쿠키를 쓴다. 다른 것은 그 쿠키를 누가 갖고 있느냐다.


브라우저의 로그인은 앱으로 건너오지 않습니다

가장 먼저 짚어야 할 것은 저장소의 소유자입니다. 브라우저에서 로그인했을 때 쿠키를 보관한 주체는 브라우저입니다. 앱이 웹뷰를 띄웠을 때 쿠키를 보관하는 주체는 그 앱입니다. 둘은 같은 기기 안에 있지만 서로를 모릅니다.

Android 문서가 이 점을 이름에서부터 드러냅니다. android.webkit.CookieManager는 “애플리케이션의 WebView 인스턴스들이 사용하는 쿠키를 관리한다”고 정의되어 있고, getInstance()로 얻는 싱글턴입니다. 저장소의 범위가 브라우저가 아니라 앱입니다.

Google은 이 차이를 비교 문서에서 직접 서술합니다. Chrome Custom Tabs를 설명하면서 “쿠키 저장소와 권한 모델을 공유하므로 사용자가 이미 로그인한 사이트에 다시 로그인할 필요가 없다”고 쓰고, 같은 문서에서 WebView는 “브라우저와 상태를 공유하지 않는다”고 명시합니다. 웹뷰를 쓰기로 한 순간 브라우저 세션과의 연결은 설계상 끊어져 있습니다.

iOS는 조금 더 간접적입니다. WKWebView의 저장소는 WKWebsiteDataStore가 관리하고, 이 객체는 WKWebViewConfiguration.websiteDataStore 프로퍼티에 매달립니다. 앱이 값을 지정하지 않으면 “기본 데이터 스토어를 사용해 데이터를 디스크에 영속적으로 저장한다”고 문서에 적혀 있습니다. 쿠키만 따로 다루는 WKHTTPCookieStore도 “특정 웹 뷰에 연결된 HTTP 쿠키를 관리하는 객체”이고, 직접 만드는 것이 아니라 데이터 스토어에서 꺼내 쓰는 물건입니다.

여기서 한 가지는 분명하게 확인됩니다. 저장소가 앱의 설정 객체에 매달려 있다는 것입니다. Apple 자신도 SFSafariViewController 문서에서 “웹 인터페이스와의 상호작용은 앱에서 볼 수 없고, 자동완성 데이터나 방문 기록, 웹사이트 데이터에 접근할 수 없다”고 쓴 다음, “앱과 Safari 사이에 데이터를 공유하려면 ASWebAuthenticationSession을 쓰라”고 안내합니다. 공유가 필요하면 별도의 API를 쓰라는 안내 자체가, 일반 웹뷰가 그 경로가 아니라는 뜻입니다.

그래서 첫 번째 결론은 이렇습니다. “브라우저에서는 로그인이 유지되는데요”는 버그 리포트가 아니라 정상 동작의 서술입니다. 앱 안의 세션은 앱 안에서 처음부터 다시 만들어져야 합니다.


앱이 고른 저장소가 세션의 수명을 정합니다

두 번째 층은 수명입니다. 앱이 웹뷰를 만들 때 어떤 저장소를 붙였느냐에 따라 세션이 앱 재시작을 넘기기도 하고 못 넘기기도 합니다.

iOS에는 이 선택이 API로 노출되어 있습니다. WKWebsiteDataStore.nonPersistent()는 “웹사이트 데이터를 메모리에 저장하고 디스크에 쓰지 않는” 데이터 스토어를 만듭니다. 문서는 이것을 “웹뷰에서 시크릿 브라우징을 구현할 때 쓰라”고 안내합니다. 앱이 보안 요건 때문에 이 설정을 골랐다면, 웹이 쿠키를 어떻게 발급하든 앱을 껐다 켜면 전부 사라집니다. 세션 쿠키냐 영속 쿠키냐를 서버에서 아무리 조정해도 결과는 같습니다.

이 상태는 앱 쪽에서 한 줄로 확인됩니다. WKWebsiteDataStore.isPersistent가 “이 객체가 데이터를 디스크에 저장하는지 나타내는 불리언 값”입니다. “저희 웹뷰의 데이터 스토어가 영속인지 확인해 주실 수 있을까요”는 앱 개발자에게 물어봐도 되는, 답이 명확한 질문입니다.

iOS 17.0부터는 식별자 기반 영속 저장소도 있습니다. WKWebsiteDataStore(forIdentifier:)는 “제공한 고유 식별자에 해당하는 영속 데이터 스토어를 반환”하며, 문서는 이것을 “프로필용 데이터 스토어를 얻는 용도”로 설명합니다. 프로필이 나뉘면 세션도 나뉩니다. 앱이 화면마다 다른 식별자를 쓴다면, 한 화면에서 로그인해도 다른 화면은 비로그인입니다.

Android에는 다른 종류의 함정이 있습니다. 쿠키가 곧바로 디스크에 기록된다는 보장이 없습니다. CookieManager.flush()가 “현재 getCookie API로 접근 가능한 모든 쿠키가 영속 저장소에 기록되도록 보장한다”고 되어 있고, 이 호출은 블로킹이며 I/O를 수행할 수 있다고 명시되어 있습니다. 즉 기록 시점을 앱이 관리해야 하는 지점이 존재합니다.

그리고 Android WebView는 DOM 저장소(DOM storage)가 기본으로 꺼져 있습니다. WebSettings.setDomStorageEnabled(boolean) 문서가 “기본값은 false”라고 명시하고 있습니다. setJavaScriptEnabled도 기본값이 false입니다. 웹 개발자 입장에서는 localStorage가 당연히 있는 물건이지만, 웹뷰에선 앱이 켜주기 전까지는 없는 물건입니다.

무엇이 어느 단위로 갈리는가

“웹뷰끼리는 상태를 공유하지 않는다”는 말은 그대로 옮기면 틀립니다. 항목마다 경계의 단위가 다릅니다.

항목플랫폼경계의 단위근거
쿠키 저장소Android 전체CookieManager가 앱 단위 싱글턴
쿠키 저장소Android프로세스API 28+ setDataDirectorySuffix: 디렉터리가 프로세스마다 달라야 함
쿠키 저장소iOS데이터 스토어지정하지 않으면 기본 스토어를 모든 웹뷰가 공유
서드파티 쿠키 정책AndroidWebView 인스턴스”허용 여부는 WebView별 정책”
카메라·마이크 웹 권한AndroidWebView 인스턴스”승인된 권한은 이 WebView에만 유효”
위치 권한Android오리진호스트·스킴·포트로 구성된 오리진 단위 저장
웹 콘텐츠 프로세스iOSWebKit이 정함WKProcessPool은 iOS 15.0에서 deprecated - 인스턴스를 나눠도 효과 없음

Android에서 프로세스가 갈리는 경우는 특히 명시적입니다. 문서가 “같은 애플리케이션의 서로 다른 프로세스는 WebView 관련 데이터를 직접 공유할 수 없다”고 쓰고, 이어서 “예를 들어 두 프로세스의 WebView가 모두 로그인 상태여야 한다면 로그인 쿠키를 한 프로세스의 쿠키 저장소에서 다른 쪽으로 복사해야 할 수 있다”고 적습니다. 앱이 결제 화면만 별도 프로세스로 띄우는 구조라면, 여기가 로그인이 풀리는 자리입니다.

iOS의 프로세스 축은 저장소 축과 다릅니다. WKProcessPool 문서는 “기본적으로 WebKit은 구현 정의된 프로세스 한계에 도달할 때까지 각 웹 뷰에 자체 프로세스 공간을 준다. 그 이후로는 같은 WKProcessPool 객체를 가진 웹 뷰들이 같은 웹 콘텐츠 프로세스를 공유한다”고 설명합니다. 다만 WKProcessPool은 iOS 15.0에서 deprecated 되었고, 같은 문서에 “여러 인스턴스를 만들어 쓰는 것은 더 이상 아무 효과가 없다”고 적혀있습니다. 앱 팀에 풀을 나눠 달라거나 합쳐 달라고 요청해도 달라지는 것이 없다는 뜻입니다. 그래도 프로세스를 공유한다는 것과 저장소를 공유한다는 것은 별개의 축입니다. 둘을 섞어 이야기하면 진단이 어긋납니다.

저장소에 얹힌 것은 세션만이 아닙니다

이 구조가 세션 밖에서 드러나는 자리가 하나 있습니다. 브라우저에 발급받아 저장해둔 인증서입니다.

정부기관 안내 페이지 기준으로, 웹저장소 기반 브라우저 인증서는 브라우저의 웹저장소 영역에 저장됩니다. 그리고 유의사항이 두 가지 명시되어 있습니다. 타 브라우저 간에 연동되지 않으며, 브라우저를 초기화하면 인증서가 삭제됩니다.

앞 절의 사실을 여기에 겹치면 결론이 보입니다. 웹뷰의 저장소는 앱이 소유하고 브라우저와 공유되지 않습니다. 브라우저 저장소 기반이라면 인증서가 어느 저장소에 있느냐가 축이고, 별도 인증 앱을 부르는 방식이라면 ep.02의 스킴 호출 문제로 내려옵니다.


서드파티 쿠키는 이미 차단이 기본입니다

인증 흐름이 여러 도메인을 오가는 경우, 다음으로 확인할 것은 서드파티 쿠키입니다.

Android는 앱의 타깃 SDK 버전(targetSdkVersion) 기준으로 기본값이 갈립니다. 문서는 “KITKAT 이하를 타깃하는 앱은 서드파티 쿠키를 허용하는 것이 기본이고, LOLLIPOP(API 21) 이상을 타깃하는 앱은 서드파티 쿠키를 허용하지 않는 것이 기본”이라고 씁니다. 여기서 기준은 기기의 OS 버전이 아니라 앱이 무엇을 타깃하는가입니다. “안드로이드 5 이상에서는”이라고 쓰면 틀리고, “앱이 API 21 이상을 타깃하면”이라고 써야 맞습니다.

쿠키 수용 자체는 기본으로 켜져 있습니다. setAcceptCookie 문서에 “기본값은 true이고 WebView는 쿠키를 받아들인다”고 되어 있습니다. 즉 자기 도메인 쿠키가 안 되는 것이 아니라, 교차 사이트 문맥에서만 막히는 것입니다.

Partitioned 속성이 붙은 쿠키도 주의 대상입니다. Android 문서는 “Partitioned 속성으로 설정된 쿠키는 해당 URL의 최상위 파티션에서만 반환된다”고 명시합니다. 파티션 키가 다르면 같은 이름의 쿠키가 없는 것처럼 보입니다.

WebKit 쪽은 더 이릅니다. 2020년 3월 WebKit 블로그가 “교차 사이트 리소스에 대한 쿠키가 이제 전면적으로 기본 차단된다”고 발표했고, 시점은 iOS·iPadOS 13.4 및 Safari 13.1입니다. 이 글 자체는 Safari 기준 서술이고 WKWebView를 명시하지 않지만, WWDC20 세션 “Discover WKWebView enhancements”에서 Apple은 “iOS 14와 macOS Big Sur에서 ITP가 모든 WKWebView 앱에 기본 활성화된다”고 발표했습니다. 지능형 추적 방지(Intelligent Tracking Prevention, ITP) 가 웹뷰에서 켜져 있다는 사실은 여기서 확인됩니다.

ITP의 정책 중 하나가 저장소 삭제입니다. 앞의 WebKit 블로그는 “사이트에서 사용자 상호작용 없이 Safari 사용 7일이 지나면 해당 웹사이트의 스크립트가 기록 가능한 저장소를 모두 삭제한다”고 씁니다. 대상은 Indexed DB, LocalStorage, Media keys, SessionStorage, 서비스 워커 등록과 캐시입니다.

두 사실을 곱하지는 않겠습니다. 원문은 “Safari 사용 7일”이라고 쓰고 있고, WKWebView에서 이 일수 정책이 어떤 기준으로 계산되는지를 서술한 문서는 확인하지 못했습니다. 확인된 것은 두 가지뿐입니다. ITP가 WKWebView에서 기본으로 켜져 있다는 것, 그리고 ITP에는 상호작용 없는 사이트의 스크립트 기록 저장소를 지우는 정책이 있다는 것. 이 두 문장 사이를 메우는 일은 자기 앱의 실기기에서 확인할 몫입니다.

실무적으로 이 층에서 할 수 있는 방어는 하나입니다. localStorage에 든 것이 언젠가 사라진다고 가정하고 코드를 짜는 것.

// 저장소는 없을 수도, 있다가 사라질 수도 있습니다
const memoryFallback = new Map()

export const safeStorage = {
  get(key) {
    try {
      return window.localStorage.getItem(key)
    } catch {
      // 저장소가 꺼져 있거나 접근이 거부된 경우
      return memoryFallback.get(key) ?? null
    }
  },
  set(key, value) {
    try {
      window.localStorage.setItem(key, value)
    } catch {
      memoryFallback.set(key, value)
    }
  },
  remove(key) {
    try {
      window.localStorage.removeItem(key)
    } catch {
      memoryFallback.delete(key)
    }
  },
}

이 래퍼의 목적은 저장소를 살려내는 것이 아닙니다. 저장소 접근 실패가 예외로 튀어 화면 전체를 죽이는 것을 막는 것입니다. 세션은 기본적으로 서버에 두고, 클라이언트 저장소는 최적화로만 씁니다.


오리진이 무너지면 연쇄로 무너집니다

여기서부터가 이 편의 축입니다. 앞의 두 층은 “저장소를 누가 갖고 있느냐”의 문제였습니다. 이 층은 다릅니다. 오리진(origin) 이라는 개념 하나가 흔들리면, 그 위에 서 있던 것들이 순서대로 넘어집니다.

증상은 로그인 실패로 시작하지만 거기서 끝나지 않습니다. API 호출이 CORS 오류로 죽고, 복사 버튼이 아무 일도 안 하고, 서비스 워커 등록이 실패하고, 위치 요청이 프롬프트도 없이 거부됩니다. 각각을 따로 고치려 들면 하루가 갑니다. 원인이 하나이기 때문입니다.

로컬 번들을 어떻게 로드했는가

계기는 대개 하나입니다. 앱이 웹 자산을 앱 번들 안에 넣어두고 file:// 경로로 로드한 경우입니다. 배포 속도나 초기 로딩 때문에 충분히 나올 수 있는 선택입니다.

문제는 file: 스킴의 오리진이 표준에서 확정되어 있지 않다는 점입니다. WHATWG URL Standard의 오리진 절은 "file" 항목에 대해 “안타깝게도 이것은 독자에게 남겨진 연습 문제다. 애매하면 새로운 불투명 오리진을 반환하라”고 적어 두었습니다. 스펙이 구현체에 맡긴 자리입니다.

Android는 그 자리를 명시적으로 채웠습니다. 로컬 콘텐츠 로드 문서가 “file:// URL과 data: URL은 불투명 오리진(opaque origin) 으로 간주되며, 이는 곧 fetch()XMLHttpRequest 같은 강력한 웹 API의 이점을 누릴 수 없다는 뜻”이라고 씁니다. 그리고 loadData()가 내부적으로 data: URL을 쓰므로 WebViewAssetLoaderloadDataWithBaseURL()을 쓰라고 권합니다.

불투명 오리진에서 무엇이 깨지는가

불투명 오리진이 되면 세 갈래가 동시에 갈라집니다. 각각이 서로 다른 스펙 조항에 걸립니다.

첫째, Web Storage 접근이 예외로 던져집니다. HTML Standard의 Web Storage 절은 localStoragesessionStorage 게터에 대해 “문서의 오리진이 불투명 오리진이거나 요청이 정책 결정을 위반하면 "SecurityError" DOMException을 던진다”고 규정합니다. 값이 비어 있는 것이 아니라 접근 자체가 예외입니다. 앞 섹션의 safeStorage 래퍼가 필요한 이유가 여기 있습니다.

둘째, 교차 출처 요청에 Origin: null이 실려 나갑니다. HTML Standard는 불투명 오리진을 “재구성할 수 있는 직렬화가 없는 내부 값(직렬화하면 "null"이 된다)이며, 유일하게 의미 있는 연산은 동등성 비교뿐”이라고 정의합니다. 서버의 교차 출처 리소스 공유(CORS) 허용 목록에는 도메인을 적게 되어 있는데, 적을 도메인이 없습니다. 서버가 Access-Control-Allow-Origin: null을 내려주면 통과하기는 합니다. 다만 MDN은 이 값을 쓰지 말라고 했습니다. data:file: 같은 비계층 스킴의 리소스와 샌드박스 문서가 모두 null로 직렬화되고, 어떤 적대적 문서든 null 오리진을 만들어 그 허용을 그대로 받아 갈 수 있기 때문입니다. 즉 이 층을 CORS 설정으로 푸는 길은 없습니다.

셋째, 보안 컨텍스트 판정이 첫 단계에서 끊깁니다. 여기는 뒤에서 따로 봅니다.

우회 스위치는 이미 잠겨 있습니다

Android에는 이 벽을 넘는 스위치가 두 개 있었습니다. setAllowFileAccessFromFileURLssetAllowUniversalAccessFromFileURLs입니다. 둘 다 API 16에 추가되었고 API 30에서 deprecated 되었습니다. 기본값은 앱이 API 16 이상을 타깃하면 false입니다. 문서는 “이 설정은 안전하지 않습니다. androidx.webkit.WebViewAssetLoader로 파일 콘텐츠를 안전하게 로드하십시오”라고 쓰고, “이 설정을 켜면 file:// 문맥에서 로드된 악성 스크립트가 교차 사이트 스크립팅 공격을 시작할 수 있다”고 경고합니다.

파일 접근 자체도 기본값이 뒤집혔습니다. setAllowFileAccess는 “Q(API 29) 이하를 타깃하는 앱에서는 기본값이 true이고, R(API 30) 이상을 타깃하면 false”입니다. 여기도 기준은 기기 OS가 아니라 앱의 타깃 SDK입니다.

file:// 스킴 쿠키도 마찬가지입니다. setAcceptFileSchemeCookies는 “file 스킴 URL에 쿠키를 쓰는 것은 잠재적으로 안전하지 않으며 기본적으로 꺼져 있다”고 되어 있고, 켜더라도 “모든 file:// URL이 쿠키를 전부 공유하므로 앱의 비공개 쿠키가 새어 나갈 수 있다”고 경고합니다. 이 메서드도 API 30에서 deprecated 되었습니다.

정리하면, 우회 방향은 플랫폼이 이미 닫았습니다. 남은 방향은 하나뿐입니다. 로컬 자산을 file://이 아닌 것으로 서빙하는 것.

그래서 앱에 요청할 것

Android에서 문서화된 해법은 WebViewAssetLoader입니다. androidx.webkit이 제공하고, 앱 내부 파일을 예약 도메인(appassets.androidplatform.net)의 URL로 서빙합니다. 문서가 이유를 직접 씁니다. “\"file://\" 대신 웹과 유사한 URL로 로컬 파일을 로드하는 것이 바람직한데, 동일 출처 정책과 호환되기 때문입니다.”

// 앱에 켜달라고 요청하는 지점입니다. 구현 전체가 아니라 이 방향을 요청하는 것이 목적입니다
val assetLoader = WebViewAssetLoader.Builder()
    .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
    .build()

빌더의 setHttpAllowed는 “HTTPS에 더해 HTTP 스킴 사용을 허용한다. 기본은 HTTP를 허용하지 않는 것”이므로, 별도로 켜지 않으면 자산은 https:// URL로 서빙됩니다. 이것이 핵심입니다. 오리진이 정상 튜플이 되면 앞의 세 갈래가 모두 원상복구됩니다.

iOS에서 문서화된 경로는 두 가지입니다. loadFileURL(_:allowingReadAccessTo:)은 로컬 파일을 로드하되 읽기 허용 범위를 제한합니다. 문서는 “WebKit이 다른 콘텐츠를 읽지 못하게 하려면 URL 파라미터와 같은 값을 지정하라”고 안내합니다. 다른 하나는 WKURLSchemeHandler로, “WebKit이 처리하지 않는 URL 스킴의 리소스를 로드하기 위한 프로토콜”입니다. 이 말은 곧 httphttps에는 등록할 수 없다는 뜻이기도 합니다.

커스텀 스킴 핸들러로 등록한 스킴이 보안 컨텍스트로 취급되는지에 대한 Apple 문서는 찾지 못했습니다. W3C 스펙은 “사용자 에이전트가 벤더별 스킴으로 이 신뢰를 확장하는 것을 선택할 수 있다”고만 씁니다. 커스텀 스킴을 쓰기로 했다면, 뒤에 나오는 진단 스니펫으로 실기기에서 직접 확인하셔야 합니다.

오리진 판정에서 시작해 세 갈래로 번지는 연쇄를 그린 경계도. 다이어그램은 위에서 아래로 네 개의 가로 층으로 구성된다. 최상단 '로드 층'에는 두 개의 입력 상자가 나란히 있다. 왼쪽 상자는 '앱이 https 로 서빙한 문서: WebViewAssetLoader 또는 원격 서버'이고 초록 계열, 오른쪽 상자는 '앱이 file 스킴으로 로드한 로컬 번들: loadData 포함'이고 붉은 계열이다. 두 상자에서 아래로 화살표가 내려와 두 번째 '오리진 층'의 판정 마름모 'UA가 이 문서에 어떤 오리진을 부여하는가'로 모이며, 마름모 옆에 근거 라벨 'URL Standard 4.7: file 스킴은 구현 정의, 애매하면 새 opaque origin'이 붙어 있다. 마름모에서 두 갈래가 나온다. 왼쪽 갈래는 초록색으로 '튜플 오리진: scheme + host + port, 직렬화 가능' 상자로 이어지고, 오른쪽 갈래는 붉은색으로 '불투명 오리진: 직렬화하면 문자열 null, 동등성 비교만 가능' 상자로 이어진다. 세 번째 층은 '연쇄 층'으로, 불투명 오리진 상자에서 붉은 화살표 세 개가 아래로 갈라진다. 첫째 화살표는 '① Web Storage' 상자로 이어지며 내용은 'localStorage 와 sessionStorage 접근이 SecurityError 로 던져짐 (HTML Standard 12.2)'이다. 둘째 화살표는 '② 교차 출처 요청' 상자로 이어지며 내용은 '요청 헤더에 Origin: null 이 실림. 서버 CORS 허용 목록에 등록할 도메인이 없음 (HTML Standard 7.1.1)'이다. 셋째 화살표는 '③ 보안 컨텍스트 판정' 상자로 이어지며 내용은 'W3C Secure Contexts 3.1: 1단계에서 opaque origin 이면 즉시 Not Trustworthy 로 종료. 뒤쪽의 file 스킴은 Potentially Trustworthy 단계에 도달하지 못함'이다. 네 번째 층은 세 번째 갈래에서만 이어지는 '신뢰 의존 기능 층'으로, ③ 상자 아래에 세 개의 작은 상자가 가로로 배열된다. 왼쪽은 'Clipboard API: 보안 컨텍스트 전용, 읽기는 사용자 활성화도 필요', 가운데는 '서비스 워커 등록: 스킴이 http 또는 https 가 아니면 TypeError, 오리진이 신뢰 불가면 SecurityError', 오른쪽은 'Android 위치 요청: 앱이 API 23 초과를 타깃하면 비보안 오리진의 요청은 프롬프트 없이 자동 거부'다. 세 상자 모두 붉은 점선 테두리다. 왼쪽 초록색 '튜플 오리진' 상자에서는 세 번째 층과 네 번째 층을 통과하는 굵은 초록 세로선이 그려져 있고 '세 갈래 모두 정상 경로'라는 라벨이 붙는다. 다이어그램 맨 아래에는 가로로 긴 해법 띠가 있고 제목은 '오리진을 되돌리는 경로: 앱에 요청할 것'이다. 띠 안은 좌우 두 칸으로 나뉘어, 왼쪽 칸은 'Android: WebViewAssetLoader 로 appassets.androidplatform.net 의 https URL 로 서빙. setHttpAllowed 기본값은 HTTP 비허용', 오른쪽 칸은 'iOS: loadFileURL 로 읽기 범위 제한, 또는 WKURLSchemeHandler 로 커스텀 스킴 처리. http 와 https 에는 등록 불가'다. 해법 띠에서 위쪽 오리진 층의 마름모를 향해 초록 곡선 화살표가 올라가며 '판정을 튜플 쪽으로 되돌림'이라는 라벨이 붙어 있다. 다이어그램 왼쪽 가장자리에는 네 개 층의 이름이 세로 라벨로 표시된다: 위에서부터 '로드 층', '오리진 층', '연쇄 층', '신뢰 의존 기능 층'.

보안 컨텍스트는 오리진과 같은 것이 아닙니다

이 편에서 가장 틀리기 쉬운 지점입니다. 보안 컨텍스트(secure context) 는 오리진의 다른 이름이 아니라 그 위에 얹힌 별도의 판정입니다.

W3C Secure Contexts의 판정 알고리즘은 첫 단계가 “오리진이 불투명 오리진이면 "Not Trustworthy"를 반환한다”입니다. 그리고 뒤쪽 단계에 “오리진의 스킴이 "file"이면 "Potentially Trustworthy"를 반환한다”가 있습니다.

두 조항이 모순처럼 보이지만 모순이 아닙니다. 순서 때문입니다. 사용자 에이전트가 이 file 문서에 어떤 오리진을 부여했느냐가 결과를 가릅니다. 스킴 file의 튜플 오리진을 부여했다면 뒤쪽 단계에 도달해 신뢰 대상이 되고, 불투명 오리진을 부여했다면 1단계에서 판정이 끊깁니다. 그리고 URL 표준은 애매하면 불투명 쪽을 권고합니다.

스펙 본문도 이 여지를 열어 둡니다. “사용자 에이전트는 file URL을 잠재적으로 신뢰 가능한 것으로 다루어야 한다(SHOULD). … 그러한 편의보다 보안을 우선하는 사용자 에이전트는 file을 배제하는 방향으로 더 엄격하게 신뢰를 할당하는 것을 선택할 수 있다(MAY).” MDN도 로컬 전달 리소스를 열거한 뒤 “file:// URL도 일반적으로 마찬가지”라고만 씁니다.

따라서 “file 스킴은 보안 컨텍스트가 아닙니다”도, “보안 컨텍스트입니다”도 단정할 수 없습니다. 정확한 문장은 이것입니다. 사용자 에이전트가 file 문서에 불투명 오리진을 부여하는 순간, 보안 컨텍스트 판정이 1단계에서 끊깁니다.

그리고 중요한 것은 층이 다르다는 점입니다. localStorageSecurityError로 죽는 이유와 클립보드가 안 되는 이유는 같은 원인에서 출발하지만 서로 다른 조항을 탑니다. 앞의 둘은 불투명 오리진 자체 때문이고, 클립보드는 그 결과로 내려진 보안 컨텍스트 판정 때문입니다. 이 구분을 못 하면 “오리진을 고쳤는데 왜 아직 안 되지”에서 멈추게 됩니다.

Clipboard API 문서는 “클립보드 API는 보안 컨텍스트에서 프로그램적으로 클립보드를 읽고 쓸 수 있게 한다”고 씁니다. 읽기에는 조건이 하나 더 붙습니다. “스펙은 사용자가 최근에 페이지와 상호작용했을 것(일시적 사용자 활성화)을 요구한다”이고, Firefox와 Safari에서는 쓰기에도 일시적 활성화가 필요합니다.

Android 쪽에는 별개의 API 표면 사실이 하나 있습니다. 웹 콘텐츠의 권한 요청을 앱에 전달하는 PermissionRequest 클래스가 정의한 리소스 상수는 RESOURCE_AUDIO_CAPTURE, RESOURCE_VIDEO_CAPTURE, RESOURCE_MIDI_SYSEX, RESOURCE_PROTECTED_MEDIA_ID 넷뿐입니다. 클립보드는 이 목록에 없습니다. 이것은 상수 목록에서 확인한 사실이고, 런타임에 클립보드 읽기가 어떤 경로를 타는지를 서술한 공식 문서는 확인하지 못했습니다.

실무에서는 클립보드 쓰기를 성공 전제로 두지 않는 편이 안전합니다.

async function copyAccountNumber(text, fallbackEl) {
  // 보안 컨텍스트가 아니면 navigator.clipboard 자체가 없습니다
  if (window.isSecureContext && navigator.clipboard) {
    try {
      await navigator.clipboard.writeText(text)
      return 'copied'
    } catch (err) {
      // NotAllowedError 등 — 사용자 활성화 조건이나 권한에서 막힌 경우
      console.warn('클립보드 쓰기 실패', err.name)
    }
  }
  // 복사 실패를 무음으로 두지 않습니다. 사용자가 직접 선택할 수 있게 노출합니다
  fallbackEl.hidden = false
  fallbackEl.textContent = text
  return 'manual'
}

핵심은 복사가 실패했다는 사실을 사용자에게 알리는 것입니다. 복사가 안 된 것이 아니라, 복사됐다고 표시된 채 클립보드가 비어 있는 것이 제일 나쁩니다.

지금 이 웹뷰의 경계를 한 번에 찍어보기

앞의 논의를 실기기에서 확인하는 스니펫입니다. 앱 개발자에게 이슈를 넘기기 전에 이 출력을 첨부하면 대화가 훨씬 짧아집니다.

function probeOriginBoundary() {
  const storage = (name) => {
    try {
      const s = window[name]
      s.setItem('__probe__', '1')
      s.removeItem('__probe__')
      return 'ok'
    } catch (err) {
      return err.name // 불투명 오리진이면 SecurityError
    }
  }

  return {
    href: location.href,
    protocol: location.protocol,
    origin: location.origin, // 불투명 오리진이면 문자열 "null"
    isSecureContext: window.isSecureContext,
    localStorage: storage('localStorage'),
    sessionStorage: storage('sessionStorage'),
    cookieEnabled: navigator.cookieEnabled,
    cookieLength: document.cookie.length,
    clipboard: Boolean(navigator.clipboard),
    serviceWorker: 'serviceWorker' in navigator,
    geolocation: 'geolocation' in navigator,
    userAgent: navigator.userAgent,
  }
}

console.table(probeOriginBoundary())

location.origin"null"로 찍히고 isSecureContextfalse라면, 그 화면에서 벌어지는 모든 증상의 원인은 하나입니다. 개별 기능을 하나씩 고치는 대신 로드 방식 자체를 바꿔야 합니다. 실기기 콘솔을 여는 절차는 ep.01에 있습니다.


권한은 두 개의 관문을 지납니다

브라우저에서 카메라 권한은 한 번 물어보고 끝납니다. 웹뷰에서는 관문이 둘입니다. 웹 권한앱 권한이 각각 따로 있고, 둘 다 통과해야 합니다.

Android 위치 문서가 이 구조를 그대로 서술합니다. setGeolocationEnabled의 기본값은 true인데, 이어지는 문장이 “WebView 안의 페이지가 Geolocation API를 쓸 수 있으려면 다음 요건이 충족되어야 한다”입니다. 요건은 두 개입니다. 앱이 기기 위치 접근 권한(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION)을 갖고 있어야 하고, 앱이 WebChromeClient.onGeolocationPermissionsShowPrompt 콜백을 구현해야 합니다. 웹뷰 설정은 켜져 있어도 나머지 둘이 비어 있으면 아무 일도 일어나지 않습니다.

여기에 오리진 조건이 하나 더 붙습니다. onGeolocationPermissionsShowPrompt 문서는 “Android N 이후 SDK를 타깃하는 애플리케이션(API 레벨이 23 초과)에서는 https 같은 보안 오리진에서 발생한 요청에 대해서만 이 메서드가 호출된다. 비보안 오리진의 위치 요청은 자동으로 거부된다”고 씁니다. 앞 섹션의 오리진 이야기가 여기서 다시 만납니다. 프롬프트가 아예 안 뜨는 것이 정상 동작입니다.

승인이 저장되는 단위도 오리진입니다. GeolocationPermissions 문서는 “위치 권한은 호스트, 스킴, 포트로 구성된 오리진에 적용된다”고 명시합니다. 도메인이 바뀌면 승인도 다시 받아야 합니다.

카메라와 마이크는 더 단호합니다. WebChromeClient.onPermissionRequest 문서가 “호스트 애플리케이션은 PermissionRequest.grant(String[]) 또는 deny()를 호출해야 한다. 이 메서드를 오버라이드하지 않으면 권한은 거부된다”고 씁니다. 앱이 아무것도 안 하면 거부입니다. 그리고 승인의 유효 범위는 PermissionRequest.grant 문서에 “승인된 권한은 이 WebView에만 유효하다”고 되어 있습니다. 앱이 새 웹뷰를 띄우면 다시 물어봐야 합니다. 그 위에 앱 자체의 런타임 권한이 또 있습니다. Manifest.permission.CAMERA는 보호 등급이 dangerous라 사용자 런타임 승인이 필요합니다.

iOS는 시기별로 갈립니다. getUserMedia는 WWDC21 세션 발언 기준으로 “iOS 14.3부터 WKWebView에서 활성화”되었고, WebKit 블로그는 “임베딩하는 애플리케이션이 오디오나 비디오를 네이티브로 캡처할 수 있으면 navigator.mediaDevices.getUserMedia가 자동으로 노출된다”고 덧붙입니다.

프롬프트가 두 번 뜨는 것도 문서화된 기본 동작입니다. 같은 WWDC21 세션이 “이 앱에서 카메라를 처음 쓰는 것이므로 시스템 전역 프롬프트를 받는다. … 그리고 현재 보고 있는 웹사이트에 특정된 두 번째 프롬프트가 오늘날 시스템의 기본 동작”이라고 설명합니다. 사용자가 “왜 두 번 물어보냐”고 하면, 그것이 설계입니다.

iOS 15.0부터는 앱이 두 번째 프롬프트를 대신 결정할 수 있습니다. WKUIDelegate의 미디어 캡처 권한 메서드가 그것이고, 문서는 “델리게이트에 이 메서드를 구현하지 않으면 시스템이 prompt를 반환한다”고 씁니다. 기기 방향·모션 권한 델리게이트도 iOS 15.0에 함께 들어왔고 기본 동작은 같습니다.

위치는 사정이 다릅니다. WKUIDelegate의 위치 권한 메서드는 iOS 27.0에 처음 추가되었고, 이 글을 쓰는 2026년 8월 25일 현재 Apple 문서 메타데이터가 베타로 표시되어 있습니다. “iOS 27부터 됩니다”가 아니라 “iOS 27에 추가되었고 현재는 베타입니다”가 정확한 서술입니다. 그 이전 버전에서 navigator.geolocation이 어떤 프롬프트 경로를 타는지는 공식 문서를 찾지 못했으므로, 자기 앱에서 실측하시는 편이 안전합니다.

두 플랫폼 모두 공통인 것이 하나 있습니다. iOS에서는 카메라나 위치를 쓰려면 Info.plist에 사용 목적 문자열이 있어야 하고, NSCameraUsageDescription 문서는 “앱이 기기 카메라에 접근하는 API를 사용한다면 이 키는 필수”라고 씁니다. 문자열이 없으면 프롬프트 이전 단계에서 막힙니다.

웹 쪽에서 할 수 있는 일은 실패를 구분해서 안내하는 것입니다. 다만 한계가 분명합니다.

async function startCamera(videoEl) {
  if (!navigator.mediaDevices?.getUserMedia) {
    // 보안 컨텍스트가 아니거나 앱이 노출하지 않은 경우
    return { ok: false, reason: 'unavailable' }
  }
  try {
    videoEl.srcObject = await navigator.mediaDevices.getUserMedia({ video: true })
    return { ok: true }
  } catch (err) {
    // NotAllowedError 는 두 관문 중 어디서 막혔는지 구분해주지 않습니다
    // NotFoundError 는 장치가 없다는 뜻이고, 이쪽은 원인이 비교적 명확합니다
    return { ok: false, reason: err.name }
  }
}

NotAllowedError 하나로는 앱 권한에서 막혔는지 사이트 권한에서 막혔는지 알 수 없습니다. 그래서 안내 문구를 “카메라 권한을 허용해 주세요” 한 줄로 두면 사용자가 어디를 열어야 할지 모릅니다. 앱 설정의 권한 화면으로 가는 경로를 함께 안내하도록 앱과 합의해 두는 편이 실제로 도움이 됩니다.


스테이징에 못 들어가는 이유는 세 개의 층입니다

“개발 서버 주소를 앱에 넣었는데 흰 화면입니다”는 자주 나오는 제보입니다. 원인 후보가 세 층으로 쌓여 있고, 층마다 담당이 다릅니다.

1층. 플랫폼 네트워크 정책

iOS에서는 웹뷰에 도달하기 전에 앱 전송 보안(App Transport Security, ATS) 이 먼저 막습니다. 문서는 “ATS는 URL 로딩 시스템으로 이루어지는 모든 HTTP 연결이 HTTPS를 사용할 것을 요구한다. … ATS는 최소 보안 사양을 충족하지 못하는 연결을 차단한다”고 씁니다.

예외 키가 있기는 합니다. NSAllowsArbitraryLoadsInWebContent는 “이 키의 값을 YES로 설정하면 URLSession 연결에는 영향을 주지 않으면서 앱의 WKWebView 인스턴스를 ATS 제한에서 면제한다”고 되어 있습니다. 다만 같은 문서가 “이 키의 값을 YES로 설정하면 App Store 심사에서 사유를 제출해야 한다”고 명시합니다. “그냥 켜달라고 하세요”로 끝낼 수 있는 항목이 아닙니다.

iOS 14.5부터는 자동 승격 스위치도 있습니다. WKWebViewConfiguration.upgradeKnownHostsToHTTPS는 “웹 뷰가 지원되는 HTTP 요청을 자동으로 HTTPS로 승격할지 나타내는 불리언 값”입니다. 로컬 디버깅에서 예상 밖의 승격이 일어난다면 이 값을 확인해볼 지점입니다.

2층. 혼합 콘텐츠 정책

HTTPS 문서 안에서 HTTP 리소스를 부르는 혼합 콘텐츠(mixed content) 는 일부만 승격되고 나머지는 차단됩니다. MDN은 “브라우저는 이미지·비디오·오디오의 혼합 콘텐츠 요청을 HTTP에서 HTTPS로 자동 승격하고, 그 밖의 모든 리소스 유형에 대한 안전하지 않은 요청은 차단한다”고 정리합니다.

Android에서는 이 정책의 기본값이 다시 타깃 SDK로 갈립니다. setMixedContentMode 문서는 “KITKAT 이하를 타깃하는 앱은 MIXED_CONTENT_ALWAYS_ALLOW가 기본이고, LOLLIPOP(API 21)을 타깃하는 앱은 MIXED_CONTENT_NEVER_ALLOW가 기본”이라고 씁니다. 그리고 “가장 안전한 모드는 MIXED_CONTENT_NEVER_ALLOW이며 MIXED_CONTENT_ALWAYS_ALLOW 사용은 강하게 권장되지 않는다”고 덧붙입니다.

중간값인 MIXED_CONTENT_COMPATIBILITY_MODE는 더 애매합니다. 문서 자신이 “일부 안전하지 않은 콘텐츠는 보안 오리진에서 로드되도록 허용되고 다른 유형은 차단된다. 어떤 유형이 허용되거나 차단되는지는 릴리스마다 바뀔 수 있으며 명시적으로 정의되어 있지 않다”고 씁니다. 이 모드에 의존하는 코드는 다음 WebView 업데이트에서 동작이 바뀔 수 있습니다.

웹 쪽에서 먼저 확인할 수 있는 것이 있습니다. 문서 안에 평문 HTTP로 나가는 서브리소스가 남아 있는지입니다.

// 배포 전 점검용: HTTPS 문서 안에서 http:// 로 나가는 서브리소스를 찾습니다
const insecure = Array.from(document.querySelectorAll('[src], [href], [srcset]'))
  .flatMap((el) => [
    el.getAttribute('src'),
    el.getAttribute('href'),
    el.getAttribute('srcset'),
  ])
  .filter((url) => url && url.startsWith('http://'))

console.log('평문 HTTP 리소스', insecure)

빌드 산출물에 절대 경로로 들어간 CDN 주소나 레거시 이미지 호스트가 여기서 자주 걸립니다. 이건 앱에 요청할 일이 아니라 웹에서 고칠 일입니다.

3층. TLS 인증서 검증

사설 인증서를 쓰는 스테이징이 여기서 막힙니다. Android WebViewClient.onReceivedSslError 문서는 “기본 동작은 리소스 로딩 과정을 취소하는 것”이라고 씁니다.

그리고 같은 문서가 아주 강한 경고를 붙입니다. “주의: 사용자에게 SSL 오류에 대해 묻지 마십시오. 사용자가 정보에 근거한 보안 판단을 내리기는 어렵고, WebView는 오류의 상세를 의미 있게 보여줄 UI를 제공하지 않습니다.” 브라우저에는 있는 “고급 - 계속 진행” 화면이 웹뷰에는 없습니다. 없는 것이 아니라 만들지 말라고 문서가 말리고 있습니다.

iOS도 비슷합니다. WKNavigationDelegate의 인증 챌린지 메서드는 “이 메서드를 구현하지 않으면 웹 뷰가 rejectProtectionSpace 처리로 인증 챌린지에 응답한다”고 되어 있습니다.

따라서 이 층에서 앱 개발자에게 “예외를 뚫어달라”고 요청하는 것은 Android 문서가 명시적으로 말리는 방향입니다. 요청의 방향을 바꿔야 합니다. 스테이징에 신뢰 가능한 인증서를 붙이거나, 개발 빌드 전용 접속 경로를 따로 만드는 쪽입니다. 운영 빌드에 예외가 남으면 그 자체가 보안 이슈가 됩니다.


서비스 워커는 두 플랫폼을 대칭으로 쓸 수 없습니다

오프라인 대응이나 자산 캐싱을 서비스 워커로 설계했다면, 두 플랫폼을 같은 문장으로 서술할 수 없습니다.

스펙 쪽 조건부터 봅니다. 서비스 워커(service worker) 등록 알고리즘은 “scriptURL의 스킴이 "http""https" 중 하나가 아니면 TypeError로 프로미스를 거부하고 단계를 중단한다”입니다. 그리고 오리진 조건도 있습니다. “작업의 스크립트 URL 오리진에 대해 잠재적으로 신뢰 가능한 오리진 판정을 실행한 결과가 Not Trusted이면 "SecurityError" DOMException으로 작업 프로미스를 거부한다.” ServiceWorker 인터페이스 자체가 [SecureContext]로 선언되어 있습니다. 앞의 오리진 섹션이 여기서도 그대로 걸립니다.

Android는 지원한다고 말할 수 있습니다. ServiceWorkerController 클래스가 “WebView가 사용하는 서비스 워커를 관리한다”고 정의되어 있고, 클래스와 getInstance()·setServiceWorkerClient()·getServiceWorkerWebSettings() 세 메서드 모두 “API 레벨 24에서 추가됨”으로 표기되어 있습니다. 즉 Android 7.0부터 제어 API가 문서화되어 있습니다.

iOS는 그렇게 쓸 수 없습니다. Apple이 WKWebView에서의 서비스 워커 지원을 문서화한 적이 없습니다. 2018년 WebKit 블로그는 서비스 워커 도입을 알리면서 “현재로서는 Safari, SFSafariViewController를 사용하는 애플리케이션, 홈 화면에 저장된 웹 애플리케이션에서만 사용할 수 있다”고 썼고, 이 목록에 WKWebView는 없습니다. 2020년 4월 WebKit 버그 트래커에 “app-bound domains에 대해 서비스 워커를 활성화”라는 항목이 RESOLVED FIXED로 기록되어 있지만, 이것은 엔지니어링 기록이지 사용자용 문서가 아닙니다. 그리고 2025년 2월 Apple Developer Forums에서 Apple 프레임워크 엔지니어 라벨이 붙은 답변은 “현재 사용 가능한 API로는 iOS WKWebView에서 서비스 워커를 명시적으로 지원할 방법이 없다”였습니다.

다만 app-bound domains를 켜면 서비스 워커가 된다는 인과는 Apple 문서가 보증하지 않습니다. app-bound domains 자체는 공식 기능이고, Info.plist에 WKAppBoundDomains 배열로 최대 10개 도메인을 지정하는 방식입니다. 그런데 WebKit 블로그의 설명이 이렇습니다. “WKAppBoundDomains 키가 Info.plist에 추가되면 애플리케이션의 모든 WKWebView 인스턴스가 JavaScript 주입, 커스텀 스타일시트, 쿠키 조작, 메시지 핸들러 사용이 거부되는 모드를 기본으로 하게 됩니다.” 되돌리려면 limitsNavigationsToAppBoundDomains 플래그를 설정해야 합니다. 그리고 이 블로그 글은 서비스 워커를 언급하지 않습니다.

웹 개발자에게 이것은 “앱에 요청할 것”이 아니라 “앱에 요청하면 다른 것이 부서지는 것” 입니다. 메시지 핸들러는 웹과 네이티브를 잇는 통로이고, 쿠키 조작은 인증 흐름의 일부입니다.

부수적으로, Android WebView에서 Notification API와 Push API는 MDN browser-compat-data에 미지원으로 기록되어 있습니다. 서비스 워커가 돈다고 해서 푸시가 따라오지 않습니다.

결론은 설계 원칙 하나로 압축됩니다.

// 서비스 워커 등록은 성공 전제가 아니라 최적화 시도로 다룹니다
if ('serviceWorker' in navigator && window.isSecureContext) {
  navigator.serviceWorker.register('/sw.js').catch((err) => {
    // TypeError: 스크립트 URL 스킴이 http/https 가 아님
    // SecurityError: 오리진이 신뢰 가능하지 않음
    console.warn('service worker 등록 실패', err.name, err.message)
  })
}

서비스 워커가 없어도 화면이 정상 동작해야 합니다. 캐싱이나 오프라인 대응을 서비스 워커에만 의존해 설계하면, iOS 웹뷰에서 그 기능은 존재하지 않는 기능이 됩니다. 배포 후 캐시가 갱신되지 않는 문제 자체는 ep.08에서 따로 다룹니다.


웹뷰 안쪽이 접근성에서 통째로 사라질 수 있습니다

권한과 저장소 이야기에 하나를 덧붙입니다. 웹뷰는 앱 화면 안에 놓인 하나의 뷰이고, 그 뷰에 붙은 접근성 설정이 웹 콘텐츠 전체의 노출 여부를 결정할 수 있습니다.

Android에는 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS 상수가 있습니다. 문서 설명은 “이 뷰는 접근성에 중요하지 않으며, 그 하위 뷰들도 마찬가지다”입니다. setImportantForAccessibility로 이 값이 웹뷰에 설정되면, 웹이 aria 속성을 아무리 정확히 붙여도 접근성 서비스가 그 안을 보지 못합니다.

iOS에도 대응하는 프로퍼티가 있습니다. accessibilityElementsHidden은 접근성 요소가 포함한 하위 요소들을 통째로 감추는 불리언 프로퍼티입니다.

그리고 뷰가 자기 내부 구조를 접근성 서비스에 노출하는 공식 계약은 Android에서 AccessibilityNodeProvider로 정의되어 있습니다. 문서는 이것을 “주어진 뷰를 루트로 하는 가상 뷰 계층에 대한 접근성 지원을 가능하게 하기 위해 클라이언트가 구현해야 하는 계약”이라고 설명하고, “가상 뷰 계층은 접근성 서비스가 창 콘텐츠를 탐색할 때 뷰 계층의 일부로 보고되는 가상의 뷰 트리”라고 덧붙입니다.

웹 화면의 접근성이 실기기에서 전혀 읽히지 않는다면, 웹 마크업을 다시 뜯기 전에 앱이 웹뷰 자체를 접근성에서 감추고 있지는 않은지 먼저 물어보는 편이 빠릅니다. 웹에서 아무리 고쳐도 바뀌지 않는 종류의 문제이기 때문입니다.


그래서 무엇을 하면 되나

이 편에서 다룬 것은 겉으로는 여러 증상이지만 층은 셋입니다. 저장소를 누가 소유하는가, 오리진이 무엇으로 판정되는가, 그 위의 신뢰 조건이 충족되는가. 담당을 가를 때도 이 세 층으로 나눕니다.

웹 개발자가 할 일

  • 저장소가 없거나 사라진다고 가정하고 짭니다. localStorage 접근을 예외 처리로 감싸고, 실패해도 화면이 죽지 않게 합니다. 세션의 진실은 서버에 두고 클라이언트 저장소는 최적화로만 씁니다.
  • 평문 HTTP 서브리소스를 없앱니다. 빌드 산출물에 남은 http:// 절대 경로는 앱 설정과 무관하게 웹이 고칠 문제입니다. 배포 전 점검에 위의 스니펫을 넣어둡니다.
  • 보안 컨텍스트 의존 기능에 대체 경로를 둡니다. 클립보드, 서비스 워커, 위치, 카메라는 모두 실패할 수 있습니다. 실패를 무음으로 두지 않고 사용자가 다음에 무엇을 할지 알 수 있게 합니다.
  • 서비스 워커 없이 성립하는 설계로 갑니다. iOS 웹뷰에서는 없는 기능으로 취급합니다.
  • 이슈를 넘기기 전에 경계를 찍습니다. 오리진 진단 스니펫의 출력을 첨부하면 앱 개발자가 어느 설정을 볼지 바로 특정됩니다.

앱에 요청할 일

  • 로컬 번들 로드 방식. file:// 대신 Android는 WebViewAssetLoader, iOS는 loadFileURL 또는 WKURLSchemeHandler 경로를 요청합니다. 오리진 하나가 정상화되면 여러 증상이 함께 사라집니다.
  • 데이터 스토어가 영속인지. iOS는 WKWebsiteDataStore.isPersistent로 한 줄 확인이 됩니다. 비영속이라면 그 판단의 배경을 먼저 듣고, 세션 설계를 그 전제에 맞춥니다.
  • DOM 저장소가 켜져 있는지. Android는 setDomStorageEnabled 기본값이 꺼짐입니다. 이건 켜달라고 요청할 항목입니다.
  • 서드파티 쿠키가 필요한 인증 흐름이 있는지. Android는 setAcceptThirdPartyCookies가 WebView 인스턴스별 정책이고, 앱이 API 21 이상을 타깃하면 기본이 차단입니다. 필요한 웹뷰를 특정해서 요청합니다.
  • 웹뷰가 여러 개이거나 프로세스가 갈리는지. Android에서 별도 프로세스의 WebView는 쿠키를 직접 공유하지 못합니다. 결제나 인증만 별도 프로세스로 띄우는 구조라면 여기가 로그인이 풀리는 지점입니다.
  • 권한 콜백이 구현되어 있는지. Android는 onPermissionRequest를 오버라이드하지 않으면 무조건 거부이고, 위치는 onGeolocationPermissionsShowPrompt가 필요합니다. iOS는 Info.plist 사용 목적 문자열이 필수입니다.
  • 스테이징 접속 경로. 인증서 오류를 뚫어달라고 요청하지 않습니다. 신뢰 가능한 인증서를 붙이거나 개발 빌드 전용 경로를 만드는 쪽으로 요청합니다. iOS에서 ATS 예외가 필요하다면 App Store 심사 사유 제출이 따라온다는 것도 함께 이야기합니다.
  • app-bound domains는 요청하지 않습니다. 서비스 워커를 위해 이 키를 켜달라고 하면, 그 앱의 모든 웹뷰에서 JavaScript 주입·커스텀 스타일시트·쿠키 조작·메시지 핸들러가 기본 차단됩니다. 얻는 것보다 잃는 것이 큽니다.
  • 웹뷰가 접근성에서 감춰져 있지 않은지. 웹 접근성이 전혀 읽히지 않을 때 가장 먼저 물어볼 항목입니다.

이 목록은 ep.09의 요청 체크리스트에 그대로 들어갑니다. 지금 단계에서 중요한 것은 목록의 길이가 아니라 구분입니다. 로그인이 풀린다는 한 문장 뒤에는 저장소 소유자의 문제와 오리진 판정의 문제가 섞여 있고, 둘은 고치는 사람이 다릅니다.


참고 자료


다음 편 예고

ep.08 - 어제 배포했는데 앱에서는 그제 화면입니다

세션이 유지되는 문제를 풀고 나면 다음은 화면 자체입니다. 캐시 정책은 앱 설정에 종속되는데 사용자에게는 강제 새로고침 수단이 없고, 백그라운드에 다녀온 웹뷰는 돌아왔을 때 같은 상태가 아닙니다.