목차


이슈 #1: 칸반보드 대용량 데이터 렌더링 성능 저하

1. 이슈 상황

항목 내용
발생 환경 VOC 조회 칸반보드에서 수백 건 이상 데이터 로드 시
증상 페이지 멈춤, 스크롤 버벅임, 브라우저 메모리 급증
영향 범위 VOC 처리 화면 전체 사용성 저하

문제 코드:

// 전체 데이터를 한 번에 불러와서 렌더링
const { data } = useQuery({
  queryKey: ['vocList', params],
  queryFn: () => getVocList(params),
});

// 수백 개의 카드가 한 번에 DOM에 마운트됨
{data?.map((voc) => (
  <VocCard key={voc.id} data={voc} />
))}

2. 해결 방안 후보

방안 장점 단점
A. 페이지네이션 구현 간단, 데이터 분리 명확 페이지 이동 UX, 전체 목록 파악 어려움
B. 가상 스크롤 (react-window) 렌더링 최적화 - 칸반보드 레이아웃에 적용 복잡

3. 선택한 해결법

선택: C안 (무한 스크롤 + useInfiniteQuery)

// src/hooks/useIntersection.ts
export const useIntersection = (
  onIntersect: (entries: IntersectionObserverEntry[]) => void,
  options?: IntersectionObserverInit
) => {
  const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
  const [rootElement, setRootElement] = useState<HTMLElement | null>(null);

  useEffect(() => {
    if (!targetElement) return;
    const observer = new IntersectionObserver(onIntersect, {
      root: rootElement,
      ...options,
    });
    observer.observe(targetElement);
    return () => observer.unobserve(targetElement);
  }, [targetElement, onIntersect, options]);

  return [setTargetElement, setRootElement];
};
// src/apis/VocProcessing/useGetVocListGroupedByStatusPaginated.ts
export const useGetVocListGroupedByStatusPaginated = (
  param: GetVocListGroupedByStatusPaginatedReq,
  checkVocFilter: boolean
) => {
  return useInfiniteQuery({
    queryKey: QUERY_KEYS.VOC_PROCESSING.boardList(JSON.stringify(param)),
    queryFn: async ({ pageParam }) =>
      await getVocListGroupedByStatusPaginated(pageParam, param),
    initialPageParam: 0,
    getNextPageParam: (data) => {
      if (data?.body?.success) {
        const nextPage = data.body.success.vocGroupList[0].vocPage.pageNumber + 1;
        return data.body.success.hasNext ? nextPage : undefined;
      }
      return undefined;
    },
    enabled: checkVocFilter, // 필터 준비 전까지 요청 방지
    select: (data) => {
      // 모든 페이지 데이터 병합
      const serializedContent = data.pages.flatMap(
        (page) => page?.body.success?.vocGroupList || []
      );
      return { serializedContent, totalItems, countOfSearchResult };
    },
  });
};
// src/pages/vocProcessing/IssueBoard/IssueBoard.tsx
const IssueBoard = observer(() => {
  const { data: vocList, fetchNextPage, hasNextPage } = 
    useGetVocListGroupedByStatusPaginated(submitSearchCondition, checkVocFilter);

  const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
    const scrollElement = entries[0];
    if (scrollElement.isIntersecting && hasNextPage) {
      fetchNextPage();
    }
  }, [fetchNextPage, hasNextPage]);

  const [setTargetElement] = useIntersection(handleIntersection);

  return (
    <BoardContainer>
      {vocList?.serializedContent.map((group) => (
        <StatusColumn key={group.statusCode}>
          {group.vocList.map((voc) => (
            <VocCard key={voc.id} data={voc} />
          ))}
        </StatusColumn>
      ))}
      {/* 스크롤 감지 요소 */}
      <div ref={setTargetElement} style={{ height: 1 }} />
    </BoardContainer>
  );
});

4. 선택 이유

관점 이유
초기 렌더링 최적화 첫 페이지(20건)만 로드, DOM 노드 수 최소화
자연스러운 UX 스크롤하면 자동으로 다음 데이터 로드
메모리 효율 React Query가 페이지별 캐시 관리
중복 요청 방지 enabled 옵션으로 필터 준비 전 요청 차단

이슈 #2: React Query 캐시 키 불일치로 인한 데이터 동기화 문제