브라우저 관측

31분 분량

이번 포스팅에서는 브라우저 관측에 대한 이야기를 해보려고 한다.

필자는 이 블로그에 방문자가 체감하는 성능을 직접 수집하는 코드를 붙이면서, 익숙하다고 믿었던 브라우저 쪽 지식이 생각보다 얕았다는 것을 알게 됐다. DevTools의 Performance 패널을 열고 Lighthouse를 실행하는 일은 오래 해왔다. 그러나 실제 사용자가 겪은 일을 지속적으로 수집하려면 질문이 달라진다. 브라우저가 언제 어떤 값을 만들고, 그 값이 무엇을 포함하며, 어떤 조건에서는 왜 만들어지지 않는지까지 알아야 한다.

이 지식은 한 문서에 모여 있지 않다. Performance Timeline, Navigation Timing, Resource Timing, Paint Timing, Event Timing처럼 여러 명세에 흩어져 있고, 그 위에 Web Vitals가 별도의 계산 규칙을 얹는다. SPA의 화면 전환, bfcache를 통한 페이지 상태 복원, 백그라운드 탭, iframe까지 들어오면 같은 페이지를 두고도 도구마다 다른 숫자가 나온다. (필자도 값이 예상과 다를 때 사이트가 느린 것인지 측정 규칙 때문인지 가르는 데서 가장 오래 헤맸다)

그래서 이 글은 브라우저가 남긴 사건, 사용자가 체감한 경험, 실제 사용자 집단의 분포라는 세 단계로 관측 범위를 넓혀간다. 그 사이사이에 이 블로그가 실제로 수집하는 값과, 반대로 관측을 늘리지 않기로 한 결정도 함께 놓아본다. 브라우저 관측의 깊이는 API를 얼마나 많이 쓰는지로 결정되지 않는다. 원재료와 지표와 분포를 구분하고, 그 과정에서 빠진 사용자와 왜곡된 조건까지 기록할 때 관측값은 판단에 쓸 수 있는 정보가 된다.

브라우저가 남기는 신호

웹 페이지가 열리면 브라우저는 내부에서 여러 종류의 PerformanceEntry를 만든다. W3C의 Performance Timeline은 이 항목들을 하나의 시간축에서 다룰 수 있는 공통 기반이다.

중요한 점은 개발자가 스톱워치처럼 시작과 끝을 직접 찍지 않아도 된다는 것이다. 브라우저는 문서 탐색, 리소스 요청, 페인트, 사용자 입력과 같은 사건을 이미 알고 있다. 프론트엔드 관측의 출발점은 이 내부 기록을 읽는 일이다.

관측 대상 대표 entry 답할 수 있는 질문
문서 탐색 navigation DNS, 연결, TLS, 응답, DOM 처리 중 어디에서 시간이 걸렸는가
이미지·스크립트·CSS resource 어떤 리소스가 늦었고 캐시와 전송 크기는 어떠했는가
화면 표시 paint, largest-contentful-paint 첫 화면과 주요 콘텐츠가 언제 보였는가
레이아웃 변화 layout-shift 사용자가 보는 화면이 언제, 무엇 때문에 움직였는가
사용자 입력 event 입력 이후 브라우저가 다음 화면을 그리기까지 어디에서 지연됐는가
긴 렌더링 작업 long-animation-frame 한 프레임 안에서 어떤 스크립트와 렌더링 단계가 시간을 사용했는가
애플리케이션 구간 mark, measure 서비스가 정의한 작업은 얼마나 걸렸는가

이 표를 보면 브라우저 관측이 단순한 페이지 로딩 시간 측정이 아니라는 점이 드러난다. 네트워크, 메인 스레드, 렌더링 파이프라인, 사용자 입력을 같은 시간축 위에 올릴 수 있다.

하지만 각각의 entry는 완성된 결론이 아니다. 브라우저가 제공하는 원재료에 가깝다.

성능 entry 수집

이 원재료를 실시간으로 받는 표준 인터페이스가 PerformanceObserver다.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    sendPerformanceEntry(entry)
  }
})
 
observer.observe({ type: 'resource', buffered: true })

코드는 짧지만 여기에는 몇 가지 중요한 조건이 숨어 있다.

