웹 프론트엔드에서 성능 최적화를 논할 때 빠지지 않는 키워드 중 하나가 바로 "Lazy Loading"입니다. 이 글에서는 Lazy Loading이 무엇인지부터, React 및 Next.js에서의 다양한 구현 방식, 그리고 실습 예제와 선택 가이드까지 모두 정리해 보았습니다.
💡 Lazy Loading이란?
Lazy Loading(지연 로딩)이란, 웹 페이지 내의 리소스를 "필요할 때까지" 로딩하지 않고 미루는 전략입니다. 일반적으로 페이지가 처음 로드될 때 모든 자원을 한 번에 불러오지만, Lazy Loading은 사용자의 시점이나 조건에 따라 나중에 리소스를 불러오는 방식입니다.
✅ 사용 이유
- 초기 로딩 시간 단축
- 트래픽 절감 (데이터 전송량 감소)
- 사용자에게 빠른 피드백 제공
예 : 사용자가 스크롤하기 전까지 보이지 않는 이미지나 컴포넌트를 나중에 로딩함으로써, 첫 화면은 훨씬 빠르게 보여줄 수 있습니다.
🛠️ Lazy Loading 구현 방식 정리
1. Next.js: next/dynamic
Next.js에서는 next/dynamic을 통해 특정 컴포넌트를 클라이언트에서만 로딩하거나, 조건부로 로딩할 수 있습니다.
- next/dynamic import함수는 컴포넌트가 필요할 때만 로딩하도록 해준다.
- next/dynamic을 사용하기 위해선 적용하고 싶은 컴포넌트의 import statement를 next/dynamic 함수로 감싸야한다
import dynamic from 'next/dynamic';
const AboutUs = dynamic(() => import('./About'), { ssr: false });
import React, {useState} from 'react';
import dynamic from 'next/dynamic'
const AboutUs = dynamic(() => import('./About'), {ssr: false})
// => dynamic으로 AboutUs 함수를 감싼 모습
// => {ssr: false}는 Next.js에게 이 AboutUs는 서버 사이드 렌더링 하지말라고 알려주는 부분.
// 왜 하지 말아야하냐면, 이 컴포넌트는 필요해진 경우에만 로딩되어야 하기 때문에 서버가 미리 렌더할 필요가 없음.
const Home = () => {
const [isLoaded, setIsLoaded] = useState(false)
return (
<div>
<h1>This is Home Page.</h1>
{ isLoaded && <AboutUs/> }
</div>
)
}
export default Home;
- { ssr: false }: 서버 사이드 렌더링을 하지 않음 (초기 로딩 성능 향상)
- isLoaded 상태를 통해 언제 로딩할지 제어 가능
2. React: React.lazy() + Suspense
React에서 기본으로 제공하는 지연 로딩 방식입니다.
React.lazy(() => import(...))는 해당 컴포넌트가 실제 DOM에 그려지려는 시점에 비로소 해당 모듈을 로딩합니다. React.lazy()는 컴포넌트가 렌더 트리에 올라올 때, 즉 조건이나 상태에 따라 Virtual DOM 트리에 포함되어 실제 DOM에 마운트 될 때 import가 실행됩니다. 따라서 실제 로딩 시점을 제어하려면 조건문(if)이나 state로 렌더링 시점 자체를 제어해야 합니다. React.lazy() 메서드는 프로미스 객체를 반환하며, 이 객체는 로딩이 완료되면 컴포넌트를 반환합니다.
suspense는 fallback을 보여주는 UI 처리 도구일 뿐, 로딩 타이밍을 결정하지는 않습니다.
const LazyComponent = React.lazy(() => import('./LazyComponent'));
<Suspense fallback={<div>로딩 중...</div>}>
{show && <LazyComponent />}
</Suspense>
const LazyComponent = React.lazy(() => import('./LazyComponent'));
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(true)}>보이기</button>
<Suspense fallback={<div>로딩 중...</div>}>
{show && <LazyComponent />} {/* 버튼 누를 때까지 import 안 됨 */}
</Suspense>
</>
);
}
여기서는 버튼을 눌러 show === true 가 되기 전까지는 LazyComponent가 렌더 트리에 포함되지 않기 때문에, 실제 import도 이루어지지 않습니다. 즉, Lazy Loading이 되는 것입니다.
next/dynamic과 React.lazy() 두 방식 모두에서 state를 사용하여 레이지 로딩할 컴포넌트의 로딩 시점을 지정합니다. 이를 통해 특정 조건에 따라 컴포넌트를 렌더링 할 시점을 제어할 수 있습니다.
3. 이미지 Lazy Loading: <img loading="lazy"> & next/image
Next.js의 <Image /> 컴포넌트는 기본적으로 Lazy Loading이 적용되어 있습니다.
import Image from 'next/image';
<Image src="/path/image.jpg" alt="image" width={500} height={300} />
4. 외부 라이브러리: react-lazyload
react-lazyload는 React 프로젝트에서 컴포넌트의 레이지 로딩을 쉽게 적용할 수 있게 해주는 라이브러리입니다. 이 라이브러리는 Intersection Observer API를 사용하여 컴포넌트가 뷰포트에 진입하여 화면에 표시되어야 할 때 로딩을 수행합니다.
npm install react-lazyload
import React, { useState } from 'react';
import ReactLazyload from 'react-lazyload';
const Image = ReactLazyload(() => import('./image.jpg'));
const App = () => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className="App">
<h1>My App</h1>
{isLoaded && <Image />}
</div>
);
};
import LazyLoad from 'react-lazyload';
<LazyLoad height={200} offset={100} once>
<YourComponent />
</LazyLoad>
- offset: 미리 로딩할 거리 지정
- once: 한 번 로딩 후 다시 로딩하지 않음
5. 순수 API: Intersection Observer
별도 라이브러리 추가할 필요 없이, 뷰포트 관련 요소만 로딩하는 목적으로 Intersection Observer API를 사용해서 레이지 로딩을 구현할 수 있습니다.
import { useEffect, useRef } from 'react';
function MyAwesomeComponent () {
const targetRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 여기서 컴포넌트나 리소스를 로딩
}
})
})
if (targetRef.current) {
observer.observe(targetRef.current)
}
// 옵저버 정리
return () => {
observer.disconnect();
}
}, [])
return (
<div ref={targetRef}>
// 레이지로딩되는 컨텐츠들은 요기
</div>
)
}
🧪 실습 예제 소개
아래는 실제로 구현한 Lazy Loading 실습 예제입니다:
- 컴포넌트 Lazy Loading vs 즉시 로딩 (React.lazy)
- 버튼 클릭으로 Lazy Component 로딩
- 이미지 Lazy Loading (loading='lazy' 적용)
- IntersectionObserver를 통한 이미지 지연 로딩
import React, { useEffect, useRef, useState } from "react";
import Box from "./Box";
import catImg from "../src/assets/cat.jpg";
import pandaImg from "../src/assets/panda.jpg";
import eagleImg from "../src/assets/eagle.jpg";
import otterImg from "../src/assets/otter.jpg";
import raccoonImg from "../src/assets/raccoon.jpg";
import tigerImg from "../src/assets/tiger.jpg";
import hedgehogImg from "../src/assets/hedgehog.jpg";
const LazyLoaidngBox = React.lazy(() => import("./LazyLoadingBox"));
interface LazyImageItem {
id: number;
src: string;
alt: string;
}
const images: LazyImageItem[] = [
{
id: 0,
src: catImg,
alt: "고양이",
},
{
id: 1,
src: hedgehogImg,
alt: "고슴도치",
},
{
id: 2,
src: eagleImg,
alt: "독수리",
},
{
id: 3,
src: otterImg,
alt: "수달",
},
{
id: 4,
src: raccoonImg,
alt: "너구리",
},
{
id: 5,
src: tigerImg,
alt: "호랑이",
},
];
function App() {
const [show1, setShow1] = useState<boolean>(false);
const [show2, setShow2] = useState<boolean>(false);
const [show3, setShow3] = useState<boolean>(false);
const containerRef = useRef<(HTMLImageElement | null)[]>([]);
const [visibleIds, setVisibleIds] = useState<number[]>([]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const target = entry.target as HTMLImageElement;
const id = Number(target.dataset.id);
if (entry.isIntersecting && !visibleIds.includes(id)) {
setVisibleIds((prev) => [...prev, id]);
observer.unobserve(target);
}
});
},
{ threshold: 0.1 }
);
containerRef.current.forEach((img) => {
if (img) observer.observe(img);
});
return () => observer.disconnect();
}, [visibleIds]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
textAlign: "center",
alignItems: "center",
}}>
{/* 컴포넌트 레이지 로딩 테스트 */}
<h2>1. 컴포넌트 초기 로딩(초기 DOM에 포함)</h2>
<button onClick={() => setShow1(true)}>컴포넌트 초기 로딩</button>
{show1 && <Box />}
<button onClick={() => setShow2(true)}>컴포넌트 레이지 로딩</button>
{show2 && <LazyLoaidngBox />}
<div style={{ marginTop: "200px" }}></div>
{/* 이미지 레이지 로딩 테스트 */}
<h2>2. 고양이 이미지 초기 로딩(초기 DOM에 포함)</h2>
<img src={catImg} alt='고양이' width={200} height={200} />
<button onClick={() => setShow3(true)}>이미지 레이지 로딩</button>
{show3 && <img src={pandaImg} alt='판다' loading='lazy' width={200} height={200} />}
<div style={{ marginTop: "200px" }}></div>
{/* IntersectionObserver를 이용한 레이지 로딩 */}
<h2>3. IntersectionObserver를 이용한 레이지 로딩</h2>
{images.map((img, index) => (
<img
key={img.id}
ref={(el: HTMLImageElement | null) => {
containerRef.current[index] = el;
}}
data-id={img.id}
src={visibleIds.includes(img.id) ? img.src : undefined}
alt={img.alt}
width={200}
height={200}
style={{ marginBottom: "20px", backgroundColor: "#eee" }}
/>
))}
</div>
);
}
export default App;
📃 Lazy Loading 기술 선택 요약
기술명 | 특징 | 권장 사용 시점 |
React.lazy() | 기본 React 기능 | 조건부 렌더링 가능한 UI에 사용 |
next/dynamic | SSR 비활성화 가능 | Next.js에서 SSR을 회피해야 할 때 |
react-lazyload | Intersection 기반 외부 도구 | 스크롤 기반 Lazy Loading에 적합 |
IntersectionObserver | JS API로 직접 제어 가능 | 로직을 커스터마이징 하고 싶은 경우 |
🙋♀️ 마무리하며
Lazy Loading은 단순한 퍼포먼스 트릭이 아니라, 사용자 경험을 극대화하기 위한 중요한 전략 중 하나입니다. 다만, 모든 컴포넌트나 리소스에 무조건 적용하기보다는 상황에 따라 전략적으로 선택해야 효과를 극대화할 수 있을 것 같습니다.