본문 바로가기

프로젝트/푸드피커

[푸드피커] 기능 구현 정리본

반응형

오늘의 명언

 

들어가기 전

프로젝트를 진행하면서 구현한 기능들을 향후 재참고 하기 위한 혹은 포트폴리오를 위해 정리해두는 모음 형식의 포스트 입니다.

 

해당 기능 구현 목록은 2024.05.24 ~ 이후로 재개발에 들어가면서 변경된 혹은 추가된 로직에 대한 구현을 담고 있습니다.

 

참고로, 사용된 코드는 실제 프로젝트 코드와는 차이가 있을 수 있습니다. 늘 현재의 코드에 부족함을 느껴서 시간이 날 때 마다 개선해 나가고 있습니다.

[무한 스크롤 버전 1 ]커스텀 리액트 쿼리와 인터섹션 옵저버를 활용한 무한 스크롤

기능 개요

무한스크롤은 사용자가 특정 리스트를 렌더링하는 페이지에서 스크롤이 최하단에 도달하는 경우 추가 리스트를 불러오는 기능으로 대량의 데이터를 일정 단위로 나누어 효율적으로 렌더링하기 위해 사용합니다. 

 

현재 프로젝트는 데이터를 조회하여 사용자에게 보여주는 단순한 컨셉의 사이트이고, 조회 페이지의 대다수는 무한 스크롤 기반으로 데이터를 조회하므로 핵심이 되는 기능 중 하나 입니다.

 

도구선택과 구현 방향성

tanstack/react-query 의 경우, 효율적인 무한스크롤 기능 구현을 위한 도구로 useInfiniteQuery 훅을 별도로 제공해주고 있습니다. 따라서 해당 훅을 사용하여 기능을 구현할 것입니다. 

 

또한 해당 훅의 경우 재사용하므로 useInfiniteScroll 이라는 커스텀 훅을 별도로 만듭니다.

 

마지막으로 스크롤의 최하단에 도달하였는지를 측정하기 위한 useIntersection 이라는 IntersectionObserver API를 재사용한  커스텀 훅을 생성 합니다. 해당 훅은 DOM 트리의 요소를 참조하는 Ref 를 매개변수로 받아서 해당 Ref 가 참조하는 DOM 요소의 위치를 관찰하여 뷰포트에 보이는 순간 true 를 반환하고, 보이지 않으면 false를 반환하는 훅입니다.

 

useInfiniteScroll 구현

우선 전체 로직은 다음과 같습니다. 로직에 대한 주요 설명은 스니펫 하단에서 이어집니다.

import { useInfiniteQuery } from '@tanstack/react-query';
import axios from 'axios';
import { config } from '../config/config';

/**
 * 무한 스크롤 커스텀 훅
 * @param key 쿼리 식별키 ex 'localfood'
 * @param url api 경로 ex /localfood?page=
 * @returns
 */
export const useInfiniteScroll = (url: string, ...key: string[]) => {
  const baseUrl = config.protocol + config.host + url + 0;

  const { data, ...props } = useInfiniteQuery({
    queryKey: key,
    queryFn: async ({ pageParam = baseUrl }) => {
      const res = await axios.get(pageParam);
      return res.data;
    },
    initialPageParam: baseUrl,
    getNextPageParam: (lastPage) => {
      return lastPage.next || undefined;
    },
  });

  const items = data?.pages.map((pageData) => {
    return pageData.items;
  });

  const totalCount = data?.pages[0].totalCount || 0;
  const concatItems = items ? [].concat(...items) : [];

  return { items: concatItems, totalCount, ...props };
};

queyrKey 와 ...key의 관계

현재 작성된 코드에서 주요하게 살펴보아야 하는 것은 useInfiniteScroll이 두 개의 매개변수를 받고 있고, key의 경우 나머지 매개변수 형식으로 전달받은 배열 요소들을 queryKey 에 전달하고 있다는 점입니다. 

 

이 경우 queryKey 의 경우 캐싱처리를 위한 고유한 식별 키를 배열 형태로 값을 할당 받습니다. 그러나, 재사용 가능한 훅의 특성상 어떤 고유한 key 의 종류가 얼마나 있을지 알 수 없기 때문에, 확장 가능성을 염두에 두고 나머지 매개변수(...)를 적용하였습니다.

userInfiniteQuery 에 대한 설명

현재 쿼리의 주요 훅 구성 사항은 다음과 같습니다. 해당 로직에서 핵심이 되는 개념과 로직 설명을 하단에서 설명합니다.

  const { data, ...props } = useInfiniteQuery({
    queryKey: key,
    queryFn: async ({ pageParam = baseUrl }) => {
      const res = await axios.get(pageParam);
      return res.data;
    },
    initialPageParam: baseUrl,
    getNextPageParam: (lastPage) => {
      return lastPage.next || undefined;
    },
  });

 