첫째, MDN의 observe() 문서가 설명하듯 bufferedtype과 함께 사용해야 한다. 여러 유형을 한 번에 받는 entryTypes와는 같이 쓸 수 없다. 초기 스크립트 실행 전에 만들어진 LCP 후보나 리소스 기록을 받아야 한다면 이 차이가 데이터 누락을 가른다.

둘째, 브라우저가 지원하지 않는 entry는 조용히 무시될 수 있다. 따라서 수집기는 PerformanceObserver.supportedEntryTypes를 확인해야 한다. 최신 Chrome에서 보이는 값이 모든 Safari와 Firefox에서도 들어올 것이라고 가정하면 대시보드의 빈 구간을 성능 문제로 오해하게 된다.

셋째, entry에는 버퍼 한도가 있다. 관측 코드를 늦게 시작하거나 리소스를 매우 많이 요청하는 애플리케이션에서는 과거 항목이 버퍼에서 밀려날 수 있다. "에러가 없었다"와 "에러를 받지 못했다"가 다르듯, "entry가 없다"와 "entry가 생성되지 않았다"도 구분해야 한다.

관측 코드 자체도 메인 스레드에서 실행된다. 콜백 안에서 큰 객체를 직렬화하고 즉시 네트워크 요청을 보내면 사용자 경험을 측정하는 코드가 사용자 경험을 악화시킬 수 있다. 수집과 전송을 분리하고, 필요한 속성만 정규화하며, 배치와 sampling을 설계해야 하는 이유다.

네트워크 시간의 구성

페이지가 느리다는 말을 가장 먼저 네트워크 문제로 번역하는 경우가 많다. 하지만 duration 하나만 보면 원인을 구분하기 어렵다.

PerformanceNavigationTimingPerformanceResourceTiming에는 요청 전후의 여러 경계가 들어 있다. DNS 조회, TCP 연결, TLS 협상, 요청 전송, 첫 바이트, 응답 완료를 나눠 볼 수 있다. 브라우저가 Service Worker를 거쳤는지, 전송된 크기와 디코딩된 크기가 얼마나 다른지, 리소스가 렌더링을 막았는지도 확인할 수 있다.

이 구분은 대응을 바꾼다.

  • domainLookupEnd - domainLookupStart가 크면 DNS 계층을 본다.
  • connectEnd - connectStart가 크면 연결과 TLS를 본다.
  • responseStart - requestStart가 크면 서버 처리와 네트워크 왕복을 함께 의심한다.
  • responseEnd - responseStart가 크면 응답 크기와 전송 속도를 본다.
  • transferSize가 0이면 캐시 재사용 가능성을 확인하되, cross-origin 공개 제한이나 브라우저 구현 조건도 함께 본다.

다만 cross-origin 리소스는 기본적으로 상세 구간이 가려진다. W3C의 Resource Timing 명세가 설명하듯 제공 서버가 Timing-Allow-Origin 응답 헤더로 허용해야 일부 상세 값이 노출된다. CDN이나 외부 이미지가 느린 것 같아도 브라우저 안에서는 구간이 0으로 보일 수 있는 것이다.

그래서 RUM 데이터에서 0은 항상 빠르다는 뜻이 아니다. 권한 때문에 보이지 않는 값일 수 있다.

Web Vitals의 해석

Web Vitals에 포함되는 LCP, INP, CLS는 브라우저가 단순히 한 번 기록한 타임스탬프가 아니다. 여러 entry와 페이지 수명 주기를 해석해 만든 사용자 중심 지표다.

LCP는 화면에 보이는 주요 콘텐츠 후보가 바뀔 때마다 갱신된다. INP는 페이지 수명 동안의 클릭, 탭, 키보드 상호작용을 관찰한 뒤 가장 느린 값에 가까운 하나를 대표값으로 고른다. 상호작용이 50회를 넘으면 극단값 일부를 제외하지만, 대부분의 페이지에서는 가장 느린 상호작용이 INP가 된다. CLS는 모든 이동을 무한히 더하지 않고 일정한 시간 간격으로 묶인 세션 윈도우 중 가장 큰 값을 선택한다.

