# Firebase WebView 이벤트 연동

WebView JavaScript 이벤트를 Native bridge를 거쳐 Firebase Analytics logEvent로 전송하는 아키텍처와 Android/iOS 설정 절차.

- 카테고리: Firebase
- 소요 시간: 약 9분
- 난이도: 중간
- 업데이트: 2026.06.16
- 원문: /wiki/playbook/firebase-checklist/how-to-connect-firebase-webview-events

## 목차

- [이 문서의 목적](#purpose)
- [1단계 · 구조 이해](#step-1)
- [2단계 · Web bridge 작성](#step-2)
- [3단계 · Android 구현](#step-3)
- [4단계 · iOS 구현](#step-4)
- [5단계 · items 배열 처리](#step-5)
- [6단계 · 검증](#step-6)
- [참고 링크](#references)
- [자주 묻는 질문](#faq)

이 문서는 WebView 안의 JavaScript 이벤트를 Android/iOS Native bridge로 넘겨 Firebase Analytics 앱 이벤트로 수집하는 방법을 다룹니다. Firebase SDK 설치와 초기화는 끝났다고 가정합니다. WebView 앱에서는 WebView 안에서 Firebase Web SDK를 직접 호출하지 않고, **Web은 이벤트 payload를 만들고 Native가 Firebase SDK로 `logEvent`를 호출하는 구조**로 맞춥니다.

> **이 문서 핵심**
>
> - Android/iOS 앱에서 Firebase 초기화가 먼저 끝나 있어야 합니다.
>
> - Web 코드는 공통 `firebaseBridge.logEvent()`만 호출하고, Native bridge 이름은 `firebaseWebBridge`로 맞춥니다.
>
> - 전자상거래 `items[]`, 커스텀 이벤트 파라미터까지 앱 데이터 스트림에서 확인할 수 있습니다.

## 1단계. WebView 이벤트 연동 구조 이해

전체 흐름은 **WebView JavaScript → Native bridge → Firebase Analytics SDK → GA4**입니다. Web은 이벤트 이름과 파라미터 객체를 만들고, Android/iOS Native가 각 플랫폼의 Firebase SDK 타입으로 변환합니다.

![WebView Firebase 이벤트 연동 구조도. Web page의 firebaseBridge.logEvent 호출이 Android와 iOS Native bridge(firebaseWebBridge)를 거쳐 Firebase Analytics SDK의 logEvent로 전달되고, 마지막으로 GA4 DebugView와 Reports에 집계되는 흐름이 화살표로 표시되어 있다. Native bridge 영역이 빨간 박스로 강조되어 있다.](/wiki-assets/playbook/how-to-connect-firebase-webview-events/01-architecture.png)

> **구조.** Web은 공통 래퍼를 호출하고, Native bridge가 앱 이벤트로 변환합니다.

| 레이어 | 책임 | 예시 |
| --- | --- | --- |
| Web | 이벤트명과 payload 생성 | `firebaseBridge.logEvent("purchase", params)` |
| Android bridge | JSON 문자열을 `Bundle`로 변환 | `window.firebaseWebBridge.logEvent` |
| iOS bridge | message body를 `[String: Any]`로 정규화 | `window.webkit.messageHandlers.firebaseWebBridge` |
| Firebase SDK | 앱 데이터 스트림으로 이벤트 전송 | `logEvent(eventName, parameters)` |

> **정보**
>
> Web 래퍼 이름과 Native bridge 이름은 분리해도 됩니다. 이 가이드는 Web 코드에서는 `firebaseBridge`, Android/iOS가 노출하는 Native bridge 이름은 `firebaseWebBridge`로 통일합니다.

> **팁**
>
> WebView 안에서 로그인 상태나 회원 등급이 바뀌는 경우는 이벤트가 아니라[User Property 설정](/wiki/playbook/firebase-checklist/how-to-set-firebase-user-properties)으로 분리합니다.

## 2단계. Web에서 공통 bridge 래퍼 작성

Web은 Android/iOS 환경을 감지해 같은 함수로 이벤트를 보냅니다. Android는 JavaScript interface 메서드가 문자열 인자를 받기 쉬우므로 payload를 JSON 문자열로 넘기고, iOS는 `postMessage`로 객체를 그대로 넘깁니다.

**firebase-bridge.js**
```javascript
var firebaseBridge = {
  logEvent: function(eventName, parameters) {
    var delivered = false;

    if (isAndroidFirebaseBridgeAvailable()) {
      window.firebaseWebBridge.logEvent(
        eventName,
        parameters === null || parameters === undefined
          ? null
          : JSON.stringify(parameters)
      );
      delivered = true;
    }

    if (isIosFirebaseBridgeAvailable()) {
      window.webkit.messageHandlers.firebaseWebBridge.postMessage({
        command: "logEvent",
        eventName: eventName,
        parameters: parameters || null
      });
      delivered = true;
    }

    if (!delivered) {
      console.log("Firebase WebView bridge not found.", eventName, parameters || null);
    }
  }
};

function isAndroidFirebaseBridgeAvailable() {
  try {
    return !!(
      window.firebaseWebBridge &&
      typeof window.firebaseWebBridge.logEvent === "function"
    );
  } catch (err) {
    console.log(err);
    return false;
  }
}

function isIosFirebaseBridgeAvailable() {
  return !!(
    window.webkit &&
    window.webkit.messageHandlers &&
    window.webkit.messageHandlers.firebaseWebBridge &&
    typeof window.webkit.messageHandlers.firebaseWebBridge.postMessage === "function"
  );
}
```

Web 화면에서는 운영 이벤트를 직접 호출합니다. 예를 들어 구매 완료 시점에는 다음처럼 보냅니다.

**purchase 이벤트 호출 예시**
```javascript
firebaseBridge.logEvent("purchase", {
  currency: "KRW",
  shipping: 3000,
  value: 19900,
  transaction_id: "ORDER_12345",
  items: [
    {
      item_id: "SKU_12345",
      item_name: "Sample Product",
      item_category: "category_l1",
      item_category2: "category_l2",
      item_category3: "category_l3",
      item_category4: "category_l4",
      item_category5: "category_l5",
      item_variant: "black",
      item_variant2: "medium",
      item_variant3: "standard",
      item_type: "physical_product",
      price: 19900,
      quantity: 1,
      discount: 1000
    }
  ]
});
```

![JavaScript bridge contract 이미지. 왼쪽에는 firebaseBridge.logEvent로 purchase payload를 보내는 코드가 있고, 오른쪽에는 Android의 window.firebaseWebBridge와 iOS의 messageHandlers.firebaseWebBridge 전송 대상이 표로 정리되어 있다.](/wiki-assets/playbook/how-to-connect-firebase-webview-events/02-web-test-event.png)

> **화면 1.** Web은 이벤트 payload를 만들고 Native bridge로 넘깁니다.

## 3단계. Android WebView에 Firebase bridge 등록

Android는 `addJavascriptInterface`로 Native 객체를 WebView에 주입합니다. Web에서`window.firebaseWebBridge.logEvent(eventName, paramsJson)`를 호출하면, Native에서 JSON을`Bundle`로 바꾼 뒤 Firebase SDK에 넘깁니다.

**MainActivity.kt**
```kotlin
webView = WebView(this).apply {
    settings.javaScriptEnabled = true
    webViewClient = WebViewClient()
    addJavascriptInterface(
        FirebaseJavascriptInterface(this@MainActivity),
        "firebaseWebBridge"
    )
}
```

**FirebaseJavascriptInterface.java**
```java
public final class FirebaseJavascriptInterface {
    private final FirebaseAnalytics analytics;

    public FirebaseJavascriptInterface(Context context) {
        analytics = FirebaseAnalytics.getInstance(context.getApplicationContext());
    }

    @JavascriptInterface
    public void logEvent(String eventName, String parametersJsonString) {
        if (eventName == null || eventName.trim().isEmpty()) {
            return;
        }

        Bundle parameters = new Bundle();
        if (parametersJsonString != null
                && !parametersJsonString.trim().isEmpty()
                && !"null".equals(parametersJsonString.trim())) {
            try {
                parameters = parseParameters(new JSONObject(parametersJsonString));
            } catch (JSONException ignored) {
                // 운영 코드에서는 로깅/모니터링 정책에 맞춰 처리합니다.
            }
        }

        analytics.logEvent(eventName, parameters);
    }
}
```

> **주의**
>
> `addJavascriptInterface`는 Web content가 Native 기능에 접근할 수 있게 만듭니다. 운영 앱에서는 신뢰하는 도메인만 로드하고, 외부 URL을 같은 WebView에서 열지 않도록 분리합니다.

![Android bridge checklist 이미지. 왼쪽에는 Android 구현 순서가 번호 목록으로 정리되어 있고, 오른쪽에는 addJavascriptInterface로 FirebaseWebBridge를 firebaseWebBridge 이름으로 등록하는 Kotlin 코드가 강조되어 있다.](/wiki-assets/playbook/how-to-connect-firebase-webview-events/03-android-bridge.png)

> **화면 2.** Android는 `firebaseWebBridge`를 WebView에 주입합니다.

## 4단계. iOS WKWebView에 Firebase bridge 등록

iOS는 `WKScriptMessageHandler`로 Web message를 받습니다. Web은`window.webkit.messageHandlers.firebaseWebBridge.postMessage(...)`를 호출하고, Native는`command`, `eventName`, `parameters`를 파싱합니다.

**ViewController.swift**
```swift
final class ViewController: UIViewController, WKScriptMessageHandler {
    private let firebaseJavascriptInterface = FirebaseJavascriptInterface()

    private lazy var webView: WKWebView = {
        let configuration = WKWebViewConfiguration()
        configuration.userContentController.add(self, name: "firebaseWebBridge")
        return WKWebView(frame: .zero, configuration: configuration)
    }()

    deinit {
        webView.configuration.userContentController
            .removeScriptMessageHandler(forName: "firebaseWebBridge")
    }

    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard message.name == "firebaseWebBridge",
              let payload = message.body as? [String: Any] else { return }

        firebaseJavascriptInterface.handleBridgeMessage(payload)
    }
}
```

**FirebaseJavascriptInterface.swift**
```swift
final class FirebaseJavascriptInterface: NSObject {
    func handleBridgeMessage(_ payload: [String: Any]) {
        guard let command = payload["command"] as? String else { return }

        switch command {
        case "logEvent":
            guard let eventName = payload["eventName"] as? String, !eventName.isEmpty else {
                return
            }
            let parameters = normalizedParameters(from: payload["parameters"])
            Analytics.logEvent(eventName, parameters: parameters)
        default:
            return
        }
    }

    private func normalizedParameters(from raw: Any?) -> [String: Any]? {
        guard let dictionary = raw as? [String: Any] else { return nil }
        let parsed = dictionary.reduce(into: [String: Any]()) { result, entry in
            result[entry.key] = normalizedValue(entry.value)
        }
        return parsed.isEmpty ? nil : parsed
    }
}
```

> **주의**
>
> `WKUserContentController`가 handler를 강하게 잡기 때문에 화면 해제 시`removeScriptMessageHandler`를 호출해 순환 참조를 끊습니다.

![iOS bridge checklist 이미지. 왼쪽에는 WKWebViewConfiguration에 firebaseWebBridge message handler를 등록하는 Swift 코드가 있고, 오른쪽에는 message body, command, parameters, cleanup 처리 기준이 표로 정리되어 있다.](/wiki-assets/playbook/how-to-connect-firebase-webview-events/04-ios-bridge.png)

> **화면 3.** iOS는 `firebaseWebBridge` message handler로 이벤트를 받습니다.

## 5단계. items 배열과 숫자 타입을 정확히 변환

WebView bridge에서 가장 자주 깨지는 지점은 전자상거래 `items[]`입니다. `items`를 문자열로 넣으면 GA4 전자상거래 차원/측정항목이 제대로 채워지지 않습니다. Android는 `Parcelable[]` 안의`Bundle`, iOS는 `[[String: Any]]` 형태를 유지해야 합니다.

**Android · JSON → Bundle 변환 핵심**
```java
private void putValue(Bundle bundle, String key, Object value) throws JSONException {
    if (value == null || value == JSONObject.NULL) {
        return;
    }

    if (FirebaseAnalytics.Param.ITEMS.equals(key) && value instanceof JSONArray) {
        bundle.putParcelableArray(FirebaseAnalytics.Param.ITEMS, parseItems((JSONArray) value));
        return;
    }

    if (value instanceof Integer || value instanceof Long) {
        bundle.putLong(key, ((Number) value).longValue());
    } else if (value instanceof Float || value instanceof Double) {
        bundle.putDouble(key, ((Number) value).doubleValue());
    } else if (value instanceof Boolean) {
        bundle.putString(key, value.toString());
    } else {
        bundle.putString(key, value.toString());
    }
}

private Parcelable[] parseItems(JSONArray itemsArray) throws JSONException {
    Bundle[] items = new Bundle[itemsArray.length()];
    for (int i = 0; i < itemsArray.length(); i++) {
        items[i] = parseParameters(itemsArray.getJSONObject(i));
    }
    return items;
}
```

**iOS · items 배열과 NSNumber 처리**
```swift
private func normalizedValue(_ value: Any) -> Any {
    if value is NSNull {
        return ""
    }

    if let items = value as? [Any] {
        return items.compactMap { normalizedItem(from: $0) }
    }

    if let number = value as? NSNumber {
        if CFGetTypeID(number) == CFBooleanGetTypeID() {
            return number.boolValue ? "true" : "false"
        }
        return number
    }

    return value
}
```

> **정보**
>
> iOS에서 JavaScript 숫자는 `NSNumber`로 들어옵니다. `NSNumber(1)`을 Bool로 오인하면`quantity`가 `true`처럼 바뀔 수 있으므로, `CFBoolean` 여부만 따로 분리합니다.

### 플랫폼별 전체 코드 복사

아래 코드는 WebView bridge 구현을 한 번에 복사할 수 있도록 정리한 예시입니다. 클래스명과 패키지명은 각 앱 구조에 맞게 바꾸고, Web 코드에서는 Native bridge 이름을 `firebaseWebBridge`로 호출합니다.

**Android · FirebaseWebBridge.java 전체 예시**
```java
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.webkit.JavascriptInterface;

import com.google.firebase.analytics.FirebaseAnalytics;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.Iterator;

public final class FirebaseWebBridge {
    private final FirebaseAnalytics analytics;

    public FirebaseWebBridge(Context context) {
        analytics = FirebaseAnalytics.getInstance(context.getApplicationContext());
    }

    @JavascriptInterface
    public void logEvent(String eventName, String parametersJson) {
        if (eventName == null || eventName.trim().isEmpty()) {
            return;
        }

        Bundle parameters = new Bundle();
        if (parametersJson != null
                && !parametersJson.trim().isEmpty()
                && !"null".equals(parametersJson.trim())) {
            try {
                parameters = parseObject(new JSONObject(parametersJson));
            } catch (JSONException ignored) {
                return;
            }
        }

        analytics.logEvent(eventName, parameters);
    }

    private Bundle parseObject(JSONObject object) throws JSONException {
        Bundle bundle = new Bundle();
        Iterator<String> keys = object.keys();

        while (keys.hasNext()) {
            String key = keys.next();
            putValue(bundle, key, object.get(key));
        }

        return bundle;
    }

    private void putValue(Bundle bundle, String key, Object value) throws JSONException {
        if (value == null || value == JSONObject.NULL) {
            return;
        }

        if (FirebaseAnalytics.Param.ITEMS.equals(key) && value instanceof JSONArray) {
            bundle.putParcelableArray(FirebaseAnalytics.Param.ITEMS, parseItems((JSONArray) value));
            return;
        }

        if (value instanceof Integer || value instanceof Long) {
            bundle.putLong(key, ((Number) value).longValue());
            return;
        }

        if (value instanceof Float || value instanceof Double) {
            bundle.putDouble(key, ((Number) value).doubleValue());
            return;
        }

        if (value instanceof Boolean) {
            bundle.putString(key, value.toString());
            return;
        }

        bundle.putString(key, value.toString());
    }

    private Parcelable[] parseItems(JSONArray array) throws JSONException {
        Bundle[] items = new Bundle[array.length()];

        for (int index = 0; index < array.length(); index++) {
            items[index] = parseObject(array.getJSONObject(index));
        }

        return items;
    }
}
```

**Android · WebView 등록 예시**
```kotlin
val webView = WebView(this).apply {
    settings.javaScriptEnabled = true
    webViewClient = WebViewClient()
    addJavascriptInterface(
        FirebaseWebBridge(this@MainActivity),
        "firebaseWebBridge"
    )
}
```

**iOS · FirebaseWebBridge.swift 전체 예시**
```swift
import FirebaseAnalytics
import WebKit

final class FirebaseWebBridge: NSObject {
    func handle(_ payload: [String: Any]) {
        guard let command = payload["command"] as? String else { return }

        switch command {
        case "logEvent":
            guard let eventName = payload["eventName"] as? String,
                  !eventName.isEmpty else { return }

            Analytics.logEvent(
                eventName,
                parameters: normalizedParameters(from: payload["parameters"])
            )
        default:
            return
        }
    }

    private func normalizedParameters(from raw: Any?) -> [String: Any]? {
        guard let dictionary = raw as? [String: Any] else { return nil }

        let parameters = dictionary.reduce(into: [String: Any]()) { result, entry in
            result[entry.key] = normalizedValue(entry.value)
        }

        return parameters.isEmpty ? nil : parameters
    }

    private func normalizedItem(from raw: Any) -> [String: Any]? {
        guard let dictionary = raw as? [String: Any] else { return nil }

        return dictionary.reduce(into: [String: Any]()) { result, entry in
            result[entry.key] = normalizedValue(entry.value)
        }
    }

    private func normalizedValue(_ value: Any) -> Any {
        if value is NSNull {
            return ""
        }

        if let items = value as? [Any] {
            return items.compactMap { normalizedItem(from: $0) }
        }

        if let number = value as? NSNumber {
            if CFGetTypeID(number) == CFBooleanGetTypeID() {
                return number.boolValue ? "true" : "false"
            }
            return number
        }

        return value
    }
}
```

**iOS · WKWebView 등록 예시**
```swift
final class WebViewController: UIViewController, WKScriptMessageHandler {
    private let firebaseWebBridge = FirebaseWebBridge()
    private lazy var webView: WKWebView = {
        let configuration = WKWebViewConfiguration()
        configuration.userContentController.add(self, name: "firebaseWebBridge")
        return WKWebView(frame: .zero, configuration: configuration)
    }()

    deinit {
        webView.configuration.userContentController
            .removeScriptMessageHandler(forName: "firebaseWebBridge")
    }

    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard message.name == "firebaseWebBridge",
              let payload = message.body as? [String: Any] else { return }

        firebaseWebBridge.handle(payload)
    }
}
```

## 6단계. DebugView로 bridge 검증

구현 후에는 Web console 로그만 보지 말고 Native 로그와 Firebase DebugView까지 이어서 확인합니다. Android는 개발 중 logcat에서 서버 업로드 성공 코드까지 보면 가장 확실합니다.

**Android · DebugView 모드 + verbose log**
```bash
adb shell setprop debug.firebase.analytics.app <application_id>
adb shell setprop log.tag.FA VERBOSE
adb shell setprop log.tag.FA-SVC VERBOSE
adb logcat -v time -s FA FA-SVC SampleApp
```

**Android 성공 로그 기준**
```text
Logging event: origin=app,name=select_store
param { name: _dbg int_value: 1 }
Uploading bundle with GmsCore network library
Network upload successful with code, uploadAttempted: 204, true
```

iOS는 Xcode Scheme의 실행 인수에 `-FIRDebugEnabled`를 추가하고, Native 로그에서 이벤트 호출을 확인합니다.

**iOS 성공 로그 기준**
```text
[Firebase] logEvent -> purchase
[Firebase] logEvent -> select_store
[FirebaseAnalytics][I-ACS023008] To disable debug logging set ...
```

![Bridge verification path 이미지. Web console, Native log, Firebase DebugView, Android upload 단계가 카드 형태로 연결되어 있고 Firebase DebugView 단계가 강조되어 있다.](/wiki-assets/playbook/how-to-connect-firebase-webview-events/05-debugview-test-event.png)

> **화면 4.** Web → Native → Firebase 서버 업로드까지 한 번에 확인합니다.

> **완료**
>
> Android에서 `_dbg=1`과 `204` 업로드 응답이 같이 보이면 앱이 DebugView 대상 이벤트를 Firebase 서버까지 보낸 상태입니다. 콘솔에서 안 보이면 Firebase 프로젝트, 앱 스트림, DebugView 기기 선택을 먼저 확인합니다.

## 참고 링크

- [Firebase Analytics 이벤트 로깅(Android)](https://firebase.google.com/docs/analytics/events?platform=android)
- [Firebase Analytics 이벤트 로깅(iOS)](https://firebase.google.com/docs/analytics/events?platform=ios)
- [Firebase 이벤트 전송하기](/wiki/playbook/firebase-checklist/how-to-send-firebase-events)
- [Firebase User Property 설정하기](/wiki/playbook/firebase-checklist/how-to-set-firebase-user-properties)
- [Firebase 연동 검증하기](/wiki/playbook/firebase-checklist/how-to-verify-firebase-integration)

## 자주 묻는 질문

### WebView에서 Firebase Web SDK를 직접 쓰면 안 되나요?

가능은 하지만 앱 데이터 스트림이 아니라 웹 측정 흐름으로 섞이기 쉽습니다. 앱 이벤트로 보고 싶다면 Native Firebase SDK에서 logEvent를 호출하는 구조가 더 안정적입니다.

### bridge 이름은 꼭 firebaseWebBridge여야 하나요?

고정값은 아닙니다. 다만 Web, Android, iOS가 모두 같은 이름을 써야 합니다. 이 가이드는 Native bridge 이름을 firebaseWebBridge로 통일합니다.

### items를 JSON 문자열 하나로 보내도 GA4에서 볼 수 있나요?

권장하지 않습니다. GA4 전자상거래 리포트와 item dimension을 쓰려면 Android는 Bundle 배열, iOS는 dictionary 배열로 유지해 Firebase SDK에 넘겨야 합니다.

## Navigation

- [전체 플레이북 Markdown sitemap](/wiki/playbook/sitemap.md)

### Firebase

- [Firebase](/wiki/playbook/firebase-checklist)
- [Firebase 프로젝트·앱 등록](/wiki/playbook/firebase-checklist/how-to-create-firebase-app)
- [Firebase SDK 초기화](/wiki/playbook/firebase-checklist/how-to-initialize-firebase-sdk)
- [Firebase WebView 이벤트 연동](/wiki/playbook/firebase-checklist/how-to-connect-firebase-webview-events) (현재 문서)
- [Firebase 이벤트 전송](/wiki/playbook/firebase-checklist/how-to-send-firebase-events)
- [Firebase User ID·User Property 설정](/wiki/playbook/firebase-checklist/how-to-set-firebase-user-properties)
- [Firebase 연동 검증](/wiki/playbook/firebase-checklist/how-to-verify-firebase-integration)
