πŸ’€ Lazy Loading

Yujin JungΒ·2025λ…„ 11μ›” 12일

β€œλ³΄μ΄λŠ” μˆœκ°„λ§Œ λ‘œλ“œν•œλ‹€β€


πŸ’€ λ“€μ–΄κ°€λ©°

μ›ΉνŽ˜μ΄μ§€κ°€ 느린 μ΄μœ λŠ” λ‹¨μˆœνžˆ μ½”λ“œκ°€ λ§Žμ•„μ„œκ°€ μ•„λ‹™λ‹ˆλ‹€.
초기 λ‘œλ”© μ‹œ ν•„μš”ν•˜μ§€ μ•Šμ€ λ¦¬μ†ŒμŠ€κΉŒμ§€ λͺ¨λ‘ 뢈러였기 λ•Œλ¬Έμž…λ‹ˆλ‹€.

이미지, λ™μ˜μƒ, μ™ΈλΆ€ 슀크립트, 차트 λΌμ΄λΈŒλŸ¬λ¦¬β€¦
μ‚¬μš©μžκ°€ 아직 μŠ€ν¬λ‘€ν•˜μ§€λ„ μ•Šμ•˜λŠ”λ° λͺ¨λ‘ λ‹€μš΄λ‘œλ“œλœλ‹€λ©΄,
λ„€νŠΈμ›Œν¬λŠ” β€˜ν•„μš” μ—†λŠ” 일’을 λ¨Όμ € μ²˜λ¦¬ν•˜κ³  μžˆλŠ” μ…ˆμ΄μ£ .

이 문제λ₯Ό ν•΄κ²°ν•˜λŠ” 것이 λ°”λ‘œ Lazy Loading (μ§€μ—° λ‘œλ”©) μž…λ‹ˆλ‹€.
즉, β€œμ‚¬μš©μžκ°€ μ‹€μ œλ‘œ λ³Ό λ•Œλ§Œ λ¦¬μ†ŒμŠ€λ₯Ό λΆˆλŸ¬μ˜€λŠ” κΈ°μˆ β€μž…λ‹ˆλ‹€.


πŸ’€ Lazy Loading κ°œλ…

Lazy Loading은 λΈŒλΌμš°μ €μ˜ κΈ°λ³Έ μž‘λ™ 방식을 β€œμ§€μ—°μ‹œμΌœβ€ ν•„μš”ν•œ μˆœκ°„μ—λ§Œ λ„€νŠΈμ›Œν¬ μš”μ²­μ„ λ°œμƒμ‹œν‚€λŠ” μ „λž΅μž…λ‹ˆλ‹€.

즉, λ‘œλ”© 타이밍을 μ΄λ ‡κ²Œ λ°”κΏ‰λ‹ˆλ‹€.

ꡬ뢄기쑴 방식Lazy Loading 방식
λ‘œλ”© μ‹œμ νŽ˜μ΄μ§€ 초기 λ‘œλ”© μ‹œλ·°ν¬νŠΈ(ν™”λ©΄)에 λ“€μ–΄μ˜¬ λ•Œ
λ‹€μš΄λ‘œλ“œμ „λΆ€ λ™μ‹œμ—ν•„μš”ν•œ λ¦¬μ†ŒμŠ€λ§Œ 순차적으둜
μ‚¬μš©μž κ²½ν—˜μ΄ˆκΈ°μ— λŠλ¦Όμ΄ˆκΈ°μ— 가볍고 빠름
λ„€νŠΈμ›Œν¬ λΆ€ν•˜λ†’μŒλΆ„μ‚°λ¨


