Development/Web

LCP, CLS, INP란?

레오나르도 다빈츠 2025. 5. 14. 11:44

 

 

성능탭에서 확인할 수 있는 수치이다.

 

 


 

 

1. LCP (Largest Contentful Paint)

"얼마나 빨리 콘텐츠를 보여줄 수 있냐"

가장 큰 콘텐츠가 나타나기까지 걸린 시간으로 사용자가 페이지에 들어갔을 때 메인 컨텐츠라고 느낄만한 가장 큰 이미지나 텍스트 블록이 렌더링 완료되기까지의 시간이다. 좋은 기준은 2.5초 이내이다.

  • 이미지 lazy load
  • 폰트 preload
  • 서버 응답 시간 개선 (백엔드 속도 or SSR 등)
  • CSS/JS 최적화

 

 

2. CLS (Cumulative Layout Shift)

"보여주는 동안 화면이 안정적이냐"

레이아웃이 갑자기 휙휙 움직이는 정도로 페이지 로딩 도중에 요소들이 예상치 못하게 움직이는 정도를 수치로 표현한다. 예를 들어, 버튼을 클릭하려는 순간, 배너가 늦게 로딩돼서 버튼이 아래로 밀리는 경우이다. 좋은 기준은 0.1 미만이다.

  • 이미지, 비디오, 광고 등의 사이즈를 명시
  • 폰트 FOUT/FOIT 방지
  • 애니메이션은 transform/opacity 사용

 

 

3. INP (Interaction to Next Paint)

"사용자가 뭔가를 눌렀을 때 화면이 그에 반응하는 데 걸리는 시간이 어느정도냐"

사용자의 입력(예: 클릭, 탭, 키 입력)에 대해, 시각적인 업데이트가 브라우저에 렌더링될 때까지 걸린 시간으로 페이지 수명 동안의 여러 상호작용 중 가장 느렸던 반응 시간(최악의 경우)을 측정한다. 좋은 값은 200ms 이하, 주의가 필요한 값은 200ms~500ms, 느린 값은 500ms가 초과하는 경우다.

 

1. 메인 스레드 블로킹 제거

1-1. 무거운 계산은 Web Worker로 분리한다.

1-2. requestIdleCallback, setTimeout(fn, 0)을 활용해 유휴 시간(Idle Time)에 실행한다.

// ❌ 느린 예시
const handleClick = () => {
  heavyCalculation(); // 이 함수가 오래 걸림
  setShowModal(true);
};

// ✅ 개선된 예시
// setTimeout(fn, 0): '지금 당장은 아니고, 콜 스택이 비면 다음 이벤트 루프에서 실행해줘'라는 뜻
const handleClick = () => {
  setTimeout(() => {
    heavyCalculation(); // 이벤트 이후, UI가 그려진 뒤 실행
  }, 0);
  setShowModal(true);
};
requestIdleCallback(() => { doBackgroundAnalytics() });

// 브라우저가 idle 상태가 되지 않으면 실행되지 않을 수도 있다.
// 모바일/저사양 디바이스에서 계속 바쁘거나 사용자가 페이지를 빠르게 떠나면 실행되지 않을 수 있다.
// 따라서 예외 없이 실행하기 위해서는 timeout 지정이 필요하다.
requestIdleCallback(callbackFn, { timeout: 1000 });

// 로깅, 히트맵, 분석 등 사용자 체감 없는 작업
useIdleCallbackEffect(() => {
  sendAnalytics({ page: location.pathname });
});

// 무거운 preload 작업
useIdleCallbackEffect(() => {
  sendAnalytics({ page: location.pathname });
});

 

 

 

2. 렌더링 최적화

불필요한 리렌더를 줄인다.

 

2-1. React.memo, useMemo, useCallback을 활용한다.

// ❌ 모든 버튼 클릭 시 전체 리스트 리렌더
const ListItem = ({ item, onClick }) => {
  return <div onClick={() => onClick(item)}>{item.name}</div>;
};

// ✅ memo로 불필요한 리렌더 방지
const ListItem = React.memo(({ item, onClick }) => {
  return <div onClick={() => onClick(item)}>{item.name}</div>;
});

 

2-1. Recoil/Zustand 같은 상태관리도 부분 구독하여(selector, slice 등) 쪼갠다.

 

 

 

3. 입력 이벤트 핸들러 최적화

