Windowing 도입해서 DOM 요소 줄이기

회고 · 2025. 3. 13.

배경

유튜브에 나온 인플루언서들의 맛집을 조회할수 있는 맛집지도 웹앱 먹튜브를 개발하면서, MVP 개발이 가장 큰 목표였습니다. 갑자기 떠올린 사이드 프로젝트 아이디어인 만큼 짧은 시간안에 중요한 기능만 개발하고 싶었습니다. 이를 위해서, 구글 시트를 DB로 사용하기도 했습니다. 또한, 일단 비효율적이라도 구현하고, 최적화는 필요한 경우에만 나중에 하기로 했습니다.

관련 포스팅 : 구글 시트 CORS 에러 삽질기

문제상황

앱 접속시 모든 데이터를 AJAX로 받아와 이를 리스트와 마커로 띄워주는 로직이였습니다. 추후에는 데이터가 더 많아질수 있는 상황에서, 초기에는 데이터가 약 100개 정도 있는 상황이었습니다. 그런데 리스트에서 렌더링이 제대로 되지 않는 현상이 발생하였습니다. 문제를 정확히 파악하기 위해 개발자도구에서 네트워크 탭과 요소 탭으로 확인해보아도, 네트워크 요청도 올바르게 이뤄지고 있었고, 요소에도 잘 포함되어있었습니다.

이를 DOM요소가 너무 많아서 발생하는 것이라고 판단하고 이를 줄일 필요를 느꼈습니다.

선택지

DOM 요소를 줄일 수 있는 선택지는 다음과 같았습니다.

  1. API 개편

    전체 데이터를 요청하는 것이 아니라 더 적은 요소만 요청하고 렌더링, 화면 변경시 재요청 로직 구현 필요. 백엔드와 프론트엔드 모두 작업 필요.

  2. 윈도잉 도입

    화면의 가시 영역의 요소만 렌더링방식. 프론트엔드에서만 작업 필요

API 개편은 고민해야할 요소가 많고, 복잡도가 높았습니다. 두번째 방법은 한번 적용해놓으면 이후에도 유효하다는 장점이 있기 때문에 윈도잉을 도입하기로 결정했습니다.

윈도잉이란?

윈도잉(Windowing)이란 목록 가상화(List Virtualization)라고 불리기도 하며 화면에 가시영역에 해당하는 DOM 요소만 렌더링 하는 기술을 말합니다. 대량의 목록 데이터를 효율적으로 렌더링하기 위해 주로 사용됩니다.

참고 자료 1 : What is windowing?..

윈도잉 라이브러리 선택하기

저는 윈도잉 라이브러리로 @tanstack/react-virtual을 선택했습니다. 네 가지 라이브러리 모두 기본적인 기능은 지원하고 예시도 잘 나와 있어서, 기본적인 윈도잉을 도입하는데는 문제가 없어 보입니다.

참고자료 2 : npm trends

윈도잉 적용하기

우선 DOM 요소를 줄이는 것이 목표였으므로, 이 효과를 극대화하기 위해 아이템 컴포넌트의 구조를 개선하여 DOM요소를 줄여줍니다.

//수정 전
function Item(data){
    return (
        ...
            <div className="flex gap-1 text-sm">
          <span>place.주소}</span>
          <span
              className="cursor-pointer text-blue-500"
          onClick={ ... }
        >
            복사
       </span>
     </div>
    )
}

//수정 후
function Item(data){
    return (
        ...
            <div className="inline text-sm">
          {place.주소}{" "}
          <span
              className="cursor-pointer text-blue-500"
          onClick={ ... }
        >
            복사
          </span>
     </div>
    )
}

그 다음 공식문서의 예시를 참고하여 윈도잉을 적용해줍니다. 처음에는 아이템들의 높이가 고정인 Fixed Windowing을 적용하였으나 이후 개발하면서, 주소 등의 긴 경우 높이 차이가 발생하도록 변경될 수 있도록 Dynamic Windowing으로 수정하였습니다.

