Published on

Firefox에서 CLS 급증 원인 추적과 디버깅 가이드

Authors

서버/네트워크 지표는 안정적인데, RUM이나 Lighthouse 기반 모니터링에서 Firefox 유저의 CLS가 갑자기 튀는 상황은 생각보다 자주 발생합니다. 특히 Chrome에서는 재현이 어렵고, 같은 페이지라도 Firefox에서만 레이아웃이 흔들리면 원인 후보가 넓어져 디버깅 시간이 길어집니다.

이 글에서는 Firefox에서 CLS가 급증할 때의 전형적인 원인(폰트, 스크롤바, 이미지/비디오, 서드파티 위젯, CSS 계산 차이)을 우선순위로 좁히는 방법과, “어떤 요소가 언제 움직였는지”를 증거 기반으로 찾는 계측/재현 루틴을 정리합니다. 마지막에는 재발 방지용 체크리스트까지 제공합니다.

관련해서 CI에서 브라우저별 회귀 테스트를 병렬화해 비용을 줄이는 방법은 GitHub Actions 매트릭스 빌드로 CI 50% 줄이기도 함께 참고하면 좋습니다.

1) Firefox에서만 CLS가 튀는 대표 패턴

CLS는 “예상치 못한 레이아웃 이동”의 누적 점수입니다. Firefox 전용 급증은 대개 다음 범주 중 하나로 수렴합니다.

1-1. 웹폰트 로딩과 폴백 폰트 메트릭 차이

  • 폴백 폰트와 웹폰트의 글자 폭/높이가 달라서 텍스트 블록이 재배치
  • Firefox에서 폰트 메트릭 반올림/서브픽셀 처리 차이로 줄바꿈이 달라짐
  • font-display 기본값, font-size-adjust, ascent-override 등 미적용

특히 “한 글자 차이로 줄바꿈이 바뀌는” 레이아웃은 CLS가 크게 튀는 전형적인 원인입니다.

1-2. 스크롤바 정책 차이로 인한 가로폭 변화

Firefox는 환경/OS 설정에 따라 스크롤바가 레이아웃 폭을 차지하는 방식이 달라질 수 있습니다. 초기 렌더 시 스크롤바 유무가 바뀌면 100vw 기반 레이아웃이 흔들리면서 CLS가 증가합니다.

대표적으로 다음 패턴이 위험합니다.

  • 헤더/모달/오버레이에 width: 100vw 사용
  • 초기에는 overflow: hidden이었다가 콘텐츠 로딩 후 overflow: auto로 변경

1-3. 이미지/비디오의 고정 크기 미지정

Chrome은 일부 상황에서 이미지 치수를 더 빨리 추론하거나, lazy-loading 타이밍이 달라 “운 좋게” 덜 흔들리는 경우가 있습니다. Firefox에서는 이미지가 로드된 후에야 높이가 확정되어 아래 콘텐츠가 밀리며 CLS가 커집니다.

1-4. 지연 주입되는 위젯/광고/동의 배너

  • 쿠키 배너, 채팅 위젯, A/B 테스트 스크립트, 추천 영역 등이 DOM 상단에 삽입
  • “나중에 삽입”하면서 공간을 밀어내는 방식이면 CLS는 거의 확정적으로 발생

1-5. CSS 계산 차이: 서브픽셀, 폰트 렌더링, line-height

  • line-height: normal에 의존
  • calc()와 퍼센트 기반 레이아웃에서 반올림 차이
  • position: sticky 주변 레이아웃

이런 차이는 “Firefox에서만 1px씩 밀리는” 형태로 나타나고, 컴포넌트가 여러 개 겹치면 누적 CLS가 커집니다.

2) 재현이 먼저다: Firefox 전용 CLS 스파이크를 로컬에서 만들기

2-1. 동일 조건 고정

Firefox에서만 튀는 문제는 “환경이 달라서” 생기는 경우가 많습니다. 다음을 고정하세요.

  • Firefox 버전(ESR 포함)
  • OS 및 디스플레이 스케일링(예: 125%)
  • 브라우저 설정(스크롤바, 폰트 렌더링)
  • 네트워크 조건(느린 4G 등)

Playwright로 Firefox를 고정 조건으로 띄우면 재현성이 크게 올라갑니다.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    browserName: 'firefox',
    viewport: { width: 1365, height: 768 },
    locale: 'ko-KR'
  }
});

2-2. “폰트 느리게” 시나리오 만들기

