June 25, 2025

과도한 클라이언트 측 데이터 처리: 비효율적인 성능과 UX 📉

JavaScript
성능
UX
상태관리

Summary

대용량 데이터를 클라이언트에서 필터링, 정렬, 페이지네이션하는 것은 네트워크 대역폭 낭비, 메모리 사용량 증가, 그리고 심각한 UI 성능 저하를 야기합니다. 대신 서버 측 API를 적극 활용하여 필요한 데이터만 가져오고, 점진적 로딩 전략을 도입해야 합니다.

Why Wrong?

데이터 페칭 시, API가 제공하는 필터링, 정렬, 페이지네이션 기능을 사용하지 않고 모든 데이터를 클라이언트로 가져온 후 JavaScript로 처리하는 것은 여러 문제를 야기합니다.

  1. 네트워크 비효율성: 필요한 데이터의 일부만 보여주는 상황에서도 모든 데이터를 전송하므로, 불필요한 네트워크 트래픽과 로딩 시간을 증가시킵니다. 이는 모바일 환경이나 저속 네트워크 사용자에게 특히 치명적입니다.
  2. 클라이언트 자원 소모: 대용량 데이터를 메모리에 적재하고 조작하는 과정에서 클라이언트의 CPU 및 메모리 자원을 과도하게 사용합니다. 이는 특히 저사양 기기에서 UI가 버벅거리거나 응답이 느려지는 현상을 초래합니다.
  3. 사용자 경험 저하: 초기 데이터 로딩이 느리고, 필터링이나 페이지 전환 시 UI가 멈추거나 지연되는 등 비정상적인 사용자 경험을 제공하게 됩니다.
  4. 확장성 부족: 데이터 양이 증가할수록 시스템의 한계에 빠르게 도달하여 유지보수 및 확장을 어렵게 만듭니다.

How to Fix?

대부분의 현대적인 API는 서버 측에서 데이터 필터링, 정렬, 페이지네이션 기능을 제공합니다. 이를 적극적으로 활용하여 효율적인 데이터 처리를 구현해야 합니다.

  1. 서버 측 페이지네이션/필터링/정렬: 필요한 데이터 범위(페이지 번호, 페이지 크기)나 필터 조건, 정렬 기준 등을 API 요청의 쿼리 파라미터로 전달하여 서버에서 해당 로직을 처리하게 합니다. 서버는 요청된 데이터의 일부만 응답하여 네트워크 트래픽을 최소화하고 클라이언트 부담을 줄입니다.
  2. 점진적 로딩 (Lazy Loading / Infinite Scroll): 초기에는 최소한의 데이터만 로드하고, 사용자가 스크롤을 내리거나 다음 페이지로 이동할 때 추가 데이터를 요청하여 로드하는 방식입니다. 이는 초기 로딩 속도를 향상시키고, 사용자에게 연속적인 경험을 제공합니다.
  3. 데이터 캐싱 전략: 자주 접근하는 데이터나 변경이 적은 데이터는 클라이언트 측에서 캐싱하여 불필요한 API 호출을 줄일 수 있습니다. (예: React Query, SWR 등의 라이브러리 활용)
  4. Debouncing/Throttling: 검색 입력 등 사용자의 빈번한 인터랙션으로 인해 API 요청이 과도하게 발생하는 것을 방지하기 위해 Debouncing 또는 Throttling을 적용하여 불필요한 요청을 줄입니다.

Before Code (Bad)

// 가정: 백엔드에서 모든 상품 데이터를 한 번에 응답한다고 가정
// 실제 애플리케이션에서는 수천, 수만 개의 상품 데이터가 될 수 있습니다.
const allProductsFromServer = [
  { id: 1, name: '사과', category: '과일' },
  { id: 2, name: '바나나', category: '과일' },
  // ... (수천 개의 데이터)
  { id: 9999, name: '감자', category: '채소' },
];

