๋ฐ˜์‘ํ˜•

1) ์Šคํฌ๋กค ์ด๋ฒคํŠธ๋ฅผ ์‚ฌ์šฉํ•œ ๋ฐฉ๋ฒ•


๋ฌดํ•œ ์Šคํฌ๋กค์€ ํ˜„์žฌ ํŽ˜์ด์ง€์—์„œ ์Šคํฌ๋กค๋ฐ”๊ฐ€ ๋งˆ์ง€๋ง‰ ์ฝ˜ํ…์ธ  ์ง€์ ์— ์žˆ์„ ๋•Œ ๋‹ค์Œ ์ฝ˜ํ…์ธ ๋ฅผ ์ž๋™์œผ๋กœ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ตฌํ˜„ ๋ฐฉ์‹์„ ๋งํ•œ๋‹ค. โžŠ์Šคํฌ๋กคํ•ด์„œ ๊ฐ€๋ ค์ง„ ์˜์—ญ์˜ ๋†’์ด์™€ โž‹ํ˜„์žฌ ํ™”๋ฉด(๋ทฐํฌํŠธ)์˜ ๋†’์ด๋ฅผ ๋”ํ•œ ๊ฐ’์ด โžŒ์ „์ฒด ๋ฌธ์„œ์˜ ๋†’์ด์™€ ๊ฐ™๋‹ค๋ฉด ํ˜„์žฌ ์Šคํฌ๋กค์ด ๊ฐ€์žฅ ํ•˜๋‹จ ๋์— ๋„๋‹ฌํ–ˆ๋‹ค๋Š” ๊ฑธ ์•Œ ์ˆ˜ ์žˆ๋‹ค.

 

์•Œ์•„์•ผ ํ•  ๊ธฐํ•˜ ํ”„๋กœํผํ‹ฐ

 

โถ ์Šคํฌ๋กคํ•ด์„œ ๊ฐ€๋ ค์ง„ ์ฝ˜ํ…์ธ  ์˜์—ญ์˜ ๋†’์ด : document.documentElement.scrollTop

 

โท ํ˜„์žฌ ํ™”๋ฉด(๋ทฐํฌํŠธ)์˜ ๋†’์ด

  • window.innerHeight : ์Šคํฌ๋กค๋ฐ” ํฌํ•จ
  • document.documentElement.clientHeight : ์Šคํฌ๋กค๋ฐ” ์ œ์™ธ

 

โธ ์ „์ฒด ๋ฌธ์„œ์˜ ๋†’์ด

// ๋ฌธ์„œ์˜ ์ •ํ™•ํ•œ ์ „์ฒด ๋†’์ด๋ฅผ ๊ตฌํ•˜๊ธฐ ์œ„ํ•œ ์ฝ”๋“œ
const scrollHeight = Math.max(
  document.body.scrollHeight,
  document.documentElement.scrollHeight,
  document.body.offsetHeight,
  document.documentElement.offsetHeight,
  document.body.clientHeight,
  document.documentElement.clientHeight,
);

 

์™œ ์ด๋Ÿฐ ๋ฐฉ์‹์œผ๋กœ ๋ฌธ์„œ ์ „์ฒด ๋†’์ด๋ฅผ ๊ตฌํ•ด์•ผ ํ•˜๋Š” ๊ฑธ๊นŒ์š”? ์ด์œ ๋Š” ์•Œ์•„๋ณด์ง€ ์•Š๋Š” ๊ฒŒ ๋‚ซ์Šต๋‹ˆ๋‹ค.
์ด๋Ÿฐ ์ด์ƒํ•œ ๊ณ„์‚ฐ๋ฒ•์€ ์•„์ฃผ ์˜ค๋ž˜ ์ „๋ถ€ํ„ฐ ์žˆ์—ˆ๊ณ  ๊ทธ๋‹ค์ง€ ๋…ผ๋ฆฌ์ ์ด์ง€ ์•Š์€ ์ด์œ ๋กœ ๋งŒ๋“ค์–ด์กŒ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
— JavaScript Info

 

Custom Hook ์ž‘์„ฑ

์ปดํฌ๋„ŒํŠธ์— ๋ฌดํ˜„ ์Šคํฌ๋กค ๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ์ง์ ‘ ์ž‘์„ฑํ•˜๊ธฐ๋ณด๋‹จ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก Custom Hook์œผ๋กœ ๋งŒ๋“œ๋Š” ๊ฒŒ ์ข‹๋‹ค. ๋ฌดํ•œ ์Šคํฌ๋กค Custom Hook์€ ์Šคํฌ๋กค ์ด๋ฒคํŠธ๋ฅผ ๊ฐ์ง€ํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋” ๋ฐ›์•„์˜ฌ์ง€ ์—ฌ๋ถ€๋ฅผ ์•Œ๋ ค์ฃผ๋Š” ์—ญํ• ์„ ํ•œ๋‹ค.

 

โžŠ์Šคํฌ๋กค ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ํ˜„์žฌ ์Šคํฌ๋กค ์œ„์น˜๊ฐ€ ์ „์ฒด ๋ฌธ์„œ์˜ ๋์— ๋„๋‹ฌํ–ˆ๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. โž‹์Šคํฌ๋กค์ด ๋ฌธ์„œ ๋์— ์œ„์น˜ ํ–ˆ๋‹ค๋ฉด isFetching ์ƒํƒœ๋ฅผ true๋กœ ๋ณ€๊ฒฝํ•˜๊ณ , โžŒํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์€ callback์„ ํ˜ธ์ถœํ•œ๋‹ค. callback ํ•จ์ˆ˜๋Š” ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ค๋Š” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.

// useInfiniteScroll.js
import { useEffect, useState } from 'react';
import { getScrollHeight } from '../../lib/utils';

