핵심 웹 지표(Core Web Vital)에 대해

FE_Sujin·2024년 9월 7일
post-thumbnail

웹사이트 성능이 좋아야 하는 이유

디지털 마케팅 에이전시 Portent의 조사에 따르면, 로딩 속도는 비즈니스 성과에 직접적인 영향을 미친다.

  • 1초 내로 로딩되는 사이트는 5초 내로 로딩되는 사이트보다 전자상거래 전환율(실제 구매로 이어지는 고객 비율)이 2.5배 높다.
  • 0~5초 구간에서, 1초 느려질 때마다 전환율이 약 4.42% 하락한다.
    5초 이상 느려지면 전환율이 약 20% 가까이 떨어진다.
  • 페이지 로드 시간이 0~2초 사이일 때 전환율이 가장 높다.

구글의 통계도 비슷한 결론을 보여준다.

  • 최적의 웹페이지를 표시하는 데 필요한 최적의 평균 리소스 요청 수는 50회 미만이다.
  • 평균적으로 웹 페이지 전체를 요청하는 데 15.3초 걸린다.
  • 페이지 로드 시간이 1초에서 10초로 늘어나면 모바일 이탈률이 123% 증가한다.

결국 빠른 페이지는 단순히 UX의 문제가 아니라, 전환율과 매출에 직결되는 비즈니스 지표다.



핵심 웹 지표 ( Core Web Vitals )란?

웹 성능을 개선하기 위해 알아야 할 구글의 핵심 측정 기준

구글이 "뛰어난 사용자 경험을 제공하는 데 필수적인 지표"로 정의한 측정 기준이다.
구글 검색 순위에도 반영되므로 SEO 관점에서도 중요하다.

핵심 지표 (Core Web Vitals)

구글에서 핵심 웹 지표로 꼽는 지표는 다음의 3가지이다.

지표측정 대상좋음보통나쁨
LCP (Largest Contentful Paint)최대 콘텐츠 렌더링 속도≤ 2.5초≤ 4초> 4초
FID (First Input Delay)최초 입력 반응 속도≤ 100ms≤ 300ms> 300ms
CLS (Cumulative Layout Shift)레이아웃 이동 정도≤ 0.1≤ 0.25> 0.25

참고: 구글은 2024년부터 FID를 INP(Interaction to Next Paint) 로 대체했다. INP는 페이지 전체 수명 동안 발생하는 모든 인터랙션의 지연을 측정하므로 FID보다 더 포괄적이다. 좋은 INP 점수는 200ms 이하다.

진단용 보조 지표

핵심 지표는 아니지만, 문제 원인을 파악할 때 함께 확인하면 유용하다.

  • TTFB (Time to First Byte): 서버 응답 속도
  • FCP (First Contentful Paint): 첫 콘텐츠가 화면에 렌더링되는 속도


1. LCP — 최대 콘텐츠풀 페인트

LCP ( Largest Contentful Paint )
페이지 로드 시작 시점부터, 뷰포트 내 가장 큰 이미지 또는 텍스트 블록이 렌더링될 때까지 걸리는 시간

여기서 "뷰포트"란 현재 사용자 화면에 보이는 영역이다.
기기마다 크기가 다르므로 LCP 대상 요소도 기기에 따라 달라질 수 있다.


왜 LCP로 성능을 측정할까?

"페이지가 로딩됐다"는 사용자의 체감 기준은 DOMContentLoaded 이벤트 시점과 다르다.

DOMContentLoaded는 HTML 파싱 완료 시점을 의미하며, 스타일시트나 이미지 로딩은 기다리지 않는다. 만약 페이지의 핵심 콘텐츠가 큰 이미지라면, HTML 파싱이 끝났어도 사용자 눈에는 아직 빈 화면일 수 있다.

LCP는 이런 맹점을 보완한다.
뷰포트에서 가장 큰 콘텐츠가 보이는 시점을 기준으로 삼아, 사용자가 실제로 "로딩이 끝났다"고 체감하는 순간에 가장 가까운 측정값을 만들어낸다.


LCP 기준 점수

LCP가 2.5초 이내이면 좋음, 4초 이내이면 보통, 4초 이상이면 나쁨으로 판단된다.


LCP 개선 방법

1. <svg> 가 아닌 <img> 로 이미지 불러오기

