본문 바로가기

Front-End

Web Core Vital 지표를 통해 FCP, LCP, CLS 개선하기

다이닝코드는 지난 22년 6월, 기존의 php, jQuery 페이지들을 새로운 UI/UX 함께 React 페이지로 리팩터링하는 사이트 리뉴얼을 했습니다.
그 과정에서 초기 React 페이지들은 성능적인 이슈를 많이 겪어, 지표상 으로 도, 통계상 으로 도 좋지 않은 성과를 내고 있었습니다.
특히나 모바일 웹 환경에서는 클라이언트 인터렉션간 페이지가 버벅이는것이 느껴질 정도로 성능이 낮은 상태 였습니다.
성능 개선을 통해 지표를 회복하는데 큰 도움이 되었던 웹 코어 바이탈 과 개선을 하기위해 진행한 작업 중 하나인, Code Spiltting 작업 경험을 공유해볼까 합니다.

이번 포스트에서는 주로 FCP, LCP, CLS 를 개선하기위해 접근해볼수 있는 항목들을 공유 해보도록 하겠습니다.

다른 성능 개선 작업에 대해서는 아래링크에 가면 더 확인해볼수 있습니다.

 

React 에서 Naver Maps API 를 사용할때 초기 페이지 로드 속도 최적화하기

제가 회사에서 작업하는 페이지에는 지도를 인터렉션 하는 UI 가 많습니다. 지도 API 는 모두 naver maps API 를 사용하고 있습니다. 이 naver maps API 를 이용하다보니, 페이지를 렌더링하는데 꽤나 많은

yoonocean.tistory.com

 

초기 페이지 로드 속도 개선 ( FCP, LCP )


성능 개선을 하는데 초점은, 웹 코어 바이탈을 지표를 준수하여 초기 페이지 로딩 속도와 CLS 항목을 개선하는데 중점을 두고 작업하게 되었습니다. 가장 먼저 한 작업은, 페이지 요청후 다운로드 받게 되는 javascript 파일과 css 파일의 크기가 너무 커, 페이지 로드간 요청되는 리소스의 크기를 먼저 줄이는 작업 이었습니다.

개선 이전의 모바일 통합검색 페이지 지표
자바스크립트 크기 보고 1
자바스크립트 크기 보고 2

성능 측정 도구를 이용해 분석한 결과, 메인 번들 파일의 크기가 363.3KB 나 되는것을 알수 있었습니다. 게다가 다른 외부 리소스는 제외하더라도 페이지 로딩에 전혀 필요없는 카카오톡 공유기능을 위한 Kakao SDK 가 포함되어 있습니다.

카카오 SDK 같은 외부 리소스는 보통 public/index.html 에 script 태그로 들어가 있는데, 먼저 이 script 를 필요할때 동적으로 추가할수 있게 빼내는 작업을 진행하였습니다.

1. 외부 리소스 ( Kakao SDK ) lazy load

// useKakaoSDKLoad.ts

const useKakaoSDKLoad = () => {
  const [isSDKLoad, setIsSDKLoad] = useState(false);
  const isFinish = useRef(false);
  const popup = useRecoilValue(popupState);

  useEffect(() => {
    if (popup.sharePopup === true) {
      getKakaoSDK(setIsSDKLoad);
    }
  }, [popup]);
  
  useEffect(() => {
    if (isSDKLoad === true && isFinish.current === false) {
      setTimeout(() => {
        try {
          window.Kakao.init("***");
          isFinish.current = true;
        } catch (e) {
          console.log(e);
        }
      }, 100);
    }
  }, [isSDKLoad]);
  
  return { isSDKLoad }
}
// getKakaoSDK.ts