const useInfiniteScroll = (callback) => {
  const [isFetching, setIsFetching] = useState(false);

  const handleScroll = () => {
    // โ‘ต ์Šคํฌ๋กค ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์ „์ฒด ๋ฌธ์„œ์˜ ๋์— ์œ„์น˜ํ–ˆ๋Š”์ง€ ํ™•์ธ
    const scrollHeight = getScrollHeight(); // ์ „์ฒด ๋ฌธ์„œ์˜ ๋†’์ด๋ฅผ ๊ตฌํ•˜๋Š” ์œ ํ‹ธ ํ•จ์ˆ˜
    const currentScroll = window.innerHeight + document.documentElement.scrollTop;
    if (currentScroll === scrollHeight && !isFetching) setIsFetching(true);
  };

  useEffect(() => {
    // โ‘ด ์Šคํฌ๋กค ์ด๋ฒคํŠธ ๊ฐ์ง€
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  useEffect(() => {
    if (isFetching) callback(); // โ‘ถ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” callback ์‹คํ–‰
  }, [isFetching]);

  return [isFetching, setIsFetching];
};

export default useInfiniteScroll;

 

Custom Hook ์ ์šฉ

๋ฆฌ์ŠคํŠธ๋ฅผ ์ถœ๋ ฅํ•˜๋Š” InfiniteScrollNonIO ์ปดํฌ๋„ŒํŠธ์—์„  ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ค๋Š” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” ํ•จ์ˆ˜๋ฅผ Custom Hook์˜ ์ธ์ž(callback)๋กœ ๋„˜๊ธด๋‹ค. ๊ทธ๋Ÿผ ์Šคํฌ๋กค์ด ๋ฌธ์„œ ๋์— ๋„๋‹ฌํ–ˆ์„ ๋•Œ ์ธ์ž๋กœ ๋„˜๊ธด ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋˜๊ณ  ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜จ ํ›„ ํ™”๋ฉด์— ๋ Œ๋”๋งํ•œ๋‹ค.

 

๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ค๋Š” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๊ธฐ ์ „ ์ปค์Šคํ…€ ํ›… ์•ˆ์—์„  ๋จผ์ € isFetching ์ƒํƒœ๋ฅผ true๋กœ ๋ณ€๊ฒฝํ•œ ํ›„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์ด๋ฅผ ๋ฐ›์€ InfiniteScrollNonIO ์ปดํฌ๋„ŒํŠธ๋Š” “๋กœ๋”ฉ ์ค‘...” ๋ฌธ๊ตฌ๋ฅผ ํ‘œ์‹œํ•œ๋‹ค. ๋ฐ์ดํ„ฐ๋ฅผ ๋ชจ๋‘ ๋ฐ›์•„์˜ค๋ฉด isFetching ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋ผ์„œ “๋กœ๋”ฉ ์ค‘...” ๋ฌธ๊ตฌ๊ฐ€ ์‚ฌ๋ผ์ง€๊ณ , ์ƒˆ๋กœ ๋ฐ›์•„์˜จ ๋ฐ์ดํ„ฐ๋ฅผ ํ™”๋ฉด์— ์ถœ๋ ฅํ•œ๋‹ค.

import React, { useState } from 'react';
import useInfiniteScroll from './useInfiniteScroll';

// InfiniteScrollNonIO.js
export default function InfiniteScrollNonIO() {
  const [listItems, setListItems] = useState(
    Array.from(Array(30).keys(), (n) => n + 1),
  );
  const [isFetching, setIsFetching] = useInfiniteScroll(fetchMoreListItems);

  function fetchMoreListItems() {
    setTimeout(() => {
      setListItems((prevState) => [
        ...prevState,
        ...Array.from(Array(20).keys(), (n) => n + prevState.length + 1),
      ]);
      setIsFetching(false);
    }, 2000); // 2์ดˆ๊ฐ„ ๋”œ๋ ˆ์ด
  }

  return (
    <section className="flex flex-col justify-center items-center p-4">
      <ul className="space-y-4 mb-4">
        {listItems.map((item, i) => (
          <li
            key={i}
            className="border text-center w-56 h-12 grid place-content-center"
          >
            List Item {item}
          </li>
        ))}
      </ul>
      <div className={`${isFetching ? 'visibility' : 'invisible'}`}>
        ๐Ÿ”๏ธ Fetching more items...
      </div>
    </section>
  );
}

 

๐Ÿ”๏ธ Array.keys() ๋ฉ”์„œ๋“œ๋Š” ๋ฐฐ์—ด์˜ ๊ฐ ์ธ๋ฑ์Šค๋ฅผ ํ‚ค ๊ฐ’์œผ๋กœ ๊ฐ€์ง€๋Š” ์ƒˆ๋กœ์šด Array Iterator ๊ฐ์ฒด(next ๋ฉ”์„œ๋“œ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์žˆ๋Š” ์ดํ„ฐ๋ ˆ์ดํ„ฐ ๊ฐ์ฒด)๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์ดํ„ฐ๋Ÿฌ๋ธ”์ด๋ฏ€๋กœ for of ๋ฐ˜๋ณต๋ฌธ์œผ๋กœ ์ˆœํšŒํ•  ์ˆ˜ ์žˆ๋‹ค.

for (const key of Array(3).keys()) console.log(key); // 0, 1, 2
Array.from(Array(3).keys(), (n) => n * 2); // [0, 2, 4]

 

์Šค๋กœํ‹€ ์ ์šฉ

๐Ÿ’ก ๋””๋ฐ”์šด์Šค๋Š” input ์ด๋ฒคํŠธ์—(๋ฆฌ์•กํŠธ์—์„  onChange), ์Šค๋กœํ‹€์€ scroll ์ด๋ฒคํŠธ์— ์ž์ฃผ ์‚ฌ์šฉ๋œ๋‹ค.

 

ํ™”๋ฉด์„ ์Šคํฌ๋กคํ•  ๋•Œ๋งˆ๋‹ค ์Šคํฌ๋กค ์ด๋ฒคํŠธ์˜ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋Š์ž„์—†์ด ํ˜ธ์ถœ๋œ๋‹ค. ๋ถˆํ•„์š”ํ•œ ํ˜ธ์ถœ์ด ๋งŽ์œผ๋ฏ€๋กœ ์Šค๋กœํ‹€์„ ์ ์šฉํ•ด์„œ ์ผ์ • ๊ฐ„๊ฒฉ์œผ๋กœ๋งŒ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ํ˜ธ์ถœ๋˜๋„๋ก ์ˆ˜์ •ํ•œ๋‹ค. ์Šค๋กœํ‹€ ํ•จ์ˆ˜๋Š” ์—ฌ๋Ÿฌ ๊ณณ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์•„๋ž˜์ฒ˜๋Ÿผ ์œ ํ‹ธ ํ•จ์ˆ˜๋กœ ๋ถ„๋ฆฌํ•ด์„œ ์‚ฌ์šฉํ•œ๋‹ค.

// utils.js
export const throttle = (callback, ms) => {
  let timeout;

  // ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ์— ํ• ๋‹น๋  ํ•จ์ˆ˜
  return function (...args) {
    if (!timeout) {
      timeout = setTimeout(() => {
        callback(...args);
        timeout = null;
      }, ms);
    }
  };
};

 

useInfiniteScroll ์ปค์Šคํ…€ ํ›…์—์„œ ์Šค๋กœํ‹€ ํ•จ์ˆ˜๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ํ›„, ๊ธฐ์กด handleScroll ํ•ธ๋“ค๋Ÿฌ๋ฅผ throttle ํ•จ์ˆ˜์˜ ์ธ์ž(์ฝœ๋ฐฑ)๋กœ ๋„˜๊ธด๋‹ค. ๊ทธ๋Ÿผ handleScroll ๋ณ€์ˆ˜์—” throttle ํ•จ์ˆ˜๊ฐ€ ๋ฐ˜ํ™˜ํ•œ ๋‚ด๋ถ€ ํ•จ์ˆ˜๊ฐ€ ํ• ๋‹น๋œ๋‹ค. ์ฆ‰, ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ์—” throttle ํ•จ์ˆ˜๊ฐ€ ๋ฐ˜ํ™˜ํ•œ ๋‚ด๋ถ€ ํ•จ์ˆ˜๊ฐ€ ํ• ๋‹น๋˜๋„๋ก ํ•œ๋‹ค.

 

  1. ์Šคํฌ๋กค ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋‚ด๋ถ€ ํ•จ์ˆ˜ ์ธ์ž (...args)๋กœ ์ด๋ฒคํŠธ ๊ฐ์ฒด๊ฐ€ ์ „๋‹ฌ๋œ๋‹ค.
  2. ํƒ€์ด๋จธ ID๊ฐ€ ์—†๋‹ค๋ฉด ms์ดˆ ๋’ค์— ์ธ์ž๋กœ ๋ฐ›์€ ์ฝœ๋ฐฑ handleScroll ํ•จ์ˆ˜๋ฅผ ์‹คํ–‰ํ•œ๋‹ค.
  3. ํƒ€์ด๋จธ ID๊ฐ€ ํ• ๋‹น๋œ ์ƒํƒœ์—์„œ ๋˜ ๋‹ค๋ฅธ ์Šคํฌ๋กค ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์œผ๋ฉด ์•„๋ฌด์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š๋Š”๋‹ค.

 

import { getScrollHeight, throttle } from '../../lib/utils';

// useInfiniteScroll.js
const useInfiniteScroll = (callback) => {
  // ...์ƒ๋žต

  const handleScroll = throttle(
    (/* event */) => {
      const scrollHeight = getScrollHeight();
      const currentScroll = window.innerHeight + document.documentElement.scrollTop;
      if (currentScroll === scrollHeight && !isFetching) setIsFetching(true);
    },
    500,
  );

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  // ...์ƒ๋žต
};

export default useInfiniteScroll;

 

๐Ÿ’ก ์ด๋ฒคํŠธ๋ฅผ ๋“ฑ๋กํ–ˆ์„ ๋•Œ์™€ ๋™์ผํ•œ ํ•ธ๋“ค๋Ÿฌ์™€ capture ์˜ต์…˜์„ ๋ช…์‹œํ•ด์•ผ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ์ธ์ž์— throttle(handleScroll, 500) ํ˜•ํƒœ๋กœ ์ž‘์„ฑํ•˜๋ฉด ์ด๋ฒคํŠธ ๋“ฑ๋ก / ์ œ๊ฑฐ์— ๋“ฑ๋กํ•œ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋‹ค๋ฅด๋ฏ€๋กœ(throttle ํ•จ์ˆ˜๊ฐ€ ๋ฐ˜ํ™˜ํ•œ ๋‚ด๋ถ€ ํ•จ์ˆ˜์˜ ์ฃผ์†Œ๊ฐ’์ด ๊ฐ๊ฐ ๋‹ค๋ฆ„) ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๊ฐ€ ์ œ๊ฑฐ๋˜์ง€ ์•Š๋Š”๋‹ค. ๋” ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋งํฌ ์ฐธ๊ณ .

 

Memory Leak ์˜ค๋ฅ˜ ํ•ด๊ฒฐ

Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup

 

์Šคํฌ๋กค์ด ํ™”๋ฉด ๋์— ๋„๋‹ฌํ•˜๋ฉด ms์ดˆ ํ›„ ์‹คํ–‰๋˜๋Š” ์ฝœ๋ฐฑ(handleScroll)์„ ์˜ˆ์•ฝํ•œ๋‹ค. ์ด๋•Œ router๋ฅผ ์ด๋™(๋’ค๋กœ๊ฐ€๊ธฐ ๋“ฑ)ํ•˜๋ฉด ์œ„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. router ์ด๋™ ํ›„, ์ด์ „ ์ปดํฌ๋„ŒํŠธ์—์„œ ์˜ˆ์•ฝํ•œ ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋ผ์„œ ์ƒํƒœ(isFetching) ๋ณ€๊ฒฝ์„ ์‹œ๋„ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ๋‹ค.

 

์ด ์—๋Ÿฌ๋Š” ์–ธ๋งˆ์šดํŠธ ํ›„ ์ƒํƒœ ๋ณ€๊ฒฝ์„ ์œ ๋ฐœํ•˜๋Š” isFetching ์ƒํƒœ๋ฅผ false๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ์ฝ”๋“œ๋ฅผ useEffect์˜ ํด๋ฆฐ์—… ํ•จ์ˆ˜์— ์ถ”๊ฐ€ํ•˜๋ฉด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค. — ์ฐธ๊ณ ๊ธ€

// useInfiniteScroll.js
import { getScrollHeight, throttle } from '../../lib/utils';

const useInfiniteScroll = (callback) => {
  // ...์ƒ๋žต

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      setIsFetching(false); // Can't perform a React state update... ์˜ค๋ฅ˜ ๋Œ€์‘
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
};

export default useInfiniteScroll;

 

๋ ˆํผ๋Ÿฐ์Šค

 

2) Intersection Observer API๋ฅผ ์ด์šฉํ•œ ๋ฐฉ๋ฒ• โญ๏ธ


Intersection Observer๋Š” ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋ทฐํฌํŠธ(ํ˜น์€ ๋‹ค๋ฅธ ๋ถ€๋ชจ ์š”์†Œ)์— ๋…ธ์ถœ๋๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ์•Œ๋ ค์ฃผ๋Š” API๋‹ค. ๋ฌดํ•œ ์Šคํฌ๋กค, Lazy Loading, ๊ด‘๊ณ  ๊ฐ€์‹œ์„ฑ(๊ด‘๊ณ  ๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ํŒŒ์•…ํ•ด์„œ ๊ด‘๊ณ  ์ˆ˜์ต ๊ณ„์‚ฐ) ๋“ฑ์„ ๊ตฌํ˜„ํ•  ๋•Œ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์‚ฌ์šฉ๋ฒ•๋„ ๊ฐ„๋‹จํ•˜๊ณ  ์„ฑ๋Šฅ๋„ ์ข‹๋‹ค. ์ด๋ฅผ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๋ ค๋ฉด scroll ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•  ๋•Œ๋งˆ๋‹ค ์ง€์ •ํ•œ ์š”์†Œ๊ฐ€ ํ™”๋ฉด์— ์กด์žฌํ•˜๋Š”์ง€ ๊ณ„์‚ฐํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด์•ผ ๋œ๋‹ค.

 

Intersection Observer API๋Š” ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋ทฐํฌํŠธ์— ๋…ธ์ถœ๋๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ์•Œ๋ ค์ค€๋‹ค

// ์˜ˆ์‹œ ์ฝ”๋“œ
const options = { threshold: 1.0 };
const callback = (entries, observer) => { // โ‘ถ
  // entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // โ‘ท ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
      observer.unobserve(entry.target); // โ‘ธ ๊ตฌ๋… ํ•ด์ œ(ํ•ด๋‹น ํƒ€๊ฒŸ์€ ๋”์ด์ƒ ๊ด€์ฐฐํ•˜์ง€ ์•Š์Œ)
      console.log('ํ™”๋ฉด์—์„œ ๋…ธ์ถœ๋จ');
    } else {
      console.log('ํ™”๋ฉด์—์„œ ์ œ์™ธ๋จ');
    }
  });
};
const observer = new IntersectionObserver(callback, options); // โ‘ด IO ๊ฐ์ฒด(์ธ์Šคํ„ด์Šค) ์ƒ์„ฑ
observer.observe(document.querySelector('.element')); // โ‘ต ๊ด€์ฐฐ ๋Œ€์ƒ ์ถ”๊ฐ€

 

  1. ์ฝœ๋ฐฑ๊ณผ ์˜ต์…˜์„ ์ธ์ž๋กœ ๋ฐ›์•„ Intersection Observer ๊ฐ์ฒด(์ธ์Šคํ„ด์Šค) ์ƒ์„ฑ.
  2. ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ์ง€์ •ํ•  ํƒ€๊ฒŸ ์š”์†Œ ์ถ”๊ฐ€. ์ด๋•Œ IO ๊ฐ์ฒด์— ๋“ฑ๋กํ•œ ์ฝœ๋ฐฑ์ด 1ํšŒ ์‹คํ–‰๋œ๋‹ค.
  3. ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ threshold ์˜ต์…˜์— ์ •์˜ํ•œ ํผ์„ผํŠธ๋งŒํผ ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๊ฑฐ๋‚˜ ์‚ฌ๋ผ์ง€๋ฉด ์ฝœ๋ฐฑ ํ•จ์ˆ˜ ํ˜ธ์ถœ
  4. ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ ์ธ์ž๋กœ ์ „๋‹ฌ๋ฐ›์€ entries ๋ฐฐ์—ด์„ ํ†ตํ•ด ๋…ธ์ถœ ์—ฌ๋ถ€ ํ™•์ธ
  5. ํƒ€๊ฒŸ ์š”์†Œ๋ฅผ ๋”์ด์ƒ ๊ด€์ฐฐํ•  ํ•„์š”๊ฐ€ ์—†๋‹ค๋ฉด observer.unobserve ๋ฉ”์„œ๋“œ๋กœ ๊ตฌ๋…(๊ด€์ฐฐ) ํ•ด์ œ

 

