[TIL] 무한스크롤 정복기 (useInfinitieQuery with supabase)

·2024년 1월 31일
2

TIL

목록 보기
82/85
post-thumbnail

코드 리팩토링의 필요성

기존에 장소 검색 페이지에서 무한 스크롤을 구현 했었으나,
react-query의 useInfiniteQuery를 사용하지 않고, 조잡한(?) 방식으로 처리하고 있었다.
그러다 보니 코드 가독성도 매우 떨어지고, 중간 중간 오작동 하는 경우에도 원인을 찾기가 쉽지 않았다.

그래서 결국 로직을 대대적으로 뜯어고쳐서 리팩토링 하기로 마음을 먹었다.(?)
내가 버린 쓰레기는 내가 치우자(?) 🧹

물론 처음부터 useInfiniteQuery를 시도하지 않은 건 아니었다.
우리의 장소 데이터 들은 supabase의 places 테이블에 12000여개가 저장 되어있는데,
supabase의 데이터들을 20개씩 끊어서 무한스크롤을 구현한다고 할 때,
useInfiniteQuery의 getNextPageParam 함수를 어떻게 써야 할지 난감해서 포기했던 것이었다.
왜냐하면 검색 결과 값을 range(0, 20) 메서드를 통해 20개씩 끊어서 가져오는데, 이 정보 만으로는 total_page 등으로 다음 페이지를 계산하여 pageParam 을 어떻게 넘겨주어야 할 지가 감이 오지 않았다.

검색을 열심히 해도 supabase 데이터들을 useInfiniteQuery를 사용하여 무한 스크롤을 구현한 사례는 찾기가 어려웠다..😇

트러블 슈팅

언제나 정답은 Docs에 있다.

우선 firebase에는 쿼리 커서로 페이지화 라는 것이 있다.

[파이어베이스 공식문서]
👉 파이어베이스 공식문서

이를 통해 페이지네이션이나 무한스크롤을 구현 할 수 있는 걸로 알고 있는데,,
supabase에는 비슷한 게 없을까 Docs를 뒤져보았다.

(일단 내가 구현한 방법이 정답은 아닐 수 있다. 이렇게 한 사람도 있다고 참고만 하시길..)

[수파베이스 공식문서]
👉 https://supabase.com/docs/reference/javascript/explain
👉 https://supabase.com/docs/guides/database/debugging-performance

supabse에 있는 explain 이라는 메서드를 발견했다.

이를 잘 활용하면 쿼리 결과의 rows 수를 알 수 있을 것 같았다.
우선, explain() 메서드는 데이터베이스 구조 및 작업에 대한 중요한 정보를 보호하기 위해 기본적으로 비활성화되어 있다.

이를 해제하려면 supabase의 SQL Editer 에 다음과 같은 함수를 실행시키자.

-- enable explain
alter role authenticator 
set pgrst.db_plan_enabled to 'true';

-- reload the config
notify pgrst, 'reload config';

이제 explain() 메서드를 통해 쿼리 실행 계획을 얻을 수 있다!

  const { data: explainData, error } = await supabase
    .rpc('search_places', {
      p_search_value: searchValue,
    })
    .explain({ format: 'json', analyze: true });

기존에 만들어 둔 rpc() 함수의 쿼리 실행 계획을 얻기 위해 explain() 메서드를 사용하였는데, 그냥 explain() 만 실행하면 text 형식으로 가져온다. 이는... 써먹을 수가 없기에 format: 'json 라는 옵션을 추가해야 json 형식으로 받아올 수 있다.

콘솔에 찍어보면 이런 결과가 나온다.
다.. 필요없고 나는 Actual Rows 부분만 필요했다. 이 숫자가 검색 결과 숫자를 의미했다.

그래서 이런 식으로 가공을 했다.

 if (Array.isArray(explainData)) {
    result = explainData
      .map((item) => item.Plan)
      .map((item: any) => item.Plans)
      .map((item) => item[0])
      .map((item) => item)[0];
  }

그리고 이 값을 바탕으로 새로운 객체를 생성해서 쿼리함수에서 리턴을 하였다.