Before vs After Lazy Loading

  • μ™Όμͺ½ (Traditional Loading): νŽ˜μ΄μ§€κ°€ μ—΄λ¦¬μžλ§ˆμž λͺ¨λ“  μ½˜ν…μΈ λ₯Ό ν•œκΊΌλ²ˆμ— λ‘œλ“œ β†’ 초기 λ‘œλ”© 속도 μ €ν•˜
  • 였λ₯Έμͺ½ (Lazy Loading): μ‚¬μš©μžκ°€ μŠ€ν¬λ‘€ν•  λ•Œλ§ˆλ‹€ 화면에 λ³΄μ΄λŠ” μ½˜ν…μΈ λ§Œ λ‘œλ“œ β†’ 초기 λ Œλ”λ§ μ‹œκ°„ 단좕
    πŸ‘‰ Lazy Loading은 β€œλ³΄μ΄λŠ” μˆœκ°„μ—λ§Œ ν•„μš”ν•œ 데이터λ₯Ό λ‘œλ“œν•œλ‹€β€λŠ” μ›λ¦¬λ‘œ μž‘λ™ν•˜λ©°, νŽ˜μ΄μ§€ λ‘œλ“œ μ„±λŠ₯을 ν–₯μƒμ‹œν‚€κ³  λΆˆν•„μš”ν•œ λ„€νŠΈμ›Œν¬ μš”μ²­μ„ μ€„μ΄λŠ” λŒ€ν‘œμ μΈ λ Œλ”λ§ μ΅œμ ν™” κΈ°λ²•μž…λ‹ˆλ‹€.

πŸ’€ Lazy Loading의 λ™μž‘ 원리

1️⃣ Intersection Observer API

λΈŒλΌμš°μ €κ°€ νŠΉμ • μš”μ†Œκ°€ λ·°ν¬νŠΈμ— μ§„μž…ν–ˆλŠ”μ§€ κ°μ‹œν•˜λŠ” κΈ°λŠ₯μž…λ‹ˆλ‹€.
μ΄λ•Œ κ°μ‹œ λŒ€μƒμ΄ 화면에 λ“±μž₯ν•˜λ©΄ 이미지 λ‘œλ“œ μš”μ²­μ„ νŠΈλ¦¬κ±°ν•©λ‹ˆλ‹€.

useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.src = entry.target.dataset.src; // μ§„μž… μ‹œ 이미지 λ‘œλ“œ
        observer.unobserve(entry.target);
      }
    });
  });
  
  document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));
  
  return () => observer.disconnect();
}, []);
  • isIntersecting: ν•΄λ‹Ή μš”μ†Œκ°€ 뷰포트 μ•ˆμœΌλ‘œ λ“€μ–΄μ™”λŠ”κ°€
  • observer.unobserve: ν•œ 번 λ‘œλ“œλœ μš”μ†ŒλŠ” λ‹€μ‹œ κ°μ‹œν•˜μ§€ μ•ŠμŒ
  • data-src: 아직 λ‘œλ“œν•˜μ§€ μ•Šμ€ 이미지 URL을 μž„μ‹œλ‘œ 보관


Intersection Observer μž‘λ™ 원리

  • νŒŒλž€μƒ‰ rootλŠ” λΈŒλΌμš°μ €μ˜ Viewport이며, κ°μ‹œ 기쀀이 λ˜λŠ” μ˜μ—­μž…λ‹ˆλ‹€.
  • trueλŠ” ν˜„μž¬ Viewport μ•ˆμ— μžˆλŠ” μš”μ†Œ, falseλŠ” ν™”λ©΄ 밖에 μžˆλŠ” μš”μ†Œλ₯Ό μ˜λ―Έν•©λ‹ˆλ‹€.
  • 슀크둀 μ‹œ μš”μ†Œκ°€ root 경계선에 λ„λ‹¬ν•˜λ©΄ Intersection Observerκ°€ 이λ₯Ό κ°μ§€ν•˜μ—¬ Lazy Loading, Infinite Scroll, μ• λ‹ˆλ©”μ΄μ…˜ μ‹€ν–‰ λ“±μ˜ λ‘œμ§μ„ μžλ™μœΌλ‘œ μˆ˜ν–‰ν•©λ‹ˆλ‹€.

2️⃣ λΈŒλΌμš°μ € κΈ°λ³Έ 속성 loading="lazy"

졜근 λŒ€λΆ€λΆ„μ˜ λΈŒλΌμš°μ €(Chrome, Edge, Firefox)λŠ” HTML 속성 ν•˜λ‚˜λ‘œ κΈ°λ³Έ Lazy Loading을 μ§€μ›ν•©λ‹ˆλ‹€.