์ฝœ๋ฐฑ ํ†บ์•„๋ณด๊ธฐ

๊ด€์ฐฐํ•  ๋Œ€์ƒ์„ ๋“ฑ๋กํ•˜๊ฑฐ๋‚˜, ํƒ€๊ฒŸ ์š”์†Œ์˜ ๊ฐ€์‹œ์„ฑ(๋…ธ์ถœ / ๋น„๋…ธ์ถœ)์— ๋ณ€ํ™”๊ฐ€ ์ƒ๊ธฐ๋ฉด, ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๊ณ  ์ฝœ๋ฐฑ์˜ ์ฒซ๋ฒˆ์งธ ์ธ์ž์—” Intersection Observer Entry ๊ฐ์ฒด๊ฐ€ ๋‹ด๊ธด ๋ฐฐ์—ด์ด ์ „๋‹ฌ๋œ๋‹ค. ํƒ€๊ฒŸ์ด ์ฒ˜์Œ ์ง€์ •๋˜๋ฉด(๊ด€์ฐฐ ๋Œ€์ƒ ๋“ฑ๋ก) ๋ชจ๋“  ์š”์†Œ์˜ ๊ฐ€์‹œ์„ฑ์„ ์ฒดํฌํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ํƒ€๊ฒŸ ์š”์†Œ๋ฅผ Entry ๋ฐฐ์—ด์— ๋„ฃ์–ด ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•œ๋‹ค. ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ ๋‘๋ฒˆ์งธ ์ธ์ž๋Š” ๊ด€์ฐฐ์ž ๊ฐ์ฒด๋ฅผ ๋ฐ›๋Š”๋‹ค.

 

  • entries : ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด (Intersection Observer Entry ์ธ์Šคํ„ด์Šค์˜ ๋ฐฐ์—ด)
  • observer : ๊ด€์ฐฐ์ž ๊ฐ์ฒด (์ฝœ๋ฐฑ ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋˜๊ณ  ์žˆ๋Š” observer ์ธ์Šคํ„ด์Šค ์ฐธ์กฐ)

 

