[번역] 리액트에서 INP를 효과적으로 최적화하는 5가지 팁

Saetbyeol·2025년 1월 21일
26

translations.zip

목록 보기
22/23
post-thumbnail

원저자의 허락을 받아 원문 <5 tips to effectively optimize INP in React>를 한국어로 번역한 글입니다.

이 글에서는 리액트로 만든 웹의 핵심 웹 바이탈 지표를 개선하는 몇 가지 최적화 기술을 살펴보겠습니다.

저희는 체코의 속도 컨설턴트로 일하고 있으며, 이 글에서는 클라이언트를 위해 수행했던 여러 프런트엔드 성능 최적화 경험을 공유하겠습니다.

여기서는 주로 INP(Interaction to Next Paint) 지표, 즉 상호작용에 대한 반응 속도를 살펴보고자 합니다. 리액트로 만든 웹사이트의 속도를 최적화하려면 오래 걸리는 자바스크립트 작업을 최적화해야 합니다. 이는 리액트가 내부적으로 동작하는 방식과 밀접한 관련이 있습니다.

리액트로 만들면 웹은 알아서 빠르게 되나요? 그렇지 않습니다!

리액트는 웹사이트나 웹 애플리케이션의 속도를 높이기 위해 일부 독창적인 기술을 내부적으로 사용하고 있습니다.

데이터가 변경된 컴포넌트만 효율적으로 업데이트하고 렌더링 하는 방식이죠. 또한, 리액트 훅은 원치 않는 레이아웃 재연산과 레이아웃 스래싱(thrashing)을 어느 정도 방지합니다.

그렇다면 리액트로 만든 웹사이트는 이러한 내부 동작에 의해 알아서 빠른 웹이 보장되는 걸까요? 많은 개발자가 그렇게 생각하지만, 정답은 간단합니다. 리액트로 만든 웹도 최적화가 필요합니다.

이는 HTTP 아카이브 데이터에서도 확인할 수 있습니다.

2020년부터 2024년까지 핵심 웹 바이탈 지표를 충족하는 리액트, PHP 웹의 비율을 나타내는 그래프

데이터에 따르면 리액트로 구축된 사이트는 PHP로 구축된 사이트보다 핵심 웹 바이탈 지표를 충족하는 빈도가 낮습니다.

리액트의 선언적인 컴포넌트는 코드를 더 예상 가능하고 이해하기 쉽게 만들지만, 상태나 컴포넌트의 수를 잘못 조작하면 상호작용의 속도가 느려질 가능성이 큽니다.

다른 도구와 마찬가지로 리액트도 항상 개발자가 얼마나 잘 알고 있는지에 따라 달라집니다.

이제 저희가 했던 작업을 기반으로 리액트 최적화 팁으로 넘어가 보겠습니다. 리액트 코드를 어떻게 제어할 수 있을까요?

1) DOM 크기 줄이기

DOM 크기를 조정하고 최적화하는 것은 아주 기본적인 요구사항입니다. 요소가 너무 많거나 깊은 중첩을 가져 DOM이 너무 크면, 성능이 저하되고 렌더링 속도가 느려지며 메모리 부하가 증가할 수 있습니다.

특히 리액트 웹에서는 이 부분이 더욱 중요합니다. 요소가 적다는 것은 컴포넌트의 수 뿐만 아니라 로드하고 처리해야 할 자바스크립트의 양이 줄어듦을 의미하기 때문입니다.

DOM 크기는 크롬 콘솔 창에서 간단히 확인할 수 있습니다. document.querySelectorAll("\*").length 스크립트를 입력하면 현재 상태를 바로 알 수 있습니다.

콘솔 창에 document.querySelectorAll('*')를 검색한 결과

발견된 DOM 요소의 수가 콘솔 창에 5,132개로 표시됩니다. 꽤 많습니다.

구글은 DOM이 최대 1,400개의 요소만을 가질 것을 권장합니다. 이는 특히 이커머스 웹/앱과 같은 대규모 사이트의 경우 꽤 엄격한 기준입니다. 경험상 2,500개 정도여도 브라우저에서 상당히 빠르게 처리됩니다. 하지만 DOM 요소의 수가 이를 초과하면 상황이 바로 복잡해집니다.

