기록

Lighthouse CLS가 좋을 때 놓치는 밀림

TL;DR

  • Lighthouse 점수는 나쁘지 않았다. Performance는 81점, CLS는 0.001이었다.
  • 그런데 화면에서는 서버 상태가 도착할 때 탭, 카운트, 목록 주변이 밀렸다.
  • 표준 CLS는 사용자 입력 500ms 안에 발생한 layout shift를 점수에서 제외한다.
  • 브라우저가 shift를 감지하지 못한 것이 아니다. hadRecentInput: true 플래그가 붙고, 표준 계산에서 빠진다.
  • 클릭 뒤 API 응답으로 뱃지나 카운트 같은 서버 상태가 화면에 반영되는 순간, 의도하지 않은 밀림도 이 500ms 안에 들어갈 수 있다.
  • 점수는 표준대로 유지하되, 제외된 shift를 따로 모아야 실제 UX 문제를 찾을 수 있었다.
  • entry.sources가 가리키는 요소는 “움직인 요소”이지 항상 “원인 요소”는 아니다.

파일 목록과 리뷰 보드 화면을 보다가 레이아웃이 곳곳에서 밀리는 것을 발견했다. 원인 방향은 비교적 빨리 잡혔다. 서버 상태가 늦게 도착하고, 그 값이 탭 카운트나 카드 상태로 반영되면서 주변 UI가 나중에 자리를 차지하는 문제였다.

그런데 Lighthouse 결과는 생각보다 멀쩡했다. 전체 Performance는 81점이었고, CLS는 0.001이었다. LCP 3.4초가 더 눈에 띄었을 뿐, CLS만 보면 레이아웃 안정성에는 거의 문제가 없는 화면처럼 보였다.

항목 값
Performance 81
FCP 0.2s
LCP 3.4s
TBT 0ms
CLS 0.001
Speed Index 1.0s

측정 조건은 2026년 9월 4일 16:35, Lighthouse 13.4.1, Chromium 152.0.0.0, desktop emulation, single page session이었다. IndexedDB에 저장된 데이터가 로딩 성능에 영향을 줄 수 있다는 경고도 있었다. 하지만 이 경고는 주로 로딩 성능 쪽에 가깝다. 눈에 보이는 밀림과 CLS 0.001 사이의 간극은 따로 설명해야 했다.

평소처럼 Lighthouse 점수만 보고 끝내기 어려웠다. 의심한 지점은 두 가지였다.

의심 이유
Lighthouse가 보는 구간과 내가 보는 문제가 다르다 화면 밀림은 초기 로드보다 클릭 이후 서버 상태가 도착하는 시점에 많았다
CLS 점수가 모든 시각적 밀림을 의미하지 않는다 눈으로 보이는 밀림에 비해 CLS 0.001은 너무 낮았다

그래서 브라우저의 Layout Instability API를 직접 붙였다. 목적은 Lighthouse를 대체하는 새 점수를 만드는 것이 아니었다. Lighthouse가 좋게 나온 상황에서도 실제로 어떤 shift가 있었는지 분해해 보려는 것이었다.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as LayoutShiftEntry[]) {
    console.log(entry.value, entry.hadRecentInput, entry.sources);
  }
});

observer.observe({ type: "layout-shift", buffered: true });

그런데 직접 붙인 프로브의 첫 결과도 비슷했다.

[cls] 0.0000
시프트 0건

Lighthouse CLS는 0.001이고, 직접 계측한 표준 CLS도 0에 가까웠다. 그런데 화면은 분명히 움직였다. 여기서 질문은 “어떤 도구로 측정할까”에서 “CLS가 무엇을 점수에 넣는가”로 바뀌었다.

0은 두 가지 뜻을 가질 수 있다

처음에는 스켈레톤이 최종 높이를 잘 잡고 있어서 실제 shift가 없는 것일 수 있다고 생각했다. 하지만 화면을 직접 보면 탭, 토글, 목록 주변이 움직였다. 서버 상태가 늦게 도착하며 레이아웃을 바꾼다는 방향도 이미 보였고, 프로브도 정상적으로 붙어 있었다.

그러면 남는 가능성은 하나였다. shift가 없었던 것이 아니라 점수 계산에서 빠졌을 수 있다.

Layout Shift entry에는 hadRecentInput이 있다. 마지막 입력이 500ms 안에 있으면 이 값이 true가 된다. CLS는 사용자가 직접 유발한 변화까지 불안정성으로 벌주지 않기 위해 이런 shift를 점수에서 제외한다.

예를 들어 사용자가 아코디언을 눌러 열었고 아래 내용이 밀렸다면, 그 변화는 사용자가 시킨 일에 가깝다. 이것까지 CLS에 넣으면 지표가 이상해진다. 이 규칙 자체는 합리적이다.

문제는 브라우저가 사용자의 의도를 읽지 않고 시간만 본다는 점이다.

탭 클릭
  -> 300ms 뒤 API 응답
  -> 뱃지와 개수가 생김
  -> 주변 요소가 밀림
  -> 입력 500ms 안이므로 hadRecentInput: true
  -> 표준 CLS 점수에서 제외

