ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Next.js] 온보딩 페이지의 LCP를 개선해보자.
    Study (etc)/Troubleshooting 2025. 11. 11. 22:51

    이 트러블슈팅은 롤페 프로젝트에서 수행되었습니다.

     

    GitHub - Team-Exiters/Roll-Pe_FE

    Contribute to Team-Exiters/Roll-Pe_FE development by creating an account on GitHub.

    github.com

     

    문제 상황 개요

    문제 상황
    '롤페' 프로젝트의 온보딩 페이지 LCP가 dev 실행 기준 40.8초로 매우 많은 시간이 소요됨

     

    멋쟁이사자처럼 부트캠프 플러스 5기 수료 후 열심히 구직 활동을 하며 건들여 볼 것이 없을까 싶어 예전 프로젝트들을 뒤져보던 중, 올해 초에 수행했던 사이드 프로젝트인 롤페 프로젝트의 lighthouse 감사를 수행해보았다.

    좌 : 모바일 기준 / 우 : 데스크탑 기준

     

    접근성과 권장사항, SEO에 있어서는 매우 높은 평가를 받았지만, 성능 면에서 조금 부족함이 있음을 깨달았다.

    특히 LCP(Largest Contentful Paint)가 모바일에서 40.8초, 데스크탑에서 6.8초로 매우 많은 시간을 잡아먹고 있음을 알 수 있었다.

     

    💡 LCP(Largest Contentful Paint; 최대 콘텐츠 렌더링 시간)

    LCP는 사용자가 페이지로 처음 이동한 시점을 기준으로 표시 영역에 표시되는 가장 큰 이미지, 텍스트 블록 또는 동영상의 렌더링 시간을 말한다.

     

    출처 : web.dev

     

    web.dev의 LCP 관련 아티클을 읽어보았고, 2.5초 이하의 값이 권장된다는 것을 알았다.

     

    LCP는 아래 요소들을 고려한다.

    • <img> 요소(GIF, 애니메이션 PNG와 같은 애니메이션 콘텐츠의 경우 '첫 번째 프레임 표시 시간 사용)
    • <svg> 내의 <image> 요소
    • <video> 요소 (포스터 이미지 로드 시간 or 동영상의 첫 번째 프레임 시간 중 더 빠른 값 사용)
    • CSS 그라데이션이 아닌 url() 함수를 사용하여 로드된 배경 이미지가 있는 요소
    • 텍스트 노드 또는 다른 인라인 수준 텍스트 하위 요소가 포함된 블록 수준 요소

     

    문제 해결 목표

    📈 문제 해결 목표
    LCP를 2.5초 이하로 줄여 성능과 사용자 경험을 개선한다.

     

    이 트러블슈팅에서는 '롤페' 서비스의 온보딩 페이지에 한하여 LCP가 고려하는 요소들을 살피고 LCP가 40초, 6.8초까지 발생한 원인을 해결해 LCP를 2.5초 이하로 감소시키며 성능과 사용자 경험을 향상시키는 것을 목표로 한다.

     

    문제 해결 과정

    1. 이미지 최적화

    롤페의 온보딩 페이지

     

     

    롤페의 온보딩 페이지는 5개의 이미지 파일을 렌더링한다.

    그 중 세 개의 파일에 대해 이미지 다운로드 시간을 줄이라는 lighthouse의 조언을 토대로 하나씩 개선을 해보자. 

     

     

    💡 원인

    여기서 파악된 문제는
    1. 배경 이미지(image_background.png)가 png 포맷으로 사용되어 리소스 크기가 매우 큰 것,
    2. 두 개의 소개 이미지가 표시된 크기에 비해 실제 이미지 크기가 너무 큰 것.
    이렇게 두 가지가 있다.

     

    1-1. 배경 이미지의 파일 포맷 개선

    바스락 거리는 질감의 배경 이미지는 CSS의 url() 함수를 통해 png 파일이 렌더링되며, 이는 LCP의 고려 요소 중 하나이다.

    background: url("images/image/image_background.png") no-repeat center center;

     

    따라서 이것을 next/Image를 활용하여 최신 이미지 포맷으로 최적화하고 성능을 개선했다.

    <div className={"background-wrapper"}>
            <Image
              src={background}
              alt={"배경 이미지"}
              fill={true}
              objectFit="cover"
              objectPosition="center"
              priority={true}
            />
            <div className={"gradient-overlay"} />
          </div>

     

    • Next/Image를 사용하여 배경 이미지를 컴포넌트로 분리하였고, position 속성과 z-index를 활용하여 기존 background 속성 사용시와 동일한 결과를 만들었다.
      • 아래로 갈수록 흐려지는 그라디언트 오버레이는 별도의 div 요소로 분리하여 스타일을 지정했다.
    • "background-wrapper"로 이미지 컨테이너를 구성하고 objectFit, objectPositon props를 통해 반응형 이미지를 구현했다.
    • priority={true}를 지정하여 loading="eager"와 동일하게 설정하여 lazy loading을 비활성화했다.
      • 이는 이 이미지를 우선적으로 로딩하여 사용자에게 빠르게 화면을 보여주기 위함이다.

    1-2. 소개 이미지의 반응형 개선

    <div className={"main-image-wrapper"}>
              <Image
                src={sectionImage}
                layout="responsive"
                width={305}
                height={406}
                alt={"롤페 설명1"}
              />
            </div>
            
     // 두 이미지의 코드는 동일하다

     

    기존의 소개 이미지는 위와 같이 작성되어있다.

    반응형으로 이미지가 표시되는 데에 문제는 없지만, width와 height를 정적으로 지정하고 있기도 하고, layout props는 Next.js 13부터 deprecated 되어서 Next.js 14로 빌드된 롤페 프로젝트에서는 사용하지 않는 것이 좋겠다고 판단하여 개선해보았다.

     

     <MainImageWrapper>
      <Image
        src={image}
        alt={`롤페 가이드 이미지 ${sectionNum}`}
        fill
        objectFit="contain"
      />
    </MainImageWrapper>
    
    
    const MainImageWrapper = styled.div`
      position: relative;
      width: 100%;
      aspect-ratio: 642 / 844;
    `;

     

     

    위는 개선된 인트로 이미지에 대한 코드이다. 

    모바일 환경과 데스크탑 환경에서의 이미지 래퍼 height가 유동적이어서 명확하게 부모에게 height값을 지정하지 못할 것 같았다.

    따라서 디자이너 친구가 만들어준 figma를 기반으로 이미지의 원본 비율을 기재했고, fill과 objectFit 속성을 통해 컨테이너 기반 반응형을 구현했다. 이 경우 컨테이너에 position: relative;는 필수다. Next/Image는 자동으로 이미지에 absoulte를 부여하기 때문이다.

     

     

    2. 네트워크 페이로드 크기 개선

     

    2-1. 이미지 압축 포맷을 avif로 변경하여 네트워크 페이로드 감소

    2-2. 폰트 포맷을 woff2로 변환하고 layout.tsx를 수정하여 폰트가 차지하는 네트워크 페이로드 감소

    기존 root layout에서는 사용하는 폰트를 모두 불러오고 있었다.

    import {
      pretendard,
      hakgyoansim,
      dunggeunmo,
      jalnangothic,
      nanumpen,
      nanummyeongjo,
    } from "@/public/fonts/fonts";

     

    next/font/local은 빌드 시

    1. 각 폰트 파일을 /next/static/media/... 로 복사
    2. _app이나 _document에 자동으로 <link rel="preload">를 삽입
    3. import된 순간 해당 폰트가 사용될 가능성이 있다고 간주되어 preload 대상이 됨

    따라서 위처럼 root layout.tsx에서 모든 폰트를 import할 시 6개의 폰트가 전부 번들링 대상이 되어서 사용하지 않아도 네트워크 요청이 발생한다.

     

    모든 폰트들을 사용하는 컴포넌트에서 각각 직접 로드하도록 변경하기로 했다.

    const hakgyoansim = localFont({
      src: "../../../public/fonts/HakgyoansimR.woff2",
      weight: "400",
      display: "swap",
    });
    
    const OnBoarding: React.FC = () => {
      return (
        <OnBoardingPageWrapper className={`${hakgyoansim.className}`}>
          <OnBoardingIntro />
          <OnBoardingGuide
            image={introImage01}
            title={
              <>
                쉽게 만드는
                <br />
                우리만의 롤페
              </>
            }
            sectionNum={1}
          />
          <OnBoardingGuide
            image={introImage02}
            title={
              <>
                함께 나누었던 추억,
                <br />
                언제 어디서나
              </>
            }
            sectionNum={2}
          />
          <Footer />
        </OnBoardingPageWrapper>
      );
    };
    
    export default OnBoarding;

     

     

     

    3. globals.css의 렌더링 차단 요청 개선

     

    기존 globals.css에는 우리가 프로젝트에서 사용하는 모든 HTML 요소에 대해 리셋하는 스타일을 작성해두었는데, 이를 layout.tsx의 HTML에 inline으로 적용함으로써 불필요하게 globals.css를 불러오며 발생하는 블로킹을 해소할 수 있었다.

     

    import type { Metadata } from "next";
    import { COLORS } from "@/public/styles/colors";
    import StyledComponentsRegistry from "@/public/lib/registry";
    import ReduxProvider from "./_components/redux-provider/ReduxProvider";
    // import SlideMenu from "./_components/ui/layouts/SlideMenu";
    
    export const metadata: Metadata = {
      title: "롤페 | Roll-Pe",
      description: "모두의 마음을 모아 사랑하는 사람에게 전달해보세요.",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="ko">
          <head></head>
          <body
            style={{
              margin: "0",
              maxWidth: "100vw",
              minHeight: "100vh",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              lineHeight: "1",
              textRendering: "optimizeSpeed",
              WebkitFontSmoothing: "antialiased",
            }}
          >
            <ReduxProvider>
              <StyledComponentsRegistry>
                <main
                  style={{
                    position: "fixed",
                    display: "flex",
                    flexDirection: "column",
                    alignContent: "center",
                    width: "100%",
                    maxWidth: "768px",
                    height: "100%",
                    border: `1px solid ${COLORS.ROLLPE_GRAY}`,
                    overflowX: "hidden",
                    overflowY: "auto",
                    scrollbarWidth: "none",
                    msOverflowStyle: "none",
                  }}
                >
                  <div
                    style={{
                      flex: "1",
                      width: "100%",
                      height: "100%",
                    }}
                  >
                    {children}
                  </div>
                </main>
              </StyledComponentsRegistry>
            </ReduxProvider>
          </body>
        </html>
      );
    }

     

     

    문제 해결 결과

    ‼️ LCP 54.4% 감소
    롤페의 온보딩 페이지 LCP를 54.4% 감소시켰다.

     

    우선! 성능 개선은 한 반쯤.... 그렇지만 성공적이었다.

     

    모바일은 기존 40.8초 → 18.6초, 데스크탑은 6.3초 → 3.1초로 모두 54.4% 감소시켰다.

    헌데, 현재 내 능력으로 아무리 줄일 수 있는 것들을 줄여봐도 이상적인 LCP인 2.5초에 도달하지 못했다.

     

    lighthouse는 위 두 가지 항목에 대해서 진단 결과를 내주었다.

    내가 파악해본 두 가지 항목은 아래와 같다.

     

    '레거시 JavaScript를 최신 브라우저에 제공하지 않기'의 경우, Polyfill이라고 하는, 구형 브라우저에서 신규 JavaScript API를 사용할 수 있게 하는 방법의 설정 변경으로 해결할 수 있을 것 같은데, 명확한 방향을 파악하지 못해 조금 더 깊은 수준의 파악이 필요해보인다.

     

    '네트워크 페이로드가 커지지 않도록 관리하기' 의 경우 여전히 pretendard variable의 woff2 파일이 2KiB 이상의 네트워크 페이로드를 차지하고 있는 문제가 있었다. 이에 대해서는 pretendard variable의 subset을 더 알아보고 프로젝트에 사용하고 있는 한글 문자의 종류와 font-weight들의 속성을 바탕으로 필요한 것들만 취해 페이로드를 감소시킬 수 있는 방법이 있을 것 같다.

     

    왠만해서는 이 포스팅에 모두 다루고 싶었지만, 이 트러블슈팅은 언제까지나 '온보딩' 페이지 단 한 장에 대한 LCP 개선 이야기였기 때문에 여기에 매몰되었다가는 다른 페이지들을 건들 시간이 없어질 것 같아서, 우선은 50% 이상의 성능 개선을 이뤄낸 데에 만족하고, 추후에 Polyfill, 폰트 서브셋 개선에 대해서 개선을 수행한 뒤에 트러블슈팅으로 따로 다뤄보도록 하겠다.

     

    참고자료

    - web.dev : 최대 콘텐츠 렌더링 시간(LCP) | https://web.dev/articles/lcp?hl=ko

    - bori님의 블로그 https://velog.io/@qhflrnfl4324/nextimage-%EB%B0%98%EC%9D%91%ED%98%95-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-Next.js-13

     

Designed by Tistory.