인턴

🚀 랜딩 페이지 성능 최적화

choijming21 2025. 7. 3. 17:52

랜딩 페이지의 Lighthouse Performance 점수를 17점에서 60점까지 올린 최적화 과정을 공유합니다. 주요 개선 사항은 Hero 배너 최적화, 섹션 지연 로딩 적용, 서드파티 스크립트 최적화, 그리고 CLS 개선입니다.

 

 

 

< 최종 결과 >

항목 Before After 개선도
Lighthouse Performance(mobile) 17점 65점 +43점
사용하지 않는 JavaScript 956KiB 693KiB -263KiB
이미지 크기 최적화 미적용 적용 -50KiB

 

 

배경과 목표

📊 문제 상황

  • 랜딩 페이지 첫 진입 시 대용량 영상과 이미지가 모두 로드되어 Lighthouse 측정이 불가할 정도로 랜더링 속도가 매우 느림
  • LCP(최대 콘텐츠 표시 시간) 지연 및 CLS(누적 레이아웃 이동) 발생
  • Lighthouse Performance 점수가 17점으로 매우 낮아 SEO 및 UX 품질 저하

 

🎯 목표

  • Lighthouse Performance 점수를 50점 이상으로 개선!!
  • 초기 렌더링 시점의 DOM, JS 비용 감소
  • 사용자 경험 개선을 통한 이탈률 감소

🔧 1단계: HeroV2 최적화 (17점 → 33점)

문제점 분석

기존 Hero 배너 컴포넌트는 CSS `background-image`를 사용하여 이미지를 로드했습니다. 이는 이미지 최적화를 할 수 없고 페이지 진입 시 바로 보이는 배너라 LCP 성능에 악영향을 미쳤습니다.

 

해결 방안

Next.js의 `<Image />` 컴포넌트로 전면 교체하여 이미지 최적화를 적용했습니다.

// Before: CSS background-image 사용
<div className="hero-banner" style={{ backgroundImage: `url(${heroImage})` }}>
  {/* content */}
</div>

// After: Next.js Image 컴포넌트 사용
import Image from 'next/image';

<div className="hero-banner">
  <Image
    src={heroImage}
    alt="Hero Banner"
    width={1920}
    height={1080}
    priority // LCP 요소이므로 우선 로드
    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  />
</div>

 

 

주요 개선사항

  • `unoptimized` 옵션 제거로 자동 최적화 활성화
  • `prioirty` 속성으로 LCP 요소 우선 로드
  • `sizes` 속성으로 뷰포트 기반 최적화
  • 메인 이미지: `fill` 속성 대신 정확한 `width`, `hight`값 명시

🔧 2단계: 지연 로딩 적용 (33점 → 49점)

문제점 분석

모든 Section과 Footer가 초기 렌더링 시점에 로드되어 불칠요한 리소스 소비가 발생했습니다.

 

해결 방안

`useInteractionLoad` 커스텀 훅을 활용한 지연 로딩을 적용했습니다.

// useInteractionLoad 커스텀 훅
import { useEffect, useState } from 'react';

export const useInteractionLoad = () => {
  const [shouldLoad, setShouldLoad] = useState(false);

  useEffect(() => {
    const handleInteraction = () => {
      setShouldLoad(true);
    };

    // 한 번만 실행되도록 { once: true } 옵션
    window.addEventListener('click', handleInteraction, { once: true });
    window.addEventListener('scroll', handleInteraction, { once: true });
    window.addEventListener('touchstart', handleInteraction, { once: true });

    return () => {
      window.removeEventListener('click', handleInteraction);
      window.removeEventListener('scroll', handleInteraction);
      window.removeEventListener('touchstart', handleInteraction);
    };
  }, []);

  return shouldLoad;
};

 

// 섹션 렌더링 로직
const shouldLoadSections = useInteractionLoad();

{isReady && renderingData && renderingData.length > 0
  ? renderingData.map((data, index) => {
      const sectionName = data.sectionName ?? data[0];
      const shouldLazyLoad = index >= 4 && !shouldLoadSections;

      return (
        <SectionWrapper
          id={sectionName}
          key={sectionName}
          sectionName={sectionName}
          enableLazyLoading={shouldLazyLoad}
          {...(data?.lnbName && {
            'data-lnb-section': data?.lnbName,
          })}
        >
          {/* 섹션 콘텐츠 */}
        </SectionWrapper>
      );
    })
  : skeletonComponents}

 

