ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 리액트 주요개념 - React로 사고하기
    IT/리액트 2022. 1. 16. 23:54
    728x90

    React는 JavaScript로 규모가 크로 빠른 웹 애플리케이션을 만드는 가장 좋은 방법입니다.

     

    이번 글에서는 React로 상품을 검색할 수 있는 데이터 테이블을 만드는 과정을 살펴볼 것입니다.

     

     

    목업으로 시작하기

    애플리케이션을 설계하기 전 디자이너로부터 JSON API목업을 받습니다.

    목업

    // 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를 컴포넌트 계층 구조로 나누기

    모든 컴포넌트의 주변에 박스를 그리고 각각에 이름을 붙입니다.

     

    어떤 것이 컴포넌트가 되는지 알 수 없다면 새로운 함수나 객체를 만들 때처럼 만들면 됩니다! 이때 사용되는 테크닉은 단일 책임 원칙입니다. 하나의 컴포넌트가 커지게 된다면 이는 보다 작은 하위 컴포넌트로 분리되어야 합니다.

    단일 책임 원칙(single responsibility principle): 객체 지향 프로그래밍에서 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다. (리액트에서는 클래스를 컴포넌트로 생각하면 될 듯하다.)

    주로 JSON 데이터를 유저에게 보여주기 때문에 데이터 모델이 적절하게 만들어졌다면 UI가 잘 연결될 것입니다. 이는 UI와 데이터 모델이 같은 인포메이션 아키텍쳐를 가지는 경향이 있기 때문입니다. 이제 각 컴포넌트가 데이터 모델의 한 조각을 나타내도록 분리합니다.

    총 다섯개의 컴포넌트로 이루어진 것을 확인할 수 있습니다.

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

    ProductTable을 보면 Name과 Price 레이블을 포함한 테이블 헤더만을 가진 컴포넌트는 없는데 데이터를 위한 독립된 컴포넌트를 생성할지 말지는 선택입니다. 위의 예시에서는 ProductTable의 책임인 데이터 컬렉션이 렌더링의 일부이기 때문에 ProductTable을 남겨두었지만 헤더가 복잡해지면 ProductTableHeader 컴포넌트를 만드는 것이 더 합리적일 것입니다.

     

    컴포넌트 계층 구조:

    • FilterableProductTable
      • SearchBar
      • ProductTable
        • ProductCategoryRow
        • ProductRow

     

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

    컴포넌트 계층구조가 만들어졌으니 앱을 실제로 구현해봅니다. 가장 쉬운 방법은 데이터 모델을 가지고 UI를 렌더링은 되지만 아무 동작도 없는 버전을 만들어보는 것입니다.

     

    데이터 모델을 렌더링하는 앱의 정적 버전을 만들기 위해 다른 컴포넌트를 재사용하는 컴포넌트를 만들고 props를 이용해 데이터를 전달해줍니다. 정적 버전을 만들기 위해 state를 사용하지 않습니다. state는 오직 상호작용을 위해, 즉 시간이 지남에 따라 데이터가 바뀌는 것에 사용합니다. 지금은 정적 버전이기 때문에 필요하지 않습니다.

     

    앱을 만들 때 하향식이나 상향식으로 만들 수 있습니다. 즉, FilterableProductTable(상층부 컴포넌트)부터 만들거나 ProductRow(하층부 컴포넌트)부터 만들 수 있습니다. 간단한 예시에서는 보통 하향식으로 만드는게 쉽지만 프로젝트가 커지면 상샹식으로 만들고 테스트를 작성하면서 개발하기가 더 쉽습니다.

     

    이 단계가 끝나면 데이터 렌더링을 위해 만들어진 재사용 가능한 컴포넌트들의 라이브러리를 가지게 됩니다. 현재는 앱의 정적 버전이기 때문에 컴포넌트는 render()메서드만 가지고 있을 것입니다. 계층구조의 최상단 컴포넌트는 prop으로 데이터 모델을 받고 데이터 모델이 변경되면 ReactDOM.render()를 다시 호출해 UI가 업데이트 됩니다. React의 단방향 데이터 흐름(one-way data flow)는 모든 것을 모듈화하고 빠르게 만들어줍니다.

    import React from "react";
    
    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"}
    ];
    
    function ProductList() {
        return (
            <div>
                <FilterableProductTable products={PRODUCTS} />
            </div>
        );
    }
    
    export default ProductList;

     

     

    3단계: UI state에 대한 최소한의 표션 찾아내기

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

     

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

     

    예시에서는 다음과 같은 데이터를 가집니다.

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

    각각 살펴보고 어떤 게 state가 되어야 하는지 살펴봅니다. 다음 조건들은 state가 아닌 조건입니다.

    1. 부모로부터 props를 통해 전달됨
    2. 시간이 지나도 변하지 않음
    3. 컴포넌트 안의 다른 state나 props를 가지고 계싼이 가능

    위의 조건으로 앱의 state를 찾아보면 다음과 같습니다.

    • 제품의 원본 목록 -> props를 통해 전달됨
    • 유저가 입력한 검색어 -> 시간에 따라 변함, 다른 것으로 계산될 수 없음
    • 체크박스의 값 -> 시간에 따라 변함, 다른 것으로 계산될 수 없음
    • 필터링된 제품들의 목록 -> 제품의 원본 목록과 검색어, 체크박스로 조합해 값을 계산할 수 있음

    따라서 애플리케이션의 state는 유저가 입력한 검색어, 체크박스의 값 입니다.

     

     

    4단계: State가 어디에 있어야 할 지 찾기

    이제 어떤 컴포넌트가 state를 변경하거나 소유할지 찾아야 합니다.

    애플리케이션이 가지는 각각의 state에 대해서 다음과 같은 단계를 수행합니다.

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

    위의 전략을 애플리케이션에 적용하면 다음과 같습니다.

    • ProductTable은 state에 의존한 상품 리스트를 필터링해야 하고 SearchBar는 검색어와 체크박스의 상태를 표시해야 함
    • 공통 소유 컴포넌트는 FilterableProductTable
    • 의미상으로도 FilterableProductTable이 검색어와 체크박스의 체크 여부를 가지는 것이 타당함
    import React from "react";
    
    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..." 
                        defaultValue={filterText} />
                    <p>
                        <input 
                            type="checkbox"
                            defaultChecked={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"}
    ];
    
    function ProductList() {
        return (
            <div>
                <FilterableProductTable products={PRODUCTS} />
            </div>
        );
    }
    
    export default ProductList;

     

     

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

    계층 구조의 하단에 있는 폼 컴포넌트에서 FilteragleProductTable의 state를 업데이트할 수 있어야 합니다.

     

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

     

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

     

    폼을 변경할 때마다 사용자의 입력을 반영할 수 있도록 업데이트하기 위해서는 FilterableProductTableSearchBar에 콜백을 넘겨 state가 업데이트되어야 할 때마다 호출되도록 합니다.

    import React from "react";
    
    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() {
            const filterText = this.props.filterText;
            const inStockOnly = this.props.inStockOnly;
    
            return (
                <form>
                    <input 
                        type="text" 
                        placeholder="Search..." 
                        value={filterText}
                        onChange={this.handleFilterTextChange} />
                    <p>
                        <input 
                            type="checkbox"
                            checked={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"}
    ];
    
    function ProductList() {
        return (
            <div>
                <FilterableProductTable products={PRODUCTS} />
            </div>
        );
    }
    
    export default ProductList;

     

    728x90

    'IT > 리액트' 카테고리의 다른 글

    리덕스 시작하기  (0) 2022.08.16
    리덕스란?  (0) 2022.08.16
    리액트 주요개념 - 합성 vs 상속  (0) 2022.01.16
    리액트 주요개념 - State 끌어올리기  (0) 2022.01.16
    리액트 주요개념 - 폼  (0) 2022.01.16

    댓글

Designed by Tistory.