huny.log

GA4 + BigQuery로 ROAS 파이프라인 직접 만들기 — 플랫폼 대시보드 못 믿을 때

플랫폼 ROAS와 GA4 ROAS가 왜 다른지 정의부터 맞추고, GA4 event export에서 7일 last-touch ROAS를 재현하는 실행 가능한 BigQuery SQL을 정리합니다.

· · · 11분 읽기 · ga4bigqueryroassqldata-pipeline

플랫폼·GA4·자체 집계의 ROAS가 다를 때, 정의를 문서화한 SQL로 비용과 구매를 다시 연결해야 차이의 원인을 설명할 수 있습니다.

GA4에서 BigQuery로 raw event를 익스포트해 자체 ROAS 대시보드를 만드는 파이프라인 다이어그램
플랫폼 ROAS와 GA4 UI는 각자의 보고 규칙으로 만든 값이다. BigQuery event export를 함께 보관하면 우리 조직의 정의를 SQL로 재현할 수 있다.

왜 직접 만들어야 하나

플랫폼·도구별 ROAS의 한계:

출처장점한계
Meta/Google Ads UI캠페인 분해와 운영이 빠름매체별 attribution·전환 정의가 다름
GA4 UI채널 통합 보고와 탐색이 쉬움reporting identity·모델링·보관 설정의 영향을 받음
BigQuery event export이벤트 단위 쿼리와 정의 재현UI의 일부 모델링 값과 다를 수 있고 저장·쿼리 비용이 발생

자체 파이프라인의 진짜 가치는 “플랫폼이 안 보여주는 정의”를 자유롭게 만들 수 있다는 거예요.

  • “브랜드 검색 제외 ROAS”
  • “신규 vs 기존 분리 ROAS”
  • “미귀속·매핑 누락을 함께 표시한 ROAS”
  • “incrementality 보정된 ROAS”

1단계 — GA4 → BigQuery 익스포트 활성화

GA4 속성 설정 → BigQuery 연결 → “매일 익스포트” 옵션을 켭니다. 연결 이후 다음 형식의 일별 테이블이 생성됩니다. Standard 속성의 일별 export에는 하루 100만 이벤트 한도가 있고, 기존 GA4 데이터는 자동으로 소급 적재되지 않습니다.

project.analytics_<property_id>.events_YYYYMMDD

각 row가 한 이벤트(page_view, purchase, click 등). 가장 자주 쓰는 컬럼들:

컬럼타입의미
event_dateSTRINGYYYYMMDD 형식
event_timestampINT64마이크로초 단위
event_nameSTRINGpage_view, purchase
event_paramsREPEATEDkey-value 배열 (URL, source, campaign 등)
ecommerceRECORDpurchase일 때 transaction_id, purchase_revenue
traffic_sourceRECORD사용자의 최초 획득 source, medium, name
deviceRECORDcategory, os, mobile_brand_name
geoRECORDcountry, region, city
user_pseudo_idSTRING클라이언트 ID (사용자 식별 핵심)

스트리밍 export를 함께 켜면 events_intraday_YYYYMMDD가 생기지만 best-effort 데이터라 누락 가능성이 있습니다. 일별 테이블이 완성되면 intraday 테이블은 삭제되고, 지연 이벤트 때문에 일별 테이블은 최대 72시간 갱신될 수 있습니다. 운영 대시보드에는 “진행 중”과 “확정” 상태를 나눠 표시해야 하는 이유입니다.

가장 헷갈리는 게 event_params예요. 배열이라 SQL에서 펼쳐야(UNNEST) 합니다. “4월 구매 이벤트의 utm_source 분포는?” 같은 단순 질문도 결국 UNNEST 한 번 거쳐야 답이 나와요.

2단계 — 광고비 + 매출 일별 join

광고비는 별도로 적재해야 합니다. Google Ads는 BigQuery Data Transfer Service를 쓸 수 있고, 다른 채널은 API 응답을 ads_spend(date_kst, channel, campaign_id, spend_krw) 계약으로 정규화합니다. 순매출은 GA4의 속성 통화값을 원화로 간주하지 않고 transaction_id가 유일한 주문 DB의 orders(transaction_id, net_revenue_krw)를 사용합니다. UTM과 매체 campaign ID를 잇는 dim_paid_campaign_map도 준비합니다.

아래 쿼리는 7일 안의 마지막 수집 paid-campaign touch를 구매에 하나만 붙이고, 매출을 touch 날짜로 되돌린 뒤 같은 날짜의 광고비와 결합합니다. 플랫폼 attribution을 복제하는 SQL이 아니라 조직이 명시적으로 선택한 custom last-touch 정의입니다.

