웹뷰 연동

매체사(WebView -> Web)

매체사(네이티브 앱) 웹뷰 호스트가 콘텐츠 웹의 자바스크립트 인터페이스를 호출하기 위한 구현 가이드입니다. AOS(Kotlin), iOS(Swift), Flutter(Dart) 샘플 코드를 제공합니다.

초기설정

  • 데이터는 단일 데이터의 경우 해당 값을 String으로 전달. 다중 데이터는 JSON 형태의 String으로 전달한다.

AOS

kotlin
// 초기화 — 객체 이름은 반드시 "addedBridge"
webView.settings.javaScriptEnabled = true
val bridge = AddedBridge(this, webView)
webView.addJavascriptInterface(bridge, "addedBridge")


class AddedBridge(
    private val activity: ComponentActivity,
    private val webView: WebView,
) {
    // @JavascriptInterface 메서드는 백그라운드(JavaBridge) 스레드에서 호출된다.
    // 화면 · 웹뷰 조작은 반드시 UI 스레드로 넘긴다(callback 은 내부에서 넘긴다).
    @JavascriptInterface
    fun interfaceName(message: String) {
        // 요청값 callback
        callback("interfaceName", "value")
    }

    /** App → Web 콜백(단일 값). JSON 인코딩으로 감싸 따옴표 · 역슬래시 · 줄바꿈이 섞여도 깨지지 않는다. */
    fun callback(name: String, value: String? = null) =
        evaluate(name, value?.let { JSONObject.quote(it) } ?: "")

    /** App → Web 콜백(다중 값). JSON 객체를 그대로 넘긴다. */
    fun callback(name: String, payload: JSONObject) = evaluate(name, payload.toString())

    private fun evaluate(name: String, arg: String) {
        // 웹이 아직 콜백을 등록하지 않았으면 조용히 넘어간다
        val js = "window.addedBridgeCallback&&addedBridgeCallback.$name&&addedBridgeCallback.$name($arg)"
        activity.runOnUiThread { webView.evaluateJavascript(js, null) }
    }
}

iOS

swift
// 초기화 — handler 이름은 반드시 "addedBridge" (단일 채널에 method 를 실어 받는다)
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "addedBridge")
webView = WKWebView(frame: .zero, configuration: config)


func userContentController(
    _ userContentController: WKUserContentController,
    didReceive message: WKScriptMessage
) {
    guard message.name == "addedBridge" else { return }

    // body 는 { method, message } 객체다. JSON 문자열로 보내는 웹도 함께 받아 준다.
    var body = message.body as? [String: Any]
    if body == nil, let text = message.body as? String {
        body = object(text)
    }
    guard let body, let method = body["method"] as? String else { return }
    let param = body["message"] as? String ?? ""   // 값이 없는 호출은 빈 문자열

    switch method {
    case "interfaceName":
        // 요청값 callback
        callback("interfaceName", "value")
    default:
        break   // 미구현 메서드는 무시한다
    }
}

/// App → Web 콜백(단일 값). JSON 인코딩으로 감싸 따옴표 · 역슬래시 · 줄바꿈이 섞여도 깨지지 않는다.
func callback(_ name: String, _ value: String? = nil) {
    var arg = ""
    if let value, let data = try? JSONEncoder().encode(value) {
        arg = String(decoding: data, as: UTF8.self)
    }
    evaluate(name, arg)
}

/// App → Web 콜백(다중 값). JSON 객체를 그대로 넘긴다.
func callback(_ name: String, payload: [String: Any]) {
    guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
    evaluate(name, String(decoding: data, as: UTF8.self))
}

private func evaluate(_ name: String, _ arg: String) {
    // 웹이 아직 콜백을 등록하지 않았으면 조용히 넘어간다
    let js = "window.addedBridgeCallback&&addedBridgeCallback.\(name)&&addedBridgeCallback.\(name)(\(arg))"
    DispatchQueue.main.async { self.webView.evaluateJavaScript(js) }
}

/// message(JSON 문자열) → 사전
func object(_ text: String) -> [String: Any]? {
    guard let data = text.data(using: .utf8) else { return nil }
    return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}

Flutter

dart
// 초기화 — 채널 이름은 반드시 'addedBridge'. 웹은 {method, message} JSON 문자열 1개를 보낸다
_controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..addJavaScriptChannel(
    'addedBridge',
    onMessageReceived: (JavaScriptMessage m) {
      final Map<String, dynamic> body;
      try {
        body = jsonDecode(m.message) as Map<String, dynamic>;
      } catch (_) {
        return; // 형식이 다른 메시지는 무시
      }
      final method = body['method'] as String? ?? '';
      final message = body['message'] as String? ?? '';   // 값이 없는 호출은 빈 문자열
      _handle(method, message);
    },
  );


Future<void> _handle(String method, String message) async {
  switch (method) {
    case 'interfaceName':
      // 요청값 callback
      _callback('interfaceName', 'value');
      break;
  }
}

/// App → Web 콜백. jsonEncode 로 감싸 따옴표 · 역슬래시 · 줄바꿈이 섞여도 깨지지 않는다.
/// 단일 값은 String, 다중 값은 Map 을 넘긴다.
void _callback(String name, [Object? value]) {
  final arg = value == null ? '' : jsonEncode(value);
  // 웹이 아직 콜백을 등록하지 않았으면 조용히 넘어간다
  _controller.runJavaScript(
    'window.addedBridgeCallback&&addedBridgeCallback.$name&&addedBridgeCallback.$name($arg)',
  );
}

