Android WebView에서 커스텀 URL 스킴을 가로채는 방법

opoinstall
2026-09-21
5 min read

Android WebView에서 딥링크와 커스텀 스킴을 처리하는 방법은 무엇인가요? Android WebView에서 딥링크를 처리하려면 WebViewClient를 확장하고, shouldOverrideUrlLoading을 재정의하여 isForMainFrame을 확인하며, 허용 목록(allowlist)을 통해 커스텀 스킴을 검증하고, 인증된 외부 인텐트를 안전하게 실행하여 웹이 아닌 스킴이 WebView 로딩 파이프라인에 남지 않도록 해야 합니다.

Android WebView 딥링크 가로채기는 맞춤형 WebViewClient 내에서 내비게이션 콜백을 재정의하여 커스텀 URI 스킴이나 Chrome Intent URI와 같은 비 HTTP 프로토콜을 캡처하는 프로그래밍 방식입니다. 요청 메타데이터를 평가하고 목적지 페이로드를 검증함으로써 호스트 앱은 네이티브 인텐트 실행을 수행하는 동시에 처리되지 않은 프로토콜로 인한 충돌을 방지합니다.

용어 정의 관련 엔티티 검색 의도 역할
WebView Android 앱 내에서 웹 콘텐츠를 렌더링하는 임베디드 Chromium 기반 뷰 컴포넌트입니다. Android SDK 정보 제공 / 상업적
커스텀 URL 스킴 웹 링크가 네이티브 앱을 대상으로 할 수 있게 해주는 독점 URI 프로토콜입니다. 딥링크 라우팅 기술적 / 정보 제공
Web to App 웹 페이지에서 네이티브 앱 인터페이스로 사용자를 전환하는 과정입니다. 모바일 딥링크 정보 제공

커스텀 스킴은 Chromium 오류 발생 전 WebView 로딩 파이프라인에서 제외되어야 합니다.

Android WebView에서 net ERR UNKNOWN URL SCHEME 오류가 발생하는 이유

Chromium 프로토콜 제한: WebView가 표준 웹 스킴만 인식하는 이유

