랜딩 페이지의 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점 달성! 🎉
'인턴' 카테고리의 다른 글
StickyQuickMenu와 Lazy Loading 충돌 해결 (0) | 2025.07.11 |
---|---|
클릭 이벤트 방지하기 (0) | 2025.06.14 |
무한 캐러셀 구현 (2) | 2025.06.14 |
[ Troubleshooting🛠️ ] 롤백시키기 (0) | 2025.05.26 |
[ Troubleshooting🛠️ ] Radix Tabs 탭 클릭 시 alert 무한 루프 발생 (0) | 2025.05.26 |