- 들어가며 — 평균은 9% 나빠졌는데 중앙값은 46% 좋아졌다
- 하나의 데이터, 네 개의 결론
- 왜 평균이 실패하는가 — 다봉 분포
- 부하 생성기가 지워 버리는 꼬리 — coordinated omission
- 무엇을 대신 그릴 것인가 — 다섯 개의 렌즈
- 진단 워크스루 — 평균이 평평한데 분포가 갈라질 때
- 운영에서 이걸 실제로 그리는 도구들
- 마치며 — 요약 통계는 가설이지 결론이 아니다
- 참고 자료
들어가며 — 평균은 9% 나빠졌는데 중앙값은 46% 좋아졌다
2026년 7월 27일 Farid Zakaria가 The mean means nothing이라는 글을 올렸고, 이틀 뒤 해커뉴스 스레드에서 73포인트를 받으며 한동안 상단에 있었습니다. 소재는 흔합니다 — 일주일에 걸쳐 캐싱 계층을 점진 배포했고, 대시보드의 평균 지연이 112ms에서 122ms로 9% 올랐습니다. 롤백 회의가 잡히기 딱 좋은 그림입니다.
그런데 같은 기간, 같은 요청 로그에서 중앙값은 99ms에서 54ms로 46% 내려갔습니다. p95는 224ms에서 454ms로, p99는 309ms에서 678ms로 두 배 넘게 올랐습니다. 캐싱은 성공했고 동시에 실패했습니다. 어느 쪽 문장도 틀리지 않았습니다.
먼저 하나 밝혀 둘 것이 있습니다. 원문의 데이터는 실제 장애 기록이 아니라 공개된 스크립트가 고정 시드로 생성한 합성 데이터입니다. 스크립트 상단 주석이 그렇게 밝히고 있고, 저자도 차트 생성에 AI의 도움을 받았다고 적었습니다. 그래서 이 글은 "이런 장애가 있었다"는 사례 보고가 아니라 "이런 모양의 데이터를 어떤 렌즈로 봐야 하는가"에 대한 교보재로 읽는 편이 정확합니다. 그리고 교보재로서는 꽤 잘 만들어졌습니다 — 캐시 적중과 미스가 섞이면서 생기는 이봉 분포는, 캐시를 하나라도 붙여 본 시스템이라면 거의 반드시 만나는 모양이기 때문입니다.
이 글은 그 상황을 출발점으로 삼아, 왜 평균이 실패하는지, 부하 생성기가 어떻게 꼬리를 지워 버리는지, 그리고 무엇을 대신 그려야 하는지를 정리합니다.
하나의 데이터, 네 개의 결론
원문이 제시하는 요약 통계는 이렇습니다.
| 통계량 | 롤아웃 전 | 롤아웃 후 | 변화 |
|---|---|---|---|
| 평균 | 112ms | 122ms | +9% |
| p50 (중앙값) | 99ms | 54ms | −46% |
| p95 | 224ms | 454ms | +103% |
| p99 | 309ms | 678ms | +119% |
네 숫자가 각각 다른 회의를 만듭니다. 평균만 보는 팀은 롤백을 결정하고, 중앙값만 보는 팀은 성공 사례로 발표하고, p99만 보는 팀은 인시던트를 엽니다. 셋 다 같은 로그를 봤습니다.
평균이 애매한 값을 내놓는 이유는 산술적으로 단순합니다. 평균은 빨라진 다수와 느려진 소수를 같은 저울에 올려 상쇄시킵니다. 절반이 45ms 빨라지고 5%가 370ms 느려지면 합계는 거의 0에 가까워집니다. 상쇄된 결과의 부호는 이 시스템에 대해 아무것도 말해 주지 않습니다. 이 점에서 평균은 틀린 지표라기보다 질문을 하나도 담고 있지 않은 지표입니다.
왜 평균이 실패하는가 — 다봉 분포
핵심은 캐싱 계층이 요청 모집단을 둘로 쪼갰다는 데 있습니다. 캐시에 적중한 요청은 백엔드를 건너뛰고 예전 기준선보다 빨라집니다. 미스가 난 요청은 캐시를 조회하는 왕복이 하나 더 붙은 뒤에야 원래 경로를 탑니다. 그래서 롤아웃 전 하나였던 봉우리가 롤아웃 후에는 두 개가 됩니다.
여기서 통계 교육의 고전들이 그대로 적용됩니다. 해커뉴스 댓글에서 가장 많이 인용된 것도 Anscombe's quartet과 그 현대판인 Same Stats, Different Graphs였습니다. 평균, 분산, 상관계수, 회귀선이 모두 같은데 그림은 전혀 다른 데이터 집합을 만들 수 있다는 것이 요지입니다. 후자의 데이터셋 중 하나는 산점도로 그리면 공룡 모양이 나옵니다.
지연 시간 데이터에서 다봉이 나오는 원인은 대개 정해져 있습니다.
- 캐시 적중과 미스
- 콜드 스타트와 웜 인스턴스
- 커넥션 풀에서 즉시 얻은 연결과 새로 맺은 TLS 핸드셰이크
- 리더 리전과 크로스 리전 폴백
- 소량 응답과 페이지네이션 없이 통째로 나가는 대량 응답
- GC 일시 정지나 컴팩션에 걸린 요청
원문의 마지막 진단이 정확히 이 목록의 마지막 항목 근처입니다 — 응답 크기와 지연을 함께 그린 산점도에서, 큰 응답이 캐시에 들어가지 못하고 미스 쪽 봉우리를 만들고 있었습니다. 그래서 처방도 "롤백"이 아니라 "캐시 용량을 늘리거나 큰 응답을 쪼갠다"가 됩니다. 분포를 나눈 축을 찾아야 처방이 나옵니다.
부하 생성기가 지워 버리는 꼬리 — coordinated omission
프로덕션 지표가 아니라 부하 테스트 결과를 보고 있다면, 분포를 그리기 전에 확인할 것이 하나 더 있습니다. 측정 자체가 꼬리를 지웠을 수 있습니다.
Gil Tene이 이름 붙인 coordinated omission은 이런 구조입니다. 부하 생성기가 초당 1,000건을 보내도록 설정돼 있는데, 응답 하나가 2초를 잡아먹었다고 합시다. 동기식 생성기라면 그 2초 동안 보냈어야 할 2,000건을 보내지 않습니다. 그 2,000건은 각각 최대 2초까지 대기했어야 할 요청이었지만, 아예 발사되지 않았으므로 히스토그램에 들어가지 않습니다. 결과적으로 시스템이 가장 느렸던 구간의 샘플이 통째로 사라지고, 부하 생성기는 자신이 만들어 낸 백프레셔에 "협조"해 버립니다.
이 결함의 증상은 특징적입니다.
- 부하를 올려도 p99가 거의 움직이지 않습니다. 실제로는 큐가 폭발하고 있는데 측정된 꼬리는 평온합니다.
- 보고된 처리량이 설정한 목표치보다 낮은데, 지연 분포는 목표치에서 측정한 것처럼 생겼습니다.
- 같은 시스템을 프로덕션 트래픽으로 관측하면 부하 테스트보다 꼬리가 훨씬 나쁩니다.
해법은 두 갈래입니다. 하나는 wrk2처럼 일정한 목표 처리량을 유지하면서 예정 발사 시각 기준으로 지연을 기록하는 생성기를 쓰는 것입니다. wrk2는 요청별 샘플 버퍼를 HdrHistogram으로 교체하고, 응답 지연을 "요청이 나갔어야 할 시각"부터 계산합니다. 다른 하나는 HdrHistogram이 제공하는 보정 API를 쓰는 것입니다. 기대 간격이 알려져 있을 때 누락된 샘플을 합성해 채워 넣습니다.
// 기대 간격 1ms(=1,000,000ns)로 기록하면, 한 샘플이 그 간격을 넘길 때
// 누락됐어야 할 중간 샘플들을 히스토그램이 스스로 채워 넣는다.
Histogram h = new Histogram(3600L * 1000 * 1000 * 1000, 3);
h.recordValueWithExpectedInterval(latencyNanos, 1_000_000L);
프로덕션 관측에는 이 문제가 없습니다. 실제 사용자는 이전 요청이 느리다고 다음 요청을 미뤄 주지 않기 때문입니다. 정확히 그 이유로, 부하 테스트 결과와 프로덕션 지표가 다르면 대개 부하 테스트 쪽을 의심해야 합니다.
무엇을 대신 그릴 것인가 — 다섯 개의 렌즈
원문이 같은 숫자를 여러 방식으로 그려 보인 부분이 이 글의 실질적인 가치입니다. 각 그래프가 답하는 질문이 다릅니다.
밀도 그래프는 봉우리가 몇 개인지 답합니다. 가장 먼저 그릴 것이고, 봉우리가 하나면 나머지 분석의 상당 부분을 생략해도 됩니다. 지연 시간은 오른쪽으로 길게 늘어지므로 x축은 로그 스케일이 기본입니다.
CDF는 "몇 퍼센트가 몇 ms 안에 끝났나"를 답합니다. 변경 전과 후의 CDF를 같은 축에 겹쳐 그렸을 때 두 곡선이 교차하면, 그 지점이 개선과 악화의 경계입니다. 원문에서는 140ms 부근에서 교차합니다. 교차하는 CDF는 "어떤 단일 백분위수도 이 변화를 요약할 수 없다"는 것의 시각적 증명입니다. 해커뉴스 댓글에는 한 단계 더 나간 제안도 있었는데, CDF 대신 1에서 뺀 값(CCDF, 즉 아직 끝나지 않은 요청의 비율)을 log-log로 그리면 CDF에서는 1에 붙은 수평선으로 뭉개지는 꼬리가 전 구간에 걸쳐 펼쳐집니다. 꼬리를 볼 때는 이쪽이 낫습니다.
시프트 함수는 "어느 백분위수까지 이득이고 어디부터 손해인가"를 답합니다. 각 백분위수 p마다 변경 후 값에서 변경 전 값을 뺀 값을 그립니다. 원문의 곡선은 p76 부근까지 음수(개선)였다가 그 위로 가파르게 양수로 넘어갑니다. 이 한 장이 "76%의 사용자는 이득을 봤고 나머지는 손해를 봤다"는 문장을 만들어 줍니다 — SLO를 어디에 걸었는지에 따라 배포 결정이 갈리는 지점입니다.
리지라인은 "언제부터 그렇게 됐나"를 답합니다. 날짜별 밀도를 위아래로 쌓으면 롤아웃 비율이 0에서 100%로 올라가는 동안 두 번째 봉우리가 자라나는 과정이 보입니다. 리지라인의 실무적 가치는 회귀가 배포와 함께 왔는지, 그 전부터 있었는지를 가리는 데 있습니다.
히트맵은 리지라인과 같은 정보를 격자에 눌러 담습니다. x축은 시간, y축은 지연 버킷, 색은 그 버킷에 떨어진 트래픽 양입니다. 리지라인보다 정보 밀도가 높고, 무엇보다 대시보드에 상시로 걸어 둘 수 있습니다. 시간 축이 며칠 이상으로 길어지면 리지라인은 겹쳐서 못 읽지만 히트맵은 계속 읽힙니다.
여기에 원문이 마지막에 쓴 두 가지를 더하면 진단이 끝납니다 — 조건부 CDF(캐시 적중/미스로 나눈 뒤 각각 그리면 다시 단봉이 됩니다)와 산점도(지연 대 응답 크기). 앞의 것은 "무엇이 분포를 갈랐는가"를 확인하고, 뒤의 것은 "왜 그 그룹인가"를 설명합니다.
진단 워크스루 — 평균이 평평한데 분포가 갈라질 때
원문의 사례는 평균이 조금이라도 움직였기 때문에 오히려 눈에 띄었습니다. 더 고약한 경우는 평균이 완전히 평평한 채로 분포만 갈라지는 것입니다. 절반이 빨라진 양이 나머지가 느려진 양과 우연히 맞아떨어지면 이런 일이 생기고, 이때는 아무 알람도 울리지 않습니다.
순서는 이렇게 잡습니다.
- 밀도 또는 히트맵을 먼저 본다. 봉우리가 몇 개인지, 언제부터 갈라졌는지 확인합니다. 여기서 단봉이면 2번으로 가지 않고 바로 6번(엔드포인트 분해)으로 갑니다.
- CDF를 전후로 겹쳐 그린다. 교차 지점을 찾습니다. 교차한다면 "평균/중앙값/p99 중 하나로 보고하겠다"는 계획을 버립니다.
- 시프트 함수로 경계 백분위수를 특정한다. SLO 임계값이 그 경계의 어느 쪽에 있는지가 배포 결정을 좌우합니다.
- 분포를 가른 축을 찾는다. 후보를 하나씩 대입해 조건부로 나눠 그립니다 — 캐시 적중 여부, 엔드포인트, 리전, 인스턴스, 클라이언트 버전, 응답 크기 구간. 나눴을 때 각각이 단봉이 되는 축이 정답입니다.
- 그 축과 다른 변수의 관계를 본다. 원문에서는 응답 크기였습니다. 여기서 처방이 나옵니다.
- 엔드포인트별로 분해해 다시 본다. 전체 지표는 트래픽 가중 평균이라, 트래픽이 적고 느린 엔드포인트의 회귀를 통째로 숨깁니다.
4번에서 후보 축이 떠오르지 않을 때 쓸 만한 요령이 하나 있습니다. 느린 봉우리에 속한 요청들만 골라 트레이스를 샘플링하는 것입니다. 분산 트레이싱을 이미 쓰고 있다면 지연 구간으로 필터링해 스팬 구성을 비교하는 것이 가장 빠릅니다. 이 부분은 관측성의 세 기둥과 LLM 워크로드 편에서 다룬 트레이스 샘플링 전략과 이어집니다.
그리고 4번을 하기 전에 꼭 확인할 것 — 팬아웃이 있는 시스템이라면 중앙값이 p99보다 더 심하게 거짓말합니다. 하나의 사용자 요청이 100개의 리프 서버로 흩어지고 전부 모여야 응답이 나가는 구조에서는, 각 리프의 p99가 사용자 지연의 중앙값을 지배합니다. Jeff Dean의 The Tail at Scale이 정리한 그대로이고, 해커뉴스 댓글에서도 이 지적이 나왔습니다. 팬아웃 시스템에서는 "구성 요소의 꼬리"가 "사용자의 평균"이 됩니다.
운영에서 이걸 실제로 그리는 도구들
교보재의 그림은 plotnine으로 그렸지만, 프로덕션에서 매일 봐야 하는 그림은 다른 파이프라인을 탑니다.
Prometheus 네이티브 히스토그램이 지금 가장 현실적인 기반입니다. 고전 히스토그램은 버킷 경계를 미리 정해야 하고, 경계를 촘촘히 잡을수록 시계열이 선형으로 늘어납니다. 네이티브 히스토그램은 지수 스케일로 버킷을 자동 배치해 이 트레이드오프를 없앱니다. v3.8.0에서 정식 기능이 됐고, OpenTelemetry의 exponential histogram과 데이터 모델이 대응되므로 OTLP 백엔드로 넘기는 경로도 열려 있습니다. 다만 켜는 데는 스크레이프 프로토콜 협상, 스토리지, 대시보드 쿼리까지 손볼 곳이 여럿이라 — 그 실무는 네이티브 히스토그램은 stable이 됐는데, 왜 아직 못 켜고 있나 편에 따로 정리해 두었습니다.
쿼리는 이렇게 생겼습니다.
# 백분위수 하나 — 이 값만 보면 이 글의 함정에 그대로 빠진다
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# 히트맵용: 버킷별 증가율을 그대로 내보내고 그래프 쪽에서 색으로 매핑한다
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# 네이티브 히스토그램이라면 le 라벨이 없다. 시리즈 자체가 분포다
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])))
histogram_fraction(0, 0.14, sum(rate(http_request_duration_seconds[5m])))
마지막 줄의 histogram_fraction이 이 글 맥락에서 유용합니다. "140ms 안에 끝난 요청의 비율"을 직접 뽑아 주므로, CDF의 특정 지점을 시계열로 걸어 둘 수 있습니다. 교차점을 알고 있다면 그 지점 하나를 알람으로 만드는 것이 p99 알람보다 훨씬 정확합니다.
Grafana의 히트맵 패널은 위의 두 번째 쿼리를 그대로 받습니다. 포맷을 Heatmap으로 두고 y축을 로그 스케일로 바꾸는 것이 사실상 필수 설정입니다. 선형 y축 히트맵은 아래쪽 버킷이 전부 한 줄로 뭉개져서 이봉을 못 보여 줍니다.
커널 쪽 분포가 필요하면 bpftrace의 hist()가 가장 저렴합니다. 애플리케이션 계측 없이 특정 시스템 콜이나 함수의 지연 분포를 로그2 버킷으로 바로 출력합니다.
# 블록 I/O 완료 지연 분포 (usec, 로그2 버킷)
sudo bpftrace -e '
kprobe:blk_account_io_start { @s[arg0] = nsecs; }
kprobe:blk_account_io_done /@s[arg0]/ {
@us = hist((nsecs - @s[arg0]) / 1000); delete(@s[arg0]);
}'
eBPF로 분포를 뜨는 접근 전반은 eBPF가 관측성을 삼키고 있다 편에서 더 다뤘습니다.
부하 테스트라면 앞서 말한 wrk2 계열, 또는 HdrHistogram을 내장하고 목표 처리량을 유지하는 생성기를 씁니다. 어떤 도구든 결과를 볼 때 확인할 것은 하나입니다 — 보고된 실제 처리량이 설정한 목표 처리량과 일치하는가. 일치하지 않으면 그 실행의 지연 분포는 신뢰할 수 없습니다.
마치며 — 요약 통계는 가설이지 결론이 아니다
정리하면 이렇습니다.
- 평균은 빨라진 다수와 느려진 소수를 상쇄합니다. 상쇄된 값의 부호는 시스템에 대해 아무것도 말하지 않습니다.
- 두 CDF가 교차하면 어떤 단일 백분위수도 그 변화를 요약할 수 없습니다. 이때는 백분위수를 하나 더 고르는 대신 시프트 함수를 그려야 합니다.
- 분포가 갈라졌다면 갈라 놓은 축을 찾는 것이 진단의 전부입니다. 조건부로 나눴을 때 각각이 단봉이 되는 축이 원인입니다.
- 부하 테스트에서 꼬리가 지나치게 얌전하면 coordinated omission을 먼저 의심합니다. 목표 처리량과 실제 처리량의 불일치가 그 신호입니다.
- 팬아웃 시스템에서는 구성 요소의 꼬리가 사용자의 평균이 됩니다.
숫자 하나를 보고 롤백을 결정하기 전에 그림 하나를 그려 보는 데 드는 비용은, 이미 히스토그램을 수집하고 있다면 쿼리 한 줄입니다. 문제는 도구가 아니라 습관 쪽에 있습니다.
참고 자료
- The mean means nothing: data visualization to debug a latency problem — Farid Zakaria (2026-07-27)
- 해커뉴스 토론 (item 49096170, 2026-07-29)
- 원문 그림 생성 스크립트 gist — nix-shell + plotnine, 합성 데이터
- wrk2 — 일정 처리량 유지, coordinated omission 보정 부하 생성기
- HdrHistogram — 고정밀 지연 히스토그램과 보정 API
- Prometheus — Native Histograms 명세
- Prometheus — Histograms and summaries 실무 가이드
- The Tail at Scale — Dean & Barroso, CACM
- Same Stats, Different Graphs — Matejka & Fitzmaurice, CHI 2017
- Anscombe's quartet
- 네이티브 히스토그램은 stable이 됐는데, 왜 아직 못 켜고 있나 (관련 글)
- eBPF가 관측성을 삼키고 있다 (관련 글)
- 관측성의 세 기둥과 LLM 워크로드 (관련 글)
현재 단락 (1/89)
2026년 7월 27일 Farid Zakaria가 [The mean means nothing](https://fzakaria.com/2026/07/27/the-mean-means-...