직접 계산할 수는 있지만, 페이지가 숨겨지거나 복원되는 시점까지 포함해 경계 조건을 맞추기는 어렵다. Google의 web-vitals 라이브러리는 브라우저 entry를 그대로 전달하는 도구가 아니라, 표준 API 위에서 이런 수명 주기와 지표별 계산 규칙을 적용하는 구현체다.

여기서 한 단계 더 들어가면 점수만으로는 부족하다는 문제가 생긴다. LCP가 4초라는 사실은 알았는데 어떤 요소와 리소스가 그 값을 만들었는지 모르면 수정 지점을 찾을 수 없다. web-vitals/attribution 빌드는 LCP 요소, INP의 이벤트 대상과 처리 구간, CLS에 기여한 요소처럼 원인에 가까운 정보를 추가한다.

즉 관측은 다음 세 단계로 깊어진다.

  1. 지표를 수집한다.
  2. 느린 사용자와 환경의 분포를 찾는다.
  3. 지표를 만든 요소, 스크립트, 요청까지 귀속한다.

첫 단계만으로는 보고서가 생기고, 세 번째 단계까지 가야 수정 가능한 정보가 생긴다.

실험실과 실제 사용자

Lighthouse와 DevTools는 통제된 조건에서 같은 페이지를 반복 측정하기 좋다. 코드를 배포하기 전에 회귀를 잡거나 특정 프로파일을 깊게 분석할 때 유용하다. 하지만 사용자가 실제로 어떤 기기와 네트워크에서 어떤 상호작용을 했는지는 보여주지 못한다.

RUM(Real User Monitoring)은 실제 방문자의 브라우저에서 값을 수집한다. Google의 Web Vitals 문서는 Core Web Vitals를 페이지 방문의 75번째 백분위수에서 평가하고 모바일과 데스크톱을 나눠 보도록 권한다. 평균값 하나로는 느린 사용자 집단이 지워질 수 있기 때문이다.

특히 INP는 실제 입력이 있어야 계산할 수 있다. 사용자가 없는 Lighthouse에서는 INP 대신 TBT를 대리 지표로 사용한다. 둘은 관련이 있지만 같은 값은 아니다.

Chrome UX Report(CrUX)도 RUM이지만 서비스 내부 RUM과는 성격이 다르다. CrUX API는 Chrome 사용자의 집계된 field data를 페이지나 origin 단위로 제공한다. 업계 기준과 비교하기에는 좋지만, 특정 릴리스나 사용자 흐름, 애플리케이션 상태를 함께 볼 수는 없다.

반대로 자체 RUM은 원하는 문맥을 붙일 수 있지만 표본 편향과 구현 오류를 직접 책임져야 한다. 광고 차단기가 수집 요청을 막거나, 동의하지 않은 사용자를 제외하거나, 특정 브라우저가 API를 지원하지 않으면 관측되는 사용자는 전체 사용자와 달라진다.

필자의 블로그가 그 선택의 작은 예다. 이 블로그는 WebVitalsReporter라는 클라이언트 컴포넌트 하나가 web-vitals를 동적으로 불러와 LCP, INP, CLS, FCP, TTFB를 측정하고, GA4에 web_vitals라는 단일 이벤트로 보낸다. 지표 이름은 event_label로 구분하고, 같은 페이지 수명에서 갱신된 값을 중복 집계하지 않도록 metric.id를 함께 싣는다. GA4 이벤트의 value는 정수라서 CLS는 1000을 곱해 반올림한다. 별도 수집 서버 없이 이미 운영하던 GA4에 얹은 구성이다.

정직하게 적어두면 이 구성으로 필자가 지금까지 확인한 것은 값이 전송된다는 사실까지다. 전용 RUM 제품이라면 기본으로 주는 p75 분포나 느린 페이지 순위를 GA4에서 보려면 탐색 보고서를 따로 짜야 하는데, 그 작업을 아직 하지 않았다. 그러니까 이 층은 켜져 있고 데이터는 쌓이고 있지만, 그것을 읽는 쪽은 비어 있다. 그리고 위의 편향도 그대로 물려받는다. 광고 차단기가 GA 요청을 막으면 그 방문자는 필자의 분포에서 빠진다.