이미지를 화면에 표시하는 방법은 여러 가지다.

<!-- 1) img 태그 -->
<img src="lcp.jpg" ... />

<!-- 2) svg 태그 -->
<svg xmlns="http://www.w3.org/1000/svg">
  <image href="lcp.jpg" ... />
</svg>

<!-- 3) (비디오의 경우) video.poster 태그 -->
<video poster="lcp.jpg" ...></video>

<!-- 4) background-image: url()  -->
<div style="background-image: url(lcp.jpg)">...</div>

이 중 <img><video poster>가 가장 빠르다. 이유는 브라우저의 프리로드 스캐너 덕분이다.

프리로드 스캐너는 HTML 파싱과 별개로, 병렬로 중요한 리소스를 미리 다운로드하는 브라우저 기능이다. <img> 태그는 이 스캐너에 의해 조기 발견되어 빠르게 요청된다.

반면 <svg> 내부의 <image>는 프리로드 스캐너가 인식하지 못해, 모든 리소스가 로드된 뒤에야 이미지를 요청한다. LCP에 직접적으로 악영향을 미치므로 LCP 대상 이미지에는 <svg>를 사용하지 않는 것이 좋다.

참고: 테스트 출처 - https://yceffort.kr 블로그에서 제공하는 예제


2. 이미지 무손실 압축

당연하게도 이미지는 무손실 형식으로 압축해 최소한의 용량으로 서비스하는 것이 좋다.

  • WebP, AVIF 같은 현대적 포맷을 사용해 품질 손실 없이 용량을 줄인다.
  • 디스플레이 크기에 맞는 이미지를 제공한다 (srcset 활용).
  • CDN을 통해 사용자와 가까운 서버에서 이미지를 제공한다.

3. LCP 이미지에 loading=lazy 사용 금지

loading="lazy" : 이미지를 필요할 때까지 로드를 미루는 전략

loading="lazy"는 뷰포트 바깥의 이미지에는 유효하지만, LCP 대상 이미지에 적용하면 치명적이다.

Lazy 속성이 붙으면 프리로드 스캐너도 이 이미지를 건너뛰게 되어, 이미지 요청이 늦어지고 LCP 점수가 크게 하락한다.

LCP 이미지에는 loading="lazy" 대신 fetchpriority="high"를 사용하자.

<img src="hero.jpg" fetchpriority="high" />

4. 클라이언트에서 빌드하지 말 것 (서버에서 빌드된 HTML 제공하기)

LCP에 이상적인 시나리오는 다음과 같다.

서버에서 이미 빌드된 HTML을 내려줌 → 프리로드 스캐너가 LCP 이미지를 즉시 발견 → 빠른 다운로드

반면, 클라이언트에서 데이터를 받아 이미지를 렌더링하는 방식은 여러 단계를 거쳐야 한다.

// ❌ LCP에 불리한 패턴
useEffect(() => {
  (async function loadData() {
    const result = await fetch('https://example.com/data') // 이미지를 API 엔드포인트에서 응답 받음
    if (result.ok) setShow(true) // 이 시점에야 이미지가 렌더링됨
  })()
}, [])

위 코드에서 이미지는 JS 파싱 → API 요청 → 응답 수신 → 상태 업데이트 → 렌더링의 순서를 모두 거쳐야 한다. 각 단계마다 LCP 시간이 늦어진다.

가능하다면 LCP 이미지는 서버에서 미리 빌드된 채로 내려보내는 것이 좋다.



2. FID — 최초 입력 지연 (→ INP으로 대체 중)

FID (First Input Delay)
사용자가 페이지에서 처음으로 클릭, 탭, 키 입력 등의 상호작용을 했을 때, 브라우저가 실제로 그 이벤트를 처리하기 시작하기까지 걸리는 시간

페이지가 빨리 로딩되더라도, 버튼을 눌렀을 때 반응이 없다면 사용자는 사이트가 느리다고 느낀다.
FID는 바로 그 "반응 지연"을 측정한다.


측정 대상

클릭, 터치, 키보드 입력 등 반응성에 해당하는 사용자의 개별 입력 이벤트를 대상으로 한다.
스크롤이나 핀치 줌은 애니메이션으로 분류되어 측정에서 제외된다.


왜 FID가 느려지나?