구현 규약

  • 주입 이름은 addedBridge, 콜백은 window.addedBridgeCallback.<이름>(인자) 입니다. 콘텐츠 웹은 주입 형태로 플랫폼을 판별합니다 — AOS 는 메서드가 객체에 드러나는 @JavascriptInterface, iOS 는 단일 messageHandlers.addedBridge(body { method, message }), Flutter 는 postMessage(JSON 문자열) 입니다.
  • 모든 메서드는 문자열 인자 1개를 받습니다. 값이 없는 호출은 빈 문자열입니다.
  • 콜백 인자는 JSON 인코딩으로 감싸 넘깁니다(샘플의 callback). ' 만 치환하면 역슬래시· 줄바꿈이 섞인 값에서 스크립트가 깨집니다. 콜백 호출 전에 웹이 등록했는지 확인해 예외를 막습니다.
  • 조회 요청에는 항상 응답합니다. 값을 모르면 빈 문자열로 답합니다. 콜백에는 ACK 가 없어, 응답하지 않으면 웹은 상한(3초)까지 기다린 뒤 값 없음으로 처리합니다.
  • 웹뷰 조작은 UI 스레드에서 합니다. AOS 의 @JavascriptInterface 메서드는 백그라운드 스레드에서 불리고, 반환할 때까지 웹의 JS 도 멈추므로 오래 걸리는 작업은 별도 스레드로 넘깁니다.
  • 구현하지 않은 메서드는 무시해도 됩니다. 웹은 상한으로 종결합니다.
  • Flutter(iOS) 의 webview_flutter 는 window.addedBridge 를 messageHandler 와 같은 객체로 만듭니다. 웹이 이를 판별해 문자열로 보내므로 Dart 쪽은 jsonDecode(m.message) 그대로 두면 됩니다.

필수

finish()

  • 현재 웹뷰를 종료합니다.
  • 콘텐츠는 웹뷰를 벗어날 때 항상 이 메서드를 호출합니다(history.back() 으로 대신하지 않음).

AOS

kotlin
@JavascriptInterface
fun finish(message: String) {
    activity.runOnUiThread { activity.finish() }
}

iOS

swift
case "finish":
    DispatchQueue.main.async {
        if let nav = self.navigationController {
            nav.popViewController(animated: true)
        } else {
            self.dismiss(animated: true)
        }
    }

Flutter

dart
case 'finish':
  if (mounted) Navigator.of(context).pop();
  break;

registerBackKey()

  • 시스템 뒤로가기(제스처/버튼)가 호출되면 네이티브에서 addedBridgeCallback.onBackKeyListener() 를 호출합니다.
  • @param {string} true|false
  • 등록되지 않았으면 웹뷰 뒤로가기 → 웹뷰 종료 순으로 앱이 직접 처리합니다.

AOS

kotlin
// AddedBridge — 웹이 보낸 등록 상태
@Volatile var isBackKeyRegistered = false

@JavascriptInterface
fun registerBackKey(message: String) {
    isBackKeyRegistered = message == "true"
}


// Activity — 시스템 뒤로가기(버튼 · 제스처) → onBackKeyListener
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
    override fun handleOnBackPressed() {
        when {
            bridge.isBackKeyRegistered -> bridge.callback("onBackKeyListener")
            webView.canGoBack() -> webView.goBack()
            else -> finish()
        }
    }
})

iOS

swift
var isBackKeyRegistered = false

case "registerBackKey":
    isBackKeyRegistered = (param == "true")


// 시스템 뒤로가기 제스처 — WKWebView 기본 스와이프 백 대신 엣지 제스처로 직접 받는다
// (UINavigationController 의 interactivePopGestureRecognizer 를 쓴다면 이 화면에서는 꺼 둔다)
webView.allowsBackForwardNavigationGestures = false
let edge = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(onEdgeSwipe(_:)))
edge.edges = .left
webView.addGestureRecognizer(edge)

@objc private func onEdgeSwipe(_ gesture: UIScreenEdgePanGestureRecognizer) {
    guard gesture.state == .recognized else { return }

    if isBackKeyRegistered {
        callback("onBackKeyListener")
    } else if webView.canGoBack {
        webView.goBack()
    } else if let nav = navigationController {
        nav.popViewController(animated: true)
    } else {
        dismiss(animated: true)
    }
}

Flutter

dart
bool _isBackKeyRegistered = false;
bool _canGoBack = false;   // onPageFinished 등에서 await _controller.canGoBack() 으로 갱신

case 'registerBackKey':
  setState(() => _isBackKeyRegistered = message == 'true');
  break;


// 시스템 뒤로가기 / 제스처
PopScope(
  canPop: !_isBackKeyRegistered && !_canGoBack,
  onPopInvokedWithResult: (didPop, result) {
    if (didPop) return;

    if (_isBackKeyRegistered) {
      _callback('onBackKeyListener');
    } else if (_canGoBack) {
      _controller.goBack();
    }
  },
  child: WebViewWidget(controller: _controller),
)

registerBackButton()

  • 앱바 영역에 백버튼이 존재하는 경우에만 필요합니다.
  • 앱바의 백버튼이 터치되면 네이티브에서 addedBridgeCallback.onBackButtonListener() 를 호출합니다.
  • @param {string} true|false

AOS

kotlin
// AddedBridge
@Volatile var isBackButtonRegistered = false

@JavascriptInterface
fun registerBackButton(message: String) {
    isBackButtonRegistered = message == "true"
}


// 앱바 백버튼 → onBackButtonListener
IconButton(onClick = {
    when {
        bridge.isBackButtonRegistered -> bridge.callback("onBackButtonListener")
        webView.canGoBack() -> webView.goBack()
        else -> activity.finish()
    }
}) {
    Icon(Icons.AutoMirrored.Filled.ArrowBack, "뒤로가기")
}

