Logo

React로 필터 UI 구현하기 (+ URL 동기화)

전자 상거래와 같이 많은 양의 데이터를 다루는 서비스에서 필터(filter)는 사용자가 데이터를 추려낼 수 있는 도와주는 매우 중요한 UI입니다. 이번 포스팅에서는 아래와 같은 간단한 상품 목록 페이지를 구현하면서 필터(filter) UI를 어떻게 React로 개발하는지 알아보겠습니다.

상품 목록 컴포넌트 구현

우선 단순히 모든 상품 목록을 보여주는 React 컴포넌트를 작성하겠습니다.

<Products/> 컴포넌트는 prop으로 상품 배열(products)와 로딩 여부(loading)를 받습니다. 그리고 아직 데이터를 로딩 중이라면 잠시만 기다려달라는 메시지를 화면에 보여주고요. 로딩이 끝났다면 상품 배열을 루프 돌면서 각 상품의 이름과 사진을 화면에 보여줍니다.

Products.jsx
function Products({ products, loading }) {
  if (loading)
    return (
      <article aria-busy="true">
        잠시만 기다려주세요. 상품 데이터를 불러오고 있습니다.
      </article>
    );
  return (
    <>
      {products.map((product) => (
        <article key={product.id}>
          <header>{product.title}</header>
          <img src={product.thumbnail} alt={product.title} />
        </article>
      ))}
    </>
  );
}

상품 데이터 API 호출

이제 최상위 <App/> 컴포넌트에서 원격 API를 호출하여 그 결과를 <Products> 컴포넌트에 전달할 건데요.

우선 상품 목록과 로딩 여부를 상태로 관리해야하는데요. 이를 위해 useState() 훅(hook)을 사용하겠습니다.

상품 데이터는 DummyJSON이라는 인터넷에 공개된 API를 호출하여 불러올 건데요. useEffect() 훅을 통해서 API를 비동기로 호출하고 상품 목록과 로딩 여부 상태를 갱신합니다. 그리고, 로딩 중 메세지를 유관으로 확인할 수 있도록 일부로 setTimeout() 함수를 사용하여 일부록 1초의 지연을 주었습니다.

App.jsx
import React from "react";
import Products from "./Products";

function App() {
  const [products, setProducts] = React.useState([]);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    setTimeout(() => {
      fetch("https://dummyjson.com/products")
        .then((res) => res.json())
        .then(({ products }) => {
          setProducts(products);
          setLoading(false);
        });
    }, 1000);
  }, []);

  return (
    <div className="grid">
      <header className="container">
        <h1>상품 목록</h1>
      </header>
      <main className="container">
        <Products products={products} loading={loading} />
      </main>
    </div>
  );
}

위 코드를 이해하기 어려우신 분들은 아래 포스팅을 통해서 관련 내용을 선수 학습을 하시고 돌아오시면 도움이 되실 거에요.

필터 양식 컴포넌트 구현

이제 사용자가 카테고리와 브랜드를 선택하고 검색어를 입력할 수 있도록 세 개의 필터로 이루어진 양식 UI를 구현할 차례입니다.

사용자가 입력한 값은 컴포넌트 내부적으로 values 상태로 관리하고요. 세 개의 필터에 대한 초기값과 양식 제출을 위한 이벤트 핸들러 함수는 prop으로 받겠습니다.

FilterForm.jsx
import React from "react";

function FilterForm({ initialValues, onSubmit }) {
  const [values, setValues] = React.useState(initialValues);

  React.useEffect(() => {
    setValues(initialValues);
  }, [initialValues]);

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit(values);
      }}
    >
      <select
        value={values.category}
        onChange={({ target: { value } }) =>
          setValues({ ...values, category: value })
        }
      >
        <option value="">모두</option>
        <option value="smartphones">스마트폰</option>
        <option value="laptops">노트북</option>
        <option value="fragrances">향수</option>
        <option value="skincare">피부관리</option>
        <option value="groceries">식료품</option>
        <option value="home-decoration">장식품</option>
      </select>
      <fieldset>
        <div className="grid">
          <label>
            <input
              type="checkbox"
              checked={values.brands.includes("Apple")}
              onChange={({ target: { checked } }) =>
                checked
                  ? setValues({
                      ...values,
                      brands: values.brands.concat("Apple"),
                    })
                  : setValues({
                      ...values,
                      brands: values.brands.filter(
                        (brand) => brand !== "Apple"
                      ),
                    })
              }
            />
            Apple
          </label>
          <label>
            <input
              type="checkbox"
              checked={values.brands.includes("Samsung")}
              onChange={({ target: { checked } }) =>
                checked
                  ? setValues({
                      ...values,
                      brands: values.brands.concat("Samsung"),
                    })
                  : setValues({
                      ...values,
                      brands: values.brands.filter(
                        (brand) => brand !== "Samsung"
                      ),
                    })
              }
            />
            Samsung
          </label>
          <label>
            <input
              type="checkbox"
              checked={values.brands.includes("Huawei")}
              onChange={({ target: { checked } }) =>
                checked
                  ? setValues({
                      ...values,
                      brands: values.brands.concat("Huawei"),
                    })
                  : setValues({
                      ...values,
                      brands: values.brands.filter(
                        (brand) => brand !== "Huawei"
                      ),
                    })
              }
            />
            Huawei
          </label>
        </div>
      </fieldset>
      <input
        placeholder="검색어 입력"
        value={values.search}
        onChange={({ target: { value } }) =>
          setValues({ ...values, search: value })
        }
      />
      <button>검색</button>
    </form>
  );
}