<img src="photo.jpg" loading="lazy" alt="sample image" />
  • 지원 λΈŒλΌμš°μ €: Chrome 76+, Edge 79+, Firefox 75+
  • 비지원 λΈŒλΌμš°μ €(μ‚¬νŒŒλ¦¬ λ“±)λŠ” JS 폴리필(IntersectionObserver)을 ν•¨κ»˜ μ‚¬μš©

이 속성 ν•˜λ‚˜λ‘œ 이미지 λ‘œλ”© 타이밍을 μžλ™μœΌλ‘œ μ΅œμ ν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
Chrome은 기본적으둜 뷰포트의 μ•½ 1250px κ·Όμ²˜μ— μ§„μž…ν•˜λ©΄ μš”μ²­μ„ μ‹œμž‘ν•©λ‹ˆλ‹€.

🧭 Tip:
loading="lazy"λŠ” <iframe> νƒœκ·Έμ—λ„ μ μš©λ©λ‹ˆλ‹€.
β†’ YouTube, Map, κ΄‘κ³  λ“±μ˜ embed μ½˜ν…μΈ λ₯Ό 늦게 λ‘œλ“œν•  수 μžˆμŠ΅λ‹ˆλ‹€.


rootMarginκ³Ό 사전 λ‘œλ”© 거리

const observer = new IntersectionObserver(callback, {
  rootMargin: "200px",
});
  • rootMargin은 ν™”λ©΄ 경계선 μ•žλ’€ μ—¬μœ  κ±°λ¦¬μž…λ‹ˆλ‹€.
  • 200px둜 μ„€μ •ν•˜λ©΄, 이미지가 화면에 200px 전에 미리 λ‘œλ“œλ©λ‹ˆλ‹€.
  • μ§€λ‚˜μΉ˜κ²Œ μž‘μœΌλ©΄ 이미지가 깜빑이며 λ‘œλ“œλ˜κ³ ,λ„ˆλ¬΄ 크면 Lazy Loading의 νš¨κ³Όκ°€ μ€„μ–΄λ“­λ‹ˆλ‹€.

🧭 μΆ”μ²œ κ°’: 200px ~ 400px (μ‚¬μš©μž 슀크둀 속도에 따라 μ‘°μ •)


Intersection Observer 속성 κ°œλ…λ„

  • μ™Όμͺ½μ˜ Root: λΈŒλΌμš°μ €μ˜ Viewport(κ°μ‹œ κΈ°μ€€ μ˜μ—­)λ₯Ό μ˜λ―Έν•˜λ©°,
  • κ°€μš΄λ°μ˜ Root Margin: Viewport λ°”κΉ₯μͺ½ μ—¬μœ  μ˜μ—­μœΌλ‘œ, 미리 λ‘œλ“œλ₯Ό μœ λ„ν•΄ ν™”λ©΄ κΉœλΉ‘μž„μ„ μ€„μž…λ‹ˆλ‹€.
  • 였λ₯Έμͺ½μ˜ Threshold: μš”μ†Œκ°€ 화면에 μ–Όλ§ˆλ‚˜ 보여야 콜백이 싀행될지λ₯Ό κ²°μ •ν•©λ‹ˆλ‹€.
    πŸ‘‰ μ μ ˆν•œ rootMarginκ³Ό threshold 섀정은 슀크둀 μ‹œ 더 λΆ€λ“œλŸ¬μš΄ μ‚¬μš©μž κ²½ν—˜μ„ μ œκ³΅ν•©λ‹ˆλ‹€.

πŸ’€ Lazy Loading 적용 μ˜ˆμ‹œ

import { useEffect, useRef, useState } from "react";

function LazyImage({ src, alt }) {
  const [isVisible, setVisible] = useState(false);
  const imgRef = useRef(null);

  useEffect(() => {
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) setVisible(true);
    }, { rootMargin: "300px" });

    if (imgRef.current) io.observe(imgRef.current);
    return () => io.disconnect();
  }, []);

  return (
    <img
      ref={imgRef}
      src={isVisible ? src : "/placeholder.jpg"}
      alt={alt}
      loading="lazy"
      style={{ width: "100%", borderRadius: "8px" }}
    />
  );
}