iOS

swift
var isBackButtonRegistered = false

case "registerBackButton":
    isBackButtonRegistered = (param == "true")


// 앱바 백버튼 → onBackButtonListener
navigationItem.leftBarButtonItem = UIBarButtonItem(
    image: UIImage(systemName: "chevron.backward"), style: .plain,
    target: self, action: #selector(onBackButtonTapped)
)

@objc private func onBackButtonTapped() {
    if isBackButtonRegistered {
        callback("onBackButtonListener")
    } else if webView.canGoBack {
        webView.goBack()
    } else if let nav = navigationController {
        nav.popViewController(animated: true)
    } else {
        dismiss(animated: true)
    }
}

Flutter

dart
bool _isBackButtonRegistered = false;

case 'registerBackButton':
  setState(() => _isBackButtonRegistered = message == 'true');
  break;


AppBar(
  title: const Text('WebView'),
  leading: IconButton(
    icon: const Icon(Icons.arrow_back),
    onPressed: () {
      if (_isBackButtonRegistered) {
        _callback('onBackButtonListener');
      } else if (_canGoBack) {
        _controller.goBack();
      } else {
        Navigator.of(context).pop();
      }
    },
  ),
),

문서 전환 시 등록 초기화 — 권장

  • registerBackKey / registerBackButton 상태는 앱에 남지만, 새 문서(pushUrl·링크 이동)에는 이전 페이지의 콜백이 없습니다. 새 페이지가 다시 등록하지 않으면 뒤로가기가 아무 동작도 하지 않습니다.
  • 메인 프레임이 새 문서를 열기 시작할 때 두 상태를 false 로 되돌리면, 등록하지 않은 페이지에서도 기본 뒤로가기·종료로 동작합니다. 같은 문서 안의 이동(pushState·해시)에서는 호출되지 않습니다.

AOS

kotlin
// 새 문서를 열기 시작하면 등록을 초기화한다
webView.webViewClient = object : WebViewClient() {
    override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
        bridge.isBackKeyRegistered = false
        bridge.isBackButtonRegistered = false
    }
}

iOS

swift
// WKNavigationDelegate — 새 문서를 열기 시작하면 등록을 초기화한다
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
    isBackKeyRegistered = false
    isBackButtonRegistered = false
}

Flutter

dart
// 새 문서를 열기 시작하면 등록을 초기화한다
_controller.setNavigationDelegate(NavigationDelegate(
  onPageStarted: (url) => setState(() {
    _isBackKeyRegistered = false;
    _isBackButtonRegistered = false;
  }),
));

선택

getAppVersion()

  • 네이티브 앱의 현재 버전 정보를 조회하여 웹으로 전달합니다.
  • 응답은 addedBridgeCallback.getAppVersion(version) 으로 전달합니다.

AOS

kotlin
@JavascriptInterface
fun getAppVersion(message: String) {
    val version = activity.packageManager
        .getPackageInfo(activity.packageName, 0).versionName ?: ""
    callback("getAppVersion", version)
}

iOS

swift
case "getAppVersion":
    let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
    callback("getAppVersion", version)

Flutter

dart
// pubspec.yaml: package_info_plus 패키지 필요
case 'getAppVersion':
  final info = await PackageInfo.fromPlatform();
  _callback('getAppVersion', info.version);
  break;

getADID()

  • 디바이스 광고 식별값(ADID/IDFA)을 조회하여 웹으로 전달합니다.
  • 응답은 addedBridgeCallback.getADID(adId) 으로 전달합니다. 조회에 실패해도 빈 문자열로 응답합니다.

AOS

kotlin
// Google Play Services Ads Identifier 의존성 필요
// implementation("com.google.android.gms:play-services-ads-identifier:18.x.x")
@JavascriptInterface
fun getADID(message: String) {
    // 블로킹 조회는 별도 스레드에서 한다 — JavaBridge 메서드가 반환할 때까지 웹의 JS 도 멈춘다
    thread {
        val adId = try {
            AdvertisingIdClient.getAdvertisingIdInfo(activity).id ?: ""
        } catch (e: Exception) {
            ""   // 실패해도 응답은 보낸다 — 응답이 없으면 웹은 상한까지 기다린다
        }
        callback("getADID", adId)
    }
}

iOS

swift
// import AdSupport, AppTrackingTransparency
// Info.plist: NSUserTrackingUsageDescription 필요. 추적을 허용받지 못하면 IDFA 는 0 으로 온다
case "getADID":
    ATTrackingManager.requestTrackingAuthorization { _ in
        let adId = ASIdentifierManager.shared().advertisingIdentifier.uuidString
        self.callback("getADID", adId)
    }

Flutter

dart
// pubspec.yaml: advertising_id 패키지 필요
case 'getADID':
  final adId = await AdvertisingId.id(true) ?? '';
  _callback('getADID', adId);
  break;

getIsAgeUnder14()

  • 유저의 나이가 14세 미만인지 여부를 조회하여 웹으로 전달합니다. ("true" | "false", 알 수 없으면 빈 문자열)
  • 응답은 addedBridgeCallback.getIsAgeUnder14(isUnder14) 으로 전달합니다.

AOS

kotlin
@JavascriptInterface
fun getIsAgeUnder14(message: String) {
    // 앱에서 관리하는 유저 나이 정책에 맞게 구현 — 알 수 없으면 빈 문자열
    val isUnder14: Boolean? = userRepository.isAgeUnder14()
    callback("getIsAgeUnder14", isUnder14?.toString() ?: "")
}

