가상스크롤을 구현해보자.

최씨·2025년 5월 17일
40

Frontend

목록 보기
10/12
post-thumbnail

🍀 글의 목적

무한 스크롤은 사용자에게 편리한 UX를 제공하는 기능입니다.
하지만 스크롤이 계속되면서 상단에 렌더링된 요소들이 누적되면, 브라우저에 점점 부담이 쌓이게 됩니다.
이러한 문제를 해결하기 위한 방법 중 하나가 바로 가상 스크롤(Virtual Scroll) 입니다.

가상 스크롤이라는 개념은 알고 있었지만, 매번 "언젠가 해봐야지" 하며 미뤄두고 있었습니다.
이전 프로젝트 발표 때 "무한 스크롤을 어떻게 개선할 수 있을까요?"라는 질문을 받았고, 이후 실제로 팀원이 리팩토링 과정에서 가상 스크롤을 적용하기도 했습니다.
또 최근 면접에서도 관련 질문을 받으면서, 이제는 정말 직접 구현해봐야겠다는 생각이 들었습니다 ㅎ...

저는 이전에 무한 스크롤 구현까지만 경험이 있어 이번 기회에 직접 구현해보며 체감해보려 합니다.
데이터 패칭 → 무한 스크롤 → 가상 스크롤 순으로 구현 과정을 정리해보겠습니다.


🍀 이론

Q. 데이터를 그냥 다 불러오면 되지, 왜 무한 스크롤이 필요할까?

  • 무한 스크롤의 장점은 초기 로딩 속도를 줄이고 사용자 경험(UX)을 개선할 수 있다는 점입니다. 모든 데이터를 한 번에 불러오는 대신, 사용자의 스크롤에 따라 필요한 만큼만 점진적으로 데이터를 요청하게 됩니다. 특히 수백 개 이상의 데이터를 다룰 때 매우 유용합니다.
  • 하지만 단점도 존재합니다. 스크롤이 계속되면서 많은 DOM 요소가 누적되고, 이는 브라우저 성능 저하로 이어질 수 있습니다. 극단적으로는 렌더링 에러가 발생하기도 합니다.

Q. 이런 문제를 어떻게 개선할 수 있을까?

  • 가상 스크롤을 사용하면 DOM 성능 문제를 해결할 수 있습니다. 보이는 영역의 데이터만 실제로 렌더링하고, 보이지 않는 요소는 DOM에서 제거하거나 교체하여 전체 노드 수를 최소화합니다. 이로써 수백~수천 개의 데이터를 다루면서도 성능을 유지할 수 있습니다.

Q. 무한 스크롤 구현 방식은 어떻게 될까?

  • 주로 Intersection Observer API를 사용해 구현합니다. 사용자가 페이지를 아래로 스크롤할 때, 마지막 요소가 뷰포트에 닿는 시점을 감지해 다음 데이터를 요청합니다.
  • 제가 이전에 무한 스크롤을 구현했을 때는 TanStack Query(구 React Query)의 useInfiniteQuery 훅을 활용했습니다. API 요청, 데이터 병합, 로딩 상태 관리 등을 자동으로 처리해주기 때문에 간편하게 구현할 수 있었습니다.

Q. 가상 스크롤(Virtual Scroll)이란?

  • 가상 스크롤은 데이터를 어떻게 요청할지보다, 이미 로드된 데이터를 어떻게 렌더링할지에 초점을 둔 기술입니다. 모든 데이터를 메모리에 가지고 있어도, DOM에는 현재 화면에 보이는 일부만 렌더링합니다.
  • 구현 방식은 Intersection Observer가 아니라, scrollTop과 각 아이템의 높이를 기준으로 현재 화면에 보여줄 데이터의 시작 인덱스와 끝 인덱스를 계산하여, 해당 구간만 렌더링합니다. 나머지 요소는 DOM에서 제거해, 전체 렌더링 성능을 유지합니다.

한 파일에 보여드리기 위해 역할분리를 하지 않아, 코드가 다소 지저분할 수 있는 점 참고 부탁드립니다.

🍀 구현

가상 스크롤의 주요 목적은 스크롤이 길어질수록 쌓이는 DOM 요소를 최소화하여 렌더링 성능을 개선하는 데 있습니다.

이를 수치로 확인하기 위해 Lighthouse의 성능 점수를 기준으로 비교해보겠습니다.

✏️ 데이터 패칭

당연한 소리지만, 데이터를 보여주려면 먼저 불러와야합니다.
그래서 기본적인 fetch를 사용해 API에서 데이터를 불러오는 작업부터 시작했습니다.

  • API는 RandomUser에서 제공하는 엔드포인트를 사용했습니다.
  • 이 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 요소가 계속 아래에 축적되어 늘어나는 것을 볼 수 있습니다.