queryFn : 데이터 페칭 비동기 함수

useInfiniteQuery 훅에서 queryFn의 동작방식은 pageParam 이라는 매개변수를 전달받은 axios.get 요청을 통해 서버로 부터 초기 혹은 추가데이터를 응답받고, 이를 Response 객체의 참조변수인 res에 접근하여 실제 데이터인 data 를 반환하도록 하고 있습니다.

 

여기서 특이한 점은 baseUrl 을 초기 요청을 위한 기본값 매개변수로 사용하고 있는데, 이는 initalPageParam과 동일한 값을 가지지만 알 수 없는 에러로 인해 발생할 수 있는 문제를 미연에 방지하기 위해서 입니다.

 

getNextPageParam: 다음 페이지 사전 요청을 위한 매개변수

getNextPageparam 은 현재 페이지의 다음 페이지를 나타내는 매개변수를 반환하는 메소드입니다. 매개변수로 마지막 페이지에 대한 data 가 담겨 있고, 해당 데이터가 다음 페이지 데이터에 대한 baseUrl 정보를 가지고 있는 경우에는 해당 baseUrl를 return 하고 그렇지 않다면, undefined 를 반환하게 처리하였습니다.

 

참고로 .next 에 담긴 값이 다음 페이지 요청에 대한 baseUrl 인 것은 제가 백엔드에서 .next 프로퍼티의 값으로 다음 페이지 요청에 필요한 baseUrl 정보를 담아서 응답해주었기 때문입니다. 예를 들어, 다음 페이지 요청에 대한 방식이 

따라서 해당 인티니티 훅에서 제가 만든 프로퍼티에 접근하여 다음 페이지에 대한 정보를 얻어서 반환할 수 있는 것입니다.

 

바로 아래와 같

백엔드에서 다음 페이지에 대한 요청 정보인 next 변수를 res 객체에 담아서 보내고 있는 모습

 

받아온 data 와 ...props 에 대한 처리

  const items = data?.pages.map((pageData) => {
    return pageData.items;
  });

  const totalCount = data?.pages[0].totalCount || 0;
  const concatItems = items ? [].concat(...items) : [];

  return { items: concatItems, totalCount, ...props };

 

앞서 인피티니 훅은  const { data, ...props } = useInfiniteQuery({ ... })  형식으로 data와 ...props 을 객체 리터럴 형식으로 반환해주고 있습니다. data 는 응답 데이터를, ...props 은 useInfiniteQuery 에서 제공해주는 error, isFetching, isPending,... 등의 부수 처리를 위한 값들이 할당된 변수들이 담겨져 있습니다.

 

사실 실제 서버에서 받아온 데이터는 data.pages 에 담겨져 있기 때문에 각각의 페이지에 대한 데이터가 중첩 배열 형태로 저장되는 것을 필요에 맞게 맵핑하기 위해 data.pages.map() 함수를 사용하였습니다.

data 을 출력한 로그

 

 

이를 통해 필요한 items 데이터만 별도로 반환받아 items 배열에 담아 주고 있습니다. 그러나 이 상태로는 각 목록들이 중첩된 배열형태로 존재하기 때문에, 실제 무한 스크롤 형식으로 목록을 렌더링 하기 위해서는 이들 items 내의 중첩된 배열 요소들을 하나의 1차원적인 배열 형태로 합쳐줄 필요가 있습니다.

items 변수만 출력한 경우의 로그, 중첩된 배열의 형태로 이대로 사용하기 어려워 보인다.

 

따라서 반환된 items 배열을 하나의 배열로 합쳐주기 위해 [].concat(...items) 를 사용하였습니다. 참고로 사용된 .concat 메소드는 접근한 배열에 인자로 전달한 배열을 합쳐주는 메소드입니다. ...items 에서 ...전개연산자 로서 쉽게 말하면 객체의 껍질은 한 차례 벗겨주는 역할을 한다고 보면 됩니다. 예를 들어, [1,2,3]의 경우 ...[1,2,3] => 1,2,3 이 되는 것과 같습니다. 이 원리를 이용하면 [...[1,2,3], ...[4,5,6]]  => [1,2,3,4,5,6] 이 되는 것입니다.

concatItems 변수를 출력한 로그, 모든 중첩배열들이 하나로 합쳐졌기에 목록 렌더링에 활용하기 수월해 졌다.

 

useIntersection 커스텀 훅 생성