iOS

swift
case "getIsAgeUnder14":
    // 앱에서 관리하는 유저 나이 정책에 맞게 구현 — 알 수 없으면 빈 문자열
    let isUnder14: Bool? = UserRepository.shared.isAgeUnder14
    callback("getIsAgeUnder14", isUnder14.map { $0 ? "true" : "false" } ?? "")

Flutter

dart
case 'getIsAgeUnder14':
  // 앱에서 관리하는 유저 나이 정책에 맞게 구현 — 알 수 없으면 빈 문자열
  final bool? isUnder14 = userRepository.isAgeUnder14;
  _callback('getIsAgeUnder14', isUnder14 == null ? '' : '$isUnder14');
  break;

getDeviceInfo()

  • 기기 정보(network / model / manufacturer / osVersion)를 조회하여 웹으로 전달합니다.
  • 응답은 JSON 문자열로 addedBridgeCallback.getDeviceInfo(deviceInfoJson) 으로 전달합니다.
  • ex) {"network":"wifi","model":"SM-S911N","manufacturer":"samsung","osVersion":"14"}

AOS

kotlin
@JavascriptInterface
fun getDeviceInfo(message: String) {
    val cm = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val caps = cm.getNetworkCapabilities(cm.activeNetwork)
    val network = when {
        caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi"
        caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "mobile"
        else -> ""
    }
    val json = JSONObject()
        .put("network", network)
        .put("model", Build.MODEL)
        .put("manufacturer", Build.MANUFACTURER)
        .put("osVersion", Build.VERSION.RELEASE)
        .toString()
    callback("getDeviceInfo", json)   // JSON 문자열로 전달
}

iOS

swift
case "getDeviceInfo":
    let info: [String: String] = [
        "network": NetworkMonitor.shared.currentType(),   // "wifi" | "mobile" — 앱의 NWPathMonitor 래퍼
        "model": UIDevice.current.model,
        "manufacturer": "Apple",
        "osVersion": UIDevice.current.systemVersion,
    ]
    if let data = try? JSONSerialization.data(withJSONObject: info) {
        callback("getDeviceInfo", String(decoding: data, as: UTF8.self))   // JSON 문자열로 전달
    }

Flutter

dart
// pubspec.yaml: device_info_plus, connectivity_plus(6.x) 패키지 필요
case 'getDeviceInfo':
  final deviceInfo = DeviceInfoPlugin();
  final connectivity = await Connectivity().checkConnectivity();   // List<ConnectivityResult>
  final network = connectivity.contains(ConnectivityResult.wifi)
      ? 'wifi'
      : connectivity.contains(ConnectivityResult.mobile) ? 'mobile' : '';
  String model = '', manufacturer = '', osVersion = '';
  if (Platform.isAndroid) {
    final android = await deviceInfo.androidInfo;
    model = android.model;
    manufacturer = android.manufacturer;
    osVersion = android.version.release;
  } else if (Platform.isIOS) {
    final ios = await deviceInfo.iosInfo;
    model = ios.model;
    manufacturer = 'Apple';
    osVersion = ios.systemVersion;
  }
  _callback('getDeviceInfo', jsonEncode({   // JSON 문자열로 전달
    'network': network,
    'model': model,
    'manufacturer': manufacturer,
    'osVersion': osVersion,
  }));
  break;

pushUrl()

  • 현재 웹뷰를 재사용하여 전달받은 URL로 이동합니다.
  • @param {string} JSON string { url, replace? } — replace=true 면 history 스택 교체, false면 push

AOS

kotlin
@JavascriptInterface
fun pushUrl(message: String) {
    val obj = JSONObject(message)
    val url = obj.optString("url")
    val replace = obj.optBoolean("replace", false)
    activity.runOnUiThread {
        webView.loadUrl(url)
        // replace=true: WebView 에는 항목 교체 API 가 없다 —
        // 로드가 끝난 뒤(onPageFinished) clearHistory() 로 이전 항목을 지운다
    }
}

iOS

swift
case "pushUrl":
    guard let json = object(param),
          let urlStr = json["url"] as? String,
          let url = URL(string: urlStr) else { return }
    let replace = json["replace"] as? Bool ?? false
    DispatchQueue.main.async {
        self.webView.load(URLRequest(url: url))
        // replace=true: WKWebView 에는 히스토리 항목을 지우는 API 가 없어 별도 정책이 필요하다
    }

Flutter

dart
case 'pushUrl':
  final data = jsonDecode(message) as Map<String, dynamic>;
  final url = data['url'] as String;
  final replace = (data['replace'] as bool?) ?? false;
  _controller.loadRequest(Uri.parse(url));
  // replace=true 의 경우 history 관리 정책 필요
  break;

openInternalBrowser()

  • 새로운 앱 화면(Activity / UIViewController)에 웹뷰를 띄웁니다.
  • @param {string} url

AOS

kotlin
@JavascriptInterface
fun openInternalBrowser(message: String) {
    activity.runOnUiThread {
        val intent = Intent(activity, WebViewActivity::class.java).apply {
            putExtra("url", message)
        }
        activity.startActivity(intent)
    }
}

iOS

swift
case "openInternalBrowser":
    guard let url = URL(string: param) else { return }
    DispatchQueue.main.async {
        let vc = WebViewController(url: url)
        if let nav = self.navigationController {
            nav.pushViewController(vc, animated: true)
        } else {
            self.present(vc, animated: true)
        }
    }

Flutter

dart
case 'openInternalBrowser':
  Navigator.of(context).push(MaterialPageRoute(
    builder: (_) => WebViewPage(url: message),
  ));
  break;

