AndChill 개발일지(9) - 최적화(LCP, CLS, SEO etc.)

dali·2024년 9월 16일
1

And Chill 개발 기록

목록 보기
9/9

최적화.. 해야겠지? 🤓🔪

성능, 검색엔진 최적화를 하나도 진행하지 않은 상태에서의 측정 결과입니다. 차근 차근 진행해 봅시다.

웹 최적화란? (feat. Lighthouse)

웹 최적화는 사용자가 URL을 입력하고 브라우저에 화면이 띄워지기까지의 과정에서 응답속도를 향상시키는 것입니다. 이는 UX를 향상시키는 데 중요한 역할을 합니다. 응답속도를 개선함으로써 사용자가 웹페이지를 더 빠르게 로드할 수 있도록 하며, 검색 엔진 최적화(SEO)를 통해 페이지 방문자 수를 늘리는 데 도움을 줄 수도 있죠.

웹사이트 모니터링 서비스 pingdom 의 분석에 따르면, 페이지 로드까지 4초가 소요되면 이탈률은 24%로 급격히 증가한다고 합니다. 이와 같이 사용자가 웹 사이트에 처음 접속했을 때 4초 이상 흰 화면만 본다면, 좋지 않은 첫인상을 갖게 됩니다. 🙁

이처럼 웹 사이트의 디자인 등 시각적인 요소 뿐만 아니라 성능도 UX에 큰 영향을 미칩니다.

🗼 저는 Google Chrome에서 제공하는, 웹앱 품질 개선에 도움을 주는 자동화 도구인 Lighthouse 를 사용해서 웹 성능을 측정하고 최적화를 진행해 보았습니다.


성능 최적화

위 이미지에서 보여지듯이 성능 계산에 상대적으로 높은 비율을 차지하는 지표가 3가지 있습니다.

  • 25% - LCP(Largest Contentful Paint)
    사용자가 처음 페이지로 이동한 시점을 기준으로 표시 영역에 표시되는 가장 큰 이미지 또는 텍스트 블록의 렌더링 시간을 의미합니다. 좋은 LCP 점수는 2.5초 이하여야 합니다.

  • 30% - TBT(Total Blocking Time)
    페이지 로딩 중 반응성이 얼마나 좋은가를 나타냅니다. 로딩 중에도 사용자의 입력에 잘 반응하는 페이지와 그렇지 않은 페이지는 사용자 입장에서 성능을 좌우하는 중요 기준이 된다. 이 기준은 입력 반응성이 떨어질 정도로 메인 스레드가 멈추는 시간을 누적한 값입니다.

  • 25% - CLS(Cumulative Layout Shift)
    CLS는 컨텐츠가 화면에서 얼마나 많이 움직이는지를 수치화 지표입니. 이 지표는 사용자 중심의 성능 지표로서 컨텐츠가 화면에서 이리저리 움직이는 것이 불편을 초래할 수 있기 때문에 제공하는 자료입니다.

TBT는 다행히 0ms가 나왔지만 문제는 LCP, CLS 가 점수를 모두 갉아먹고 있네요.. 두 부분 위주로 성능 개선을 해봅시다.


CLS 개선

CLS는 처음 봤을 때 성능과 다소 무관한 지표라고 생각했습니다. 그러나 특정한 경우에서는 CLS 지표가 떨어지는 것이 큰 문제가 될 수도 있습니다. 예를 들어서 사용자가 로딩이 완료될 즈음에 구매 취소 버튼을 누르려고 했는데, 그 순간 뒤늦게 페이지 상단에 광고가 끼어들게 된다면 어떻게 될까요? 사용자가 누르려고 했던 취소 버튼은 아래로 이동할 것이고, 잘못된 영역(운이 좋지 않다면 구매 버튼)을 누르게 되버릴 겁니다.

재미있는 예시가 있어서 들고 와봤습니다 ㅋㅋ

저렇게 layout shifting 이 일어나면 안되고, 광고가 있어야 할 자리에 공간이 차지되어 있어갸 겠죠.


✅ 스켈레톤 UI 제공

위와 같은 일을 방지하기 위한 방법으로는 스켈레톤 UI를 제공하면 됩니다. 스켈레톤 UI는 실제 데이터가 로딩되지 전 화면의 윤곽을 미리 보여주는 로딩화면 입니다.

