3강 — 시계열 분석과 트렌드
🎯 학습 목표
- 전체 분석과 시계열 분석의 차이를 설명하고, 왜 시간 축이 필요한지 안다
- 리뷰를 월별로 집계해 시계열 데이터로 만들 수 있다
- 이동 평균으로 노이즈를 걷어내고 트렌드를 파악할 수 있다
- 변화 지점(change point)을 감지해 원인 분석의 출발점으로 삼을 수 있다
📖 해설
1. "긍정 65%"는 좋은 걸까요?
PM이 "우리 게임 평가 어때요?" 하고 묻습니다. "긍정 65%입니다"라고 답하면 될까요? 잠깐, 만약 6개월 전에는 85%였다면요? 25%나 떨어진 거잖아요. 그러면 "좋아요"가 아니라 "큰일"입니다. 핵심은 이겁니다. 같은 65%라도 오르는 중인지 떨어지는 중인지에 따라 의미가 완전히 달라집니다.
지금까지 우리는 리뷰를 한 덩어리로 봤습니다. "전체 긍정 65%, 부정 25%" 식으로요. 단순하고 직관적이지만, 변화가 보이지 않습니다. 그냥 "평가가 좋네요" 하고 끝나죠. 시계열 분석은 시간별로 쪼개서 봅니다. "1월엔 80%였는데 6월엔 50%로 떨어졌다"면 "업데이트 후 급락했네"라는 인사이트가 나옵니다. 같은 데이터라도 시간 축을 더하면 새로운 것이 보입니다.
시계열 분석이 있으면 이런 질문에 데이터로 답할 수 있습니다.
| 질문 | 시계열로 할 수 있는 것 |
|---|---|
| 업데이트 반응이 어땠나요? | 업데이트 전후 감성 비교 |
| 언제부터 평가가 나빠졌나요? | 감성 하락 시점 특정 |
| 이벤트 효과가 있었나요? | 이벤트 기간 전후 비교 |
| 요즘 트렌드가 어떤가요? | 최근 N개월 추이 확인 |
2. 시계열 데이터란 무엇인가
시계열 데이터는 간단히 시간 순서대로 정렬된 데이터입니다. 핵심은 각 데이터에 날짜·시간 정보가 있어야 한다는 것. 우리가 쓰는 Steam 리뷰 데이터에는 timestamp_created(리뷰가 작성된 시간)라는 필드가 있어서, 이걸 활용하면 일별·주별·월별로 집계할 수 있습니다.
| 월 | 리뷰 수 | 긍정 비율 |
|---|---|---|
| 2026-01 | 150 | 75% |
| 2026-02 | 180 | 72% |
| 2026-03 | 200 | 68% |
| 2026-04 | 350 | 45% ← 무슨 일이? |
4월에 리뷰가 급증하면서 긍정률이 뚝 떨어졌네요. 뭔가 사건이 있었던 겁니다.
다이어트 체중 기록으로 비유해 봅시다. "올해 평균 체중 70kg"은 별 의미가 없습니다. 하지만 "1월 75kg → 6월 68kg → 현재 70kg"이라면 인사이트가 있죠. 감량에 성공했다가 반등했으니 다시 관리가 필요하다는. 리뷰 감성도 똑같습니다. 변화의 흐름을 봐야 의미 있는 인사이트가 나옵니다.
3. 시뮬레이션 데이터 만들기
실제 리뷰 데이터가 없으니, 현실적인 시나리오로 시뮬레이션을 만들어 연습하겠습니다. 시나리오: 어떤 게임이 3월에 큰 업데이트를 했는데 버그가 많아 부정 리뷰가 급증했고, 5월 핫픽스 후 회복한다.
# ① 환경 준비 (이 강의 첫 셀에서 한 번만)
!pip install openai -q
import json
import re
import random
from datetime import datetime # 오늘 주제가 '시간'이라 새로 등장 — 파이썬 기본 시간 처리 라이브러리
from collections import defaultdict
from google.colab import userdata
from openai import OpenAI
# 시각화 (없으면 텍스트로 대체)
try:
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
HAS_MPL = True
except ImportError:
HAS_MPL = False
MODEL = "mistralai/devstral-2512:free"
api_key = userdata.get('OPENROUTER_API_KEY')
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key)
def clean_json_response(text):
text = text.strip()
if '```json' in text:
m = re.search(r'```json\s*([\s\S]*?)\s*```', text)
if m: return m.group(1).strip()
if '```' in text:
m = re.search(r'```\s*([\s\S]*?)\s*```', text)
if m: return m.group(1).strip()
m = re.search(r'(\{[\s\S]*\}|\[[\s\S]*\])', text)
return m.group(1).strip() if m else text
def call_llm(system_prompt, user_message):
res = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}],
)
return res.choices[0].message.content
def call_llm_json(system_prompt, user_message):
return json.loads(clean_json_response(call_llm(system_prompt, user_message)))
print("준비 완료!")
def generate_monthly_data():
"""월별 감성 데이터를 시나리오에 맞춰 생성.
1-2월 안정(긍정 65% 전후) → 3-4월 위기(45%) → 5-6월 회복(60%↑)."""
base_rates = {1: 65, 2: 67, 3: 45, 4: 47, 5: 58, 6: 63} # 월별 기본 긍정률(%)
data = []
for month in range(1, 7):
total = random.randint(180, 220) if month in (3, 4) else random.randint(120, 160)
rate = max(30, min(80, base_rates[month] + random.randint(-3, 3))) # 약간의 노이즈
positive = int(total * rate / 100)
neutral = int((total - positive) * 0.3)
negative = total - positive - neutral
data.append({'month_name': f"{month}월", 'total': total,
'positive': positive, 'negative': negative, 'neutral': neutral,
'positive_rate': round(positive / total * 100, 1)})
return data
monthly_data = generate_monthly_data()
print(f"{'월':^5}|{'총':^6}|{'긍정':^5}|{'부정':^5}|{'중립':^5}|{'긍정률':^7}")
for m in monthly_data:
print(f"{m['month_name']:^5}|{m['total']:^6}|{m['positive']:^5}|{m['negative']:^5}|{m['neutral']:^5}|{m['positive_rate']:^6}%")
(랜덤 노이즈가 들어가니 실행마다 수치는 조금씩 달라집니다. 3~4월에 긍정률이 낮아지는 큰 흐름은 유지됩니다.)
참고로, 위
call_llm은 리뷰 하나를 보내 응답 하나를 받는 단발성 호출입니다. 만약 챗봇처럼 맥락을 이어가는 대화형으로 만들고 싶다면,messages에 그동안의 대화 목록을 함께 쌓아 보내야 합니다. ChatGPT를 오래 쓰면 "대화 용량이 찼다"는 메시지가 뜨는 이유가 바로 이것 — 매 회차마다 이전 대화를 같이 보내기 때문입니다. 지금 우리 분석은 단발성으로 충분합니다.
4. 이동 평균 — 노이즈를 부드럽게
일별·월별 데이터에는 노이즈(일시적 변동)가 섞여 있습니다. 어제 긍정률 50%, 오늘 60%라고 해서 "하루 만에 10% 뛰었다!"고 해석하면 위험합니다. 그건 우연일 수 있거든요. 그래서 이동 평균(moving average)을 씁니다. 최근 N개 기간의 평균을 내서 흔들림을 부드럽게 만드는 거죠. "숲을 보는 방법"입니다 — 개별 나무(하루치 데이터)에 휘둘리지 않고 전체 방향을 봅니다.
예를 들어 일별 긍정률이 70·65·80·60·75처럼 들쭉날쭉하면 트렌드가 잘 안 보이지만, 여기에 3일 이동평균을 적용하면 71.7·68.3·71.7로 부드러워집니다 — 흔들림은 사라지고 흐름만 남죠.
주식 차트가 좋은 예입니다. 일봉은 매일 등락이 심해 트렌드가 안 보입니다. 그래서 5일 이동평균선(단기 트렌드), 20일 이동평균선(중기 트렌드)을 함께 봅니다. 감성 분석도 똑같이, 일별 데이터의 노이즈를 이동 평균으로 걷어내고 추세를 봅니다.
def moving_average(data, window=3):
"""최근 window개의 평균을 계산. 데이터가 부족한 앞부분은 None."""
result = []
for i in range(len(data)):
if i < window - 1:
result.append(None) # 아직 window개가 안 모임
else:
avg = sum(data[i - window + 1:i + 1]) / window
result.append(round(avg, 1))
return result
rates = [m['positive_rate'] for m in monthly_data]
months = [m['month_name'] for m in monthly_data]
ma_3 = moving_average(rates, window=3) # 3개월 이동 평균
print(f"{'월':^5}|{'긍정률':^8}|{'3개월 이동평균':^14}")
for i, mth in enumerate(months):
ma = f"{ma_3[i]}%" if ma_3[i] is not None else "-"
print(f"{mth:^5}|{rates[i]:^7}%|{ma:^14}")
# 원본 vs 이동평균 시각화
if HAS_MPL:
x = range(len(months))
plt.figure(figsize=(10, 5))
plt.plot(x, rates, 'o-', label='Original', linewidth=2)
ma_x = [i for i, v in enumerate(ma_3) if v is not None]
ma_y = [v for v in ma_3 if v is not None]
plt.plot(ma_x, ma_y, 's--', label='3-Month MA', linewidth=2)
plt.xticks(list(x), months); plt.ylim(30, 80)
plt.ylabel('Positive Rate (%)'); plt.title('Monthly Positive Rate with Moving Average')
plt.legend(); plt.grid(alpha=0.3); plt.tight_layout(); plt.show()
앞 두 칸은 None으로 비어 있습니다(아직 3개월치가 안 모였으니까요). 그 뒤부터 부드러운 선이 그려지며 전체 흐름이 보입니다. 처음엔 용어가 생소하실 수 있는데, 이 과정은 난이도가 꽤 높은 편입니다 — AI에 고급 LLM을 접목하고 수학적 접근까지 실무 수준으로 다루는 거라, 반복 복습으로 익숙해지는 게 정답입니다.
이동 평균 해석법도 알아 둡시다.
| 관찰 | 의미 |
|---|---|
| 이동평균이 하락 | 추세적으로 악화 중 |
| 이동평균이 상승 | 추세적으로 개선 중 |
| 원본 > 이동평균 | 현재가 추세보다 좋음 |
| 원본 < 이동평균 | 현재가 추세보다 나쁨 |
예를 들어 "이번 달 긍정률 63%, 이동평균 56%"라면 현재가 추세보다 높으니 회복 중이라고 읽습니다.
5. 변화 지점(change point) 감지
변화 지점은 트렌드가 급격히 바뀌는 시점입니다. 이게 중요한 이유는 원인 분석의 시작점이 되기 때문입니다. 긍정이 80%에서 40%로 급락했다면 그 시점에 뭔가(버그 업데이트, 서버 문제) 있었던 거고, 50%에서 75%로 급등했다면 대규모 패치나 할인 이벤트가 있었을 수 있습니다. 개발에서 "왜 안 되지?"만큼이나 "왜 되지?"도 중요합니다 — 좋아진 이유를 알아야 다시 재현할 수 있으니까요.
변화 지점을 찾는 방법은 이동 평균 전후를 비교하거나 표준편차를 활용하는 것도 있지만, 복잡한 통계 없이 전월 대비 변화량이 임계값을 넘으면 변화 지점으로 잡는 간단한 방법만으로도 충분합니다.
def detect_change_points(data, threshold=10):
"""이전 값 대비 변화량이 threshold(%p)를 넘으면 변화 지점으로 표시."""
points = []
for i in range(1, len(data)):
change = data[i] - data[i - 1]
if abs(change) >= threshold:
points.append({'index': i, 'from': data[i - 1], 'to': data[i],
'change': round(change, 1),
'direction': '상승' if change > 0 else '하락'})
return points
for cp in detect_change_points(rates, threshold=10):
i = cp['index']
print(f"{months[i-1]} → {months[i]}: {cp['from']}% → {cp['to']}% "
f"({cp['change']:+.1f}%p, {cp['direction']})")
if cp['direction'] == '하락':
print(" → 부정적 변화. 그 시점에 무슨 일이 있었는지 원인 분석 필요")
else:
print(" → 긍정적 회복. 어떤 조치가 효과적이었는지 확인")
2월에서 3월로 넘어갈 때 큰 하락(업데이트 버그), 4~5월에 회복(핫픽스)이 변화 지점으로 잡힙니다. 감지에서 끝나는 게 아니라 그 시점을 들여다보고 민첩하게 대응하는 데까지 가는 게 목적입니다.
지금까지 배운 걸 하나로 묶으면 시계열 종합 분석 함수가 됩니다. 흐름은 월별 데이터 → 이동평균 계산 → 변화점 감지 → 전체 추세 방향 판단 → 종합 리포트. 전체 추세는 첫 이동평균과 마지막 이동평균을 비교해 상승/하락/안정으로 가립니다.
def analyze_time_series(monthly_data, ma_window=3, change_threshold=10):
"""이동평균·변화점·전체 추세 방향을 한 번에 종합한다."""
values = [m['positive_rate'] for m in monthly_data]
ma = moving_average(values, window=ma_window)
changes = detect_change_points(values, threshold=change_threshold)
first_ma = next(v for v in ma if v is not None) # 첫 유효 이동평균
diff = ma[-1] - first_ma # 처음 대비 마지막
trend = "상승 추세" if diff > 5 else "하락 추세" if diff < -5 else "안정적"
return {'trend': trend, 'change_points': len(changes), 'current': values[-1]}
a = analyze_time_series(monthly_data)
print(f"전체 추세: {a['trend']} / 변화점 {a['change_points']}개 / 현재 긍정률 {a['current']}%")
6. LLM으로 추이 해석하기
숫자만으로는 "왜"를 알기 어렵습니다. 여기서 LLM을 또 다른 방식으로 활용할 수 있습니다 — 추이 데이터에 맥락 정보(이벤트 타임라인)를 함께 주면, AI가 데이터와 사건을 연결해 의미 있는 해석과 권장 사항을 내놓습니다.
# 이벤트 타임라인 — 데이터에 '맥락'을 입혀 주는 핵심
events = [
{"month": 3, "event": "대규모 콘텐츠 업데이트 (버전 2.0)"},
{"month": 4, "event": "버그 리포트 급증, 공식 사과문 발표"},
{"month": 5, "event": "긴급 핫픽스 패치 배포"},
]
SYSTEM_PROMPT = """당신은 게임 리뷰 분석 전문가입니다.
월별 감성 추이와 이벤트 타임라인을 분석해 인사이트를 제공합니다.
다음 JSON 형식으로만 응답하세요:
{
"summary": "한 줄 요약",
"correlations": [{"period": "기간", "event": "관련 이벤트", "impact": "데이터에 미친 영향"}],
"recommendations": ["권장사항 1", "권장사항 2"]
}"""
request = f"""## 월별 긍정률
{json.dumps([{'month': m['month_name'], 'positive_rate': m['positive_rate']} for m in monthly_data], ensure_ascii=False)}
## 이벤트 타임라인
{json.dumps(events, ensure_ascii=False)}
위 데이터를 분석해 인사이트를 제공해주세요."""
try:
result = call_llm_json(SYSTEM_PROMPT, request)
print("요약:", result.get('summary'))
print("\n이벤트-데이터 연관성:")
for c in result.get('correlations', []):
print(f" • {c.get('period')}: {c.get('event')} → {c.get('impact')}")
print("\n권장사항:")
for i, rec in enumerate(result.get('recommendations', []), 1):
print(f" {i}. {rec}")
except Exception as e:
print(f"LLM 분석 실패: {e} (API 키나 네트워크를 확인하세요)")
AI는 "대규모 업데이트 후 버그로 긍정률이 급락했으나 핫픽스로 회복세"처럼 이벤트와 데이터를 엮어 요약하고 권장 사항까지 제시합니다. 숫자(정량)와 맥락(정성)을 합쳐 비로소 "그래서 무엇을 할 것인가"에 답하는 거죠.
🔑 핵심 용어
| 용어 | 쉬운 설명 |
|---|---|
| 시계열 데이터 | 시간 순서로 정렬된 데이터. 날짜·시간 정보가 핵심 |
timestamp_created |
Steam 리뷰의 작성 시각 필드. 월별 집계의 기준 |
| 이동 평균(moving average) | 최근 N개의 평균으로 노이즈를 줄여 추세를 보는 기법 |
| 윈도우(window) | 이동 평균을 낼 기간 크기(예: 3개월이면 window=3) |
| 변화 지점(change point) | 트렌드가 급격히 바뀌는 시점. 원인 분석의 출발점 |
| 노이즈 | 추세와 무관한 일시적 변동. 이동 평균으로 완화 |
🎮 게임 기획자 시점
시계열은 리뷰 분석을 "현황 보고"에서 "운영 의사결정 도구"로 끌어올립니다. 전체 평균은 상태를 말하지만, 기획자가 정작 알고 싶은 건 변화의 방향과 원인이죠. 변화 지점 감지는 곧 업데이트·이벤트의 효과 측정입니다 — 패치 전후 2주를 비교해 "이 패치가 먹혔나"를 데이터로 답하고, 급락 시점을 짚어 "그날 무슨 빌드가 나갔지?"로 역추적할 수 있습니다. 이동 평균의 "노이즈에 휘둘리지 말라"는 교훈은 라이브 운영에서 하루치 지표 출렁임에 과민 반응하지 않는 감각과 같고, LLM에 이벤트 타임라인을 함께 주는 방식은 지표 대시보드 옆에 패치 노트를 나란히 두는 운영 습관으로 그대로 옮겨집니다.
⚠️ 흔한 오해 / 짚고 갈 점
- 하루(혹은 한 달) 사이의 급등락을 곧장 추세로 해석하면 안 됩니다. 우연일 수 있습니다. 이동 평균으로 노이즈를 걷어낸 뒤 판단하세요.
- 이동 평균의 앞부분이
None인 건 오류가 아닙니다. window만큼 데이터가 모이기 전에는 평균을 낼 수 없어 비워 둡니다. - 변화 지점은 "감지"가 끝이 아닙니다. 그 시점에 무슨 이벤트가 있었는지 연결해야 비로소 인사이트가 됩니다.
- 숫자만으론 "왜"를 알 수 없습니다. 맥락(이벤트 타임라인)을 함께 줘야 원인 해석이 가능합니다.
✅ 30초 정리
- 같은 "65%"도 오르는 중/떨어지는 중에 따라 의미가 다릅니다. 시간 축을 더하면 새 인사이트가 보입니다.
- 이동 평균으로 일별 노이즈를 걷어내 트렌드를 보고, 변화 지점으로 급변 시점을 찾아 원인을 분석합니다.
- 숫자(정량)에 맥락(이벤트)을 더하면, LLM이 "왜 이렇게 됐는지"까지 해석해 줍니다.
▶️ 다음 강 연결
여기까지 우리는 결과를 검증하고 추세까지 읽었습니다. 그런데 분석의 재료와 도구 자체가 한쪽으로 치우쳐 있다면 어떨까요? 영어 리뷰만 봤거나, 전체 평균에 최근 급락이 묻혔거나, LLM이 특정 표현을 못 알아듣는다면요. 다음 4강 AI 편향과 공정성에서는 분석의 모든 단계에 숨은 편향을 점검하고, 신뢰받는 분석을 위한 체크리스트를 만듭니다.