(์ฝœ๋ฐฑ์˜ ์ฒซ๋ฒˆ์งธ ์ธ์ž) entries ์†์„ฑ

1๊ฐœ์˜ Intersection Observer Entry ๊ฐ์ฒด์—” ์•„๋ž˜ ์†์„ฑ๋“ค์ด ์ •์˜๋˜์–ด ์žˆ๋‹ค.

โถ target : ๊ด€์ฐฐ ๋Œ€์ƒ ์š”์†Œ

โท time : ๊ต์ฐจ ์ƒํƒœ ๋ณ€๊ฒฝ(๋…ธ์ถœ / ๋น„๋…ธ์ถœ)์ด ๋ฐœ์ƒํ•œ ์‹œ๊ฐ„์„ ๋‚˜ํƒ€๋‚ด๋Š” DOMHighResTimeStamp  

โธ isIntersecting : ๋…ธ์ถœ ์—ฌ๋ถ€ — target๊ณผ root(๋ทฐํฌํŠธ)๊ฐ€ ๊ต์ฐจ ์ƒํƒœ์ธ์ง€ ์—ฌ๋ถ€ true|false

๋”๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ HEROPY Tech

โน intersectionRatio : ๋…ธ์ถœ๋œ ๋น„์œจ — target๊ณผ root๊ฐ€ ์–ผ๋งˆ๋‚˜ ๊ต์ฐจ๋˜๊ณ  ์žˆ๋Š”์ง€์˜ ๋ฐฑ๋ถ„์œจ(0~1)

โบ intersectionRect : ๋…ธ์ถœ๋œ ์˜์—ญ — target๊ณผ root๊ฐ€ ๊ต์ฐจ๋˜๊ณ  ์žˆ๋Š” ์˜์—ญ์˜ ์ •๋ณด

โป boundingClientRect : target ์š”์†Œ ์ •๋ณด top, right, bottom, left, width, height, x, y 

๋”๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ HEROPY Tech

โผ rootBounds : root ์š”์†Œ ์ •๋ณด. IO ์ƒ์„ฑ ์˜ต์…˜์˜ root๋ฅผ ์ง€์ •ํ•˜์ง€ ์•Š์•˜๋‹ค๋ฉด(๊ธฐ๋ณธ๊ฐ’) viewport ํฌ๊ธฐ

 

