메인 페이지의 최근 게시물 목록 구현에서 다음과 같은 문제점이 발견되었습니다:
렌더링 성능 저하
게시물 목록이 많아질수록 DOM 노드 증가로 인한 렌더링 부담이 커져 전반적인 성능이 저하됨
스크롤 성능 문제
사용자가 스크롤할 때마다 과도한 렌더링이 발생하여 UX가 저하되고 부드러운 스크롤이 방해됨
데이터 로딩 비효율
한 번에 모든 데이터를 로드하는 방식으로 초기 로딩 시간이 길어짐
위 문제를 해결하기 위해 다음 최적화 기법을 적용하였습니다:
const useInfinitePostsQuery = () => {
const { handleError } = useApiError();
const result = useSuspenseInfiniteQuery({
queryKey: [queryKeys.RecentPost],
queryFn: fetchPosts,
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : undefined,
initialPageParam: 1,
});
const { error, status } = result;
useEffect(() => {
if (status === "error" && error) {
handleError(error);
}
}, [error, status, handleError]);
const flattenedPosts = result.data?.pages.flatMap((page) => page.posts) || [];
return {
posts: flattenedPosts,
fetchNextPage: result.fetchNextPage,
hasNextPage: result.hasNextPage,
isFetchingNextPage: result.isFetchingNextPage,
};
};
export default useInfinitePostsQuery;
주요 특징:
useSuspenseInfiniteQuery를 사용하여 Suspense 기반 비동기 데이터 페칭hasNextPage, fetchNextPage 기능 제공기존 가상 스크롤 라이브러리들이 프로젝트의 디자인 그리드 레이아웃과 완전히 일치하지 않아 직접 구현했습니다. 특히 반응형 컬럼 처리와 복잡한 레이아웃 요구사항을 정확히 반영하기 위해 맞춤형 솔루션이 필요했습니다.
function getColumnCount() {
if (window.innerWidth >= 1024) return 3; // 데스크톱: 3열
if (window.innerWidth >= 640) return 2; // 태블릿: 2열
return 1; // 모바일: 1열
}
아이템 높이를 열 높이로 지정합니다. 이를 통해 가상 렌더링의 시작과 끝을 결정합니다.
const calculateRange = () => {
const rowHeight = itemHeight; // 예: 400px
// scrollTop: 현재 스크롤 위치 (예: 800px)
const startRow = Math.floor(scrollTop / rowHeight); // 800 / 400 = 2
// containerHeight: 화면에 보이는 높이 (예: 800px)
const endRow = Math.ceil((scrollTop + containerHeight) / rowHeight); // (800 + 800) / 400 = 4
// overscan: 추가로 렌더링할 행 수 (예: 1)
const start = Math.max(0, (startRow - overscan) * columns); // (2 - 1) * 3 = 3
const end = Math.min(totalItems, (endRow + overscan) * columns); // (4 + 1) * 3 = 15
return { start, end };
};

가상 아이템을 반응형으로 어떻게 배치할지 결정합니다.
const virtualItems = Array.from({ length: end - start }, (_, index) => {
// start가 3이고 index가 0인 경우:
const absoluteIndex = start + index; // 3 + 0 = 3
// 3열 그리드에서 아이템 3의 위치:
const columnIndex = absoluteIndex % columns; // 3 % 3 = 0 (첫 번째 열)
// 아이템 3의 행 번호:
const row = Math.floor(absoluteIndex / columns); // 3 / 3 = 1 (두 번째 행)
return {
index: absoluteIndex, // 3
offsetTop: row * itemHeight, // 1 * 400 = 400px
columnIndex, // 0
columnWidth: 100 / columns, // 33.33%
};
});
배치 결과:
[그리드 레이아웃]
absoluteIndex: 3, 4, 5
┌────────┬────────┬────────┐
│ 3 │ 4 │ 5 │
│(0,400px│(1,400px│(2,400px│
│ 33.33%)│ 33.33%)│ 33.33%)│
└────────┴────────┴────────┘
↑ ↑ ↑
columnIndex:
0 1 2
const onScroll = useCallback(
_.throttle((e: Event) => {
const scroll = e.target as HTMLElement;
setScrollTop(Math.max(0, scroll.scrollTop));
}, 16), // 60fps에 맞춘 스로틀링
[]
);

