유저 행동 로그 1,600만 건을 분석하여 개인화 추천 시스템을 설계한 과제입니다.
유저 행동 로그와 상품 정보를 분석하여 구매 전환의 인사이트를 파악합니다.
EDA 인사이트를 바탕으로 실행 가능한 개인화 추천 시스템을 설계합니다.
| 테이블 | 규모 | 주요 컬럼 |
|---|---|---|
products |
33,236건 | category, price, fulfillment_type, is_advertisement |
attributes |
288,152건 | key (10종), value |
reviews |
21,745건 | rating, count, contents (요약 텍스트) |
embeddings |
33,098건 | vector (64차원) |
users |
581,278건 | age, gender |
logs |
16,030,051건 | event_type, event_time, user_id, product_id |
| 항목 | 내용 |
|---|---|
| 로그 기간 | 단일 날짜(1일치) — 장기 선호 변화·계절성 반영 불가 |
결측치: users.age / gender |
약 20% 결측 — 선택적 입력 또는 검증용 의도적 제거로 판단, 행동 기반 분석에는 포함 |
결측치: reviews |
전체 상품 중 약 65%는 리뷰 없음 — TF-IDF 클러스터를 근사값으로 활용 |
embeddings 커버리지 |
33,098 / 33,236 상품 (약 0.4% 미생성) — 해당 상품은 인기도 fallback 처리 |
| 세션 식별자 부재 | order_id / session_id 없음 — 30분 윈도우로 세션 근사 (상세: 트러블슈팅) |
impression ──────────────────────────── 약 9.4M
│
▼ CTR ≈ 높음
click ──────────────
│
▼ CVR ≈ 1~2%
purchase ──
→ 클릭 이후 전환율이 병목. 추천 시스템의 우선 목표는 CVR 개선.
| # | 가설 | 검증 결과 | 추천 시스템 시사점 |
|---|---|---|---|
| 1 | 광고 CTR↑, CVR 중립 | 채택 | 광고 여부는 랭킹 피처로 주의 사용 |
| 2 | 빠른 배송(express) → CVR↑ | 채택 | 배송 방식을 랭킹 피처로 활용 |
| 3 | 성별·연령별 패턴 상이 | 채택 | 인구통계 기반 세그먼트 개인화 필요 |
| 4 | 카테고리별 CVR 차이 | 채택 | 카테고리 인기도를 후보 생성에 반영 |
| 5 | 할인율 30-50% 구간 최적 | 채택 | 할인율을 랭킹 피처에 포함 |
| 6 | 고가 상품일수록 리뷰 의존↑ | 채택 | 가격대별 리뷰 가중치 차등 적용 |
| 7 | 속성별 전환율 차이 존재 | 채택 | color·fit·material을 피처로 활용 |
| 8 | 헤비유저 ≠ 일반유저 | 채택 | 세그먼트별 추천 전략 차별화 |
| 9 | 장바구니 후 빠른 구매 결정 | 채택 | 리타게팅 타이밍 최적화 |
| 10 | 리뷰 클러스터별 CVR 차이 | 채택 | 리뷰 텍스트를 상품 표현에 반영 |
| 11 | 세트 구매 패턴 존재 | 채택 | Cross-selling 추천에 활용 |
dataset/
├── logs.parquet (16M행 — DuckDB로 집계)
├── products.parquet.txt
├── users.parquet.txt
├── reviews.parquet.txt
├── attributes.parquet.txt
└── embeddings.parquet.txt
│
▼
┌────────────────────────────────────────┐
│ reco_eda_model.ipynb │
│ │
│ 0. 환경 설정 & 데이터 로딩 │
│ 1. 기본 탐색 (결측치·분포·퍼널) │
│ 2. 전처리 — 로그 집계 (DuckDB PIVOT) │
│ 3. 가설 검증 (11개) │
│ 4. EDA 인사이트 요약 │
│ ───────────────────────────── │
│ 5. 추천 시스템 문제 정의 │
│ 6. 피처 엔지니어링 │
│ 7. 유저 세그먼테이션 (K-Means) │
│ 8. 추천 모델 설계 │
│ 8.1 Retrieval — 임베딩 유사도 │
│ 8.2 Retrieval — 협업 필터링 │
│ 8.3 Ranking — LightGBM 설계 │
│ 9. 평가 방법론 │
│ 10. 시스템 아키텍처 │
│ 11. 결론 및 한계점 │
│ 12. 모델 저장 │
└────────────────────────────────────────┘
│
▼
models/
├── config.json (하이퍼파라미터·피처 메타)
├── user_scaler.pkl (StandardScaler)
├── user_kmeans.pkl (유저 세그먼테이션 K=4)
├── review_tfidf.pkl (TF-IDF Vectorizer)
├── review_kmeans.pkl (리뷰 클러스터링 K=5)
└── model.pth (임베딩 행렬 + 클러스터 파라미터)
[유저 요청]
│
▼
┌──────────────────────┐
│ Stage 1: Retrieval │ 33K 상품 → 수백 후보
│ │
│ ① 임베딩 유사도 │ product embedding (64d)
│ 코사인 유사도 │ → 유사 상품 Top-K
│ │
│ ② 협업 필터링 │ user vector = Σ(상품 임베딩
│ 가중 평균 임베딩 │ × 행동 가중치)
│ purchase=5 │ → 코사인 유사도로 후보 생성
│ cart=3 │
│ wishlist=2 │
│ click=1 │
│ │
│ ③ 인기도 Fallback │ 신규 유저 / cold start
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Stage 2: Ranking │ 후보 → 최종 순위 (Top-N)
│ LightGBM │
│ │
│ 유저 피처 │ age, gender, segment,
│ │ click_to_purchase_rate
│ 상품 피처 │ category, price, discount_rate,
│ │ fulfillment_type, rating
│ 상호작용 피처 │ embedding cosine similarity,
│ │ 과거 클릭·위시리스트 여부
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Re-ranking │ 비즈니스 규칙 적용
│ │ (다양성 보장, 광고 제한 등)
└──────────────────────┘
행동 기반 K-Means 클러스터링으로 유저를 4개 세그먼트로 분류합니다.
| 피처 | 설명 |
|---|---|
| total_clicks | 총 클릭 수 |
| total_purchases | 총 구매 수 |
| total_wishlist | 총 위시리스트 추가 수 |
| total_cart | 총 장바구니 추가 수 |
| avg_price | 평균 관심 상품 가격 |
| avg_discount_rate | 평균 할인율 |
| click_to_purchase_rate | 클릭 대비 구매 전환율 |
pandas로 16M행을 메모리에 올리면 수십 GB가 필요합니다. DuckDB를 사용하면 parquet 파일을 직접 SQL로 집계하여 결과만 pandas DataFrame으로 가져옵니다. PIVOT 구문 하나로 (user_id, product_id) × event_type 교차 집계를 처리했습니다.
# 집계 결과만 메모리로 가져옴 (전체 16M행 로드 불필요)
log_pivot = con.execute(f"""
PIVOT (
SELECT user_id, product_id, event_type, COUNT(*) as cnt
FROM '{LOGS_PATH}'
GROUP BY user_id, product_id, event_type
)
ON event_type USING SUM(cnt)
""").df()단순히 "구매 이력이 많은 상품"을 추천하는 popularity-based 방식 대신 Two-Stage 구조를 선택한 이유는:
- 확장성: 33K 상품 × 581K 유저를 실시간 full-pair 스코어링하면 초당 처리 불가
- 품질: Retrieval로 후보를 줄인 뒤 Ranking에서 정밀한 개인화 적용
- 유연성: 각 Stage를 독립적으로 개선 가능
단순 클릭보다 구매가 훨씬 강한 구매 의사 신호입니다. 임베딩 기반 협업 필터링에서 유저 벡터를 상품 임베딩의 가중 평균으로 구성할 때, 이 가중치가 유저 선호를 얼마나 잘 표현하는지 직결됩니다. EDA에서 확인된 이벤트 퍼널의 전환 강도를 근거로 설정했습니다.
리뷰 텍스트를 개별 상품의 속성으로 사용하면 데이터 희소성 문제가 있습니다. 리뷰가 없는 상품도 많기 때문입니다. TF-IDF로 텍스트를 벡터화한 뒤 K-Means로 클러스터를 만들면, 리뷰가 없는 상품도 상품 속성(카테고리, 가격 등)으로 클러스터를 근사할 수 있습니다.
age, gender 결측은 회원가입 시 선택 입력이거나, 품질 검증 목적으로 의도적으로 제거된 것으로 판단했습니다. 이 유저들의 행동 로그는 유효하므로:
- 인구통계 기반 분석: 결측 제외
- 행동 기반 분석 (CVR, 클러스터링): 결측 포함 (
unknown그룹으로 분류)
문제: "동시 구매" 패턴(가설 11)을 분석하려면 세션 단위 묶음이 필요한데 데이터에 order_id, session_id가 없었습니다.
해결: 동일 유저의 구매 이벤트를 시간 기준 30분 윈도우로 묶어 세션을 근사했습니다. DuckDB에서 LAG 윈도우 함수로 이전 구매 시각과의 차이를 계산한 뒤, 30분 초과이거나 첫 구매이면 새 세션으로 분류했습니다.
WITH lagged AS (
SELECT *,
LAG(purchase_time) OVER (PARTITION BY user_id ORDER BY purchase_time) as prev_time
FROM purchases
),
sessioned AS (
SELECT *,
SUM(CASE WHEN EXTRACT(EPOCH FROM (purchase_time - prev_time)) > 1800
OR prev_time IS NULL THEN 1 ELSE 0 END)
OVER (PARTITION BY user_id ORDER BY purchase_time) as session_id
FROM lagged
)문제: DuckDB에서 SUM(...) OVER 안에 LAG(...) OVER를 중첩하면 파싱 오류가 발생했습니다.
해결: CTE(Common Table Expression)를 단계별로 분리하여 LAG 계산 → SUM 계산을 순차적으로 처리했습니다. (위 코드 참고)
Python 3.14+, uv 기반 프로젝트입니다.
# 의존성 설치
uv sync
# Jupyter 노트북 실행
uv run jupyter lab reco_eda_model.ipynb재현 가능성을 위해 uv.lock에 정확한 버전이 고정되어 있습니다. uv sync 실행 시 동일한 환경이 재구성됩니다.
| 패키지 | 버전 | 용도 |
|---|---|---|
duckdb |
≥ 1.4.4 | 1,600만 건 로그 고속 집계 |
pandas |
≥ 3.0.0 | 데이터 처리 |
numpy |
≥ 2.4.2 | 수치 연산 |
scikit-learn |
≥ 1.8.0 | K-Means, TF-IDF, 코사인 유사도 |
matplotlib |
≥ 3.10.8 | 시각화 |
seaborn |
≥ 0.13.2 | 통계 시각화 |
pyarrow |
≥ 23.0.1 | parquet 파일 I/O |
| Python | ≥ 3.14 | 런타임 |
models/
├── config.json 하이퍼파라미터, 피처 구성, 메타 정보
├── user_scaler.pkl 유저 피처 StandardScaler
├── user_kmeans.pkl 유저 세그먼테이션 KMeans (k=4)
├── review_tfidf.pkl 리뷰 TF-IDF Vectorizer (max_features=3000)
├── review_kmeans.pkl 리뷰 클러스터링 KMeans (k=5)
└── model.pth 임베딩 행렬 + 클러스터 파라미터
EDA에서 도출된 인사이트를 추천 시스템에 반영했을 때 기대할 수 있는 효과와, 우선순위별 개선 방향을 제안합니다.
| 우선순위 | 제안 | 근거 |
|---|---|---|
| 1순위 | 클릭→구매 전환율 개선을 핵심 KPI로 설정 | CTR은 높지만 CVR이 1~2%로 병목 구간 확인 |
| 2순위 | express 배송 상품을 랭킹 가중치에 반영 | 배송 방식별 CVR 차이 유의미 (가설 2) |
| 3순위 | 연령·성별 세그먼트별 추천 전략 분리 | 인구통계별 행동 패턴 상이 (가설 3) |
| 4순위 | 장바구니 추가 후 30분 이내 리타게팅 강화 | 구매 결정까지 시간이 짧음 (가설 9) |
| 5순위 | Cross-selling 추천 모듈 별도 구성 | 세트 구매 패턴 존재 (가설 11) |
| 한계점 | 영향 | 개선 방안 |
|---|---|---|
| 1일치 로그만 사용 가능 | 계절성·장기 선호 변화 반영 불가 | 다기간 로그 축적 후 재학습 |
session_id 부재 |
정확한 세션 단위 분석 제한 | 30분 윈도우 근사 사용, 실서비스에서 세션 ID 수집 필요 |
| LightGBM 학습 데이터 미구비 | Ranking 모델은 설계만 수행, 실제 학습 불가 | 레이블(purchase 여부) 포함된 충분한 로그 필요 |
| Cold start 대응 제한 | 신규 유저·신규 상품 추천 정확도 낮음 | 인구통계 기반 초기 추천 + 온보딩 선호 수집 강화 |
| 온라인 평가 미수행 | A/B 테스트 없이 오프라인 지표만 검증 | 실서비스 배포 전 단계적 A/B 테스트 필수 |
| 단계 | 지표 | 설명 |
|---|---|---|
| 오프라인 | Precision@K, Recall@K, NDCG@K | 추천 목록의 관련성 평가 |
| 오프라인 | Coverage | 전체 상품 중 추천에 노출되는 비율 |
| 온라인 (A/B) | CTR, CVR | 실제 클릭·구매 전환율 변화 |
| 온라인 (A/B) | Revenue per User | 유저당 매출 변화 |
| 다양성 | Intra-list Diversity | 추천 목록 내 카테고리 다양성 |
pandas로 메모리에 올리기 버거운 대용량 로그를 SQL로 집계하는 패턴이 얼마나 강력한지 체감했습니다. PIVOT, LAG, 윈도우 함수를 활용해 복잡한 집계를 단 몇 줄로 처리할 수 있었고, 결과만 pandas로 가져오는 방식이 실무에서도 매우 유용하겠다고 느꼈습니다.
"데이터를 탐색한다"는 접근보다 "이 가설이 맞는지 검증한다"는 접근이 분석의 방향성을 훨씬 명확하게 만들어줬습니다. 11개 가설을 먼저 세우고 데이터로 검증하는 과정에서, 인사이트가 Task #2 추천 시스템 설계에 자연스럽게 연결되는 흐름이 만들어졌습니다.
1일치 로그만으로는 장기 선호도 변화나 계절성을 반영할 수 없고, session_id 부재로 정확한 세션 분석이 제한됩니다. 완벽한 데이터가 갖춰지지 않은 상황에서도 합리적인 근사(30분 윈도우 세션)와 명확한 한계 명시가 중요하다는 것을 배웠습니다.