이 글은 누구를 위한 것인가
- "어떤 광고가 진짜 효과가 있는지" 모르는 마케팅 팀
- 라스트 클릭만 보다가 상위 퍼널 채널이 다 죽은 경험이 있는 팀
- 광고 예산을 데이터 기반으로 재배분하고 싶은 이커머스 팀
들어가며
고객은 첫 광고를 보고 바로 구매하지 않는다. 인스타그램 광고 → 네이버 검색 → 가격비교 → 이메일 → 구매. 이 여정에서 마지막 클릭(이메일)에만 100% 기여를 주면 인스타그램 광고를 끊어버리는 실수를 한다.
이 글은 bluefoxdev.kr의 마케팅 어트리뷰션 가이드 를 참고하여 작성했습니다.
1. 어트리뷰션 모델 비교
[어트리뷰션 모델 유형]
라스트 클릭 (Last Click):
기여: 마지막 터치포인트에 100%
장점: 단순, 구현 쉬움
단점: 상위 퍼널 채널 과소평가
퍼스트 클릭 (First Click):
기여: 첫 터치포인트에 100%
장점: 인지 단계 파악
단점: 전환 기여 채널 무시
선형 (Linear):
기여: 모든 터치포인트에 균등 배분
장점: 공정, 단순
단점: 실제 기여도 반영 못함
시간 감소 (Time Decay):
기여: 구매에 가까울수록 높은 가중치
장점: 전환 기여 채널 강조
단점: 인지/고려 채널 과소평가
데이터 드리븐 (Data-Driven):
기여: ML로 실제 기여도 계산
장점: 가장 정확
단점: 충분한 데이터 필요 (100+ 전환/채널)
[권장]
데이터 적을 때: 선형 모델
데이터 충분할 때: 데이터 드리븐
2. 터치포인트 수집 구현
from datetime import datetime, timedelta
import uuid
class AttributionTracker:
def track_touchpoint(
self,
session_id: str,
user_id: str | None,
channel: str, # "organic_search", "paid_search", "instagram", "email"
campaign: str | None,
source: str, # utm_source
medium: str, # utm_medium
):
"""터치포인트 기록"""
touchpoint = {
"id": str(uuid.uuid4()),
"session_id": session_id,
"user_id": user_id,
"channel": channel,
"campaign": campaign,
"source": source,
"medium": medium,
"timestamp": datetime.utcnow().isoformat(),
}
# Redis에 30일 보관
key = f"journey:{user_id or session_id}"
r.lpush(key, json.dumps(touchpoint))
r.expire(key, 86400 * 30)
def get_conversion_path(self, user_id: str, order_id: str) -> list[dict]:
"""주문 완료 시 고객 여정 조회"""
key = f"journey:{user_id}"
raw = r.lrange(key, 0, -1) # 최대 50개
touchpoints = [json.loads(tp) for tp in raw]
touchpoints.reverse() # 시간 순서로
return touchpoints
def calculate_linear_attribution(touchpoints: list[dict], order_value: float) -> list[dict]:
"""선형 어트리뷰션: 균등 배분"""
if not touchpoints:
return []
credit_per_touch = order_value / len(touchpoints)
return [
{
"channel": tp["channel"],
"campaign": tp["campaign"],
"credit": credit_per_touch,
"credit_pct": 100 / len(touchpoints),
}
for tp in touchpoints
]
def calculate_time_decay_attribution(touchpoints: list[dict], order_value: float) -> list[dict]:
"""시간 감소 어트리뷰션: 최근일수록 높은 비중"""
if not touchpoints:
return []
# 최근 터치포인트일수록 2배 가중치 (7일 반감기)
weights = []
for i, tp in enumerate(touchpoints):
days_before = len(touchpoints) - i - 1
weight = 2 ** (-days_before / 7)
weights.append(weight)
total_weight = sum(weights)
return [
{
"channel": tp["channel"],
"campaign": tp["campaign"],
"credit": order_value * w / total_weight,
"credit_pct": w / total_weight * 100,
}
for tp, w in zip(touchpoints, weights)
]
3. 채널별 ROI 대시보드
def generate_channel_roi_report(
start_date: str,
end_date: str,
model: str = "linear" # "last_click", "linear", "time_decay"
) -> dict:
"""채널별 기여 수익 및 ROI 계산"""
# 기간 내 완료 주문의 어트리뷰션 데이터 조회
attributions = db.fetch("""
SELECT a.channel, a.campaign,
SUM(a.credit) as attributed_revenue,
COUNT(DISTINCT a.order_id) as attributed_orders
FROM order_attributions a
JOIN orders o ON o.id = a.order_id
WHERE o.completed_at BETWEEN $1 AND $2
AND a.model = $3
GROUP BY a.channel, a.campaign
""", start_date, end_date, model)
# 채널별 광고비 조회
ad_costs = get_ad_costs_by_channel(start_date, end_date)
report = []
for row in attributions:
channel = row["channel"]
cost = ad_costs.get(channel, 0)
revenue = row["attributed_revenue"]
roas = revenue / cost if cost > 0 else None
report.append({
"channel": channel,
"attributed_revenue": revenue,
"ad_cost": cost,
"roas": roas,
"attributed_orders": row["attributed_orders"],
})
return sorted(report, key=lambda x: -(x["roas"] or 0))
마무리
어트리뷰션 모델 변경 하나로 마케팅 전략이 완전히 달라질 수 있다. 라스트 클릭에서 선형 모델로 바꾸면 브랜드 광고와 SNS 상위 퍼널의 기여도가 보이기 시작한다. 데이터 드리븐 모델로 가기 전에 선형 모델로 먼저 시작하고, 채널별 기여 수익을 보면서 예산을 재배분하라.