저는 스켈레톤 UI 라고 부르기는 하지만 실제로는 원래 영화 리스트의 공간만큼 차지하는 div에 로딩 아이콘을 띄워주는 형식으로 MovieListSkeleton 을 구현했습니다.

const { data: randomMovieData, isLoading } = useMovieResultsQuery(...);
...
                                                                  
{isLoading ? (
        <MovieListSkeleton /> //데이터 로딩 시 스켈레톤 UI render
      ) : (
        <MovieList data={movieData?.results} />
)}

적용 전 - 로딩 전에 공간을 차지하지 않아서 아주 그냥 난리가 납니다.

적용 후 - 로딩 전에 스켈레톤이 공간을 차지하고 있어서 이질감이 없습니다. (편안~)

로딩처리 및 스켈레톤 UI 만 잘 적용해 주어도, CLS를 많이 개선할 수 있어서 성능 점수를 대폭 올릴 수 있습니다.

  • CLS 지표 0.2280 으로 개선
  • 성능 점수 68점82점 으로 개선

LCP 개선

사실 이미지가 많은 웹사이트에서의 LCP 개선은 이미지 형식을 바꾸면서부터 시작합니다. jpg, png 형식이 아닌 차세대 형식 webp 를 사용하면 이미지의 용량을 매우 줄일 수 있지만 현재는 정해진 형식으로 오픈 api에서 이미지를 받아오다 보니 이 방식을 적용하기에는 이슈가 있었습니다. 따라서 그 외에 프론트 단에서 할 수 있는 최적화 방법들을 적용해 보았습니다.

✅ React Router 기반 code splitting

Code Splitting 을 적용하면 초기 로드 시 불필요한 자바스크립트 파일을 제외하고, 현재 페이지에 필요한 리소스만 로드되므로 주요 콘텐츠가 더 빠르게 렌더링됩니다. 이미지가 많은 많은 제 페이지에서는 중요한 작업입니다.

예를 들어 페이지가 /main , /about , /post 이렇게 세 가지 페이지로 이루어진 SPA를 개발한다고 할 때, /main으로 들어가는 동안 /about이나 /post 페이지 정보는 사용자에게 필요하지 않을 확률이 높겠죠. 이러한 파일들을 splitting 하여 지금 사용자에게 필요한 파일만 불러올 수가 있다면 로딩도 빠르게 이루어지고 사용자 경험이 좋아질 수가 있습니다.

react의 lazy를 통해 페이지들을 동적으로 로드하고, Suspense 컴포넌트를 활용하여 로딩 중에 대체 UI를 보여주는 방식으로 code splitting 을 진행하였습니다.

src/pages/index.ts

import { lazy } from 'react';

const Root = lazy(() => import('@pages/Root'));
const Home = lazy(() => import('@pages/home'));
const MovieDetails = lazy(() => import('@pages/movie-details'));
const Discover = lazy(() => import('@pages/discover'));
...

export { Root, Home, MovieDetails, Discover, ... };

src/Router.tsx

import { Suspense } from 'react';
import * as Page from '@pages/index';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

const LoadingFallback = () => <div />;

const Router = () => {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingFallback />}>
        <Routes>
          <Route path="/" element={<Page.Root />}>
            <Route index element={<Page.Home />} />
            <Route path="movie-details/:movieId" element={<Page.MovieDetails />} />
            <Route path="discover" element={<Page.Discover />} />
			...
          </Route>
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
};

export default Router;

✅ 공통 모듈 분리

Vite 의 빌드 도구인 Rollup 을 활용해보았습니다.

import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          if (id.indexOf("node_modules") !== -1) {
            const module = id.split("node_modules/").pop().split("/")[0];
            return `vendor-${module}`;
          }
        },
      },
    },
  },
});

rollupOptionmanulChunk 를 설정해줍니다. 해당 기능은 공통으로 사용하는 모듈을 분리하는 역할을 합니다. 콜백 함수의 첫 번째 파라미터인 id 에서 node_modules 가 있는지 찾고, node_modules 를 스플릿 해서 vendor 라는 prefix 를 붙인 module 을 넣어줍니다.

manualChunk 를 설정한 후 다시 빌드를 해줍니다.

https://rollupjs.org/configuration-options/#output-manualchunks

이전 build

이후 build

1000Kib 의 스크립트를 줄였네요!