(์ฝœ๋ฐฑ์˜ ๋‘๋ฒˆ์งธ ์ธ์ž) observer ๋ฉ”์„œ๋“œ

  • observe(target) : ๋Œ€์ƒ ์š”์†Œ์˜ ๊ด€์ฐฐ ์‹œ์ž‘
  • unobserve(target) : ๋Œ€์ƒ ์š”์†Œ์˜ ๊ด€์ฐฐ ์ค‘์ง€
  • disconnect() : IntersectionObserver ์ธ์Šคํ„ด์Šค๊ฐ€ ๊ด€์ฐฐํ•˜๋Š” ๋ชจ๋“  ์š”์†Œ์˜ ๊ด€์ฐฐ ์ค‘์ง€
  • takeRecords() : IntersectionObserverEntry ๊ฐ์ฒด์˜ ๋ฐฐ์—ด ๋ฐ˜ํ™˜ (์‚ฌ์šฉํ•  ์ผ ๊ฑฐ์˜ ์—†์Œ)

 

Observer ๊ฐ์ฒด ์ƒ์„ฑ ์˜ต์…˜ ํ†บ์•„๋ณด๊ธฐ

Intersection Observer ์ƒ์„ฑ ์‹œ ์•„๋ž˜ 3๊ฐ€์ง€ ์˜ต์…˜์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

โถ root : ๋…ธ์ถœ / ๋น„๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ์–ด๋–ค ์š”์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ• ์ง€ ์ง€์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’(null)์€ ๋ทฐํฌํŠธ. ๋งŒ์•ฝ ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ root๋กœ ์ง€์ •ํ•œ ์š”์†Œ์˜ ์ž์‹์œผ๋กœ ์žˆ์ง€ ์•Š๋‹ค๋ฉด ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋”๋ผ๋„ ๋…ธ์ถœ๋กœ ์—ฌ๊ธฐ์ง€ ์•Š์Œ.

 

โท rootMargin : ๋ฐ”๊นฅ ์—ฌ๋ฐฑ(Margin)์„ ์ด์šฉํ•ด Root ๋ฒ”์œ„๋ฅผ ํ™•์žฅ / ์ถ•์†Œํ•˜๋Š” ์˜ต์…˜. threshold๋Š” ์ง€์ •ํ•œ rootMargin ๋งŒํผ ๋”ํ•ด์„œ ๊ณ„์‚ฐ๋œ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ 0px 0px 0px 0px (px ํ˜น์€ % ๋‹จ์œ„ ํ•„์ˆ˜ ์ž…๋ ฅ).

๋”๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ HEROPY Tech

 

โธ threshold : ํ™”๋ฉด์— ์–ผ๋งŒํผ ๋…ธ์ถœ๋ผ์•ผ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ• ์ง€ ๊ฒฐ์ •ํ•˜๋Š” ์˜ต์…˜์œผ๋กœ ๊ธฐ๋ณธ๊ฐ’์€ 0(0%). ์ตœ๋Œ€ 1(100%)๊นŒ์ง€ ์ง€์ • ๊ฐ€๋Šฅ. Array<number> ํ˜•ํƒœ๋กœ ์—ฌ๋Ÿฌ ๋น„์œจ์„ ์ง€์ •ํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

๋”๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ HEROPY Tech
  • ๊ธฐ๋ณธ๊ฐ’(0)์ผ ๋• ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ 1px๋ผ๋„ ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•œ๋‹ค. 
  • 0.5๋กœ ์ง€์ •ํ•˜๋ฉด ํ™”๋ฉด์— 50% ์ด์ƒ ๋ณด์ผ๋•Œ๋ถ€ํ„ฐ ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•œ๋‹ค. 
  • ๋ฐฐ์—ด๋กœ ์ง€์ •ํ•˜๋ฉด ๊ฐ ๋น„์œจ๋กœ ๋…ธ์ถœ๋  ๋•Œ๋งˆ๋‹ค ์ฝœ๋ฐฑ์„ ํ˜ธ์ถœํ•œ๋‹ค. e.g. { threshold: [0, 0.2] }

 

React์— ์ ์šฉํ•˜๊ธฐ

๋ Œ๋”๋ง ๋ฆฌ์ŠคํŠธ ๊ฐ€์žฅ ์•„๋ž˜์— ๋นˆ ์š”์†Œ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , Intersection Observer๋ฅผ ์ด์šฉํ•ด ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋กํ•œ๋‹ค. ์Šคํฌ๋ฅผ์ด ๊ฐ€์žฅ ํ•˜๋‹จ์— ๋„๋‹ฌํ•˜๋ฉด(target๊ณผ root์˜ ๊ต์ฐจ ์‹œ์ ) IO์— ๋“ฑ๋กํ•œ ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋˜๊ณ  ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ž‘์—…(ํ˜น์€ page ์ƒํƒœ +1 → ํ•ด๋‹น page์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ ์š”์ฒญ)์„ ์ˆ˜ํ–‰ํ•˜๋„๋ก ์ž‘์„ฑํ•˜๋ฉด ๋œ๋‹ค.

 

โถ Intersection Observer ๊ฐ์ฒด์— ๋“ฑ๋กํ•  ์ฝœ๋ฐฑ ํ•จ์ˆ˜ ์ •์˜

export default function InfiniteScrollIO() {
  const [isFetching, setIsFetching] = useState(false);
  const [listItems, setListItems] = useState(
    Array.from(Array(30).keys(), (n) => n + 1),
  );

  // Intersection Observer ์ฝœ๋ฐฑ ์ •์˜(useCallback ์ ์šฉ์€ ้€‰้กน)
  // (์ธ์ž1) entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, (์ธ์ž2) observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  const handleObserver = useCallback((entries, observer) => {
    const target = entries[0]; // ๋ฆฌ์ŠคํŠธ ๊ฐ€์žฅ ์•„๋ž˜์˜ ๋นˆ์š”์†Œ

    if (target.isIntersecting) {
      // ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
      setIsFetching(true); // '๋กœ๋”ฉ ์ค‘' ํ‘œ์‹œ
      setTimeout(() => {
        // (dummy) ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ
        setListItems((prev) => [
          ...prev,
          ...Array.from(Array(20).keys(), (n) => n + prev.length + 1),
        ]);
        setIsFetching(false); // '๋กœ๋”ฉ ์ค‘' ํ‘œ์‹œ ํ•ด์ œ
      }, 1000);
    }
  }, []);

  // ...
}

 

โท Intersection Observer ๊ฐ์ฒด ์ƒ์„ฑ / ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก

export default function InfiniteScrollIO() {
  // ...

  useEffect(() => {
    const options = {
      root: null, // ๊ธฐ๋ณธ๊ฐ’ null(๋ทฐํฌํŠธ)
      rootMargin: '30px', // ์Šคํฌ๋กค์ด ์ตœํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„์ฆˆ์Œ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์œ„ํ•ด rootMargin ์„ค์ •
      threshold: 0, // ๊ธฐ๋ณธ๊ฐ’ 0(1px ๋ผ๋„ ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ ํ˜ธ์ถœ)
    };
    const observer = new IntersectionObserver(handleObserver, options); // ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก ๋ฐ IO ๊ฐ์ฒด ์ƒ์„ฑ
  }, []);

  // ...
}

 