필터링된 상품만 표시

상품 배열을 상대로 filter() 함수를 연쇄적으로 호출하여 각 필터에 설정된 값에 부합하는 상품만 추려내겠습니다. useMemo() 훅을 사용하여 이 추려진 상품 목록을 filteredProducts 변수에 저장해놓으면, 상품 목록이나 필터 값이 바뀌었을 때만 필터링을 일어나므로 불필요한 연산을 줄일 수 있을 것입니다.

useMemo()에 대한 내용은 관련 포스팅를 참고하시기 바랍니다.

App.jsx
import React from "react";
import FilterForm from "./FilterForm";import Products from "./Products";

function App() {
  const [products, setProducts] = React.useState([]);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    setTimeout(() => {
      fetch("https://dummyjson.com/products")
        .then((res) => res.json())
        .then(({ products }) => {
          setProducts(products);
          setLoading(false);
        });
    }, 1000);
  }, []);

  const [filterValues, setFilterValues] = React.useState({    category: "",    brands: [],    search: "",  });
  const filteredProducts = React.useMemo(    () =>      products        .filter(          (product) =>            !filterValues.category || product.category === filterValues.category        )        .filter(          (product) =>            !filterValues.brands ||            filterValues.brands.length === 0 ||            filterValues.brands.includes(product.brand)        )        .filter(          (product) =>            !filterValues.search ||            product.title              .toLowerCase()              .includes(filterValues.search.toLowerCase()) ||            product.description              .toLowerCase()              .includes(filterValues.search.toLowerCase())        ),    [products, filterValues]  );
  return (
    <>
      <header className="container">
        <hgroup>
          <h1>상품 목록</h1>
          <h2>            {filteredProducts.length} / {products.length}          </h2>        </hgroup>
        <FilterForm initialValues={filterValues} onSubmit={setFilterValues} />      </header>
      <main className="container">
        <Products products={filteredProducts} loading={loading} />      </main>
    </>
  );
}

이제 필터 값들을 변경하고 검색 버튼을 클릭해보면 그에 따라 필더링된 상품만 나타날 것입니다.

커스텀 훅으로 비즈니스 로직

이쯤되니 <App/> 컴포넌트의 코드가 너무 지저분해져서 리팩토링(refactoring)을 하면 좋을 것 같습니다.

컴포넌트 코드를 보시면 비지니스 로직이 실제 UI를 그리는 코드보다 더 많아졌다는 것을 알 수 있는데요. API를 호출하고 데이터를 필터링하기 위한 코드를 useProducts()이라는 커스텀 훅으로 분리해내보겠습니다.

useProducts.js
import React from "react";

function useProducts() {
  const [products, setProducts] = React.useState([]);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    setTimeout(() => {
      fetch("https://dummyjson.com/products")
        .then((res) => res.json())
        .then(({ products }) => {
          setProducts(products);
          setLoading(false);
        });
    }, 1000);
  }, []);

  const [filterValues, setFilterValues] = React.useState({
    category: "",
    brands: [],
    search: "",
  });

  const filteredProducts = React.useMemo(
    () =>
      products
        .filter(
          (product) =>
            !filterValues.category || product.category === filterValues.category
        )
        .filter(
          (product) =>
            !filterValues.brands ||
            filterValues.brands.length === 0 ||
            filterValues.brands.includes(product.brand)
        )
        .filter(
          (product) =>
            !filterValues.search ||
            product.title
              .toLowerCase()
              .includes(filterValues.search.toLowerCase()) ||
            product.description
              .toLowerCase()
              .includes(filterValues.search.toLowerCase())
        ),
    [products, filterValues]
  );

  return {
    total: products.length,
    products: filteredProducts,
    loading,
    filterValues,
    submitFilter: setFilterValues,
  };
}

이렇게 비지니스 로직을 useProducts() 훅으로 분리하면 <App/> 컴포넌트의 코드는 다시 깔끔하게 정리될 것입니다.