✅ Vite 설정 파일 수정 (스크립트 압축)

vite-plugin-compression 을 사용하여 Vite 설정을 통해 빌드된 파일을 미리 압축하는 방법으로도 LCP를 단축 시킬 수 있습니다. 빌드된 파일을 GzipBrotli 형식으로 미리 압축하여 효율적으로 제공할 수 있도록 도와줍니다.

vite-plugin-compression 설치

npm i vite-plugin-compression --save-dev

Vite 설정 파일 수정 vite.config.js

import compression from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    react(),
    compression({ algorithm: 'gzip' }), // Gzip 압축 활성화
    compression({ algorithm: 'brotliCompress', ext: '.br' }) // Brotli 압축 활성화
  ]
});

✅ 폰트 변경

사실 이 부분이 하이라이트 입니다. 이 방법을 적용해서 LCP를 약 2초나 줄였기 때문이에요!

1. WOFF 2.0 형식의 폰트 사용
woff2 형식은 woff 형식의 30-50% 압축된 형식이며 IE를 제외한 모든 브라우저에서 지원하고 있습니다.

  @font-face {
    font-family: 'Pretendard';
    src: url(${PretendardLight}) format('woff2');
    font-weight: 300;
    font-display: swap;
  }

2. Subset 폰트 사용
서브셋 폰트는 노란색으로 표시된 글자와 같이 실생활에서 거의 사용되지 않는 글자를 폰트 파일에서 제거한 폰트입니다. 저는 Pretendard-subset 폰트를 사용했습니다!

https://github.com/orioncactus/pretendard

기본 폰트는 용량이 9.59MB 이고 sebset폰트는 2.29MB 로 약 76% 더 가벼운 걸 확인할 수 있습니다!



성능 최적화 결과

이렇게 성능 최적화를 보았는데요, 과연 얼마나 개선되었을까요??

😎
초록 불빛들을 보는 순간 도파민이 뿜어져 나왔습니다. 아마 저 나머지 5점은 이미지 최적화 혹은 리사이즈 및 데이터가 불러오는 속도에 영향을 받는 것 같아서 SSR 방식을 도입하면 개선할 수 있지 않을까 싶습니다.

성능 최적화 결과

  • CLS 지표 0.2280
  • LCP 지표 4.0초1.0초
  • 성능 점수 68점95점 으로 약 40% 개선

검색엔진(SEO) 최적화

SEO 란 검색엔진이 이해하기 쉽도록 홈페이지의 구조와 페이지를 개발해 검색 결과 상위에 노출될 수 있도록 하는 작업을 말합니다. SEO 점수는 크롤링의 가능성과 성능에 영향을 미치는 요인들을 기반으로 측정됩니다. 높은 SEO 점수는 웹사이트가 검색 결과에서 높은 순위를 차지할 가능성이 높다는 것을 의미하겠죠.

현재 저의 SEO 점수입니다.

좋은 SEO 점수는 80점 이상입니다. 90점을 넘으면 웹에서 기술적으로 최적화된 최고의 웹사이트 중 상위 10%에 속한다는 의미입니다! 이건 못참으니까 다음과 같은 방법들을 사용해서 점수를 올려보았습니다.

✅ robots.txt 설정

제일 간단하게 진행할 수 있는 robots.txt 파일부터 설정해보겠습니다.
robots.txt 는 검색로봇에게 사이트 및 웹페이지를 수집할 수 있도록 허용하거나 제한하는 국제 권고안입니다. 파일은 항상 사이트의 루트 디렉터리에 위치해야 하며 일반 텍스트 파일로 작성해야 합니다.

작성 방법에 따라 특정 페이지, 문서에 대한 접근을 선택적으로 허용하거나 차단할 수 있습니다. 제 웹사이트는 모든 페이지에 대한 접근 허가이므로 다음과 같이 작성해 주었습니다.

public/robots.txt

User-agent: *
Allow: /

✅ react helmet 이용한 meta tag 설정

리액트는 SPA라, 각 검색봇(크롤러)들이 단 하나의 public/index.html 만을 읽게 됩니다. 이는 각각의 페이지에 대한 정보를 읽지 못한다는 단점이 있습니다.

