본문 바로가기
TIL

[react] Intersection Observer API 스크롤에 도달했을 때 이미지 로딩하기 lazy load

by dev__log 2024. 2. 27.

이번 프로젝트는 유튜브 api를 사용하여 추천 영상을 등록하고, 검색하는 프로젝트를 진행하였다.

한 페이지에 이미지가 많이 배치될 예정이라 이미지 렌더링을 한 번에 하지 않는 방법을 찾던 중 IntersectionObserver 를 알게 되었다.

 

먼저 useState와 useRef를 추가한다.

렌더링 될 이미지가 여러 개이기 때문에 배열로 useRef에 담는다.

const [visibleIndices, setVisibleIndices] = useState([]);
const imageRefs = useRef([]);

 

 

useEffect로 data가 변경될 때마다 실행시킨다.

data : supabase에서 검색해 온 데이터

IntersectionObserver(callback[, options]) : 대상요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우를 비동기적으로 감지한다.

매개변수

  • callback(entries, observer) : 교차 영역에 대한 최소한의 조건에 도달했을 때 호출 할 함수.
    • entries : 교차 영역에 대한 조건에 대해 만족하게 된 요소를 나타내는 IntersectionObserverEntry 객체의 배열 (스크롤했을 때 보이거나 가려진 요소)
    • observer : 콜백을 호출한 IntersectionObserver

entries에 다양한 속성이 있는데 그중 isIntersecting 은 해당 요소가 화면에 보이는지에 대한 속성이다. 

만약 entry.isIntersecting 이 true 면 화면에 보인다는 뜻이다.

이 부분은 console.log를 찍으면서 확인해 보았다.

스크롤이 아래 지점에 도달하면 화면에 보이는 요소를 console.log로 찍었다.

그리고 네트워크 탭에서도 하나씩 이미지를 불러오는 걸 볼 수 있다.

 

코드의 일부분

const observer = new IntersectionObserver((entries) => {
  // console.log('entries', entries);
  entries.forEach((entry) => {
    console.log('entry', entry);
    if (entry.isIntersecting) {
      console.log('화면에 보이는 entry', entry);
      const index = imageRefs.current.indexOf(entry.target);
      setVisibleIndices((prevVisibleIndices) => [...prevVisibleIndices, index]);
    }
  });
});

 

 

observe(targetElement) : (교차 지점이 변경될 때 주시할 대상을) 주시 대상 목록에 추가한다. (조건에 맞는지 감지할 요소를 목록에 추가한다)

 

disconnect() : 모든 변화 주시 대상을 해제한다.

 

 

entry.isIntersecting true 인 요소의 index를 구해서 렌더링 할 요소를 state에 업데이트한다.

useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const index = imageRefs.current.indexOf(entry.target); //화면에 보이는 요소의 index 값을 구한다.
          setVisibleIndices((prevVisibleIndices) => [...prevVisibleIndices, index]); //state 업데이트
        }
      });
    });

    imageRefs.current.forEach((ref) => {
      if (ref) {
        observer.observe(ref); //주시 대상 목록에 추가
      }
    });

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

 

 

visibleIndices state에 있는 요소만 이미지가 로딩되도록 조건부 렌더링 처리를 한다.

return (
    <SearchWrap>
      {data && data.length > 0 ? (
        <>
          <PageTitle>"{searchKeyword}" 검색 결과 입니다.</PageTitle>
          <SearchListArea>
            {data?.map((el, index) => {
              const src = el.videoId ? `https://img.youtube.com/vi/${el.videoId}/maxresdefault.jpg` : '';
              return (
                <Item key={el.id}>
                  //imageRefs.current의 배열 index에 해당 요소를 할당
                  <Thumbnail ref={(e) => (imageRefs.current[index] = e)}>
                    //state 에 포함된 요소만 렌더링 되도록 조건부 렌더링
                    {visibleIndices.includes(index) && (
                      <Link to={`/detail/${el.id}`} state={{ id: el.id }}>
                        <LazyLoadedImage src={src} alt={'썸네일'} />
                      </Link>
                    )}
                  </Thumbnail>
                  <ItemTitle>
                    <Link to={`/detail/${el.id}`} state={{ id: el.id }}>
                      {el.title}
                    </Link>
                  </ItemTitle>
                  <Time>{formatAgo(el.timeString, 'ko')}</Time>
                </Item>
              );
            })}
          </SearchListArea>
        </>
      ) : (
        <NoDataArea>"{searchKeyword}" 검색 결과가 없습니다.</NoDataArea>
      )}
    </SearchWrap>
  );

 

 

 

