목차
이슈 #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) |
렌더링 최적화 |
- 칸반보드 레이아웃에 적용 복잡 |
- 가상화된 DOM에서 DnD 처리 복잡
- 드래그 중인 아이템이 가상화로 사라질 수 있음 |
| C. 무한 스크롤 (Intersection Observer) | 자연스러운 UX, 점진적 로딩 | 스크롤 위치 관리 필요 |
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 캐시 키 불일치로 인한 데이터 동기화 문제