Files
ds_new_user_analy/ds_new_user_analy.py
2025-08-27 01:10:50 +09:00

803 lines
28 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
던전 스토커즈 신규 유저 리텐션 분석 스크립트
hack-detector의 고급 최적화 기법을 적용한 대용량 데이터 처리
주요 최적화 기법:
1. Composite Aggregation - 메모리 효율적 코호트 선정
2. Streaming Pattern - 활동 시간 계산 최적화
3. NDJSON + 백오프 재시도 - msearch 배치 처리 안정화
4. Future Pattern - 비동기 병렬 처리
5. Memory Optimization - 스트리밍 CSV 작성
작성자: Claude Code
기획서 기반: DS-new_users-analy.md
"""
import os
import csv
import json
import time
import argparse
import threading
from datetime import datetime, timedelta, timezone
from collections import defaultdict
from typing import Dict, List, Optional, Tuple, Generator, Any
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
import pandas as pd
from tqdm import tqdm
from opensearchpy import OpenSearch
# ==============================================================================
# 1. 설정 및 상수
# ==============================================================================
# OpenSearch 연결 설정 (기획서에서 업데이트됨)
OPENSEARCH_CONFIG = {
"host": "ds-opensearch.oneunivrs.com",
"port": 9200,
"auth": {
"username": "admin",
"password": "DHp5#r#GYQ9d"
},
"use_ssl": True,
"verify_certs": False,
"timeout": 60,
"max_retries": 3,
"headers": {"Connection": "close"}
}
# 한국 표준시
KST = timezone(timedelta(hours=9))
# 성능 최적화 설정
DEFAULT_BATCH_SIZE = 1000
DEFAULT_MAX_WORKERS = 6
DEFAULT_COMPOSITE_SIZE = 1000 # composite aggregation 페이지 크기
DEFAULT_TIMEOUT = 120
SCROLL_TIMEOUT = "5m"
SESSION_GAP_MINUTES = 5 # 세션 분리 기준 (5분)
MAX_SESSION_HOURS = 3 # 최대 세션 길이 (3시간)
# 출력 파일 설정
OUTPUT_DIR = r"E:\DS_Git\DS_data_center\DS Log 분석"
# 전역 타이머 제어
stop_timer_event = threading.Event()
# ==============================================================================
# 2. OpenSearch 연결 및 유틸리티
# ==============================================================================
def create_opensearch_client() -> Optional[OpenSearch]:
"""OpenSearch 클라이언트 생성 (hack-detector 방식)"""
print("[INFO] OpenSearch 클러스터에 연결 중...")
try:
client = OpenSearch(
hosts=[{
"host": OPENSEARCH_CONFIG['host'],
"port": OPENSEARCH_CONFIG['port'],
"scheme": "https" if OPENSEARCH_CONFIG['use_ssl'] else "http"
}],
http_auth=(
OPENSEARCH_CONFIG['auth']['username'],
OPENSEARCH_CONFIG['auth']['password']
),
use_ssl=OPENSEARCH_CONFIG['use_ssl'],
verify_certs=OPENSEARCH_CONFIG['verify_certs'],
ssl_show_warn=False,
timeout=OPENSEARCH_CONFIG['timeout'],
max_retries=OPENSEARCH_CONFIG['max_retries'],
retry_on_timeout=True,
headers=OPENSEARCH_CONFIG['headers']
)
if not client.ping():
raise ConnectionError("클러스터에 PING을 보낼 수 없습니다.")
print("[SUCCESS] OpenSearch 연결 성공!")
return client
except Exception as e:
print(f"[ERROR] OpenSearch 연결 실패: {e}")
return None
def exponential_backoff_retry(func, *args, **kwargs) -> Any:
"""지수 백오프 재시도 패턴 (hack-detector 기법)"""
for delay in [1, 2, 4, 8, 16]:
try:
return func(*args, **kwargs)
except Exception as e:
if delay == 16: # 마지막 시도
raise e
print(f"[WARNING] 재시도 중... {delay}초 대기 (오류: {str(e)[:100]})")
time.sleep(delay)
# ==============================================================================
# 3. 핵심 알고리즘 - Composite Aggregation을 활용한 코호트 선정
# ==============================================================================
def get_new_user_cohort_optimized(
client: OpenSearch,
start_time: str,
end_time: str,
page_size: int = DEFAULT_COMPOSITE_SIZE
) -> Dict[str, Dict]:
"""
Composite Aggregation을 활용한 메모리 효율적 신규 유저 코호트 선정
hack-detector의 고급 기법 적용
"""
print(f"\n[1단계] 신규 유저 코호트 선정 (Composite Aggregation)")
print(f" - 분석 기간: {start_time} ~ {end_time}")
cohort = {}
after_key = None
# Composite aggregation 쿼리
base_query = {
"size": 0,
"query": {
"bool": {
"filter": [
{"range": {"@timestamp": {"gte": start_time, "lt": end_time}}}
]
}
},
"aggs": {
"new_users": {
"composite": {
"size": page_size,
"sources": [
{"auth_id": {"terms": {"field": "auth.id.keyword"}}},
{"uid": {"terms": {"field": "uid.keyword"}}}
]
},
"aggs": {
"first_login": {"min": {"field": "@timestamp"}},
"user_info": {
"top_hits": {
"size": 1,
"sort": [{"@timestamp": {"order": "asc"}}],
"_source": ["country", "body.device_mod"]
}
}
}
}
}
}
total_users = 0
while True:
query = base_query.copy()
if after_key:
query["aggs"]["new_users"]["composite"]["after"] = after_key
try:
response = exponential_backoff_retry(
client.search,
index="ds-logs-live-login_comp",
body=query,
request_timeout=DEFAULT_TIMEOUT,
track_total_hits=False # 성능 최적화
)
buckets = response["aggregations"]["new_users"]["buckets"]
if not buckets:
break
for bucket in buckets:
auth_id = bucket["key"]["auth_id"]
uid = bucket["key"]["uid"]
first_login_utc = bucket["first_login"]["value_as_string"]
# 사용자 정보 추출
user_hit = bucket["user_info"]["hits"]["hits"][0]["_source"]
cohort[uid] = {
'auth_id': auth_id,
'first_login_utc': first_login_utc,
'first_login_dt': datetime.fromisoformat(first_login_utc.replace('Z', '+00:00')),
'country': user_hit.get('country', 'N/A'),
'device': user_hit.get('body', {}).get('device_mod', 'N/A')
}
total_users += 1
# 다음 페이지 키 확인
after_key = response["aggregations"]["new_users"].get("after_key")
if not after_key:
break
except Exception as e:
print(f"[ERROR] 코호트 선정 중 오류: {e}")
break
print(f" - [SUCCESS] 총 {total_users}명의 신규 유저 코호트 확정")
return cohort
# ==============================================================================
# 4. Active Hours 계산 - 스트리밍 방식 (Generator Pattern)
# ==============================================================================
def calculate_active_hours_streaming(
client: OpenSearch,
uids: List[str],
cohort_data: Dict[str, Dict]
) -> Dict[str, int]:
"""
스트리밍 방식으로 활동 시간 계산
Generator 패턴으로 메모리 사용량 최소화
"""
def stream_user_events(uid: str) -> Generator[Dict, None, None]:
"""개별 유저의 이벤트를 스트리밍으로 처리"""
user_info = cohort_data.get(uid)
if not user_info:
return
first_login_dt = user_info['first_login_dt']
d0_end_dt = first_login_dt + timedelta(hours=24)
# 해당 유저의 D+0 이벤트만 스캔
query = {
"query": {
"bool": {
"filter": [
{"term": {"uid.keyword": uid}},
{"range": {"@timestamp": {
"gte": user_info['first_login_utc'],
"lt": d0_end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
}}}
]
}
}
}
try:
from opensearchpy.helpers import scan
for doc in scan(
client,
query=query,
index="ds-logs-live-*",
scroll=SCROLL_TIMEOUT,
_source=["@timestamp", "type"]
):
source = doc['_source']
event_dt = datetime.fromisoformat(source['@timestamp'].replace('Z', '+00:00'))
if first_login_dt <= event_dt < d0_end_dt:
yield {
"time": event_dt,
"type": source.get('type', '').lower()
}
except Exception:
# 오류 시 빈 generator 반환
pass
results = {}
for uid in uids:
events = list(stream_user_events(uid))
if len(events) < 2:
results[uid] = 0
continue
# 세션 기반 활동 시간 계산
events.sort(key=lambda x: x['time'])
total_active_seconds = 0
i = 0
while i < len(events) - 1:
current_event = events[i]
next_event = events[i + 1]
# 세션 간격 체크 (5분 이상 차이나면 다른 세션)
time_diff = next_event['time'] - current_event['time']
if time_diff <= timedelta(minutes=SESSION_GAP_MINUTES):
# 최대 세션 길이 제한
session_duration = min(
time_diff.total_seconds(),
MAX_SESSION_HOURS * 3600
)
total_active_seconds += session_duration
i += 1
results[uid] = int(total_active_seconds)
return results
# ==============================================================================
# 5. NDJSON + 백오프 재시도를 활용한 msearch 배치 처리
# ==============================================================================
def build_msearch_queries(uids: List[str], cohort: Dict[str, Dict]) -> List[str]:
"""
msearch용 NDJSON 쿼리 생성
hack-detector의 NDJSON 직접 생성 기법 적용
"""
queries = []
# 분석할 지표 정의 (기획서 기반)
metrics_config = {
"retention_d1": {
"index": "ds-logs-live-login_comp",
"time_range": "d1", # 24-48시간
"filters": []
},
"tutorial_entry": {
"index": "ds-logs-live-tutorial_entry",
"time_range": "d0",
"filters": [{"nested": {"path": "body", "query": {"term": {"body.action.keyword": "Start"}}}}]
},
"tutorial_completed": {
"index": "ds-logs-live-log_tutorial",
"time_range": "d0",
"filters": [
{"nested": {"path": "body", "query": {"bool": {"must": [
{"term": {"body.action_type.keyword": "Complet"}},
{"term": {"body.stage_type.keyword": "result"}}
]}}}}
]
},
"dungeon_entry_count": {
"index": "ds-logs-live-survival_sta",
"time_range": "d0",
"filters": []
},
"dungeon_escape_count": {
"index": "ds-logs-live-survival_end",
"time_range": "d0",
"filters": [{"nested": {"path": "body", "query": {"term": {"body.result": 1}}}}]
},
"monster_kill_count": {
"index": "ds-logs-live-survival_end",
"time_range": "d0",
"agg_field": "body.play_stats.monster_kill_cnt"
},
"player_kill_count": {
"index": "ds-logs-live-player_kill",
"time_range": "d0",
"target_field": "body.instigator_uid"
},
"matching_complete_count": {
"index": "ds-logs-live-matching_complete",
"time_range": "d0",
"filters": []
},
"friend_add_count": {
"index": "ds-logs-live-friend",
"time_range": "d0",
"filters": [{"nested": {"path": "body", "query": {"bool": {"must": [
{"term": {"body.oper_type": 0}},
{"term": {"body.friend_type": 0}}
]}}}}]
}
}
for uid in uids:
user_data = cohort[uid]
first_login_dt = user_data['first_login_dt']
# 시간 범위 정의
d0_start = user_data['first_login_utc']
d0_end = (first_login_dt + timedelta(hours=24)).strftime('%Y-%m-%dT%H:%M:%SZ')
d1_start = d0_end
d1_end = (first_login_dt + timedelta(hours=48)).strftime('%Y-%m-%dT%H:%M:%SZ')
for metric_name, config in metrics_config.items():
# 시간 범위 선택
if config["time_range"] == "d0":
time_filter = {"range": {"@timestamp": {"gte": d0_start, "lt": d0_end}}}
else: # d1
time_filter = {"range": {"@timestamp": {"gte": d1_start, "lt": d1_end}}}
# 사용자 식별 필터
user_filter = {"bool": {"should": [
{"term": {"uid.keyword": uid}},
{"term": {"auth.id.keyword": user_data['auth_id']}}
], "minimum_should_match": 1}}
# 쿼리 구성
query_filters = [user_filter, time_filter]
# 추가 필터 적용
if "filters" in config:
query_filters.extend(config["filters"])
# 특별한 필드 처리 (player_kill의 경우)
if "target_field" in config and config["target_field"] == "body.instigator_uid":
query_filters.append({"nested": {"path": "body", "query": {"term": {"body.instigator_uid.keyword": uid}}}})
query_body = {
"size": 0 if "agg_field" not in config else 1000,
"query": {"bool": {"filter": query_filters}},
"track_total_hits": False
}
# Aggregation이 필요한 경우
if "agg_field" in config:
query_body["aggs"] = {
"total": {"sum": {"field": config["agg_field"]}}
}
# NDJSON 형태로 추가
queries.append(json.dumps({"index": config["index"]}, ensure_ascii=False))
queries.append(json.dumps(query_body, ensure_ascii=False))
return queries
def execute_msearch_with_backoff(client: OpenSearch, queries: List[str]) -> List[Dict]:
"""
NDJSON + 지수 백오프 재시도로 msearch 실행
hack-detector의 안정화 기법 적용
"""
# NDJSON 문자열 생성
body_ndjson = "\n".join(queries) + "\n"
# 지수 백오프로 재시도
response = exponential_backoff_retry(
client.msearch,
body=body_ndjson,
request_timeout=60
)
return response.get('responses', [])
# ==============================================================================
# 6. Future Pattern을 활용한 병렬 처리 최적화
# ==============================================================================
def process_user_batch_optimized(
client: OpenSearch,
batch_uids: List[str],
cohort: Dict[str, Dict]
) -> List[Dict]:
"""
최적화된 배치 처리 함수
Future Pattern + 스트리밍 + NDJSON 기법 결합
"""
# 1. 활동 시간 계산 (스트리밍 방식)
active_hours_map = calculate_active_hours_streaming(client, batch_uids, cohort)
# 2. msearch 쿼리 생성 및 실행 (NDJSON + 백오프)
msearch_queries = build_msearch_queries(batch_uids, cohort)
msearch_responses = execute_msearch_with_backoff(client, msearch_queries)
# 3. 결과 집계
batch_results = []
metrics_per_user = 9 # 정의된 지표 수
for idx, uid in enumerate(batch_uids):
try:
user_data = cohort[uid]
user_responses = msearch_responses[idx * metrics_per_user : (idx + 1) * metrics_per_user]
# 기본 정보
result = {
'uid': uid,
'auth_id': user_data['auth_id'],
'nickname': 'N/A', # TODO: nickname 조회 로직 추가
'first_login_time': user_data['first_login_utc'],
'retention_status': 'Retained_d0', # 기본값
'country': user_data['country'],
'device': user_data['device'],
'active_seconds': active_hours_map.get(uid, 0)
}
# msearch 결과 파싱
metrics = [
'retention_d1', 'tutorial_entry', 'tutorial_completed',
'dungeon_entry_count', 'dungeon_escape_count', 'monster_kill_count',
'player_kill_count', 'matching_complete_count', 'friend_add_count'
]
for i, metric in enumerate(metrics):
response = user_responses[i]
if 'error' in response:
result[metric] = 0
continue
hits_total = response.get('hits', {}).get('total', {}).get('value', 0)
if metric == 'retention_d1':
result['retention_status'] = 'Retained_d1' if hits_total > 0 else 'Retained_d0'
result[metric] = 1 if hits_total > 0 else 0
elif metric == 'monster_kill_count':
agg_value = response.get('aggregations', {}).get('total', {}).get('value', 0)
result[metric] = int(agg_value) if agg_value else 0
else:
result[metric] = hits_total
batch_results.append(result)
except Exception as e:
print(f" - ⚠️ UID '{uid}' 처리 중 오류: {e}")
return batch_results
def process_cohort_parallel(
client: OpenSearch,
cohort: Dict[str, Dict],
batch_size: int,
max_workers: int
) -> List[Dict]:
"""
Future Pattern을 활용한 병렬 처리
동적 청크 크기 조정 + 실패 재처리
"""
# 동적 배치 크기 조정 (사용자 수 기반)
user_count = len(cohort)
if user_count < 1000:
adjusted_batch_size = min(batch_size, 100)
else:
adjusted_batch_size = batch_size
print(f"\n[2단계] 병렬 배치 처리 시작 (배치크기: {adjusted_batch_size}, 워커: {max_workers})")
# UID 리스트를 청크로 분할
uid_list = list(cohort.keys())
chunks = [uid_list[i:i + adjusted_batch_size] for i in range(0, len(uid_list), adjusted_batch_size)]
all_results = []
failed_chunks = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Future 객체 생성
future_to_chunk = {
executor.submit(process_user_batch_optimized, client, chunk, cohort): chunk
for chunk in chunks
}
# 진행률 표시
with tqdm(total=len(chunks), desc=" - 배치 처리 진행률") as pbar:
for future in as_completed(future_to_chunk):
chunk = future_to_chunk[future]
try:
batch_results = future.result(timeout=300) # 5분 타임아웃
if batch_results:
all_results.extend(batch_results)
else:
failed_chunks.append(chunk)
except Exception as e:
print(f" - ⚠️ 배치 처리 실패: {e}")
failed_chunks.append(chunk)
finally:
pbar.update(1)
# 실패한 청크 재처리 (단일 스레드)
if failed_chunks:
print(f"\n - 실패한 {len(failed_chunks)}개 배치 재처리 중...")
for chunk in failed_chunks:
try:
batch_results = process_user_batch_optimized(client, chunk, cohort)
all_results.extend(batch_results)
except Exception as e:
print(f" - ❌ 재처리 실패: {e}")
return all_results
# ==============================================================================
# 7. 스트리밍 CSV 작성 (메모리 최적화)
# ==============================================================================
def write_results_streaming(results: List[Dict], output_path: str) -> None:
"""
스트리밍 방식으로 CSV 작성
메모리에 모든 데이터를 올리지 않고 직접 파일에 쓰기
"""
if not results:
print(" - [ERROR] 저장할 결과 데이터가 없습니다.")
return
# CSV 헤더 정의 (기획서 기반)
headers = [
'uid', 'auth_id', 'nickname', 'first_login_time', 'retention_status',
'country', 'device', 'active_seconds', 'retention_d1',
'tutorial_entry', 'tutorial_completed', 'dungeon_entry_count',
'dungeon_escape_count', 'monster_kill_count', 'player_kill_count',
'matching_complete_count', 'friend_add_count'
]
try:
with open(output_path, 'w', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=headers, extrasaction='ignore')
writer.writeheader()
# 스트리밍으로 한 줄씩 작성
for result in results:
writer.writerow(result)
print(f" - [SUCCESS] 결과 파일 저장 완료: {output_path}")
print(f" - [INFO] 총 {len(results)}명의 데이터가 저장되었습니다.")
except Exception as e:
print(f" - [ERROR] CSV 파일 저장 실패: {e}")
# ==============================================================================
# 8. 실시간 타이머 (유틸리티)
# ==============================================================================
def live_timer(start_time: float, pbar: tqdm) -> None:
"""실시간 경과 시간 표시"""
while not stop_timer_event.is_set():
elapsed = str(timedelta(seconds=int(time.time() - start_time)))
pbar.set_postfix_str(f"경과 시간: {elapsed}")
time.sleep(1)
# ==============================================================================
# 9. 메인 함수 및 명령줄 인터페이스
# ==============================================================================
def parse_arguments() -> argparse.Namespace:
"""명령줄 인자 파싱"""
parser = argparse.ArgumentParser(
description="던전 스토커즈 신규 유저 리텐션 분석 스크립트",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
예시:
python ds_new_user_analy.py --start-time "2025-08-22T12:00:00+09:00" --end-time "2025-08-25T12:00:00+09:00"
python ds_new_user_analy.py --start-time "2025-08-22T12:00:00+09:00" --end-time "2025-08-22T13:00:00+09:00" --sample-size 100
"""
)
parser.add_argument(
'--start-time',
required=True,
help='분석 시작 시간 (KST, ISO 형식): "2025-08-22T12:00:00+09:00"'
)
parser.add_argument(
'--end-time',
required=True,
help='분석 종료 시간 (KST, ISO 형식): "2025-08-25T12:00:00+09:00"'
)
parser.add_argument(
'--output-dir',
default=OUTPUT_DIR,
help=f'결과 파일 저장 경로 (기본값: {OUTPUT_DIR})'
)
parser.add_argument(
'--batch-size',
type=int,
default=DEFAULT_BATCH_SIZE,
help=f'배치 처리 크기 (기본값: {DEFAULT_BATCH_SIZE})'
)
parser.add_argument(
'--max-workers',
type=int,
default=DEFAULT_MAX_WORKERS,
help=f'병렬 처리 스레드 수 (기본값: {DEFAULT_MAX_WORKERS})'
)
parser.add_argument(
'--sample-size',
type=int,
help='샘플 분석 크기 (None이면 전체 분석)'
)
return parser.parse_args()
def main():
"""메인 실행 함수"""
# 시작 시간 기록
overall_start_time = time.time()
print("=" * 80)
print("던전 스토커즈 신규 유저 리텐션 분석 v2.0 (Claude Code)")
print("hack-detector 고급 최적화 기법 적용")
print("=" * 80)
# 명령줄 인자 파싱
args = parse_arguments()
# 시간 변환 (KST -> UTC)
try:
start_kst = datetime.fromisoformat(args.start_time)
end_kst = datetime.fromisoformat(args.end_time)
start_utc = start_kst.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
end_utc = end_kst.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
print(f"[INFO] 분석 기간: {args.start_time} ~ {args.end_time}")
print(f"[INFO] UTC 변환: {start_utc} ~ {end_utc}")
except Exception as e:
print(f"[ERROR] 시간 형식 오류: {e}")
return
# OpenSearch 클라이언트 생성
client = create_opensearch_client()
if not client:
return
try:
# 1단계: 신규 유저 코호트 선정 (Composite Aggregation)
cohort = get_new_user_cohort_optimized(client, start_utc, end_utc)
if not cohort:
print("\n[ERROR] 분석할 신규 유저가 없습니다.")
return
# 샘플링 모드
if args.sample_size and args.sample_size < len(cohort):
uid_list = list(cohort.keys())
sampled_uids = uid_list[:args.sample_size]
cohort = {uid: cohort[uid] for uid in sampled_uids}
print(f"[WARNING] 샘플링 모드: {args.sample_size}명만 분석합니다.")
# 2단계: 병렬 배치 처리 (Future Pattern)
results = process_cohort_parallel(
client,
cohort,
args.batch_size,
args.max_workers
)
# 3단계: 결과 저장 (스트리밍 CSV)
print(f"\n[3단계] 결과 저장")
timestamp = datetime.now(KST).strftime('%Y%m%d_%H%M%S')
filename = f"ds_new_users_analy_{timestamp}.csv"
output_path = os.path.join(args.output_dir, filename)
write_results_streaming(results, output_path)
# 통계 요약
if results:
retained_d1 = sum(1 for r in results if r.get('retention_status') == 'Retained_d1')
retention_rate = (retained_d1 / len(results)) * 100
print(f"\n[SUMMARY] 분석 요약:")
print(f" - 총 신규 유저: {len(results)}")
print(f" - D+1 리텐션: {retained_d1}명 ({retention_rate:.1f}%)")
print(f" - 평균 활동 시간: {sum(r.get('active_seconds', 0) for r in results) / len(results) / 60:.1f}")
except KeyboardInterrupt:
print(f"\n[WARNING] 사용자에 의해 중단되었습니다.")
except Exception as e:
print(f"\n[ERROR] 예상치 못한 오류: {e}")
import traceback
traceback.print_exc()
finally:
# 타이머 정지
stop_timer_event.set()
# 총 소요 시간
end_time = time.time()
total_time = str(timedelta(seconds=int(end_time - overall_start_time)))
print(f"\n[INFO] 총 소요 시간: {total_time}")
print("\n[SUCCESS] 분석 완료!")
if __name__ == "__main__":
main()