참고 영상

 

 

주요 개선사항

  • 상위 3개 섹션만 미리 렌더링 하여 LCP 대상에 포함
  • 이후 섹션은 사용자 인터랙션 후 렌더링
  • Footer도 뷰포트 하단 진입 시점에만 랜더링
  • 고정 높이 placeholder로 CLS 방지 -> skeletonComponents

🔧 3단계: 서드파티 최적화 (49점 → 60점)

문제점 분석

마케팅 스크립트와 분석 도구들이 초기 로드 시점에 모두 실행되어 불필요한 JavaScript 로드가 발생했습니다.

 

해결 방안

위의 `useInteractionLoad` 커스텀 훅을 사용해 사용자 인터랙션 후에만 서드파티 스크립트를 로드하도록 개선했습니다.

// _app.tsx에서 조건부 스크립트 로딩
import { useInteractionLoad } from '@/hooks/useInteractionLoad';

export default function App({ Component, pageProps }: AppProps) {
  const shouldLoadScripts = useInteractionLoad();

  return (
    <>
      <Head>
        {/* 필수 스크립트만 초기 로드 */}
      </Head>
      
      <Component {...pageProps} />
      
      {/* 인터랙션 후 로드되는 스크립트들 */}
      {shouldLoadScripts && (
        <>
          <GoogleTagManager />
          <KakaoScripts />
          <InitDataDog />
          <DynamicEnlipleTracker />
          <SmartLog />
        </>
      )}
    </>
  );
}
// SmartLog 동적 import 적용
import dynamic from 'next/dynamic';

const SmartLog = dynamic(() => import('@/components/SmartLog'), {
  ssr: false, // CSR로 전환
});

🔧 4단계: JavaScript 청크 분리 (60점 → 63점)

문제점 분석

  • `_app.js` 번들이 590KB로 과도하게 큰 상태
  • PDF 생성 라이브러리(`jspdf`, `html2canvas`)가 메인 번들에 포함되어 초기 로딩 부담
  • 회사 Auth 라이브러리도 모든 페이지에서 불필요하게 로드됨
  • "사용하지 않는 자바스크립트 줄이기" Lighthouse 경고 지속

해결 방안

  • Webpack 청크 분리 설정
// next.config.js
const nextConfig = (phase, { defaultConfig }) => {
  return {
    // ... 기타 설정들
    webpack: (config, { isServer, dev }) => {
      // 프로덕션 빌드에서만 청크 분리 적용
      if (!isServer && !dev) {
        config.optimization = config.optimization || {};
        config.optimization.splitChunks = config.optimization.splitChunks || {};
        config.optimization.splitChunks.cacheGroups =
          config.optimization.splitChunks.cacheGroups || {};

        // PDF 관련 라이브러리 분리
        config.optimization.splitChunks.cacheGroups.pdfLibs = {
          name: 'pdf-libs',
          test: /[\\\\/]node_modules[\\\\/](jspdf|html2canvas)[\\\\/]/,
          chunks: 'all',
          priority: 30,
          enforce: true,
        };

        // 회사 Auth 라이브러리 분리
        config.optimization.splitChunks.cacheGroups.회사 = {
          name: '회사-auth',
          test: /[\\\\/]node_modules[\\\\/]@회사[\\\\/]auth-frontend[\\\\/]/,
          chunks: 'all',
          priority: 25,
          enforce: true,
        };
      }
      return config;
    },
  };
};

module.exports = nextConfig;

 


🔧 5단계: CLS 개선 (63점 → 65점)

문제점 분석

 

이미지 크기가 명시되지 않아 로딩 중 레이아웃 이동이 발생했습니다.

 

해결 방안

모든 이미지에 정확한 크기를 명시하고 `next/image`로 교체했습니다.

// Before: 크기 미명시
<img src={imageSrc} alt="description" />

// After: 크기 명시 및 최적화
<Image
  src={imageSrc}
  alt="description"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, 50vw"
/>

 

주요 개선사항

  • 모든 이미지에 `sizes`, `width`, `height` 속성 명시
  • `unoptimized` 옵션 제거로 자동 최적화 활성화
  • Lighthouse "이미지 크기 적절하게 설정하기" 항목 해결

🚨 트러블슈팅: StickyQuickMenu 충돌 해결

문제 상황

 