export function PlaceList({
  data,
    ...
}: PlaceListProps) {
    ...

  // The scrollable element for your list
  const parentRef = useRef(null);

  // The virtualizer
  const virtualizer = useVirtualizer({
    count: sortedData.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 84,
  });

  const items = virtualizer.getVirtualItems();

  if (error) {
    return (
      <div className="flex h-40 flex-col items-center justify-center gap-2 p-4 text-center">
        <p className="text-destructive font-semibold">오류가 발생했습니다</p>
        <p className="text-muted-foreground text-sm">{error.message}</p>
      </div>
    );
  }

  ...

  return (
    <div
      ref={parentRef}
      className="h-full w-full overflow-y-auto"
      style={{
        contain: "strict",
      }}
    >
      <div
        className="relative"
        style={{
          height: virtualizer.getTotalSize(),
        }}
      >
        <ul
          className="absolute top-0 left-0 w-full divide-y p-1"
          style={{
            transform: `translateY(${items[0]?.start ?? 0}px)`,
          }}
        >
          {items.map((virtualRow) => {
            const place = sortedData[virtualRow.index];
            return (
              <li
                key={virtualRow.key}
                data-index={virtualRow.index}
                ref={virtualizer.measureElement}
                className="flex w-full flex-col p-2"
              >
                  ...
              </li>
            );
          })}
        </ul>
      </div>
    </div>
  );
});

참고자료 3 : TanStack Virtual Examples Dynamic

메모이제이션 활용하여 불필요한 리렌더링 막기

PlaceList 컴포넌트의 상위 컴포넌트 AppSidebar 컴포넌트에서는 useIsMobile 훅으로 화면 너비를 감지하여 조건부 렌더링을 하는 방식으로 반응형 레이아웃을 구현하고 있었습니다. 이때 화면 너비가 바뀌어 부모 컴포넌트가 리렌더링될 때, 자식 컴포넌트인 PlaceList 컴포넌트가 불필요하게 리렌더링 될 필요가 없으므로 메모이제이션을 적용해줍니다. 이는 컴포넌트가 크고 복잡할 때 적절한데 지금의 경우가 그 경우인 것 같습니다.

//AppSidebar.tsx
...
import { useIsMobile } from "@/hooks/use-mobile";
import { PlaceList } from "@/components/place-list";
...

// 사이드바 컴포넌트
// 데스크탑에서는 사이드바 형태로 보여주고, 모바일에서는 드로어 형태로 보여줌
export function AppSidebar({
  data,
    ...
}: AppSidebarProps) {

  const isMobile = useIsMobile();
  ...

  if (isMobile) {
    return (
      <Drawer
                ...
      >
       ...
         <PlaceList
             data={data}
         ...
       />
      </Drawer>
    );
  }

  return (
    <Sidebar>
        ...
        <PlaceList
          data={data}
      />
       </Sidebar>
  );
}

//PlaceList.tsx
import { memo } from "react";

export const PlaceList = memo(function PlaceList({
  data,
    ...
}: PlaceListProps) {
    ... 
});

메모이제이션은 항상 공짜가 아니고 비용을 소모하게 되므로 정말 필요한 경우에만 적용해 주도록 합시다. 문제가 없다면 적용할 필요가 없을지도 몰라요.

참고자료 3 : 네이버 D2 성능 하면 빠질 수 없는 메모이제이션, 네가 궁금해

참고자료 4 : 왜 useCallback, React.memo, useMemo를 사용할까?(리랜더링 줄이기 전략)

결과물

후기

이번 사이드 프로젝트는 주말동안 갑자기 떠오른 아이디어를, 2~3일이라는 짧은 시간안에 구현하게 되었습니다. MVP를 만드는 것에 집중해서 정말 단순하게 만들었지만, 오히려 그 단순함을 돌이켜 보면 그 속에서의 의사결정 과정이 정말 소중했던 것 같습니다. 이번 프로젝트를 통해서 일단 구현하고 최적화는 나중에라는 교훈을 실천할 수 있었고, 이를 앞으로의 개발에서도 적용하면 좋을 것 같습니다.