export default function Gallery() {
  return (
    <section>
      <h2>Lazy Loaded Gallery</h2>
      <div className="grid">
        {Array.from({ length: 12 }, (_, i) => (
          <LazyImage
            key={i}
            src={`/images/photo-${i + 1}.jpg`}
            alt={`photo-${i}`}
          />
        ))}
      </div>
    </section>
  );
}

Lazy Loading λ™μž‘ μ˜ˆμ‹œ

  • μ™Όμͺ½: 슀크둀 μ „ μƒνƒœλ‘œ, 아직 μ‹€μ œ 이미지가 λ‘œλ“œλ˜μ§€ μ•Šμ•„ 저해상도 Placeholder(λΈ”λŸ¬ 처리 이미지)κ°€ λ³΄μž…λ‹ˆλ‹€.
  • 였λ₯Έμͺ½: μ‚¬μš©μžκ°€ μŠ€ν¬λ‘€ν•΄ ν•΄λ‹Ή μœ„μΉ˜μ— λ„λ‹¬ν–ˆμ„ λ•Œ, μ‹€μ œ 고해상도 이미지가 λ‘œλ“œλ˜μ–΄ μžμ—°μŠ€λŸ½κ²Œ μ „ν™˜λœ λͺ¨μŠ΅μž…λ‹ˆλ‹€.
    πŸ‘‰ Lazy Loading은 이런 λ°©μ‹μœΌλ‘œ λΆˆν•„μš”ν•œ 초기 λ‘œλ”©μ„ 쀄이고
    μŠ€ν¬λ‘€μ— 맞좰 μ½˜ν…μΈ λ₯Ό μ μ§„μ μœΌλ‘œ ν‘œμ‹œν•©λ‹ˆλ‹€.

πŸ’€ Lazy Loading의 ν•œκ³„μ™€ 주의점

λ¬Έμ œμ„€λͺ…ν•΄κ²°μ±…
⚠️ LCP μ§€μ—°νžˆμ–΄λ‘œ 이미지(LCP 후보)λ₯Ό Lazy μ²˜λ¦¬ν•˜λ©΄ ν™”λ©΄ ν‘œμ‹œκ°€ λŠ¦μ–΄μ§LCP μ΄λ―Έμ§€λŠ” loading="eager" λ˜λŠ” fetchpriority="high"
⚠️ SEO λ¬Έμ œκ΅¬κΈ€λ΄‡μ΄ JSλ₯Ό μ‹€ν–‰ν•˜μ§€ μ•ŠμœΌλ©΄ Lazy 이미지 인식 λΆˆκ°€SSR λ˜λŠ” <noscript> 폴백 이미지 μΆ”κ°€
⚠️ κΉœλΉ‘μž„rootMargin λ„ˆλ¬΄ μž‘μ„ 경우200~400px둜 μ™„ν™”
⚠️ λ ˆμ΄μ•„μ›ƒ 흔듀림이미지 높이가 μ§€μ •λ˜μ§€ μ•ŠμœΌλ©΄ CLS λ°œμƒwidth, height, λ˜λŠ” aspect-ratio μ§€μ •
⚠️ 저사양 기기슀크둀 이벀트/κ΄€μ°°μž μˆ˜κ°€ 많으면 CPU 점유IntersectionObserver μž¬μ‚¬μš©μœΌλ‘œ κ°μ‹œ 수 μ΅œμ†Œν™”

πŸ’€ Lazy Loading 적용 ν›„ μΈ‘μ • 방법

도ꡬ확인할 ν•­λͺ©
Chrome DevTools β†’ Network이미지 μš”μ²­μ΄ 슀크둀 μ‹œμ μ— λ°œμƒν•˜λŠ”μ§€
Performance νƒ­μŠ€ν¬λ‘€ 쀑 FPS μœ μ§€, Paint μ΅œμ†Œν™”
Lighthouseβ€œDefer offscreen images” κ°œμ„  확인
WebPageTest / GTMetrixTime to Interactive, Total Bytes κ°μ†Œ