대부분의 경우 브라우저 메인 스레드가 다른 작업에 묶여 있기 때문이다.
메인 스레드는 한 번에 하나의 작업만 처리할 수 있다.
대규모 JavaScript 파싱, 무거운 렌더링 작업 등이 실행 중이면, 사용자 입력은 그 작업이 끝날 때까지 대기해야 한다.


FID 기준 점수

FID가 100ms 이내이면 좋음, 300ms 이내이면 보통, 300ms 이상이면 나쁨으로 처리된다.


FID 개선 방법

FID 를 개선하려면 FID 에 가장 큰 영향을 미치는 메인 스레드에 이벤트를 실행할 여유를 줘야 한다.

1. 오래 걸리는 작업 (Long Task) 분리

브라우저 개발자 도구 > 성능(Performance) 탭에서 50ms를 초과하는 Long Task를 확인할 수 있다.

초기 로딩 시 불필요한 컴포넌트는 지연 로드하자.
팝업, 모달 등과 같이 사용자 액션으로 인해 노출되는 요소들은 처음부터 로드할 필요가 없다.

React에서는 Suspense + lazy를 활용한다.

import React, { Suspense, lazy } from 'react';

// 해당 컴포넌트가 요청된 시점에 동적으로 컴포넌트를 로드한다
// import() 함수를 사용해 컴포넌트를 비동기적으로 가져온다
import LazyComponent = lazy(() => import('./LazyComponent'));

export function App() {
	return (
    	<div className='App'>
          <h1>My React App</h1>
          {/* 로딩 중일 땐 fallback 보여주고, 로딩 완료되면 내부 컴포넌트 렌더링 */}
          <Suspense fallback={<div>Loading...</div>}>
            <LazyComponent />
          </Suspense>
        </div>
    )
}
export const LazyComponent = () => {
	return (
    	<div>
          <h2>This is a lazily loaded component!</h2>
        </div>
    )
}

Next.js에서는 dynamic을 활용한다.

import React from 'react';
import dynamic from 'next/dynamic';

// 동적으로 HeavyComponent 를 로드한다 (동적으로 로드할 모듈을 반환한다)
const DynamicHeavyComponent = dynamic(() => import('./HeavyComponent'), {
	loading: () => <p>Loading...</p>, // 로딩 중에 표시할 컴포넌트
    ssr: false,  // 서버 사이드 렌더링 비활성화
})

const HomePage = () => {
	return (
    	<div>
          <h1>Welcome to the home page</h1>
          {/* 동적으로 로드된 컴포넌트 */}
          <DynamicHeavyComponent />
        </div>
    )
}
export const HeavyComponent = () => {
	return (
    	<div>
          <h1>This is a heavy component</h1>
          <p>It is loaded dynamically.</p>
        </div>
    )
}

2. 자바스크립트 번들 크기 줄이기

  1. 번들링 도구 사용하기 :
  • Next.js는 내부적으로 Webpack을 사용해 코드 스플리팅과 트리 쉐이킹을 자동으로 수행한다
  1. 개발자 도구 > Coverage 탭에서 제거 대상 코드 찾기 :
  • 개발자 도구 > Coverage 탭에서 실제로 사용되지 않는 JavaScript 코드를 확인할 수 있다
  • 당장 급하지 않은 코드는 지연로딩 시키거나, 사용하지 않는 코드 제거한다
  1. 사용하지 않는 라이브러리를 제거하거나, 더 가벼운 대안으로 교체한다

3. 타사 자바스크립트 코드 실행의 지연

Google Analytics, Firebase, 채팅 위젯 등 웹페이지 통계 집계를 위한 서드파티 스크립트는 대부분 페이지 초기 로딩에 필수적이지 않다. <script>async 또는 defer 속성으로 지연 로드한다.

<!-- defer: HTML 파싱 완료 후 실행, 선언 순서 보장 -->
<script defer src="analytics.js"></script>
 
<!-- async: 다운로드 완료되는 즉시 실행, 순서 보장 안 됨 -->
<script async src="chat-widget.js"></script>

참고: defer vs async 선택 기준

둘 다 외부 JavaScript 파일을 HTML 파싱과 병렬로 다운로드하게 해주는 속성이다.
핵심 차이는 “다운로드가 끝난 뒤 언제 실행되느냐”이다.

