개발 가이드

웹뷰 연동

웹뷰가 네이티브앱에서 정상적으로 동작하기 위한 웹뷰 설정입니다. 적용되지 않은 경우 뒤로가기, 광고 노출 등이 정상 작동하지 않을 수 있습니다. 매체사와 콘텐츠 제작사 모두 이 문서를 참고하여 연동해야 합니다.

기본 설정

미디어 자동재생

사용자의 제스처 없이 자동으로 영상이 재생되도록 설정합니다.

AOS

kotlin
val settings = webView.settings
settings.mediaPlaybackRequiresUserGesture = false

iOS

swift
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true// 영상이 자동으로 전체화면이 되지 않도록 설정
config.mediaTypesRequiringUserActionForPlayback = []// 사용자 제스처 없이 자동재생 허용 (오디오/비디오 모두 적용)

// iOS 9버전 이하일시 ===========================
config.mediaPlaybackRequiresUserAction = false

Flutter

dart
InAppWebView(
  initialSettings: InAppWebViewSettings(
    //공통: 사용자 제스처 없이 자동재생 허용
    mediaPlaybackRequiresUserGesture: false,


		//IOS ===================================
    allowsInlineMediaPlayback: true,//영상이 자동으로 전체화면이 되지않도록 설정
    allowsPictureInPictureMediaPlayback: false,// PIP(Picture-in-Picture) 비활성화
  ),
)

생명주기

앱이 백그라운드 진입 시 영상을 자동으로 중단하기 위해 설정합니다.

AOS

kotlin
override fun onResume() {
    super.onResume()
    webView.onResume()
    WebView.resumeTimers()
}

override fun onPause() {
    super.onPause()
    webView.onPause()
    WebView.pauseTimers()
}

iOS

swift
//해당없음

Flutter

dart
lass _WebViewPageState extends State<WebViewPage> with WidgetsBindingObserver {
  InAppWebViewController? _controller;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (!Platform.isAndroid) return;
    if (state == AppLifecycleState.paused) {
      _controller?.pauseTimers();
    } else if (state == AppLifecycleState.resumed) {
      _controller?.resumeTimers();
    }
  }

  // ...
}

URL 처리

광고 랜딩 URL은 세션/스토리지 오염 방지, 광고 시청 확인 여부, 딥링크 연동(앱열기), UX 보존(뒤로가기 등)과 같은 이유로 항상 외부 브라우저로 열려야 합니다.

  • 만약 내부에서 동작되어야 할 도메인들은 허용 도메인(allowedDomains)에 추가해 예외 처리 합니다.
  • *.blomics.net, *.tenqube.com 도메인은 허용 도메인에 반드시 추가되어야 합니다.
  • 아래는 예제 코드입니다.
네이티브 앱 정책 확인 필요

해당 설정의 경우 네이티브 앱의 정책에 따라 다양한 케이스가 존재하므로 내부 정책 및 설정을 확인하시기 바랍니다.

  • URL을 외부 브라우저로 여는 경우
    • 비허용 도메인 링크 클릭, 새 창 시도, 네비게이션 등
    • 비 http/https 스킴(mailto:, tel:, sms:, intent:, itms-apps: 등)
  • 내부 웹뷰 재사용 하는 경우
    • 초기 로드 URL
    • 허용 도메인 링크 클릭, 새 창 시도, 네비게이션 등

AOS