이번에는 스크롤의 끝지점을 관찰하여 교차점에 들어오면 true 를 교차점을 벗어나면 false 를 반환하는 커스텀 훅을 만들어야 합니다. 우선 구현된 전체 코드를 살펴보면 다음과 같습니다. 

import { useEffect, useState } from 'react';

/**
 * * 참조하는 요소의 경계가 끝 지점에 도달하였는지 체크하는 커스텀 훅
 * @param ref  참조할 DOM 요소의 인스턴스
 * @returns true or false
 */
export default function useIntersection(ref: React.RefObject<any>) {
  const [isEnd, setIsEnd] = useState(false);

  const options = {
    threshold: 0.7,
    root: null,
  };
  const obsever = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) return setIsEnd(true);
      else setIsEnd(false);
    });
  }, options);

  useEffect(() => {
    if (!ref.current) return;
    const viewTarget = ref.current;
    obsever.observe(viewTarget);

    return () => {
      obsever.disconnect();
    };
  });
  return { isEnd };
}

 

intersection api 에 대한 간략 설명

사실 해당 훅은 크게 설명할 것은 없습니다. 보다 자세한 내용은 여기를 (https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver) 참고 해보면, 쉽게 이해할 수 있습니다. 

 

IntersectionObserver 가 어떤 값들을 반환하고 활용하는지 간략하게만 보자면, new IntersectionObserver() 생성자를 호출하게 되면, observer 라는 변수에 아래와 같은 인스턴스를 반환해줍니다.

 

옵저버 인스턴스를 출력한 로그, options 로 설정한 정보와 해당 생성자 함수의 프로토타입을 통해 접근가능한 메소드 목록을 볼 수 있다.

 

여기서 new IntersectionObserver() 생성자의  첫 번째 매개변수는 콜백함수로서  .observe(element) 로 등록한 관찰 대상에 대한 특정 작업을 수행할 수 있도록 도와주는 함수입니다

TMI | entries 의 의미
해당 콜백함수는 매개변수로 entries 를 받는데, 영어의미 그대로 번역하면 참여자들 이라 이해할 수 있고 이해하기 쉽게 의역하면 관찰대상들 이라고 이해할 수 있을 것 같습니다.

 

 

두 번째 매개변수는 별도의 설정을 위한 option 을 전달할 수 있는 객체를 받습니다. 해당 객체는 아래와 같이 threshold 와 root를 전달하고 있습니다. 즉, 앞서 인스턴스의 반환값 중 thresholds 의 배열요소로 0.7 이 들어 있는 것을 볼 수 있는데, 해당 출력 예시에서 threshold가 0.7에 가까워 졌다는 것은 관찰대상이 되는 요소가 뷰포트와 교차되는 지점의 70% 위치에 근접해 있다는 의미와 같습니다. 

  const options = {
    threshold: 0.7,
    root: null,
  };

 

root 의 역할
참고로, root는 관찰 대상의 요소가 교차되는 지점을 측정할 때 기준이 되는 경계를 설정하는 옵션입니다. 별도로 지정하는 요소가 없으면 브라우저의 뷰포트를 기준으로 observe 에 등록한 요소를 관찰합니다.

 

관찰 대상 등록과 제거 | observe 는 등록, disconnect 는 연결 해제

현재 커스텀 훅은 매개변수로 ref 를 전달받고 있습니다. 해당 ref 는 관찰하고자 하는 대상이 되는 요소에 대한 참조로서  ref.current 로 접근하여 해당 요소에 접근할 수 있습니다.  그리고 이를  useEffect 내부에서 .observe 객체의 인수로 전달 해주면서 관찰 대상으로 등록합니다.

 

만일 현재의 관찰 대상이 사라진 다른 컴포넌트(페이지)에 있는 경우에는 .disconnet() 메소드를 호출하여 모든 관찰대상에 대한 연결을 끊어주는 동작까지 해주고 있습니다. 참고로 관찰 대상이 하나인 경우에는 .disconnect 대신 unobserve() 메소드를 사용하는 것이 좋아 보입니다. 저는 여기서는 일단 언 마운트 시 모든 관찰 대상을 해제하는 형식으로 가져 가겠습니다.

  useEffect(() => {
    if (!ref.current) return;
    const viewTarget = ref.current;
    obsever.observe(viewTarget);

    return () => {
      obsever.disconnect();
    };
  });

 

entries 순회와 isIntersecting 의 활용, 그리고 이를 이용한 상태 관리