둘 중 하나가 정답이 아니라 질문이 다르다.

  • 배포 전에 이 변경이 느려졌는가: lab data
  • 실제 사용자의 느린 구간은 어디인가: 자체 RUM
  • 공개 웹에서 이 origin은 어느 수준인가: CrUX

자체 RUM을 선택하면 다음 질문은 무엇을 한 번의 페이지 경험으로 셀 것인가다. 이 경계가 정해져야 수집할 값과 문맥도 일관되게 설계할 수 있다.

흐려진 페이지 경계

전통적인 navigation은 브라우저가 문서의 시작과 끝을 알고 있다. SPA의 client-side navigation은 URL과 화면이 바뀌어도 문서가 새로 만들어지지 않는다. 브라우저 관점에서는 하나의 긴 페이지 수명으로 남기 쉽다.

이 문제를 해결하려고 각 RUM 도구와 프레임워크가 자체 휴리스틱을 사용해왔다. 하지만 구현마다 "새 화면"의 정의가 달라 비교하기 어려웠다. Chrome 팀은 Soft Navigations API를 통해 사용자 입력, URL 변화, 화면 갱신을 묶어 브라우저가 soft navigation을 직접 인식하는 방향을 추진해왔다.

이 API는 2026년 8월에 나온 Chrome 151부터 기본 제공된다. web-vitals 라이브러리도 6.0부터 reportSoftNavs 옵션으로 soft navigation 단위의 지표 보고를 지원하기 시작했다. 다만 아직 Chromium 계열에서만 동작하고 Firefox와 Safari에는 대응 구현이 없으므로, 기존 route 계측을 즉시 대체할 수는 없다. 필자의 블로그도 Next.js의 client-side navigation으로 글 목록에서 글로 들어가는데, 수집 코드가 5.x 기반이던 동안에는 그 전환이 별도의 페이지 경험으로 잡히지 않았다. 이 글을 쓰면서 6.2.1로 올리고 reportSoftNavs를 켠 뒤, 프로덕션에 헤드리스 Chrome을 붙여 GA4로 나가는 요청을 그대로 열어봤다. 한 세션에서 이런 값이 나갔다.

지표 navigationType
TTFB 798ms navigate
FCP 1680ms navigate
LCP 1680ms navigate
FCP 542ms soft-navigation
TTFB 0ms soft-navigation

의도대로 목록에서 글로 들어간 전환이 별도 경험으로 잡혔다. 그런데 같은 표가 옵션 하나로 끝나지 않는 이유도 보여준다. soft navigation의 TTFB는 0이다. 서버에 요청한 적이 없으니 당연한 값인데, 이 0이 최초 로드의 798ms와 같은 자리에 쌓이면 TTFB 평균은 코드를 고치지 않아도 조용히 내려간다. 그래서 지표마다 함께 오는 navigationType을 이벤트 파라미터로 같이 보내도록 고쳐야 했다. 관측을 하나 늘리면 그것을 구분할 차원도 같이 늘어난다.

재보면서 알게 된 것이 두 개 더 있다. 하나는 브라우저가 soft navigation을 인정하려면 사용자 입력이 있어야 한다는 점이다. 스크립트로 click()을 호출했을 때는 URL이 바뀌고 화면이 갱신됐는데도 soft-navigation entry가 생기지 않았고, 실제 마우스 입력을 보내고 나서야 잡혔다. 다른 하나는 이 블로그에서 그 전환이 일어나는 자리가 생각보다 좁다는 것이다. 목록과 헤더의 링크는 Next의 Link라 client-side navigation이지만, 글 본문 안의 내부 링크는 마크다운이 만든 평범한 a 태그라 전체 페이지 로드다. 같은 사이트 안에서도 어떤 이동은 soft navigation이고 어떤 이동은 아니다.

이 사례가 보여주는 더 중요한 사실은 SPA 성능 측정이 단순한 라이브러리 설정 문제가 아니라 페이지의 경계를 누가 정의할 것인가의 문제라는 점이다.

페이지 경계를 정하면 route, metric id, session 문맥을 어떤 단위로 저장할지도 정할 수 있다. 이제 그 질문을 RUM 데이터 모델로 옮겨보자.

