React 리스트 필터링 앱 만들어보기

0

React는 JavaScript로 규모가 크고 빠른 웹 애플리케이션을 만드는 좋은 선택지 중에 하나로, 이미 FacebookInstagram을 통해 확장성을 입증했습니다.

React의 최대 장점 중에 하나는 앱을 설계하는 방식인데, React로 상품들을 검색할 수 있는 데이터 테이블을 만드는 과정을 통해 알아보겠습니다. 디자이너로 부터 전달받은 목업과 JSON API 데이터는 다음과 같습니다.

리스트 디자인 Mock up
JSON API 데이터
[
  {category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
  {category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
  {category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
  {category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
  {category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
  {category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];

1단계: UI를 컴포넌트 계층 구조로 나누기

우선은 첫 번째로 모든 컴포넌트와 하위 컴포넌트의 주변에 박스를 그리고 그 각각에 이름을 붙이는 것이 도움이 되는데, 하나의 컴포넌트는 한 가지 일을 하는 것이 이상적이고, 하나의 컴포넌트가 커지게 된다면 이는 보다 작은 하위 컴포넌트로 분리해 주는 것이 좋습니다.

리스트형 애플리케이션은 주로 JSON 데이터를 유저에게 보여주기 때문에, 데이터 모델이 적절하게 만들어져야 하는데, UI와 데이터 모델은 같은 인포메이션 아키텍처를 가지는 경향이 있습니다.

컴포넌트의 구조 설계

위 이미지와 같이 이번에 만들어 볼 앱은 다섯개의 컴포넌트로 이루어져 있는데, 각각의 컴포넌트의 역할은 다음과 같습니다.

  • FilterableProductTable: 예시 전체를 포괄함
  • SearchBar: 모든 유저의 입력을 받음
  • ProductTable: 유저의 입력을 기반으로 데이터 콜렉션을 필터링해서 보여줌
  • ProductCategoryRow: 각 카테고리의 헤더를 보여줌
  • ProductRow: 각각의 제품에 해당하는 행을 보여줌

ProductTable을 보면 NamePrice 레이블을 포함한 테이블 헤더만을 가진 컴포넌트는 없는데, 이런 경우 데이터를 위한 독립된 컴포넌트를 생성할지 말지는 선택할 수 있습니다. 여기서는 ProductTable의 책임인 데이터 컬렉션이 렌더링의 일부이기 때문에 ProductTable을 남겨뒀는데, 이 정렬 기능 등으로 인해 헤더가 복잡해지면 ProductTableHeader 컴포넌트를 만드는 것이 더 합리적일 수 있습니다.

목업의 컴포넌트를 계층 구조로 나타내면 다음과 같습니다.

컴포넌트의 계층 구조
FilterableProductTable
    └ SearchBar
    └ ProductTable
        └ ProductCategoryRow
        └ ProductRow

2단계: React로 정적인 버전 만들기

컴포넌트 계층구조를 만들었다면, 데이터 모델을 가지고 UI를 렌더링은 되지만 아무 동작도 없는 정적인 버전을 만들어 볼 필요가 있는데, 빠르게 UI를 구현해 볼 수 있다는 장점이 있습니다.

데이터 모델을 렌더링하는 앱의 정적 버전을 만들기 위해서는 다른 컴포넌트를 재사용하는 컴포넌트를 만들고 props를 이용해 데이터를 전달해줘야 하는데, props는 부모가 자식에게 데이터를 넘겨줄 때 사용할 수 있는 방법입니다.

정적인 버전을 만들 때는 state를 사용할 필요가 없는데, state는 오직 상호작용, 즉 시간이 지남에 따라 데이터가 바뀌는 것을 구현할 때 사용하기 때문입니다.

앱을 만들 때는 하향식이나 상향식으로 만들 수 있는데, 간단한 예시에서는 보통 하향식으로 만드는 것이 쉽지만 프로젝트가 커지면 상향식으로 만들고 테스트를 작성하면서 개발하는 것이 더 쉬울 수 있습니다.

정적인 버전의 리스트 앱
class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

class ProductTable extends React.Component {
  render() {
    const rows = [];
    let lastCategory = null;

    this.props.products.forEach((product) => {
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name} />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

class SearchBar extends React.Component {
  render() {
    return (
      <form>
        <input type="text" placeholder="Search..." />
        <p>
          <input type="checkbox" />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

class FilterableProductTable extends React.Component {
  render() {
    return (
      <div>
        <SearchBar />
        <ProductTable products={this.props.products} />
      </div>
    );
  }
}

const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

const root = ReactDOM.createRoot(document.getElementById('container'));
root.render(<FilterableProductTable products={PRODUCTS} />);

위와 같이 정적인 버전을 만들면 데이터 렌더링을 위해 만들어진 재사용 가능한 컴포넌트들의 라이브러리를 가지게 되는데, 계층구조의 최상단 컴포넌트prop으로 데이터 모델을 받고, 데이터 모델이 변경되면 ReactDOM.render()를 다시 호출해서 UI가 업데이트되는 것을 확인할 수 있습니다.

3단계: UI state 파악하기

UI를 상호작용하게 만들려면 기반 데이터 모델을 변경할 수 있는 방법이 있어야 하는데, React에서는 state를 통해 이를 수행할 수 있습니다.

앱을 올바르게 만들기 위해서는 앱에서 필요로 하는 변경 가능한 state의 최소 집합을 생각해 볼 필요가 있는데, 여기서 핵심은 중복배제원칙입니다. 즉 앱이 필요로 하는 가장 최소한의 state를 찾고 이를 통해 나머지 모든 것들은 필요에 따라 그때그때 계산되도록 만드는 겁니다.

예를 들어 TODO 리스트를 만든다고 하면, TODO 아이템을 저장하는 배열만 유지하고 TODO 아이템의 개수를 표현하는 state는 별도로 만들 필요가 없는데, TODO 갯수를 렌더링해야 한다면 TODO 아이템 배열의 길이를 가져오면 되기 때문입니다.

  • 제품의 원본 목록
  • 유저가 입력한 검색어
  • 체크박스의 값
  • 필터링 된 제품들의 목록

현재의 리스트 앱은 다음과 같은 데이터를 가지고 있는데, 각각의 데이터를 살펴보고 어떤 데이터에 state가 되어야 하는지 결정할 필요가 있습니다. 결정이 어렵다면 다음의 세 가지 질문을 참고해 보세요.

부모로부터 props를 통해 전달되는가?
부모로부터 props를 통해 전달된다면 확실히 state가 아니다.
시간이 지나도 변하지 않는가?
시간이 지나도 변하지 않는다면 확실히 state가 아니다.
컴포넌트 안의 다른 stateprops를 가지고 계산이 가능한가?
다른 stateprops로 계산이 가능하다면 state가 아니다.

위 질문을 통해 우리는 제품의 원본 목록props를 통해 전달되기 때문에 state가 아니고, 시간이 지남에 따라 변하기도 하면서 다른 것들로부터 계산될 수 없는 검색어체크박스state로 결정할 수 있습니다.

마지막으로 필터링된 목록은 제품의 원본 목록과 검색어, 체크박스의 값을 조합해서 계산해낼 수 있기 때문에 state가 아니기 때문에 리스트 앱은 다음과 같은 state를 가지게 됩니다.

  • 유저가 입력한 검색어
  • 체크박스의 값

4단계: state의 적용 위치 찾기

앱에서 최소한으로 필요한 state가 무엇인지 찾아냈다면, 다음으로 어떤 컴포넌트가 state를 변경하거나 소유할지를 결정해야 합니다.

React는 항상 컴포넌트 계층구조를 따라 아래로 내려가는 단방향 데이터 흐름을 따르는데, 어떤 컴포넌트가 어떤 state를 가져야 하는지 결정하기 어렵다면 다음의 과정을 따르는 것이 도움이 될 수 있습니다.

애플리케이션이 가지는 각각의 state에 대해:

  • state를 기반으로 렌더링하는 모든 컴포넌트 찾기
  • 공통 소유 컴포넌트 찾기(공통 혹은 더 상위에 있는 컴포넌트가 state를 가져야 함)
  • state를 소유할 적절한 컴포넌트를 찾지 못했다면, state를 소유하는 컴포넌트를 하나 만들어서 공통 오너 컴포넌트의 상위 계층에 추가하기

공통 소유 컴포넌트란 계층 구조 내에서 특정 state가 있어야 하는 모든 컴포넌트들의 상위에 있는 하나의 컴포넌트를 말합니다.

위 전략을 리스트 앱에 적용하면 다음과 같이 state를 적용할 위치를 찾을 수 있습니다.

  • ProductTablestate에 의존한 상품 리스트를 필터링
  • SearchBar는 검색어와 체크박스의 상태를 표시
  • 공통 소유 컴포넌트는 FilterableProductTable(의미상으로도 FilterableProductTable이 검색어와 체크박스의 체크 여부를 가지는 것이 타당함)

이 전략에 따라 stateFilterableProductTable에 두고, 먼저 인스턴스 속성인 this.state = {filterText: '', inStockOnly: false}FilterableProductTableconstructor에 추가하여 애플리케이션의 초기 상태를 반영해 준 후, filterTextinStockOnlyProductTableSearchBarprop으로 전달합니다. 그리고 마지막으로 이 props를 사용하여 ProductTable의 행을 정렬하고 SearchBar의 폼 필드 값을 설정하면 앱이 동작하는 것을 볼 수 있습니다.

리스트 앱에 state 적용하기
class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

class ProductTable extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    const rows = [];
    let lastCategory = null;

    this.props.products.forEach((product) => {
      if (product.name.indexOf(filterText) === -1) {
        return;
      }
      if (inStockOnly && !product.stocked) {
        return;
      }
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name}
        />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

class SearchBar extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    return (
      <form>
        <input
          type="text"
          placeholder="Search..."
          value={filterText} />
        <p>
          <input
            type="checkbox"
            checked={inStockOnly} />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

class FilterableProductTable extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      filterText: '',
      inStockOnly: false
    };
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
        <ProductTable
          products={this.props.products}
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
      </div>
    );
  }
}

const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

const root = ReactDOM.createRoot(document.getElementById('container'));
root.render(<FilterableProductTable products={PRODUCTS} />);

위 코드에서 filterText"ball"로 설정하고 을 새로고침하면, 데이터 테이블이 올바르게 업데이트 되는 것을 확인할 수 있습니다.

5단계: 역방향 데이터 흐름 추가하기

지금까지 계층 구조 아래로 흐르는 propsstate의 함수로 리스트 앱을 만들었는데, 계층 구조의 하단에 있는 폼 컴포넌트에서는 FilterableProductTablestate를 업데이트할 수 있어야 합니다.

이런 경우 React는 전통적인 양방향 데이터 바인딩과 비교할 때 더 많은 타이핑을 필요로 하지만, 데이터 흐름을 명시적으로 보이게 만들어 프로그램이 어떻게 동작하는지 쉽게 파악할 수 있습니다.

4단계에서는 체크를 하거나 키보드를 타이핑할 경우 React가 입력을 무시하는 것을 확인할 수 있는데, 이는 input 태그의 value 속성이 항상 FilterableProductTable에서 전달된 state와 동일하도록 설정했기 때문입니다.

최종적으로 리스트 앱은 사용자가 폼을 변경할 때마다 사용자의 입력을 반영하여 state를 업데이트해야 하는데, 컴포넌트는 자신의 state만 변경할 수 있기 때문에 FilterableProductTableSearchBar에 콜백을 넘겨 state가 업데이트 될 때마다 호출되도록 만들어야 합니다.

이를 위해 inputonChange 이벤트를 사용해서 알림을 받고, FilterableProductTable에서 전달된 콜백은 setState()를 호출해 사용자의 입력을 반영하여 데이터를 업데이트할 수 있습니다.

리스트 앱 업데이트
class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

class ProductTable extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    const rows = [];
    let lastCategory = null;

    this.props.products.forEach((product) => {
      if (product.name.indexOf(filterText) === -1) {
        return;
      }
      if (inStockOnly && !product.stocked) {
        return;
      }
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name}
        />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

class SearchBar extends React.Component {
  constructor(props) {
    super(props);
    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInStockChange = this.handleInStockChange.bind(this);
  }

  handleFilterTextChange(e) {
    this.props.onFilterTextChange(e.target.value);
  }

  handleInStockChange(e) {
    this.props.onInStockChange(e.target.checked);
  }

  render() {
    return (
      <form>
        <input
          type="text"
          placeholder="Search..."
          value={this.props.filterText}
          onChange={this.handleFilterTextChange}
        />
        <p>
          <input
            type="checkbox"
            checked={this.props.inStockOnly}
            onChange={this.handleInStockChange}
          />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

class FilterableProductTable extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      filterText: '',
      inStockOnly: false
    };

    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInStockChange = this.handleInStockChange.bind(this);
  }

  handleFilterTextChange(filterText) {
    this.setState({
      filterText: filterText
    });
  }

  handleInStockChange(inStockOnly) {
    this.setState({
      inStockOnly: inStockOnly
    })
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
          onFilterTextChange={this.handleFilterTextChange}
          onInStockChange={this.handleInStockChange}
        />
        <ProductTable
          products={this.props.products}
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
      </div>
    );
  }
}

const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

const root = ReactDOM.createRoot(document.getElementById('container'));
root.render(<FilterableProductTable products={PRODUCTS} />);

답글 남기기