생성자함수의 첫 번째 매개변수인 콜백함수를 호출 하는 경우 해당 함수의 매개변수는 entries 라는 관찰 대상들에 대한 정보가 담긴 배열이 담겨져 옵니다. 그리고 해당 배열을 순회하면, 각 관찰대상(entry)에 대한 부수처리를 수행할 수 있게되는데,  그 중 isIntersecting 은 root로 지정한 요소와의 교차 지점에 관찰대상이 지나가는 경우 true를 반환하고, 그렇지 않으면, false 를 반환해 줍니다.

  const [isEnd, setIsEnd] = useState(false);

  const options = {
    threshold: 0.7,
    root: null,
  };
  const obsever = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) return setIsEnd(true);
      else setIsEnd(false);
    });
  }, options);

 

여기서 저의 경우 threshold 를 0.7 로 지정하였습니다, 이는 root 요소(기본값은 뷰포트)의 70%가 지나는 지점에 관찰 대상이 교차되는 순간 true 를 반환하게 하여, 완전히 끝지점이 아닌 지점에서 새로운 데이터를 페칭하여 마치 사전로드가 되는 것처럼 자연스러운 데이터 페칭을 유도하기 위해서 입니다.

 

 

결론적으로, 위 로직은 교차지점에 들어오는 순간이 스크롤의 끝지점이라 가정하고 이를 상태로 저장하는 것이 주요 목적이자 기능입니다. 이렇게 설정된 isEnd 라는 상태를 커스텀 훅에서 반환하게 되면, 사용하고자 하는 위치에서 이 값의 변경에 따른 분기처리를 시도해주기만 하면 됩니다.

 

구현된 커스텀 훅의 적용

우선 아래 컴포넌트는 생략된 로직이 많습니다. 이해를 위해 필요한 로직만 붙여두었습니다. 앞서 만들었던, useIntersection 훅의 인자로 observerRef 를 전달해주고 있습니다. 해당 요소에 대한 관찰결과(boolean)가 담긴 isEnd 를 반환해주고 있고, isEnd 의 결과와 useInfiniteQuery에서 제공해주는 다음 페이지에 대한 정보가 있는지 유무를 나타내는 hasNextPage 변수를 사용하여 이 두 값이 모두 true 인 경우에만 다음 데이터를 가져오는 fetchNextPage() 메소드를 호출하도록 해주고 있습니다.

// LocalMarket.tsx
import { useEffect, useRef } from 'react';
import { useInfiniteScroll } from '../../hooks/useInfiniteScroll';
import useIntersection from '../../hooks/useIntersection';

export default function LocalMarketPage(){
  const observerRef = useRef<HTMLButtonElement>(null); // 관찰 대상에 대한 참조
  const { isEnd } = useIntersection(observerRef); // 끝 지점을 측정하는 커스텀 훅
  const { items, totalCount, isFetching, hasNextPage, fetchNextPage } = useInfiniteScroll(
    `/localmarkets?region=${region}&page=`,
    'localmarket',
    `${region}`
  ); // 데이터 페칭

  // 끝지점에 도달하고 다음 페이지가 존재하는 경우에만 다음 페이지 데이터 페칭
  async function nextPageHanlder(isEnd: boolean) {
    isEnd ? hasNextPage ? fetchNextPage() : null : null
  }

  useEffect(() => {
    nextPageHanlder(isEnd);
  }, [isEnd]);

  return (
    <section className={styles.localmarket_page_container}>
      <div className={styles.localmarket_page_inner_boundaray}>
        <LocalMarketList localmarkets={items} /> // 리스트
        
        {/*관찰대상*/}
        <button className={styles.scroll_pointer} ref={observerRef} aria-hidden={'true'}></button> 
     </div>
    </section>
  );
};

 

부연 설명
참고로 해당 컴포넌트에서 저 로직 그대로 사용하는 경우에는 불필요한 추가 렌더링이 발생하는 문제가 있기 때문에 이를 처리하기 위한 별도의 로직들을 추가하여 사용하고 있습니다. 즉, 원본 로직이 아님을 참고하시면 좋을 것 같습니다.

구현결과

※ 영상이 일시적으로 보이지 않는다면 새로고침 하면 대부분 해결이 됩니다.

 

구현을 통해 느낀점

낯설었기에 고생한 부분

리액트 쿼리를 이용해서 무한 스크롤을 구현한 것은 해당 프로젝트가 처음이었습니다. 기능 구현에 필요한 도구도 많고, 공유한 코드도 많지만, 프로젝트마다 상황이 다르기 때문에, 제가 만들고 있는 프로젝트의 상황에 맞게 로직을 만들어가는 것은 생각보다 쉬운 일은 아니었던 것 같습니다. 특히 백엔드에서 nextPage 에 대한 정보(즉, 커서 역할을 하는 정보)를 만들고 이를 응답받아서 릴레이 식으로 데이터를 페칭해오는 방식은 처음 구현시 에는 매우 헷갈렸던 부분이라 고생 했었습니다(의도한 다음 페이지 정보를 넘겨주지 못하여 무한 스크롤이 안 되는 문제 등).

 