Android WebView 컴포넌트는 표준 웹 리소스 프로토콜(http://, https://, file://, data://, about:, javascript:)을 구문 분석하고 표시하도록 구성된 Chromium 렌더링 엔진으로 구동됩니다.

기본적으로 맞춤형 WebViewClient가 지정되지 않은 경우, Android 프레임워크는 처리되지 않은 URL을 시스템의 Activity Manager로 위임합니다. 그러나 앱이 맞춤형 WebViewClient를 제공하면 비 HTTP(S) 스킴(myapp://product/detail/1024, alipay://, whatsapp:// 등)이 클라이언트의 내비게이션 콜백으로 직접 라우팅됩니다. 호스트 앱이 커스텀 스킴을 가로채지 않고 WebView 로딩 파이프라인에 그대로 두면, Chromium 엔진은 해당 스킴을 일반 웹 리소스로 해석하려고 시도합니다. 표준 프로토콜이 아닌 경우 DNS나 웹 서버 전송 계층을 통해 해석할 수 없으므로, 엔진은 하위 수준의 내비게이션 실패를 노출합니다.

net::ERR_UNKNOWN_URL_SCHEME의 충돌 해부: 사용자 탭에서 웹 리소스 로드 실패까지

사용자 상호작용에서 오류 페이지로 전환되는 과정은 다음과 같이 신속하고 결정적인 순서로 일어납니다:

  1. 사용자 트리거: 사용자가 임베디드 H5 랜딩 페이지 내의 버튼이나 하이퍼링크를 탭합니다.
  2. 내비게이션 요청: 웹 엔진이 대상 URI 문자열(예: myapp://promo/summer)을 포함하는 내비게이션 요청을 생성합니다.
  3. 파이프라인 평가: shouldOverrideUrlLoading()이 false를 반환하거나 비 HTTP(S) URI를 가로채지 못하면, Chromium 엔진은 해당 URL을 표준 웹 리소스 로드로 처리합니다.
  4. 오류 발생: 해당 프로토콜에 대한 내부 핸들러를 찾지 못한 엔진은 onReceivedError()를 트리거하고 WebView 콘텐츠를 기본 Chromium 오류 템플릿인 net::ERR_UNKNOWN_URL_SCHEME로 교체합니다.

이 오류 페이지는 사용자 여정을 중단시키고, 전환 추적을 방해하며, 호스트 앱의 안정성에 대한 신뢰도를 떨어뜨립니다.

대상 애플리케이션 부재 시 발생하는 ActivityNotFoundException 충돌 위험

개발자가 net::ERR_UNKNOWN_URL_SCHEME를 해결하려고 할 때 흔히 하는 실수는 URI 문자열을 구문 분석한 후 충분한 예외 처리 없이 context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))를 호출하는 것입니다.

사용자가 기기에 대상 앱을 설치하지 않은 경우, Android 운영체제는 Intent를 해결할 수 있는 적절한 Activity를 찾을 수 없습니다. 표준 Android 실행 환경에서 핸들러 없이 startActivity()를 호출하면 처리되지 않은 android.content.ActivityNotFoundException이 발생합니다. 이 예외를 구조화된 try-catch 블록 내에서 처리하지 않으면 전체 호스트 앱 프로세스가 즉시 종료되어 치명적인 충돌이 발생합니다.

shouldOverrideUrlLoading은 어떻게 딥링크 프로토콜을 가로채는가

API 발전: API 24+의 WebResourceRequest 오버로드와 레거시 문자열 콜백 비교

Android의 WebViewClient는 shouldOverrideUrlLoading 콜백을 제공하여 호스트 앱이 웹 엔진에서 실행되기 전에 내비게이션 요청을 검사하고 가로챌 수 있게 합니다. 이 메서드의 시그니처와 기능은 Android API 수준에 따라 발전해 왔습니다:

  • API 24+ (Android 7.0 이상): shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean을 재정의합니다. WebResourceRequest 인터페이스는 대상 URL(request.url), HTTP 요청 메서드(request.method), 요청 헤더, 프레임 계층 플래그(request.isForMainFrame, request.hasGesture())를 포함한 풍부한 컨텍스트 메타데이터를 제공합니다.
  • 레거시 오버로드 (API 24에서 Deprecated): shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean을 재정의합니다. 이 레거시 콜백은 요청하는 프레임이나 사용자 상호작용 상태에 대한 메타데이터 없이 원시 URL 문자열만 받습니다.

Android Developers Reference의 WebViewClient shouldOverrideUrlLoading에 따라, 최신 앱은 WebResourceRequest 오버로드를 구현해야 합니다. 레거시 Android 버전을 지원할 때는 보안 우회를 방지하기 위해 폐기된 문자열 콜백을 보수적으로 구성해야 합니다.

request.isForMainFrame의 보안 중요성: 서브프레임 및 Iframe 하이재킹 차단

메인 프레임 확인은 iframe 내비게이션이 외부 앱 경로를 시작하는 것을 방지합니다.

Android Developers Guide의 WebResourceRequest에 따라, shouldOverrideUrlLoading 콜백은 최상위 문서 내비게이션뿐만 아니라 서브프레임, iframe, 백그라운드 리소스 리디렉션에 대해서도 트리거될 수 있습니다.

앱 보안을 위해 request.isForMainFrame을 적용하는 것은 필수적입니다:

  • 프레임 경계 격리: request.isForMainFrame을 확인하면 서브프레임과 iframe이 외부 앱 실행이나 커스텀 스킴 인텐트를 트리거할 수 없습니다. 서브프레임이 내비게이션을 시도하는 경우, 표준 HTTP(S) 로드는 false를 반환하여 인프레임 렌더링을 계속해야 하며, 비 HTTP(S) 스킴은 true를 반환하여 오류 없이 실행을 억제해야 합니다.
  • 사용자 제스처 구분: isForMainFrame은 요청이 기본 문서에 적용됨을 확인하지만, 사용자의 실제 활성화를 증명하지는 않습니다. 아키텍처에 사용자 제스처 확인이 필요한 경우, request.hasGesture()를 평가하거나 앱 수준의 확인 모달을 적용하세요. 단, Android 문서에 따르면 hasGesture()가 false를 반환하는 유효한 사용자 흐름이 존재할 수 있음에 유의해야 합니다.

정상적인 웹 내비게이션과 딥링크 핸드오프 구분: false 대 true 반환 시점

shouldOverrideUrlLoading의 불리언 반환값은 WebView의 렌더링 수명 주기를 제어합니다:

  • false 반환: 호스트 앱이 URL을 처리하지 않았음을 Android 프레임워크에 알립니다. WebView는 표준 웹 내비게이션을 계속하며 내부 Chromium 컨테이너 내에서 URL을 로드합니다. 이는 임베디드 브라우저 내에 표시되어야 하는 표준 http:// 및 https:// 웹 페이지에 필수적입니다.
  • true 반환: 호스트 앱이 URL을 가로채서 처리했음을 프레임워크에 알립니다. WebView는 즉시 내부 로딩 프로세스를 취소합니다. 이는 외부 Intent를 실행하거나, 네이티브 액티비티로 라우팅하거나, 승인되지 않은 스킴을 억제할 때 필수적입니다.

임베디드 컨테이너 내에서 호스트 앱의 검증된 App Link 처리

WebView 내의 링크가 호스트 앱 자체의 검증된 HTTPS 도메인(https://app.example.com/product/1024 등)을 가리키는 경우, 기본 내비게이션을 실행하면 WebView가 해당 상품 페이지의 웹 버전을 로드하게 됩니다.

네이티브 경험을 제공하려면 WebViewClient가 들어오는 HTTPS URL을 검사해야 합니다. 호스트가 앱의 연관 도메인과 일치하면, 클라이언트는 요청을 가로채고, 경로와 쿼리 매개변수를 내부 경로 유효성 검사기(AppLinkRouteValidator)에 대해 검증한 다음, 데이터를 직접 네이티브 라우터(internalRouter(validatedRoute))로 발송하고 true를 반환하여 중복되는 웹 렌더링을 취소합니다.

커스텀 스킴 및 Intent URI를 위한 보안 인텐트 핸들러 구조화

Chrome Intent URI 구문 분석: 스킴, 패키지 및 S.browser_fallback_url

Android에서 웹 페이지는 종종 네이티브 딥링크를 브라우저 폴백 메커니즘과 함께 구조화하기 위해 Chrome Intent URI를 활용합니다. Intent URI는 intent:// 스킴을 사용하며 라우팅 매개변수, 패키지 타겟, 폴백 URL을 직렬화된 문자열로 인코딩합니다:

intent://product/detail/1024#Intent;scheme=myapp;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end

Chrome for Developers Guide의 Android Intent URIs에 문서화된 대로, URI는 명확한 컴포넌트로 나뉩니다:

  • 호스트 및 경로 (product/detail/1024): 내부 앱 목적지를 정의합니다.
  • scheme=myapp: 기본 커스텀 프로토콜을 지정합니다.
  • package=com.example.app: 인텐트 해결을 지정된 Android 패키지로 엄격히 제한합니다.
  • S.browser_fallback_url=...: 대상 앱이 설치되지 않은 경우 로드할 명시적인 웹 또는 스토어 URL을 정의하는 선택적 문자열 추가 항목입니다.

원시 파싱 인텐트 실행의 위험: 인텐트 주입 취약점 완화

흔한 구현 안티 패턴은 Intent.parseUri(url, Intent.URI_INTENT_SCHEME)로 들어오는 URI를 구문 분석하고, 결과로 생성된 Intent를 context.startActivity(intent)에 직접 전달하는 것입니다.

OWASP Mobile Application Security Testing Guide의 안전하지 않은 딥링크 지침에 따르면, 원시 파싱 인텐트를 실행하면 앱이 인텐트 주입(Intent Injection) 공격에 노출됩니다. 웹 페이지를 제어하는 공격자가 악의적인 명시적 컴포넌트 타겟(component=com.example.app/.InternalAdminActivity), 민감한 작업, 또는 권한이 부여된 인텐트 추가 항목(S.is_admin=true)이 포함된 Intent URI를 조작할 수 있습니다. WebView는 이러한 매개변수를 역직렬화하고, 인증 장벽을 우회하여 내보내지 않은 활동(unexported activity)이나 내부 활동을 실행할 수 있습니다.

파싱된 인텐트 URI는 Android 디스패치 전에 검증되고 재구성되어야 합니다.

안전한 인텐트 재구성: 컴포넌트 제거 및 ACTION_VIEW로 작업 제한

인텐트 주입 취약점을 제거하려면 호스트 앱은 역직렬화된 Intent를 직접 실행해서는 안 됩니다. 대신, WebViewClient가 URI를 구문 분석하고 검증된 데이터 페이로드만 추출하여 깨끗하고 전용인 Intent를 재구성해야 합니다:

  1. 데이터 추출: URI를 구문 분석하고 검증된 대상 패키지와 데이터 URI를 추출합니다.
  2. 패키지 검증: 선언된 패키지가 승인된 파트너 또는 내부 패키지의 명시적 허용 목록과 일치하는지 확인합니다.
  3. 재구성: 새 Intent(Intent.ACTION_VIEW, validatedDataUri)를 인스턴스화합니다.
  4. 격리: intent.addCategory(Intent.CATEGORY_BROWSABLE)을 적용하여 브라우저 가능 활동만 요청을 해결할 수 있도록 하고, 명시적 플래그(FLAG_ACTIVITY_NEW_TASK)를 설정하며, intent.component 및 intent.selector가 null로 유지되도록 합니다.

엄격한 스킴, 호스트, 경로 및 쿼리 매개변수 허용 목록 시행

커스텀 스킴(myapp://)과 재구성된 Intent URI 모두 실행 전에 엄격한 검사기를 통과해야 합니다.

이 참조 구현은 호스트 앱 자체의 myapp:// 스킴을 구체적으로 처리하며, 인텐트 실행을 com.example.app으로 명확히 고정합니다. 다중 앱 파트너 생태계의 경우 개발자는 엄격한 스킴-투-패키지 매핑을 유지하거나 검증된 HTTPS App Link를 우선시해야 합니다.

검사기는 다음 항목을 확인합니다:

  • 스킴: 승인된 커스텀 스킴(myapp)과 일치하는지 확인합니다.
  • 호스트: 정의된 라우팅 범주(open, product, event)와 일치하는지 확인합니다.
  • 경로 접두사: 예상되는 경로 구조(/detail/, /view/, /main/)를 준수하는지 확인합니다.
  • 쿼리 매개변수: 허용된 쿼리 키(target, id, promo, utm_source)를 강제하고, 매개변수 값이 길이 제한(예: 128자 이하) 및 영숫자 문자 집합을 준수하는지 검증합니다.
[사용자가 Android WebView 내 링크를 탭함]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[서브프레임 스킴 확인]      [URL 스킴 검사]
├─ HTTP(S) -> return false       │
└─ 비웹(Non-Web) -> return true   ┌────┴───────────────────────────┐
   (오류 억제)                   ▼                                ▼
                             [HTTP / HTTPS]                 [비 HTTP(S) 스킴]
                                    │                                │
                                    ▼                                ▼
                           [호스트 도메인 확인]             [CustomSchemeValidator]
                           ├─ 내부 앱 링크                  ├─ 유효 -> 고정 인텐트 실행
                           │  -> 내부 라우터                └─ 인텐트 -> 재구성 및 폴백
                           └─ 외부 웹
                              -> return false

Android 11 패키지 가시성 및 앱 스토어 폴백 처리 방법

패키지 가시성은 프리플라이트 쿼리에 영향을 주며, 실행 실패 시 검증된 폴백을 사용합니다.

Android 11 패키지 가시성 변화: 프리플라이트 쿼리에 선언이 필요한 이유

Android 11(API 레벨 30)부터 Google은 사용자 개인정보 보호를 강화하기 위해 패키지 가시성 필터링을 도입했습니다. 이 프레임워크 하에서는 다른 설치된 앱에 대한 앱의 가시성이 기본적으로 제한됩니다.

인텐트 실행과 인텐트 쿼리 간의 차이를 이해하는 것이 중요합니다:

  • 직접 인텐트 실행: context.startActivity(intent)에 대한 표준 호출은 패키지 가시성 선언이 필요하지 않습니다. 대상 앱이 설치되어 있으면 운영체제는 패키지 쿼리에 대한 가시성 여부와 상관없이 이를 성공적으로 라우팅하고 실행합니다.
  • 프리플라이트 인텐트 쿼리: 만약 WebViewClient 아키텍처가 실행 전 검사(예: packageManager.resolveActivity(intent, 0) 또는 packageManager.queryIntentActivities(intent, 0)를 호출하여 앱 존재 여부 확인)를 수행하는 경우, 대상 패키지나 스킴이 AndroidManifest.xml에 선언되어 있지 않으면 쿼리는 null이나 빈 목록을 반환합니다.

커스텀 스킴 및 패키지 타겟을 위한 AndroidManifest.xml 내 <queries> 요소 선언

Android Developers Guide의 Android 11 패키지 가시성에 따라, 인텐트 실행 전 프리플라이트 확인을 수행하는 앱은 매니페스트에 대상 패키지나 인텐트 시그니처를 선언해야 합니다:

<manifest package="com.example.app">
    <!-- 명시적 패키지 발견을 위한 선택적 프리플라이트 쿼리 선언 -->
    <queries>
        <package android:name="com.example.partner" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>
</manifest>

표준 실행 패턴을 선호하는 경우, ActivityNotFoundException을 처리하는 try-catch 블록 내에서 startActivity()를 실행하면 쿼리에 독립적인 탄력적인 구현을 제공할 수 있습니다.

폴백 체인 구현: 대상 앱이 없을 때의 우아한 스토어 라우팅

가로챈 커스텀 스킴이나 Intent URI가 기기에 설치된 대상 앱이 없어 실행될 수 없는 경우, WebViewClient는 명시적인 폴백 체인을 실행해야 합니다:

  1. 폴백 URL 추출 및 바인딩: intent:// URI의 경우 S.browser_fallback_url 문자열 매개변수를 추출하고 승인된 목적지를 가리키는지 검증합니다. Google Play URL의 경우, 오픈 리디렉션 악용을 방지하기 위해 id 매개변수가 대상 패키지 이름(id=targetPackage)과 엄격히 일치하는지 확인합니다.
  2. Market URI 생성: 폴백 URL이 없으면 마켓 인텐트를 구성합니다: market://details?id=com.example.app.
  3. 웹 Play 스토어 폴백: Google Play가 비활성화되었거나 없는 경우, 마켓 인텐트에서 ActivityNotFoundException을 캐치하고 https://play.google.com/store/apps/details?id=com.example.app을 로드합니다.

OpoInstall을 활용한 폴백 리디렉션 전반의 컨텍스트 매개변수 바인딩

설치하지 않은 사용자가 Google Play로 라우팅되면 정적 스토어 링크는 동적 매개변수를 삭제합니다. 모바일 기여 분석 및 딥링크 플랫폼인 OpoInstall은 스토어 리디렉션 전에 기여 분석 백엔드에서 추천 토큰과 캠페인 컨텍스트를 캡처하여 이 격차를 해소합니다. 프로덕션에 배포된 OpoInstall Android SDK 버전에 따라 지연 매개변수 검색 동작과 초기화 API를 확인하세요.

후속 설치 및 초기 앱 실행 시 OpoInstall Android SDK가 캐시된 매개변수를 검색하여 호스트 앱이 사용자의 대상 장면을 복원할 수 있게 합니다. 설치 후 지연 매개변수 수신 방법에 대한 지침은 SDK 통합 문서를 검토하세요.

안전한 스킴 가로채기를 위한 프로덕션 WebViewClient 구현

Kotlin을 이용한 커스텀 WebViewClient 아키텍처 구조화

프로덕션 WebViewClient는 프레임 검증, 스킴 허용 목록 설정, 인텐트 재구성, 폴백 추출 및 내부 라우팅을 조정합니다. 구현은 내비게이션 콜백을 깔끔하고 유지 관리하기 쉽게 만들기 위해 검증 규칙을 전용 검사기 객체(AppLinkRouteValidator 및 CustomSchemeValidator) 내에 캡슐화합니다.

AppLinkRouteValidator 및 CustomSchemeValidator 모델 구현

검사기 객체는 엄격한 보안 실패 설정을 강제합니다:

  • AppLinkRouteValidator는 내부 HTTPS 도메인, 허용된 경로 접두사, 승인된 쿼리 키(target, id, promo, utm_source)를 검증하고 구조화된 ValidatedAppRoute 데이터 클래스를 반환합니다.
  • CustomSchemeValidator는 승인된 커스텀 스킴(myapp)을 확인하고, 허용된 호스트(open, product, event)를 검증하며, 경로 접두사를 강제하고, 영숫자 문자 집합 및 길이 제한(128자 이하)에 대해 쿼리 매개변수 값을 삭제합니다.

아래 코드 구현은 선택적인 AndroidManifest.xml 패키지 가시성 선언을 구성하고, 자동화된 폴백 처리를 갖춘 Kotlin의 스레드 안전하고 안전한 CustomWebViewClient를 구현하는 방법을 보여줍니다. 인증된 SDK 바이너리 및 통합 래퍼는 OpoInstall SDK 다운로드 센터에서 획득할 수 있습니다.

<!-- AndroidManifest.xml: 프리플라이트 인텐트 쿼리를 위한 선택적 패키지 가시성 선언 (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- 프리플라이트 쿼리를 사용하는 경우에만 외부 패키지 및 커스텀 스킴을 선언하세요 -->
    <queries>
        <!-- 특정 파트너 또는 대상 패키지에 대한 프리플라이트 발견 권한 부여 -->
        <package android:name="com.example.partner" />
        
        <!-- 커스텀 딥링크 스킴의 프리플라이트 확인 권한 부여 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- 검증된 HTTPS App Link 도메인의 프리플라이트 확인 권한 부여 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">
        <!-- 애플리케이션 컴포넌트 -->
    </application>
</manifest>
// Android: 프레임 경계 보안, 허용 목록 검증, 폴백 라우팅을 갖춘 커스텀 WebViewClient
// 참조 통합 예제입니다; 배포된 Android 아키텍처에 맞춰 메서드 시그니처를 확인하세요.
package com.example.app.webview

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

data class ValidatedAppRoute(val path: String, val queryParams: Map<String, String>)

object AppLinkRouteValidator {
    private val allowedPrefixes = setOf("/open/", "/product/", "/promo/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): ValidatedAppRoute? {
        val path = uri.path ?: ""
        if (allowedPrefixes.none { path.startsWith(it) }) {
            return null
        }

        val sanitizedParams = mutableMapOf<String, String>()
        for (name in uri.queryParameterNames) {
            // 엄격한 보안 정책: 알 수 없는 쿼리 키가 있으면 요청 거부
            if (name !in allowedQueryKeys) {
                return null
            }
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length <= 64 && value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                sanitizedParams[name] = value
            } else {
                return null
            }
        }

        return ValidatedAppRoute(path, sanitizedParams)
    }
}

object CustomSchemeValidator {
    private val allowedSchemes = setOf("myapp")
    private val allowedHosts = setOf("open", "product", "event")
    private val allowedPathPrefixes = setOf("/detail/", "/view/", "/main/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): Uri? {
        val scheme = uri.scheme?.lowercase() ?: return null
        val host = uri.host?.lowercase() ?: return null
        val path = uri.path ?: ""

        if (!allowedSchemes.contains(scheme) || !allowedHosts.contains(host)) {
            return null
        }

        if (allowedPathPrefixes.none { path.startsWith(it) }) {
            return null
        }

        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) return null
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length > 128 || !value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                return null
            }
        }

        return uri
    }
}