โธ ๋นˆ ์š”์†Œ(๋ฆฌ์ŠคํŠธ ๊ฐ€์žฅ ์•„๋ž˜ ์š”์†Œ)๋ฅผ IO ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก

export default function InfiniteScrollIO() {
  const loader = useRef(null);
  // ...
  useEffect(() => {
    // ...
    if (loader.current) observer.observe(loader.current); // ๋นˆ์š”์†Œ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก
  }, []);

  return (
    <section className="flex flex-col justify-center items-center p-4">
      {/* ...์ƒ๋žต */}
      <div ref={loader} className="w-full" />
    </section>
  );
}

 

โน ์–ธ๋งˆ์šดํŠธ ๋กœ์ง ์ถ”๊ฐ€

export default function InfiniteScrollIO() {
  // ...
  useEffect(() => {
    // ...
    return () => {
      setIsFetching(false); // Can't perform a React state update... ์˜ค๋ฅ˜ ๋Œ€์‘
      observer.disconnect(); // ์–ธ๋งˆ์šดํŠธ์‹œ ๋ชจ๋“  ์š”์†Œ์— ๋Œ€ํ•œ ๊ด€์ฐฐ ์ค‘์ง€
    };
  }, []);
  // ...
}
๋”๋ณด๊ธฐ
import React, { useCallback, useEffect, useRef, useState } from 'react';

export default function InfiniteScrollIO() {
  const [isFetching, setIsFetching] = useState(false);
  const [listItems, setListItems] = useState(
    Array.from(Array(30).keys(), (n) => n + 1),
  );
  const loader = useRef(null);

  // Intersection Observer ์ฝœ๋ฐฑ ์ •์˜(useCallback ์ ์šฉ์€ ้€‰้กน)
  // (์ธ์ž1) entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, (์ธ์ž2) observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  const handleObserver = useCallback((entries, obersver) => {
    const target = entries[0]; // ๋ฆฌ์ŠคํŠธ ๊ฐ€์žฅ ์•„๋ž˜์˜ ๋นˆ์š”์†Œ

    if (target.isIntersecting) {
      // ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
      setIsFetching(true); // '๋กœ๋”ฉ ์ค‘' ํ‘œ์‹œ
      setTimeout(() => {
        // (dummy) ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ
        setListItems((prev) => [
          ...prev,
          ...Array.from(Array(20).keys(), (n) => n + prev.length + 1),
        ]);
        setIsFetching(false); // '๋กœ๋”ฉ ์ค‘' ํ‘œ์‹œ ํ•ด์ œ
      }, 1000);
    }
  }, []);

  useEffect(() => {
    const options = {
      root: null, // ๊ธฐ๋ณธ๊ฐ’ null(๋ทฐํฌํŠธ)
      rootMargin: '30px', // ์Šคํฌ๋กค์ด ์ตœํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„์ฆˆ์Œ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์œ„ํ•ด rootMargin ์˜ต์…˜ ์„ค์ •
      threshold: 0, // ๊ธฐ๋ณธ๊ฐ’ 0(1px ๋ผ๋„ ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ ํ˜ธ์ถœ)
    };
    const observer = new IntersectionObserver(handleObserver, options); // ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก ๋ฐ IO ๊ฐ์ฒด ์ƒ์„ฑ
    if (loader.current) observer.observe(loader.current); // ๋นˆ์š”์†Œ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก

    return () => {
      setIsFetching(false); // Can't perform a React state update... ์˜ค๋ฅ˜ ๋Œ€์‘
      observer.disconnect(); // ์–ธ๋งˆ์šดํŠธ์‹œ ๋ชจ๋“  ์š”์†Œ์— ๋Œ€ํ•œ ๊ด€์ฐฐ ์ค‘์ง€
    };
  }, []);

  return (
    <section className="flex flex-col justify-center items-center p-4">
      <ul className="space-y-4 mb-4">
        {listItems.map((item, i) => (
          <li
            key={i}
            className="border text-center w-56 h-12 grid place-content-center"
          >
            List Item {item}
          </li>
        ))}
      </ul>
      <div className={`${isFetching ? 'visibility' : 'invisible'}`}>
        ๐Ÿ”๏ธ Fetching more items...
      </div>
      <div ref={loader} className="w-full" />{' '}
      {/* ํ™”๋ฉด ๋…ธ์ถœ ์—ฌ๋ถ€ ํ™•์ธ์„ ์œ„ํ•œ ๋นˆ์š”์†Œ */}
    </section>
  );
}

 

Hook์œผ๋กœ ๋ถ„๋ฆฌํ•˜๊ธฐ โญ๏ธ

Intersection Observer API๋Š” ๋ฌดํ•œ ์Šคํฌ๋กค์€ ๋ฌผ๋ก  Lazy Loading, ๊ด‘๊ณ  ๊ฐ€์‹œ์„ฑ ํ™•์ธ ๋“ฑ ๋‹ค์–‘ํ•œ ๊ณณ์—์„œ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ์ปค์Šคํ…€ Hook์œผ๋กœ ๋ถ„๋ฆฌํ•ด์„œ ์žฌ์‚ฌ์šฉํ•˜๋ฉด ์ข‹๋‹ค. ์ปค์Šคํ…€ Hook์˜ ์ธ์ž๋Š” ์•„๋ž˜ 3๊ฐœ๋ฅผ ๋ฐ›๋„๋ก ์ž‘์„ฑํ•œ๋‹ค. ํ•จ์ˆ˜ ํ˜ธ์ถœ์‹œ ์ธ์ž ์ˆœ์„œ์— ๊ตฌ์• ๋ฐ›์ง€ ์•Š๋„๋ก RORO ํŒจํ„ด์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค.

 

  1. callback : ๊ต์ฐจ ์ƒํƒœ์—์„œ ์‹คํ–‰ํ•  ๋กœ์ง์ด ๋‹ด๊ธด ์ฝœ๋ฐฑ ํ•จ์ˆ˜
  2. unObserver : ๊ต์ฐจ ์ƒํƒœ ํ›„ ํ•ด๋‹น ํƒ€๊ฒŸ์˜ ๊ด€์ฐฐ์„ ์ค‘์ง€ํ• ์ง€ ์—ฌ๋ถ€. true | false
    ๐Ÿ’ก ๋ฌดํ•œ์Šคํฌ๋กค์€ ๊ต์ฐจ ์ƒํƒœ ์ดํ›„์—๋„ ๊ฐ€์žฅ ํ•˜๋‹จ์˜ ๋นˆ ์š”์†Œ๋ฅผ ๊ณ„์† ๊ด€์ฐฐํ•ด์•ผ ํ•˜์ง€๋งŒ(false), Lazy Loading ๋“ฑ์„ ๊ตฌํ˜„ํ•  ๋• ์ด๋ฏธ ํ™”๋ฉด์— ๋…ธ์ถœ๋ผ์„œ ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•œ ์ƒํƒœ๋ฉด ๋”์ด์ƒ ๊ด€์ฐฐํ•  ํ•„์š”๊ฐ€ ์—†๋‹ค(true).
  3. options : Intersection Observer ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ ์‹œ ๋„˜๊ธธ ์˜ต์…˜

 