그럼에도 익숙해지면 타 방식에 비해 좋은 점

그럼에도 해당 기능을 구현하게 되면서, 무한 스크롤과 캐싱 기능이 서로 결합이 되었을 때 얼마나 효율적인 데이터 처리가 가능한지 많이 느꼈습니다. 그 이유 중 하나는 제가 리액트 쿼리를 사용한 무한 스크롤과 일반적인 인터섹션 옵저버만을 사용한 무한 스크롤 두 가지를 함께 구현했기 때문이고, 인터섹션 옵저버만을 사용한 구현의 경우에는 받아온 데이터를 캐싱처리 하기 위해 별도의 로직을 추가로 필요했기에 신경 쓸 부분이 많았다는 점 입니다. 또한, 리액트 쿼리의 경우 데이터 페칭 실패, 성공, 보류, 에러 등에 대한 다양한 도구들을 지원하고 있기 때문에 이 부분을 직접 구현하여 사용해야 했던 점까지 비교 했을 때, 개발 편의성과 유지보수성이 크게 향상되는 것을 많이 느꼈습니다.

 

 

[무한 스크롤 버전 2] 인터섹션 옵저버만을 이용한 무한 스크롤

버전 1과 2 두 가지가 있는 이유

아마 이 부분이 궁금하실 수도 있을 것 같아서 언급합니다. 사실 처음 무한스크롤을 구현할 때는 무한 스크롤 1 버전으로 모든 로직에서 재사용하려고 했습니다. 하지만, 공공 데이터 포털의 api 를 사용 시 트래픽 호출 제한이 1000 건으로 매우 적기 때문에 이를 효율적으로 처리하기 위해서 1번의 GET 요청에 필요한 데이터 모두를 받아오고, 이를 분절하여 로드하는 방식을 선택하였습니다. 따라서 각 요청 마다 새로운 데이터를 받아오는 방식이 아니므로 리액트 쿼리를 사용할 필요성이 없었고, 앞서 만들어둔 useIntersecion 커스텀 훅을 재사용하여 무한 스크롤 버전2 를 구현하게 되었습니다.

 

구현된 전체 로직

우선 구현된 전체 로직은 아래와 같습니다. 이에 대한 주요 로직을 다음 챕터로 넘어가서 설명해 봅니다. 

import useIntersection from '@/hooks/useIntersection';
import ObserverSpinner from '@/components/Common/Spinner/ObserverSpinner';

export default function RecipeList({ recipes = [], totalCount, searchValue, category }: ResultType) {
  
  const [visibleRecipes, setVisibleRecipes] = useState<RecipeType[]>([]);// 사용자에게 보여지는 레시피 목록
  const observerRef = useRef<HTMLSpanElement>(null)// 스크롤 끝 지점 관찰 대상 참조
  const { isEnd } = useIntersection(observerRef)  // 스크롤 끝 지점 유무를 boolean 으로 반환하는 커스텀 훅
  const isLastRecipes = (totalCount>0 && totalCount == visibleRecipes.length)  // 마지막 레시피 인가?

  // 스크롤 처리 함수
  const handleScroll = (currentLength: number) => {
    if (isEnd && isLastRecipes) return toast.info('모든 데이터를 조회하였습니다.')

    if (isEnd && (totalCount > visibleRecipes.length)) {

      // 다음으로 보여줄 레시피가 있는가? 
      const nextRecipes = recipes?.slice(currentLength, currentLength + 10);
      const hasNextRecipe = nextRecipes && nextRecipes.length > 0

      // 있다면 기존 레시피에서 추가된 10개의 레시피를 추가하고, 총 갯수 캐시 갱신
      if (hasNextRecipe) {
        setVisibleRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
      }
    }
  };

  useEffect(() => {
    const currentLength = Number(sessionStorage.getItem('currentRecipes'));
    handleScroll(currentLength)
  }, [isEnd]);


// 스크롤의 끝지점에 도달하는 순간 현재 조회된 레시피 개수를 기억(memo: 이 값은 다음 레시피 목록을 불러오는 기준으로 처리)
  useEffect(() => {
    sessionStorage.setItem('currentRecipes', `${visibleRecipes.length}`);
  }, [visibleRecipes.length, isEnd])

  return (
    <>
      <div className={styles.recipe_list_container}>
        { visibleRecipes.length>0
        ? visibleRecipes.map((recipe) => (
          <RecipeCard key={recipe.RCP_SEQ} recipe={recipe} />
        ))
        : <p className={styles.replace_message}>현재 조회된 목록이 없습니다.  <br /><br />우측 상단에 검색된 레시피 개수가 보이는 경우에는 아래로 스크롤 하시면 목록이 표시됩니다.</p>}
      </div>
      {/* 로딩 시 스피너를 보여주면서, 스크롤의 끝지점을 관찰하는 대상 */}
      <ObserverSpinner ref={observerRef}>  </ObserverSpinner>
    </>
  );
}

 

