Cloudflare Analytics CLS 점수가 나쁨일 때 — Cumulative Layout Shift 검산법

CloudflareAnalyticsCoreWebVitalsCLS성능측정트러블슈팅

Cloudflare Web Analytics 대시보드가 이 블로그의 CLS를 “나쁨” 으로 표시했습니다. CLS는 페이지가 뜨는 동안 화면 요소가 얼마나 밀려났는지 재는 값이고, 구글 Core Web Vitals의 세 지표 중 하나입니다. 백분위를 펼쳐 보니 P50부터 P99까지 전부 같은 값이었습니다.

원인은 사이트가 아니라 측정 쪽이었습니다.

대시보드에 값을 보내는 비콘(방문자 브라우저에서 지표를 재서 전송하는 작은 스크립트)이 전송 직전에 모든 지표를 정수로 올립니다.

CLS는 좋음과 나쁨을 가르는 값이 소수점 아래에 있습니다. 올림을 거치면 시프트가 없는 페이지와 있는 페이지 두 종류로만 남습니다.

그래서 이 화면은 값을 버리고 건수만 읽습니다.

“CLS 나쁨 N건”은 “시프트가 조금이라도 있었던 페이지뷰가 N건”이라는 뜻입니다.

검색 순위와도 무관합니다. 구글은 Core Web Vitals 판정에 이 비콘 대신 크롬 사용자 실측 데이터인 CrUX를 쓰고, 그 값은 Search Console에서 봅니다.

다만 값을 못 믿는다는 것과 시프트가 없다는 것은 다릅니다.

건수는 살아 있었고, 함께 오는 좌표를 따라가니 결함이 하나 나왔습니다.

body 에 min-width 한 줄이 없었습니다.

오버레이가 뜨는 순간 페이지 전체가 624px 옆으로 점프하고 있었습니다.

왜 값이 전부 1로 올까?

Cloudflare의 beacon.min.js는 전송 직전에 모든 지표 값에 일괄로 올림을 겁니다.

d.forEach(t => { P[t] && void 0 !== P[t].value && (
  n[t] = P[t], n[t] && n[t].value && (n[t].value = Math.ceil(n[t].value)) ) })

LCP와 INP는 밀리초 단위입니다. 2847.3ms를 2848ms로 올려도 판정이 흔들리지 않습니다.

CLS는 무단위 소수입니다. 상한이 있는 값은 아니지만(이동이 쌓이면 1을 넘습니다) 실제 페이지에서는 거의 늘 1 미만이고, 좋음 기준이 0.1, 나쁨 기준이 0.25입니다. 그 사이의 모든 값이 올림 한 번에 1로 붙습니다.

  • 시프트가 전혀 없으면 → 0
  • 0보다 크고 1 이하면 → 무엇이든 1 (나쁨 기준의 네 배)
  • 1을 넘는 극단값만 → 2 이상

로컬 빌드에서 확인했습니다. CLS 0.02853(좋음 기준 0.1의 4분의 1)을 만들었더니 전송 페이로드가 "cls":{"value":1}이었습니다. 이 대시보드에는 “개선 필요” 구간이 존재할 수 없습니다. 정수만 남고, 실제 페이지에서는 사실상 0 아니면 1이니까요.

브라우저가 잰 0.02853 은 좋음 구간에 있는데 비콘은 올림을 거쳐 1 로 보내므로, 이 화면에 남는 값은 0 과 1 둘뿐이고 찍히는 1 은 나쁨 기준 0.25 의 네 배다 ① 브라우저가 잰 값 ② 비콘이 보내는 값 0.1 0.25 1 0 1 실측값 0.02853 은 좋음 구간 좋음 나쁨 좋음 기준 0.1, 나쁨 기준 0.25 0 보다 크면 무엇이든 1 나쁨 기준의 네 배다
같은 눈금을 위아래에 두었습니다. 브라우저가 잰 값이 어디에 있든 비콘은 0 아니면 1 로만 보냅니다.

대시보드가 보여준 백분위는 이랬습니다.

백분위 CLS
P50 1
P75 1
P90 1
P99 1

P75(방문의 4분의 3이 그 값보다 좋다는 뜻)도 같은 운명입니다.

상수를 백분위로 늘어놓으면 네 줄이 같은 숫자로 채워집니다.

백분위가 전부 같다는 사실만으로 측정 오류라고 단정할 수는 없어요. 다만 연속값이 정수 하나에 전부 몰린 모양은 단서로 충분했습니다.

