무한 스크롤은 사용자에게 편리한 UX를 제공하는 기능입니다.
하지만 스크롤이 계속되면서 상단에 렌더링된 요소들이 누적되면, 브라우저에 점점 부담이 쌓이게 됩니다.
이러한 문제를 해결하기 위한 방법 중 하나가 바로 가상 스크롤(Virtual Scroll) 입니다.
가상 스크롤이라는 개념은 알고 있었지만, 매번 "언젠가 해봐야지" 하며 미뤄두고 있었습니다.
이전 프로젝트 발표 때 "무한 스크롤을 어떻게 개선할 수 있을까요?"라는 질문을 받았고, 이후 실제로 팀원이 리팩토링 과정에서 가상 스크롤을 적용하기도 했습니다.
또 최근 면접에서도 관련 질문을 받으면서, 이제는 정말 직접 구현해봐야겠다는 생각이 들었습니다 ㅎ...
저는 이전에 무한 스크롤 구현까지만 경험이 있어 이번 기회에 직접 구현해보며 체감해보려 합니다.
데이터 패칭 → 무한 스크롤 → 가상 스크롤 순으로 구현 과정을 정리해보겠습니다.
Q. 데이터를 그냥 다 불러오면 되지, 왜 무한 스크롤이 필요할까?
Q. 이런 문제를 어떻게 개선할 수 있을까?
Q. 무한 스크롤 구현 방식은 어떻게 될까?
useInfiniteQuery 훅을 활용했습니다. API 요청, 데이터 병합, 로딩 상태 관리 등을 자동으로 처리해주기 때문에 간편하게 구현할 수 있었습니다.Q. 가상 스크롤(Virtual Scroll)이란?
scrollTop과 각 아이템의 높이를 기준으로 현재 화면에 보여줄 데이터의 시작 인덱스와 끝 인덱스를 계산하여, 해당 구간만 렌더링합니다. 나머지 요소는 DOM에서 제거해, 전체 렌더링 성능을 유지합니다.한 파일에 보여드리기 위해 역할분리를 하지 않아, 코드가 다소 지저분할 수 있는 점 참고 부탁드립니다.
가상 스크롤의 주요 목적은 스크롤이 길어질수록 쌓이는 DOM 요소를 최소화하여 렌더링 성능을 개선하는 데 있습니다.
이를 수치로 확인하기 위해 Lighthouse의 성능 점수를 기준으로 비교해보겠습니다.
당연한 소리지만, 데이터를 보여주려면 먼저 불러와야합니다.
그래서 기본적인 fetch를 사용해 API에서 데이터를 불러오는 작업부터 시작했습니다.
import { useState } from "react";
import type { User } from "./types/user.ts";
const App = () => {
const [users, setUsers] = useState<User[]>([]);
const fetchUsers = async () => {
try {
const res = await fetch("https://randomuser.me/api/?results=20");
const data = await res.json();
setUsers(data.results);
} catch (error) {
console.error(error);
}
};
return (
<>
<div>데이터 가져오기</div>
<button onClick={fetchUsers}>패칭</button>
<ul>
{users.map((user) => (
<li key={user.login.uuid}>
{user.name.first} {user.name.last} --- {user.email}
</li>
))}
</ul>
</>
);
};
export default App;
이후에는 무한 스크롤을 보다 간편하게 구현하기 위해 TanStack Query를 도입했습니다.
물론 직접 구현하는 것도 가능하지만, 스크롤 위치 계산, 페이지네이션 처리, 로딩 상태 관리 등 고려할 요소가 많아 번거롭습니다.
무한스크롤은 사용자가 스크롤을 끝까지 내리면, 하단의 옵저버가 이를 감지해 추가 데이터를 가져오고, 기존 목록에 이어서 렌더링합니다.

