긴 웹페이지를 스크롤할 때 모든 이미지가 미리 로드되면 초기 페이지 로딩 속도는 엄청나게 느려진다. 특히 전자상거래 사이트나 SNS처럼 이미지가 수십 개 이상인 페이지라면 사용자가 눈으로 볼 수 없는 아래쪽 이미지까지 다운로드하느라 네트워크와 브라우저 메모리가 낭비된다. 대부분의 개발자는 이미지 최적화의 중요성을 알면서도 단순히 <img src="...">만 쓰고 넘어간다. 이번에는 Lazy Loading이 정확히 무엇인지, 왜 필요한지, IntersectionObserver API로 어떻게 구현하는지 완벽하게 정리해서 소개하겠다.

 

Lazy Loading이란 뭔가
Lazy Loading은 페이지 로드 시점에 모든 이미지를 내려받지 않고, 사용자가 실제로 보려는 이미지에 도달했을 때만 다운로드하는 기법이다. 예를 들어 1000px 높이의 모니터에서 10000px 길이의 페이지를 볼 때, 맨 처음 보이는 영역(Viewport)의 이미지만 로드하고, 사용자가 스크롤할 때 새로운 영역의 이미지를 그때그때 받는 것이다.

성능 효과는 상당하다. 초기 페이지 로딩 시간이 70% 이상 단축되고, 불필요한 데이터 사용량도 대폭 줄어든다. 모바일 사용자 입장에서는 데이터 통신료도 절약되고, 서버 입장에서는 대역폭 비용도 절감할 수 있다.

 

구현 방법 3가지
1. 네이티브 loading 속성 (가장 간단)

최신 브라우저는 HTML5 표준의 loading="lazy" 속성을 지원한다. 코드 한 줄만 추가하면 된다.

<img src="image.jpg" alt="설명" loading="lazy" />

이 방법은 가장 간편하지만, 구형 브라우저(IE, 구 Edge)에서는 작동하지 않는다. 한국 사용자 기준 2024년 현재 점유율은 약 95% 정도다.

2. JavaScript Scroll 이벤트 (구형 방식)

구 버전의 코드다. 사용자가 스크롤할 때마다 이벤트가 발생하고, 각 이미지가 뷰포트에 들어왔는지 확인한다.

const images = document.querySelectorAll('img[data-src]');

window.addEventListener('scroll', () => {
  images.forEach(img => {
    const rect = img.getBoundingClientRect();
    // 이미지가 뷰포트에 보이면
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      img.src = img.dataset.src;
      img.removeAttribute('data-src');
    }
  });
});

문제는 scroll 이벤트가 매우 자주 발생한다는 것이다. 사용자가 스크롤할 때마다 모든 이미지의 위치를 계산하므로 CPU 사용량이 높아진다. 성능 최적화라고 하면서 오히려 성능을 떨어뜨리는 모순이 생긴다.

3. IntersectionObserver API (권장)

IntersectionObserver는 특정 요소가 뷰포트에 들어왔을 때만 콜백을 실행하는 API다. Scroll 이벤트보다 훨씬 효율적이다.

 

IntersectionObserver 실전 구현
기본 코드
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // 요소가 뷰포트에 진입했을 때
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // data-src에서 src로 전환
      img.removeAttribute('data-src');
      observer.unobserve(img); // 로드 완료 후 감시 중지
    }
  });
});

// 모든 lazy 이미지 감시 시작
document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

HTML 마크업은 이렇게 준비한다.

<img data-src="image1.jpg" alt="이미지 1" />
<img data-src="image2.jpg" alt="이미지 2" />
<img data-src="image3.jpg" alt="이미지 3" />

처음에는 src 속성이 없고 data-src에만 URL이 들어 있다. 사용자가 스크롤해서 이미지가 보이면, data-src의 URL을 src로 옮겨서 이미지를 로드한다.

여백을 고려한 고급 구현