지연 로딩 작업 중 `StickyQuickMenu` 기능이 별도로 머지되면서 충돌이 발생했습니다.

  • 문제: StickyQuickMenu는 DOM 요소가 실제 렌더링 된 후에 메뉴를 생성하는 구조
  • 충돌: 지연 로딩으로 인해 초기에 섹션들이 렌더링 되지 않아 메뉴가 생성되지 않음
  • 결과: 사용자가 끝까지 스크롤해야만 모든 섹션이 로드되어 메뉴가 표시됨

 

해결 방안

1. preloadedMenus 시스템 도입

 : 섹션 렌더링과 독립적으로 메뉴 데이터를 미리 추출하는 시스템을 구현했습니다.

// 사전 메뉴 데이터 구성
const preloadedMenus = useMemo(() => {
  if (!renderingData) return [];
  return renderingData
    .filter((data) => data.lnbName)
    .map((data) => ({
      id: data.sectionName ?? data[0],
      name: data.lnbName,
    }));
}, [renderingData]);

 

2. StickyQuickMenu 개선

 : DOM 기반 메뉴 구성에서 사전 정의된 메뉴 데이터를 우선 사용하도록 변경했습니다.

function StickyQuickMenu({
  track,
  preloadedMenus,
}: {
  track: TrackType;
  preloadedMenus?: Array<{ id: string; name: string }>;
}) {
  const domLnbMenus = useLnbSections({ dataAttribute: 'data-lnb-section' });

  const lnbMenus = useMemo(() => {
    if (preloadedMenus?.length) {
      return preloadedMenus
        .map((menu) => {
          const element = document.getElementById(menu.id);
          return element
            ? { id: menu.id, name: menu.name, element }
            : null;
        })
        .filter((menu): menu is NonNullable<typeof menu> => menu !== null);
    }
    return domLnbMenus;
  }, [preloadedMenus, domLnbMenus]);

  // 메뉴 클릭 시 300ms 지연으로 섹션 로딩 완료 후 스크롤 이동
  const handleMenuClick = (id: string, name: string) => {
    logLandingQuickMenuClick(name, track);
    setTimeout(() => {
      const el = document.getElementById(id);
      if (el) el.scrollIntoView({ behavior: 'smooth' });
    }, 300);
  };

  return (
    <S.Wrapper>
      {lnbMenus.map(({ name, id }) => (
        <S.MenuWrapper
          key={name}
          onClick={() => handleMenuClick(id, name)}
        >
          <S.Menu href={`#${id}`}>{name}</S.Menu>
        </S.MenuWrapper>
      ))}
    </S.Wrapper>
  );
}

 

3. 단계별 로딩 전략

  • 초기: preloadedMenus로 메뉴 표시
  • 사용자 상호작용 후: 전체 섹션 렌더링
  • 스크롤 지연 처리: 메뉴 클릭 시 300ms 지연을 두어 섹션 로딩 완료 후 이동

해결 결과

  • 초기 메뉴 렌더링 안정화
  • Hydration mismatch 방지
  • UX 개선: 사용자는 페이지 진입 즉시 메뉴 확인 가능
  • LCP/CLS 보호: 첫 4개 섹션 즉시 렌더링으로 성능 지표 보존

성과

  • 초기 페이지 로딩 속도 대폭 개선
  • 사용자 경험 향상으로 이탈률 감소 기대
  • SEO 성능 개선으로 검색 노출 품질 향상

 

향후 개선 방향

  • 추가 이미지 최적화: WebP 포맷 적용 및 적응형 이미지 구현
  • 캐싱 전략: Service Worker 활용한 리소스 캐싱
  • 번들 분석: Webpack Bundle Analyzer로 추가 최적화 포인트 발굴

 

마무리

 이번 최적화 작업을 통해 단순히 점수를 올리는 것을 넘어서, 실제 사용자 경험을 크게 개선할 수 있었습니다. 특히 지연 로딩서드파티 스크립트 최적화는 초기 렌더링 성능에 큰 영향을 미쳤습니다.

 

 성능 최적화는 한 번에 끝나는 작업이 아니라 지속적으로 모니터링하고 개선해야 하는 과정입니다. 앞으로도 사용자 경험을 최우선으로 생각하며 꾸준히 개선해 나가겠습니다. 읽어주셔서 감사합니다!

 

 

최종 Lighthouse 점수: 65점 달성! 🎉