RUM 데이터 모델

navigator.sendBeacon()으로 값을 보내는 코드는 길지 않다. 어려운 것은 나중에 답하고 싶은 질문을 잃지 않으면서 비용과 cardinality를 통제하는 일이다.

최소한 다음 문맥을 함께 고려하게 된다.

문맥 필요한 이유
페이지와 route 느린 화면을 구분한다
release와 commit 회귀가 들어온 배포를 찾는다
navigation type 새 탐색, 새로고침, bfcache 복원을 구분한다
device와 connection 환경에 따른 분포 차이를 본다
metric id 같은 페이지 수명에서 갱신된 값을 중복 집계하지 않는다
session과 trace id 행동, 에러, 서버 요청을 연결한다
visibility state 백그라운드 탭에서 왜곡된 값을 거른다

여기에 DOM selector 전체, URL 전체, 사용자 ID를 무작정 붙이면 분석은 쉬워 보이지만 비용과 개인정보 위험이 커진다. 동적인 URL은 cardinality를 폭발시키고, selector와 네트워크 본문에는 개인 정보가 섞일 수 있다.

관측 데이터는 많을수록 좋은 것이 아니다. 나중에 내릴 결정과 연결되지 않는 속성은 수집하지 않는 편이 낫다.

수집과 저장의 선택지

브라우저 관측을 직접 처음부터 만들 필요는 없다.

  • web-vitals는 Core Web Vitals 계산과 attribution을 제공한다.
  • Boomerang은 오래된 오픈소스 RUM 수집기로 여러 성능 플러그인과 beacon 전송 방식을 제공한다.
  • Grafana Faro Web SDK는 성능, 에러, 로그, trace를 브라우저에서 수집하고 백엔드 관측과 연결한다.
  • OpenTelemetry JavaScript는 브라우저 trace를 만들 수 있지만, 공식 문서는 브라우저 client instrumentation을 아직 experimental로 표시한다.

도구를 고를 때는 기능 개수보다 소유할 범위를 먼저 봐야 한다. SDK만 쓸 것인지, 수집 endpoint와 저장소도 운영할 것인지, 개인정보 삭제와 보존 정책까지 직접 책임질 것인지에 따라 선택이 달라진다.

SaaS를 쓰면 운영 부담이 줄고, 오픈소스 스택을 직접 운영하면 데이터 경로와 비용 모델을 더 세밀하게 통제할 수 있다. 어느 쪽도 공짜는 아니다.

79KB를 포기한 결정

비용 이야기가 나온 김에, 필자가 이 블로그에서 실제로 내린 결정 하나를 적어둔다. 이 블로그의 에러 계측(Sentry)은 서버 전용이다. 필자는 브라우저에서만 나는 에러를 못 보는 상태가 계속 마음에 걸려서, 클라이언트 계측을 켜보고 clean build 기준으로 클라이언트 JS의 gzip 총합을 비교했다.

구성 client JS (gzip) 증가분
Sentry 미적용 181.6 KB 기준
서버 전용 (현재) 182.3 KB +0.7 KB
서버 전용 + 에러 폴백 UI에서 captureException 호출 186.0 KB +4.4 KB
클라이언트 + 서버 260.4 KB +78.8 KB

서버 계측은 사실상 공짜인데 브라우저 계측은 78.8KB를 요구했다. 번들 최적화 옵션도 시도했지만 수치는 그대로였고, 클라이언트 비용을 줄이는 유일한 방법은 브라우저 초기화 파일을 아예 두지 않는 것이었다. 세 번째 행이 4.4KB를 차지하는 이유도 같은 구조다. 브라우저 SDK가 없으면 에러 폴백 UI의 captureException은 아무 일도 하지 않는 no-op인데, SDK 코드는 번들에 실린다. 그래서 호출 자체를 뺐다.

이 측정에는 흠도 있다. 정적 산출물 전체를 합산한 값이라 방문자 한 명이 실제로 내려받는 양과 다르고, 최적화 옵션을 켰는데 한 바이트도 줄지 않았다는 것은 그 옵션이 먹지 않았다는 신호일 수도 있다. 그러니 정확히 쓰면 "브라우저 관측은 79KB다"가 아니라 "필자의 설정에서는 그 아래로 내리지 못했다"가 맞다.