다음으로 넘어가기 전 부연 설명
필요한 로직만 남겨두고 관련성이 떨어지는 부분은 모두 제거하였기에 완전히 같은 로직은 아닙니다. 해당 로직의 주요 흐름을 언급하면, 앞서 생성한 useIntersection 커스텀 훅이 반환하는 isEnd 의 상태를 활용해서 끝 지점에 도달하면 다음 데이터를 불러오는 것이 다입니다. 다만, 한 번의 데이터 페칭 시 최대 200개의 목록을 한도로 모두 받아오고 이를 slice 로 분리하여 페이지 단위로 렌더링하는 것이 앞서 무한 스크롤과의 차이 입니다. 

 

컴포넌트의 매개변수와 상태관리

현재 RecipeList 는 매개변수로 {recipes = [ ], }  를 받고 있습니다. recipes 는 Recipe 페이지의 루트에서 사용자가 Get 요청을 하면 해당 응답으로 받아온 레시피 목록이 담겨 있습니다.나머지 속성은 현재 구현에서 주요 포인트는 아니므로 생략하도록 하겠습니다.

export default function RecipeList({ recipes = [], totalCount, searchValue, category }: ResultType) {}

 

 

상태 관리를 위해서는 visibleRecipes 상태를 관리 useState () 훅을 사용하고 있습니다. 해당 변수에는 향후 매개변수로 받아온 recipes 에서 사용자에게 페이지 단위로 보여줄 목록만을 관리하게 되고, 이를  visibleRecipes.map() 을 사용하여 화면에 렌더링할 때 사용됩니다. useIntersecion 의 경우 스크롤의 끝 지점을 관찰하여 반환하는 훅이고, isLastRecipes 변수는 모든 레시피 목록을 렌더링하였는지를 관리해주는 변수입니다.

  const [visibleRecipes, setVisibleRecipes] = useState<RecipeType[]>([]);// 사용자에게 보여지는 레시피 목록
  const observerRef = useRef<HTMLSpanElement>(null)// 스크롤 끝 지점 관찰 대상 참조
  const { isEnd } = useIntersection(observerRef)  // 스크롤 끝 지점 유무를 boolean 으로 반환하는 커스텀 훅
  const isLastRecipes = (totalCount>0 && totalCount == visibleRecipes.length)  // 마지막 레시피 인가?

 

무한 스크롤을 구현한 handScroll( )

해당 함수는 현재 List 컴포넌트에서 실질적인 무한 스크롤을 처리하는 함수입니다. 매개변수로 currentLength 라고 하여 현재 렌더링되어 있는 목록의 길이를 담고 있는 변수를 받습니다. 그리고 이 변수를 이용하여 recipes 에 담겨 있는 전체 목록 중 visibleRecipes 상태의 업데이트에 반영할 일부 목록을 복사하는데 사용됩니다. 각 로직에 대한 설명은 아래 하단에서 언급해보도록 하겠습니다.

  // 스크롤 처리 함수
  const handleScroll = (currentLength: number) => {
    if (isEnd && isLastRecipes) return toast.info('모든 데이터를 조회하였습니다.')

    if (isEnd && (totalCount > visibleRecipes.length)) {

      // 다음으로 보여줄 레시피가 있는가? 
      const nextRecipes = recipes?.slice(currentLength, currentLength + 10);
      const hasNextRecipe = nextRecipes && nextRecipes.length > 0

      // 있다면 기존 레시피에서 추가된 10개의 레시피를 추가하고, 총 갯수 캐시 갱신
      if (hasNextRecipe) {
        setVisibleRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
      }
    }
  };

 

끝 지점이고, 렌더링할 목록이 더 이상 없으면, 이른 반환