function ProductListBefore() {
  const [searchTerm, setSearchTerm] = React.useState('');
  const [currentPage, setCurrentPage] = React.useState(1);
  const itemsPerPage = 10;

  // ⚠️ 안티패턴: 모든 데이터를 클라이언트로 가져와서 필터링
  const filteredProducts = React.useMemo(() => {
    return allProductsFromServer.filter(product =>
      product.name.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }, [searchTerm]);

  // ⚠️ 안티패턴: 필터링된 모든 데이터에서 페이지네이션
  const indexOfLastItem = currentPage * itemsPerPage;
  const indexOfFirstItem = indexOfLastItem - itemsPerPage;
  const currentProducts = filteredProducts.slice(indexOfFirstItem, indexOfLastItem);

  const totalPages = Math.ceil(filteredProducts.length / itemsPerPage);

  return (
    <div>
      <h2>전체 상품 ({allProductsFromServer.length})</h2>
      <input
        type="text"
        placeholder="상품 검색..."
        value={searchTerm}
        onChange={(e) => {
          setSearchTerm(e.target.value);
          setCurrentPage(1); // 검색어 변경 시 첫 페이지로 리셋
        }}
      />
      <p>현재 페이지: {currentPage} / {totalPages}</p>
      <ul>
        {currentProducts.length > 0 ? (
          currentProducts.map(product => (
            <li key={product.id}>{product.name} ({product.category})</li>
          ))
        ) : (
          <li>검색 결과가 없습니다.</li>
        )}
      </ul>
      <div>
        {Array.from({ length: totalPages }, (_, i) => (
          <button 
            key={i} 
            onClick={() => setCurrentPage(i + 1)}
            disabled={currentPage === i + 1}
            style={{ fontWeight: currentPage === i + 1 ? 'bold' : 'normal', margin: '0 5px' }}
          >
            {i + 1}
          </button>
        ))}
      </div>
    </div>
  );
}

After Code (Good)

// 서버는 검색어, 페이지, 페이지당 항목 수를 받아 해당하는 데이터와 전체 개수를 반환한다고 가정
// 예: /api/products?search=사과&page=1&limit=10 -> { products: [...], totalCount: 25 }

function ProductListAfter() {
  const [searchTerm, setSearchTerm] = React.useState('');
  const [currentPage, setCurrentPage] = React.useState(1);
  const [products, setProducts] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [totalPages, setTotalPages] = React.useState(0);
  const itemsPerPage = 10; // 서버와 협의된 페이지당 항목 수

  // 💡 검색어 입력 시 불필요한 API 호출을 줄이기 위한 Debounce 훅 사용
  const debouncedSearchTerm = useDebounce(searchTerm, 500); 

  React.useEffect(() => {
    const fetchProducts = async () => {
      setLoading(true);
      try {
        // ✅ 올바른 패턴: 서버 측 API에 검색어, 페이지, 페이지당 항목 수 전달
        const response = await fetch(
          `/api/products?search=${debouncedSearchTerm}&page=${currentPage}&limit=${itemsPerPage}`
        );
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        setProducts(data.products); // 서버에서 필터링/페이지네이션된 데이터만 받음
        setTotalPages(Math.ceil(data.totalCount / itemsPerPage)); // 서버에서 전체 개수 받음
      } catch (error) {
        console.error("상품을 불러오는 데 실패했습니다.", error);
        setProducts([]); // 에러 발생 시 데이터 초기화
        setTotalPages(0);
      } finally {
        setLoading(false);
      }
    };
    fetchProducts();
  }, [debouncedSearchTerm, currentPage]); // 검색어 또는 페이지 변경 시에만 API 호출

  // useDebounce 훅 예시 (실제 구현 시 별도 파일 또는 유틸리티 라이브러리 사용 권장)
  function useDebounce(value, delay) {
    const [debouncedValue, setDebouncedValue] = React.useState(value);

    React.useEffect(() => {
      const handler = setTimeout(() => {
        setDebouncedValue(value);
      }, delay);

      return () => {
        clearTimeout(handler);
      };
    }, [value, delay]);

    return debouncedValue;
  }

  return (
    <div>
      <h2>상품 목록</h2>
      <input
        type="text"
        placeholder="상품 검색..."
        value={searchTerm}
        onChange={(e) => {
          setSearchTerm(e.target.value);
          setCurrentPage(1); // 검색어 변경 시 첫 페이지로 리셋
        }}
      />
      {loading ? (
        <div>상품 목록 로딩 중...</div>
      ) : (
        <>
          <p>현재 페이지: {currentPage} / {totalPages}</p>
          <ul>
            {products.length > 0 ? (
              products.map(product => (
                <li key={product.id}>{product.name} ({product.category})</li>
              ))
            ) : (
              <li>검색 결과가 없습니다.</li>
            )}
          </ul>
          <div>
            {Array.from({ length: totalPages }, (_, i) => (
              <button
                key={i}
                onClick={() => setCurrentPage(i + 1)}
                disabled={currentPage === i + 1}
                style={{ fontWeight: currentPage === i + 1 ? 'bold' : 'normal', margin: '0 5px' }}
              >
                {i + 1}
              </button>
            ))}
          </div>
        </>
      )}
    </div>
  );
}