오른쪽을 보면, div 요소가 계속 아래에 축적되어 늘어나는 것을 볼 수 있습니다.
[ 구현 순서 ]
초기 데이터 페칭
useInfiniteQuery를 통해 첫 페이지의 유저 데이터를 불러옴initialPageParam을 1로 지정사용자가 스크롤을 내림
div (bottomRef)가 뷰포트에 진입하면 이벤트 발생IntersectionObserver가 하단 도달 감지
entry.isIntersecting이 true일 때hasNextPage) 페칭 중이 아니라면(!isFetchingNextPage)fetchNextPage() 호출로 다음 페이지 요청다음 데이터 추가 렌더링
data.pages.flat()으로 모든 페이지의 유저들을 평탄화해 출력마지막 페이지 도달 시 종료 표시
!hasNextPage가 true가 되면 "더 이상 데이터 없음" 출력import type { User } from "./types/user";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
// 사용자 데이터를 비동기로 받아오는 함수 (한 페이지에 30명)
const fetchUsers = async ({ pageParam = 1 }): Promise<User[]> => {
const res = await fetch(
`https://randomuser.me/api/?page=${pageParam}&results=30`
);
const data = await res.json();
return data.results;
};
const App = () => {
// 무한 스크롤을 위한 useInfiniteQuery 훅
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["users"], // 쿼리 캐싱 키
queryFn: fetchUsers, // 데이터 요청 함수
initialPageParam: 1, // 초기 페이지 번호
getNextPageParam: (_lastPage, allPages) => {
// 전체 페이지가 100개 미만일 때 다음 페이지 요청
return allPages.length < 100 ? allPages.length + 1 : undefined;
},
});
// 여러 페이지에서 받아온 유저 목록을 평탄화
const users = data?.pages.flat() ?? [];
// 스크롤 하단 감지를 위한 ref
const bottomRef = useRef<HTMLDivElement | null>(null);
// IntersectionObserver를 이용해 스크롤 하단 도달 시 다음 페이지 요청
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
});
if (bottomRef.current) {
observer.observe(bottomRef.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<>
<h1>무한 스크롤</h1>
{/* 사용자 목록 출력 */}
{users.map((user, index) => (
<div key={user.login.uuid}>
{index + 1} {user.name.first} {user.name.last} --- {user.email}
</div>
))}
{/* 마지막 요소: 화면에 나타나면 다음 페이지 요청 트리거 */}
<div ref={bottomRef} style={{ height: "1px" }} />
{/* 로딩, 종료 표시 */}
{isFetchingNextPage && <p>로딩 중...</p>}
{!hasNextPage && <p>더 이상 데이터 없음</p>}
</>
);
};
export default App;

무한 스크롤이 데이터를 계속 불러오는 방식이라면, 가상 스크롤은 일정량의 데이터만 화면에 렌더링해 성능을 유지합니다.