์ปค์Šคํ…€ Hook์€ Ref ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ํ•œ๋‹ค. ๊ทธ๋Ÿผ Hook์„ ํ˜ธ์ถœํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์—์„œ Ref ๊ฐ์ฒด๋ฅผ ๋ฐ›์€ ๋’ค ๊ด€์ฐฐํ•˜๊ณ  ์‹ถ์€ ์ปดํฌ๋„ŒํŠธ์˜ ref ์†์„ฑ์— ๋ฐ”๋กœ ํ• ๋‹นํ•˜๋ฉด ๋œ๋‹ค. ์‚ฌ์šฉํ•˜๊ธฐ ๋” ํŽธํ•˜๋‹ค

// ์ฝ”๋“œ ์ผ๋ถ€ ์ฐธ๊ณ  : https://mrcoles.com/intersection-observer-react-hook/
// hooks/useIntersectionObserver.js
import { useCallback, useEffect, useRef } from 'react';

export default function useIntersectionObserver({
  callback,
  unObserve = false,
  options = {
    root: null, // ๋…ธ์ถœ&๋น„๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ์–ด๋–ค ์š”์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ• ์ง€ ์ง€์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ null(๋ทฐํฌํŠธ)
    rootMargin: '0px', // ๋ฐ”๊นฅ ์—ฌ๋ฐฑ(Margin)์„ ์ด์šฉํ•ด Root ๋ฒ”์œ„๋ฅผ ํ™•์žฅ/์ถ•์†Œํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0px
    threshold: 0, // ํ™”๋ฉด์— ์–ผ๋งŒํผ ๋…ธ์ถœ๋ผ์•ผ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ• ์ง€ ๊ฒฐ์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0(1px ๋ผ๋„ ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ ํ˜ธ์ถœ)
  },
}) {
  const ioRef = useRef(null);

  // Intersection Observer ์ฝœ๋ฐฑ ์ •์˜(useCallback ์ ์šฉ์€ ้€‰้กน)
  // (์ธ์ž1) entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, (์ธ์ž2) observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  const ioHandler = useCallback((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        // ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
        callback(); // ๊ต์ฐจ ์‹œ์ ์—(๊ด€์ฐฐ ๋Œ€์ƒ์ด ๋ทฐํฌํŠธ์— ๋…ธ์ถœ) ์ธ์ž๋กœ ๋ฐ›์€ ์ฝœ๋ฐฑ ํ˜ธ์ถœ
        if (unObserve) observer.unobserve(entry.target); // (์กฐ๊ฑด ๋งŒ์กฑ์‹œ) ํ•ด๋‹น ํƒ€๊ฒŸ์€ ๊ด€์ฐฐ ์ค‘์ง€
      }
    });
  }, []);

  useEffect(() => {
    if (window.IntersectionObserver) {
      const observer = new IntersectionObserver(ioHandler, options); // ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก ๋ฐ IO ๊ฐ์ฒด ์ƒ์„ฑ
      observer.observe(ioRef.current); // ์ธ์ž๋กœ ๋„˜๊ธด ์š”์†Œ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก

      return () => observer.disconnect(); // ์–ธ๋งˆ์šดํŠธ์‹œ ๋ชจ๋“  ์š”์†Œ์— ๋Œ€ํ•œ ๊ด€์ฐฐ ์ค‘์ง€
    }
  }, []);

  return ioRef;
}
๋”๋ณด๊ธฐ
// ์ฝ”๋“œ ์ผ๋ถ€ ์ฐธ๊ณ  : https://mrcoles.com/intersection-observer-react-hook/
// hooks/useIntersectionObserver.tsx
import { useCallback, useEffect, useRef } from 'react';

interface IOProps {
  callback: VoidHandler;
  unObserve?: boolean;
  options?: IntersectionObserverInit;
}

export default function useIntersectionObserver<T extends HTMLElement>({
  callback,
  unObserve = false,
  options = {
    root: null, // ๋…ธ์ถœ&๋น„๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ์–ด๋–ค ์š”์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ• ์ง€ ์ง€์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ null(๋ทฐํฌํŠธ)
    rootMargin: '0px', // ๋ฐ”๊นฅ ์—ฌ๋ฐฑ(Margin)์„ ์ด์šฉํ•ด Root ๋ฒ”์œ„๋ฅผ ํ™•์žฅ/์ถ•์†Œํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0px
    threshold: 0, // ํ™”๋ฉด์— ์–ผ๋งŒํผ ๋…ธ์ถœ๋ผ์•ผ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ• ์ง€ ๊ฒฐ์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0(1px ๋ผ๋„ ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ ํ˜ธ์ถœ)
  },
}: IOProps) {
  const ioRef = useRef<T>(null);

  // Intersection Observer ์ฝœ๋ฐฑ ์ •์˜(useCallback ์ ์šฉ์€ ้€‰้กน)
  // (์ธ์ž1) entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, (์ธ์ž2) observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  const ioHandler: IntersectionObserverCallback = useCallback(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          // ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
          callback(); // ๊ต์ฐจ ์‹œ์ ์—(๊ด€์ฐฐ ๋Œ€์ƒ์ด ๋ทฐํฌํŠธ์— ๋…ธ์ถœ) ์ธ์ž๋กœ ๋ฐ›์€ ์ฝœ๋ฐฑ ํ˜ธ์ถœ
          if (unObserve) observer.unobserve(entry.target); // (์กฐ๊ฑด ๋งŒ์กฑ์‹œ) ํ•ด๋‹น ํƒ€๊ฒŸ์€ ๊ด€์ฐฐ ์ค‘์ง€
        }
      });
    },
    [callback, unObserve],
  );

  useEffect(() => {
    if (!window.IntersectionObserver || !ioRef.current) return undefined;
    const observer = new IntersectionObserver(ioHandler, options); // ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก ๋ฐ IO ๊ฐ์ฒด ์ƒ์„ฑ
    observer.observe(ioRef.current); // ์ธ์ž๋กœ ๋„˜๊ธด ์š”์†Œ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก

    return () => observer.disconnect(); // ์–ธ๋งˆ์šดํŠธ์‹œ ๋ชจ๋“  ์š”์†Œ์— ๋Œ€ํ•œ ๊ด€์ฐฐ ์ค‘์ง€
  }, [ioHandler, options]);

  return ioRef;
}

 