우선 해당 함수의 내부 로직을 하나씩 살펴보면 모든 목록을 렌더링한 경우에는 더 이상 추가적인 로직을 처리할 필요가 없기 때문에 이른 반환을 통해서 탈출하는 로직을 함수의 최상단에 입력해주었습니다. isLastRecipes 는 앞서 살펴본 변수의 초기화 부분에서  const isLastRecipes = (totalCount>0 && totalCount == visibleRecipes.length)  // 마지막 레시피 인가? 를 관리하는 변수입니다.

 if (isEnd && isLastRecipes) return toast.info('모든 데이터를 조회하였습니다.')
부연설명
totalCount : Recipe 페이지에서 Get 요청으로 받아온 모든 레시피 목록의 개수
visibleRecipes.length : 현재 사용자에게 보여지고 있는 레시피 목록의 개수

 

스크롤의 끝지점인데, 아직 렌더링할 목록이 남아있는 경우의 조건 처리

그 다음 조건처리로 스크롤은 끝지점에 도달하였으나, totalCount(Get 요청으로 받아온 전체 레시피 목록의 개수)가 visibleRecipes.length(현재 사용자에게 보여지고 있는 렌더링된 레시피 목록의 개수) 보다 큰 경우에는 아직 조회할 목록이 남아 있기 때문에 그 내부의 로직을 실행하도록 처리 하였습니다.

  if (isEnd && (totalCount > visibleRecipes.length)) { }

 

주요 로직 설명

앞서 살펴본 해당 조건문 내부의 로직을 나름의 이해를 위해서 상세하게 설명하고 넘어가겠습니다. 

  if (isEnd && (totalCount > visibleRecipes.length)) { 
	  // 다음으로 보여줄 레시피가 있는가? 
      const nextRecipes = recipes?.slice(currentLength, currentLength + 10);
      const hasNextRecipe = nextRecipes && nextRecipes.length > 0

      // 있다면 기존 레시피에서 추가된 10개의 레시피를 추가하고, 총 갯수 캐시 갱신
      if (hasNextRecipe) {
        setVisibleRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
      }
 }

 

nextRecipes

우선 nextRecipes 라는 상수를 할당하는 변수는 다음으로 렌더링할 레시피 목록을 할당받는 변수입니다.

 

recipes 의 경우에는 Get 요청으로 받아온 전체 레시피 목록으로 .slice 메소드를 사용하면 첫 번째 인자에 지정한 인덱스 부터, 두 번째 인자로 전달한 인덱스-1 에 해당하는 배열의 요소를 복사하여 배열 형태로 반환해줍니다.

 

그리고 이를 nextRecipes 변수가 할당받고 있습니다.

const nextRecipes = recipes?.slice(currentLength, currentLength + 10);

 

hasNextRecipe

hasNextRecipe 변수는 다음 레시피가 있는지에 따른 boolean 값을 할당받는 변수입니다. 앞서 구한 nextRecipes 가 존재하는 경우에는 true 로 평가되고 && 연산자를 통해 우측 표현식에 대한 평가로 넘어가게 되는데,

 

이 때 nextRecipes.length 가 0 보다 큰 경우에는 다음 페이지가 존재한다는 것을 의미하므로 true 를 받게 되고, 반대로 0보다 작거나 같은 경우에는 빈 배열을 의미하기 때문에 false 를 할당받게 됩니다.

const hasNextRecipe = nextRecipes && nextRecipes.length > 0

 

if(hasNextRecipe){...  }

이렇게 다음 레시피 목록이 있다는 사실을 hasNextRecipe 라는 변수를 통해서 알게 되었다면, if(hasNextRecipe){} 의 내부 로직을 실행하게 됩니다. 여기서는 이전 단계에서 레시피 목록을 nextRecipes 에 담아두었기 때문에, 이를 기존의 visibleRecipes 상태에 저장되어 있던 레시피 목록에 추가적으로 새로운 목록을 결합하여 하나의 배열 형태로 만들어주기 위해  setVisibleRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]); 을 사용하였습니다.

      // 있다면 기존 레시피에서 추가된 10개의 레시피를 추가하고, 총 갯수 캐시 갱신
      if (hasNextRecipe) {
        setVisibleRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
      }

ㄴ setState의 첫 번째 인자로 콜백함수를 전달하게 되면, 해당 함수의 첫 번째 매개변수로 이전 상태에 대한 값(-> 여기서는 이전 상태에 저장된 배열이 되겠습니다)을 전달받게 되고, 이를 [...prev, ...next] 로 표현하게 되면, 이전 상태와 다음 상태가 결합된 새로운 상태가 visibleRecipes 에 할당되게 됩니다.

 

useEffect 내에서 handleScroll 함수호출 및 현재 무한 스크롤 기능의 핵심 원리