export default function getKakaoSDK(
  setter: (value: React.SetStateAction<boolean>) => void
) {
  if (document.head.querySelector("#kakaoSDK")) return;

  const version = "2.0.0";
  const integrity =
    "***";

  const $kakaoScript = document.createElement("script");
  $kakaoScript.id = "kakaoSDK";
  $kakaoScript.async = true;
  $kakaoScript.type = "text/javascript";
  $kakaoScript.src = `https://t1.kakaocdn.net/kakao_js_sdk/${version}/kakao.min.js`;
  $kakaoScript.integrity = integrity;
  $kakaoScript.crossOrigin = "anonymous";
  document.head.appendChild($kakaoScript);

  const set = () => {
    setter(true);
    if (!$kakaoScript) return;
    $kakaoScript.removeEventListener("load", set);
  };
  if ($kakaoScript) {
    $kakaoScript.addEventListener("load", set);
  }
}

위와 같이, 리코일에서 전역 상태로 관리되는 popupState 에는, 각종 모달 팝업의 노출 여부를 관리하는 상태가 들어가 있습니다. popup.sharePopup 이 true 가 되면, kakao SDK 로딩을 시작합니다. 이후 kakado SDK 로딩이 완료되면, state 를 업데이트 하여 kakao SDK 를 init 합니다. 위와 같이하면, 초기 페이지 로딩시에는 kakao SDK js 파일이 다운로드 되지 않고 있다가, 공유 팝업이 열릴때 다운로드를 시켜서 불필요한 자바스크립트 크기를 줄일수 있었습니다.

추가로, Share Popup Component 는 아직 한번도 렌더링 된적이 없으므로, 이 컴포넌트 자체도 Lazy load 되도록 해줄수 있습니다. 이 과정에서 React.lazy 와 Suspense 를 사용하는대신 loadable 을 사용하였는데요, 로딩상태나 에러 상태를 기본적으로 지원해줘서 여러모로 개발하는데 있어 편하기도하고, 라이브러리 크기도 크지 않아서 사용하게 되었습니다.

// PopupContainer.tsx

const SharePopup = loadable(() => import(".../SharePopup"));

const NoResultPopup = loadable(
  () =>
    import(
      ".../NoResultPopup"
    )
);

const NoGPSPopup = loadable(
  () =>
    import(
      ".../NoGPSPopup"
    )
);

const AddPoiMain = loadable(
  () => import(".../AddPoiPopup")
);

const ReadMoreContainer = loadable(() => import(".../ReadMoreContainer"));


const PopupContainer = () => {
  const popup = useRecoilValue(popupFlagState);
  
	...

  return (
    <>
      {popup.sharePopup && <SharePopup />}
      {popup.noGPSPopup && <NoGPSPopup setPopup={setPopup} />}
      {popup.noFilterResultPopup && <NoResultPopup setPopup={setPopup} />}
      {popup.addPoiPopup && <AddPoiMain />}
      {popup.readMorePopup && <ReadMoreContainer isSDKLoad={isSDKLoad} />}
    </>
  );
};

이렇게 되면, 번들링 과정에서 해당 컴포넌트들을 스플리팅하여 메인 번들 스크립트에 포함 시키지 않고 렌더링이 필요한 상황에서 로드하게 됩니다. 따라서 그안에 포함된 서드 파티 라이브러리들도 포함되어, 메인 번들 스크립트를 많이 줄일수 있었습니다.

2. 로직에 따라 부분적으로 로드되는 UI Lazy load 

몇몇 UI 는 특정한 경우에만 나타나는 UI 도 부분적으로 있습니다. 검색 결과가 없음을 나타내는 UI 나, 추천 결과 등이 그러한 UI 중 하나였습니다. 추천 결과 섹션의 경우, 꽤나 코드 몸집도 크고 내부적으로 React-Query 라이브러리를 이용하기 때문에, 추천결과가 없는 경우에 로드하게되면 불필요한 JS 가 번들에 포함되게 됩니다. 따라서 이부분도 lazy load 하도록 처리했습니다.

// ContentContainer.tsx

const RecomContainer = loadable(() => import("./RecomContainer"));

const SubPoiContainer = loadable(() => import("./SubPoiContainer"));

const NoResultContainer = loadable(() => import("./NoResultContainer"));