CLS의 큰 비중이 폰트에서 오기 때문에, 폰트 로딩을 의도적으로 늦추면 원인이 빠르게 드러납니다.

  • 개발 서버에서 폰트 파일에 인위적인 지연을 추가
  • CDN 캐시를 비우거나 쿼리스트링으로 캐시 미스 유도

예: Nginx에서 폰트 확장자에 딜레이를 주는 방식(운영 적용 금지, 로컬/스테이징 전용)

location ~* \.(woff2|woff|ttf)$ {
  add_header Cache-Control "no-store";
  # 테스트용 지연
  echo_sleep 1.5;
  try_files $uri =404;
}

3) Firefox에서 “무엇이 언제 움직였는지” 계측하기

3-1. Layout Shift API로 shift 이벤트 수집

브라우저의 PerformanceObserver로 레이아웃 쉬프트 이벤트를 직접 수집하면, “원인 요소 후보”를 좁힐 수 있습니다.

주의: MDX 빌드 에러 방지를 위해 부등호가 들어간 코드는 모두 백틱으로 감쌉니다.

// layout-shift-logger.js
export function startLayoutShiftLogger() {
  if (!('PerformanceObserver' in window)) return;

  let cls = 0;

  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 사용자 입력(클릭/키보드) 직후 발생한 shift는 CLS에서 제외
      if (entry.hadRecentInput) continue;

      cls += entry.value;

      const sources = entry.sources?.map((s) => {
        const el = s.node;
        return {
          tag: el?.tagName,
          id: el?.id,
          className: el?.className,
          previousRect: s.previousRect,
          currentRect: s.currentRect
        };
      });

      console.log('[layout-shift]', {
        value: entry.value,
        cls,
        startTime: entry.startTime,
        sources
      });
    }
  });

  po.observe({ type: 'layout-shift', buffered: true });

  return () => po.disconnect();
}

이 로그에서 핵심은 sources입니다. Firefox에서 sources가 덜 친절하게 나오는 경우도 있지만, “어떤 DOM 근처에서 움직였는지” 단서가 됩니다.

3-2. 스크린샷/비디오로 시각적 증거 남기기

Playwright로 Firefox만 녹화하면 “어느 프레임에서 밀리는지”가 명확해집니다.

import { test } from '@playwright/test';

test('firefox cls reproduction', async ({ page }) => {
  await page.goto('https://example.com');
  await page.waitForTimeout(5000);
});

실행 시 --video 옵션(또는 config)으로 녹화를 켜고, 레이아웃이 흔들리는 타이밍을 shift 로그의 startTime과 대조하면 원인 추적이 훨씬 빨라집니다.

4) 원인별 해결책: Firefox에서 자주 먹히는 처방

4-1. 폰트로 인한 CLS 줄이기

처방 A: font-display: swap만으로는 부족할 수 있다

swap은 FOUT를 허용하는 대신 레이아웃 이동 가능성을 남깁니다. “레이아웃이 흔들리면 안 되는 영역”은 다음을 함께 고려합니다.

  • 폴백 폰트 스택을 메트릭이 유사한 폰트로 구성
  • font-size-adjust로 x-height를 맞춰 줄바꿈 변화를 줄임
  • 가능하면 핵심 폰트는 preload
<link rel="preload" href="/fonts/Brand.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
  font-family: 'Brand';
  src: url('/fonts/Brand.woff2') format('woff2');
  font-display: swap;
}

:root {
  font-family: Brand, system-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
  font-size-adjust: 0.52;
}

처방 B: 중요한 타이틀/히어로 영역은 높이를 고정하거나 최소 높이 확보

텍스트 줄바꿈이 바뀌어도 주변 레이아웃이 밀리지 않도록 “컨테이너의 최소 높이”를 확보합니다.

.heroTitle {
  min-height: 3.2em; /* 2줄 정도를 가정 */
}

4-2. 스크롤바/100vw로 인한 흔들림

처방 A: 100vw 대신 100% 우선

100vw는 스크롤바 폭을 포함해 계산되는 환경이 있어, 스크롤바 등장/제거 시 폭이 바뀔 수 있습니다.

.header {
  width: 100%;
}

처방 B: 스크롤바 공간을 미리 예약

지원 브라우저에서는 다음이 효과적입니다.

html {
  scrollbar-gutter: stable;
}

또는 모달 오픈 시 overflow: hidden을 적용하면서 레이아웃이 흔들린다면, 스크롤바 폭만큼 패딩을 보정하는 방식도 자주 씁니다.