kotlin
		// 허용 도메인(서브도메인 포함) — 내부에서 열릴 도메인 추가
    private val allowedDomains = setOf(
        "*.blomics.net"
    )

    private fun isAllowedDomain(host: String?): Boolean {
        val h = host?.lowercase() ?: return false
        return allowedDomains.any { base -> h == base || h.endsWith(".$base") }
    }

    private fun shouldOpenExternally(url: Uri): Boolean {
        val scheme = url.scheme?.lowercase() ?: return true
        return if (scheme == "http" || scheme == "https") {
            !isAllowedDomain(url.host)
        } else {
            true
        }
    }

    private fun openExternally(uri: Uri) {
        try {
            startActivity(Intent(Intent.ACTION_VIEW, uri))
        } catch (_: ActivityNotFoundException) {}
    }

    private fun tryHandleSpecialSchemes(uri: Uri): Boolean {
        val scheme = (uri.scheme ?: "").lowercase()
        if (scheme == "intent") {
            try {
                val intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME)
                try {
                    startActivity(intent)
                    return true
                } catch (_: ActivityNotFoundException) {
                    intent.`package`?.let { pkg ->
                        val market = Uri.parse("market://details?id=$pkg")
                        try { startActivity(Intent(Intent.ACTION_VIEW, market)) } catch (_: Exception) {}
                    }
                    return true
                }
            } catch (_: Exception) { return true }
        }
        if (scheme == "market") {
            openExternally(uri)
            return true
        }
        return false
    }

iOS

swift
		// 허용 도메인(서브도메인 포함) — 내부에서 열릴 도메인 추가
    private let allowedDomains: Set<String> = [
        "*.blomics.net"
    ]

    private func isAllowedDomain(_ host: String?) -> Bool {
        guard let h = host?.lowercased() else { return false }
        return allowedDomains.contains(where: { base in h == base || h.hasSuffix("." + base) })
    }

    // 허용되지 않은 http(s) 또는 비 http(s) 스킴이면 true
    private func shouldOpenExternally(_ url: URL) -> Bool {
        guard let scheme = url.scheme?.lowercased() else { return true }
        if scheme == "http" || scheme == "https" {
            return !isAllowedDomain(url.host)
        }
        // mailto:, tel:, sms:, itms-apps: 등 비 http(s) 스킴은 외부
        return true
    }

    private func openExternally(_ url: URL) {
        if UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url, options: [:], completionHandler: nil)
        }
    }

    //<a> 링크 클릭 정책 처리 (isUserClick만 담당, isNewWindow는 createWebViewWith에서 처리)
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

        guard let url = navigationAction.request.url else {
            decisionHandler(.allow); return
        }

        let isUserClick = navigationAction.navigationType == .linkActivated

        if isUserClick && shouldOpenExternally(url) {
            decisionHandler(.cancel)
            openExternally(url)
        } else {
            decisionHandler(.allow)
        }
    }

    // window.open / target="_blank" 새 창 생성 정책 처리 (isNewWindow 담당)
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView? {

        guard let url = navigationAction.request.url else { return nil }

        if shouldOpenExternally(url) {
            openExternally(url)
        } else {
            webView.load(URLRequest(url: url))
        }
        return nil
    }

Flutter

dart
	// 허용 도메인(서브도메인 포함) — 내부에서 열릴 도메인 추가
  final Set<String> allowedDomains = {
		"*.blomics.net"
  };

  bool isAllowedDomain(String? host) {
    final h = host?.toLowerCase();
    if (h == null) return false;
    for (final base in allowedDomains) {
      if (h == base || h.endsWith(".$base")) return true;
    }
    return false;
  }

  // 허용되지 않은 http(s) 또는 비 http(s) 스킴이면 true
  bool shouldOpenExternally(Uri url) {
    final scheme = url.scheme.toLowerCase();
    if (scheme == "http" || scheme == "https") {
      return !isAllowedDomain(url.host);
    }
    // mailto:, tel:, sms:, itms-apps: 등 비 http(s) 스킴은 외부
    return true;
  }

  Future<void> openExternally(Uri url) async {
    if (await canLaunchUrl(url)) {
      await launchUrl(url, mode: LaunchMode.externalApplication);
    }
  }

샘플 코드

AOS

kotlin
package com.tenqube.alloweddomainwebview

import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.os.Message
import android.webkit.*
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature

class MainActivity : AppCompatActivity() {

    private lateinit var webView: WebView

    // 허용 도메인(서브도메인 포함) — 내부에서 열릴 도메인 추가
    private val allowedDomains = setOf(
        "*.blomics.net"
    )

