발행일

GA4 이벤트를 웹·Android·iOS 세 갈래로 보냈다 — 웹뷰 브리지

GA4 이벤트를 웹·Android·iOS 세 갈래로 보냈다

이 글은 2026년 9월에 당시 코드를 다시 보며 정리한 것입니다. 저장소는 퇴사 전에 스냅샷으로 보관한 것이라 커밋 이력이 없고, 경력기술서와 당시 쓴 이력서에 적은 담당 범위와 코드를 대조해 썼습니다. 날짜는 코드에 남은 시기 단서 기준입니다.

숙박 큐레이션 플랫폼의 프런트 팀은 넷, 백엔드 팀도 넷이었습니다. 2023년 12월 이력서에 제 담당 역할은 "가격, 홍보에 관련된 프런트엔드 개발"이라 적혀 있고, 이벤트 태깅은 홍보 쪽 일이었어요.

이 서비스는 웹과 앱이 따로 있었는데, 앱의 상당 부분이 웹 화면을 웹뷰로 띄우는 구조였습니다. 경력기술서 3절에 "웹/앱 사용자 행동 데이터가 분리돼 있어 캠페인 효과를 측정할 수 없는 상태"라고 적은 게 이 상황이에요.

브라우저에서 열린 화면은 gtag로 GA4에 보내면 됩니다. 문제는 웹뷰였습니다. 웹뷰 안에서도 gtag를 부르면 그 이벤트는 웹 스트림으로 가고, 앱이 Firebase로 보내는 이벤트는 앱 스트림으로 갑니다. 사용자는 한 사람인데 앱을 열고 웹뷰에서 예약하면 퍼널이 두 토막으로 갈려요.

이벤트 함수 하나에 갈래 셋

해결은 웹 코드가 "내가 지금 어디서 열렸나"를 보고 보낼 곳을 고르는 거였습니다. 이벤트 함수는 이렇게 생겼어요.

