Loading...
본문 바로가기
👥
총 방문자
📖
0개 이상
총 포스팅
🧑
오늘 방문자 수
📅
0일째
블로그 운영

여러분의 방문을 환영해요! 🎉

다양한 개발 지식을 쉽고 재미있게 알려드리는 블로그가 될게요. 함께 성장해요! 😊

코딩 정보/NextJs

스크롤 리스트 효율적인 랜더링 방식

by 꽁이꽁설꽁돌 2025. 9. 30.
728x90
반응형
     

목차

     

     

     

     

     

     

    참고

    서버 컴포넌트에 대해 이해하고 읽으면 좋을 것 같습니다.

    https://be-senior-developer.tistory.com/377

     

    [개념부터 nextjs] 서버 컴포넌트

    목차 서버컴포넌트의 이점데이터 페칭: Server Components를 사용하면 데이터 소스에 더 가까운 서버에서 데이터 페칭을 수행할 수 있습니다. 이는 렌더링에 필요한 데이터를 가져오는 시간을 줄이

    be-senior-developer.tistory.com

     

     

    의문점

    문득 서버에서 데이터를 받아오는 과정에서 어느 순간부터 내가 모든 api를 서버액션으로 만들어 놓았다는 사실에 이게 과연 맞을까라는 생각에 이번 글을 쓰게 되었다.

     

     

    그래서 추후에 데이터가 늘어나는 상황을 가정하여 비교 코드를 작성해보았다.

    각 경우에 대해 먼저 만개의 배열로 비교해보았다.

     

     

     

    서버액션을 통해 받아오는 경우

     

    코드

    export const dynamic = "force-dynamic";
    
    async function getImages() {
      const res = await fetch("http://localhost:3000/api/images");
      return res.json();
    }
    
    export default async function Page() {
      const items = await getImages();
    
      return (
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, 200px)",
            gap: "12px",
          }}
        >
          {items.map((item) => (
            <div key={item.id}>
              <img src={item.url} alt={item.title} width={200} height={150} />
              <p>{item.title}</p>
            </div>
          ))}
        </div>
      );
    }

     

     

    performance를 통한 확인

     

    cpu 실행 시간 대부분이 Script에 사용된 것을 볼 수 있다.

     

     

     

    보이는 화면 상태만큼 랜더링하는 경우

     

    다음의 경우 tanstack/react-virtual을 사용해 스크롤에 보이는 만큼만 랜더링 되게끔 만들어주었다.

     

    선택 이유

    1. 점유율

     

    2. 공식문서의 설명이 매우 친절하다.

     

    코드

    "use client";
    
    import { useEffect, useMemo, useId, useState } from "react";
    import { useWindowVirtualizer } from "@tanstack/react-virtual";
    
    type Item = { id: number; title: string; url: string };
    
    export default function VirtualPage() {
      const [items, setItems] = useState<Item[]>([]);
      const id = useId();
    
      const COLUMNS = 8 as const;
      const CARD_HEIGHT = 180;
      const GAP_X = 16; 
      const GAP_Y = 16; 
      const ROW_PAD_Y = 8; 
    
      const ROW_HEIGHT = CARD_HEIGHT + ROW_PAD_Y * 2 + GAP_Y;
    
      useEffect(() => {
        fetch("/api/images")
          .then((r) => r.json())
          .then((data: Item[]) => setItems(data));
      }, []);
    
      const rowCount = useMemo(
        () => Math.ceil(items.length / COLUMNS),
        [items.length]
      );
    
      const rowVirtualizer = useWindowVirtualizer({
        count: rowCount,
        estimateSize: () => ROW_HEIGHT,
        overscan: 8,
        scrollMargin: 0,
      });
    
      return (
        <div style={{ position: "relative", width: "100%" }}>
          <div
            style={{
              height: rowVirtualizer.getTotalSize(),
              width: "100%",
              position: "relative",
            }}
          >
            {rowVirtualizer.getVirtualItems().map((vr) => {
              const startIndex = vr.index * COLUMNS;
              const rowItems = items.slice(startIndex, startIndex + COLUMNS);
              const fillers = COLUMNS - rowItems.length;
    
              return (
                <div
                  key={vr.index}
                  data-row-index={vr.index}
                  style={{
                    position: "absolute",
                    top: 0,
                    left: 0,
                    width: "100%",
                    transform: `translateY(${vr.start}px)`,
                    display: "grid",
                    gridTemplateColumns: `repeat(${COLUMNS}, minmax(0, 1fr))`,
                    columnGap: GAP_X,
                    rowGap: GAP_Y,
                    padding: `${ROW_PAD_Y}px 8px`,
                    boxSizing: "border-box",
                    height: ROW_HEIGHT,
                  }}
                >
                  {rowItems.map((item) => (
                    <div
                      key={item.id}
                      style={{ display: "block", height: CARD_HEIGHT }}
                      className="border rounded-lg shadow-sm bg-white overflow-hidden"
                    >
                      <img
                        src={item.url}
                        alt={item.title}
                        className="w-full h-[140px] object-cover"
                        loading="lazy"
                      />
                      <p className="px-2 py-1 text-xs text-gray-700 truncate">
                        {item.title}
                      </p>
                    </div>
                  ))}
    
                  {fillers > 0 &&
                    Array.from({ length: fillers }).map((_, i) => (
                      <div
                        key={`${id}-${vr.index}-${i}`}
                        style={{ height: CARD_HEIGHT }}
                      />
                    ))}
                </div>
              );
            })}
          </div>
        </div>
      );
    }

     

    performance를 통한 확인

     

    확실히 script의 실행 시간이 준 것을 확인할 수 있다.

     

    그렇다면 배열은 어느정도일때 서버액션이 더 효율이 좋을까?

    그래서 나는 다음과 같이 배열의 크기를 바꾼 뒤 각 경우에 대해 확인 해 보았다.

     

    최대 병목 시간

     

    html streaming

    만개의 배열: 205ms

    1000개의 배열: 20ms

     

    window virtualizing

    만개의 배열: 62ms

    1000개의 배열: 60ms

     

     

    • Virtualizer는 클라이언트 랜더링 비용이 배열 크기와 상관없이 거의 고정이기 때문에, 배열이 커질수록 상대적인 장점이 커진다.
    • Server Action 전체 렌더링은 배열이 작을 때는 초기 표시 속도가 빠르지만, 배열이 커질수록 DOM 파싱/렌더링 비용 때문에 오히려 느려진다.

     

    ++추가 정보

    DOM 트리가 크면 다음과 같은 여러 가지 방법으로 페이지 성능이 느려질 수 있습니다.

    • 네트워크 효율성 및 부하 성능
    • 큰 DOM 트리에는 사용자가 페이지를 처음 로드할 때 표시되지 않는 노드가 많이 포함되어 있으므로 사용자의 데이터 비용이 불필요하게 증가하고 로드 시간이 느려집니다.
    • 런타임 성능
    • 사용자와 스크립트가 페이지와 상호작용할 때 브라우저는 끊임없이 노드의 위치와 스타일을 다시 계산해야 합니다. 큰 DOM 트리와 복잡한 스타일 규칙을 함께 사용하면 렌더링 및 상호작용 속도가 크게 저하될 수 있습니다.
    • 메모리 성능
    • JavaScript에서 document.querySelectorAll('li')와 같은 일반 쿼리 선택기를 사용하는 경우 의도치 않게 매우 많은 수의 노드에 대한 참조를 저장할 수 있으며, 이는 사용자 기기의 메모리 기능을 과도하게 사용하게 할 수 있습니다.

     

    Speed Index 점수를 개선하는 방법

    페이지 로드 속도를 개선하기 위해 취하는 모든 조치가 속도 지수 점수를 개선하지만 이러한 진단 감사에서 발견된 문제를 해결하면 특히 큰 영향을 미칩니다.

    따라서 배열의 크기가 커질 경우 lcp 측면에서 개선되는 부분이 훨씬 크고 대신 js 번들이 늘어나기 때문에 speed index 점수가 떨어질 수 있다.

     

     

     

    배열의 크기가 클 경우 적절한 방식은?

    그렇다면 큰 배열의 경우 또 고민이 생긴다. 백엔드에서 각 크기별로 잘라서 줄 것인지 

    모든 데이터를 받은 후 window virtualizing으로 보여줄 것인지

    그래서 나는 다음과 같이 비교해 보았다.

     

    다음 예시는 5만개의 배열을 백엔드를 통해 받는다.

     

     

    백엔드 요청을 잘라서 받을 경우

     

    코드

    "use client";
    
    import { useEffect, useState, useRef } from "react";
    
    interface Item {
      id: number;
      title: string;
      url: string;
    }
    
    export default function Page() {
      const [items, setItems] = useState<Item[]>([]);
      const [offset, setOffset] = useState(0);
      const [loading, setLoading] = useState(false);
      const loaderRef = useRef<HTMLDivElement | null>(null);
    
      // 요청 직렬화 큐
      const queue = useRef(Promise.resolve());
    
      async function fetchItems(nextOffset: number) {
        setLoading(true);
        const res = await fetch(`/api/scroll?limit=20&offset=${nextOffset}`);
        const data = await res.json();
    
        setItems((prev) => [...prev, ...data.items]);
        setOffset(data.nextOffset ?? null);
        setLoading(false);
      }
      
      //큐에 넣어 빼는 방식으로 순서 보장
      function enqueueFetch(nextOffset: number) {
        queue.current = queue.current.then(() => fetchItems(nextOffset));
      }
    
      useEffect(() => {
        if (!loaderRef.current) return;
    
        const observer = new IntersectionObserver(
          (entries) => {
            if (entries[0].isIntersecting && offset !== null && !loading) {
              enqueueFetch(offset);
            }
          },
          {
           //20% 스크롤 시 프리페치 진행
            threshold: 0.2,
            rootMargin: "200px",
          }
        );
    
        observer.observe(loaderRef.current);
        return () => observer.disconnect();
      }, [offset, loading]);
    
      return (
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, 200px)",
            gap: "12px",
          }}
        >
          {items.map((item) => (
            <div key={item.id}>
              <img src={item.url} alt={item.title} width={200} height={150} />
              <p>{item.title}</p>
            </div>
          ))}
    
          <div ref={loaderRef} style={{ height: "50px" }}>
            {loading ? <p>Loading...</p> : null}
          </div>
        </div>
      );
    }

     

    race condition의 문제가 생기지 않고 최대한 순서를 보장하기 위해 큐에 담아 넘겨주는 식으로 구현했고

    스크롤 threshold를 0.2로 설정하여 20%정도 스크롤했을 때 프리페치가 되도록 하여 끊김을 방지하고자 하였다. 

     

     

     

    네트워크 요청

     

    잘라서 스크롤에 따라 요청하다보니 확실히 빠른 것을 볼 수 있다.

     

    초기 랜더링 모습

    비교적 빠른 것을 볼 수 있다.

     

    스크롤 랜더링 모습

     

    확실히 잘라서 요청을 하기 때문에 빠르게 스크롤할 경우 스크롤이 끊기는 듯한 느낌이 들고

    요청에 대해 순차적으로 해야 되기 때문에 스크롤을 빠르게 건너뛸 수 없다.

     

     

    모든 데이터를 받아 window virtualizing으로 랜더할 경우

     

    코드

    "use client";
    
    import { useEffect, useMemo, useId, useState } from "react";
    import { useWindowVirtualizer } from "@tanstack/react-virtual";
    
    type Item = { id: number; title: string; url: string };
    
    export default function VirtualPage() {
      const [items, setItems] = useState<Item[]>([]);
      const id = useId();
    
      const COLUMNS = 8 as const;
      const CARD_HEIGHT = 180; 
      const GAP_X = 16; 
      const GAP_Y = 16; 
      const ROW_PAD_Y = 8; 
    
      const ROW_HEIGHT = CARD_HEIGHT + ROW_PAD_Y * 2 + GAP_Y;
    
      useEffect(() => {
        fetch("/api/images")
          .then((r) => r.json())
          .then((data: Item[]) => setItems(data));
      }, []);
    
      const rowCount = useMemo(
        () => Math.ceil(items.length / COLUMNS),
        [items.length]
      );
    
      const rowVirtualizer = useWindowVirtualizer({
        count: rowCount,
        estimateSize: () => ROW_HEIGHT,
        overscan: 8,
        scrollMargin: 0,
      });
    
      return (
        <div style={{ position: "relative", width: "100%" }}>
          <div
            style={{
              height: rowVirtualizer.getTotalSize(),
              width: "100%",
              position: "relative",
            }}
          >
            {rowVirtualizer.getVirtualItems().map((vr) => {
              const startIndex = vr.index * COLUMNS;
              const rowItems = items.slice(startIndex, startIndex + COLUMNS);
              const fillers = COLUMNS - rowItems.length;
    
              return (
                <div
                  key={vr.index}
                  data-row-index={vr.index}
                  style={{
                    position: "absolute",
                    top: 0,
                    left: 0,
                    width: "100%",
                    transform: `translateY(${vr.start}px)`,
                    display: "grid",
                    gridTemplateColumns: `repeat(${COLUMNS}, minmax(0, 1fr))`,
                    columnGap: GAP_X,
                    rowGap: GAP_Y, 
                    padding: `${ROW_PAD_Y}px 8px`,
                    boxSizing: "border-box",
                    height: ROW_HEIGHT, 
                  }}
                >
                  {rowItems.map((item) => (
                    <div
                      key={item.id}
                      style={{ display: "block", height: CARD_HEIGHT }}
                      className="border rounded-lg shadow-sm bg-white overflow-hidden"
                    >
                      <img
                        src={item.url}
                        alt={item.title}
                        className="w-full h-[140px] object-cover"
                        loading="lazy"
                      />
                      <p className="px-2 py-1 text-xs text-gray-700 truncate">
                        {item.title}
                      </p>
                    </div>
                  ))}
    
                  {fillers > 0 &&
                    Array.from({ length: fillers }).map((_, i) => (
                      <div
                        key={`${id}-${vr.index}-${i}`}
                        style={{ height: CARD_HEIGHT }}
                      />
                    ))}
                </div>
              );
            })}
          </div>
        </div>
      );
    }

     

     

    네트워크 요청

     

    모든 데이터를 다 받아오다 보니 네트워크의 지연이 생기는 것을 볼 수 있다.

     

     

    초기 랜더링 속도

    확실히 느린것을 볼 수 있다.

     

     

    스크롤 랜더링 모습

     

    모든 데이터를 이미 받아왔기 때문에 스크롤을 건너뛰어도 되는 것을 볼 수 있고

    확실히 잘라 요청하는 것보다 부드러운 스크롤을 보여주는 것을 볼 수 있다.

     

     

    그렇다면 어느 방식이 나을까? 

     

    프레임 병목 시간

    performance를 분석해 본 결과 프레임측에서 많은 차이가 나는 것을 볼 수 있어

    이 수치를 통해 비교했다.

     

     

    데이터 분할

    10만개의 배열: 51ms

    1만개의 배열: 51ms

    1천개의 배열: 51ms

     

    virtual window

    10만개의 배열: 850ms

    1만개의 배열: 158ms

    1천개의 배열: 116ms

     

    -> 천개이내의 배열의 경우에는 virtual window가 사용자 측면에서 훨씬 좋은 것 같다.

    이후 데이터 확장 시에는 데이터를 한번에 받아오는 것보다

    데이터 분할 후 받아오는 것이 적절해 보인다. 

     

     

    전체 만개의 배열 요약

    server action

    window virtualizing

     

    데이터 분할

    반응형