class CustomWebViewClient(
    private val context: Context,
    private val internalRouter: (ValidatedAppRoute) -> Unit
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        request ?: return false

        // 보안 요구사항: 외부 앱 라우팅을 메인 프레임 내비게이션으로만 제한
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // 표준 HTTP(S) 서브프레임 로딩은 허용하되, 비웹 스킴은 iframe 하이재킹 및 오류 방지를 위해 억제
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "서브프레임/iframe에서 비웹 스킴 억제됨: $scheme")
                true // 외부 인텐트 실행이나 Chromium 오류 표출 없이 소비/취소
            }
        }

        val url = request.url.toString()
        return handleUrlNavigation(view, url)
    }

    @Deprecated("Deprecated in Java")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()

        // 레거시 콜백에는 신뢰할 수 있는 isForMainFrame 메타데이터가 없음; 외부 스킴 라우팅 금지
        return if (scheme == "http" || scheme == "https") {
            false // 표준 웹 콘텐츠 로딩 허용
        } else {
            Log.w("WebViewClient", "레거시 콜백에서 외부 스킴 차단됨: $scheme")
            true // 외부 인텐트 실행 없이 처리되지 않은 스킴 오류 억제
        }
    }

    private fun handleUrlNavigation(view: WebView?, url: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase() ?: return false

        // 1. HTTP / HTTPS 및 내부 앱 링크 처리
        if (scheme == "http" || scheme == "https") {
            // URL이 호스트 앱 자신의 App Link 도메인과 일치하는지 확인
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "검증된 내부 HTTPS 경로. 내부 라우터로 발송.")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "형식이 잘못되었거나 승인되지 않은 내부 App Link 거부: $uri")
                }
                return true // 내부적으로 처리; 웹뷰 로딩 취소
            }
            return false // WebView가 표준 웹 콘텐츠를 네이티브하게 로드하도록 허용
        }

        // 2. 하이재킹 방지를 위해 엄격한 허용 목록과 패키지 고정으로 커스텀 스킴 검증
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // 호스트 앱 패키지 명시적 고정
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        // 3. 안전한 재구성과 폴백 추출을 통한 Chrome Intent URI 처리
        if (scheme == "intent") {
            return try {
                val parsed = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                val targetPackage = parsed.`package`
                val allowedPackages = setOf("com.example.app", "com.example.partner")
                val fallbackUrl = parsed.getStringExtra("browser_fallback_url")

                if (targetPackage != null && allowedPackages.contains(targetPackage)) {
                    val rawDataUri = parsed.data
                    if (rawDataUri != null) {
                        val validatedDataUri = CustomSchemeValidator.validate(rawDataUri)
                        if (validatedDataUri != null) {
                            // 원시 파싱 객체를 실행하는 대신 깨끗하고 전용인 ACTION_VIEW Intent 재구성
                            val safeIntent = Intent(Intent.ACTION_VIEW, validatedDataUri).apply {
                                `package` = targetPackage
                                addCategory(Intent.CATEGORY_BROWSABLE)
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            }
                            launchSafeIntent(safeIntent, fallbackAction = {
                                handleIntentFallback(view, fallbackUrl, targetPackage)
                            })
                        } else {
                            Log.w("WebViewClient", "유효하지 않은 데이터 URI가 포함된 인텐트 거부: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "데이터 URI 페이로드가 부족한 인텐트 거부")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "확인되지 않은 패키지를 대상으로 하는 인텐트 거부: $targetPackage")
                    true // 확인되지 않은 패키지 인텐트 취소
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "인텐트 URI 파싱 실패: $url", e)
                true // 처리되지 않은 스킴 오류 억제
            }
        }

        // net::ERR_UNKNOWN_URL_SCHEME 오류를 방지하기 위해 알 수 없는 스킴 거부
        Log.w("WebViewClient", "확인되지 않은 스킴 내비게이션 차단됨: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "대상 앱이 설치되지 않음: ${intent.data}. 폴백 트리거.")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "인텐트 실행 중 보안 예외: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            Log.i("WebViewClient", "승인된 브라우저 폴백 URL로 라우팅: $fallbackUrl")
            view?.loadUrl(fallbackUrl)
        } else {
            handleStoreFallback(view, targetPackage)
        }
    }

    private fun handleStoreFallback(view: WebView?, packageName: String) {
        val marketUri = Uri.parse("market://details?id=$packageName")
        val marketIntent = Intent(Intent.ACTION_VIEW, marketUri).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        try {
            context.startActivity(marketIntent)
        } catch (e: ActivityNotFoundException) {
            val webStoreUrl = "https://play.google.com/store/apps/details?id=$packageName"
            Log.i("WebViewClient", "Play Store 앱 없음. 웹 스토어 URL 로드 중: $webStoreUrl")
            view?.loadUrl(webStoreUrl)
        }
    }

    private fun isAuthorizedFallbackUrl(url: String, targetPackage: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()
        val host = uri.host?.lowercase()

        if (scheme != "https" || host == null) return false

        return when (host) {
            "play.google.com" -> {
                // Google Play 폴백 URL이 대상 패키지를 구체적으로 가리키는지 강제
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                // 소유 도메인 폴백 경로 검증
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

스레드 안전한 실행: 내부 경로 발송 및 수명 주기 상태 관리

AppLinkRouteValidator가 유효한 내부 경로를 확인하면 UI 전환은 Android의 메인 UI 스레드에서 실행되어야 합니다. internalRouter가 프래그먼트, 액티비티 전환 또는 Compose 내비게이션을 트리거하는 경우, 스레딩 충돌을 방지하기 위해 runOnUiThread 또는 메인 스레드 수명 주기 코루틴 내에서 발송 호출을 래핑하세요.

Android WebView 리디렉션 오류 및 진단 솔루션 매트릭스

포괄적인 Android WebView 딥링크 문제 해결 가이드

아래 매트릭스는 Android WebView에서 커스텀 스킴을 처리할 때 발생하는 일반적인 오류 시그니처와 그 주요 원인 및 엔지니어링 개선 방안을 상세히 설명합니다:

오류 시그니처 / 증상 주요 근본 원인 영향을 받는 API 수준 진단 체크포인트 권장 개선 방안
net::ERR_UNKNOWN_URL_SCHEME WebView 로더에서 비웹 스킴 처리 미흡 모든 API 수준 shouldOverrideUrlLoading 누락 또는 false 반환 델리게이트 재정의, 스킴 가로채기, true 반환
ActivityNotFoundException 기기에 대상 앱 없음 모든 API 수준 처리되지 않은 context.startActivity() 예외 캐치 및 S.browser_fallback_url 또는 Play 스토어 경로 실행
Iframe 인텐트 하이재킹 서브프레임이 외부 인텐트 트리거 API 24+ request.isForMainFrame 확인 누락 if (!request.isForMainFrame)으로 방어 및 비웹 스킴 억제
프리플라이트 쿼리가 Null 반환 Android 11+ 패키지 가시성 필터 API 30+ (Android 11+) 매니페스트 선언 없이 resolveActivity 호출 매니페스트에 <queries> 선언 또는 startActivity try-catch 직접 사용
내부 앱 링크 리로드 루프 호스트 앱 링크가 WebView로 재라우팅됨 모든 API 수준 델리게이트에서 호스트 도메인 처리 미흡 자체 도메인 가로채기, 경로 검증, 네이티브 라우터로 발송

자주 묻는 질문 (FAQ)

Android WebView에서 커스텀 URL 스킴을 열 때 앱이 충돌하는 이유는 무엇인가요?
커스텀 스킴은 URL이 호스트 앱에 의해 가로채이지 않고 WebView 로딩 파이프라인에 남아 있을 때 `net::ERR_UNKNOWN_URL_SCHEME`를 생성합니다. 커스텀 `WebViewClient`를 사용할 때 비웹 스킴을 안전하게 처리하거나 억제한 후 `true`를 반환하세요. WebView가 일반적인 웹 내비게이션을 계속해야 할 때만 `false`를 반환해야 합니다. 대상 앱이 없는 경우, `try-catch` 블록 없이 `startActivity()`를 호출하면 처리되지 않은 `ActivityNotFoundException`이 발생하여 앱이 충돌합니다.
보안 취약점 없이 WebView에서 intent:// URL을 안전하게 처리하려면 어떻게 해야 하나요?
`intent://` URI를 안전하게 처리하려면 `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`를 사용하여 URI를 구문 분석하고, 대상 패키지와 데이터 URI가 엄격한 허용 목록과 일치하는지 검증한 다음, 새롭고 깨끗한 `Intent(Intent.ACTION_VIEW, safeDataUri)`를 구성하세요. 오직 브라우저 가능한 활동만 인텐트를 처리할 수 있도록 `Intent.CATEGORY_BROWSABLE`을 명시적으로 추가하고, 위험한 컴포넌트를 제거하며, 대상 패키지에 바인딩된 검증된 폴백 URL 처리를 구현하세요.
Android 11의 패키지 가시성이 WebView의 딥링크 가로채기에 어떤 영향을 미치나요?
Android 11 패키지 가시성은 `startActivity()`가 일치하는 외부 활동을 시작하는 것을 방해하지 않습니다. 하지만 `WebViewClient`가 시작을 시도하기 전에 `resolveActivity()`나 `queryIntentActivities()`를 사용하여 프리플라이트 확인을 수행하는 경우, `AndroidManifest.xml` 파일의 `<queries>` 요소 내에 대상 패키지 이름이나 커스텀 인텐트 스킴을 선언해야 합니다.

요약 및 의사결정 프레임워크

Android WebView 내에서 딥링크와 커스텀 스킴을 처리하는 것은 웹 렌더링 샌드박스와 네이티브 운영체제의 인텐트 라우팅 사이의 간극을 메우는 과정입니다. 기본 WebView 동작에 의존하면 처리되지 않은 프로토콜 오류, 사용자 경험 저하, 잠재적인 보안 취약점이 발생합니다.

프레임 경계를 검증하고, 명시적 허용 목록에 대해 커스텀 스킴을 검증하며, 안전한 ACTION_VIEW 인텐트를 재구성하고, 우아한 스토어 폴백을 처리하는 강력한 WebViewClient를 구현함으로써 엔지니어링 팀은 WebView 스킴 처리 실패를 줄이고 더욱 탄력적인 내비게이션을 지원할 수 있습니다. 신뢰할 수 있는 클라이언트 측 라우팅을 포괄적인 모바일 기여 분석 프레임워크와 통합하면 기여 분석 흐름 전반의 라우팅 마찰이 감소하고 더욱 일관된 사용자 여정을 지원합니다.

네이티브 Android 딥링크 및 매개변수 라우팅 아키텍처를 탐색하려면 SDK 통합 문서를 참조하고, OpoInstall SDK 다운로드 센터에서 클라이언트 라이브러리를 다운로드하고, 모바일 기여 분석 구현 참조를 탐색하거나 OpoInstall 개발자 콘솔에서 앱을 등록하세요.

관련 자료

Share this article