성능을 개선했는데 오히려 지표가 나빠 보이는 경험, 혹은 플랫폼마다 같은 데이터가 다른 숫자를 내뱉는 경험을 해본 적 있는가. 전자는 측정 방식의 함정이고, 후자는 아키텍처 일관성의 부재다. 이 두 문제는 겉보기엔 전혀 다른 이야기처럼 보이지만, 결국 같은 질문으로 수렴한다. 우리는 성능을 제대로 보고 있는가?
하나의 숫자가 만드는 네 가지 현실
GeekNews에 소개된 '평균은 아무 의미가 없다' 아티클은 그 질문에 아주 구체적인 답을 들이민다. 새 캐시 계층을 일주일에 걸쳐 점진적으로 배포한 사례에서, 평균 지연 시간은 112ms에서 122ms로 9% 악화됐다. 평균만 보면 즉시 롤백해야 할 상황이다. 그런데 중앙값(p50)은 99ms에서 54ms로 46% 개선됐다. 반면 p99는 309ms에서 678ms로 119% 악화됐다. 같은 데이터, 같은 배포, 완전히 반대 방향을 가리키는 네 가지 판단. 어떤 숫자를 고르느냐에 따라 '대성공'도 '심각한 장애'도 된다.
이 모순의 원인은 분포 형태에 있었다. 배포 전 지연 시간은 하나의 봉우리를 가진 단봉 분포였지만, 배포 후에는 두 개의 봉우리로 갈라진 이봉 분포가 됐다. 빠른 요청 집단과 느린 요청 집단이 분리된 것이다. 누적분포함수(CDF)를 겹쳐 보면 약 140ms를 경계로 그 이하 구간의 요청은 개선되고, 그 이상 구간의 요청은 악화됐다. 두 CDF 곡선이 교차하는 순간, 어떤 단일 백분위도 변화 전체를 요약할 수 없다는 것이 수학적으로 확정된다.
더 흥미로운 것은 시간 축이다. 배포가 0%에서 100%로 확대되는 일주일 동안, 능선 그래프(ridgeline plot)와 히트맵을 통해 보면 빠른 요청의 봉우리가 왼쪽으로 이동하는 동시에 느린 요청의 새 봉우리가 오른쪽에서 조용히 자라나는 것을 볼 수 있다. 배포 전후 두 시점만 비교하는 A/B 방식으로는 이 중간 과정이 완전히 사라진다. 캐시 적중(hit)과 미스(miss)로 데이터를 분해하자 이봉 분포의 원인이 드러났다. 작은 응답은 캐시에 적중해 빨라지고, 큰 응답은 캐시 용량 초과로 미스가 발생해 추가 홉만큼 느려진 것이다. 원인을 알아야 조치가 나온다. 캐시 최대 객체 크기를 늘리거나 큰 응답을 분할하는 방향으로.
분포가 흔들리면 숫자도 흔들린다
이 이야기가 Core Web Vitals 최적화와 직결된다는 점을 짚어야 한다. LCP, CLS, INP 같은 Web Vitals 지표는 단일 백분위(주로 p75)로 집계된다. 구글 Search Console의 'Good' 판정은 p75 기준이다. 하지만 만약 당신의 사이트가 이봉 분포를 가지고 있다면—예컨대 캐시 히트 사용자와 미스 사용자, 혹은 모바일과 데스크톱 사용자가 극단적으로 다른 경험을 한다면—p75 하나로 '최적화됐다'고 말하는 것은 절반의 진실만 말하는 셈이다. 측정 도구가 단일 숫자를 강요할수록, 우리는 분포를 직접 설계하는 습관을 의식적으로 가져야 한다.
세 플랫폼, 하나의 계산 로직
두 번째 이야기는 다른 레이어에서 시작하지만 같은 맥락으로 닿는다. Dev.to에 공개된 SportsFlow 빌드-인-퍼블릭 시리즈의 세 번째 포스트는 웹, React Native(Expo), 오프라인 라이브 트래킹이라는 세 런타임에서 분석 수치가 제각각이었던 문제를 해결한 아키텍처를 다룬다. 시즌 페이지는 62%, 라이브 뷰는 58%, 네이티브 앱은 60%—같은 선수, 같은 경기, 세 개의 슛 성공률. 세 숫자가 다르면 사용자는 어떤 숫자도 믿지 않게 된다.
해법은 @sportsflow/analytics라는 순수 함수 패키지로 분석 로직을 완전히 격리하는 것이었다. 이 패키지에는 단 하나의 규칙이 있다. DB 의존성도, DOM 의존성도 없어야 한다. 이벤트가 들어오면 숫자가 나온다. 그게 전부다. 어댑터가 DB의 raw 이벤트 행을 AnalyticsEvent 타입으로 변환하고, 이후의 모든 계산은 순수 변환이다. 덕분에 동일한 함수가 웹(tRPC를 통해 fetch한 데이터 위에서), 네이티브(Expo에서 같은 import로), 오프라인(서버에 아직 싱크되지 않은 인메모리 이벤트 큐 위에서) 세 곳에서 동일하게 동작한다.
특히 라이브 경기 중 '실시간 스탯' 탭이 별도의 단순화된 구현이 아닌 실제 분석 엔진과 동일한 코드로 돌아간다는 점이 핵심이다. 경기가 끝나고 데이터가 서버에 싱크되면, 시즌 통계도 같은 코드로 계산된다. 드리프트(drift)가 구조적으로 불가능하다. tRPC 라우터는 fetch와 filter만 한다. 집계는 서버에 없다. 서버가 분석에 관한 의견을 가지지 않으니, 서버와 오프라인 경로가 다른 결과를 낼 여지가 없다.
두 이야기가 동시에 가리키는 것
캐시 배포 사례와 크로스플랫폼 분석 엔진 사례는 표면적으로 다른 문제를 다루지만, 두 이야기 모두 '같은 데이터가 다른 숫자를 만드는 상황'을 어떻게 방지하는가에 대한 답이다. 전자는 측정 레이어에서, 후자는 계산 레이어에서 그 답을 찾는다.
프론트엔드 팀이 성능을 다루는 방식에 적용하면 이렇게 된다. Lighthouse 점수 하나, p75 LCP 하나로 '성능 완료'를 선언하는 대신—어떤 사용자 집단에서 어떤 분포가 형성되고 있는지를 묻는 것이 먼저다. 네트워크 조건별, 디바이스 티어별, 캐시 상태별로 분포가 어떻게 갈라지는지를 CDF로 겹쳐 보는 것이 Lighthouse 점수를 0.1 올리는 것보다 훨씬 많은 것을 알려준다.
실무 설계로의 번역
이 두 관점을 실무에 연결하면 세 가지 원칙이 나온다.
첫째, 성능 대시보드에 CDF를 추가하라. 평균과 p95 두 숫자 대신, 배포 전후 CDF를 겹쳐 보는 뷰를 만들어라. 두 곡선이 교차하면 당신의 배포는 일부 사용자를 개선하고 일부를 악화시키고 있는 것이다.
둘째, 하위 집단으로 분해하는 습관을 가져라. 이봉 분포가 보이면 '무엇으로 집단이 갈라지는가'를 묻는 것이 다음 단계다. 캐시 히트/미스, 응답 크기, 디바이스 유형, 지역—어떤 축으로 잘라야 분포가 단봉으로 수렴하는지를 찾는 것이 원인 분석이다.
셋째, 비즈니스 로직은 런타임에서 독립시켜라. 같은 계산이 웹, 앱, 오프라인에서 달라지는 순간 신뢰는 무너진다. 순수 함수 패키지로 분리하고, DB/DOM 의존성을 어댑터 레이어에 가두는 것은 '좋은 설계'가 아니라 '신뢰할 수 있는 숫자'를 위한 필수 조건이다.
전망: 분포를 설계하는 팀이 이긴다
Core Web Vitals는 앞으로도 단일 백분위 숫자로 사용자를 대표하려 할 것이다. AI 기반 성능 분석 도구들도 '요약'을 기본값으로 제공한다. 하지만 요약은 언제나 정보의 손실이다. 진짜 성능 문제는 대부분 평균 뒤에 숨어 있다. 느린 1%의 사용자, 특정 조건에서만 발생하는 캐시 미스, 오프라인 상태에서만 다르게 계산되는 수치—이것들은 단일 숫자로는 절대 보이지 않는다.
측정을 잘 하는 팀과 아키텍처를 일관되게 유지하는 팀은 결국 같은 것을 지향한다. 사용자가 실제로 경험하는 것과 우리가 보는 숫자 사이의 간극을 최소화하는 것. 평균을 버리고 분포를 설계하는 것은 그 간극을 줄이는 가장 직접적인 방법이다.