const ContentContainer = () => {
  const result_data = useRecoilValue(resultState);
  const recommend = result_data?.recommend;

  const isNoResult = !result_data;

  const isSubPoiExist =
    result_data && ...;

  const isRecomExist =
    result_data && ...;

  return (
    <>
      {result_data && result_data.poi_section && (
        <div id="Content">
          {
            <>
              ...
              {isSubPoiExist && <SubPoiContainer />}
              {isRecomExist && (
                <div className="Recommend__Content">
                  <RecomContainer />
                </div>
              )}
              ...
            </>
          }
        </div>
      )}

      {isNoResult && (
          <NoResultContainer />
      )}
    </>
  );
};

이런 방식으로, 초기 페이지를 로딩하는데 필요하지 않은 코드들을 lazy load 하여, 페이지 렌더링이 빠르게 될수 있도록 작업할수 있었습니다.

레이아웃 쉬프팅 개선하기 ( CLS )


CLS 는 화면상으로 나타나는 UI 가 페이지 로드될때 얼마나 이동이 일어나는가를 수치화 한 지표 입니다. 저의 경우에는 보통 잘못 작성한 CSS 나, 크기를 지정하지 않거나, 비동기적으로 컨텐츠가 로드되는 컴포넌트들이 데이터가 채워지며 발생하는 CLS 가 대부분 이었습니다.

특히나 클라이언트 사이드 렌더링 특성상, API 호출을 페이지 렌더링과 동시에 하기 때문에 취약할수 밖에 없다고 생각합니다. 이러한 부분을 고려하여 컴포넌트를 설계하고, CSS 를 작성해야합니다. CLS 지표는 페이지 로드시 화면에 보여지는 부분 ( 문서의 최상단 ) 에서 CLS 가 발생할때 특히 많이 보고 되고, 위에서 아래로 렌더링되며 변동되는 레이아웃 크기 변화는 보통 포함되지 않는 현상을 경험 할수 있었습니다.

또한 클라이언트 사이드 렌더링하면서 주로 보게되는, 그려져 있는 화면에 응답 데이터를 받아오면서 하나씩 채워지는 모습이나 useEffect 처리를 통해 화면이 번쩍이는 모습은 유저가 보기에 상당히 좋지 못하다고 생각합니다. 응답 데이터를 가지고있을때 화면에 렌더링되게 처리해주는 것이 좋습니다.

const PoiListContainer = () => {
  const result_data = useRecoilValue(resultState);

  return (
    <>
      {result_data &&
        ... && (
          <PoiBox
            poiData={result_data}
            ...
          />
        )}
    </>
  );
};

위의 코드는, API 응답 데이터를 전역 상태로 가지고있는 resultState 에 그 데이터가 존재할때 UI 컴포넌트를 렌더링하도록 구성했습니다. 위와 같이 Container - UI Component 구조로, PoiBox 데이터를 props 주입받아 화면에 렌더링하게되는 stateless  UI 컴포넌트 입니다. 이렇게 하면 API 응답 데이터가 있을때 화면에 렌더링 되게 됩니다.

이런식으로 설계하게 되면, 처음 로드 당시에는 API 호출 말고 다른 작업을 하지 않기 때문에 PoiBox 위에서 작업한것과 마찬가지로 코드 스플리팅하도록 작업 해줄수 있습니다. 물론 코드를 스플리팅된 js 파일을 로드 하고 실행시키는 시간이 생겨, 오히려 LCP 늘어나지는 않는지 검토해봐야 합니다

또한, 컨텐츠 내용에 따라 UI 가 가변적으로 늘어나고 줄어드는 경우 height 값을 작성하지 않거나 auto 로 설정하고, % 단위를 사용하는 습관을 들이지 않도록 해야합니다. 주로 CLS 는 height 가 변동할때 발생합니다. width 가 변동되기 쉬운 text 등을 담고 있는 레이아웃등은, 레이아웃 자체를 한 block 을 모두 사용하게 하는것이 좋습니다.

CLS 가 발생한 레이아웃에 대해 찾아보면, 어디에서 레이아웃 쉬프팅이 크게 일어났는지 대략적으로 알수 있습니다. 개발자도구로 body 를 확인해 보면,