    private fun isAllowedDomain(host: String?): Boolean {
        val h = host?.lowercase() ?: return false
        return allowedDomains.any { base -> h == base || h.endsWith(".$base") }
    }

    private fun shouldOpenExternally(url: Uri): Boolean {
        val scheme = url.scheme?.lowercase() ?: return true
        return if (scheme == "http" || scheme == "https") {
            !isAllowedDomain(url.host)
        } else {
            true // 비 http(s) 스킴은 외부로
        }
    }

    private fun openExternally(uri: Uri) {
        try {
            startActivity(Intent(Intent.ACTION_VIEW, uri))
        } catch (_: ActivityNotFoundException) {}
    }

    private fun tryHandleSpecialSchemes(uri: Uri): Boolean {
        val scheme = (uri.scheme ?: "").lowercase()
        if (scheme == "intent") {
            try {
                val intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME)
                try {
                    startActivity(intent)
                    return true
                } catch (_: ActivityNotFoundException) {
                    intent.`package`?.let { pkg ->
                        val market = Uri.parse("market://details?id=$pkg")
                        try { startActivity(Intent(Intent.ACTION_VIEW, market)) } catch (_: Exception) {}
                    }
                    return true
                }
            } catch (_: Exception) { return true }
        }
        if (scheme == "market") {
            openExternally(uri)
            return true
        }
        return false
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)

        webView = findViewById(R.id.webView)

        // 쿠키 허용 (광고 타겟팅 및 세션 유지에 필요)
        CookieManager.getInstance().setAcceptCookie(true)
        CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)

        val settings = webView.settings
        settings.javaScriptEnabled = true
        settings.domStorageEnabled = true
        settings.setSupportMultipleWindows(true)              // window.open 허용
        settings.javaScriptCanOpenWindowsAutomatically = true
        settings.userAgentString = settings.userAgentString + " AllowedDomainWebView/1.0"
        settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW

        // 영상 자동재생 허용 (사용자 제스처 불필요)
        settings.mediaPlaybackRequiresUserGesture = false
            override fun onPermissionRequest(request: PermissionRequest?) {
                request?.grant(request.resources)
            }

            // window.open / target=_blank 처리
            override fun onCreateWindow(
                view: WebView?, isDialog: Boolean,
                isUserGesture: Boolean, resultMsg: Message?
            ): Boolean {
                val temp = WebView(this@MainActivity).apply {
                    settings.javaScriptEnabled = true
                    settings.domStorageEnabled = true
                    settings.setSupportMultipleWindows(true)
                    settings.javaScriptCanOpenWindowsAutomatically = true
                    webViewClient = object : WebViewClient() {
                        override fun onPageStarted(v: WebView?, urlStr: String?, favicon: Bitmap?) {
                            val target = urlStr ?: "about:blank"
                            val uri = Uri.parse(target)
                            if (tryHandleSpecialSchemes(uri)) { try { v?.destroy() } catch (_: Throwable) {}; return }
                            if (shouldOpenExternally(uri)) openExternally(uri)
                            else this@MainActivity.webView.loadUrl(target)
                            try { v?.destroy() } catch (_: Throwable) {}
                        }
                    }
                }
                val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
                transport.webView = temp
                resultMsg.sendToTarget()
                return true
            }

            override fun onCloseWindow(window: WebView?) {
                try { window?.destroy() } catch (_: Throwable) {}
                super.onCloseWindow(window)
            }
        }

        // 뒤로가기 처리
        onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                if (this@MainActivity::webView.isInitialized && webView.canGoBack()) {
                    webView.goBack()
                } else {
                    isEnabled = false
                    onBackPressedDispatcher.onBackPressed()
                }
            }
        })

    }

}

iOS

swift
import UIKit
import WebKit

final class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {

