이벤트 데이터 수집과 전처리 ─
dataLayer부터 B2B 퍼널 분석까지
1. 이벤트 데이터란 무엇인가
1-1. 이벤트 데이터 vs 일반 데이터
일반 데이터(예: 회원 테이블)는 현재 상태를 저장한다. 반면 이벤트 데이터는 "누가 무엇을 언제 했는가"를 기록한다. 이 차이가 그로스마케터에게 이벤트 데이터가 중요한 이유다.
| 구분 | 일반 데이터 | 이벤트 데이터 |
|---|---|---|
| 저장 방식 | 현재 상태 (스냅샷) | 행동 로그 (타임라인) |
| 질문 | "누가 있다" | "누가 무엇을 했다" |
| 예시 | user_id, name, age | event_time, event_type, user_id |
1-2. 이벤트 데이터의 3요소
이벤트 데이터는 Who / What / When 세 가지 요소로 구성된다. 이 구조를 이해해야 퍼널 분석, 리타겟팅, A/B 테스트 평가 등 실무 작업을 제대로 수행할 수 있다.
1-3. 이벤트 타입 상세
이번 실습 데이터셋에는 4가지 이벤트가 있으며, 각각 실무에서의 의미가 다르다.
상품 상세 페이지 조회. 가장 많이 발생하며, 리타겟팅·개인화 추천의 핵심 신호다.
장바구니 담기. 구매 의도 신호. view 대비 cart 비율 = 장바구니 전환율.
장바구니에서 상품 제거. 구매 이탈 신호. 이 이벤트가 많은 상품은 가격·UX 문제 점검 필요.
결제 완료. 최종 목표 이벤트. cart 대비 purchase 비율 = 결제 전환율.
1-4. 그로스마케터가 이벤트 데이터로 하는 일
| 업무 | 필요한 이벤트 데이터 |
|---|---|
| 퍼널 분석 | view → cart → purchase 이벤트 흐름 |
| 리타겟팅 광고 | cart 이벤트 발생 후 purchase 없는 사용자 |
| 개인화 추천 | 특정 카테고리 view 이벤트가 많은 사용자 |
| 이탈 분석 | 마지막 이벤트 이후 접속 없는 사용자 |
| A/B 테스트 평가 | 그룹별 이벤트 발생 횟수 및 전환율 비교 |
2. dataLayer와 GTM이란 무엇인가
2-1. 왜 dataLayer가 필요했나
디지털 마케팅 초창기에는 사용자 행동을 수집하려면 개발자가 각 페이지 코드를 직접 수정해야 했다. 마케팅 도구가 바뀔 때마다 코드를 전부 다시 짜야 하는 문제가 있었다.
2-2. dataLayer → GTM → GA4 전체 흐름
2-3. dataLayer 실제 코드 구조
// ① 상품 상세 페이지 조회 (view_item)
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'view_item',
'ecommerce': {
'items': [{
'item_id': '5300797',
'item_name': 'Samsung Galaxy S10',
'item_category': 'electronics.smartphone',
'item_brand': 'samsung',
'price': 559.00
}]
}
});
// ② 장바구니 담기 (add_to_cart)
dataLayer.push({
'event': 'add_to_cart',
'ecommerce': {
'items': [{ 'item_id': '5300797', 'price': 559.00, 'quantity': 1 }]
}
});
// ③ 구매 완료 (purchase)
dataLayer.push({
'event': 'purchase',
'ecommerce': {
'transaction_id': 'TXN-20191101-8821',
'value': 559.00,
'currency': 'USD',
'items': [{ 'item_id': '5300797', 'price': 559.00, 'quantity': 1 }]
}
});
2-4. dataLayer 키 → CSV 컬럼 매핑
| dataLayer 키 | CSV 컬럼 | 예시 값 |
|---|---|---|
event |
event_type |
view, cart, purchase |
items[0].item_id |
product_id |
5300797 |
items[0].item_category |
category_code |
electronics.smartphone |
items[0].price |
price |
559.00 |
| 사용자 식별 시스템 | user_id |
512428238 |
| 서버 타임스탬프 | event_time |
2019-11-01 00:00:00 UTC |
purchase 이벤트가 들어오면 GA4와 메타픽셀에 동시에 보내라"는 규칙을 코딩 없이 설정할 수 있게 해준다. 마케팅 도구가 바뀌어도 개발 코드는 그대로, 설정만 변경하면 된다.3. GA4에서 데이터를 확인하고 추출하는 법
GA4 데모 계정 접속 → 탐색 분석 → CSV 내보내기
purchase, add_to_cart 등 이벤트 확인GA4 CSV를 pandas로 불러오기
import pandas as pd
# GA4 CSV는 상단 8행이 메타 정보 → skiprows=8 필요
ga4_df = pd.read_csv('Analytics 탐색 분석 데이터.csv', skiprows=8)
print(ga4_df.shape)
print(ga4_df.head())
print(ga4_df.dtypes)
.head(10)).4. 실습 데이터셋 소개 : Olist Marketing Funnel
4-1. 왜 B2B 리드 퍼널 데이터인가
이번 실습은 B2B 리드 퍼널로 관점을 바꿔본다. 그로스마케터는 SaaS, 에듀테크, 부동산 플랫폼 등 B2B 구조의 서비스를 다룰 일도 많기 때문이다.
4-2. Olist 데이터셋 구성
| 파일명 | 설명 | 행 수 |
|---|---|---|
olist_marketing_qualified_leads_dataset.csv |
MQL (마케팅 자격 리드) 목록 | 8,000건 |
olist_closed_deals_dataset.csv |
실제 계약이 성사된 딜 정보 | 842건 |
5. 전처리 실습 : B2B 리드 퍼널 데이터
환경 세팅: 라이브러리 임포트 및 데이터 불러오기
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
pd.set_option('display.max_columns', None)
pd.set_option('display.float_format', '{:.2f}'.format)
# MQL 파일 : 리드 획득 정보
mql = pd.read_csv('olist_marketing_qualified_leads_dataset.csv')
# Closed Deals 파일 : 계약 성사 정보
closed = pd.read_csv('olist_closed_deals_dataset.csv')
print(f"MQL 행 수: {len(mql):,} / 열 수: {mql.shape[1]}")
print(f"Closed Deals 행 수: {len(closed):,} / 열 수: {closed.shape[1]}")
목적: 컬럼 타입, 결측치, 데이터 분포를 파악해 전처리 방향 결정
# MQL 결측치 현황
missing_mql = mql.isnull().sum()
missing_mql_pct = (missing_mql / len(mql) * 100).round(1)
print(pd.DataFrame({
'결측치 수': missing_mql,
'결측치 비율(%)': missing_mql_pct
}))
# Closed Deals 결측치 현황
missing_cd = closed.isnull().sum()
missing_cd_pct = (missing_cd / len(closed) * 100).round(1)
print(pd.DataFrame({
'결측치 수': missing_cd,
'결측치 비율(%)': missing_cd_pct
}))
.isnull()과 함께 쓰면 True(=결측치)의 개수를 컬럼별로 합산한다. True는 1, False는 0으로 계산되기 때문에 합계 = 결측치 수가 된다..round(2)면 둘째 자리, .round(0)이면 정수로 반올림.has_company, declared_monthly_revenue는 결측치 90% 이상이다. 오류가 아니라 리드가 폼에 선택적으로 입력한 항목들이기 때문. 결측치 비율을 보고 어떤 컬럼을 분석에 쓸지 판단해야 한다.목적: 날짜 문자열을 datetime으로 변환하고 분석용 파생 컬럼(년/월/분기/주차) 생성
# 날짜 타입 변환 (문자열 → datetime)
mql['first_contact_date'] = pd.to_datetime(mql['first_contact_date'])
closed['won_date'] = pd.to_datetime(closed['won_date'])
# 수집 기간 확인
print(f"MQL 수집 기간: {mql['first_contact_date'].min().date()} ~ {mql['first_contact_date'].max().date()}")
# MQL 파생 컬럼 생성
mql['contact_year'] = mql['first_contact_date'].dt.year
mql['contact_month'] = mql['first_contact_date'].dt.month
mql['contact_quarter'] = mql['first_contact_date'].dt.quarter
mql['contact_week'] = mql['first_contact_date'].dt.isocalendar().week.astype(int)
# Closed Deals 파생 컬럼
closed['won_year'] = closed['won_date'].dt.year
closed['won_month'] = closed['won_date'].dt.month
closed['won_quarter'] = closed['won_date'].dt.quarter
.dt는 날짜 관련 속성에 접근하는 접두어다. 이렇게 추출한 값으로 "월별 리드 유입 추이", "분기별 계약 성사 추이" 같은 분석이 가능해진다.isocalendar().week는 특수한 타입으로 반환되기 때문에 연산을 쉽게 하려면 정수로 바꿔주는 것이 좋다.목적: 불규칙하게 입력된 origin 값을 분석 가능한 형태로 표준화
# 현황 파악 — 값별 분포 확인
print(mql['origin'].value_counts())
print(f"결측치: {mql['origin'].isnull().sum()}건")
# 결측치와 'unknown' → 'not_identified'로 통일
mql['origin'] = mql['origin'].fillna('not_identified')
mql['origin'] = mql['origin'].replace('unknown', 'not_identified')
# 세부 채널 → 상위 채널로 매핑
channel_map = {
'organic_search': 'organic_search',
'paid_search': 'paid_search',
'social': 'social',
'email': 'email',
'direct_traffic': 'direct',
'other_publicities': 'other',
'other': 'other',
'not_identified': 'not_identified'
}
mql['origin_clean'] = mql['origin'].map(channel_map).fillna('other')
.fillna('other')로 처리한다.origin은 GTM의 utm_source 값과 동일한 개념이다. 어떤 채널에서 유입된 리드가 가장 잘 전환되는지 파악하는 것이 그로스마케터의 핵심 업무다.목적: 8,000개 리드 전체에 계약 성사 여부를 붙여 퍼널 전체 뷰 생성
# LEFT JOIN: mql_id 기준으로 두 파일 결합
# MQL 전체 기준, 계약이 성사된 경우에만 closed 정보가 추가됨
funnel = mql.merge(closed, on='mql_id', how='left')
print(f"결합 후 전체 행 수: {len(funnel):,}")
# 계약 성사 여부 컬럼 생성
# won_date가 있으면(not null이면) True, 없으면 False
funnel['is_won'] = funnel['won_date'].notna()
print(funnel['is_won'].value_counts())
print(f"전체 전환율: {funnel['is_won'].mean()*100:.1f}%")
# 출력 결과:
# False 7158
# True 842
# 전체 전환율: 10.5%
.isnull()의 반대. 여기서는 won_date가 있으면(= 계약이 성사됐으면) True로 표시해 is_won 컬럼을 만든다.is_won.mean() = 전환율이 된다.목적: 리드별 영업 사이클 일수 계산, 이상값(음수) 제거, 분포 시각화
# 성사된 리드만 필터링
won = funnel[funnel['is_won'] == True].copy()
# 영업 사이클 = 계약 성사일 - 최초 컨택일
won['sales_cycle_days'] = (won['won_date'] - won['first_contact_date']).dt.days
print(won['sales_cycle_days'].describe())
# 음수 값 확인 (데이터 오류) 및 제거
negative = won[won['sales_cycle_days'] < 0]
print(f"음수 오류: {len(negative)}건 → 제거")
won = won[won['sales_cycle_days'] >= 0]
# 시각화: 영업 사이클 분포
fig, ax = plt.subplots(figsize=(10, 5))
ax.hist(won['sales_cycle_days'], bins=50, color='steelblue', alpha=0.8)
ax.axvline(won['sales_cycle_days'].median(), color='red', linestyle='--',
label=f"중앙값: {won['sales_cycle_days'].median():.0f}일")
ax.set_title('영업 사이클 분포')
ax.legend()
plt.show()
.copy()를 붙이지 않으면 원본 데이터와 연결된 뷰(view)가 만들어져, 나중에 값을 수정할 때 경고가 발생한다..dt.days를 붙여야 우리가 쓰기 편한 숫자(일수)가 된다.목적: 채널별 MQL 수·성사 수·전환율·평균 영업 사이클을 비교해 마케팅 예산 배분 근거 도출
# 채널별 전환율 집계
channel_funnel = (
funnel.groupby('origin_clean')
.agg(
total_leads=('mql_id', 'count'),
won_leads=('is_won', 'sum')
)
.reset_index()
)
channel_funnel['conversion_rate'] = (
channel_funnel['won_leads'] / channel_funnel['total_leads'] * 100
).round(2)
# 채널별 평균 영업 사이클 추가
channel_cycle = (
won.groupby('origin_clean')['sales_cycle_days']
.mean().round(0).reset_index()
.rename(columns={'sales_cycle_days': 'avg_cycle_days'})
)
channel_funnel = channel_funnel.merge(channel_cycle, on='origin_clean', how='left')
channel_funnel = channel_funnel.sort_values('conversion_rate', ascending=False)
# 시각화: 채널별 전환율 vs MQL 수
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].barh(channel_funnel['origin_clean'], channel_funnel['conversion_rate'], color="#CF1441")
axes[0].set_title('채널별 리드 전환율 (%)')
axes[1].barh(channel_funnel['origin_clean'], channel_funnel['total_leads'], color="#BDC334")
axes[1].set_title('채널별 MQL 수')
plt.tight_layout()
plt.show()
'count'는 개수, 'sum'은 합계, 'mean'은 평균.bar()는 세로 막대, barh()는 수평 막대. 채널 이름처럼 텍스트가 긴 항목을 y축에 놓을 때 가독성이 좋다.목적: SDR별 담당 건수·평균 사이클을 비교해 우수 SDR 행동 패턴 파악
# SDR 성과 집계 (성사 리드 기준)
sdr_perf = (
won.groupby('sdr_id')
.agg(
total_leads=('mql_id', 'count'),
avg_cycle=('sales_cycle_days', 'mean'),
median_cycle=('sales_cycle_days', 'median')
)
.round(0)
.reset_index()
)
sdr_perf = sdr_perf.sort_values('total_leads', ascending=False)
print(sdr_perf.head(10).to_string(index=False))
mean)은 극단값(아주 긴 계약 등)에 크게 흔들리지만, 중앙값은 데이터를 크기순으로 나열했을 때 정중앙에 위치한 값이라 이상치에 강하다. SDR 성과를 볼 때 평균과 중앙값을 같이 보면 더 정확하다.6. 전처리 완료 데이터 저장 및 정리
6-1. 전처리 결과 요약
| 번호 | 작업 내용 | 핵심 메서드 |
|---|---|---|
| 01 | 두 파일 기본 정보 및 결측치 구조 파악 | .isnull().sum(), .describe() |
| 02 | 날짜 컬럼 변환 및 파생 컬럼 생성 | pd.to_datetime(), .dt.quarter |
| 03 | 유입 채널(origin) 정제 및 표준화 | .fillna(), .replace(), .map() |
| 04 | 두 파일 LEFT JOIN 퍼널 통합 | .merge(how='left'), .notna() |
| 05 | 영업 사이클 기간 계산 및 오류 제거 | .dt.days, 음수 필터링 |
| 06 | 채널별 전환율 분석 및 시각화 | .groupby().agg(), .barh() |
| 07 | SDR 담당자별 성과 분석 | .groupby().agg(), .sort_values() |
6-2. 최종 데이터 저장
# 전처리 완료된 통합 퍼널 데이터 저장
funnel.to_csv('olist_funnel_clean.csv', index=False)
print(f"저장 완료: olist_funnel_clean.csv")
print(f"최종 행 수: {len(funnel):,}")
print(f"최종 컬럼: {list(funnel.columns)}")
index=False를 붙인다.이벤트 데이터는 "누가 있다"가 아니라 "누가 무엇을 했다"를 기록하는 데이터다. dataLayer → GTM → GA4로 이어지는 수집 구조를 이해하면 데이터가 어디서, 어떻게 만들어지는지 알 수 있다.
B2B 퍼널 분석의 핵심은 두 파일의 LEFT JOIN이다. MQL 전체를 기준으로 계약 성사 여부를 붙인 다음, 채널별 전환율과 영업 사이클을 같이 봐야 진짜 인사이트가 나온다. MQL 수가 많다고 좋은 채널이 아니다.
'부트캠프' 카테고리의 다른 글
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 31일차_260422 (1) | 2026.04.22 |
|---|---|
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 30일차_260421 (0) | 2026.04.21 |
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 28일차_260417 (1) | 2026.04.17 |
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 27일차_260416 (0) | 2026.04.16 |
| 멋쟁이사자처럼 부트캠프 그로스마케팅 4기 26일차_260415 (1) | 2026.04.15 |