function lockScroll() {
  const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
  document.documentElement.style.overflow = 'hidden';
  document.documentElement.style.paddingRight = `${scrollBarWidth}px`;
}

function unlockScroll() {
  document.documentElement.style.overflow = '';
  document.documentElement.style.paddingRight = '';
}

4-3. 이미지/비디오 CLS: 크기 예약이 정답

처방 A: width/height 속성 지정

반응형이라도 원본 비율을 알면 공간 예약이 가능합니다.

<img src="/banner.jpg" width="1200" height="630" alt="banner" style="max-width: 100%; height: auto;" />

처방 B: CSS aspect-ratio로 자리 확보

.cardMedia {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #f2f2f2;
  overflow: hidden;
}

.cardMedia img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

4-4. 지연 주입 위젯/배너는 “밀어내기” 대신 “겹치기”

쿠키 배너, 공지 배너를 상단에 삽입하면서 기존 콘텐츠를 아래로 밀면 CLS가 커집니다. 다음 중 하나로 바꿔야 합니다.

  • position: fixed로 오버레이
  • 미리 자리 확보(placeholder)

예: placeholder 방식

<div id="cookie-banner-slot" aria-hidden="true"></div>
#cookie-banner-slot {
  min-height: 56px; /* 배너 높이만큼 예약 */
}

.cookieBanner {
  height: 56px;
}

이 방식은 “보이는 영역이 밀리지 않게” 하는 대신, 항상 공간을 먹는 트레이드오프가 있습니다. 상단 히어로가 중요한 랜딩 페이지라면 fixed 오버레이가 더 나을 때가 많습니다.

5) Firefox 전용 회귀 방지: 자동화와 관측

5-1. Playwright로 Firefox 포함한 브라우저 매트릭스

CLS는 브라우저별로 다르게 튈 수 있으니, 최소한 Chrome과 Firefox는 동일 시나리오로 돌려 “레이아웃 쉬프트 이벤트가 특정 임계치를 넘으면 실패” 같은 가드레일을 두는 것이 좋습니다.

CI 비용이 부담되면 GitHub Actions 매트릭스 빌드로 CI 50% 줄이기의 매트릭스 전략을 적용해 브라우저별 테스트를 병렬화할 수 있습니다.

5-2. RUM에서 Firefox 세그먼트 분리

실서비스에서는 다음 차원을 반드시 분리해서 봐야 합니다.

  • 브라우저(특히 Firefox)
  • OS(Windows, macOS, Linux)
  • 디바이스 스케일링/해상도(가능하면)
  • 네트워크 상태

Firefox에서만 튄다면 “최근 배포와의 상관관계”뿐 아니라 “특정 OS에서만” 같은 교차 조건을 찾는 게 가장 빠른 길입니다.

6) 실제 디버깅 절차(추천 루틴)

  1. RUM에서 Firefox만 CLS 분포가 바뀌었는지 확인(평균이 아니라 p75, p95)
  2. 문제 페이지를 Firefox에서 동일 해상도/OS로 재현
  3. Layout Shift API 로깅으로 shift 발생 시각과 후보 요소 수집
  4. 폰트 지연, 이미지 지연 등 원인 가설을 하나씩 강화해 재현성을 올림
  5. 해결책 적용(공간 예약, 100vw 제거, placeholder, 폰트 메트릭 보정)
  6. Playwright Firefox 시나리오를 CI에 추가해 회귀 방지

7) 최종 체크리스트

  • 텍스트가 중요한 영역에 min-height 또는 레이아웃 고정 장치가 있는가
  • 웹폰트는 preload가 필요한가, 폴백 폰트 메트릭은 유사한가
  • 100vw 사용으로 스크롤바 등장 시 폭이 바뀌지 않는가
  • 이미지/비디오에 width/height 또는 aspect-ratio로 공간을 예약했는가
  • 배너/위젯이 DOM 상단에 삽입되며 콘텐츠를 밀어내지 않는가
  • Firefox를 포함한 브라우저 회귀 테스트가 자동화되어 있는가

Firefox 레이아웃 쉬프트 급증은 “Firefox가 이상하다”라기보다, 브라우저 차이가 드러날 만큼 레이아웃이 경계 조건에 걸려 있다는 신호인 경우가 많습니다. 위 루틴대로 재현과 계측을 먼저 고정하고, 폰트/스크롤바/미디어/위젯 순으로 제거해 나가면 대부분의 케이스는 짧은 시간 안에 원인을 특정할 수 있습니다.