사용자가 의도한 것은 탭 전환이지, 버튼 폭이 바뀌며 주변 UI가 흔들리는 것이 아니다. 하지만 시간상으로는 입력 직후다. 그래서 표준 CLS 점수에 들어가지 않는다.

처음 만든 프로브도 표준 계산을 따라 hadRecentInput이면 그냥 넘기고 있었다. 그 결과 0이 두 가지 뜻을 갖게 됐다.

숫자 가능한 의미
0.0000 실제로 밀림이 없음
0.0000 밀렸지만 전부 hadRecentInput이라 점수에서 빠짐

이 둘을 가르지 않으면 개선할 대상을 찾을 수 없다.

제외된 shift를 따로 모았다

표준 점수를 바꾸고 싶었던 것은 아니다. 외부에 말할 수 있는 CLS는 표준 규칙대로 계산해야 한다. 대신 점수에서 빠진 shift를 별도 버킷에 담았다.

const scored: ShiftRecord[] = [];
const excluded: ShiftRecord[] = [];

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as LayoutShiftEntry[]) {
    const record = {
      value: entry.value,
      at: Math.round(entry.startTime),
      sources: (entry.sources ?? []).map((source) => describe(source.node)),
    };

    if (entry.hadRecentInput) {
      excluded.push(record);
      continue;
    }

    scored.push(record);
  }
});

이렇게 나누자 숫자가 바로 달라졌다.

[cls] 0.0000
점수에 든 시프트 0건

[cls] 입력 직후라 표준이 제외한 시프트 28건
합계 0.2145

표준 CLS는 여전히 0이다. 하지만 눈에 보인 밀림은 있었다. 이 화면에서는 그 밀림이 거의 전부 클릭 후 500ms 안에 발생했을 뿐이다.

여기서 기준을 둘로 나눴다.

숫자 용도
표준 CLS 외부 보고, Core Web Vitals 기준
제외된 shift 합계 내부 UX 개선 대상 찾기

이 구분이 중요했다. 우리만의 CLS를 새로 만들면 대외적으로 의미가 약해진다. 반대로 표준 CLS만 보면 눈에 보인 문제를 놓친다. 그래서 점수는 표준대로 두고, 제외된 엔트리를 옆에 놓기로 했다.

서버 상태 문제는 맞았지만 전부는 아니었다

처음부터 가장 유력하게 본 원인은 서버 상태 값이었다. 탭 카운트나 카드 상태가 늦게 도착하면서 화면이 밀린다는 판단이었다. 이 가설은 맞았다. 다만 직접 계측해 보니 그게 전부는 아니었다.

제외된 shift를 entry.sources 기준으로 묶자 토글 손잡이가 크게 잡혔다.

span.absolute.top-[2px].size-[18px]   0.0683   24회

코드를 보니 토글 손잡이를 left로 움직이고 있었다.

on ? "left-[24px]" : "left-[2px]"

left는 레이아웃에 영향을 주는 속성이다. 이 값을 transition하면 애니메이션 프레임마다 layout shift가 찍힐 수 있다. 보이는 움직임은 같아도, 구현 속성을 바꾸면 shift는 사라진다.

"left-[2px] transition-transform",
on ? "translate-x-[22px]" : "translate-x-0",

이건 서버 응답과 무관한 CSS 문제였다. Lighthouse 점수만 봤다면 찾기 어려웠고, “서버 상태가 늦게 와서 밀린다”는 큰 방향만 따라갔다면 놓쳤을 문제다.

서버 상태 가설도 핵심 경로에서는 맞았다. 탭 카운트 뱃지는 count가 없으면 렌더하지 않았다.

if (count === undefined) return null;

서버에서 카운트가 도착하는 순간 뱃지가 새로 생기고 탭 폭이 바뀌었다. 이 경우에는 DOM을 없애지 않고 자리를 예약했다.

<span className={cn("inline-flex h-[16px]", count === undefined && "invisible")}>
  {count}
</span>

같은 처방이 Requested to me 카운트에도 필요했다. 값이 늦게 오더라도 폭과 높이는 처음부터 잡아둬야 했다.

attribution은 원인 표시가 아니다

한 번 더 헷갈린 지점이 있었다. 토글을 transform으로 고쳤는데도 측정 결과에 토글 주변 요소가 계속 나왔다. 처음에는 목록이 접히고 펴지면서 토글이 함께 움직인다고 봤다. 그래서 React Query의 placeholderData: keepPreviousData를 넣어 목록 접힘을 줄였다.

하지만 값은 그대로였다.

다시 보니 원인은 다른 곳이었다. 같은 줄의 카운트 텍스트가 조건부로 생기면서 버튼 폭을 바꾸고 있었다. 정렬 시나리오에서는 정렬 버튼 라벨 길이가 달라지며 버튼 폭이 변했고, 그 옆 요소들이 함께 밀렸다.

entry.sources가 알려주는 것은 원인 요소가 아니라 이동한 요소다.

