sanguk.dev
작성완료
React 이미지 Lazy Loading 구현

React 이미지 Lazy Loading 구현

Lazy loading은 초기 렌더링 속도를 개선하고 네트워크 트래픽을 줄이며 사용자 경험을 향상시키기 위해 필요하다. React에서 IntersectionObserver를 사용하여 이미지 로딩 상태를 관리하고, 사용자가 이미지를 스크롤할 때만 로딩하도록 설정하는 방법을 설명한다. 코드 예제와 함께 이미지 참조 배열 관리 및 로딩 상태 관리를 위한 useRef와 useState의 사용법을 제시한다.

Reactlazy

1. Lazy Loading이 필요한 이유

사용자가 스크롤을 내리기 전까지, 모든 이미지를 한 번에 로딩하면 발생하는 것들

  • 초기 렌더링 속도가 느려짐
  • 네트워크 트래픽이 증가
  • UX가 떨어짐

전체 코드 {color="brown_bg"}

typescript
import { useEffect, useRef, useState } from "react";
import viteLogo from "/vite.svg";
import styled from "styled-components";

const loadingImage =
  "https://upload.wikimedia.org/wikipedia/commons/b/b1/Loading_icon.gif?20151024034921";

const list: number[] = Array(100)
  .fill(0)
  .map((_, i) => i);

export default () => {
  const imageRefs = useRef<(HTMLImageElement | null)[]>([]);
  const [isLoadings, setIsLoadings] = useState<boolean[]>(
    list?.map(() => true)
  );

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry?.isIntersecting) {
            const target = entry?.target as HTMLImageElement;
            const idx = Number(target?.dataset?.index);

            setIsLoadings((prev) => {
              const copy = [...prev];
              copy[idx] = false;
              return copy;
            });

            // 한 번 로딩됐으면 관찰 중단
            observer.unobserve(target);
          }
        });
      },
      {
        root: null,
        threshold: 0.01, // 1%만 보여도 감지
      }
    );

    const imageEls = imageRefs.current;
    imageEls?.forEach((el) => el && observer?.observe(el));

    return () => observer.disconnect();
  }, []);

  return (
    <Container>
      {list?.map((item, idx) => (
        <ItemWrap key={item}>
          <ItemContainer>
            <ItemImg
              data-index={idx}
              src={isLoadings[idx] ? loadingImage : viteLogo}
              ref={(el) => {
                imageRefs.current[idx] = el!;
              }}
            />
            <ItemTitle>{item}</ItemTitle>
          </ItemContainer>
        </ItemWrap>
      ))}
    </Container>
  );
};

const Container = styled.div`
  text-align: center;
  display: flex;
  flex-wrap: wrap;
`;
const ItemWrap = styled.div`
  width: 25%;
  aspect-ratio: 1;
  padding: 5px;
`;
const ItemContainer = styled.div`
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-evenly;
  border-radius: 10px;
  border: 1px solid #f00;
  gap: 10px;
`;
const ItemImg = styled.img`
  width: 50%;
  aspect-ratio: 1;
  object-fit: contain;
`;
const ItemTitle = styled.div`
  font-size: 24px;
  font-weight: 700;
`;

2. 이미지 참조 배열 관리 (useRef)

typescript
const imageRefs = useRef<(HTMLImageElement | null)[]>([]);
  • imageRefs.current[idx] = el
    이런 식으로 img 태그의 실제 DOM이 인덱스별로 저장됨
  • IntersectionObserver에 등록하는 데 쓰임

3. 이미지 로딩 상태 관리 (useState)

typescript
const [isLoadings, setIsLoadings] = useState<boolean[]>(
  list?.map(() => true)
);
  • 리스트 길이만큼 true를 세팅
  • **초기 상태는 모든 이미지가 로딩 **상태
  • 이후 특정 이미지가 화면에 등장하면 해당 인덱스를 false로 바꾸면서 실제 이미지를 렌더링

4. IntersectionObserver 설정 (핵심 로직)

typescript
useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry?.isIntersecting) {
            const target = entry?.target as HTMLImageElement;
            const idx = Number(target?.dataset?.index);

            setIsLoadings((prev) => {
              const copy = [...prev];
              copy[idx] = false;
              return copy;
            });

            // 한 번 로딩됐으면 관찰 중단
            observer.unobserve(target);
          }
        });
      },
      {
        root: null,
        threshold: 0.01, // 1%만 보여도 감지
      }
    );

    const imageEls = imageRefs.current;
    imageEls?.forEach((el) => el && observer?.observe(el));

    return () => observer.disconnect();
  }, []);

4.1. 옵션

typescript
{
  root: null,
  threshold: 0.01, // 1%만 보여도 감지
}
  • root: null 은 브라우저 뷰포트를 기준으로 관찰하겠다는 뜻
  • threshold: 0.01 은 요소의 1%만 화면에 들어와도 보이는 것으로 판정

4.2. IntersectionObserver 인스턴스 생성

typescript
const observer = new IntersectionObserver(
	(entries) => {
    entries.forEach((entry) => {
      if (entry?.isIntersecting) {
        const target = entry?.target as HTMLImageElement;
        const idx = Number(target?.dataset?.index);

        setIsLoadings((prev) => {
          const copy = [...prev];
          copy[idx] = false;
          return copy;
        });

        // 한 번 로딩됐으면 관찰 중단
        observer.unobserve(target);
      }
    });
  },
  {
    root: null,
    threshold: 0.01, // 1%만 보여도 감지
  }
);
  • `IntersectionObserver
Code

감지된 모든 대상 요소들의 관찰 결과를 

entries`로 반환

  • entry.isIntersecting이 true면 지금 화면 안에 들어왔다는 뜻
  • setIsLoadings을 통해 해당 entry의 index 순서의 항목 false 만들기
  • `observer.unobserve(target)
Code

해당 이미지가 한 번 로딩되면 계속 관찰 중단

## 5. 렌더링

``typescript
return (

{list?.map((item, idx) => (


<ItemImg
data-index={idx}
src={isLoadings[idx] ? loadingImage : viteLogo}
ref={(el) => {
imageRefs.current[idx] = el!;
}}
/>
{item}


))}

);

Code
- `target?.dataset?.index` 으로 접근을 하기 위한 `data-index` 추가
- `isLoadings`의 `idx` 순서의 항목이 `true` 이면 `loadingImage` 출력 `false`이면 `viteLogo` 출력
- `ref`를 통해 미리 이미지 태그의 DOM 등록