이러한 SPA의 단점을 극복하기 위해 react-helmet 라이브러리를 사용해서 페이지별 메타태그를 다르게 정의했습니다. 각 페이지의 정보를 메타태그로 무슨 페이지인지 설명해 주어야, 웹사이트 안에 있는 페이지들을 원활하게 수집할 수 있습니다. 수집한 페이지가 많으면 많을수록 SEO 점수가 높아지고, 검색결과에 여러 개의 페이지들이 등록이 됩니다.

결론은, 사이트에 대한 기본 설명이 있는 메타태그와 페이지별 메타태그를 구분지어 정의할 수 있어야 된다는 것

1. react-helmet-async 설치

npm i react-helmet-async

2. HelmetProvider로 감싸기

Helmet 컴포넌트는 HelmetProvider 컴포넌트 안에 있어야 하기 때문에 라우터에서 감싸줍니다.

import { HelmetProvider } from 'react-helmet-async';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

const Router = () => {
  return (
    <BrowserRouter>
      <HelmetProvider>
        <Routes>
          <Route path="/" element={<Page.Root />}>
            <Route index element={<Page.Home />} />
            ...
          </Route>
        </Routes>
      </HelmetProvider>
    </BrowserRouter>
  );
};

export default Router;

3. 메타태그 관리하기

페이지별 메타태그는 각 페이지 컴포넌트 파일 안에 <Helmet> ~ </Helmet> 으로 정의할 수 있습니다.

메타태그 예시

<Helmet>
  <title>React Title</title>
  <meta name="description" content="페이지에 대한 설명~~~~" />
  <meta property="og:image" content="" />
  <meta property="og:url" content="" />
</Helmet>

위처럼 긴 메타태그를 각 페이지 컴포넌트 파일에 여러 번 정의 해야 되다 보니, 메타태그 컴포넌트를 따로 만들어 사용하면 관리가 편하겠죠. 우선은 기본적으로 들어가야 하는 태그들만 담긴 컴포넌트를 만들었습니다.

src/pages/SEOMetaTag.tsx

import { Helmet } from 'react-helmet-async';

function MetaTag(props: any) {
  return (
    <Helmet>
      <title>{props.title}</title>
      <meta name="description" content={props.description} />
    </Helmet>
  );
}

export default MetaTag;

이제 영화 상세 정보 사이트에 적용해 봅시다. 아래 예시와 같이 메타 태그를 동적으로 설정할 수도 있습니다.

import MetaTag from '@pages/SEOMetaTag';

const MovieDetails = () => {
  ...
  const { data: movieDetailsData} = useMovieDetailsQuery(movieIdNumber, lang);

  return (
    <>
      {movieDetailsData && (
        <MetaTag
          title={`${movieDetailsData.title}`}
          description={`'${movieDetailsData.title}' 영화 상세 정보 페이지 입니다.
        />
      )}

      <S.Container>
        ...
      </S.Container>
    </>
  );
};

export default MovieDetails;



SEO 최적화 결과

이렇게 SEO 최적화도 마무리 지었습니다. SEO 점수를 포함해서 전체적으로 얼마나 개선되었는지 보겠습니다.

🟢🟢🟢🟢

모두 초록불로 바꾸었습니다. 성공적으로 웹 최적화를 마쳤습니다!

구글에도 검색했을 때 제 사이트가 스멀스멀 올라오고 있는 것 같습니다. 조금 더 상위에 랭크되고 구체화 되려면 3~5일 정도는 걸린다고 하네요


웹 최적화 최종 결과

Chrome Browser에서 FP(First Paint) 부터 LCP(Largest Contentful Paint) 까지 걸린 시간을 측정해봤습니다.

최적화 이전 - FP(373.24ms) → LCP(1450ms): 1076.76ms

최적화 이후 - FP(839.56ms) → LCP(1008ms): 177.44ms

1초 에서 0.17초 까지 렌더링 시간이 줄어든 것을 확인할 수 있습니다!

  • CLS 지표 0.2280
  • LCP 지표 4.0초1.0초
  • 성능 점수 68점95점 으로 약 40% 개선
  • SEO 점수 75점100점 으로 약 33% 개선
  • 렌더링 시간 1076.76ms177.44ms 으로 약 84% 개선

2개의 댓글

comment-user-thumbnail
2024년 9월 27일

와 달리님 최적화 포스팅 엄청 꼼꼼히 하셨네요!! 멋집니다!! 저도 폰트 최적화 다시 해봐야겠어요

1개의 답글

관련 채용 정보