setTitle()

  • 앱바 영역의 타이틀을 수정합니다. 앱바를 앱에서 관리하고 타이틀이 존재할 때만 사용합니다.
  • @param {string} title

AOS

kotlin
@JavascriptInterface
fun setTitle(message: String) {
    activity.runOnUiThread {
        // Compose 상태값 업데이트 등 앱바 구현에 맞게 반영
        appBarTitle.value = message
    }
}

iOS

swift
case "setTitle":
    DispatchQueue.main.async {
        self.title = param
        self.navigationItem.title = param
    }

Flutter

dart
case 'setTitle':
  setState(() => _title = message);
  break;

광고 (보상형 · 전면형)

콘텐츠 웹이 호출한 AdMob 보상형(rewarded) · 전면형(interstitial) 광고를 전체 화면으로 표시합니다. 두 형식 모두 전체 화면과 닫기 UI 를 AdMob SDK 가 소유하므로, 네이티브 오버레이와 달리 웹에서 rect 를 받지 않습니다. 결과는 requestId 기준으로 콜백합니다.

  • 보상형: adInit(1회) → adLoad(format: rewarded) → adShowRewarded(또는 adShow) → onAdShown → onAdRewarded(보상 조건 충족 시) → onAdClosed.
  • 전면형: adInit(1회) → adLoad(format: interstitial) → adShow → onAdShown → onAdClosed. onAdRewarded 는 보내지 않습니다.
AdMob 광고 형식 구현 (필수)
  • 보상형(rewarded)은 RewardedAd, 전면형(interstitial)은 InterstitialAd(AOS · iOS · Flutter 공통 이름)를 사용하며, 전체화면·닫기 UI 는 SDK 가 소유합니다. 노출/클릭/닫힘/실패는 두 형식 모두 같은 전체화면 콜백(FullScreenContentCallback / iOS FullScreenContentDelegate)으로 통지합니다.
  • 표시 메서드는 adShowRewarded 와 adShow 를 모두 받아 같은 동작을 합니다 — requestId 로 로드해 둔 광고를 로드한 형식대로 표시합니다. 전면형은 adShow 로, 보상형은 콘텐츠(광고 미디에이션 라이브러리)에 따라 두 이름 중 하나로 옵니다. iOS·Flutter 는 단일 채널이라 웹이 어느 이름을 구현했는지 알 수 없어, 한쪽만 구현하면 광고가 로드까지만 되고 표시되지 않습니다.
  • 보상형은 onUserEarnedReward 시점에 onAdRewarded 를 보냅니다. onAdClosed 보다 먼저 보내야 합니다 — 웹은 닫힘 시점까지 받은 보상 신호로 보상 여부를 판정합니다. 이는 클라이언트 신호이므로, 실제 보상 지급은 서버(S2S) 검증 후 처리하도록 안내합니다. 전면형에는 보내지 않습니다.
  • 노출된 광고(onAdShown 을 보낸 광고)는 어떤 이유로 닫히든 반드시 onAdClosed 를 보냅니다 — 사용자 닫기, 리드타임 만료, 백그라운드에서 복귀한 뒤의 정리, SDK 오류 모두 해당합니다. 보상형은 시청을 완료했을 때만 그 앞에 onAdRewarded 를 보냅니다. 노출 전에 실패하면 onAdFailed(stage:'show')를 보냅니다. onAdClosed 가 오지 않으면 게임은 광고가 끝나기를 계속 기다리며 멈춥니다.

구현 노트

  • 로드한 광고는 형식과 무관하게 requestId 로 캐시하고, 표시 메서드에서 같은 requestId 로 찾아 씁니다. 준비되지 않았으면 onAdFailed(stage:'show', code:'NOT_READY')로 통지합니다.
  • 두 형식 모두 1회성이라 표시할 때 캐시에서 제거하고, 닫힘/보상은 SDK 콜백으로 받습니다(전체화면·닫기 UI 와 리소스는 SDK 가 자동 소유·해제).
  • ids.adUnitId 가 비어 있으면 앱의 기본 유닛을 씁니다. AdMob 유닛은 형식별로 발급되므로(보상형 유닛으로 전면형을 로드할 수 없음) 기본 유닛도 형식별로 둡니다.
  • 지원하지 않는 format 은 즉시 onAdFailed(stage:'load', code:'UNSUPPORTED_FORMAT')로 알립니다. 응답하지 않으면 웹은 로드 상한(5초)까지 기다립니다.
  • 표시를 요청한 뒤 onAdShown 이 오기 전까지 웹은 로딩 화면을 띄우고, 10초가 지나면 그 위에 [닫기] 를 보여 줍니다. 사용자가 [닫기] 를 눌러야 미노출로 종결합니다(자동으로 종결하지 않습니다).
  • onAdShown 을 받은 뒤에는 웹이 onAdClosed 를 기한 없이 기다립니다. 그래서 위의 onAdClosed 규칙은 반드시 지켜야 합니다.

adInit()

  • 부팅 시 1회. AdMob SDK 를 초기화합니다(동의/ATT 는 호스트 책임).
  • test=true 면 테스트 디바이스/광고 단위를 사용합니다.

AOS

kotlin
// build.gradle: implementation("com.google.android.gms:play-services-ads:24.x.x")
// AndroidManifest: <meta-data android:name="com.google.android.gms.ads.APPLICATION_ID" .../>

// 앱 시작 시 1회
MobileAds.initialize(context) {}

