웹뷰 연동
매체사(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/ iOSFullScreenContentDelegate)으로 통지합니다. - 표시 메서드는
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없음) 순으로 오면 정상입니다.
관련 문서
- 웹뷰 연동 — 기본 설정, 샘플 코드
- 콘텐츠사(Web → WebView) — 콘텐츠 웹에서 호출하는 자바스크립트 인터페이스 명세