증명은 위 코드였습니다.

그럼 시프트는 정말 있었나?

건수는 실재합니다. 시프트가 0이면 0으로 옵니다.

값을 못 쓰게 됐으니 판별을 좌표로 바꿨습니다.

비콘은 시프트가 난 요소의 사각형(rect)을 함께 보내고, 그 좌표는 0이 아닌 한 실재하는 값에서 옵니다.

가장 많이 잡힌 서명은 이것이었습니다.

이전   x:624,  width:672,  right:1296
이후   x:0

672px은 이 사이트 본문 컨테이너의 폭(42rem)입니다. 그리고 1920px 화면에서 (1920 − 672) ÷ 2 = 624입니다. 페이지 전체가 중앙에서 왼쪽 끝으로 점프한 것입니다.

원인은 CSS 한 줄의 부재였습니다.

body {
  margin: 0;
  /* ⚠️ 지우지 말 것. 이 한 줄이 CLS를 막는다.
   *
   * 이 사이트의 가로 정렬은 전부 `.wrap { max-width: 42rem; margin: 0 auto }`
   * 하나에 달려 있다. 그래서 body 폭이 곧 정렬 기준이다.
   *
   * 외부 스크립트(애드센스 앵커·비네트 광고)가 오버레이를 띄우며 스크롤을 잠글 때
   * `body { position: fixed }` 를 건다. 그러면 body가 shrink-to-fit 이 되어
   * 폭이 뷰포트가 아니라 가장 넓은 자식 = .wrap 의 672px 로 붕괴한다. */
  min-width: 100%;
}

position: fixed 가 걸린 body 는 shrink-to-fit 이 됩니다.

폭이 뷰포트 대신 가장 넓은 자식을 따라가고, 그 자식이 672px 짜리 본문 컨테이너였습니다.

외부 스크립트가 오버레이를 띄우며 스크롤을 잠글 때 body { position: fixed } 를 겁니다. 그래서 배너 하나가 페이지 정렬 전체를 무너뜨릴 수 있습니다.

min-width 는 width 보다 나중에 적용되므로 fixed 와 fit-content 와 명시적 width 세 경우를 모두 되돌립니다.

⚠️ 좌표에 0이 보이면 실제 0으로 읽지 않습니다.

대시보드는 일부 필드를 0으로 지워 보여주는데, 비콘이 보낸 페이로드에는 온전한 값이 들어 있었습니다.

0은 “정보 없음”으로 읽고 0이 아닌 값만 실측으로 믿습니다. 그 대조는 부록 2에 뒀습니다.

재현이 왜 안 됐을까?

원인을 확인하려고 로컬에서 창 폭을 바꿔 x를 624에서 0으로 움직여 봤습니다. layout-shift 항목이 한 건도 안 잡혔습니다.

왜 안 잡혔을까요?

버그가 아닙니다. 사용자 입력 뒤 500밀리초 안에 생긴 시프트에는 hadRecentInput 플래그가 붙고, 집계에서 뺄 수 있습니다. 그 문서가 예로 드는 것은 탭과 클릭과 키 입력이고 창 리사이즈는 적혀 있지 않은데, 제가 만든 리사이즈 시프트에는 그 플래그가 찍혀 있었고 비콘은 그 항목을 버립니다.

if (t.hadRecentInput) return;

재현 방법이 틀리면 맞는 가설을 스스로 기각합니다.

리포트에 올라온 시프트는 정의상 입력과 무관한 원인에서 온 것입니다. 그렇다면 리사이즈로는 절대 재현되지 않습니다.

실제 트리거는 창 리사이즈가 아니고 오버레이가 거는 position: fixed 였습니다.

하나 더 있습니다. 라이브 사이트에서 CLS 실험을 하면 그 트래픽이 대시보드에 그대로 쌓이고 걸러낼 방법이 없습니다. “건수가 안 늘면 우리 테스트 트래픽이었다”는 판별법도 쓸 수 없습니다. 개발과 검수로 우리 자신이 계속 방문하므로 트래픽이 잠잠해지는 구간이 없습니다(수정 배포 후 24시간에 CLS 14건, 같은 창에 방문 39건). 실험은 로컬 빌드에서 합니다.

검산 넷, 전부 이미 받은 화면에서

데이터를 더 모으지 않고 대시보드와 소스만으로 할 수 있는 확인입니다.