컴포넌트를 지우거나 지연 로딩하기

DOM에 없는 것은 렌더링 할 필요가 없으며 브라우저는 그 시간만큼 쉴 수 있습니다. 그러므로 먼저 크기가 크고 SEO에 중요하지 않은 컴포넌트가 있는지 확인해 보세요. DOM에서 해당 컴포넌트를 제거하고 지연 로딩을 사용하세요.

import { lazy } from "react";

const MarkdownPreview = lazy(() => import("./MarkdownPreview.js"));

효과를 극대화하려면 사용자가 필요로 하거나 뷰포트에 표시될 때 컴포넌트를 지연 로드하세요. 지도, 차트, 시각화, WYSIWYG뿐만 아니라 양식 및 필터 같은 컴포넌트가 보통 해당됩니다. 일반적으로 가장 먼저 보이는 뷰포트의 밖에 위치하기 때문입니다.

컴포넌트 구조를 단순화하기

UI에서는 종종 많은 컴포넌트가 생성됩니다. 하지만 HTML, CSS에 최신 기능이 여러 추가되어 레이아웃 역할만 수행하는 래퍼 요소의 필요성은 줄어들고 있습니다.

DOM 크기를 낭비하는 아주 전형적인 예시는 별점과 별도의 "별 아이콘" 요소들로 인해 DOM을 불필요하게 확장하는 것입니다.

// ❌
<StarRating>
  <SVGStar />
  <SVGStar />
  <SVGStar />
  <SVGStar />
  <SVGStar />
</StarRating>

width와 반복되는 백그라운드 이미지를 이용하면 위의 문제를 해결할 수 있습니다.

DOM의 크기를 제한하는 여러 기법들

렌더링 된 여러 컴포넌트를 효과적으로 제거하는 다른 기법도 있습니다. 아주 긴 목록을 위한 가상 스크롤이 아주 좋은 예시입니다.

SSR은 항상 도움이 된다

SSR(Server Side Rendering)을 사용하면 첫 HTML 응답을 작성하는 시간이 최적화됩니다. 또한 데이터 크기도 더 작아집니다. 모든 면에서 이롭습니다.

항상 컴포넌트의 우선순위와 HTML 구성의 효율성을 고려해야 합니다.

거대한 DOM을 효율적으로 해결하는 다른 방법도 확인해 보세요.

2) 컴포넌트를 작게, 확장 가능하게 나누기

다시 말하지만, 이 권장 사항은 DOM 요소를 제거하는 것에 지나지 않습니다. 하지만 오늘날 HTML과 DOM의 구조를 바라보는 패러다임을 근본적으로 바꾼다는 점에서 약간의 차이가 있습니다.

컴포넌트나 그 콘텐츠가 SEO 또는 접근성 측면에서 중요하다고 해서 처음 렌더링 될 때 반드시 시각적으로 완전해야 한다는 의미는 아닙니다. 특히 해당 컴포넌트가 첫 번째 뷰포트에서 보이지 않는다면 더욱 그렇습니다.

사용자는 실제로 몇 개의 컴포넌트를 볼까요? 그중 일부는 메가메뉴(megamenu)나 모달 창과 같은 상호작용 뒤에 숨어 있거나 스크롤을 해야 볼 수 있습니다. 모든 컴포넌트가 최종 형태로 HTML에 포함될 필요가 있을까요?

최소한의 정보만 제공하는 컴포넌트와 여러 기능을 갖는 컴포넌트로 나누어 최적화하는 것은 한 페이지에서 여러 번 사용되는 요소일 때 더 효과적입니다. 일반적으로 랜딩 페이지, 상품 목록 또는 이미지에서 볼 수 있는 여러 항목이 그러한 경우에 속합니다.

뷰포트에 보이지 않을 때 갖는 간단한 UI와 실제 뷰포트에 있을 때 보이는 완전한 형태의 UI 차이를 보여주는 스크린샷

페이지가 로드될 때 SEO 관련 데이터만 제공하는 컴포넌트의 예시입니다. 이 상태는 사용자에게 시각적으로 숨겨져 있습니다. 사용자가 컴포넌트를 볼 수 있을 때 "완전한 형태"로 활성화됩니다.

