GA 입문 6편. BigQuery Export 깊이 — daily export vs streaming vs fresh daily, events_YYYYMMDD 테이블 구조, event_params·user_properties·items 의 nested record 스키마, UNNEST 사용 패턴, 1M event/일 한도, $0.05/GB streaming 비용, 24시간 트래픽 소스 lag, 2일 retroactive window, partition 최적화, intraday 테이블, 운영 함정·실전 쿼리 패턴까지. GA4 의 진짜 깊이는 BQ 에서 시작.
이 글은 Google Analytics 입문에서 운영까지 시리즈 6편이에요. 5편 Data API · Admin API 의 report API 는 sampling · 한도 · 미리 정의된 dimension 의 제약이 있었어요. 6편은 이 제약이 사라지는 raw 데이터 자리예요.
이번 글의 범위
GA4 의 진짜 깊이는 BigQuery (구글의 서버리스 데이터 웨어하우스) 에서 시작해요. 모든 raw event 가 매일 BQ 로 export 되니까, SQL 로 자유롭게 query 하고 sampling 도 없고 Looker Studio · ML 파이프라인 결합까지 열려요.
| 자산 | 차이 |
|---|---|
| Data API | 미리 정의된 dimension/metric · sampling 가능 · quota |
| BigQuery Export | raw event · SQL 자유 · 비용 GCP 청구 · 함정 많음 |
GA4 의 모든 분석을 받쳐주는 회사 표준 stack 의 핵심이에요.
Export 의 3 종류
┌──────────────────────┬──────────────────────┬──────────────────────┐
│ Daily Export │ Streaming Export │ Fresh Daily (360) │
├──────────────────────┼──────────────────────┼──────────────────────┤
│ 전날 데이터 (오후) │ 수 분 내 실시간 │ 매일 오전 5시 (Seoul) │
│ 기본 포함 무료 │ $0.05/GB 추가 │ 360 만 (Standard X) │
│ 1M event/일 (Std) │ 무제한 │ 무제한 │
│ events_YYYYMMDD │ events_intraday_ │ events_YYYYMMDD │
│ │ YYYYMMDD │ (더 일찍 도착) │
└──────────────────────┴──────────────────────┴──────────────────────┘
Daily Export — 기본
오늘 (2026-05-17):
새벽 0시 ~ 23:59 의 event 가 GA 서버에 도착
내일 (2026-05-18) 오후 (보통 14~16시 KST):
→ events_20260517 테이블이 BQ 에 도착
대부분의 회사는 Daily Export 만으로 충분해요. 무료 + 1M event/일 (Standard) 이상이면 유료로 한도를 늘리거나 360 으로 업그레이드하는 길이 있어요.
Streaming Export — 실시간
event 발생 → 수 분 내 → events_intraday_YYYYMMDD 테이블에 도착
여기서 시험 함정이 하나 있어요. Streaming 의 제한이에요.
Streaming = best-effort service. 완전성 보장 X. 새 사용자의 traffic source 정보 24시간 후에 채워짐. — 공식 docs
신규 사용자의 campaign · source · medium 이 빈값으로 들어와요 (24시간 후 daily 에 채워져요). 완전성 보장도 없어서 드물게 event 누락이 생기고, 비용도 $0.05/GB (약 600,000 GA4 event ≈ 1GB) 따로 붙어요.
그래서 실시간 dashboard · 이상 감지는 streaming, 공식 report 는 daily 로 나눠 쓰는 게 정석이에요.
Fresh Daily (360 만)
일반 daily: 오후 14~16시 KST (전날 데이터)
Fresh daily: 오전 5시 KST (전날 데이터)
9~10 시간 빨리 도착해요. 360 Property 만 가능해요 (Standard X).
events_YYYYMMDD 테이블 구조
행의 단위
각 event 의 한 행 — 보이는 첫 인상은 평범한 row 지만:
SELECT
event_date,
event_timestamp,
event_name,
user_pseudo_id,
user_id
FROM `our-gcp-project.analytics_123456789.events_20260517`
LIMIT 10
event_date | event_timestamp | event_name | user_pseudo_id | user_id
─────────────────────────────────────────────────────────────────────────
2026-05-17 | 1747443600000000 | page_view | 555.111 | u_12345
2026-05-17 | 1747443610000000 | add_to_cart| 555.111 | u_12345
2026-05-17 | 1747443700000000 | purchase | 555.111 | u_12345
여기까지는 간단해요. 함정은 nested record (한 행 안에 배열을 품은 구조) 에서 시작돼요.
8 주요 필드 그룹
┌────────────────────────┬────────────────────────────────────┐
│ Field │ 내용 │
├────────────────────────┼────────────────────────────────────┤
│ event_* │ event_name · event_timestamp · ... │
│ user_* │ user_id · user_pseudo_id · user_* │
│ event_params │ event 의 parameter (nested array) │
│ user_properties │ user property (nested array) │
│ items │ e-commerce items (nested array) │
│ device │ category · OS · browser · ... │
│ geo │ country · city · region │
│ traffic_source │ source · medium · campaign │
└────────────────────────┴────────────────────────────────────┘
Nested Record 의 핵심
event_params · user_properties · items 는 Repeated Record (한 행 안에 배열 구조) 예요.
event_params (한 event 의 parameter 들):
┌───────────────────────────────────────┐
│ key │ value │
├───────────────────────────────────────┤
│ ga_session_id │ {int_value: 100} │
│ page_location │ {string_value: ...} │
│ page_title │ {string_value: ...} │
│ engagement_time│ {int_value: 5000} │
│ value │ {double_value: 50000}│
│ currency │ {string_value: KRW} │
└───────────────────────────────────────┘
각 parameter 의 value 는 다음 4 type 중 하나만 채워져요 — string_value, int_value, double_value (실수), float_value (드물게 사용).
여기 정말 중요한 시험 함정이 숨어 있어요. value 가 string 인지 number 인지 알아야 맞는 field 로 추출돼요. purchase 의 value 는 double, transaction_id 는 string 이에요.
Items (e-commerce)
items (purchase event 의 items 배열):
┌──────────────────────────────────────────────────┐
│ item_id │ item_name │ item_brand │ price │ qty │
├──────────────────────────────────────────────────┤
│ P_1 │ Shoes │ Nike │ 25000 │ 2 │
│ P_2 │ Socks │ Adidas │ 5000 │ 3 │
└──────────────────────────────────────────────────┘
한 purchase event 의 items 가 여러 row 로 풀려요. flat 한 SQL 로는 보이지 않아서 UNNEST 가 필요해요.
UNNEST — Nested 풀기
기본 패턴
-- event_params 에서 page_location 추출
SELECT
event_date,
event_name,
user_pseudo_id,
(SELECT value.string_value
FROM UNNEST(event_params)
WHERE key = 'page_location') AS page_location,
(SELECT value.int_value
FROM UNNEST(event_params)
WHERE key = 'ga_session_id') AS session_id
FROM `our-gcp-project.analytics_123456789.events_20260517`
WHERE event_name = 'page_view'
LIMIT 100
Sub-query UNNEST 패턴 — 한 parameter 만 뽑을 때 가장 흔해요.
CROSS JOIN UNNEST — 모든 parameter 펼치기
SELECT
event_date,
event_name,
param.key,
param.value.string_value,
param.value.int_value,
param.value.double_value
FROM `our-gcp-project.analytics_123456789.events_20260517`,
UNNEST(event_params) AS param
WHERE event_name = 'purchase'
LIMIT 100
이 query 는 한 event 가 parameter 수만큼 row 로 펼쳐져요. 디버그나 탐색 때 쓸모가 커요.
Items UNNEST — e-commerce 분석
-- 어제 가장 많이 팔린 상품 top 10
SELECT
i.item_id,
i.item_name,
i.item_brand,
SUM(i.quantity) AS total_quantity,
SUM(i.item_revenue) AS total_revenue
FROM `our-gcp-project.analytics_123456789.events_20260517`,
UNNEST(items) AS i
WHERE event_name = 'purchase'
GROUP BY i.item_id, i.item_name, i.item_brand
ORDER BY total_revenue DESC
LIMIT 10
User Property UNNEST
-- premium 사용자의 어제 매출
SELECT
user_id,
SUM(ecommerce.purchase_revenue) AS revenue
FROM `our-gcp-project.analytics_123456789.events_20260517`,
UNNEST(user_properties) AS up
WHERE event_name = 'purchase'
AND up.key = 'user_tier'
AND up.value.string_value = 'premium'
GROUP BY user_id
ORDER BY revenue DESC
다중 UNNEST
-- 특정 카테고리 의 특정 캠페인 매출
SELECT
i.item_category,
traffic_source.source,
COUNT(*) AS purchase_count,
SUM(i.item_revenue) AS revenue
FROM `our-gcp-project.analytics_123456789.events_20260517`,
UNNEST(items) AS i
WHERE event_name = 'purchase'
AND traffic_source.source = 'newsletter'
GROUP BY i.item_category, traffic_source.source
ORDER BY revenue DESC
UNNEST 가 두 개 이상 필요한 경우는 일반적인 e-commerce 분석에서 자주 나와요. 평소 BQ 안 써본 사람한테 가장 큰 함정 자리예요.
짧은 UNNEST 의 BQ SQL trick
-- WITH 절 + UNNEST 의 가독성 패턴
WITH purchase_events AS (
SELECT
user_pseudo_id,
event_timestamp,
(SELECT value.string_value
FROM UNNEST(event_params)
WHERE key = 'transaction_id') AS transaction_id,
(SELECT value.double_value
FROM UNNEST(event_params)
WHERE key = 'value') AS purchase_value
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260510' AND '20260517'
AND event_name = 'purchase'
)
SELECT
DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul') AS event_date_kst,
COUNT(DISTINCT transaction_id) AS transactions,
SUM(purchase_value) AS revenue
FROM purchase_events
GROUP BY event_date_kst
ORDER BY event_date_kst
비용 — GCP 청구
Storage 비용
BigQuery Storage:
- Active storage: $0.02 / GB / month (90일 이내 수정)
- Long-term storage: $0.01 / GB / month (90일 이상 안 수정)
- First 10 GB / month = free
GA4 export 는 daily 테이블이라 90일이 지나면 자동으로 long-term 으로 넘어가서 비용이 절반으로 떨어져요.
Query 비용 (on-demand)
Query (on-demand): $6.25 / TB scanned (asia-northeast3 Seoul)
- First 1 TB / month = free
여기서 시험 함정이 또 나와요. SELECT * FROM events_* 한 번이 수 TB scan 까지 가는 비용 폭탄이 돼요.
Streaming 비용
Streaming insertion: $0.05 / GB
- 약 600,000 GA4 event ≈ 1GB
- 100만 event/일 → 약 $0.083/일 → $2.5/월
- 1000만 event/일 → 약 $0.83/일 → $25/월
대부분 회사는 daily 무료로 충분해요. streaming 은 실시간 dashboard 가 정말 필요할 때만 붙이는 게 좋아요.
비용 예 — 1000만 event / 일
Daily:
- 일 1000만 event → 약 17 GB
- 월 약 510 GB (active) → $10 (storage)
- 90일 후 long-term → $5/월
- Query: 사용량 (보통 월 $20~100)
≈ $50/월 (보통)
Streaming 추가 시:
- $25/월 streaming 비용
- 실시간 query 도 더 자주 (storage·query 약간 증가)
≈ $80~100/월
Reservation (대규모)
대규모:
- 월 $2000+ = Flat-rate reservation 더 저렴
- Slot 단위 (100 slots = $2000/월)
- 정해진 throughput 보장
데이터 lag · retroactive
Daily Export 의 도착 시점
2026-05-17 의 데이터:
→ 2026-05-18 오후 (보통 14~16시 KST) 도착
문제 case:
- 2026-05-17 의 데이터가 18시 가 지나도 안 옴 → GA 백엔드 lag (드물게 발생)
- 2026-05-19 새벽까지 안 오면 GA support 에 문의
Retroactive Update (2일 window)
일일 테이블은 event timestamp 기준 2 캘린더일 + 당일 까지 업데이트 가능. — 공식 docs
오늘이 2026-05-17 라면:
- events_20260517 → 업데이트 가능
- events_20260516 → 업데이트 가능
- events_20260515 → 업데이트 가능 (마지막 날)
- events_20260514 → 확정 (변경 X)
왜 retroactive 인가? — 모바일 앱의 offline event 가 사용자 device 오프라인 동안 device 에 저장돼 있다가 온라인으로 복귀하면서 GA 로 전송돼요. 도착 시점이 2일 후라도 원래 이벤트 시각의 테이블로 들어가요.
함정 — 3일 전 query 의 변경
-- 어제 매출 (2026-05-16, 오늘이 17일)
-- → 데이터가 아직 안 옴 (도착 시점 2026-05-17 오후)
-- 그제 매출 (2026-05-15)
-- → 데이터 있지만 retroactive update 가능
-- → 같은 query 의 *결과가 시간 지나며 변경 가능*
-- 3일 전 매출 (2026-05-14)
-- → 확정 (변경 X)
→ 확정된 데이터를 보려면 3일 전 기준이에요. 모바일이 강한 비즈니스일수록 lag 가 더 커져요.
Partition · Clustering 최적화
Date Suffix = 자동 partition
events_YYYYMMDD 의 날짜 suffix 가 BQ 의 자연 partition 이에요.
-- 좋은 query (1일 만 scan)
SELECT *
FROM `our-gcp-project.analytics_123456789.events_20260517`
-- 좋은 query (Wildcard + _TABLE_SUFFIX 범위)
SELECT *
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260510' AND '20260517'
-- 나쁜 query (모든 테이블 scan — 비용 폭탄)
SELECT *
FROM `our-gcp-project.analytics_123456789.events_*`
-- _TABLE_SUFFIX 없음
Clustering 없음 — 한계
BQ 의 일반 partition table 에 비해 clustering 이 자동으로 잡히지 않아서, 수많은 column 의 비효율 scan 이 일어나요.
해결법은 materialized view 나 별도 partitioned/clustered 테이블로 ETL (Extract·Transform·Load) 을 돌리는 거예요.
-- 매일 새벽 새 view 만들기 (또는 scheduled query)
CREATE OR REPLACE TABLE `our-project.analytics_views.daily_purchases`
PARTITION BY event_date
CLUSTER BY user_pseudo_id, item_category
AS
SELECT
PARSE_DATE('%Y%m%d', event_date) AS event_date,
event_timestamp,
user_pseudo_id,
user_id,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'transaction_id') AS transaction_id,
(SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value') AS purchase_value,
i.item_id,
i.item_name,
i.item_category,
i.item_revenue,
i.quantity,
device.category AS device_category,
geo.country,
traffic_source.source,
traffic_source.medium,
traffic_source.name AS campaign_name
FROM `our-gcp-project.analytics_123456789.events_*`,
UNNEST(items) AS i
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
AND event_name = 'purchase'
→ 분석가의 SQL 비용도 줄고 partition + clustering 도 살아나요.
실전 쿼리 패턴
Pattern 1: 어제 매출 + 어제 사용자
WITH yesterday AS (
SELECT
user_pseudo_id,
user_id,
event_name,
event_timestamp,
(SELECT value.double_value
FROM UNNEST(event_params)
WHERE key = 'value') AS purchase_value
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
)
SELECT
COUNT(DISTINCT user_pseudo_id) AS users,
COUNTIF(event_name = 'purchase') AS transactions,
SUM(CASE WHEN event_name = 'purchase' THEN purchase_value ELSE 0 END) AS revenue
FROM yesterday
Pattern 2: Funnel 분석
-- view_item → add_to_cart → purchase 의 funnel
WITH events AS (
SELECT
user_pseudo_id,
event_name,
event_timestamp
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260510' AND '20260516'
AND event_name IN ('view_item', 'add_to_cart', 'purchase')
)
SELECT
COUNT(DISTINCT CASE WHEN event_name = 'view_item' THEN user_pseudo_id END) AS step1_users,
COUNT(DISTINCT CASE WHEN event_name = 'add_to_cart' THEN user_pseudo_id END) AS step2_users,
COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_pseudo_id END) AS step3_users,
SAFE_DIVIDE(
COUNT(DISTINCT CASE WHEN event_name = 'add_to_cart' THEN user_pseudo_id END),
COUNT(DISTINCT CASE WHEN event_name = 'view_item' THEN user_pseudo_id END)
) AS view_to_cart_rate,
SAFE_DIVIDE(
COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_pseudo_id END),
COUNT(DISTINCT CASE WHEN event_name = 'add_to_cart' THEN user_pseudo_id END)
) AS cart_to_purchase_rate
FROM events
Pattern 3: Cohort 분석
-- 가입 주차 별 retention
WITH first_events AS (
SELECT
user_pseudo_id,
MIN(DATE(TIMESTAMP_MICROS(event_timestamp))) AS first_date
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260516'
GROUP BY user_pseudo_id
),
weekly_active AS (
SELECT
f.user_pseudo_id,
DATE_TRUNC(f.first_date, WEEK) AS cohort_week,
DATE_DIFF(
DATE_TRUNC(DATE(TIMESTAMP_MICROS(e.event_timestamp)), WEEK),
DATE_TRUNC(f.first_date, WEEK),
WEEK
) AS weeks_after
FROM first_events f
JOIN `our-gcp-project.analytics_123456789.events_*` e
ON f.user_pseudo_id = e.user_pseudo_id
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260516'
)
SELECT
cohort_week,
weeks_after,
COUNT(DISTINCT user_pseudo_id) AS active_users
FROM weekly_active
GROUP BY cohort_week, weeks_after
ORDER BY cohort_week, weeks_after
Pattern 4: Custom Event 분석
-- 자체 custom event 분석
SELECT
(SELECT value.string_value
FROM UNNEST(event_params)
WHERE key = 'feature_name') AS feature_name,
(SELECT value.string_value
FROM UNNEST(user_properties)
WHERE key = 'user_tier') AS user_tier,
COUNT(*) AS event_count,
COUNT(DISTINCT user_pseudo_id) AS unique_users
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260510' AND '20260516'
AND event_name = 'feature_used'
GROUP BY feature_name, user_tier
ORDER BY event_count DESC
Pattern 5: GA 매출 vs DB 매출 reconcile
-- BQ 의 매출
WITH ga_revenue AS (
SELECT
DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul') AS date_kst,
SUM((SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value')) AS revenue
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260510' AND '20260516'
AND event_name = 'purchase'
GROUP BY date_kst
),
db_revenue AS (
SELECT
DATE(payment_completed_at, 'Asia/Seoul') AS date_kst,
SUM(amount) AS revenue
FROM `our-gcp-project.our_db.payments`
WHERE payment_completed_at BETWEEN '2026-05-10' AND '2026-05-16'
AND status = 'completed'
GROUP BY date_kst
)
SELECT
COALESCE(g.date_kst, d.date_kst) AS date_kst,
g.revenue AS ga_revenue,
d.revenue AS db_revenue,
SAFE_DIVIDE(g.revenue - d.revenue, d.revenue) AS delta_percent
FROM ga_revenue g
FULL OUTER JOIN db_revenue d USING (date_kst)
ORDER BY date_kst
→ GA 측정 누락 (Consent · ad blocker · 모바일 offline) 을 감사하는 query 예요. 보통 GA 가 DB 의 85~95% 정도예요.
함정 정리
사고 1: SELECT * 의 비용 폭탄
원인 — SELECT * FROM events_* 1회가 수 TB scan 으로 수십 달러까지 가요.
해결 — column 을 명시하고, _TABLE_SUFFIX 필터를 걸고, 탐색 단계에서는 LIMIT 으로 끊어요.
사고 2: Streaming 의 traffic source 빈값
원인 — 신규 사용자의 traffic source 는 24시간 후에 채워져서, streaming 의 실시간 dashboard 에서 campaign 이 빈값으로 나와요.
해결 — attribution 을 보려면 daily 를 써요. streaming 은 event 활동량과 현재 활성 사용자만 보는 자리예요.
사고 3: Nested record 무시
원인 — event_params 의 column 으로 그냥 SELECT event_params.value 를 거는 실수예요. parameter 는 배열이라 그렇게 못 빼요.
해결 — UNNEST 가 필수고, (SELECT ... FROM UNNEST(event_params) WHERE key = '...') sub-query 패턴으로 가요.
사고 4: value type 잘못
원인 — value parameter 를 int_value 로 추출했는데 빈값이 나오는 경우예요 (실은 double_value).
해결 — 4 type 중 하나만 채워진다는 걸 기억해요. 일반적으로 purchase value · revenue 는 double, id · name 은 string, count 는 int 예요.
사고 5: 1M event/일 한도 초과 (Standard)
원인 — 트래픽 급증으로 일 1M event 를 넘으면 그날 일부 event 가 export 에서 빠져요.
해결 — 360 으로 업그레이드 (사실상 한도 X) 하거나 event 자체를 줄여요 (debug event 제외).
사고 6: Retroactive update 의 reporting 불일치
원인 — 월요일에 본 "지난 주 매출" 이 수요일에 다른 값으로 보여요. 모바일 offline event 가 retroactive 로 들어와서 그래요.
해결 — 비즈니스 reporting 은 3일 전까지를 확정 기준으로 잡아요.
사고 7: Wildcard scan 의 비용 누락
원인 — events_* 를 _TABLE_SUFFIX 없이 쓰면 모든 history 테이블이 scan 돼요.
해결 — _TABLE_SUFFIX BETWEEN ... 을 반드시 걸어요. BQ 의 Query Cost Validator (쿼리 비용 검증기) 도 켜둬요.
사고 8: timezone 의 불일치
원인 — event_timestamp 는 UTC microsecond 고 event_date 도 UTC 라, 한국 시간 기준 분석 때 날짜가 어긋나요.
해결 — DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul') 로 KST 명시 conversion 을 해줘요.
사고 9: GA4 와 Firebase 의 BQ 분리 X
원인 — Firebase project 에 이미 BQ 가 연결돼 있고 GA4 와 통합한 뒤, 다른 BQ project 로 분리를 시도하는 경우예요.
해결 — 같은 project 안에서만 가능해요. 분리가 필요하면 Firebase 의 BQ 연결을 먼저 해제하고 GA4 에 새 project linking 을 걸어요.
사고 10: Dataset location 의 cross-region cost
원인 — BQ dataset 은 US 에, BI 도구는 asia-northeast3 에 있으면 cross-region transfer 비용이 붙어요.
해결 — 처음부터 같은 region 으로 맞춰요. Property linking 때 dataset_location 을 asia-northeast3 (서울) 로 명시해요.
운영 권장 패턴
Pattern 1: ETL 로 materialized view
-- Scheduled Query — 매일 새벽 5시 KST
CREATE OR REPLACE TABLE `our-project.ga_views.daily_user_summary`
PARTITION BY event_date
CLUSTER BY user_pseudo_id
AS
SELECT
PARSE_DATE('%Y%m%d', event_date) AS event_date,
user_pseudo_id,
user_id,
COUNT(*) AS event_count,
COUNTIF(event_name = 'page_view') AS page_views,
COUNTIF(event_name = 'purchase') AS purchase_count,
SUM(CASE WHEN event_name = 'purchase'
THEN (SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value')
ELSE 0 END) AS purchase_revenue,
MAX(device.category) AS device_category,
MAX(geo.country) AS country
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
GROUP BY event_date, user_pseudo_id, user_id
이렇게 만들면 분석가는 작은 view 만 query 하게 돼서 원본 비용이 확 줄어요.
Pattern 2: Looker Studio (Data Studio) 연결
Looker Studio:
→ BigQuery connector
→ SELECT * FROM ga_views.daily_user_summary (materialized view)
→ dashboard 자동 refresh
원본 events_* 를 직접 연결하면 비용 폭탄이라 절대 안 돼요. materialized view 만 연결해요.
Pattern 3: Reverse ETL — 마케팅 도구
[BQ events]
↓ (Hightouch · Census · RudderStack)
[Braze · Iterable · HubSpot · Salesforce]
↓
[이메일 · 푸시 · SMS 자동화]
GA4 의 audience 를 마케팅 도구로 직접 export 하기엔 한계가 있어서 BQ 를 경유해요.
Pattern 4: ML pipeline 입력
# BigQuery → Pandas → ML
from google.cloud import bigquery
import pandas as pd
client = bigquery.Client()
query = """
SELECT
user_pseudo_id,
COUNT(DISTINCT event_date) AS active_days,
COUNTIF(event_name = 'purchase') AS purchase_count,
SUM(IF(event_name = 'purchase',
(SELECT value.double_value FROM UNNEST(event_params) WHERE key = 'value'),
0)) AS lifetime_revenue
FROM `our-gcp-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260516'
GROUP BY user_pseudo_id
"""
df = client.query(query).to_dataframe()
# Churn 예측 ML model 학습
from sklearn.ensemble import RandomForestClassifier
# ...
Pattern 5: 비용 모니터
-- BQ 의 Information schema 로 query 비용 추적
SELECT
user_email,
query,
total_bytes_processed / POW(2, 40) AS tb_scanned,
total_bytes_processed / POW(2, 40) * 6.25 AS estimated_cost_usd,
start_time
FROM `our-gcp-project.region-asia-northeast3.INFORMATION_SCHEMA.JOBS`
WHERE start_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
AND statement_type = 'SELECT'
ORDER BY total_bytes_processed DESC
LIMIT 50
매주 비용 1위 query 를 확인해서 분석가한테 교정 + 비용 의식을 잡아줘요.
Pattern 6: Dry run 강제
# 분석가 query 의 dry run 검증
def estimate_cost(query: str):
config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
job = client.query(query, job_config=config)
tb_scanned = job.total_bytes_processed / (1024 ** 4)
cost_usd = tb_scanned * 6.25
return tb_scanned, cost_usd
tb, cost = estimate_cost(query)
if cost > 10:
raise Exception(f"Query 너무 비쌈: ${cost:.2f}, scan={tb:.2f}TB")
시험 직전 한 번 더 — BigQuery Export 함정 압축 노트
3 종류 Export
- Daily — 전날 오후 (보통 14~16시 KST), 무료, 1M event/일 한도 (Standard)
- Streaming — 수 분 내 실시간, $0.05/GB, 무제한, traffic source 24시간 lag
- Fresh Daily — 매일 오전 5시 KST, 360 Property 만
Table 구조
events_YYYYMMDD(daily)events_intraday_YYYYMMDD(streaming)- 한 event = 한 row
- 8 field 그룹 — event · user · params · properties · items · device · geo · traffic_source
Nested Record (UNNEST 필수)
event_params(parameter 의 array)user_properties(user property 의 array)items(e-commerce items 의 array)- value 의 4 type — string · int · double · float
UNNEST 패턴
- Sub-query —
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = '...') - CROSS JOIN —
FROM events_*, UNNEST(event_params) AS p - 다중 UNNEST 가 일반적 (items + event_params)
비용
- Storage active $0.02/GB/월 (90일 이내)
- Storage long-term $0.01/GB/월 (90일 이상)
- Query $6.25/TB scanned (asia-northeast3)
- Streaming $0.05/GB (약 60만 event = 1GB)
- 첫 10GB storage · 1TB query / 월 = 무료
Retroactive
- event timestamp 기준 2 캘린더일 + 당일 까지 update 가능
- 모바일 offline event 가 retroactive
- 확정 데이터 = 3일 전 까지 기준
Partition (date suffix)
_TABLE_SUFFIX필터 반드시SELECT * FROM events_*의 비용 폭탄 가장 큰 사고- clustering 자동 X → materialized view 활용
timezone
event_timestamp= UTC microsecondevent_date= UTC- 한국 분석 =
DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul')
사고
- SELECT * 비용 폭탄
- Streaming의 traffic source 빈값 (24h lag)
- Nested record 의 UNNEST 누락
- Value type 잘못 (int 대신 double)
- 1M event/일 한도 (Standard)
- Retroactive 의 reporting 불일치 (3일 전 기준)
- Wildcard scan 의 _TABLE_SUFFIX 누락
- timezone (UTC vs KST)
- Firebase 와 BQ project 통합 (분리 X)
- Dataset location 의 cross-region
패턴
- ETL materialized view (매일 새벽 5시)
- Looker Studio 의 view 연결 (원본 X)
- Reverse ETL (BQ → 마케팅 도구)
- ML pipeline 입력
- 비용 모니터 (INFORMATION_SCHEMA.JOBS)
- Dry run 강제 (분석가 교정)
공식 문서: BigQuery Export schema · BigQuery Export setup 에서 더 깊은 spec 을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 1편 — GA4 종합 · Event 기반 · 4 측정 방법
- 2편 — 데이터 모델 깊이 · User ID · Session · Custom Dimension
- 3편 — 측정 통합 4 방법 깊이 (gtag · GTM · Firebase · MP)
- 4편 — Event · Conversion · Audience 설계 깊이
- 5편 — Data API · Admin API · 외부 통합 깊이
다음 글: