실제 성능 실험에서 마주친 측정값을 범위만 남겨 익명화했다. 특정 서비스의 성능 결과가 아니라, 결과를 해석하는 과정에 대한 글이다.

성능 측정 결과가 좋아졌다. 여러 번 실행한 평균은 이전보다 짧았다. 이대로라면 “개선됐다”고 말해도 될 것 같았다.

그런데 원본 측정값을 펼쳐보니 이상했다.

0.6초, 1.1초, 0.7초, 1.2초, 0.7초,
1.2초, 1.2초, 0.7초, 0.7초, 1.1초 ...

값이 평균 주변에 모이지 않았다. 대략 0.6~0.7초와 1.1~1.3초, 두 무리로 갈렸다. 평균은 그 사이 어디쯤에 있었지만 실제 실행에서 평균과 비슷한 값은 거의 나오지 않았다.

이때 평균은 대표값이 아니라 서로 다른 두 실행 조건을 섞어 만든 숫자일 가능성이 높다.

TL;DR

  • 평균 하나만 보면 서로 다른 성능 상태가 섞인 것을 놓칠 수 있다.
  • 측정값이 두 무리로 갈리면 코드 개선보다 캐시, 연결 재사용, 서버 상태와 실험 조건을 먼저 의심해야 한다.
  • 고정 간격 측정도 캐시 만료 주기와 맞물리면 결과를 왜곡할 수 있다.
  • 중앙값, p75, p95와 함께 원본 분포와 실행 메타데이터를 봐야 한다.
  • 개선 주장은 같은 조건의 A/B 분포가 안정적으로 갈릴 때 하는 편이 안전하다.

평균값에 실제 사용자가 없었다

예를 들어 다음과 같은 결과가 있다고 하자.

빠른 군: 0.6, 0.7, 0.7, 0.6초
느린 군: 1.1, 1.2, 1.2, 1.3초
전체 평균: 약 0.9초

보고서에 평균 0.9초라고 쓰면 사용자는 대부분 0.9초 근처를 경험한다고 느끼기 쉽다. 실제로는 절반가량이 0.6~0.7초, 나머지가 1.1~1.3초를 경험한다.

이런 분포를 이봉 분포(bimodal distribution)라고 부른다. 봉우리가 둘이라는 뜻이다. 웹 성능에서는 서로 다른 실행 조건이 섞였을 때 자주 나타난다.

  • 캐시 적중과 미적중
  • 새로운 연결과 재사용 연결
  • 콜드 스타트와 웜 인스턴스
  • 서로 다른 서버 또는 리전
  • 로그인·비로그인 경로
  • 프리페치 성공과 실패
  • 앱 WebView와 일반 브라우저
  • 백그라운드와 포그라운드 상태

두 무리를 하나의 평균으로 합치기 전에, 무엇이 실행을 둘로 나누는지 찾아야 한다.

가장 먼저 캐시를 의심한 이유

빠른 값과 느린 값의 차이가 비교적 일정했다. 실행 순서도 완전히 무작위처럼 보이지 않았다. 이런 패턴에서는 캐시가 자연스러운 후보가 된다.

캐시는 여러 층에 있다.

브라우저 메모리 캐시
-> Service Worker 또는 앱 캐시
-> CDN 캐시
-> API Gateway 캐시
-> 서버 애플리케이션 캐시
-> 데이터베이스 캐시

“캐시를 사용했다”는 한 문장으로는 부족하다. 어느 층이 적중했는지에 따라 개선의 의미가 달라진다.

  • 브라우저 캐시만 적중했다면 최초 방문자는 느릴 수 있다.
  • CDN 캐시가 적중했다면 지역과 키에 따라 결과가 달라질 수 있다.
  • 애플리케이션 캐시라면 만료 시점에 느린 요청이 몰릴 수 있다.
  • 프리페치 캐시라면 사용자가 이전 화면에서 얼마나 머물렀는지가 영향을 준다.

따라서 성능값 옆에는 적어도 다음 정보를 남기는 편이 좋다.

{
  "durationMs": 680,
  "startedAt": "2026-07-21T06:00:00Z",
  "cacheStatus": "HIT",
  "connectionReused": true,
  "prefetched": true,
  "appVersion": "candidate",
  "scenario": "previous-page-to-target-page"
}

모든 층의 캐시 상태를 얻을 수는 없다. 그래도 응답 헤더, Resource Timing, 서버 로그, 프리페치 여부처럼 확보 가능한 정보부터 붙이면 두 무리를 분리할 실마리가 생긴다.

고정 간격 측정도 조건이 된다

실험은 일정한 간격으로 자동 실행됐다. 반복 측정에는 좋은 방식처럼 보이지만, 간격 자체가 시스템의 주기와 맞물릴 수 있다.

예를 들어 캐시의 유효 시간이 5분이고 측정도 5분 간격이라면 다음과 같은 패턴이 생길 수 있다.

측정 실행
-> 만료 직후 첫 요청이 캐시 갱신
-> 느린 값 기록
-> 다음 조건에서는 갱신된 캐시 사용
-> 빠른 값 기록

스케줄러, 배치, 오토스케일링, 연결 유휴 시간도 같은 영향을 줄 수 있다. 측정 간격을 고정했다는 사실이 변수를 제거하는 게 아니라 새로운 변수를 만들기도 한다.

이를 확인하는 방법은 어렵지 않다.

  • 측정 간격을 몇 가지로 바꿔본다.
  • 실행 시각에 작은 무작위 지연을 추가한다.
  • 캐시를 명시적으로 비운 군과 유지한 군을 나눈다.
  • 첫 실행과 반복 실행을 별도로 집계한다.
  • 느린 값이 특정 시각이나 순서에 몰리는지 본다.

실험 자동화가 안정적으로 돌았다는 사실과 실험 조건이 공정하다는 사실은 다르다.

평균 대신 무엇을 볼까

평균을 버릴 필요는 없다. 다만 평균 하나로 결론 내리지 않아야 한다.

원본 분포

표본이 많지 않다면 정렬된 원본값만 봐도 많은 것을 알 수 있다.

560, 600, 600, 640, 680, 680, 680,
1080, 1120, 1160, 1200, 1240 ...

중간 구간이 비어 있다면 하나의 안정된 성능 상태라고 보기 어렵다.

중앙값과 백분위수

  • 중앙값(p50): 절반의 실행이 이 값 이하
  • p75: 느린 쪽 25%가 시작되는 지점
  • p95: 드물지만 사용자가 반복해서 만날 수 있는 꼬리 지연

표본이 작을 때 p95를 지나치게 정밀한 숫자로 해석하면 안 되지만, 평균이 숨기는 느린 실행을 드러내는 데는 도움이 된다.

군별 통계

캐시 적중 여부를 알 수 있다면 처음부터 분리해 집계한다.

cache hit:  median 0.68초, p75 0.70초
cache miss: median 1.18초, p75 1.22초

이제 “평균 0.9초”보다 훨씬 실행 가능한 질문을 만들 수 있다.

  • 캐시 적중률을 높일 것인가
  • 미적중 경로 자체를 줄일 것인가
  • 최초 방문 성능을 개선할 것인가
  • 캐시 갱신을 요청 경로 밖으로 옮길 것인가

A/B도 평균끼리만 비교하면 부족하다

기능을 켠 상태와 끈 상태를 비교할 때 흔히 다음처럼 말한다.

OFF 평균: 1.0초
ON 평균: 0.9초
개선: 0.1초

하지만 두 분포가 크게 겹치고 실행 편차가 0.5초라면 0.1초 차이는 측정 노이즈일 수 있다. 반대로 평균 차이는 작아도 느린 군이 사라졌다면 사용자 경험은 의미 있게 좋아졌을 수 있다.

다음 순서로 보는 편이 낫다.

  1. 동일한 시나리오와 환경인지 확인한다.
  2. 워밍업과 실제 표본을 분리한다.
  3. ON/OFF 실행 순서를 교차하거나 무작위화한다.
  4. 원본 분포와 이상치를 확인한다.
  5. 중앙값과 상위 백분위수를 비교한다.
  6. 캐시·프리페치·연결 상태별로 다시 나눈다.
  7. 차이가 반복 실험에서도 유지되는지 본다.

성능 개선은 숫자 하나가 작아지는 사건이 아니라, 같은 조건에서 사용자에게 유리한 분포가 반복해서 나타나는 상태다.

측정에 함께 남길 최소 정보

다음 항목만 있어도 나중에 원인을 추적하기 쉬워진다.

구분 예시
시나리오 장바구니에서 주문 화면으로 이동
시작 조건 로그인, 상품 수, 이전 화면 체류 시간
빌드 기준 버전, 후보 버전
기능 상태 Feature Flag ON/OFF
실행 순서 워밍업, 첫 실행, 반복 실행
캐시 단서 응답 헤더, 프리페치 여부, 캐시 초기화 여부
환경 기기, 네트워크, 앱·브라우저 버전
구간 navigation, API, render, interaction-ready
결과 원본값, p50, p75, p95, 실패율

여기서 중요한 것은 측정 항목을 무한히 늘리는 게 아니다. 현재 의심하는 원인을 구분할 수 있는 메타데이터를 남기는 것이다.

개선을 보류하는 것도 성능 작업이다

측정 결과가 애매하면 일단 머지하고 운영에서 보자는 선택을 할 수 있다. 위험이 낮고 관측 장치가 충분하다면 가능한 판단이다. 그러나 개선 효과를 근거로 복잡한 코드나 새로운 캐시 계층을 추가한다면 기준이 달라진다.

효과가 노이즈와 구분되지 않는다면 다음 중 하나가 더 정직하다.

  • 실험을 보강한다.
  • 변경 범위를 줄인다.
  • 관측만 먼저 배포한다.
  • 개선 주장을 보류한다.
  • 복잡성에 비해 효과가 작다면 작업을 접는다.

성능 PR을 멈추는 것은 실패가 아니다. 증명되지 않은 복잡성을 제품에 넣지 않은 결과일 수 있다.

마치며

어제의 평균은 나쁘지 않았다. 하지만 원본값에는 평균 근처의 실행이 거의 없었다. 그 순간 질문은 “얼마나 빨라졌나”에서 “왜 실행이 두 종류로 갈리는가”로 바뀌었다.

평균은 요약에는 유용하다. 원인을 찾는 데는 너무 많은 것을 지운다.

성능값이 두 무리로 갈리면 평균을 계산하기 전에 두 무리를 만든 조건부터 찾아야 한다.

캐시인지, 연결인지, 프리페치인지 아직 모를 수 있다. 그 상태에서 “개선됐다”고 결론 내리지 않는 것부터가 올바른 성능 측정의 시작이다.