아래 코드로 예를 들어보면, Interaction Observer를 사용하여 무거운 컴포넌트를 로드할 수 있습니다.

import React from "react";
import { useInView } from "react-intersection-observer";

const Offer = ({images, title}) => {
  const { ref, inView, entry } = useInView();

  return (
    <article className="offer" ref={ref}>
      <div className="gallery">
        {!inView ? <Image data={images[0]}> : <ImagesCarousel data={images} />}
        <h3>{title}<h3>
      </div>
    </article>
  );
};

중요한 콘텐츠와 생성된 HTML을 비교하면 DOM의 뼈대가 상당히 단순하다는 것을 알 수 있습니다. 시각적 풍부함은 사용자가 페이지를 이용하는 동안 추가됩니다.

그러나 지나친 렌더링 최적화로 CLS(레이아웃 변경 누적 최적화) 지표가 엉망이 되지 않도록 레이아웃 안정성에 신경 써주세요.

서버 컴포넌트도 도움이 됩니다

이 섹션에서 언급한 문제 중 일부는 비교적 최신 기능인 리액트 서버 컴포넌트로 해결되기도 합니다. 서버 컴포넌트는 클라이언트 자바스크립트에서는 사용할 수 없고 서버에서만 사용할 수 있습니다.

서버 컴포넌트를 활용하면 브라우저는 이미 렌더링 된 콘텐츠를 응답받아 콘텐츠를 표시하거나 애니메이션을 적용하기 위해 자바스크립트를 다시 실행할 필요가 없습니다. 그렇더라도 DOM 구조를 가능한 한 작게 유지하세요. 그래야 실제 브라우저에서 UI의 렌더링이 최적화됩니다.

3) <Suspense> 사용하기

좋습니다. 우리는 위 두 가지 방법을 이용해서 꽤 최적화된 DOM을 갖고 있습니다. 이는 종종 INP(대화형 반응 지표) 를 크게 개선합니다. 이제 작업을 더 작은 단위로 나누어 이를 점진적으로 처리할 수 있을지 고민해 봅시다.

리액트에서 <Suspense> 태그는 주로 다른 컴포넌트를 로드하는 동안 플레이스홀더 콘텐츠를 표시하는 데 사용됩니다.

그러나 <Suspense>의 숨겨진 능력 중 하나는 선택적 렌더링을 활성화한다는 점입니다. 이를 통해 중요도에 따라 페이지와 컴포넌트를 나눌 수 있습니다.

트리가 민트색과 빨간색으로 나누어져 있음. 민트색의 컴포넌트는 높은 우선순위를 가지고 빨간 컴포넌트는 낮은 우선순위를 가짐.

컴포넌트 트리는 태그로 분할됩니다. 빨간색 컴포넌트는 덜 중요한 것으로 표시됩니다..

SSR을 사용할 때, <Suspense> 태그의 효과는 꽤 상당합니다.

하이드레이션은 서버에서 생성된 코드가 클라이언트에서 다시 깨어나는 과정입니다. 서버는 사용자에게 빠르게 보이는 HTML을 전달하고, 리액트는 여기에 상호작용을 추가합니다. <Suspense>를 사용하지 않으면 항상 하나의 긴 자바스크립트 작업으로 동작해야 합니다.

페이지를 별도의 논리 단위로 분할하고 각각의 <Suspense> 태그로 감쌉니다.

각 로직단위 별 빨간색 네모박스로 표시한 booking.com 사이트 스크린샷

booking.com 웹사이트는 개별 로직 단위로 분리되어 있습니다.

하지만 여기서 주의해야 할 점이 있습니다! <Suspense> 태그를 사용해서 비생산적인 경우도 있습니다. 사용자가 <Suspense> 안에 있는 요소로 빠르게 상호작용 하면, 리액트는 이를 처리하기 위해 포커스를 전환하고 Suspense 블록을 처리합니다. 그렇지 않으면 사용자가 무엇을 했는지 정확히 알 수 없기 때문입니다. 이는 동기식으로 이루어지므로 전체 이벤트가 느려지게 됩니다.

그러므로 너무 큰 블록이 아닌, 작은 단위만 <Suspense> 태그로 감싸세요. 또한 첫 뷰포트에 보이는 요소에는 <Suspense>를 사용하지 마세요.