앞서 구현한 함수를 useEffect의 콜백함수 내부에서 호출해줍니다. 여기서 추가된 useEffect 는 두 가지가 있는데, 하나는 handleScroll 함수를 호출하고, 다른 하나는 현재 조회중인 레시피의 개수를 관찰하여 sessionStorage 를 사용해 캐싱처리하고 있습니다.

  useEffect(() => {
    const currentLength = Number(sessionStorage.getItem('currentRecipes'));
    handleScroll(currentLength)
  }, [isEnd]);


// 스크롤의 끝지점에 도달하는 순간 현재 조회된 레시피 개수를 기억(memo: 이 값은 다음 레시피 목록을 불러오는 기준으로 처리)
  useEffect(() => {
    sessionStorage.setItem('currentRecipes', `${visibleRecipes.length}`);
  }, [visibleRecipes.length, isEnd])

 

 

캐싱처리 로직을 다시 살펴보면, currnetRecipes 라는 키에 현재 사용자에게 보여지고 있는 목록의 길이(visibleRecipes.length)를 저장하고 있는데, 이를 앞서 handleScroll 함수를 호출하고 있는 useEffect 내부에서 .getItem 으로 불러온 뒤 재사용하고 있는 것을 확인할 수 있습니다.

  useEffect(() => {
    // 현재 보여지고 있는 목록의 개수를 세션 스토로지를 사용하여 캐싱한다.
    sessionStorage.setItem('currentRecipes', `${visibleRecipes.length}`);
  }, [visibleRecipes.length, isEnd])

 

  useEffect(() => {
    // 현재 렌더링된 목록의 길이를 읽어온다
    const currentLength = Number(sessionStorage.getItem('currentRecipes'));
    // 불러온 목록의 길이를 기반으로 무한 스크롤 로직을 구현한다.
    handleScroll(currentLength)
  }, [isEnd]);

 

즉, 이렇게 매번 추적된 현재 목록의 길이를 기반으로 전체 레시피 목록인 recipes 를 slice 로 효율적으로 복사하여 렌더링할 수 있게 된 것입니다. 해당 길이가 정상적으로 캐싱되고, 재사용되지 않는다면 사실상 무한 스크롤의 주요 로직을 실행할 수 업기 때문에 해당 기능 구현의 핵심적인 로직이라 할 수 있습니다.

 

구현결과

정상적으로 조회되는 것을 확인할 수 있었습니다.

 

 

구현 소감

캐싱 처리를 위해 구현한 로직이 이상하게 동작하기도

해당 기능의 경우 리액트 쿼리와 같은 캐싱 라이브러리와 다르게 순수한 axios 요청으로 필요한 데이터 목록을 모두 Get 요청으로 받아오고, 이를 한번에 렌더링하는 것이 아니라 일부 목록만을 우선 보여주고 사용자가 스크롤 끝에 도달했을 경우 추가 목록을 불러오는 기능이었습니다.

 

따라서 이전 목록에 대한 별도의 정보를 캐싱하여 반환해주는 로직이 주어지지 않기 때문에, 필요에 따라 직접 구현해야 했고, 이를 구현한 것 중에 하나가 아래 session 스토로지를 활용한 캐싱처리 였습니다.

 

이 로직 자체만 본다면 단순함 그 자체이지만, 사실 해당 로직을 사용해서 캐싱된 현재 목록 길이를 .getItem 을 통해 불어올 때 예기치 못한 문제가 발생했습니다.  바로 업데이트된 목록이 아니라 과거 목록의 길이를 계속 반환해주는 문제였고, 무한 스크롤이 정상 동작하지 않는 문제를 경험했습니다.

 

다행히 해당 문제가 List 컴포넌트 내부가 아니라 recipe 데이터를 Get 요청하는 루트 컴포넌트 내에서 초기 레시피 목록의 현재 길이를 설정하는 일부 로직상의 문제로 발생한 것임을 확인할 수 있었고, 그 문제의 해결은 그리 오랜 시간이 걸리지 않았기에 다행이었던 순간이 있었습니다.

 

한 순간 식겁했던 경험이긴 했지만, 이 문제를 해결하는 과정에서 상위 컴포넌트에서 하위 컴포넌트의 초기 상태를 결정하는 경우, 잘못된 참조가 예기치 못한 문제를 발생 시킬 수 있음을 인지할 수 있었고, 최대한 부모 컴포넌트와 자식 컴포넌트 간에 상태 관리를 최대한 독립적으로 처리하여 컴포넌트 간의 의존성을 최대한 낮출 수 있으면 그래야 함을 느끼는 계기가 되었습니다.

 

이미지 스트라이프 기법을 적용한 카테고리(분류) 기능(정리예정)

반응형