const event = (name, params) => {
  if (!name) {
    return;
  }

  if (window.AnalyticsWebInterface) {
    // Call Android interface
    ...
    window.AnalyticsWebInterface.logEvent(name, JSON.stringify(modifiedParams));
    return;
  }

  if (
    window.webkit &&
    window.webkit.messageHandlers &&
    window.webkit.messageHandlers.firebase
  ) {
    // Call iOS interface
    ...
    window.webkit.messageHandlers.firebase.postMessage(message);
    return;
  }

  // No Android or iOS interface found
  if (!window.gtag) {
    return;
  }

Android 앱은 웹뷰에 AnalyticsWebInterface라는 객체를 심어 두고, iOS 앱은 webkit.messageHandlers.firebase를 심어 둡니다. 웹 코드는 그 객체가 있으면 앱에게 넘기고 끝냅니다. 둘 다 없을 때만 gtag예요. 그러니까 웹뷰 안에서는 gtag가 한 번도 안 불립니다. 앱이 자기 Firebase SDK로 보내니까 앱 스트림 하나로 모이고, 이벤트 이름과 파라미터는 웹이 정한 대로 갑니다.

인터페이스 이름과 판별 순서는 Firebase의 웹뷰 안내 문서가 쓰는 것과 같습니다. 그 문서를 그때 따랐는지, 앱 팀이 먼저 이름을 정해 준 것인지는 코드에 남아 있지 않아요. 남은 건 "웹이 앱에게 넘긴다"는 방향뿐입니다.

앱으로 넘길 때 파라미터를 납작하게 만든다

GA4 전자상거래 이벤트는 ecommerce 객체 안에 items·value·currency를 넣습니다. 그런데 네이티브 인터페이스는 한 겹짜리 파라미터만 받아요.

    const modifiedParams = {
      ...params,
      ...params.ecommerce,
    };
    delete modifiedParams.ecommerce;

ecommerce 안의 키를 바깥으로 꺼내고 껍데기를 지웁니다. 같은 코드가 Android 갈래와 iOS 갈래에 한 번씩 있어요. 여기에 page_title·page_referrer·page_location을 손으로 붙입니다. 브라우저의 gtag는 이 셋을 자동으로 채우는데 앱으로 넘기면 아무도 안 채우니까요.

이 평탄화는 웹뷰 이벤트가 앱 이벤트와 같은 모양이 되게 하려는 건데, 대신 items 배열은 JSON 문자열로 실려 가서 GA4가 아이템 단위로 풀어 주지 못합니다. 지금 보면 그런 한계인데, 그때 알고 있었는지는 기록이 없습니다.

아이템 정보는 화면이 아니라 서버에서

숙소 공유, 프리오더 신청 같은 이벤트는 GA4 아이템 파라미터(item_id·item_name·item_category…)가 필요합니다. 화면마다 그 값을 조립하는 대신 서버에 물었습니다.

const fetchGA4Item = (identifier: string): Promise<{
  item_id: number;
  item_name: string;
  item_brand: string;
  item_category: string;
  ...
}> => fetcherWithError(`${placeApiUrl}/${identifier}/ga4_item`);

숙소 식별자로 ga4_item을 받아 그대로 이벤트에 얹습니다. 카테고리 이름 같은 걸 화면이 들고 있으면 화면마다 다르게 조립할 테니, 서버가 한 번 정한 모양을 쓰기로 했어요. 대가는 공유 버튼을 누를 때마다 요청이 하나 더 나간다는 것인데, 그걸 누가 어떻게 저울질했는지는 코드에 없습니다.

이렇게 measure로 시작하는 함수가 스냅샷에 23개 있습니다. 회원가입, 로그인, 배너 클릭, 공유 다섯 종, 프리오더 신청, 할부 안내 클릭… 화면은 이 함수만 부르고 어디로 갈지는 모릅니다.

구매와 환불은 서버가 보낸다

결제 완료와 환불은 웹 화면에서 쏘지 않았습니다. 결제는 PG 콜백으로 서버에서 확정되니까, 화면에서 보내면 "결제 시작"은 잡혀도 "결제 성공"은 못 잡아요. 그래서 서버가 Measurement Protocol로 보냅니다. 이 부분은 백엔드 팀 코드라 구조만 적습니다.

서버 잡은 요청의 User-Agent를 보고 iOS 앱, Android 앱, 웹 중 어느 스트림으로 보낼지 고르고, 실전송 전에 /debug 엔드포인트로 검증합니다. 검증 응답에 validationMessages가 있으면 보내지 않고 오류 테이블에 요청 본문을 남겨요. 그 테이블을 만든 마이그레이션 날짜가 2023년 5월 3일이라 이 글의 날짜로 썼습니다.

퍼널의 앞 절반(조회·공유·예약 시작)은 화면이 보내고 뒤 절반(구매·환불)은 서버가 보내는 구조입니다. 둘이 같은 사용자·같은 아이템으로 이어져야 하는데, 그걸 어떻게 맞췄는지는 서버 쪽 유틸이 아이템을 조립하는 코드만 남아 있고 웹 쪽 협의 기록은 없습니다.

검증

  • 날짜 근거: Rails 마이그레이션 create_measurement_protocol_errors(2023-05-03). 경력기술서의 캠페인 기간(2023-07~09)보다 두 달 앞서는데, 측정 기반이 먼저 깔린 순서로 읽힙니다.
  • 갈래 셋과 평탄화는 common/gtag.tsx에서, measure* 함수 수는 grep -c "^export const measure"로 셌습니다(23개).
  • 서버 전송은 app/jobs/ga4/event_sender_job.rb와 그걸 부르는 measurement/purchaser.rb·refunder.rb에서 확인했습니다.
  • 문서 두 개와 대조한 결과입니다.
항목경력기술서(2024-03)이력서(2023-12)
웹뷰 동기화§3 "GA4 + Firebase 웹뷰 동기화" — 코드와 일치"홍보에 관련된 프런트엔드 개발" — 범위 안
구글 애즈 전환 태깅§3에 있음 — gtag 설정 외 별도 코드 못 찾음언급 없음
팀 규모적지 않음프런트 4명·백엔드 4명 — gtag.tsx를 혼자 썼는지는 확인 불가

남은 것 · 한계

  • 누가 언제 어느 부분을 짰는지 이력이 없습니다. 경력기술서와 이력서가 이벤트 태깅을 제 범위로 적었고 그 범위 안에서 썼지만, 프런트가 넷이라 gtag.tsx의 어느 줄을 제가 썼는지는 갈라 말할 수 없습니다. 서버 잡과 앱 인터페이스는 다른 팀 몫이었습니다.
  • 웹뷰 판별이 전역 객체 존재 여부입니다. 앱이 인터페이스 이름을 바꾸면 웹은 조용히 gtag로 떨어지고, 퍼널이 다시 두 토막이 나는데 아무 오류도 안 납니다.
  • items가 문자열로 실려 갑니다. 앱 스트림에서 아이템 단위 리포트가 안 됐고, 그때 이 문제를 알았는지, 어떻게 판단했는지는 남아 있지 않습니다.
  • 측정 ID가 코드에 문자열로 박혀 있습니다. 이 글에서는 뺐습니다.
  • 경력기술서에 적은 캠페인 지표는 팀 전체의 것이고, 이 코드가 그 지표를 셀 수 있게 한 것까지가 제 몫입니다.

관련 글: 숙박 큐레이션 플랫폼 프런트엔드 2022–2024 · 루트 레이아웃에 'use client'가 붙어 있었다 · Google Optimize가 사라진 뒤 미들웨어로 A/B를 돌렸다 · 설정을 localStorage에 두고 소켓 메시지 16갈래로 창끼리 맞췄다