@JavascriptInterface
fun adInit(message: String) {
    // message = { test, networks:[{ key:"admob", app:{ appId } }] }
    val obj = JSONObject(message)
    if (obj.optBoolean("test")) {
        MobileAds.setRequestConfiguration(
            RequestConfiguration.Builder()
                .setTestDeviceIds(listOf(AdRequest.DEVICE_ID_EMULATOR))
                .build()
        )
    }
}

iOS

swift
// SPM: Google-Mobile-Ads-SDK 12 (import GoogleMobileAds)
// Info.plist: GADApplicationIdentifier = ca-app-pub-XXXX~YYYY

// 앱 시작 시 1회
MobileAds.shared.start(completionHandler: nil)

case "adInit":
    // 시뮬레이터는 자동으로 테스트 기기다. 실기기는 콘솔 로그에 찍히는 기기 ID 를 넣는다
    if object(param)?["test"] as? Bool == true {
        MobileAds.shared.requestConfiguration.testDeviceIdentifiers = ["<테스트 기기 ID>"]
    }

Flutter

dart
// pubspec.yaml: google_mobile_ads
// 앱 시작 시 1회
await MobileAds.instance.initialize();

case 'adInit':
  final obj = jsonDecode(message) as Map<String, dynamic>;
  if (obj['test'] == true) {
    MobileAds.instance.updateRequestConfiguration(
      RequestConfiguration(testDeviceIds: ['<테스트 기기 ID>']),
    );
  }
  break;

adLoad() — format: rewarded · interstitial

  • format: 'rewarded' 이면 RewardedAd, format: 'interstitial' 이면 InterstitialAd 를 로드해 requestId 로 캐시합니다(화면엔 표시하지 않음).
  • 결과는 onAdLoaded / onAdFailed 콜백으로 통지합니다.

AOS

kotlin
private val loadedAds = ConcurrentHashMap<String, Any>()   // requestId → RewardedAd | InterstitialAd
private val defaultUnits = mapOf(                          // ids.adUnitId 가 비었을 때
    "rewarded" to "ca-app-pub-XXXX/ZZZZ",
    "interstitial" to "ca-app-pub-XXXX/WWWW",
)

// 광고 콜백 — payload 는 객체로 전달(웹은 객체/JSON 문자열 모두 허용)
private fun adEvent(name: String, requestId: String, vararg extra: Pair<String, Any>) {
    val payload = JSONObject().put("requestId", requestId)
    extra.forEach { payload.put(it.first, it.second) }
    callback(name, payload)
}

@JavascriptInterface
fun adLoad(message: String) {
    val obj = JSONObject(message)
    val requestId = obj.optString("requestId")
    val format = obj.optString("format")
    // 지원하지 않는 형식은 즉시 실패로 알린다(웹이 상한까지 기다리지 않게)
    val fallbackUnit = defaultUnits[format]
        ?: return adEvent("onAdFailed", requestId, "stage" to "load", "code" to "UNSUPPORTED_FORMAT")
    val adUnitId = obj.optJSONObject("ids")?.optString("adUnitId").orEmpty().ifBlank { fallbackUnit }

    fun loaded(ad: Any) {
        loadedAds[requestId] = ad
        adEvent("onAdLoaded", requestId)
    }
    fun failed(e: LoadAdError) =
        adEvent("onAdFailed", requestId, "stage" to "load", "code" to e.code.toString())

    activity.runOnUiThread {
        val request = AdRequest.Builder().build()
        when (format) {
            "rewarded" -> RewardedAd.load(activity, adUnitId, request,
                object : RewardedAdLoadCallback() {
                    override fun onAdLoaded(ad: RewardedAd) = loaded(ad)
                    override fun onAdFailedToLoad(e: LoadAdError) = failed(e)
                })
            "interstitial" -> InterstitialAd.load(activity, adUnitId, request,
                object : InterstitialAdLoadCallback() {
                    override fun onAdLoaded(ad: InterstitialAd) = loaded(ad)
                    override fun onAdFailedToLoad(e: LoadAdError) = failed(e)
                })
        }
    }
}

iOS

swift
private var loadedAds: [String: FullScreenPresentingAd] = [:]   // requestId → RewardedAd | InterstitialAd
private var showing: [ObjectIdentifier: String] = [:]            // 표시 중인 광고 → requestId
private let defaultUnits = [                                     // ids.adUnitId 가 비었을 때
    "rewarded": "ca-app-pub-XXXX/ZZZZ",
    "interstitial": "ca-app-pub-XXXX/WWWW",
]

// 광고 콜백 — payload 는 객체로 전달
func adEvent(_ name: String, _ requestId: String, _ extra: [String: Any] = [:]) {
    var payload = extra
    payload["requestId"] = requestId
    callback(name, payload: payload)
}

