홍준혁

리액트 컴포넌트 설계 방법 본문

React.js

리액트 컴포넌트 설계 방법

홍준혁 [Hong-JunHyeok] 2022. 6. 22. 11:56

리액트 설계 방법에 대한 이해가 부족하다고 생각되어 공부하면서 글을 정리합니다.

 

개발을 하다 보면 앱이 커지고 그럴수록 컴포넌트의 수가 많아지고 컴포넌트의 수가 많아질수록 설계 방식이 중요해진다고 생각합니다.

단일 책임 원칙은 컴포넌트가 한 가지 일을 하는 것이 이상적이라는 설계 원칙입니다. 그래서 이러한 설계 원칙을 따르며 진행해보겠습니다.

 

위 컴포넌트는 리액트 공식문서에서 가져온 사진입니다. 각 컴포넌트를 자세히 살펴보도록 하겠습니다.

 

FilterableProductTable (노란색) => 전체를 포괄하는 영역입니다.

SearchBar (파란색) => 모든 유저의 입력을 받습니다.

ProductTable (연두색) => 유저의 입력을 기반으로 데이터 컬렉션을 필터링해서 보여줍니다.

ProductCategoryRow (하늘색) => 각 카테고리의 헤더를 보여줍니다.

ProductRow (빨강색) => 각 제품에 해당되는 행을 보여줍니다.

 

왜 이렇게까지 나누는 걸까요?

각 컴포넌트는 단일 책임을 가지고 설계를 해야합니다. 우선은 우리가 원칙을 그렇게 정했으니까 계속해보도록 하겠습니다.

 

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'}
];

위와 같은 JSON 데이터가 있다고 칩시다. 그러면 이 컴포넌트를 기준으로 React 코드를 작성해보겠습니다.

const FilterableProductable = ({ products }) => {
  return (
    <div>
      <SearchBar />
      <ProductTable products={products} />
    </div>
  );
};

아까 노란색 영역에 있던 컴포넌트는 SearchBar와 ProductTable을 렌더링합니다.

그럼 SearchBar는 다음과 같이 구현이 가능합니다.

const SearchBar = () => {
  return (
    <form>
      <input type="text" placeholder="Search..." />
      <p>
        <input type="checkbox" /> Only show products in stock
      </p>
    </form>
  );
};

다음 ProductTable을 구현해보겠습니다.

const ProductTable = ({ products }) => {
  const rows = [];
  let lastCategory = null;

  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>
  );
};

프로덕트의 카테고리를 받아 ProductCategoryRow (파란색)에 전달해주고, 프로덕트 자체를 ProductRow (빨간색)에 넘겨줍니다.

ProductCategoryRow는 순전히 카테고리를 출력하는 역할을 합니다.

const ProductCategoryRow = ({ category }) => {
  return (
    <tr>
      <th colSpan="2">{category}</th>
    </tr>
  );
};

다음 ProductRow도 같습니다. 다만 stocked 옵션을 체크해서 빨간색으로 변경해주는 역할도 수행합니다.

 

