BigQuery Export 모델링
GA4 BigQuery Export의 dataset, table, event_params를 분석 모델로 풀어내는 방법
핵심 요약
- BigQuery Export는
analytics_<property_id>dataset과 일별events_YYYYMMDDtable로 GA4 원시 이벤트를 적재합니다. event_params,user_properties,items는 repeated field이라UNNEST로 flatten해야 분석에 쓸 수 있습니다.- 표준 property는 daily batch export가 1일 100만 events로 제한되며, late arrival로 daily table은 며칠간 갱신될 수 있습니다.
- 운영 mart는 daily table과 도착 지연을 기준으로 재계산합니다. 코호트는 가입 후 7일 관측창이 끝난 사용자만 분모에 넣습니다.
- GA4 UI와 BigQuery는 attribution, export delay, identity, sampling/thresholding에서 차이가 나므로 그 차이를 명시적으로 기록합니다.
Export 완료 판정과 재계산 창
일별 표가 존재한다고 데이터가 완성된 것은 아닙니다. Google은 보통 표 날짜 이후 2일+당일의 늦게 도착한 데이터를 반영하며 예외적인 재처리는 더 늦게도 발생합니다. 최근 3일을 재계산하는 창과 마감 후 정정 절차를 나눕니다. 속성 시간대 변경은 export 누락·차이를 만들 수 있어 별도 변경으로 기록합니다.
Analytics 360은 Daily·Fresh Daily의 completeness signal을 Cloud Logging에서 확인할 수 있습니다. 신호는 export 처리일의 흐름을 나타내며 과거 이벤트 날짜의 파티션이 영구 불변임을 뜻하지 않습니다. Standard에 같은 신호가 있다고 가정하지 말고 표 갱신 시각·행 수·늦은 도착 비율을 점검합니다.
Streaming의 신규 사용자에는 traffic_source.name/source/medium이 없으므로 귀속 KPI는 daily에서 계산합니다. dbt 예제의 ga4_observed_through는 팀이 확정한 BigQuery TIMESTAMP 표현식 변수로, 검증하지 않은 사용자 문자열을 삽입하지 않습니다.
BigQuery Export · Export schema.
GA4 UI가 운영용 표면이라면, BigQuery Export는 원시 이벤트를 직접 소유하고 재가공하는 분석 기반입니다.
Google 공식 문서에 따르면 GA4 property를 BigQuery에 연결할 때마다 analytics_<property_id> dataset과 일별 events_YYYYMMDD table이 생깁니다.
Export 구조
| 항목 | 구조 |
|---|---|
| Dataset | analytics_<property_id> |
| Daily table | events_YYYYMMDD |
| Intraday table | events_intraday_YYYYMMDD |
| Event row | 각 row가 하나의 event |
| Repeated fields | event_params, user_properties, items 등 |
Daily export는 전날 데이터를 한 번에 내보내고, streaming export는 당일 데이터를 계속 쌓아 올립니다. 공식 schema 문서에 따르면 late arrival 때문에 daily table은 이벤트 발생일 이후 며칠간 더 갱신되기도 합니다.
Standard property export 주의
| 항목 | 기준 |
|---|---|
| Daily export limit | 표준 GA4 property는 daily batch export에 1일 100만 events 제한 |
| Streaming export | 이벤트 수 제한은 없지만 BigQuery streaming 비용 고려 |
| Backfill | Analytics에서 BigQuery로 한 번 export한 데이터를 다시 export하지 않음 |
| Filtering | data stream과 event exclusion으로 export volume 제어 가능 |
Event params flatten 예시
SELECT
event_date,
TIMESTAMP_MICROS(event_timestamp) AS event_ts,
user_pseudo_id,
event_name,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'plan') AS plan,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'activation_type') AS activation_type,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'trial_day') AS trial_day
FROM `my_project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260501' AND '20260510'
AND event_name = 'activation_complete';SaaS activation cohort SQL
아래는 조회 기간 안의 첫 sign_up을 가입 시점으로 삼는 7×24시간 코호트입니다. 생애 최초 가입이 필요하면 별도의 사용자 차원에 최초 시점을 보존합니다. 속성 시간대를 Asia/Seoul로 가정하며 _TABLE_SUFFIX로 스캔 범위를 제한합니다.
DECLARE cohort_start DATE DEFAULT DATE '2026-10-01';
DECLARE cohort_end DATE DEFAULT DATE '2026-10-07';
DECLARE property_timezone STRING DEFAULT 'Asia/Seoul';
DECLARE observed_through TIMESTAMP DEFAULT TIMESTAMP '2026-10-18 00:00:00+09';
WITH source AS (
SELECT
user_pseudo_id,
event_name,
TIMESTAMP_MICROS(event_timestamp) AS event_ts
FROM `my_project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', cohort_start)
AND FORMAT_DATE('%Y%m%d', DATE(observed_through, property_timezone))
AND user_pseudo_id IS NOT NULL
AND event_name IN ('sign_up', 'activation_complete')
),
signups AS (
SELECT user_pseudo_id, MIN(event_ts) AS signed_up_at
FROM source
WHERE event_name = 'sign_up'
AND DATE(event_ts, property_timezone) BETWEEN cohort_start AND cohort_end
GROUP BY user_pseudo_id
),
first_valid_activation AS (
SELECT s.user_pseudo_id, MIN(e.event_ts) AS activated_at
FROM signups s
JOIN source e
ON e.user_pseudo_id = s.user_pseudo_id
AND e.event_name = 'activation_complete'
AND e.event_ts >= s.signed_up_at
AND e.event_ts <= TIMESTAMP_ADD(s.signed_up_at, INTERVAL 7 DAY)
GROUP BY s.user_pseudo_id
)
SELECT
DATE(s.signed_up_at, property_timezone) AS signup_date,
COUNT(*) AS signups,
COUNTIF(a.activated_at IS NOT NULL) AS activated_d7,
SAFE_DIVIDE(COUNTIF(a.activated_at IS NOT NULL), COUNT(*)) AS activation_d7_rate
FROM signups s
LEFT JOIN first_valid_activation a USING (user_pseudo_id)
WHERE TIMESTAMP_ADD(s.signed_up_at, INTERVAL 7 DAY) <= observed_through
GROUP BY signup_date
ORDER BY signup_date;| 경계 조건 | 기대 결과 |
|---|---|
| 가입 전 활성화만 존재 | 미전환 |
| 가입 전·가입 후 활성화 모두 존재 | 가입 후 첫 유효 이벤트로 전환 |
| 정확히 가입+7일에 활성화 | 전환 (이 예시의 우측 경계는 포함) |
| 가입+7일 이후 활성화 | 미전환 |
| 관측 기준일에 아직 7일 미경과 | 분모에서 제외 |
| consent 때문에 식별자 없음 | 원본에는 보존하고 식별 가능한 코호트에서만 제외 |
7일을 “8개 달력 날짜”로 바꾸려면 날짜 기반 규칙으로 별도 정의합니다. 이 SQL은 UI의 모델링 사용자와 동일한 분모를 보장하지 않습니다. observed_through는 단순 현재 시간이 아니라 수집·export가 확인된 시각으로 정하고 late arrival 반영 뒤 재계산합니다.
모델링 레이어
| Layer | 목적 | 예시 |
|---|---|---|
| Raw | GA4 export 원본 | events_YYYYMMDD |
| Staging | params flatten, type casting | stg_ga4_events |
| Intermediate | session/user/account 모델 | int_ga4_user_journey |
| Mart | KPI 보고용 | mart_activation_cohort, mart_revenue_funnel |
dbt 모델 예시
models/staging/stg_ga4_events.sql
WITH source AS (
SELECT *
FROM {{ source('ga4_raw', 'events') }}
),
flattened AS (
SELECT
event_date,
TIMESTAMP_MICROS(event_timestamp) AS event_ts,
user_pseudo_id,
user_id,
event_name,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'source_surface') AS source_surface,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'plan') AS plan,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'activation_type') AS activation_type,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'cta_location') AS cta_location,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'cta_intent') AS cta_intent,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'transaction_id') AS transaction_id,
COALESCE(
(SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value'),
CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'value') AS FLOAT64)
) AS event_value,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'currency') AS currency
FROM source
)
SELECT *
FROM flattenedmodels/intermediate/int_ga4_funnel_events.sql
SELECT
user_pseudo_id,
user_id,
DATE(event_ts, 'Asia/Seoul') AS event_day,
MIN(IF(event_name = 'sign_up', event_ts, NULL)) AS signed_up_at,
MIN(IF(event_name = 'activation_complete', event_ts, NULL)) AS activated_at,
MIN(IF(event_name = 'begin_checkout', event_ts, NULL)) AS checkout_started_at,
MIN(IF(event_name = 'purchase', event_ts, NULL)) AS purchased_at
FROM {{ ref('stg_ga4_events') }}
WHERE event_name IN ('sign_up', 'activation_complete', 'begin_checkout', 'purchase')
GROUP BY 1, 2, 3models/marts/mart_activation_cohort.sql
WITH first_signup AS (
SELECT
user_pseudo_id,
MIN(signed_up_at) AS signed_up_at
FROM {{ ref('int_ga4_funnel_events') }}
WHERE signed_up_at IS NOT NULL AND user_pseudo_id IS NOT NULL
GROUP BY 1
),
first_activation AS (
SELECT
s.user_pseudo_id,
MIN(e.event_ts) AS activated_at
FROM first_signup s
JOIN {{ ref('stg_ga4_events') }} e
ON e.user_pseudo_id = s.user_pseudo_id
AND e.event_name = 'activation_complete'
AND e.event_ts >= s.signed_up_at
AND e.event_ts <= TIMESTAMP_ADD(s.signed_up_at, INTERVAL 7 DAY)
GROUP BY s.user_pseudo_id
)
SELECT
DATE(s.signed_up_at, 'Asia/Seoul') AS signup_date,
COUNT(*) AS signups,
COUNTIF(a.activated_at IS NOT NULL) AS activated_d7,
SAFE_DIVIDE(
COUNTIF(a.activated_at IS NOT NULL),
COUNT(*)
) AS activation_d7_rate
FROM first_signup s
LEFT JOIN first_activation a USING (user_pseudo_id)
WHERE TIMESTAMP_ADD(s.signed_up_at, INTERVAL 7 DAY) <= {{ var('ga4_observed_through') }}
GROUP BY 1dbt_project.yml의 예제 관측 시점
아래 값은 팀이 확정한 SQL 표현식 예제입니다. 실제 적재가 확인된 시점으로 갱신하고 재집계합니다.
vars:
ga4_observed_through: "TIMESTAMP('2026-10-18 00:00:00+09')"models/schema.yml
version: 2
models:
- name: stg_ga4_events
columns:
- name: event_name
tests: [not_null]
- name: event_ts
tests: [not_null]
- name: mart_activation_cohort
columns:
- name: signup_date
tests: [not_null, unique]
- name: activation_d7_rate
tests:
- dbt_utils.accepted_range:
min_value: 0
max_value: 1dbt source는 stable daily table 기준
운영 mart는 events_intraday_YYYYMMDD가 아니라 stable daily export를 기준으로 만드는 편이 안전합니다.
streaming export는 near realtime 확인용으로만 쓰고, user attribution과 완성 데이터가 필요한 KPI는 daily table에서
다시 계산하세요.
UI와 BigQuery 차이를 기록하기
| 차이 | 처리 |
|---|---|
| Attribution | GA4 UI의 attribution model과 BigQuery raw event 기준을 구분 |
| Export delay | daily table 완성 시점과 late arrival 반영 |
| Consent mode | cookieless pings와 모델링 데이터 해석 주의 |
| Identity | user_pseudo_id, user_id, account join key 차이 |
| Sampling/thresholding | UI 탐색 제한과 raw export 집계 차이 |