case "adLoad":
    guard let json = object(param), let requestId = json["requestId"] as? String else { return }
    let format = json["format"] as? String ?? ""
    guard let fallbackUnit = defaultUnits[format] else {
        // 지원하지 않는 형식은 즉시 실패로 알린다(웹이 상한까지 기다리지 않게)
        adEvent("onAdFailed", requestId, ["stage": "load", "code": "UNSUPPORTED_FORMAT"])
        return
    }
    let ids = json["ids"] as? [String: Any]
    let unit = (ids?["adUnitId"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? fallbackUnit

    // 로드 결과 — 두 형식이 같은 처리를 쓴다
    let done: (FullScreenPresentingAd?, Error?) -> Void = { [weak self] ad, error in
        guard let self else { return }
        if let ad {
            self.loadedAds[requestId] = ad
            self.adEvent("onAdLoaded", requestId)
        } else {
            self.adEvent("onAdFailed", requestId, ["stage": "load", "code": "\((error as NSError?)?.code ?? -1)"])
        }
    }
    if format == "rewarded" {
        RewardedAd.load(with: unit, request: Request()) { ad, error in done(ad, error) }
    } else {
        InterstitialAd.load(with: unit, request: Request()) { ad, error in done(ad, error) }
    }

Flutter

dart
final Map<String, AdWithoutView> _loadedAds = {};   // requestId → RewardedAd | InterstitialAd
static const _defaultUnits = {                       // ids.adUnitId 가 비었을 때
  'rewarded': 'ca-app-pub-XXXX/ZZZZ',
  'interstitial': 'ca-app-pub-XXXX/WWWW',
};

// 광고 콜백 — payload 는 객체로 전달
void _adEvent(String name, String requestId, [Map<String, Object?> extra = const {}]) =>
    _callback(name, {'requestId': requestId, ...extra});

case 'adLoad':
  final obj = jsonDecode(message) as Map<String, dynamic>;
  final requestId = obj['requestId'] as String;
  final format = obj['format'] as String? ?? '';
  final fallbackUnit = _defaultUnits[format];
  if (fallbackUnit == null) {
    // 지원하지 않는 형식은 즉시 실패로 알린다(웹이 상한까지 기다리지 않게)
    _adEvent('onAdFailed', requestId, {'stage': 'load', 'code': 'UNSUPPORTED_FORMAT'});
    break;
  }
  final unit = (obj['ids']?['adUnitId'] as String?) ?? '';
  final adUnitId = unit.isEmpty ? fallbackUnit : unit;

  void loaded(AdWithoutView ad) {
    _loadedAds[requestId] = ad;
    _adEvent('onAdLoaded', requestId);
  }
  void failed(LoadAdError err) =>
      _adEvent('onAdFailed', requestId, {'stage': 'load', 'code': err.code.toString()});

  if (format == 'rewarded') {
    RewardedAd.load(
      adUnitId: adUnitId,
      request: const AdRequest(),
      rewardedAdLoadCallback: RewardedAdLoadCallback(onAdLoaded: loaded, onAdFailedToLoad: failed),
    );
  } else {
    InterstitialAd.load(
      adUnitId: adUnitId,
      request: const AdRequest(),
      adLoadCallback: InterstitialAdLoadCallback(onAdLoaded: loaded, onAdFailedToLoad: failed),
    );
  }
  break;

adShowRewarded() · adShow()

  • 로드된 광고를 SDK 로 전체화면 표시합니다. 두 이름 모두 같은 동작이며, 캐시에서 꺼낸 광고의 형식(RewardedAd / InterstitialAd)대로 표시합니다. 전체화면·닫기 UI 는 SDK 가 소유하며, 결과는 전체화면 콜백(노출/클릭/닫힘/실패)으로 통지합니다.
  • 보상형은 onUserEarnedReward 시점에 onAdRewarded({ requestId, type, amount }) 를 함께 보냅니다. 전면형은 보상 콜백이 없습니다.
  • 1회성이므로 표시 시 캐시에서 제거하고 SDK 가 리소스를 해제합니다.

AOS

kotlin
// 호스트는 adShowRewarded 와 adShow 를 모두 받는다 — 둘 다 requestId 로 로드한 광고를 그 형식대로 표시한다
@JavascriptInterface
fun adShowRewarded(message: String) = showAd(JSONObject(message).optString("requestId"))

@JavascriptInterface
fun adShow(message: String) = showAd(JSONObject(message).optString("requestId"))

private fun showAd(requestId: String) {
    val ad = loadedAds.remove(requestId)         // 1회성 — 표시하며 캐시에서 제거
        ?: return adEvent("onAdFailed", requestId, "stage" to "show", "code" to "NOT_READY")
    // 노출/클릭/닫힘/실패 — 두 형식이 같은 전체화면 콜백을 쓴다
    val callback = object : FullScreenContentCallback() {
        override fun onAdShowedFullScreenContent() = adEvent("onAdShown", requestId)
        override fun onAdClicked() = adEvent("onAdClicked", requestId)
        override fun onAdDismissedFullScreenContent() = adEvent("onAdClosed", requestId)
        override fun onAdFailedToShowFullScreenContent(e: AdError) =
            adEvent("onAdFailed", requestId, "stage" to "show", "code" to e.code.toString())
    }
    activity.runOnUiThread {
        when (ad) {
            is RewardedAd -> {
                ad.fullScreenContentCallback = callback
                ad.show(activity) { reward ->        // 보상 조건 충족 — onAdClosed 보다 먼저 보낸다
                    adEvent("onAdRewarded", requestId, "type" to reward.type, "amount" to reward.amount)
                }
            }
            is InterstitialAd -> {                   // 전면형 — 보상 콜백 없음
                ad.fullScreenContentCallback = callback
                ad.show(activity)
            }
        }
    }
}

iOS

swift
// 호스트는 adShowRewarded 와 adShow 를 모두 받는다 — 둘 다 requestId 로 로드한 광고를 그 형식대로 표시한다
case "adShowRewarded", "adShow":
    guard let requestId = object(param)?["requestId"] as? String else { return }
    guard let ad = loadedAds.removeValue(forKey: requestId) else {   // 1회성 — 표시하며 캐시에서 제거
        adEvent("onAdFailed", requestId, ["stage": "show", "code": "NOT_READY"])
        return
    }
    showing[ObjectIdentifier(ad as AnyObject)] = requestId
    ad.fullScreenContentDelegate = self          // SDK 가 전체화면/닫기 UI 를 소유
    if let rewarded = ad as? RewardedAd {
        rewarded.present(from: self) {           // 보상 조건 충족 — onAdClosed 보다 먼저 보낸다
            let r = rewarded.adReward
            self.adEvent("onAdRewarded", requestId, ["type": r.type, "amount": r.amount.intValue])
        }
    } else if let interstitial = ad as? InterstitialAd {
        interstitial.present(from: self)         // 전면형 — 보상 콜백 없음
    }


// FullScreenContentDelegate — 표시 중인 광고 → requestId (두 형식 공통)
private func requestId(of ad: FullScreenPresentingAd) -> String {
    showing[ObjectIdentifier(ad as AnyObject)] ?? ""
}
func adDidRecordImpression(_ ad: FullScreenPresentingAd) { adEvent("onAdShown", requestId(of: ad)) }
func adDidRecordClick(_ ad: FullScreenPresentingAd) { adEvent("onAdClicked", requestId(of: ad)) }
func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
    let rid = requestId(of: ad)
    showing[ObjectIdentifier(ad as AnyObject)] = nil
    adEvent("onAdClosed", rid)
}
func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
    adEvent("onAdFailed", requestId(of: ad), ["stage": "show", "code": "\((error as NSError).code)"])
}