lodash.debounce, lodash.throttle을 활용하여 검색, 오토컴플릿, 스크롤 이벤트에 적용한다.

import debounce from 'lodash.debounce';

const handleInput = debounce((value) => {
  fetchSuggestions(value);
}, 300);

<input onChange={(e) => handleInput(e.target.value)} />

 

 

 

4. 스타일 계산 및 레이아웃 시점 늦추기

스타일이나 DOM 변경을 바로 트리거하면 브라우저가 바로 렌더하려고 하기 때문에 레이아웃 스로틀을 적용해야 한다.

requestAnimationFrame이나 setTimeout으로 늦춘다.

// ❌ 바로 style 바꾸면 INP 지연
element.style.height = '500px';

// ✅ requestAnimationFrame으로 프레임 뒤로 미룸
requestAnimationFrame(() => {
  element.style.height = '500px';
});

 

 

 

5. 서드파티 스크립트 최적화

챗봇, 광고, 추적기 등 외부 스크립트가 메인 스레드를 점유하는 문제가 있다.

 

5-1. 필요할 때만 lazy load

5-2. async, defer 속성 사용

5-3. 라이브러리 활용

1. Partytown: 서드파티 스크립트를 Web Worker로 옮겨주는 툴 (메인 스레드 차단 방지) 예: 구글 애널리틱스, 채팅 위젯 등

2. iframe: 외부 위젯이나 광고가 메인 DOM 렌더링을 막지 않도록 분리하고 완전히 격리된 환경 제공 (스크립트 충돌 방지)

3. IntersectionObserver: 특정 요소가 화면에 보일 때만 로딩되게 함 (이미지, 컴포넌트 등)

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      loadImage(entry.target);
    }
  });
});

 

 

 

6. 클릭 응답 직후 paint 타이밍 최적화

React 18+ 부터 제공하는 useTransition을 활용하여 비동기 UI 작업을 낮은 우선순위로 처리한다.

React 입장에서는 state가 바뀌면 렌더링이 발생하므로, setState 계열 함수가 곧 UI 작업이다.

사용자 체감이 중요한 상태(state) 변경은 빠르게하고 그 외의 변경은 지연시켜 자연스럽게 처리한다.

 

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();

const handleClick = () => {
  startTransition(() => {
    // 1. 검색 필터링
    // 2. 큰 리스트 변경
    // 3. 대량 컴포넌트 변경 ex) 이미지 갤러리, 카드 UI 등
    // 4. 리치 에디더 상태 갱신 ex) Draft.js, Slate 같은 에디터 값 변경
    // 5. 무거운 계산 결과 UI에 반영 ex) 통계 게산 후 차트 렌더링 등
    setHeavyContent(data);
  });
};
import { useState, useTransition } from 'react';

const bigList = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);

export default function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value); // ✅ 즉각적인 UI 응답 (input 값 변경)

    startTransition(() => {
      // 🐢 무거운 작업 (많은 데이터를 필터링해서 렌더링)
      const filtered = bigList.filter(item => item.includes(value));
      setResults(filtered); // 👉 이게 바로 setHeavyContent
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <div>필터링 중...</div>}
      <ul>
        {results.map((item, i) => (
          <li key={i}>{item}</li>
        ))}
      </ul>
    </>
  );
}

 

7. 큰 DOM 업데이트는 가상화 (Virtualization)

react-window, react-virtualized를 사용한다. 리스트나 테이블 같은 대량의 DOM 노드를 가상화(Virtualization) 하여 렌더링 성능을 최적화 한다.

 

7-1. react-window

경량화된 최신 가상 리스트 라이브러리로 스크롤 영역에 보이는 아이템만 렌더링 한다.

import { FixedSizeList as List } from 'react-window';

<List
  height={400}
  width={300}
  itemCount={1000}
  itemSize={35}
>
  {({ index, style }) => (
    <div style={style}>Row {index}</div>
  )}
</List>

 

 

7-2. react-virtualized

그리드, 테이블 등 좀 더 다양한 UI를 지원한다. 복잡한 경우에는 이를 더 잘 활용할 수 있다.

import { List } from 'react-virtualized';

<List
  width={300}
  height={300}
  rowCount={1000}
  rowHeight={20}
  rowRenderer={({ index, key, style }) => (
    <div key={key} style={style}>Row {index}</div>
  )}
/>