최종 결과 화면 및 코드

 

 

 

SearchList.jsx 전체 코드

import React, { useEffect, useRef, useState } from 'react';
import { Item, ItemTitle, NoDataArea, PageTitle, SearchListArea, SearchWrap, Thumbnail } from 'styles/SearchStyle';
import client from 'api/supabase';
import { Link, useSearchParams } from 'react-router-dom';
import { LazyLoadedImage } from './LazyLoadedImage';

const SearchList = () => {
  const [data, setData] = useState();
  const [searchParams] = useSearchParams();
  const searchKeyword = searchParams.get('keyword');

  const [visibleIndices, setVisibleIndices] = useState([]);
  const imageRefs = useRef([]);

  //데이터 조회
  useEffect(() => {
    const fetchData = async () => {
      try {
        const { data, error } = await client
          .from('content')
          .select('*')
          .like('title', `%${searchKeyword}%`)
          .order('timeString', { ascending: false });
        if (error) {
          throw error;
        }
        setData(data);
      } catch (error) {
        console.error('Error fetching data:', error.message);
        alert('검색 중 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.');
      }
    };

    fetchData();
  }, [searchKeyword]);

  //IntersectionObserver : 대상요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우를 비동기적으로 감지
  //supabase에서 가져온 data 의 값이 변경될 때 실행
  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const index = imageRefs.current.indexOf(entry.target);
          setVisibleIndices((prevVisibleIndices) => [...prevVisibleIndices, index]);
        }
      });
    });

    imageRefs.current.forEach((ref) => {
      if (ref) {
        observer.observe(ref);
      }
    });

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

  return (
    <SearchWrap>
      {data && data.length > 0 ? (
        <>
          <PageTitle>"{searchKeyword}" 검색 결과 입니다.</PageTitle>
          <SearchListArea>
            {data?.map((el, index) => {
              const src = el.videoId ? `https://img.youtube.com/vi/${el.videoId}/maxresdefault.jpg` : '';
              return (
                <Item key={el.id}>
                  <Thumbnail ref={(e) => (imageRefs.current[index] = e)}>
                    {visibleIndices.includes(index) && (
                      <Link to={`/detail/${el.id}`} state={{ id: el.id }}>
                        <LazyLoadedImage src={src} alt={'썸네일'} />
                      </Link>
                    )}
                  </Thumbnail>
                  <ItemTitle>
                    <Link to={`/detail/${el.id}`} state={{ id: el.id }}>
                      {el.title}
                    </Link>
                  </ItemTitle>
                </Item>
              );
            })}
          </SearchListArea>
        </>
      ) : (
        <NoDataArea>"{searchKeyword}" 검색 결과가 없습니다.</NoDataArea>
      )}
    </SearchWrap>
  );
};

export default SearchList;

 

LazyLoadedImage.jsx

import React from 'react';

export const LazyLoadedImage = ({ src, alt }) => {
  return <img src={src} alt={alt} />;
};

 

느낀 점

이미지 렌더링 최적화라는 걸 처음 해봐서 정말 막막했는데, 이렇게 처리할 수 있게 돼서 기쁘다. 

메인화면, 자체 검색 화면, 유튜브 api를 활용한 검색화면 총 3개의 화면에 적용했다.

사실 지금은 데이터가 많지 않아서 느리거나 하는 이슈는 없지만 이번 경험을 토대로 다음에 데이터가 많은 경우에 사용할 수 있을 것 같다.