[ 구현 순서 ]

  1. 초기 데이터 페칭

    • useInfiniteQuery를 통해 첫 페이지의 유저 데이터를 불러옴
    • initialPageParam1로 지정
  2. 사용자가 스크롤을 내림

    • DOM 하단에 위치한 div (bottomRef)가 뷰포트에 진입하면 이벤트 발생
  3. IntersectionObserver가 하단 도달 감지

    • entry.isIntersectingtrue일 때
    • 아직 다음 데이터가 있고(hasNextPage) 페칭 중이 아니라면(!isFetchingNextPage)
    • fetchNextPage() 호출로 다음 페이지 요청
  4. 다음 데이터 추가 렌더링

    • data.pages.flat()으로 모든 페이지의 유저들을 평탄화해 출력
    • 새로운 데이터가 기존 리스트 아래에 붙어서 이어지는 방식으로 렌더링됨
  5. 마지막 페이지 도달 시 종료 표시

    • !hasNextPagetrue가 되면 "더 이상 데이터 없음" 출력

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;

  • 계속 스크롤을 하다보면 축적되는 돔 요소들이 많아져 조금씩 더 느려집니다.
  • 성능 점수는 87점 !

✏️ 가상 스크롤

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

오른쪽을 보면, div가 계속 추가되는 것이 아니라 지정된 개수 내에서 교체되는 것을 확인할 수 있습니다.

가상 스크롤은 직접 구현하거나, react-window, react-virtualized, @tanstack/react-virtual 등 다양한 라이브러리를 활용해 구현할 수 있습니다.

항목직접 구현@tanstack/react-virtualreact-windowreact-virtualized
API 형태직접 계산 및 구현훅 기반컴포넌트 기반컴포넌트 기반
유지보수❌ 복잡✅ 활발✅ 유지 중❌ 중단됨
가변 높이 지원✅ 가능 (직접 처리)
반응형 대응✅ (유연함)
React Query 연동❌ 수동 처리✅ 최적화

저는 동작 경험 중심의 구현을 목표로 했고, TanStack Query를 활용한 무한 스크롤 구조를 갖춘 상태였기 때문에, 연동이 자연스럽고 빠르게 적용할 수 있는 @tanstack/react-virtual을 선택했습니다.

물론 라이브러리가 항상 최선은 아닙니다. 에러 처리나 UI 동작에 대한 세밀한 제어가 필요한 복잡한 상황에서는 직접 구현이 더 적합할 수 있습니다.


[ 구현 순서 ]

  1. 데이터 요청

    • useInfiniteQuery를 사용해 유저 데이터를 페이지 단위로 불러오고, 최대 100페이지까지 페칭 가능하도록 설정
  2. 스크롤 영역 설정

    • 고정 높이(500px)의 div를 가상 스크롤 뷰포트로 만들고, 내부에서만 스크롤되도록 overflow: auto를 적용
  3. 가상 스크롤 구성

    • useVirtualizer로 전체 유저 수를 기준으로 렌더링해야 할 줄 수를 계산하고, 화면에 보이는 줄만 absolute 위치로 렌더링
  4. 하단 도달 감지

    • 스크롤 중 getVirtualItems()의 마지막 index가 전체 데이터 마지막 index와 같아질 경우, fetchNextPage()로 다음 페이지 요청을 보냄
  5. 데이터 누적 & 렌더링

    • data.pages.flat()으로 기존 데이터와 새 데이터를 합쳐서 렌더링하고, 가상 스크롤이 화면에 필요한 줄만 유지시켜 성능을 보장
  6. 종료 상태 처리

    • 모든 데이터를 불러오면 "더 이상 데이터 없음" 메시지를 출력해 무한 스크롤 종료를 사용자에게 알림

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;

  • 계속 스크롤해도 돔에 축적되는 요소가 없어서 느려지지 않습니다.
  • 성능 점수도 87 → 92점으로 향상 !!!

🍀 결론

가상 스크롤을 통해 성능이 개선되고, 그로 인해 UX도 좋아지는 경험을 할 수 있었습니다. 처음 개발할 때는 무한 스크롤 구현만 해도 신기하고 어렵게 느껴졌는데, 이제는 가상 스크롤까지 활용하게 되었네요. 이런 성능 최적화는 프론트엔드 개발자에게 필수적인 역량이라고 생각합니다.

가상 스크롤 다음 단계는 무엇일까 하는 궁금즘도 드네요.

예를 들어, 무한 스크롤과 가상 스크롤이 적용된 화면에서 특정 항목을 클릭해 상세 페이지로 이동했다가, 다시 뒤로 돌아왔을 때 이전 스크롤 위치를 어떻게 복원할 수 있을까 하는 고민이 들었습니다. 지금은 해당 위치 정보를 로컬이나 세션스토리지에 저장해두었다가 복원하는 방식이 떠오르지만, 실제로 구현 시 어떤 제약이 있을지도 궁금합니다.

요건 다음에 작성해 보겠습니다 ㅎㅎ
부족한 글 읽어주셔서 감사합니다 : )


🌐 참고 링크

profile
정답은 없지만, 가까워지려고 노력하고 있습니다 :)

6개의 댓글

comment-user-thumbnail
2025년 5월 18일

가상 스크롤이라는 개념이 생소했는데 덕분에 유익한 내용 잘 배우고 갑니다 감사합니다~!

1개의 답글
comment-user-thumbnail
2025년 5월 18일

코드로 정리해주셔서 이해가 잘 되네요 👍👍

1개의 답글
comment-user-thumbnail
2025년 5월 22일

글 잘 읽었습니다. 감사합니다!
혹시 페이지 이동 간 스크롤이랑 fetch 된 내용 유지하는 방법에 대해서는 생각해 보신 적 있으신가요?

1개의 답글