deferasync
다운로드HTML 파싱과 동시에 다운로드HTML 파싱과 동시에 다운로드
HTML 파싱 차단차단 안 함차단 안 함
실행 시점HTML 파싱 완료 후다운로드 완료 즉시
실행 순서선언 순서 보장순서 보장 안 됨
적합한 용도DOM 조작이 필요한 스크립트(일반 서비스 JS)독립적으로 동작하는 스크립트(광고, 분석, 외부 추적 스크립트)

[참고 링크: defer, async 스크립트]



3. CLS — 누적 레이아웃 이동

CLS : Cumulative Layout Shift
사용자에게 발생하는 레이아웃 이동(layout shift) 빈도를 측정한 값


CLS 기준 점수

CLS는 숫자가 아닌 "점수"로 표현된다.
레이아웃이 얼마나 많이, 얼마나 크게 이동했는지를 종합한 값이다.

CLS가 0.1이하인 경우 좋음, 0.25 이하인 경우 보통, 0.25 이상이면 나쁜 점수로 판단된다.

참고로, 뷰포트 높이가 작을수록 조금 더 유리한 점수를 얻을 수 있다
즉, 크기가 같은 요소더라도 데스크탑보다 모바일 기기에서 CLS 점수가 더 좋을 수 있다.


CLS가 발생하는 주요 원인

  • 이미지에 width / height 속성이 없어 크기를 미리 알 수 없는 경우

  • useEffect 안에서 UI 레이아웃을 변경하는 경우 (렌더링 후 상태를 바꾸면 레이아웃이 재계산됨)

  • 폰트가 늦게 로드되어 텍스트 크기가 바뀌는 경우

  • 동적으로 삽입되는 콘텐츠(배너, 광고, 알림)가 기존 레이아웃을 밀어내는 경우


CLS 개선 방법

1. 이미지에 명시적 크기 설정

브라우저가 이미지 로드 전에 공간을 미리 확보할 수 있도록, 반드시 widthheight를 지정한다.

<!-- ❌ 크기 미지정: 이미지 로드 전까지 높이가 0 → 로드 시 레이아웃 이동 발생 -->
<img src="product.jpg" />
 
<!-- ✅ 크기 명시: 브라우저가 공간을 미리 확보 -->
<img src="product.jpg" width="800" height="600" />

CSS에서 반응형으로 사용할 경우에도 aspect-ratio를 활용하면 CLS를 방지할 수 있다.

img {
  width: 100%;
  aspect-ratio: 4 / 3; /* 이미지 로드 전에도 비율에 맞는 공간 확보 */
  height: auto;
}

2. 동적 콘텐츠를 위한 공간 사전 확보

로딩 후 삽입될 콘텐츠의 공간을 미리 잡아두면 레이아웃 이동을 막을 수 있다.

스켈레톤 UI 활용이 대표적인 방법이다.
실제 콘텐츠가 로드되기 전, 비슷한 크기의 회색 박스를 먼저 보여주면 레이아웃이 고정되어 CLS를 최소화한다.

또한 useEffect 대신 useLayoutEffect 를 사용하면, DOM이 화면에 그려지기 전에 레이아웃 변경을 적용해 시각적인 이동을 방지할 수 있다.
[useLayoutEffect 링크]

// useEffect: 렌더링 후 실행 → 사용자 눈에 이동이 보일 수 있음
useEffect(() => {
  setHeight(calculateHeight());
}, []);
 
// useLayoutEffect: 렌더링 전 동기 실행 → 이동이 보이지 않음
useLayoutEffect(() => {
  setHeight(calculateHeight());
}, []);

3. 폰트 로딩 최적화

폰트는 자체적인 너비와 높이를 가지므로, 지정한 폰트가 로드되기 전에 다른 폰트로 텍스트가 표시되면 레이아웃이 바뀐다.
이를 FOUT (Flash of Unstyled Text) 또는 FOIT (Flash of Invisible Text) 라고 한다.

<link rel="preload"> 로 폰트를 조기 로드한다.

<link
  rel="preload"
  href="/fonts/pretendard.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

font-display: optional 을 사용하면, 100ms 내에 폰트 다운로드가 완료되지 않으면 기본 폰트를 유지하고 레이아웃 이동을 방지한다.

body {
  font-family: 'CustomFont', optional, sans-serif;
}
@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard.woff2') format('woff2');
  font-display: optional;
}
profile
안녕하세요 :)

0개의 댓글