Lazy Loading μ„±λŠ₯ 비ꡐ κ·Έλž˜ν”„

  • κ·Έλž˜ν”„ μ„€λͺ…:
    WordPress μ‚¬μ΄νŠΈλ₯Ό κΈ°μ€€μœΌλ‘œ, λ„€μ΄ν‹°λΈŒ 이미지 Lazy Loading을 μ μš©ν–ˆμ„ λ•Œμ™€ κ·Έλ ‡μ§€ μ•Šμ„ λ•Œμ˜ LCP(Largest Contentful Paint, 핡심 μ½˜ν…μΈ κ°€ λ Œλ”λ§λ˜κΈ°κΉŒμ§€μ˜ μ‹œκ°„)을 λΉ„κ΅ν•œ κ²°κ³Όμž…λ‹ˆλ‹€.
  • 쒌츑(No): Lazy Loading 미적용 μ‹œ, λΈŒλΌμš°μ €κ°€ λͺ¨λ“  이미지λ₯Ό ν•œκΊΌλ²ˆμ— λ‘œλ“œν•΄ LCPκ°€ 더 길게 μΈ‘μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.
  • 우츑(Yes): Lazy Loading 적용 μ‹œ, 초기 λ Œλ”λ§μ— ν•„μš”ν•œ μ΄λ―Έμ§€λ§Œ μš°μ„  λ‘œλ“œλ˜μ–΄ LCPκ°€ λ‹¨μΆ•λ˜μ—ˆμŠ΅λ‹ˆλ‹€.
    πŸ‘‰ Lazy Loading은 νŽ˜μ΄μ§€ 초기 λ‘œλ”© μ„±λŠ₯을 ν–₯μƒμ‹œν‚€κ³ , μ‚¬μš©μž 체감 속도λ₯Ό κ°œμ„ ν•˜λŠ” 데 νš¨κ³Όμ μž…λ‹ˆλ‹€.

πŸ’€ 마치며

Lazy Loading은 β€œκΈ°μˆ μ μœΌλ‘œ μ–΄λ ΅μ§€ μ•Šμ€λ°, κ°€μž₯ νš¨κ³Όκ°€ 큰 μ΅œμ ν™”β€μž…λ‹ˆλ‹€.
이미지 ν•œ μž₯만 쀄여도 LCPκ°€ 절반 κ°€κΉŒμ΄ κ°œμ„ λ˜λŠ” κ²½μš°λ„ μžˆμŠ΅λ‹ˆλ‹€.

κ°€μž₯ μ€‘μš”ν•œ 원칙은 단 ν•˜λ‚˜μž…λ‹ˆλ‹€.

β€œμ‚¬μš©μžκ°€ μ‹€μ œλ‘œ λ³΄λŠ” μˆœκ°„μ—λ§Œ λ‘œλ“œν•˜λΌ.”

ν•„μš”ν•œ μ‹œμ μ— ν•„μš”ν•œ λ¦¬μ†ŒμŠ€λ₯Ό 보내면, μ‚¬μš©μž κ²½ν—˜μ€ μžμ—°μŠ€λŸ½κ²Œ λΆ€λ“œλŸ½κ³  λΉ λ₯Έ μ„œλΉ„μŠ€λ‘œ λ°”λ€λ‹ˆλ‹€.

profile
맀일맀일 μ‘°κΈˆμ”© μ„±μž₯ν•˜λ € λ…Έλ ₯ν•˜λŠ” ν”„λ‘ νŠΈμ—”λ“œ κ°œλ°œμžμž…λ‹ˆλ‹€!

1개의 λŒ“κΈ€

comment-user-thumbnail
2025λ…„ 11μ›” 12일

Intersection Observer API의 μž‘λ™ 원리와 속성듀 λŒ€ν•΄ κ·Έλ¦Όκ³Ό ν•¨κ»˜ μ„€λͺ…ν•΄μ£Όμ…”μ„œ Intersection Observer APIλ₯Ό 처음 접함에도 이해가 λ„ˆλ¬΄ 잘 λμ–΄μš”!!
LCP 비ꡐλ₯Ό 톡해 Lazy Loading이 μ–Όλ§ˆλ‚˜ νš¨κ³Όμ μΈμ§€λ„ μ•Œ 수 μžˆμ–΄ 도움이 λμŠ΅λ‹ˆλ‹€

λ‹΅κΈ€ 달기