검산 무엇을 보나 이번에 나온 것
표본 수 백분위의 분모 백분위를 논할 크기가 아니었음
내부 정합성 right = x + width 가 맞는가 자기모순 — 단 0은 결측이고 실제 0이 아니었음
분포 형태 연속값이 정수에만 놓이는가 전부 1, 이산화 단서 (증명은 비콘 코드)
독립 교차검증 다른 파이프라인인가 CrUX / Search Console

표본 수는 가장 싼 검산이고 가장 자주 건너뜁니다. 트래픽이 하루 수십 명인 사이트에서 “나쁨 3건”은 세 명의 특정 브라우저 상태일 수 있습니다. 그런데도 대시보드는 표본 수와 무관하게 늘 같은 얼굴로 P75를 보여줍니다.

마지막 줄이 가장 자주 어긋납니다. 교차검증을 같은 소스의 다른 화면에서 하면 정보가 늘지 않습니다. 같은 파이프라인이면 같은 올림을 지나오니까요. 독립 소스여야 합니다.

자주 묻는 질문

그럼 이 대시보드는 못 쓰나요

값은 못 씁니다. 건수는 씁니다. “CLS 나쁨 N건” 은 “시프트가 조금이라도 있었던 페이지뷰가 N건” 이라는 뜻이고, 함께 오는 좌표가 어디가 밀렸는지 알려줍니다.

구글 판정에 영향이 있나요

없습니다. 구글은 Core Web Vitals 판정에 이 비콘이 아니라 크롬 사용자 실측 데이터인 CrUX 를 씁니다. 그 값은 Search Console 에서 봅니다.

왜 창 리사이즈로 재현이 안 되나요

리사이즈로 만든 시프트에는 hadRecentInput 플래그가 찍히고 비콘이 그 항목을 버립니다. 리포트에 올라온 시프트는 정의상 입력과 무관한 원인에서 온 것이라 리사이즈로는 절대 재현되지 않아요.

min-width 값은 어떻게 정하나요

본문 컨테이너 폭에 맞춥니다. 이 사이트는 42rem(672px)이고, min-width 가 width 보다 나중에 적용되므로 fixed 와 fit-content 와 명시적 width 를 모두 되돌립니다.

참고 자료

  • web.dev — CLS 판정 기준 은 좋음이 0.1, 나쁨이 0.25 라는 근거입니다. 그 사이 값이 올림 한 번에 전부 1 로 붙는 이유가 여기 있습니다.
  • web.dev — 사용자 입력에서 비롯된 시프트 는 입력 뒤 500ms 안의 시프트에 hadRecentInput 이 붙고 집계에서 뺄 수 있다는 근거입니다. 그 문서가 예로 드는 것은 탭과 클릭과 키 입력이고 창 리사이즈는 적혀 있지 않습니다.

남는 것

리포트의 숫자도 검산 대상입니다. 계측 도구가 준 값이라고 해서 그것이 관측이라는 보장은 없습니다. 이번에는 값들 사이의 항등식 하나와 백분위 네 줄이 단서였고, 확정은 비콘 코드에서 했습니다.

일반론 진단을 코드 확인 없이 적용하지 않습니다. CLS가 나쁘다고 하면 나오는 원인 목록은 정해져 있습니다. 늦게 오는 CSS, DOM을 만지는 JS, 웹폰트 교체. 흔한 원인은 흔하기 때문에 확인 없이 통과하고, 이미 들어 있는 수정을 다시 적용한 뒤 좋은 점수를 보면 고쳤다고 읽게 됩니다. 반대로 “우리는 해당 없다”고 넘기는 것도 확인 없이 하면 안 됩니다. 저는 스타일시트가 외부 링크라는 사실을 확인하고 나서야 첫 항목을 살려뒀고, 그 항목이 부록 1의 서명으로 돌아왔습니다.

재현 실패는 원인을 기각하는 근거가 못 됩니다. 창 리사이즈는 집계에서 빠지는 경로였습니다. 무스타일 서명은 거꾸로, 네 번 시도해 세 번 음성이었지만 그게 그런 일이 없었다는 증명까지는 못 됩니다. 정상 경로로는 나지 않는다까지가 확인된 전부입니다.

원인을 몰라도 유효한 대비책이 있으면 그걸 먼저 확보합니다. 첫 페인트의 기하가 이미 옳으면 CSS가 언제 오든, 심지어 안 와도 이동할 것이 없습니다.

그리고 값이 못 쓰게 됐어도 건수와 좌표는 살아 있었습니다. 도구가 망가졌다는 결론이 이 도구로는 아무것도 못 한다는 뜻이 되지는 않습니다. 어느 열이 죽었고 어느 열이 살았는지 가려내면, 남은 것으로 결함 하나를 찾아낼 수 있습니다.