정렬 버튼 폭이 바뀜
  -> 같은 flex 줄의 이웃 요소가 이동
  -> source에는 이웃 요소도 잡힘

이 차이를 모르고 보면 움직인 요소를 계속 고치게 된다. 실제로는 같은 줄 전체의 폭 변화, 조건부 렌더, 최소 폭 부족을 봐야 했다.

keepPreviousData는 항상 CLS를 낮추지 않는다

목록 접힘을 없애기 위해 keepPreviousData를 넣은 뒤 정렬 시나리오에서는 숫자가 오히려 커졌다.

시나리오 before after
정렬 5회, 파일 2개 0.3365 0.5241
정렬 5회, 파일 8개 - 0.8144

처음에는 개선이 실패한 줄 알았다. 하지만 이 값은 다른 사실을 보여줬다.

layout shift는 새로 삽입된 요소 자체보다 기존 요소가 이동했는지를 본다. 이전 구현에서는 정렬 중 목록이 접혔다가 다시 생겼다. 화면은 불안정하게 보이지만, 기존 카드가 이동한 것으로 잡히지 않는 구간이 있었다. keepPreviousData를 넣으면 같은 카드들이 자리를 바꾼다. 정렬이라는 행위의 목적 자체가 카드 재배치라서 shift 값이 커질 수 있다.

그래서 이 값은 고칠 대상이 아니라고 판단했다. 정렬할 때 카드가 움직이는 것은 기능의 결과다. 대신 폴더 전환처럼 내용은 바뀌지만 목록 접힘은 불필요한 경로에서는 keepPreviousData가 효과가 있었다.

이 경험 때문에 keepPreviousData를 “CLS를 낮추는 옵션”으로 보지 않게 됐다. 더 정확히는 로딩 상태에서 레이아웃을 접지 않게 해주는 선택지다. 같은 요소가 재배치되는 화면에서는 CLS 숫자가 오를 수도 있다.

결과

실제로 고친 것은 다섯 가지였다.

항목 변경
토글 손잡이 left transition을 transform transition으로 변경
탭 카운트 뱃지 조건부 렌더 대신 자리 예약
Requested to me 카운트 값이 없어도 폭과 높이 유지
정렬 버튼 가장 긴 라벨 기준으로 폭 고정
목록 로딩 query key 변경 시 이전 데이터 유지

직접 계측한 값은 이렇게 움직였다.

시나리오 before after
로딩만 28건 · 0.2145 1건 · 0.0004
토글만 24건 · 0.0683 0건
폴더 전환 - 4건 · 0.0002

정렬 시 카드 재배치 값은 남겼다. 숫자는 컸지만, 이 경우에는 화면이 의도적으로 재배열되는 중이었다. 성능 지표를 낮추는 것보다 정렬 결과가 자연스럽게 이해되는지가 더 중요한 경로였다.

Lighthouse 전후 점수는 거의 변하지 않았다. 이 글의 결론은 “CLS 점수를 크게 개선했다”가 아니다. 오히려 반대에 가깝다. Lighthouse 기준 CLS는 처음부터 0.001로 좋았고, 수정 뒤에도 그 숫자만 보면 큰 변화가 없었다.

변한 것은 점수보다 관측 방식이었다. 표준 점수에 들어간 shift와 hadRecentInput 때문에 빠진 shift를 분리하자, 서버 상태 도착 때문에 생기는 자리 변경과 CSS 애니메이션 문제가 보였다. Lighthouse가 틀린 것이 아니라, Lighthouse가 대답하는 질문이 내가 풀려던 질문보다 좁았다.

정리

이번 문제는 “Lighthouse 대신 다른 도구를 쓰면 된다”로 끝나는 문제가 아니었다. Lighthouse는 초기 로드 성능을 보는 데 여전히 유용했고, 실제로 이 측정에서도 LCP 3.4초 같은 다른 문제를 보여줬다. 다만 내가 확인하려던 것은 “서버 상태가 도착하며 화면이 얼마나 흔들리는가”였다. 그 질문에는 표준 CLS 하나만으로 부족했다.

브라우저는 layout shift를 감지하고 hadRecentInput을 붙여 넘긴다. 표준 CLS는 그중 최근 입력과 가까운 shift를 점수에서 제외한다. 이 규칙은 의도한 상호작용을 보호하기 위해 필요하지만, 클릭 뒤 비동기 응답으로 생기는 원치 않는 밀림까지 함께 가릴 수 있다.

그래서 앞으로 이런 화면을 볼 때는 표준 점수와 내부 개선용 숫자를 분리해서 본다.

표준 CLS가 0인가?
  -> 대외 지표는 괜찮을 수 있다.

제외된 shift도 0인가?
  -> 눈에 보인 밀림도 정말 없는지 볼 수 있다.

0.001은 끝이 아니었다. 이 화면에서는 “점수에 들어간 밀림이 거의 없다”는 뜻이었고, “서버 상태가 도착할 때 화면이 밀리지 않는다”는 뜻은 아니었다.

참고

관련 글