기본 옵션만으로는 사용자가 이미지까지 도달했을 때 이미 로딩이 시작되므로, 약간 늦을 수 있다. rootMargin 옵션으로 여백을 미리 설정하면, 뷰포트보다 먼저 이미지 로딩을 시작할 수 있다.

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  },
  {
    rootMargin: '50px' // 뷰포트 경계로부터 50px 전에 로딩 시작
  }
);

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

 

실수하기 쉬운 부분

✗ 잘못된 것: 처음부터 src에 이미지 URL 넣기

<img src="image.jpg" alt="이미지" />

이렇게 하면 HTML 파싱 단계에서 바로 이미지 다운로드가 시작되므로 Lazy Loading이 의미 없다.

✓ 올바른 것: 처음부터 src는 없거나 placeholder 사용

<!-- 방법 1: src 없음 -->
<img data-src="image.jpg" alt="이미지" />

<!-- 방법 2: placeholder 이미지 -->
<img src="placeholder.jpg" data-src="image.jpg" alt="이미지" />

방법 2는 로딩 중에 회색 박스 대신 작은 흐릿한 이미지를 먼저 보여줄 수 있어서 UX가 낫다.

✗ 잘못된 것: data-src 속성 제거하지 않기

if (entry.isIntersecting) {
  const img = entry.target;
  img.src = img.dataset.src;
  // data-src를 안 지우면 다시 로드될 수 있음
}

✓ 올바른 것: 로드 후 정리 작업

if (entry.isIntersecting) {
  const img = entry.target;
  img.src = img.dataset.src;
  img.removeAttribute('data-src'); // 꼭 지우기
  observer.unobserve(img); // 감시 중지
}

로드가 완료된 이미지는 더 이상 감시할 필요가 없으므로, unobserve()로 감시 목록에서 제거하면 메모리 사용량도 줄어든다.

 

브라우저 호환성과 폴백

IntersectionObserver는 IE를 제외한 모든 현대 브라우저에서 지원한다. IE 사용자를 고려해야 한다면 폴백을 준비하자.

if ('IntersectionObserver' in window) {
  // IntersectionObserver 사용
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  });

  document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
  });
} else {
  // IE 등 구형 브라우저용 폴백: 모든 이미지 즉시 로드
  document.querySelectorAll('img[data-src]').forEach(img => {
    img.src = img.dataset.src;
  });
}

 

번외: 이미지 로드 상태 표시

사용자가 로딩 중임을 알 수 있도록 로딩 상태를 표시하면 더 좋은 경험을 제공할 수 있다.

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.classList.add('loading'); // 로딩 중 표시

      // 실제 이미지 로드
      const actualImg = new Image();
      actualImg.onload = () => {
        img.src = img.dataset.src;
        img.classList.remove('loading');
        img.classList.add('loaded');
        observer.unobserve(img);
      };
      actualImg.onerror = () => {
        img.classList.remove('loading');
        img.classList.add('error');
        observer.unobserve(img);
      };
      actualImg.src = img.dataset.src;
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

CSS에서 로딩 상태에 따라 스타일을 다르게 줄 수 있다.

<style>
img {
  opacity: 0;
  transition: opacity 0.3s ease-in;
}

img.loaded {
  opacity: 1;
}

img.loading {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

img.error {
  background-color: #fdd;
  border: 1px solid #f00;
}
</style>

 

정리

Lazy Loading은 페이지 성능 최적화의 기본이다. 작은 최적화가 모여서 초기 로딩 시간 50% 감소, 데이터 사용량 70% 절감 같은 큰 효과를 만든다. IntersectionObserver는 Scroll 이벤트보다 훨씬 효율적이면서도 구현이 간단하다. 이 글의 기본 코드를 참고해 지금 바로 프로젝트에 적용하면, 사용자가 체감하는 페이지 속도가 눈에 띄게 빨라질 것이다.