본문 바로가기

리액트

[react] window.scroll 를 이용한 무한 스크롤 구현 시 문제되는 코드를 개선한 내용을 저장한 포스트

반응형

 

리팩토링 전

간략하게 정리해보면, 리팩토링 전의 코드는 스크롤 이후 추가적으로 불러오는 데이터를 카운트할 때 부모 컴포넌트로 부터 props 를 전달받아와서 사용했고, 이를 JSX 내에서 slice 를 활용해 중복으로 데이터를 불러오도록 하고 있다. 누가 봐도 전체적으로 성능 문제를 일으킬 수 있고, props 로 전달받은 데이터가 변동될 때 마다 컴포넌트가 전체적으로 리렌더링되는 등 문제가 많은 코드로 작성되어 있다. 그리고 아래 코드에서는 나오지 않았지만, 부모 컴포넌트에서 window.scroll 를 이용해 모든 자식 컴포넌트에서 불필요한 이벤트 호출이 발생하고 있다.

 

그리고 데이터가 추가적으로 로딩이 되는 경우 사용자는 데이터가 로딩되고 있는지 확인이 불가능했다. 아래 코드를 처음 작성했을 때는 일단 작동하게만 하자는 마음으로 코드를 구성하였으나 문제의 심각성을 깨닫고 나서 리팩토링하고자 했다.

import { useEffect } from "react";
import { RecipeType } from "../../type/RecipeType";
import styles from "../page/recipe/Recipe.module.scss";
import { Link } from "react-router-dom";
interface ResultType {
  recipes?: RecipeType[];
  meg: string;
  extraRecipeDataCount: number;
  setExtraRecipeDataCount: (p: number) => void;
}
function RecipeSearchResult({
  recipes,
  meg,
  extraRecipeDataCount,
  setExtraRecipeDataCount,
}: ResultType) {
  useEffect(() => {
    if (recipes?.length! < extraRecipeDataCount) {
      setExtraRecipeDataCount(recipes?.length!);
    }
  }, [extraRecipeDataCount]);
  return (
    <>
      <h3 className={styles.undefined_meg}>{meg}</h3>
      <article className={styles.search_result_container}>
        {recipes?.slice(0, extraRecipeDataCount).map((recipe) => {
          console.log(extraRecipeDataCount);
          return (
            <Link
              to={`/food-recipe/detail/${recipe.RCP_SEQ}`}
              key={recipe.RCP_SEQ}
            >
              <ul
                className={styles.recipe_item_con}
                style={{
                  backgroundImage: `url(${
                    recipe.ATT_FILE_NO_MAIN ||
                    process.env.PUBLIC_URL + "/not-image.png"
                  })`,
                  backgroundPosition: "center",
                  backgroundSize: "cover",
                }}
              >
                <li className={styles.recipe_main_item}>
                  <h3>{recipe.RCP_SEQ}</h3>
                  <h3 className={styles.recipe_main_title}>{recipe.RCP_NM}</h3>
                  <p className={styles.recipe_main_category}>
                    {recipe.RCP_PAT2}
                  </p>
                </li>
              </ul>
            </Link>
          );
        })}
      </article>
    </>
  );
}

export default RecipeSearchResult;

 

 리팩토링 후

일단 부모 컴포넌트로 부터 굳이 props 로 count 관련 데이터를 받아올 필요가 없다고 판단했으므로 불필요한 props 는 제거했다. 그리고 window.scroll 의 경우에도 굳이 부모 컴포넌트에서 실행할 필요가 없으므로 해당 로직을 필요로 하는 자식 컴포넌트에서 로직을 재작성했다.

 

앞서 리팩토링 이전 코드에서는 JSX 내에서 map 과 slice 를 이용해서 데이터를 추가적으로 불러오는 중복된 요청을 하는 잘못된 로직을 사용하였는데, 이를 분리하여 추가적으로 필요한 데이터만 불러오고, 이전에 사용된 데이터를 재사용할 수 있도록 수정하였다.

import { useEffect, useState, useRef } from "react";
import { RecipeType } from "../../type/RecipeType";
import styles from "../page/recipe/Recipe.module.scss";
import { Link } from "react-router-dom";

interface ResultType {
  recipes?: RecipeType[];
  meg: string;
}

function RecipeSearchResult({ recipes, meg }: ResultType) {
  const [visibleRecipes, setVisibleRecipes] = useState<RecipeType[]>([]);
  const containerRef = useRef<HTMLDivElement | null>(null);
  const loadingRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (recipes) {
      setVisibleRecipes(recipes.slice(0, 10));
    }
  }, [recipes]);

  useEffect(() => {
    const container = containerRef.current;
    const loading = loadingRef.current;

    const handleScroll = () => {
      if (container && loading) {
        if (container.getBoundingClientRect().bottom <= window.innerHeight + 100) {
          const currentLength = visibleRecipes.length;
          const nextRecipes = recipes?.slice(currentLength, currentLength + 10);
          if (nextRecipes && nextRecipes.length > 0) {
            setVisibleRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
          }
        }
      }
    };
    
 // 이벤트 리스너를 언마운트 시에 제거해주어야 메모리 누수를 방지할 수 있다(메모리 공간 낭비 방지).
 // 이벤트 리스너를 제거하지 않는다면 리스너 등록이 쌓이게 되고,
 // 쌓인 리스너가 마운트 될 때 마다 동일한 함수를 호출하기 때문에 중복호출로 인한 예기치 못한 문제가 발생할 수 있다.
    window.addEventListener("scroll", handleScroll);
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, [recipes, visibleRecipes]);

  return (
    <>
      <h3 className={styles.undefined_meg}>{meg}</h3>
      <article ref={containerRef} className={styles.search_result_container}>
        {visibleRecipes.map((recipe) => (
          <Link to={`/food-recipe/detail/${recipe.RCP_SEQ}`} key={recipe.RCP_SEQ}>
            <ul
              className={styles.recipe_item_con}
              style={{
                backgroundImage: `url(${recipe.ATT_FILE_NO_MAIN || process.env.PUBLIC_URL + "/not-image.png"})`,
                backgroundPosition: "center",
                backgroundSize: "cover",
              }}
            >
              <li className={styles.recipe_main_item}>
                <h3>{recipe.RCP_SEQ}</h3>
                <h3 className={styles.recipe_main_title}>{recipe.RCP_NM}</h3>
                <p className={styles.recipe_main_category}>{recipe.RCP_PAT2}</p>
              </li>
            </ul>
          </Link>
        ))}
        <br />
        <div ref={loadingRef}>Loading...</div>
      </article>
    </>
  );
}

export default RecipeSearchResult;

 

 

반응형