Flutter

dart
// 호스트는 adShowRewarded 와 adShow 를 모두 받는다 — 둘 다 requestId 로 로드한 광고를 그 형식대로 표시한다
case 'adShowRewarded':
case 'adShow':
  final requestId = (jsonDecode(message) as Map<String, dynamic>)['requestId'] as String;
  final ad = _loadedAds.remove(requestId);       // 1회성 — 표시하며 캐시에서 제거
  if (ad is RewardedAd) {
    ad.fullScreenContentCallback = _fullScreenCallback<RewardedAd>(requestId);
    ad.show(onUserEarnedReward: (ad, reward) {   // 보상 조건 충족 — onAdClosed 보다 먼저 보낸다
      _adEvent('onAdRewarded', requestId, {'type': reward.type, 'amount': reward.amount});
    });
  } else if (ad is InterstitialAd) {
    ad.fullScreenContentCallback = _fullScreenCallback<InterstitialAd>(requestId);
    ad.show();                                   // 전면형 — 보상 콜백 없음
  } else {
    _adEvent('onAdFailed', requestId, {'stage': 'show', 'code': 'NOT_READY'});
  }
  break;


// 노출/클릭/닫힘/실패 — 두 형식이 같은 전체화면 콜백을 쓴다
FullScreenContentCallback<T> _fullScreenCallback<T extends AdWithoutView>(String requestId) =>
    FullScreenContentCallback<T>(
      onAdShowedFullScreenContent: (ad) => _adEvent('onAdShown', requestId),
      onAdClicked: (ad) => _adEvent('onAdClicked', requestId),
      onAdDismissedFullScreenContent: (ad) {
        ad.dispose();
        _adEvent('onAdClosed', requestId);
      },
      onAdFailedToShowFullScreenContent: (ad, err) {
        ad.dispose();
        _adEvent('onAdFailed', requestId, {'stage': 'show', 'code': err.code.toString()});
      },
    );

adDestroy() — 선택

  • 로드만 하고 표시하지 않은 광고를 캐시에서 정리합니다. 콜백은 없습니다.
  • @param {string} JSON string { requestId }

AOS

kotlin
@JavascriptInterface
fun adDestroy(message: String) {
    loadedAds.remove(JSONObject(message).optString("requestId"))
}

iOS

swift
case "adDestroy":
    if let requestId = object(param)?["requestId"] as? String {
        loadedAds.removeValue(forKey: requestId)
    }

Flutter

dart
case 'adDestroy':
  final requestId = (jsonDecode(message) as Map<String, dynamic>)['requestId'] as String;
  _loadedAds.remove(requestId)?.dispose();
  break;

콜백 (App → Web)

호스트는 각 광고의 후속 이벤트를 동일 requestId 를 실어 통지합니다.

  • onAdLoaded — { requestId } 로드 성공(fill).
  • onAdFailed — { requestId, stage, code } 로드/표시 실패. stage='load' | 'show'.
  • onAdShown — { requestId } 노출(AdMob onAdShowedFullScreenContent).
  • onAdClicked — { requestId } 클릭.
  • onAdRewarded — { requestId, type, amount } 보상형 광고에서 보상 조건 충족(클라이언트 신호). onAdClosed 보다 먼저 보냅니다. 전면형에는 보내지 않습니다.
  • onAdClosed — { requestId } 광고가 닫힘. 콘텐츠 흐름 재개 신호로 사용합니다(전면형은 이 콜백이 곧 정상 종료).
닫힘 · 보상 콜백 요약
두 형식 모두 전체 화면과 닫기 UI 를 AdMob SDK 가 소유합니다. 보상형은 보상 조건을 충족하면 onAdRewarded, 이어서 광고가 닫히면 onAdClosed 를 보냅니다. 전면형은 onAdClosed 만 보냅니다. 표시 시 1회성으로 소비되며 SDK 가 리소스를 자동 해제합니다.

검증

  • 구현을 마치면 앱 웹뷰로 /bridge-test 를 열어 확인합니다. 감지된 플랫폼(AOS / iOS / Flutter)이 맞는지, 각 메서드 호출에 대응 콜백이 돌아오는지 봅니다.
  • 광고는 형식을 바꿔 두 번 확인합니다. 보상형은 onAdLoaded → onAdShown → onAdRewarded → onAdClosed, 전면형은 onAdLoaded → onAdShown → onAdClosed(onAdRewarded 없음) 순으로 오면 정상입니다.

관련 문서