4) 하이드레이션 에러 주의하기

위에서 하이드레이션이 무엇인지 설명했습니다. 하지만 중요한 한 가지를 언급하지 않았습니다. 하이드레이션이 끝나면 결과 요소 트리(DOM)를 서버에서 가져온 상태와 비교하는 유효성 검사가 수행됩니다. HTML은 클라이언트인 리액트가 예상하는 것과 정확히 일치해야 합니다. 두 버전 간에 차이가 있으면 브라우저 콘솔에서 에러가 발생합니다.

브라우저 콘솔 창에 나타난 에러 문구: Warning Text content did not match

브라우저 콘솔에서 보이는 하이드레이션 에러

이 시점에서 리액트는 페이지의 모든 컴포넌트 또는 대부분의 컴포넌트를 무효화하고, 클라이언트 자바스크립트를 통해 업데이트를 트리거할 수 있습니다. 이는 하이드레이션 단계를 연장해 성능을 저하할 수 있으며, LCP(최대 콘텐츠 표시 시간) 지표를 짜증 날 정도로 지연시킬 수 있습니다.

더 나쁜 점은, 이러한 에러를 쉽게 마주한다는 것입니다. 예를 들어, Math.random() 또는 Date.now()를 서버와 클라이언트에서 각각 사용하면 콘텐츠가 동일하지 않게 되어 에러가 발생할 수도 있습니다.

브라우저 전용 API를 조건부로 사용하는 경우에도 에러를 맞닥뜨릴 수 있습니다.

// ❌
function LanguageComponent() {
  const language = window?.navigator?.language ?? "en";

  return <h1>Your language is: {language}</h1>;
}

이럴 때는 useEffect 함수를 사용해야 합니다. 코드가 약간 복잡해지기는 하지만 에러를 방지할 수 있습니다.

// ⭕️
function LanguageComponent() {
  const [language, setLanguage] = useState("en");
  useEffect(() => {
    // 클라이언트 사이드 API로 언어 업데이트
    setLanguage(window.navigator.language);
  }, []);

  return <h1>Your language is: {language}</h1>;
}

5) useEffect() 주의하기

useEffect는 컴포넌트 또는 상태의 변화에 반응하는 역할만 갖는 특별한 함수입니다.

import React, { useState, useEffect } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  // useEffect는 count 변수의 변경을 감지하고 반응합니다
  useEffect(() => {
    console.log(`The count has been updated to: ${count}`);
  }, [count]); // count 변수 변경만 확인

  return (
    <div>
      <h1>Click count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Counter;

정의상 이 함수는 변경이 발생한 후에 호출되는 함수입니다. 일반적으로 리액트 개발자들은 이 함수가 HTML이 렌더링 된 후에도 호출된다고 생각합니다.

하지만 안타깝게도 그렇지 않습니다. useEffect항상 비동기적인 것은 아닙니다. 사용자가 입력을 호출하면(ex. 클릭) "effect 훅"을 포함한 모든 리액트 코드는 동기적으로 실행됩니다.

훅을 사용하여 실제로 다음 렌더링 주기까지 작업을 지연시키려면 setTimeout 또는 다른 메서드를 사용해야 합니다.

useEffect(() => {
  // 별도의 작업으로 연기합니다
  setTimeout(() => {
    sendAnalytics();
  }, 0);
}, []);

분석 코드에 주의하세요.

정리

INP 지표를 중심으로 리액트 웹의 성능을 최적화하는 구체적인 방법을 소개했습니다. 여러분에게 도움이 되었기를 바랍니다.

기본적으로 거대한 DOM을 조심하고, 가능하면 지연시키고, 하이드레이션 과정에 특별한 주의를 기울이면 됩니다.

리액트는 도구일 뿐입니다. 중요한 건 리액트를 잘 알아야 한다는 것입니다. 이와 관련해서는 리액트 내부 심층 분석 시리즈를 적극 추천합니다. INP를 최적화하는 다른 아티클도 읽어 보시길 바랍니다.

1개의 댓글

comment-user-thumbnail
2025년 1월 27일

Geeta Colony Escorts Service - offering a wide range of services tailored to your needs. Trust us for a truly satisfying experience.

답글 달기

관련 채용 정보