🔥 쉬운 내용인데 나만 몰랐음 주의 🔥
나는 Actual Rows 값이 필요한데.. 객체에 접근할 때 result.ActualRows 이런 식으로만 객체의 프로퍼티에 접근을 했었지, 사이에 공백이 있는 경우에 어떤 식으로 접근해야 하는지 몰랐다..ㅎ
result.Actul Rows 이런 식으로 쓰면 당연히 안된다.
정답은 배열 인덱스처럼 쓰면 되는 것이다.
result['Actual Rows'] 이렇게 쓰면 공백이 있는 경우에도 참조할 수 있다.

다음과 같이 쿼리함수를 작성한다.

export const fetchPlacesData = async ({
  pageParam = 1,
  queryKey,
}: {
  pageParam?: number;
  queryKey: (string | string[])[];
}) => {
  const [_, searchValue, selected] = queryKey;
  let result;
  const { data: explainData, error } = await supabase
    .rpc('search_places', {
      p_search_value: searchValue,
    })
    .explain({ format: 'json', analyze: true });

  console.log('explainData', explainData);

  if (Array.isArray(explainData)) {
    result = explainData
      .map((item) => item.Plan)
      .map((item: any) => item.Plans)
      .map((item) => item[0])
      .map((item) => item)[0];
  }
  let query = supabase.rpc('search_places', {
    p_search_value: searchValue,
  });

  if (selected) {
    // selected가 배열이면 forEach 실행
    if (Array.isArray(selected)) {
      selected.forEach((select) => {
        query = query.in(select, [true]);
      });
    } else {
      // selected가 배열이 아니면 단일 값으로 처리
      query = query.in(selected, [true]);
    }
  }

  const { data } = await query.range((pageParam - 1) * 21, pageParam * 21 - 1);

  // 가공을 해서 넘긴다
  const resultPlaces = {
    total_length: result['Actual Rows'], // 검색 결과 숫자 (ex. 176)
    data: data, // 결과들이 담긴 array
    page: pageParam, // 1부터 시작
    total_pages: Math.ceil(result['Actual Rows'] / 20), // 토탈페이지 구하기 위함
  };

  if (resultPlaces) return resultPlaces;
};

다음과 같이 useInfiniteQuery 를 작성한다.

const {
    data: places,
    hasNextPage,
    fetchNextPage,
    refetch,
  } = useInfiniteQuery({
    queryKey: ['places', realSearch, selected], // ⭐️⭐️⭐️
    queryFn: fetchPlacesData,
    initialPageParam: currentPage, // 초기 페이지 값 설정
    getNextPageParam: (lastPage) => {
      if (lastPage) {
        if (lastPage?.page < lastPage?.total_pages) {
          return lastPage.page + 1;
        }
      }
    },
    select: (data) => {
      return data.pages.map((pageData) => pageData?.data).flat();
    },
  });

select로 필요한 값만을 가공하여 data들만 들어있게 만든다.
초기에는 쿼리키를 queryKey: ['places', searchValue, selected] 와 같이 설정하였었다.
쿼리키를 저렇게 설정한 이유는,
input 태그가 searchValue 라는 state와 연결되어 있어서, searchValue가 바뀔때마다, 쿼리키가 바뀌는 것이므로, 그 때마다 쿼리함수가 트리거 되었다.
이는 의도한 동작이 아니었다.

검색 버튼을 클릭 했을 때에만 쿼리함수를 실행시키고 싶었다. 그래서 state를 하나 더 만들게 되었다.

const searchValue = useSelector((state: RootState) => state.searchValue);
const [realSearch, setRealSearch] = useState(searchValue);
// 생략
const handleClickSearch = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setRealSearch(searchValue);
  };

realSearch의 초기값을 searchValue로 설정해주고,
검색버튼을 클릭했을 때 setRealSearch 값을 searchValue 값으로 업데이트 한다!!
그러면 검색버튼을 클릭했을 때 realSearch의 상태 값이 검색 값으로 바뀌게 되고, 쿼리함수가 트리거된다~~!

오늘 새로운 기능을 추가한 건 아니지만,
대대적인(?) 리팩토링을 시도하고 성공했다는 점이 매우매우 뿌듯하다..!!!!!!
이를 바탕으로 장소 검색 페이지를 더 다듬어봐야 겠다.!!
기능 구현했다고 땡이 아니라 유지보수하기 좋고 가독성 좋은 코드로 리팩토링 하는 과정이 반드시 필요하다는 것을 뼈저리게 느낀 하루다.🔥

profile
느리더라도 조금씩, 꾸준히

0개의 댓글