# 한 행 = 한 이벤트로 펼친 평탄화 마트

GA4 이벤트를 평탄화한 핵심 마트 Event_Flat의 구조를 정리합니다. 한 행 = 한 이벤트라는 단위, 식별·세션·페이지·유입·기기 묶음으로 본 표준 컬럼 구성, event_params 배열을 평탄 컬럼으로 펼치는 변환 SQL, 실제 분석 쿼리, 그리고 어떤 파라미터를 컬럼으로 뺄지 고르는 기준까지 비개발자도 따라올 수 있게 정리합니다.

- 카테고리: BigQuery
- 소요 시간: 약 8분
- 난이도: 입문
- 업데이트: 2026.06.05
- 원문: /wiki/playbook/bigquery-checklist/what-is-event-flat-table

## 목차

- [한 행 = 한 이벤트](#grain)
- [표준 컬럼 + 커스텀 예시](#schema)
- [그냥 베껴 오지 않은 컬럼들](#notable)
- [제공 쿼리 3종 — 스키마·예약쿼리·백필](#queries)
- [Event_Flat로 답하는 질문들](#usage)
- [자주 묻는 질문](#faq)

`Event_Flat`은 GA4 이벤트를 [평탄화](/wiki/playbook/bigquery-checklist/why-flatten-ga4-bigquery-data)한 핵심 마트입니다 — raw `events_YYYYMMDD`에서 중첩 구조를 풀어 ‘**한 행 = 한 이벤트, 분석에 자주 쓰는 값은 각자 컬럼**’으로 만든 표죠. 이 글은 Event_Flat이 어떤 컬럼으로 구성되고, 그 테이블을 만들고 채우는 **스키마 생성·예약쿼리·백필** 쿼리 3종을 정리합니다. 각 컬럼의 의미를 문서로 관리하는 방법은 [테이블 정의서](/wiki/playbook/bigquery-checklist/why-table-definition-doc), 매일 자동 갱신하는 방법은 [예약 쿼리 자동 실행](/wiki/playbook/bigquery-checklist/how-to-trigger-scheduled-query-on-ga4-export) 글을 함께 보세요.

## 한 행 = 한 이벤트

Event_Flat의 한 행은 GA4 이벤트 하나입니다. raw에서는 한 이벤트가 ‘평탄 컬럼 + `event_params` 배열 안 여러 key/value 줄’로 흩어져 있었는데, Event_Flat에서는 그 배열을 미리 풀어 자주 쓰는 값을 제 컬럼에 펼쳐 둡니다. 테이블은 `date` 기준으로 파티션을 나누고, `event_name`·`session_id`·`user_pseudo_id`로 클러스터링해 자주 거는 필터가 빨라지도록 만듭니다.

| 구분 | raw `events_` | `Event_Flat` |
| --- | --- | --- |
| 한 이벤트의 모양 | 평탄 컬럼 + key/value 배열 줄 여러 개 | 딱 한 줄, 자주 쓰는 값이 각자 컬럼 |
| 파라미터 꺼내기 | `UNNEST(event_params)` 서브쿼리 필요 | 컬럼 이름으로 바로 `SELECT` |
| BI 도구 연결 | 중첩 컬럼이라 불가 | 평탄 컬럼이라 Looker Studio·Sheets 바로 연결 |
| 저장 단위 | 일별 테이블(`events_YYYYMMDD`) | `date` 파티션 하나의 큰 테이블 |

## 표준 컬럼 + 커스텀 예시

Event_Flat은 GA4 export 기본 필드를 분석하기 좋게 묶고 다듬은 표준 컬럼에, 사이트마다 실제로 수집하는 **커스텀 파라미터**를 몇 개 더해 구성합니다. 묶음별로 보면 다음과 같습니다(맨 아래 ‘커스텀’은 예시).

| 묶음 | 컬럼(타입) | 의미 |
| --- | --- | --- |
| 이벤트 | `event_name` (STRING) | 이벤트 이름 |
| 사용자·세션 | `user_id` (STRING) | 로그인 사용자 ID(있을 때만) |
| `user_pseudo_id` (STRING) | 기기·브라우저 단위 익명 사용자 ID |  |
| `session_id` (STRING) | `user_pseudo_id` + `ga_session_id`로 만든 세션 키 |  |
| `session_engaged` (INT64) | 참여 세션 플래그 |  |
| `session_number` (INT64) | 해당 사용자의 몇 번째 세션인지 |  |
| `visit_type` (STRING) | `session_number`로 파생: new_visit / return_visit |  |
| 기기 | `device_category` (STRING) | desktop / mobile / tablet |
| `device_os` (STRING) | 운영체제 |  |
| `device_browser` (STRING) | 브라우저 |  |
| `host_name` (STRING) | 호스트명 |  |
| 지역 | `country` · `city` · `region` (STRING) | 국가 · 도시 · 지역 |
| 이커머스 요약 | `ecommerce_transaction_id` (STRING) | 거래 ID |
| `purchase_revenue` (NUMERIC) | 이벤트 단위 구매 금액 |  |
| 날짜·시간 | `date` (DATE) | 이벤트 날짜(Asia/Seoul, 파티션 기준) |
| `date_time` (DATETIME) | 이벤트 일시(Asia/Seoul) |  |
| 유입(트래픽 소스) | `cross_channel_source/medium/campaign` · `channel_group` | 크로스채널 기준 유입(기본 권장) |
| `manual_source/medium/campaign/content/term` | 수동(UTM) 기준 유입 |  |
| 페이지 | `content_group` (+ `_1~4depth`) | 콘텐츠 그룹 원본과 `\|` 기준 분해 |
| `page_title` · `page_url` (STRING) | 페이지 제목 · 전체 URL |  |
| `landing_page_url` (STRING) | 진입 페이지(page_view + entrances=1일 때만) |  |
| `engagement_time_sec` (FLOAT64) | 참여 시간(초) |  |
| 커스텀(예시) | `login_status` (STRING) | 로그인 상태 — `event_params.login_status` |
| `content_type` (STRING) | 콘텐츠 유형 — `event_params.content_type` |  |
| `membership_level` (STRING) | 회원 등급 — `user_properties.membership_level` |  |

> **팁**
>
> **‘커스텀(예시)’ 세 컬럼은 사이트마다 다릅니다.** 우리가 실제로 수집하는 `event_params`·`user_properties` 중 분석에 필요한 것만 컬럼으로 더하면 됩니다. 추가한 커스텀 컬럼의 의미·원천·예시값은 [테이블 정의서](/wiki/playbook/bigquery-checklist/why-table-definition-doc)에 한 줄씩 적어 둡니다.

## 그냥 베껴 오지 않은 컬럼들

Event_Flat은 raw 값을 단순 복사만 하지 않습니다. 몇몇 컬럼은 ‘분석하기 좋게’ 한 번 가공합니다 — 이 가공 규칙을 알아 두면 숫자를 오해하지 않습니다.

- **`session_id`는 파생 컬럼** — `ga_session_id`는 사용자끼리 겹칠 수 있어, `user_pseudo_id`와 이어 붙여 진짜 고유 세션 키로 만듭니다. ‘세션 수’는 이 컬럼으로 셉니다.
- **유입은 cross_channel을 기본으로** — 유입 소스/매체는 보통 `cross_channel_*`·`channel_group`을 우선 씁니다. `manual_*`는 UTM 직접 세팅값을 확인할 때 보조로 봅니다. `(not set)`은 `(direct)`/`(none)`으로 정규화해 빈칸 혼란을 줄입니다.
- **`date`·`date_time`은 Asia/Seoul 기준** — raw의 UTC 마이크로초 타임스탬프를 한국 시간으로 변환해, ‘오늘’의 경계가 한국 날짜와 맞도록 합니다.
- **`landing_page_url`은 진입 시점만** — `page_view` 이벤트이면서 `entrances = 1`인 행에서만 채워, 세션의 첫 페이지를 구분합니다(나머지는 null).
- **`content_group`은 깊이별로 분해** — `|`로 이어진 콘텐츠 그룹 문자열을 `_1depth ~ _4depth`로 쪼개, 카테고리 단계별 집계를 바로 할 수 있게 합니다.

## 제공 쿼리 3종 — 스키마·예약쿼리·백필

Event_Flat 운영에는 세 가지 쿼리가 필요합니다. `[PROJECT_ID]`·`[DATASET_ID]`·속성 ID는 자기 환경 값으로 바꿔 쓰세요.

| 쿼리 | 언제 | 역할 |
| --- | --- | --- |
| **① 스키마 생성** | 최초 1회 | 빈 테이블을 컬럼·타입·파티션·설명과 함께 만든다 |
| **② 예약쿼리(일간)** | 매일 자동 | 전날(D) 하루치를 추출해 적재한다 |
| **③ 백필** | 최초 구축·재처리 | 과거 구간을 한 번에 채운다 |

### ① 스키마 생성

빈 테이블을 만들면서 각 컬럼에 `description`을 박아 둡니다 — 이 설명은 사람과 [AI 에이전트가 함께 읽는 맥락](/wiki/playbook/bigquery-checklist/why-table-definition-doc)이 됩니다.

**Event_Flat.schema.sql**
```sql
CREATE OR REPLACE TABLE `[PROJECT_ID].[DATASET_ID].Event_Flat` (
  event_name STRING OPTIONS(description="GA4 이벤트 이름"),

  -- 사용자 / 세션
  user_id STRING OPTIONS(description="로그인 사용자 ID(있을 때만)"),
  user_pseudo_id STRING OPTIONS(description="익명 사용자 ID"),
  session_id STRING OPTIONS(description="user_pseudo_id + ga_session_id로 만든 세션 키"),
  session_engaged INT64 OPTIONS(description="참여 세션 플래그"),
  session_number INT64 OPTIONS(description="사용자의 세션 순번"),
  visit_type STRING OPTIONS(description="new_visit / return_visit"),

  -- 기기 / 지역
  device_category STRING, device_os STRING, device_browser STRING, host_name STRING,
  country STRING, city STRING, region STRING,

  -- 이커머스 요약 / 날짜
  ecommerce_transaction_id STRING OPTIONS(description="거래 ID"),
  purchase_revenue NUMERIC OPTIONS(description="이벤트 단위 구매 금액"),
  date DATE OPTIONS(description="이벤트 날짜(Asia/Seoul)"),
  date_time DATETIME OPTIONS(description="이벤트 일시(Asia/Seoul)"),

  -- 유입
  cross_channel_source STRING, cross_channel_medium STRING, cross_channel_campaign STRING,
  channel_group STRING OPTIONS(description="크로스채널 기본 채널 그룹"),
  manual_source STRING, manual_medium STRING, manual_campaign STRING, manual_content STRING, manual_term STRING,

  -- 페이지
  content_group STRING,
  content_group_1depth STRING, content_group_2depth STRING,
  content_group_3depth STRING, content_group_4depth STRING,
  page_title STRING, page_url STRING,
  landing_page_url STRING OPTIONS(description="진입 페이지(entrances=1)"),
  engagement_time_sec FLOAT64 OPTIONS(description="참여 시간(초)"),

  -- 커스텀 (예시: 우리 taxonomy에 맞게 추가)
  login_status STRING OPTIONS(description="로그인 상태. event_params.login_status"),
  content_type STRING OPTIONS(description="콘텐츠 유형. event_params.content_type"),
  membership_level STRING OPTIONS(description="회원 등급. user_properties.membership_level")
)
PARTITION BY date
CLUSTER BY event_name, session_id, user_pseudo_id
OPTIONS(description = "GA4 이벤트 단위 평탄화 마트");
```

### ② 예약쿼리(일간 적재)

전날(D = `@run_date - 1일`) 하루치만 읽어 `WRITE_APPEND`로 적재합니다. 재실행 시 중복을 막기 위해 함수가 `date = D` 파티션을 먼저 지운 뒤 실행합니다([예약 쿼리 자동 실행](/wiki/playbook/bigquery-checklist/how-to-trigger-scheduled-query-on-ga4-export) 참고). 맨 아래 ‘커스텀(예시)’ 세 줄이 위 스키마의 커스텀 컬럼을 채우는 부분입니다.

**Event_Flat.transform.sql (예약쿼리)**
```sql
INSERT INTO `[PROJECT_ID].[DATASET_ID].Event_Flat`
SELECT
  event_name,

  -- 사용자 / 세션
  user_id,
  user_pseudo_id,
  CONCAT(user_pseudo_id,
    CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key='ga_session_id') AS STRING)) AS session_id,
  (SELECT value.int_value FROM UNNEST(event_params) WHERE key='session_engaged')   AS session_engaged,
  (SELECT value.int_value FROM UNNEST(event_params) WHERE key='ga_session_number') AS session_number,
  CASE
    WHEN (SELECT value.int_value FROM UNNEST(event_params) WHERE key='ga_session_number') = 1 THEN 'new_visit'
    WHEN (SELECT value.int_value FROM UNNEST(event_params) WHERE key='ga_session_number') > 1 THEN 'return_visit'
  END AS visit_type,

  -- 기기 / 지역
  device.category AS device_category,
  device.operating_system AS device_os,
  device.web_info.browser AS device_browser,
  device.web_info.hostname AS host_name,
  geo.country AS country, geo.city AS city, geo.region AS region,

  -- 이커머스 요약 / 날짜 (Asia/Seoul)
  ecommerce.transaction_id AS ecommerce_transaction_id,
  ecommerce.purchase_revenue AS purchase_revenue,
  DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul')     AS date,
  DATETIME(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul') AS date_time,

  -- 유입 (cross_channel 우선, (not set) 정규화)
  CASE WHEN session_traffic_source_last_click.cross_channel_campaign.source='(not set)'
       THEN '(direct)' ELSE session_traffic_source_last_click.cross_channel_campaign.source END AS cross_channel_source,
  CASE WHEN session_traffic_source_last_click.cross_channel_campaign.medium='(not set)'
       THEN '(none)'   ELSE session_traffic_source_last_click.cross_channel_campaign.medium END AS cross_channel_medium,
  session_traffic_source_last_click.cross_channel_campaign.campaign_name         AS cross_channel_campaign,
  session_traffic_source_last_click.cross_channel_campaign.primary_channel_group AS channel_group,
  session_traffic_source_last_click.manual_campaign.source       AS manual_source,
  session_traffic_source_last_click.manual_campaign.medium       AS manual_medium,
  session_traffic_source_last_click.manual_campaign.campaign_name AS manual_campaign,
  session_traffic_source_last_click.manual_campaign.content      AS manual_content,
  session_traffic_source_last_click.manual_campaign.term         AS manual_term,

  -- 페이지
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key='content_group') AS content_group,
  SPLIT((SELECT value.string_value FROM UNNEST(event_params) WHERE key='content_group'),'|')[SAFE_OFFSET(0)] AS content_group_1depth,
  SPLIT((SELECT value.string_value FROM UNNEST(event_params) WHERE key='content_group'),'|')[SAFE_OFFSET(1)] AS content_group_2depth,
  SPLIT((SELECT value.string_value FROM UNNEST(event_params) WHERE key='content_group'),'|')[SAFE_OFFSET(2)] AS content_group_3depth,
  SPLIT((SELECT value.string_value FROM UNNEST(event_params) WHERE key='content_group'),'|')[SAFE_OFFSET(3)] AS content_group_4depth,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key='page_title')    AS page_title,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key='page_location') AS page_url,
  CASE WHEN event_name='page_view'
        AND (SELECT value.int_value FROM UNNEST(event_params) WHERE key='entrances')=1
       THEN (SELECT value.string_value FROM UNNEST(event_params) WHERE key='page_location') END AS landing_page_url,
  (SELECT value.int_value FROM UNNEST(event_params) WHERE key='engagement_time_msec') / 1000 AS engagement_time_sec,

  -- 커스텀 (예시: taxonomy에 맞게 추가)
  (SELECT value.string_value FROM UNNEST(event_params)    WHERE key='login_status')     AS login_status,
  (SELECT value.string_value FROM UNNEST(event_params)    WHERE key='content_type')     AS content_type,
  (SELECT value.string_value FROM UNNEST(user_properties) WHERE key='membership_level') AS membership_level
FROM `[PROJECT_ID].analytics_[PROPERTY_ID].events_*`
WHERE _table_suffix = FORMAT_DATE('%Y%m%d', DATE_SUB(@run_date, INTERVAL 1 DAY));
```

### ③ 백필(과거 구간 한 번에)

최초 구축이나 컬럼 추가 후 과거를 다시 채울 때 씁니다. **SELECT 본문은 예약쿼리와 완전히 동일**하고, 날짜 조건만 ‘하루’에서 ‘구간’으로 바꾸면 됩니다. 재실행 멱등성을 위해 그 구간을 먼저 지웁니다.

**Event_Flat.backfill.sql**
```sql
-- 1) 대상 구간 삭제 (재실행해도 중복되지 않도록)
DELETE FROM `[PROJECT_ID].[DATASET_ID].Event_Flat`
WHERE date BETWEEN '2026-01-01' AND '2026-05-31';

-- 2) 같은 SELECT 본문으로 구간을 한 번에 적재
INSERT INTO `[PROJECT_ID].[DATASET_ID].Event_Flat`
SELECT
  /* ↑ '② 예약쿼리'와 완전히 동일한 SELECT 본문 (event_name … membership_level) */
  ...
FROM `[PROJECT_ID].analytics_[PROPERTY_ID].events_*`
WHERE _table_suffix BETWEEN '20260101' AND '20260531';
```

> **주의**
>
> **세 쿼리·정의서·컬럼 description은 한 몸으로 움직여야 합니다.** 커스텀 컬럼을 하나 더하면 ① 스키마에 컬럼+description 추가 → ② 예약쿼리·③ 백필 SELECT에 추출식 추가 → 정의서에 한 줄, 이 네 곳을 함께 고칩니다. 한 곳이라도 빠지면 마트가 어긋납니다.

## Event_Flat로 답하는 질문들

평탄 컬럼만 골라 읽으니, raw에서 길었던 질문이 짧아집니다. 날짜 조건은 파티션 컬럼 `date`로 겁니다.

**어제 페이지뷰 상위 5개 URL**
```sql
SELECT page_url, COUNT(*) AS pv
FROM `[PROJECT_ID].[DATASET_ID].Event_Flat`
WHERE date = DATE_SUB(CURRENT_DATE('Asia/Seoul'), INTERVAL 1 DAY)
  AND event_name = 'page_view'
GROUP BY page_url
ORDER BY pv DESC
LIMIT 5;
```

**회원 등급별 참여 세션 수(최근 7일) — 커스텀 컬럼 활용**
```sql
SELECT
  membership_level,
  COUNT(DISTINCT IF(session_engaged = 1, session_id, NULL)) AS engaged_sessions
FROM `[PROJECT_ID].[DATASET_ID].Event_Flat`
WHERE date >= DATE_SUB(CURRENT_DATE('Asia/Seoul'), INTERVAL 7 DAY)
GROUP BY membership_level
ORDER BY engaged_sessions DESC;
```

`UNNEST` 없이 컬럼 이름만으로 끝나고, 세션은 `session_id`, 유입은 `channel_group`, 커스텀은 `membership_level`처럼 ‘한 번 정한 정의’를 모두가 공유합니다. 같은 테이블을 Looker Studio에 연결하면 비 SQL 사용자도 같은 기준의 차트를 만듭니다.

> **팁**
>
> **이벤트 단위는 Event_Flat, 상품 단위는 Item_Performance.** 한 이벤트에 여러 상품이 들어오는 `items` 배열은 ‘한 행 = 한 이벤트’ 구조와 결이 달라, 상품 단위 분석은 별도 마트 **Item_Performance**로 분리합니다(다음 글).

## 자주 묻는 질문

### 커스텀 컬럼은 어떻게 더하나요?

네 곳을 함께 고칩니다 — ① 스키마에 컬럼과 `description`을 추가하고, ② 예약쿼리와 ③ 백필 SELECT에 `(SELECT value.string_value FROM UNNEST(event_params) WHERE key='...')` 같은 추출식을 더하고, 마지막으로 정의서에 의미·원천·예시값을 한 줄 적습니다. `user_properties`는 `UNNEST(user_properties)`에서 꺼냅니다.

### 스키마·예약쿼리·백필을 매번 손으로 맞춰야 하나요?

SELECT 본문이 셋 다 같아서, 이벤트 택소노미만 정해 두면 세 쿼리를 한꺼번에 만들어 주는 마트 생성 도구를 쓸 수 있습니다. 손으로 관리할 땐 ‘예약쿼리 SELECT를 기준으로 백필은 날짜 조건만 바꾼다’는 원칙만 지키면 어긋날 일이 줄어듭니다.

### 세션 수는 어떤 컬럼으로 세나요?

`session_id`입니다. raw의 `ga_session_id`는 사용자끼리 겹칠 수 있어, `user_pseudo_id`와 이어 붙인 `session_id`를 고유 세션 키로 씁니다. 참여 세션만 세려면 `session_engaged = 1` 조건을 더합니다.

### purchase_revenue와 상품별 매출은 같은 건가요?

아닙니다. `purchase_revenue`는 **이벤트(구매) 단위** 금액입니다. 어떤 상품이 얼마치 팔렸는지 같은 **상품 단위** 매출은 `items`를 푼 **Item_Performance** 마트의 `item_revenue`로 봅니다.

### Event_Flat과 GA4 화면 숫자가 조금 다른데요?

GA4 UI는 표본추출·모델링·고유 추정 등 자체 처리를 거쳐 BigQuery raw를 그대로 집계한 값과 늘 같지는 않습니다. Event_Flat은 ‘우리가 정의한 규칙대로 일관되게 센 값’이고, GA4 UI와 1:1로 맞추는 게 아니라 ‘매번 같은 기준으로 비교 가능한 값’을 얻는 게 목표입니다.

## Navigation

- [전체 플레이북 Markdown sitemap](/wiki/playbook/sitemap.md)

### BigQuery

- [BigQuery](/wiki/playbook/bigquery-checklist)
- [BigQuery 온보딩](/wiki/playbook/bigquery-checklist/bigquery-onboarding-guide)
- [BigQuery 인증 설정](/wiki/playbook/bigquery-checklist/how-to-set-up-bigquery-authentication)
- [BigQuery 데이터셋·테이블 설계](/wiki/playbook/bigquery-checklist/bigquery-dataset-and-table-design-guide)
- [BigQuery 쿼리·비용 제어](/wiki/playbook/bigquery-checklist/bigquery-query-and-cost-control-guide)
- [BigQuery 쓰기·MERGE·CDC](/wiki/playbook/bigquery-checklist/bigquery-data-write-and-cdc-guide)
- [BigQuery 코드 예제 모음](/wiki/playbook/bigquery-checklist/bigquery-code-examples)
- [BigQuery로 들어오는 GA4 데이터](/wiki/playbook/bigquery-checklist/what-is-ga4-data-in-bigquery)
- [BigQuery Studio 인터페이스 이해하기](/wiki/playbook/bigquery-checklist/what-is-bigquery-studio-interface)
- [BigQuery 예상 비용](/wiki/playbook/bigquery-checklist/how-to-estimate-bigquery-costs)
- [무료 버전(샌드박스) 해제해야 하는 이유](/wiki/playbook/bigquery-checklist/why-upgrade-from-bigquery-sandbox)
- [GA4 BigQuery 데이터를 왜 평탄화해야 하나](/wiki/playbook/bigquery-checklist/why-flatten-ga4-bigquery-data)
- [Log Router란?](/wiki/playbook/bigquery-checklist/what-is-log-router)
- [Pub/Sub이란?](/wiki/playbook/bigquery-checklist/what-is-pubsub)
- [Cloud Functions이란?](/wiki/playbook/bigquery-checklist/what-is-cloud-function-and-run)
- [GA4 export 시점에 예약 쿼리 자동 실행하기](/wiki/playbook/bigquery-checklist/how-to-trigger-scheduled-query-on-ga4-export)
- [테이블 정의서는 왜 필요한가](/wiki/playbook/bigquery-checklist/why-table-definition-doc)
- [Event_Flat 테이블 이해하기](/wiki/playbook/bigquery-checklist/what-is-event-flat-table) (현재 문서)
- [Item_Performance 테이블 이해하기](/wiki/playbook/bigquery-checklist/what-is-item-performance-table)
