[React] 팀프로젝트에서 무한스크롤을 구현하는데 'Encountered two children with the same key' 에러가 뜬다

선영·2023년 1월 23일
1

📚 Library

목록 보기
6/14
post-thumbnail

🧨 문제


진행하고 있는 팀프로젝트에서 useInfiniteQuery와 IntersectionObserver API를 사용하여 스크롤이 바닥을 찍을때마다 특정 id값으로 서버데이터를 요청하는 로직을 구현해놓았다. 그런데 어느순간부터 홈화면에서 아무것도 하지 않았는데 위와 같은 에러가 끝없이 뜨기 시작하고, 스크롤링하지도 않았는데 서버데이터를 5개씩 받아와서 이어붙인 뒤 스토어객체에 저장하는 로직이 일련의 과정이 실행되어 홈화면에서 렌더링되는 게시글이 무한증식되어 서버데이터를 한 번에 받아오지 않고 5개씩 받아오는 무한스크롤을 구현한 의미가 없어졌다.

문제를 파악하는데 앞서 무한스크롤을 구현한 과정을 설명하려한다.

🤔 구현과정


무한스크롤을 구현하는 이유는 서버데이터가 많은 경우 한 번에 서버데이터를 모두 들고오는게 성능상 비효율적이기 때문에 5개씩 요청해서 클라이언트에 캐시해두는 방식을 활용하려 했다.

처음엔 IntersectionObserver API만을 사용하여 아래와 같이 무한스크롤 훅을 구현하였다. useRef로 DOM의 요소중 ul를 가져와서 자식으로 포함된 list노드 배열중에 마지막 노드를 관찰하고 있다가 뷰포인트안에 들어오면, useMutation함수가 리턴하는 객체의 mutate메서드를 실행하여 서버데이터를 요청하는 로직을 수행한다. 이 로직의 단점은 한 번은 잘 실행되지만 그 다음부터 계속 같은 값의 서버데이터를 가져와서 앞전에 언급한 Encountered two children with the same key에러를 유발했다. 이유는 현재 목록의 마지막 객체의 아이디값이 갱신되지 않았기 때문이다. 때문에 다른 방법을 모색해야했다.

// ...중략
// 아래는 MeetingList컴포넌트에서 사용되는 훅이다.
export default function useInfiniteScroll(currMeetingList: Meeting[]) {
  const dispatch = useDispatch();

  const listRef = useRef<HTMLUListElement>(null);

  const pageSize = useRef<number>(currMeetingList.length);

  const nextMeetings = useMutation({
    mutationFn: getNextMeetings,
    onSuccess: (data) => {
      const meetingList = data?.data;
      dispatch(addMeetingList({ meetingList }));
    },
  });

  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const lastMeetingId = currMeetingList[pageSize.current - 1].id;
          nextMeetings.mutate(lastMeetingId);

          // observer.unobserve(entry.target);
        }
      });
    },
    []
  );

  useEffect(() => {
    if (!listRef.current) {
      return;
    }

    const observer = new IntersectionObserver(callback);

    const nodesListArr = listRef.current?.querySelectorAll('li');
    const lastNode = nodesListArr[nodesListArr.length - 1];

    observer.observe(lastNode);
  }, []);

  return { listRef };
}

이유는 현재 리스트아이템의 마지막 노드를 관찰하는 것은 MeetingList컴포넌트가 렌더링된 후 실행된다. 때문에 불러온 서버데이터로 dispatch까지 성공하더라도 페이지가 자동으로 새로고침되지 않기 때문에(또한, 새로고침되더라도 스토어 객체가 초기화되기 때문에) 한 번만 로직이 실행될 수 밖에 없었다.

그래서 무한 스크롤 적용 일기를 참고하여 코드를 아래와 같이 리팩터링 했다. 컴포넌트 렌더링 이례 갱신되지 않을 마지막 list노드 대신 <div ref={intersectRef}></div>를 관찰한다. 즉, 스크롤이 마지막에 닿으면 자동으로 다음 데이터를 요청하는 것이다.

// ...중략
// 이어질 두 개의 훅을 활용한 MeetingList컴포넌트이다.
type ListItemsProps = {
  currMeetingList: Meeting[];
};