    private lazy var webView: WKWebView = {
        let config = WKWebViewConfiguration()

        // window.open 허용
        config.preferences.javaScriptCanOpenWindowsAutomatically = true

        // 영상 광고 인라인 재생 허용
        config.allowsInlineMediaPlayback = true

        // 사용자 제스처 없이 자동재생 허용 (오디오·비디오 모두)
        config.mediaTypesRequiringUserActionForPlayback = []

        let wv = WKWebView(frame: .zero, configuration: config)
        wv.navigationDelegate = self
        wv.uiDelegate = self
        wv.translatesAutoresizingMaskIntoConstraints = false
        wv.allowsBackForwardNavigationGestures = true
        return wv
    }()

    // 허용 도메인(서브도메인 포함) — 내부에서 열릴 도메인 추가
    private let allowedDomains: Set<String> = [
        " *.blomics.net"
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        // 서드파티 쿠키 허용 (광고 타겟팅 및 세션 유지에 필요)
        // WKWebView는 기본적으로 HTTPCookieStorage.shared를 사용하며,
        // iOS 11+ 에서는 WKHTTPCookieStore를 통해 쿠키를 직접 제어할 수 있습니다.
        HTTPCookieStorage.shared.cookieAcceptPolicy = .always

        view.addSubview(webView)
        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])

    }

    private func isAllowedDomain(_ host: String?) -> Bool {
        guard let h = host?.lowercased() else { return false }
        return allowedDomains.contains(where: { base in h == base || h.hasSuffix("." + base) })
    }

    // 허용되지 않은 http(s) 또는 비 http(s) 스킴이면 true
    private func shouldOpenExternally(_ url: URL) -> Bool {
        guard let scheme = url.scheme?.lowercased() else { return true }
        if scheme == "http" || scheme == "https" {
            return !isAllowedDomain(url.host)
        }
        // mailto:, tel:, sms:, itms-apps: 등 비 http(s) 스킴은 외부
        return true
    }

    private func openExternally(_ url: URL) {
        if UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url, options: [:], completionHandler: nil)
        }
    }

    // <a> 링크 클릭 정책 처리 (isUserClick만 담당, isNewWindow는 createWebViewWith에서 처리)
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

        guard let url = navigationAction.request.url else {
            decisionHandler(.allow); return
        }

        let isUserClick = navigationAction.navigationType == .linkActivated

        if isUserClick && shouldOpenExternally(url) {
            decisionHandler(.cancel)
            openExternally(url)
        } else {
            decisionHandler(.allow)
        }
    }

    // window.open / target="_blank" 새 창 생성 정책 처리 (isNewWindow 담당)
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView? {

        guard let url = navigationAction.request.url else { return nil }

        if shouldOpenExternally(url) {
            openExternally(url)
        } else {
            webView.load(URLRequest(url: url))
        }
        return nil
    }
}

Flutter

dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (Platform.isAndroid) {
    await InAppWebViewController.setWebContentsDebuggingEnabled(true);
  }
  // iOS 서드파티 쿠키 허용 (광고 타겟팅 및 세션 유지에 필요)
  // Android는 InAppWebViewSettings.thirdPartyCookiesEnabled로 처리
  if (Platform.isIOS) {
    final cookieManager = CookieManager.instance();
    await cookieManager.setCookiesEnabled(true);
  }
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: WebViewPage(),
    );
  }
}

class WebViewPage extends StatefulWidget {
  const WebViewPage({super.key});

  @override
  State<WebViewPage> createState() => _WebViewPageState();
}

class _WebViewPageState extends State<WebViewPage> {
  InAppWebViewController? _controller;

  // 허용 도메인(서브도메인 포함) — 내부에서 열릴 도메인 추가
  final Set<String> allowedDomains = {
    "*.blomics.net",
  };

  bool isAllowedDomain(String? host) {
    final h = host?.toLowerCase();
    if (h == null) return false;
    for (final base in allowedDomains) {
      if (h == base || h.endsWith(".$base")) return true;
    }
    return false;
  }

  // 허용되지 않은 http(s) 또는 비 http(s) 스킴이면 true
  bool shouldOpenExternally(Uri url) {
    final scheme = url.scheme.toLowerCase();
    if (scheme == "http" || scheme == "https") {
      return !isAllowedDomain(url.host);
    }
    // mailto:, tel:, sms:, itms-apps: 등 비 http(s) 스킴은 외부
    return true;
  }

