전체 일자 합계와 채널-일자(channel-day) 단위는 왜 다른 결론을 만들 수 있는가?
같은 로그에서 무엇을 즉시 검토 항목으로 올릴지 기준선 설계가 어떻게 바꾸는가?
핵심은 기준선 구간과 샘플 단위를 바꿔 같은 로그를 다시 읽었을 때 어떤 비교 설계가 다음 질문을 더 선명하게 만드는지 확인하는 데 있습니다. 지금 필요한 것은 더 복잡한 모델이 아니라, 같은 데이터에서도 무엇을 비교 기준으로 삼느냐에 따라 회고의 첫 문장이 달라진다는 사실입니다.
P7-1.2까지 읽으면 보통 회고는 잘 남기면 된다고 생각하기 쉽습니다. 하지만 실제 프로젝트에서는 회고 문장을 잘 쓰는 것보다 먼저 무슨 비교표를 회고 앞에 둘 것인가가 더 중요합니다.
같은 로그라도 기준선 설계에 따라 회고가 어떻게 달라지는지 먼저 표로 고정하면 다음과 같습니다.
비교 설계
먼저 보이는 것
놓치기 쉬운 것
전체 일자 합계
서비스 전체가 내려가는지
특정 채널 급락
채널-일자 단위
어떤 채널이 흔들리는지
전체 흐름의 크기
최근 7일 vs 이전 7일
지금 당장 달라진 신호
더 긴 기준선에서의 평소 수준
더 최근 4일 vs 이전 10일
급한 운영 이상 신호
표본 수가 작아 해석이 흔들릴 수 있음
예를 들어 최근 4일 집중 설정에서 ads 하락폭이 더 크게 보이면, 빠르게는 이 기준선이 가장 민감하니 항상 이것만 쓰면 된다고 적고 싶어질 수 있습니다. 하지만 더 안전한 다음 판단은 하락폭 크기만 보고 기준선을 고정하는 것이 아니라, 지금 질문이 전체 건강도 확인인가, 원인 후보 좁히기인가, 표본 수가 줄어 해석 강도가 얼마나 약해지는가를 함께 적는 것입니다. 그렇게 적어야 더 크게 보이는 설계와 현재 질문에 더 맞는 설계를 구분할 수 있습니다.
flowchart TD
A["문제 장면<br/>최근 4일 기준선에서 ads 하락폭이 더 큼"]
B["빠른 판단<br/>가장 민감한 기준선만 항상 사용"]
C["질문 확인<br/>전체 건강도인가 원인 후보 좁히기인가"]
D["표본 수 확인<br/>해석 강도가 얼마나 줄어드는가"]
E["비교 단위 확인<br/>전체 합계와 채널-일자 중 무엇이 맞는가"]
F["더 안전한 판단<br/>현재 질문에 맞는 기준선 설계를 고른다"]
A --> B
A --> C --> D --> E --> F
# 웹 트래픽 기준선을 전체 합계, 채널-일자, 최근 집중 기간으로 바꿔 우선 검토 항목과 다음 질문을 재설계하는 예제입니다.importcsvfromcollectionsimportdefaultdictfromdatetimeimportdatetimefrompathlibimportPathdata_path=Path("docs/assets/part-07/chapter-01/p7-1-traffic-log.csv")rows=list(csv.DictReader(data_path.open(encoding="utf-8")))forrowinrows:row["date"]=datetime.strptime(row["date"],"%Y-%m-%d").date()row["visitors"]=int(row["visitors"])row["signups"]=int(row["signups"])row["errors"]=int(row["errors"])defsummarize(group_rows):visitors=sum(row["visitors"]forrowingroup_rows)signups=sum(row["signups"]forrowingroup_rows)errors=sum(row["errors"]forrowingroup_rows)ifvisitors==0:raiseValueError("비교 구간에 visitors가 없습니다. 기준선 경계일을 확인하세요.")return{"visitors":visitors,"signups":signups,"conversion_rate":round(signups/visitors,4),"error_rate":round(errors/visitors,4),}defaggregate_by_day(group_rows):grouped=defaultdict(lambda:{"visitors":0,"signups":0,"errors":0})forrowingroup_rows:grouped[row["date"]]["visitors"]+=row["visitors"]grouped[row["date"]]["signups"]+=row["signups"]grouped[row["date"]]["errors"]+=row["errors"]return[{"date":date,**values}fordate,valuesinsorted(grouped.items())]experiments=[# 조작 변수: cutoff와 unit을 바꾸면 회고 앞줄에 올라오는 항목이 달라집니다.{"name":"전체 합계 / 7일 기준선","cutoff":"2026-06-08","unit":"date-total"},{"name":"채널-일자 / 7일 기준선","cutoff":"2026-06-08","unit":"channel-day"},{"name":"채널-일자 / 최근 4일 집중","cutoff":"2026-06-11","unit":"channel-day"},]results=[]forexperimentinexperiments:cutoff=datetime.strptime(experiment["cutoff"],"%Y-%m-%d").date()baseline_rows=[rowforrowinrowsifrow["date"]<cutoff]recent_rows=[rowforrowinrowsifrow["date"]>=cutoff]ifexperiment["unit"]=="date-total":baseline=summarize(aggregate_by_day(baseline_rows))recent=summarize(aggregate_by_day(recent_rows))priority="전체 하락 여부 재확인"detail={"conversion_delta":round(recent["conversion_rate"]-baseline["conversion_rate"],4),"error_delta":round(recent["error_rate"]-baseline["error_rate"],4),}else:by_channel=defaultdict(lambda:{"baseline":[],"recent":[]})forrowinrows:period="recent"ifrow["date"]>=cutoffelse"baseline"by_channel[row["channel"]][period].append(row)channel_deltas=[]forchannel,groupedinby_channel.items():baseline=summarize(grouped["baseline"])recent=summarize(grouped["recent"])channel_deltas.append({"channel":channel,"conversion_delta":round(recent["conversion_rate"]-baseline["conversion_rate"],4),"error_delta":round(recent["error_rate"]-baseline["error_rate"],4),})channel_deltas.sort(key=lambdarow:row["conversion_delta"])detail=channel_deltas[0]priority=f"{detail['channel']} 채널 우선 검토"results.append({"실험":experiment["name"],"기준선 경계일":experiment["cutoff"],"단위":experiment["unit"],"우선 검토 항목":priority,"핵심 변화":detail,})retrospective=[]forresultinresults:ifresult["단위"]=="date-total":retrospective.append({"실험":result["실험"],"사실":f"전체 합계 기준으로 보면 전환율 변화는 {result['핵심 변화']['conversion_delta']}이고 오류율 변화는 {result['핵심 변화']['error_delta']}이다.","해석":"서비스 전체 흐름이 약하게 흔들렸는지는 볼 수 있지만, 어떤 채널이 원인인지는 아직 분해되지 않는다.","다음 질문":"채널-일자 단위로 다시 쪼개면 어느 채널이 먼저 튀는가?",})else:retrospective.append({"실험":result["실험"],"사실":f"{result['핵심 변화']['channel']} 채널의 전환율 변화가 {result['핵심 변화']['conversion_delta']}로 가장 크게 내려갔다.","해석":"현재 질문이 원인 후보 좁히기라면 채널 단위 기준선이 더 적합하다.","다음 질문":"ads 안에서도 campaign, browser, release_version 같은 더 세분화된 축이 필요한가?",})print("기준선 재설계 결과 =")forrowinresults:print(row)print("[회고 메모]")forrowinretrospective:print(row)
기준선 재설계 결과 =
{'실험': '전체 합계 / 7일 기준선', '기준선 경계일': '2026-06-08', '단위': 'date-total', '우선 검토 항목': '전체 하락 여부 재확인', '핵심 변화': {'conversion_delta': -0.0108, 'error_delta': 0.0032}}
{'실험': '채널-일자 / 7일 기준선', '기준선 경계일': '2026-06-08', '단위': 'channel-day', '우선 검토 항목': 'ads 채널 우선 검토', '핵심 변화': {'channel': 'ads', 'conversion_delta': -0.0361, 'error_delta': 0.0114}}
{'실험': '채널-일자 / 최근 4일 집중', '기준선 경계일': '2026-06-11', '단위': 'channel-day', '우선 검토 항목': 'ads 채널 우선 검토', '핵심 변화': {'channel': 'ads', 'conversion_delta': -0.0279, 'error_delta': 0.0091}}
[회고 메모]
{'실험': '전체 합계 / 7일 기준선', '사실': '전체 합계 기준으로 보면 전환율 변화는 -0.0108이고 오류율 변화는 0.0032이다.', '해석': '서비스 전체 흐름이 약하게 흔들렸는지는 볼 수 있지만, 어떤 채널이 원인인지는 아직 분해되지 않는다.', '다음 질문': '채널-일자 단위로 다시 쪼개면 어느 채널이 먼저 튀는가?'}
{'실험': '채널-일자 / 7일 기준선', '사실': 'ads 채널의 전환율 변화가 -0.0361로 가장 크게 내려갔다.', '해석': '현재 질문이 원인 후보 좁히기라면 채널 단위 기준선이 더 적합하다.', '다음 질문': 'ads 안에서도 campaign, browser, release_version 같은 더 세분화된 축이 필요한가?'}
{'실험': '채널-일자 / 최근 4일 집중', '사실': 'ads 채널의 전환율 변화가 -0.0279로 가장 크게 내려갔다.', '해석': '현재 질문이 원인 후보 좁히기라면 채널 단위 기준선이 더 적합하다.', '다음 질문': 'ads 안에서도 campaign, browser, release_version 같은 더 세분화된 축이 필요한가?'}
전체 합계 기준선에서는 전환율 하락이 약하게 보여 서비스 전체 문제처럼 읽힐 수 있었지만, 같은 로그를 채널-일자 단위로 다시 묶자 ads 채널의 전환율 하락과 오류율 상승이 훨씬 강하게 드러났다. 특히 기준선을 더 최근 구간으로 당기면 이 하락폭이 더 커져 긴급 검토 대상으로는 유용하지만, 표본 수가 줄어 해석은 더 보수적으로 해야 한다. 따라서 지금 단계의 회고 앞줄은 서비스 전체 하락보다 ads 채널 우선 검토가 더 적합하다.
# action event 단위 요약에서 기준선 경계 순서를 바꿔 mid-flow와 late-drop gap이 어떻게 달라지는지 비교하는 예제입니다.importcsvfrompathlibimportPathsummary_path=Path("docs/assets/part-07/chapter-01/p7-action-unit-summary.csv")rows=list(csv.DictReader(summary_path.open(encoding="utf-8")))forrowinrows:row["event_order"]=int(row["event_order"])row["mid_flow_mean"]=float(row["mid_flow_mean"])row["late_drop_rate"]=float(row["late_drop_rate"])defsummarize(group_rows):ifnotgroup_rows:raiseValueError("비교 구간이 비어 있습니다. cutoff_order를 확인하세요.")return{"count":len(group_rows),"mid_flow_mean":round(sum(row["mid_flow_mean"]forrowingroup_rows)/len(group_rows),3),"late_drop_rate":round(sum(row["late_drop_rate"]forrowingroup_rows)/len(group_rows),3),"review_ratio":round(sum(row["review_needed"]=="yes"forrowingroup_rows)/len(group_rows),3,),}comparison_rows=[rowforrowinrowsifrow["period"]in{"baseline","recent"}]# 조작 변수: 최근 구간을 어디서 시작할지 바꿔 봅니다.forcutoff_orderin[9,10]:baseline=[rowforrowincomparison_rowsifrow["event_order"]<cutoff_order]recent=[rowforrowincomparison_rowsifrow["event_order"]>=cutoff_order]baseline_summary=summarize(baseline)recent_summary=summarize(recent)result={"recent_start_order":cutoff_order,"baseline_count":baseline_summary["count"],"recent_count":recent_summary["count"],"mid_flow_gap":round(recent_summary["mid_flow_mean"]-baseline_summary["mid_flow_mean"],3),"late_drop_gap":round(recent_summary["late_drop_rate"]-baseline_summary["late_drop_rate"],3),"recent_review_ratio":recent_summary["review_ratio"],}print(result)
첫 비교에서는 최근 4회 중 3회가 검토 대상으로 남습니다. 두 번째 비교는 최근 구간이 더 짧아져 민감한 운영 신호를 볼 수 있지만, 표본 수가 3회뿐이므로 결론은 더 조심스럽게 써야 합니다. 회고 앞줄은 센서가 나빠졌다가 아니라 최근 구간에서 중반 유량 평균 하락과 후반 하강 증가가 함께 반복되지만, 짧은 구간 비교는 표본 수를 함께 확인해야 한다처럼 써야 합니다.