export default function MeetingList({ currMeetingList }: ListItemsProps) {
  const sortbyKeyword = loadItem('keyword');

  const { onIntersect } = useInfiniteScroll(currMeetingList);
  const intersectRef = useIntersect(onIntersect);

  return (
      <ul>
        {currMeetingList.map((meeting) => (
          <li key={meeting.id}>
            <ListContent currMeeting={meeting} />
            {sortbyKeyword === 'calendar' ? null : <ButtonContent currMeeting={meeting} />}
          </li>
        ))}
        <div ref={intersectRef}></div> // 이 부분을 관찰한다.
      </ul>
  );
}

그 외에 로직이 길어져서 화면이 교차될 때 실행되는 로직과 서버데이터를 불러오는 로직의 훅을 각각 분리했다. 이 과정에서 IntersectionObserver API뿐만 아니라 프로젝트에서 사용중인 react-query의 useInfiniteQuery API도 사용해주었다.

// ...중략
// 아래는 화면이 교차될 때 실행되는 onIntersect함수를 수행하는 로직이다.
export default function useInfiniteScroll(currMeetingList: Meeting[]) {
  const dispatch = useDispatch();

  const keyword = loadItem('keyword');
  const pageId = useRef<number>(1);

  const { fetchNextPage } = useInfiniteQuery(
    ['nextMeetings'],
    ({
      pageParam = keyword === 'popular'
        ? pageId.current
        : currMeetingList[currMeetingList.length - 1].id,
    }) =>
      getNextMeetings({
        meetingId: pageParam,
        keyword,
      }),
    {
      getNextPageParam: (lastPage) => {
        const lastMeetingList = lastPage.data.meetingList;

        return keyword === 'popular'
          ? pageId.current
          : lastMeetingList[lastMeetingList.length - 1].id;
      },
      onSuccess: (data) => {
        const nextMeetingList = data.pages[data.pages.length - 1].data.meetingList;
        dispatch(addMeetingList(nextMeetingList));
      },
    }
  );

  const onIntersect = useCallback(
    (entry: IntersectionObserverEntry, observer: IntersectionObserver): void => {
      pageId.current = pageId.current + 1;
      fetchNextPage();
    },
    []
  );

  return { onIntersect };
}
// ...중략
// 아래는 화면이 교차되는지 판단하고, onIntersect함수를 호출하는 로직이다.
export default function useIntersect(
  onIntersect: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void
) {
  const listRef = useRef<HTMLDivElement>(null);

  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void => {
      entries.forEach((entry: IntersectionObserverEntry) => {
        if (entry.isIntersecting) {
          onIntersect(entry, observer);
        }
      });
    },
    []
  );

  useEffect(() => {
    if (!listRef.current) {
      return;
    }

    const observer = new IntersectionObserver(callback);
    observer.observe(listRef.current);
    return () => observer.disconnect();
  }, []);

  return listRef;
}

🤔 원인


문제로 다시 돌아와서 위에서 언급한 부분까지 구현을 마치니 무한스크롤은 잘 동작하였다. 다만 스크롤을 동작하지 않아도 데이터를 무한으로 즐길 수 있어서 문제였다.

그러나 문제해결은 생각보다 명확하고 간단했다. 왜냐면 에러가 너무 친절했다. Encountered two children with the same key자식에 대한 재귀적 처리에서 설명하듯, 리액트 key속성으로 인한 에러이다. 즉 중복된 key값이 들어오기 때문에 발생하는 에러이다.

때문에 디버깅이 비교적 간단했다.

🙌 해결과정


에러에서 언급되는 컴포넌트를 살펴보았다. 두 군데 모두 리액트 쿼리 API를 사용하는 부분이 있었다. 때문에 이 부분을 특정 동작(컴포넌트 최초 렌더링 혹은 스크롤이 바닥에 닿는 등)에만 작동할 수 있도록 React Query에서 버튼 클릭시에 데이터를 요청하는 방법 (feat. useQuery)을 참고하여 useQuery에 전달되는 객체에 enabled: false값을 주었고, 리턴하는 객체에서 refetch를 꺼내어 최초 렌더링시 한 번만 호출하게 하였다.

// ...중략
export default function HomePage() {
  const dispatch = useDispatch();

  const sortbyKeyword = loadItem('keyword');
  const { meetingList } = useSelector((state: HomeState) => state.home);

  const { data, isLoading, isError, refetch } = useQuery({
    queryKey: ['meetings'],
    queryFn: () => getSortbyMeetings(sortbyKeyword),
    enabled: false,
  });

  useEffect(() => {
    refetch();
    dispatch(setMeetingList(data));
  }, [data?.data.data.meetingList]);

  return (
    <>
      {isLoading && <div>로딩중 입니다...</div>}
      {isError && <div>에러가 발생...</div>}
      <TopNavBar name={'home'} />
      {sortbyKeyword === 'calendar' && <Calendar />}
      {meetingList && meetingList.length !== 0 && <MeetingList currMeetingList={meetingList} />}
    </>
  );
}