부록 1. 무스타일 첫 페인트 — 네 번 시도해 세 번 음성

x:8, y:8, width:1904, height:93 서명은 우리 CSS가 적용되기 전 화면이 한 번 그려졌다는 뜻입니다. margin: 8px은 브라우저 기본 스타일시트의 값이고, 높이 93px은 본문이 아직 헤더뿐인 상태입니다.

조건 결과
CSS 응답을 1.5초 지연 FCP 1560ms, 시프트 0건 — 렌더 차단이 정상 작동
<link>를 </header> 뒤로 이동 재현 — 성공한 유일한 조건
로컬 빌드, 캐시 없이 콜드 로드 첫 프레임(23ms)부터 margin: 0px, 시프트 0건
라이브 사이트, 실제 네트워크 TTFB 368 → CSS 374~387 → FCP 448, 시프트 0건

마지막 줄이 결정적입니다. CSS가 첫 페인트보다 61ms 먼저 도착했습니다. 렌더 차단이 의도대로 작동하고 있고, 정상적인 방문 경로로는 이 서명이 나올 수 없습니다.

그러면 리포트에 왜 있을까요? 모릅니다. 우리가 관측할 수 없는 클라이언트 쪽 사정입니다. CSS 요청이 실패해 렌더 차단이 풀린 뒤 뒤늦게 적용됐거나, 렌더 차단을 무시하는 렌더러나 프록시나 확장 프로그램일 수 있습니다. 요청 실패 가설은 아직 검증하지 못했습니다.

대비책은 정해뒀습니다. 레이아웃을 결정하는 규칙만(body의 margin과 min-width, .wrap의 max-width와 margin) <head>에 인라인하는 것입니다. 아직 넣지 않은 이유는 재현 실패와 무관합니다. 이 사이트가 광고 심사 중이고, 심사 중에 <head>를 건드리지 않는다는 별개의 원칙이 있어서입니다.

부록 2. rect 서명 세 종류와 0의 의미

이전 rect 서명 무엇인가
x:624, w:672, right:1296 → x:0 페이지 전체가 중앙에서 왼쪽 끝으로 점프
x:8, y:8, w:1904, h:93 무스타일 첫 페인트 (부록 1)
x:0, w:640 → x:624, w:672 뷰포트 가로폭 640 → 1920, 집계 대상 아님

대시보드가 준 행은 right = x + width를 만족하지 않았고, 높이는 0이었습니다. 리포트에 적힌 값 그대로 따지면 높이가 0이라 면적이 0이고, 면적이 0이라 CLS 기여분도 0입니다. 그런데 대시보드는 이 행을 시프트로 세고 있었습니다.

비콘이 보내는 페이로드를 가로채 보니 답이 나왔습니다.

height: 801.40625     ← 비콘이 실제로 보낸 값

모든 행이 지워지는 것도 아니어서, height: 93 / bottom: 902가 그대로 살아 있는 행도 섞여 있었습니다. 0을 실제 값으로 믿으면 “기여 0인데 왜 세지”라는 존재하지 않는 미스터리를 쫓게 되고, 살아 있는 좌표까지 싸잡아 버리면 유일하게 쓸 수 있는 단서를 버리게 됩니다.

이 글에서 고친 것 3개
  • 2026-09-12 — 처음 발행본은 “실제 측정값이라면 P50과 P99가 같을 수 없습니다. 상수를 백분위로 표시하고 있는 겁니다”라고 썼습니다. 틀린 일반화입니다. 백분위 일치는 이산화의 단서일 뿐이고, 이 글에서 오류의 증거는 비콘 코드와 전송 페이로드의 대응입니다.
  • 2026-09-12 — 처음 발행본은 “CLS는 0과 1 사이의 무단위 소수”라고 썼습니다. CLS에 상한은 없습니다. 올림이 값을 망가뜨린다는 결론은 그대로지만, 그 이유는 “범위가 0~1이라서”가 아니라 “좋음·나쁨을 가르는 값이 전부 1 아래에 있어서”입니다.
  • 2026-09-27 — CLS 판정 기준값과 입력 직후 시프트 제외 규칙에 web.dev 링크를 달았습니다. 창 리사이즈가 그 제외 규칙에 걸린다는 것은 문서에 없어 제 실측으로 표시를 바꿨습니다. 수치와 판정은 그대로입니다.

이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.