오른쪽을 보면, div가 계속 추가되는 것이 아니라 지정된 개수 내에서 교체되는 것을 확인할 수 있습니다.
가상 스크롤은 직접 구현하거나, react-window, react-virtualized, @tanstack/react-virtual 등 다양한 라이브러리를 활용해 구현할 수 있습니다.
| 항목 | 직접 구현 | @tanstack/react-virtual | react-window | react-virtualized |
|---|---|---|---|---|
| API 형태 | 직접 계산 및 구현 | 훅 기반 | 컴포넌트 기반 | 컴포넌트 기반 |
| 유지보수 | ❌ 복잡 | ✅ 활발 | ✅ 유지 중 | ❌ 중단됨 |
| 가변 높이 지원 | ✅ 가능 (직접 처리) | ✅ | ❌ | ✅ |
| 반응형 대응 | ✅ (유연함) | ✅ | ❌ | ❌ |
| React Query 연동 | ❌ 수동 처리 | ✅ 최적화 | ❌ | ❌ |
저는 동작 경험 중심의 구현을 목표로 했고, TanStack Query를 활용한 무한 스크롤 구조를 갖춘 상태였기 때문에, 연동이 자연스럽고 빠르게 적용할 수 있는 @tanstack/react-virtual을 선택했습니다.
물론 라이브러리가 항상 최선은 아닙니다. 에러 처리나 UI 동작에 대한 세밀한 제어가 필요한 복잡한 상황에서는 직접 구현이 더 적합할 수 있습니다.
[ 구현 순서 ]
데이터 요청
useInfiniteQuery를 사용해 유저 데이터를 페이지 단위로 불러오고, 최대 100페이지까지 페칭 가능하도록 설정스크롤 영역 설정
500px)의 div를 가상 스크롤 뷰포트로 만들고, 내부에서만 스크롤되도록 overflow: auto를 적용가상 스크롤 구성
useVirtualizer로 전체 유저 수를 기준으로 렌더링해야 할 줄 수를 계산하고, 화면에 보이는 줄만 absolute 위치로 렌더링하단 도달 감지
getVirtualItems()의 마지막 index가 전체 데이터 마지막 index와 같아질 경우, fetchNextPage()로 다음 페이지 요청을 보냄데이터 누적 & 렌더링
data.pages.flat()으로 기존 데이터와 새 데이터를 합쳐서 렌더링하고, 가상 스크롤이 화면에 필요한 줄만 유지시켜 성능을 보장종료 상태 처리
"더 이상 데이터 없음" 메시지를 출력해 무한 스크롤 종료를 사용자에게 알림import type { User } from "./types/user";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useEffect } from "react";
// 사용자 데이터를 비동기로 받아오는 함수 (한 페이지에 30명)
const fetchUsers = async ({ pageParam = 1 }): Promise<User[]> => {
const res = await fetch(
`https://randomuser.me/api/?page=${pageParam}&results=30`
);
const data = await res.json();
return data.results;
};
const App = () => {
// 무한 스크롤을 위한 useInfiniteQuery 훅
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["users"],
queryFn: fetchUsers,
initialPageParam: 1,
getNextPageParam: (_lastPage, allPages) =>
allPages.length < 100 ? allPages.length + 1 : undefined,
});
const users = data?.pages.flat() ?? [];
// 가상 스크롤: 뷰포트 참조용 ref
const parentRef = useRef<HTMLDivElement | null>(null);
// 가상 스크롤 Virtualizer 설정
const rowVirtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 30, // 각 줄의 예상 높이
overscan: 0, // 추가로 렌더링할 줄 수
});
// 스크롤이 끝에 가까워졌을 때 → 다음 페이지 요청
useEffect(() => {
const lastItem = rowVirtualizer.getVirtualItems().at(-1);
if (
lastItem &&
lastItem.index >= users.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage(); // 무한스크롤 트리거
}
}, [rowVirtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage]);
return (
<>
<h1>가상 + 무한스크롤</h1>
{/* 가상 스크롤 뷰포트 (필수로 높이 고정) */}
<div
ref={parentRef}
style={{
height: "500px", // 고정 높이 → 가상스크롤 기준
overflow: "auto", // 내부 스크롤 생성
}}
>
{/* 전체 스크롤 높이를 확보하기 위한 래퍼 */}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`, // 전체 가상 높이
position: "relative",
}}
>
{/* 실제로 렌더링되는 가상 항목들 */}
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const user = users[virtualRow.index];
if (!user) return null;
return (
<div
key={user.login.uuid}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
fontSize: "14px",
}}
>
{virtualRow.index + 1}. {user.name.first} {user.name.last} —{" "}
{user.email}
</div>
);
})}
</div>
{/* 로딩, 종료 표시 */}
{isFetchingNextPage && <div>로딩 중...</div>}
{!hasNextPage && <div>더 이상 데이터 없어유</div>}
</div>
</>
);
};
export default App;

가상 스크롤을 통해 성능이 개선되고, 그로 인해 UX도 좋아지는 경험을 할 수 있었습니다. 처음 개발할 때는 무한 스크롤 구현만 해도 신기하고 어렵게 느껴졌는데, 이제는 가상 스크롤까지 활용하게 되었네요. 이런 성능 최적화는 프론트엔드 개발자에게 필수적인 역량이라고 생각합니다.
가상 스크롤 다음 단계는 무엇일까 하는 궁금즘도 드네요.
예를 들어, 무한 스크롤과 가상 스크롤이 적용된 화면에서 특정 항목을 클릭해 상세 페이지로 이동했다가, 다시 뒤로 돌아왔을 때 이전 스크롤 위치를 어떻게 복원할 수 있을까 하는 고민이 들었습니다. 지금은 해당 위치 정보를 로컬이나 세션스토리지에 저장해두었다가 복원하는 방식이 떠오르지만, 실제로 구현 시 어떤 제약이 있을지도 궁금합니다.
요건 다음에 작성해 보겠습니다 ㅎㅎ
부족한 글 읽어주셔서 감사합니다 : )
🌐 참고 링크
가상 스크롤이라는 개념이 생소했는데 덕분에 유익한 내용 잘 배우고 갑니다 감사합니다~!