😎 결론


결과적으로 Encountered two children with the same key에러는 해결할 수 있었다. 다만 모임목록에 있어서 하위컴포넌트에서의 쿼리무효화가 먹히지 않는 이슈가 발생했다. 그래서 코드를 아래와 같이 리팩터링 했다. 전역상태객체를 이용하는 대신 받아오는 data를 바로 MeetingList컴포넌트로 넘겨주며, loadItem('keyword')을 변수에 할당하지 않고 바로 인자로 넘겨준다.

MeetingList에선 상위컴포넌트인 HomePage에서 넘겨받은 currMeetingList 그려주고, 또 따로 무한스크롤링에 의한 로직으로 받아오는 데이터인 nextMeetingList를 그려준다.

export default function HomePage() {
  const sortbyKeyword = loadItem('keyword');

  const { data } = useQuery({
    queryKey: ['meetings'],
    queryFn: () => getSortbyMeetings(loadItem('keyword')),
  });

  return (
    <>
      <TopNavBar name={'home'} />
      {sortbyKeyword === 'calendar' && <Calendar />}
      {data?.data.data.meetingList && data?.data.data.meetingList.length !== 0 && (
        <MeetingList currMeetingList={data?.data.data.meetingList} />
      )}
    </>
  );
}

최종적인 무한스크롤 로직은 아래와 같다. 이것도 최종이 아닐 순 있지만... onSuccess의 로직을 지우고, useInfiniteQuery가 반환하는 data를 맵으로 돌려서 [[],[],[]...] 형식이 되는 것을 flat메서드를 사용하여 하나의 배열로 만들어서 nextMeetingList로 반환한다. 그리고 위에서 말했듯, 이를 별개로 화면에 보여준다.

export default function useInfiniteScroll(currMeetingList: Meeting[]) {
  const keyword = loadItem('keyword');
  const pageId = useRef<number>(1);

  const { fetchNextPage, data } = useInfiniteQuery(
    ['nextMeetings'],
    ({
      pageParam = keyword === 'popular'
        ? pageId.current
        : currMeetingList[currMeetingList.length - 1].id,
    }) =>
      getNextMeetings({
        meetingId: pageParam,
        keyword,
      }),
    {
      getNextPageParam: (lastPage) => {
        const lastMeetingList = lastPage.data.meetingList;

        return keyword === 'popular'
          ? pageId.current
          : lastMeetingList.length !== 0 && lastMeetingList[lastMeetingList.length - 1].id;
      },
    }
  );

  const nextMeetingList = data?.pages.map((page) => page.data.meetingList).flat();

  const onIntersect = useCallback(
    (entry: IntersectionObserverEntry, observer: IntersectionObserver): void => {
      pageId.current = pageId.current + 1;
      fetchNextPage();
    },
    []
  );

  return { onIntersect, nextMeetingList };
}

HomePage의 하위컴포넌트인 카테고리 버튼은 버튼클릭시 쿼리무효화를 적용하여 캐싱하지 않은 데이터를 새로 불러오고, queryClient.resetQueries({ queryKey: ['nextMeetings'] });로 무한스크롤 데이터를 리셋하고, 새로 불러온 데이터를 리셋한다.

export default function SortbyCategories() {
  const queryClient = useQueryClient();

  const sortMeetings = useMutation({
    mutationFn: getSortbyMeetings,
    onSuccess: (data, variables) => {
      variables && saveItem('keyword', variables);
      queryClient.invalidateQueries({ queryKey: ['meetings'] });

      queryClient.resetQueries({ queryKey: ['nextMeetings'] });
      queryClient.resetQueries({ queryKey: ['meetings'] });
    },
  });

여기까지 하고 스웨거로 실제로 받아오는 서버데이터와 비교해봤을때 무한스크롤을하고 신규-인기 탭을 넘나들어도 데이터가 중복되지않고 초기화 되었다가, 신규-인기 값에 따른 무한스크롤 데이터를 잘 읽어들이는 것을 확인할 수 있었다.

☑️ 참고


profile
Superduper-India

0개의 댓글