  Future<void> openExternally(Uri url) async {
    if (await canLaunchUrl(url)) {
      await launchUrl(url, mode: LaunchMode.externalApplication);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: InAppWebView(
          initialUrlRequest: URLRequest(
            url: WebUri(""),
          ),
            initialSettings: InAppWebViewSettings(
            javaScriptEnabled: true,
            useShouldOverrideUrlLoading: true,
            allowsBackForwardNavigationGestures: true,
            javaScriptCanOpenWindowsAutomatically: true,
            supportMultipleWindows: true,

            //영상 자동재생 허용
            mediaPlaybackRequiresUserGesture: false,

            //AOS ====================================
            thirdPartyCookiesEnabled: true,//서드파티 쿠키 허용

            //IOS =====================================
            allowsInlineMediaPlayback: true,//인라인 재생 허용
            allowsPictureInPictureMediaPlayback: false,
          ),
          onWebViewCreated: (controller) {
            _controller = controller;
          },
          // <a> 링크 클릭 처리
          shouldOverrideUrlLoading: (controller, navigationAction) async {
            final uri = navigationAction.request.url;
            if (uri == null) return NavigationActionPolicy.ALLOW;

            final isUserClick = navigationAction.navigationType == NavigationType.LINK_ACTIVATED;

            if (isUserClick && shouldOpenExternally(Uri.parse(uri.toString()))) {
              await openExternally(Uri.parse(uri.toString()));
              return NavigationActionPolicy.CANCEL;
            }
            return NavigationActionPolicy.ALLOW;
          },
          // window.open / target="_blank" 처리
          onCreateWindow: (controller, onCreateWindowRequest) async {
            final uri = onCreateWindowRequest.request.url;
            if (uri == null) return true;

            if (shouldOpenExternally(Uri.parse(uri.toString()))) {
              await openExternally(Uri.parse(uri.toString()));
            } else {
              // 새 창을 만들지 않고 현재 웹뷰에서 로드 (재사용)
              await _controller?.loadUrl(urlRequest: URLRequest(url: uri));
            }
            return false;
          },
        ),
      ),
    );
  }
}

자바스크립트 인터페이스

초기설정

  • 데이터는 단일 데이터의 경우 해당 값을 String으로 전달하며, 다중 데이터는 JSON 형태의 String으로 전달합니다. 자세한 가이드는 각 상세 가이드를 참고해주세요.

매체사(WebView → Web)

  • finish() — 현재 웹뷰를 종료합니다.
  • registerBackKey() — 시스템 뒤로가기(제스처/버튼)가 호출되면 네이티브에서 addedBridgeCallback.onBackKeyListener()를 호출합니다.
  • registerBackButton() — 앱바 영역에 백버튼이 존재하는 경우, 백버튼 터치 시 네이티브에서 addedBridgeCallback.onBackButtonListener()를 호출합니다.

자세한 사용법은 매체사 상세 가이드를 참고하세요.

콘텐츠사(Web → WebView)

  • openExternalBrowser() — 콘텐츠사에서 요청 받은 URL을 외부 브라우저로 연결합니다.
  • finish() — 현재 웹뷰를 종료합니다.
  • registerBackKey() — 시스템 뒤로가기(제스처/버튼) 발생 시 호출될 콜백을 등록합니다.
  • registerBackButton() — 앱바 백버튼이 있는 경우, 백버튼 터치 시 호출될 콜백을 등록합니다.

자세한 사용법은 콘텐츠사 상세 가이드를 참고하세요.

웹뷰 최적화 설정

광고 수익화를 위한 추가 권장 설정 (Google for Developers).

웹뷰 디버깅
개발 단계에서는 Chrome DevTools chrome://inspect 또는 Safari Web Inspector 로 웹뷰를 디버깅할 수 있도록 setWebContentsDebuggingEnabled(true) (Android) / isInspectable = true (iOS 16.4+) 를 활성화하세요. 프로덕션 빌드에선 비활성화 필수.