์ปค์Šคํ…€ Hook์„ ํ˜ธ์ถœํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์—์„  callback options์„ Hook์˜ ์ธ์ž๋กœ ๋„˜๊ธด๋‹ค. callback์€ ๊ธฐ์กด ์ž‘์„ฑํ–ˆ๋˜ handleObserver ํ•จ์ˆ˜์˜ ๋ณธ๋ฌธ๋งŒ ์˜ฎ๊ฒจ์ฃผ๋ฉด ๋œ๋‹ค. unObserve ์ธ์ž๋Š” ๋ช…์‹œํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ ๊ธฐ๋ณธ๊ฐ’ false๊ฐ€ ์ง€์ •๋œ๋‹ค. ๊ทธ๋Ÿผ ๊ด€์ฐฐ ๋Œ€์ƒ์ด ํ™”๋ฉด์— ๋…ธ์ถœ๋œ ์ดํ›„์—๋„ ๊ด€์ฐฐ์„ ์ค‘์ง€ํ•˜์ง€ ์•Š๋Š”๋‹ค.

import useIntersectionObserver from '../../hooks/useIntersectionObserver';

// components/InfiniteScrollIO.js
export default function InfiniteScrollIO() {
  // ...์ƒ๋žต

  const loaderRef = useIntersectionObserver({
    callback: () => {
      setIsFetching(true);
      setTimeout(() => {
        /* (dummy) ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ ์ฝ”๋“œ ์ƒ๋žต */
      }, 1000);
    },
    options: { rootMargin: '30px' }, // ์Šคํฌ๋กค์ด ์ตœํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„์ฆˆ์Œ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์œ„ํ•ด rootMargin ์„ค์ •
  });

  useEffect(() => {
    return () => setIsFetching(false); // Can't perform a React state update... ์˜ค๋ฅ˜ ๋Œ€์‘
  }, []);

  return (
    <section className="flex flex-col justify-center items-center p-4">
      {/* ...์ƒ๋žต */}
      <div ref={loaderRef} className="w-full" />{' '}
      {/* ํ™”๋ฉด ๋…ธ์ถœ ์—ฌ๋ถ€ ํ™•์ธ์„ ์œ„ํ•œ ๋นˆ์š”์†Œ */}
    </section>
  );
}
๋”๋ณด๊ธฐ
import React, { useEffect, useState } from 'react';
import useIntersectionObserver from '../../hooks/useIntersectionObserver';

// components/InfiniteScrollIO.js
export default function InfiniteScrollIO() {
  const [isFetching, setIsFetching] = useState(false);
  const [listItems, setListItems] = useState(
    Array.from(Array(30).keys(), (n) => n + 1),
  );

  const loaderRef = useIntersectionObserver({
    callback: () => {
      setIsFetching(true);
      setTimeout(() => {
        setListItems((prev) => [
          ...prev,
          ...Array.from(Array(20).keys(), (n) => n + prev.length + 1),
        ]);
        setIsFetching(false);
      }, 1000);
    },
    options: { rootMargin: '30px' }, // ์Šคํฌ๋กค์ด ์ตœํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„์ฆˆ์Œ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์œ„ํ•ด rootMargin ์„ค์ •
  });

  useEffect(() => {
    return () => setIsFetching(false); // Can't perform a React state update... ์˜ค๋ฅ˜ ๋Œ€์‘
  }, []);

  return (
    <section className="flex flex-col justify-center items-center p-4">
      <ul className="space-y-4 mb-4">
        {listItems.map((item, i) => (
          <li
            key={i}
            className="border text-center w-56 h-12 grid place-content-center"
          >
            List Item {item}
          </li>
        ))}
      </ul>
      <div className={`${isFetching ? 'visibility' : 'invisible'}`}>
        ๐Ÿ”๏ธ Fetching more items...
      </div>
      <div ref={loaderRef} className="w-full" />{' '}
      {/* ํ™”๋ฉด ๋…ธ์ถœ ์—ฌ๋ถ€ ํ™•์ธ์„ ์œ„ํ•œ ๋นˆ์š”์†Œ */}
    </section>
  );
}

 

๊ตฌํ˜„ ํ™”๋ฉด GIF

 

IO Custom hook ver.2 โญ๏ธ

@hyesungoh ๋ธ”๋กœ๊ทธ์—์„œ ์šฐ์—ฐํžˆ ๋ฐœ๊ฒฌํ•œ ์ฝ”๋“œ. ๊ด€์ฐฐํ•  ์š”์†Œ์˜ ๋ ˆํผ๋Ÿฐ์Šค๊ฐ€ ๋‹ด๊ธธ target ๋ณ€์ˆ˜๋ฅผ useState ์ƒํƒœ์— ํ• ๋‹นํ•˜๊ณ , ์ƒํƒœ ์„ค์ • ํ•จ์ˆ˜ ์ž์ฒด๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ ์š”์†Œ์˜ ref ์†์„ฑ์— ํ• ๋‹นํ•˜๋Š” ๋ฐฉ์‹. ref๊ฐ€ ์„ค์ • / ํ•ด์ œ๋  ๋•Œ ํŠน์ • ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋„๋ก ํ•˜๊ธฐ ์œ„ํ•ด callback ref ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•œ ๊ฒƒ. ์ฝ”๋“œ๋ฅผ ๋” ๊น”๋”ํ•˜๊ฒŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.

 

import useIntersectionObserver from 'hooks/useIntersectionObserver';

const Foo = () => {
  const onIntersect: IntersectionObserverCallback = ([{ isIntersecting }]) => {
    console.log(`๊ฐ์ง€๊ฒฐ๊ณผ : ${isIntersecting}`);
  };

  // ์ปค์Šคํ…€ ํ›… ์‚ฌ์šฉ
  const { setTarget } = useIntersectionObserver({ onIntersect });

  return <div ref={setTarget}></div>;
};
๋”๋ณด๊ธฐ

์ฝ”๋“œ ์ฐธ๊ณ  via hyesungoh.log

import { useEffect, useState } from 'react';

// useIntersectionObserver.ts
interface UseIntersectionObserverProps {
  root?: null;
  rootMargin?: string;
  threshold?: number;
  onIntersect: IntersectionObserverCallback;
}

const useIntersectionObserver = ({
  root,
  rootMargin = '0px',
  threshold = 0,
  onIntersect,
}: UseIntersectionObserverProps) => {
  const [target, setTarget] = useState<HTMLElement | null>(null);

  useEffect(() => {
    if (!target) return undefined;

    const observer: IntersectionObserver = new IntersectionObserver(
      onIntersect,
      { root, rootMargin, threshold },
    );
    observer.observe(target);

    return () => observer.unobserve(target);
  }, [onIntersect, root, rootMargin, target, threshold]);

  return { setTarget };
};

export default useIntersectionObserver;

 

๋ ˆํผ๋Ÿฐ์Šค

 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
๋ฐ˜์‘ํ˜•