GA 입문 6편 — BigQuery Export 깊이 (schema · UNNEST · 비용)

2026-05-17Google Analytics 입문에서 운영까지

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편 — BigQuery Export 깊이 (schema · UNNEST · 비용)

이 글은 Google Analytics 입문에서 운영까지 시리즈 6편이에요. 5편 Data API · Admin APIreport APIsampling · 한도 · 미리 정의된 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 · itemsRepeated 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 로 추출돼요. purchasevalue 는 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 microsecond
  • event_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 을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!