WITH google_touches AS (
SELECT
e.user_pseudo_id,
TIMESTAMP_MICROS(e.event_timestamp) AS touch_time,
'google_ads' AS channel,
CAST(
e.session_traffic_source_last_click.google_ads_campaign.campaign_id
AS STRING
) AS campaign_id,
1 AS mapping_priority
FROM `project.analytics_<id>.events_*` e
WHERE _TABLE_SUFFIX BETWEEN '20260325' AND '20260430'
AND e.event_name = 'session_start'
AND e.user_pseudo_id IS NOT NULL
AND e.session_traffic_source_last_click.google_ads_campaign.campaign_id
IS NOT NULL
),
manual_touches AS (
SELECT
e.user_pseudo_id,
TIMESTAMP_MICROS(e.event_timestamp) AS touch_time,
m.channel,
m.campaign_id,
2 AS mapping_priority
FROM `project.analytics_<id>.events_*` e
JOIN `project.mart.dim_paid_campaign_map` m
ON LOWER(e.collected_traffic_source.manual_source) = LOWER(m.utm_source)
AND LOWER(e.collected_traffic_source.manual_medium) = LOWER(m.utm_medium)
AND LOWER(e.collected_traffic_source.manual_campaign_name)
= LOWER(m.utm_campaign)
WHERE _TABLE_SUFFIX BETWEEN '20260325' AND '20260430'
AND e.event_name = 'session_start'
AND e.user_pseudo_id IS NOT NULL
),
touches AS (
SELECT * EXCEPT(mapping_priority)
FROM (
SELECT * FROM google_touches
UNION ALL
SELECT * FROM manual_touches
)
QUALIFY ROW_NUMBER() OVER (
PARTITION BY user_pseudo_id, touch_time
ORDER BY mapping_priority
) = 1
),
purchases AS (
SELECT
e.ecommerce.transaction_id,
e.user_pseudo_id,
TIMESTAMP_MICROS(e.event_timestamp) AS purchase_time,
o.net_revenue_krw AS revenue_krw,
e.event_timestamp
FROM `project.analytics_<id>.events_*` e
JOIN `project.mart.orders` o
ON e.ecommerce.transaction_id = o.transaction_id
WHERE e.event_name = 'purchase'
AND e.ecommerce.transaction_id IS NOT NULL
AND _TABLE_SUFFIX BETWEEN '20260401' AND '20260430'
QUALIFY ROW_NUMBER() OVER (
PARTITION BY e.ecommerce.transaction_id
ORDER BY e.event_timestamp
) = 1
),
attributed AS (
SELECT p.*, t.touch_time, t.channel, t.campaign_id
FROM purchases p
LEFT JOIN touches t
ON p.user_pseudo_id = t.user_pseudo_id
AND t.touch_time BETWEEN TIMESTAMP_SUB(p.purchase_time, INTERVAL 7 DAY)
AND p.purchase_time
QUALIFY ROW_NUMBER() OVER (
PARTITION BY p.transaction_id
ORDER BY t.touch_time DESC NULLS LAST
) = 1
),
revenue_daily AS (
SELECT DATE(touch_time, 'Asia/Seoul') AS date_kst, channel, campaign_id,
COUNT(*) AS orders, SUM(revenue_krw) AS revenue_krw
FROM attributed
WHERE touch_time IS NOT NULL
GROUP BY 1, 2, 3
),
spend_daily AS (
SELECT date_kst, channel, campaign_id, SUM(spend_krw) AS spend_krw
FROM `project.mart.ads_spend`
GROUP BY 1, 2, 3
)
SELECT
COALESCE(r.date_kst, s.date_kst) AS date_kst,
COALESCE(r.channel, s.channel) AS channel,
COALESCE(r.campaign_id, s.campaign_id) AS campaign_id,
COALESCE(s.spend_krw, 0) AS spend_krw,
COALESCE(r.revenue_krw, 0) AS revenue_krw,
COALESCE(r.orders, 0) AS orders,
SAFE_DIVIDE(
COALESCE(r.revenue_krw, 0), COALESCE(s.spend_krw, 0)
) AS roas
FROM revenue_daily r
FULL OUTER JOIN spend_daily s
ON r.date_kst = s.date_kst
AND r.channel = s.channel
AND r.campaign_id = s.campaign_id

Google Ads 자동 태깅은 세션의 google_ads_campaign.campaign_id로, 수동 UTM은 매핑 테이블로 연결합니다. 과거 export에 세션 필드가 없는지는 샘플 날짜의 null 비율로 먼저 확인합니다. 이 touch는 광고 클릭 로그가 아니라 수집 campaign이므로 결과명도 “7일 last collected paid-campaign touch”가 정확합니다. 미귀속 구매 비율과 주문 DB의 transaction_id 합계를 감시해야 하며, user_pseudo_id가 바뀌는 cross-device 구매는 복구되지 않습니다.