body 가 실제로 상단 UI 에 의해 이동되어져 있는 모습을 볼수 있습니다. 이 경우에는 비정상적인 css 에 의해 발생한 CLS 입니다. body 태그는 레이아웃중에서 가장 최상위 태그인데 body 에는 상단에 여백이 생길만한 이유가 없습니다. 찾아 보니 하위 UI 에 의해 상단에 여백이 생겼습니다.

이러한 잘못된 CSS 로 인해 발생하는 CLS 이슈가 많습니다. 보통 주변의 UI 에 margin 값이 있어 주변 Element 를 밀어내는 경우도 많습니다. 레이아웃의 범위가 클수록, CLS 수치는 높게 평가 됩니다. 아래와 같이 정상적인 CSS 를 입혀 body 태그는 전체 레이아웃을 감싸고, 헤더가 위치하는 Element 구조를 변경하고, 문제가되었던 class 명이 "Content" 였던 레이아웃에 padding 값을 입혀 동일한 UI 를 CLS 없이 구현할수 있었습니다.

지금 까지 알아본 페이지는 다이닝코드의 통합 검색 페이지 이며, 이러한 작업들을 통해 web core vital 지표를 개선해볼수 있었습니다.

 

하나더 예시를 들어보면, API 응답 결과를 받아온후 변경되는 컨텐츠에 의해 생기는 CLS 가 있습니다. 아래는 메인페이지를 lighthouse 를 통해 CLS 수치를 측정해본 결과 입니다.

 

보고된 스냅샷을 확인해 보면, 유저정보 API 를 타기전에 default 문구가 렌더링 되었다가, API 응답후 컨텐츠가 변경된것을 발견할수 있었습니다.

 

이런 경우에는 위에서 처리 예시로 들었던, API 응답 처리가 끝나후 렌더링과 함께 min-height 를 지정하여 CLS 문제를 해결할수 있습니다.

const Header = ({ location, loggedUser, regionKeyword }: Props) => {
  return (
    <HeaderBox>
      <HeaderBgImage />
      {loggedUser && (
        <HeaderInitContent
          username={loggedUser && loggedUser.name}
          location={location && regionKeyword}
        />
      )}
    </HeaderBox>
  );
};
.Header__Init__Section__User {
    min-height: 2.0625rem;

    padding: 0 .625rem;
    padding-top: 2.475rem;

    white-space: nowrap;

    font-weight: 300;
    letter-spacing: -0.0688rem;
}

 

이렇게 Web Core Vital 성능 측정 도구를 이용하여 문제가 되는 부분이 어느것이고, 어떤식으로 처리하여 개선할수 있는지 그 과정을 작성해 보았습니다. 생각보다 원인을 찾아보면, 단순하거나 당연히 처리했어야할 부분들인 경우가 많았습니다. 하지만 기존에 작업된 프로젝트가 존재하고, 그 상태에서 개선작업을 하게되면 꽤나 쉽지 않았던 기억이 납니다. 하지만 이런 지표 개선들을 통해 실제로도 유저 경험 개선과 실적 개선을 이끌어 낼수 있었습니다.

내용이 너무 길어져 작성하지 못했지만, 추가로 개발자 도구 탭의 performance 탭에서도 페이지 로드 성능 측정이 가능합니다. 제가 사용하면서 느낀건 가장 정확하다고 느껴졌습니다. 하지만 이러한 측정 도구들이 항상 정확하게 동일한 측정 결과를 보여주지는 않습니다. 여러 측정 도구들을 사용해가며, 개선점이 없는지 면밀히 검토할 필요가 있습니다.

이번 포스트에서 다루지 않은 성능 개선 내용으로는, 이미지 관련 처리와 React Devtools Profiler 를 통한 측정으로 React 성능 최적화가 있습니다. 이미지 또한 웹 페이지 로드 성능에 있어 큰 영향을 미치는 부분이고, 어떻게 이미지와 관련된 성능 최적화 개선할수 있었는지 경험을 작성해보도록 하겠습니다.