App.jsx
import React from "react";
import FilterForm from "./FilterForm";
import Products from "./Products";
import useProducts from "./useProducts";
function App() {
  const { total, products, loading, filterValues, submitFilter } =    useProducts();
  return (
    <>
      <header className="container">
        <hgroup>
          <h1>상품 목록</h1>
          <h2>
            {products.length} / {total}          </h2>
        </hgroup>
        <FilterForm initialValues={filterValues} onSubmit={submitFilter} />
      </header>
      <main className="container">
        <Products products={products} loading={loading} />      </main>
    </>
  );
}

필터와 URL 동기화

여기까지 구현하시면 어느 정도 필터로서 기본적인 기능이 완성이 되지만 한 가지 보완할 부분이 있는데요. 바로 브라우저에서 페이지를 새로고침하면 기존에 필터에 설정해 놓은 값들이 모두 사라진다는 것입니다. 만약에 필터가 많다면 사용자들이 매우 불편하겠죠?

사실 이 문제는 SPA(Single Page App)에서 클라이언트 단 필터링을 구현할 때 흔하게 발생하는 문제인데요. 보통 필터에 입력된 값들과 URL의 쿼리 스트링(query string)을 동기화시켜 해결합니다.

이렇게 해주면 새로고침이나 뒤로 가기, 앞으로 가기를 해도 기존에 설정해놓은 필터값들이 그대로 보존이 되고요. URL을 즐겨찾기로 등록해놓거나 다른 사용자와 공유할 수도 있어서 사용자 경험을 크게 향상시킬 수 잇습니다.

우선 URL의 쿼리 스트링으로 저장되어 있는 검색 파라미터를 읽어서 초기 필터 값에 반영을 해야하는데요. 이를 위해서 qs 라이브러리리의 parse() 함수를 사용합니다.

그리고 window 객체에 popstate 이벤트가 발생할 때 마다 URL의 검색 파라미터를 필터 값에 반영해줍니다.

마지막으로 필터가 제출될 때 마다 history 객체의 pushState() 함수를 호출하여 URL을 갱신해줍니다. 이를 위해서 qs 라이브러리리의 stringify() 함수를 사용합니다.

쿼리 스트링을 다루기 위해서 사용한 qs 라이브러리에 대해서는 별도 포스팅을 참고 바랍니다.

useProducts.js
import React from "react";
import qs from "qs";
const defaultFilterValues = {  category: "",  brands: [],  search: "",};
function useProducts() {
  const [products, setProducts] = React.useState([]);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    setTimeout(() => {
      fetch("https://dummyjson.com/products")
        .then((res) => res.json())
        .then(({ products }) => {
          setProducts(products);
          setLoading(false);
        });
    }, 1000);
  }, []);

  const [filterValues, setFilterValues] = React.useState({    ...defaultFilterValues,    ...qs.parse(window.location.search),  });
  React.useEffect(() => {    const handlePopState = () => {      setFilterValues({        ...defaultFilterValues,        ...qs.parse(window.location.search),      });    };    window.addEventListener("popstate", handlePopState);    return () => window.removeEventListener("popstate", handlePopState);  }, []);
  const submitFilter = (filter) => {    setFilterValues(filter);    window.history.pushState({}, "", "?" + qs.stringify(filter));  };
  const filteredProducts = React.useMemo(
    () =>
      products
        .filter(
          (product) =>
            !filterValues.category || product.category === filterValues.category
        )
        .filter(
          (product) =>
            !filterValues.brands ||
            filterValues.brands.length === 0 ||
            filterValues.brands.includes(product.brand)
        )
        .filter(
          (product) =>
            !filterValues.search ||
            product.title
              .toLowerCase()
              .includes(filterValues.search.toLowerCase()) ||
            product.description
              .toLowerCase()
              .includes(filterValues.search.toLowerCase())
        ),
    [products, filterValues]
  );

  return {
    total: products.length,
    products: filteredProducts,
    loading,
    filterValues,
    submitFilter,
  };
}

이제 필터 값을 설정한 후 제출할 때 마다 브라우저 주소창의 URL이 그에 따라 바뀔 것입니다. 반대로, 새로 고침이나 뒤로 가기, 앞으로 가기를 하면 브라우저 주소청의 URL에 따라 필터 값이 설정될 것입니다.

클라이언트 단 라우팅에 활용되는 자바스크립트의 History API에 대해서는 별도 포스팅에 자세히 설명 해놓았으니 참고 바랍니다.

전체 코드

본 포스팅에서 작성한 코드는 아래에서 확인하고 직접 실행해보실 수 있습니다.

마치면서

지금까지 실습을 통해서 어떻게 React를 사용해서 필터 UI를 구현할 수 있는지 살펴보았습니다. 필터를 구현하게 되면 자연스럽게 다음 단계로 페이지네이션(pagination)을 고려하시게 되실텐데요. 이 부분에 대해서는 별도 포스팅에서 다뤄보도록 하겠습니다.