const ProductRow = ({ product }) => {
  const name = product.stocked ? (
    product.name
  ) : (
    <span style={{ color: "red" }}>{product.name}</span>
  );

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

최종적으로 만들어진 코드를 확인해보세요.

 

 

화면상에 렌더링은 완료가 되었는데, 기능은 아직 추가하지 못했습니다.

이제 기능도 추가해보도록 하겠습니다.

 

앱을 만들 때는 하향식과 상향식으로 만들 수 있습니다. 

하향식은 상위 컴포넌트부터 설계한 다음 점점 하위 컴포넌트를 만드는 설계 방식이고, 상향식은 그 반대입니다.

간단한 앱에서는 하향식으로 만들고, 프로젝트가 커지면 커질수록 상향식으로 만드는 게 개발하기 쉽다고 합니다.

이 앱에서는 상향식으로 설계를 했습니다.

 

지금은 정적으로 앱을 구성했습니다. PRODUCTS라는 배열 데이터를 받아 props로 전달을 해서 render만 했을 뿐입니다.

UI를 상호작용하게 만들려면 데이터 모델을 수정할 수 있는 방법이 있어야 하고 그것이 state입니다.

state를 설계할 때 핵심은 중복배제입니다. 최소한의 state를 찾고 나머지는 계산되도록 하는 것인데요, 예시는 다음과 같습니다.

 

"TODO 리스트를 만들 때, TODO 아이템을 저장하는 배열을 만들고 TODO 아이템 개수를 구하는 state는 별도로 만들지 않습니다."

 

이 원칙을 가지고 이 애플리케이션을 생각해보면 

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

그럼 여기서 어떻게 state를 설계해야 할까요? 이때 아래의 체크리스트를 본다면 도움이 될 수 있을 것 같습니다.

  1. props를 통해서 전달되는가? Y/N
  2. 시간이 지나도 변하지 않는가? Y/N
  3. 컴포넌트 안에 state나 props를 가지고 계산이 가능한가? Y/N

이 중에서 하나라도 Y면 state가 아닙니다. 그러면 위 체크박스로 필터링해본 결과 state로 다루어야 하는 데이터는 다음과 같습니다.

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

그럼 필터 된 값들은 왜 state가 아닌지 분석해보겠습니다.

 

제품의 원본 목록  -> 정적 값으로 props를 통해 전달되므로 state로 다루어지는 값이 아닙니다. 또한 시간이 지나서 변하지도 않죠.

필터링된 제품의 목록 -> 이는 계산이 가능한 값입니다. 그래서 state로 다룰 필요가 없습니다.

 

그러면, 어떤 컴포넌트가 state를 소유하고 변경해야 할지 생각해야 합니다.

React 공식문서에서는 다음과 같이 설명합니다.

  • state를 기반으로 렌더링 하는 모든 컴포넌트를 찾으세요.
  • 공통 소유 컴포넌트 (common owner component)를 찾으세요. (계층 구조 내에서 특정 state가 있어야 하는 모든 컴포넌트들의 상위에 있는 하나의 컴포넌트).
  • 공통 혹은 더 상위에 있는 컴포넌트가 state를 가져야 합니다.
  • state를 소유할 적절한 컴포넌트를 찾지 못하였다면, state를 소유하는 컴포넌트를 하나 만들어서 공통 오너 컴포넌트의 상위 계층에 추가하세요.

그럼 이 전략을 컴포넌트에 적용해보면

 

state를 기반으로 렌더링 하는 컴포넌트 

ProductTable -> state에 의존하고 상품 리스트의 필터링을 해야 함.

SearchBar -> 검색어와 체크박스의 상태를 표시해주어야 함.

 

공통 소유 컴포넌트 (common owner component)를 찾으세요.

공통 컴포넌트는 FilterableProductTable임.

 

그럼, 상위의 FilterableProductTable에서 state를 선언해줍니다. 그 이유는 하위의 컴포넌트들이 상태를 공유해야 하기 때문입니다.

이제 state를 props로 넘겨준 형태의 코드를 보도록 하겠습니다.

 

 

이렇게 작성을 하면 state를 기반으로 렌더링을 했습니다. 하지만 변화가 일어나지 않습니다. 그 작업을 지금부터 해보겠습니다.

리액트 컴포넌트에서 state를 업데이트하기 위해서는 setState를 사용해야 합니다. 절대 state값을 직접 변경하면 안 됩니다.

그 이유는 렌더링을 진행하는 시점은 React가 정합니다. 그래서 state.number = 1 이렇게 지정해놔도 리액트가 렌더링을 하지 않는다면 state변화는 적용되지 않습니다.

setState함수는 리액트 엔진이 이를 감지하고 렌더링을 진행합니다. 

 

리액트는 기본적으로 단방향 데이터 흐름이므로 위에서 아래로 흐릅니다.

양방향 데이터 흐름에 비해서 타이핑이 많아지지만 그래서 데이터 흐름을 명시적으로 보이게 해서 프로그램이 어떻게 돌아가는지 파악할 수 있습니다.

 

const FilterableProductable = ({ products }) => {
  const [filterText, setFilterText] = useState("");
  const [inStockOnly, setInStockOnly] = useState(false);

  const changeFilterText = (newFilterText) => {
    setFilterText(newFilterText);
  };

  const changeInStockOnly = (newInStockOnly) => {
    setInStockOnly(newInStockOnly);
  };

다음과 같이 setState를 하는 함수를 넘겨줍니다.

state를 set 하는 컴포넌트는 SearchBar 뿐이므로 넘겨줍니다.

      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        changeFilterText={changeFilterText}
        changeInStockOnly={changeInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />

SearchBar는 다음과 같이 작성하면 됩니다.

const SearchBar = ({
  filterText,
  inStockOnly,
  changeFilterText,
  changeInStockOnly
}) => {
  const handleChangeFilterText = (event) =>
    changeFilterText(event.target.value);
  const handleChangeInStockOnly = (event) =>
    changeInStockOnly(event.target.checked);

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

이제 실행 결과를 확인해봅시다.

 

 

정상적으로 잘 동작하는 모습을 볼 수 있습니다.

자, 그럼 아까 "각 컴포넌트는 단일 책임을 가지고 설계를 해야 합니다"이라고 설명했었죠.

지금 작성한 코드는 단일 책임원칙을 잘 따르고 있는 걸까요? 그리고 왜 단일 책임원칙을 따라야 하는 걸까요?

 

제가 예전부터 단일 책임 원칙에 대해서 오해를 했던 부분이 있습니다. 하나의 모듈이 하나의 동작을 해야 한다는 생각을 했었습니다.

하지만 이는 단일 책임 원칙의 정확한 정의가 아닙니다. 정정하자면 한 모듈은 한 사용자를 위해 동작해야 한다. 는 것입니다.

 

function Foo(aValue,bValue,cValue) {
    this.aValue = aValue;
    this.bValue = bValue;
    this.cValue = cValue;
    
    this.forA = function() {
        ...
    }

    this.forB = function() {
        ...
    }
    
    this.forC = function() {
        ...
    }
}

위 코드는 단일 책임 원칙을 지키고 있지 않습니다.

그 이유를 살펴보도록 하겠습니다. 여기서 사용자는 누구일까요? A, B, C라고 생각해봅시다. forA는 A를 위해, forB는 B forC는 C.

그럼 각각 다른 사용자를 위해 설계된 함수이므로 단일 책임 원칙에 어긋나는 것입니다.

이를 단일 책임 원칙을 기반으로 리펙터링해볼까요?

 

function Foo() {
    this.aValue = aValue;
    this.bValue = bValue;
    this.cValue = cValue;
}

function ForA(aValue) {
	this.data = aValue;
	this.forA = function() {}
}

function ForB(bValue) {
	this.data = bValue;
	this.forB = function() {}
}

function ForC(cValue) {
	this.data = cValue;
	this.forC = function() {}
}

그럼, 각 모듈은 저마다 다른 사용자를 위해 동작합니다. 

 

단일 책임을 이해했으니 아까 작성했던 리액트 코드를 살펴보도록 할까요?

현 구조는 부모 컴포넌트 (FilterableProductTable - 노란색)이 자식 컴포넌트의 렌더링에 대한 책임을 지고 있습니다.

하지만 자식 컴포넌트가 많아지면 상태가 많아지고 그러면 책임이 부모 컴포넌트에 너무 몰리게 됩니다.

 

그러면 어떻게 하면 좋을까요? 그럴 땐 contextAPI를 통한 상태 관리 책임을 분리하는 것이 좋다고 생각합니다.

 

제가 놓친 부분이나 추가하고 싶은 내용이 있으면 댓글로 알려주세요! 긴 글 읽어주셔서 감사합니다.

 

 

Comments