이 API로 실제 무엇을 만들 수 있는지

매일 새벽 BigQuery scheduled query로 결과를 만들면 마케터가 SQL을 몰라도 쓰는 대시보드 테이블이 됩니다. 현재 SQL의 날짜·채널·campaign ID·원화 지표에 후속 enrichment를 더한 운영 스키마 예시는 다음과 같습니다.

컬럼의미
date_kstKST 집계 일자
channelgoogle_ads / meta / tiktok / naver / other
campaign_name캠페인
is_new_user신규 vs 기존 분리 진단
device_categorymobile / desktop / tablet
spend_krw, revenue_krw, orders원화 비용·순매출·주문 수
roas합의한 attribution 정의의 매출/광고비
status_flagunattributed / mapping_missing / provisional / ok

이 테이블을 Looker Studio·Tableau·내부 BI로 연결하면, 마케터는 채널·캠페인 ROAS와 데이터 품질 상태를 같은 정의로 봅니다. 여기에 다음 운영 도구를 얹을 수 있습니다.

  • 일별 비용 적재 누락과 GA4 지연 데이터를 구분하는 파이프라인 상태판
  • 플랫폼 보고값·GA4 이벤트·주문 DB를 나란히 보여주는 reconciliation 리포트
  • 신규/기존 고객, 브랜드/논브랜드, 환불 차감 여부를 전환할 수 있는 ROAS 뷰
  • 미매핑 UTM, 중복 transaction_id, 비정상 환율을 배포 전에 잡는 데이터 품질 경보

잘 빠지는 함정 — 직접 만들 때만 발생하는 것들

1) 전환일과 광고 상호작용일 혼동

사용자가 4월 30일에 클릭하고 5월 1일에 결제하면 구매 이벤트는 5월 1일에 생기지만 광고 상호작용은 4월 30일에 있습니다. “전환 발생일” 보고와 “상호작용일에 귀속한” 보고는 서로 다른 질문입니다. 임의의 7일 offset으로 날짜를 맞추지 말고, 조직이 정한 lookback window 안에서 touchpoint와 conversion을 timestamp로 매칭한 뒤 두 보고 날짜를 모두 보존합니다.

2) Cross-device 이슈

같은 사람이 모바일에서 클릭하고 데스크탑에서 결제하면 user_pseudo_id가 달라져 별개 사용자로 잡힙니다. GA4 user_id(로그인 ID) 매칭을 켜뒀으면 일부 회복.

3) 환불·캐시백 빠뜨리기

GA4 purchase만 수집하면 이후 환불을 자동으로 알 수 없습니다. transaction_id를 포함한 refund 이벤트를 구현했거나 주문 DB에 환불 상태가 있을 때만 구매와 연결해 차감할 수 있습니다. 부분 환불·쿠폰·부가세·배송비 포함 규칙도 매출 계약에 명시합니다.

운영 팁 — 마케터 데이터 팀이 자주 묻는 것

  • _TABLE_SUFFIX BETWEEN으로 읽는 날짜를 제한하고 scheduled query 결과만 BI에 연결합니다. export 한도와 스트리밍 누락 가능성은 상태판에 함께 표시합니다.
  • 1차 모델은 문서화한 last-touch와 정합성 검사부터 만듭니다. 주문 DB 대조를 통과한 뒤 멀티터치나 실험 보정을 별도 모델로 추가합니다.
  • attribution 범위·환불 차감·채널 매핑표를 한 페이지 데이터 계약으로 남기고 분기마다 갱신합니다. A/B·geo-lift 결과도 같은 주문·비용 계약을 재사용합니다.

마치며

플랫폼 대시보드 ROAS는 각 플랫폼의 attribution·보고 시간·전환 설정에 따라 계산된 숫자입니다. GA4 event export + BigQuery + SQL로 자체 파이프라인을 만들면 그 숫자를 무조건 대체하는 것이 아니라, 차이를 설명하고 조직의 의사결정 정의를 재현할 수 있습니다.

가장 먼저 만들 뷰는 화려한 멀티터치 모델보다 date × channel × campaign 단위의 비용·주문·환불·매핑 상태표가 좋습니다. 이 추천은 실제 회사 성과를 인용한 것이 아니라, 데이터 계약을 작은 단위에서 검증하기 위한 구현 순서입니다.

참고

Analytics Ops (GA4·GTM) 카테고리의 다른 글

전체 보기 →