그래도 결정은 명확했다. 이 블로그에서 로딩 성능은 곧 사용자 경험이자 검색 노출의 전제인데, 78.8KB의 비용을 내는 쪽은 필자가 아니라 방문자다. 사용자가 비용을 내는 관측이라면 그 관측이 사용자에게 무엇을 돌려주는지 물어야 하고, 개인 블로그의 클라이언트 에러 계측에 대한 필자의 답은 "충분히 돌려주지 못한다"였다. 앞 절에서 사용자 경험을 측정하는 코드가 사용자 경험을 악화시킬 수 있다고 했는데, 그 원칙을 도구 도입 단위로 확장하면 이렇게 된다. 관측을 더 촘촘히 하는 것이 항상 옳은 선택은 아니다.

질문의 진입 장벽

앞에서 본 API와 도구를 실제 질문으로 연결할 때 AI가 유용한 지점은 세 가지다.

첫째, 현상에서 명세로 가는 경로를 좁힌다. "LCP가 두 번 들어온다", "cross-origin 리소스의 구간이 모두 0이다", "SPA 이동 뒤 값이 갱신되지 않는다" 같은 현상을 관련 API와 조건으로 연결할 수 있다.

둘째, 서로 다른 도구의 결과를 같은 시간축으로 번역하는 일을 돕는다. DevTools trace, RUM event, Sentry span, 서버 로그가 가리키는 시각과 식별자가 다를 때 비교할 후보를 빠르게 만들 수 있다.

셋째, 수집된 데이터에서 분포와 이상 구간을 탐색할 수 있다. 단순 평균 대신 브라우저, route, release, device별 차이를 제안하고 다음 쿼리를 만드는 비용이 내려간다.

그러나 이 과정은 후보를 만드는 일이지 근거를 대신하는 일이 아니다. 수집되지 않은 사용자는 데이터에 없고, 잘못 정의한 metric은 정교하게 분석해도 잘못된 결론을 낸다. 개인정보를 보내도 되는지, 관측 코드의 비용을 사용자가 부담해도 되는지도 기술 문서만으로 결정할 수 없다.

AI는 브라우저를 더 잘 관측하게 만드는 새로운 센서라기보다, 기존 센서의 사용 설명서를 읽고 질문을 만드는 비용을 낮추는 도구에 가깝다.

관측은 질문에서 시작된다

정리하면 브라우저는 네트워크, 렌더링, 입력, 레이아웃 변화를 이미 상세하게 기록하고 있다. PerformanceObserver는 그 기록을 읽는 출발점이고, Web Vitals는 이를 사용자 경험의 언어로 해석한 지표이며, RUM은 실제 사용자 환경에서 그 분포를 지속적으로 수집하는 체계다.

세 단계는 닮아 보이지만 답하는 질문이 다르다. entry는 브라우저에서 무슨 일이 있었는지 알려주고, Web Vitals는 사용자가 무엇을 체감했는지 압축하며, RUM은 그 경험이 누구에게 얼마나 반복되는지 보여준다.

여러 명세를 읽고 도구를 조합하는 문턱은 낮아졌지만, 쉽게 수집할 수 있게 된 것과 올바르게 해석할 수 있게 된 것은 다른 문제다. 어떤 사용자가 빠졌고 어떤 조건에서 값이 왜곡되는지까지 설명할 수 있을 때, 관측 데이터는 비로소 판단에 쓸 수 있는 정보가 된다. 이 글을 읽는 독자 분들도 지금 보고 있는 성능 숫자가 누구의 경험을 어떤 규칙으로 요약한 값인지 한 번 되짚어보면 좋겠다.

다음 글인 시스템 관측에서는 이 브라우저 데이터를 에러, trace, profile, 서버 로그와 어떻게 연결할 수 있는지 살펴보려고 한다. 사용자 화면에서 시작된 한 번의 요청이 시스템 안에서 어디까지 이어졌는지를 따라가는 이야기다.

참고 자료

함께 읽으면 좋은 글

댓글