리액트

#5. 프로퍼티, 상태, 컴포넌트 트리

YJH3968 2021. 3. 29. 14:53
728x90

1. 프로퍼티 검증

  • javascript는 type 검증이 느슨한 언어이기 때문에 변수값의 데이터 type이 바뀔 수 있다. 이로 인해 애플리케이션을 디버깅할 때 시간이 아주 오래 걸릴 수 있다.
  • 리액트 component는 프로퍼티 type을 지정하고 검증하는 방법을 제공한다. 이를 통해 디버깅 시간을 크게 줄일 수 있고, 프로퍼티에 잘못된 type의 값을 지정하면 경고가 표시되기 때문에 버그를 쉽게 찾을 수 있다.
  • 리액트에는 자동 프로퍼티 검증 기능이 있다. 이 검증 기능은 React.ProtoTypes를 통해 구현할 수 있다.
// prop-types 모듈 설치
npm install prop-types --save
타입 검증기
Arrays React.ProtoTypes.array
Boolean React.ProtoTypes.bool
Functions React.ProtoTypes.func
Numbers React.ProtoTypes.number
Objects React.ProtoTypes.object
Strings React.ProtoTypes.string
  • 이 절에서는 조리법 앱의 Summary component를 만든다. Summary component는 조리법 제목과 재료 수, 조리 절차의 단계 수를 함께 표시한다.
  • 이때 component 프로퍼티의 type을 검증하는 게 중요해지는데, 만약 Summary component를 다음과 같이 구현했다고 하자.
const Summary = createClass({
    displayName: "Summary",
    render() {
        const {ingredients, steps, title} = this.props
        return (
            <div className="summary">
                <h1>{title}</h1>
                <p>
                    <span>재료 {ingredients.length} 종류 | </span>
                    <span>총 {steps.length} 단계 </span>
                </p>
            </div>
        )
    }
})
  • ingredients와 steps가 배열인 경우에는 큰 문제가 없지만 만약 배열이 아닌 문자열을 넘긴다면 문자열의 길이를 출력할 것이다.
  • 이러한 문제를 해결하기 위해 createClass의 인자 객체에 propTypes라는 프로퍼티를 추가해 프로퍼티 type 검증을 사용한다.
// 리액트의 내장 프로퍼티 type 검증을 사용한 Summary
const Summary = createClass({
    displayName: "Summary",
    propTypes: {
        ingredients: PropTypes.array,
        steps: PropTypes.array,
        title: PropTypes.string
    },
    render() {
        const {ingredients, steps, title} = this.props
        return (
            <div className="summary">
                <h1>{title}</h1>
                <p>
                    <span>재료 {ingredients.length} 종류 | </span>
                    <span>총 {steps.length} 단계 </span>
                </p>
            </div>
        )
    }
})
  • 이렇게 코드를 작성한 경우 ingredients, steps에 다른 type의 값을 넣으면 오류를 발생시킨다. 
  • 프로퍼티를 지정하지 않고 Summary component를 렌더링하면 프로퍼티의 type이 undefined가 되어 오류가 발생한다. 리액트는 이를 방지하기 위해 필수 프로퍼티를 지정하는 방법을 제공한다.
// 필수 프로퍼티를 지정한 Summary
const Summary = createClass({
    displayName: "Summary",
    propTypes: {
        ingredients: PropTypes.array.isRequired,
        steps: PropTypes.array.isRequired,
        title: PropTypes.string
    },
    render() {
        const {ingredients, steps, title} = this.props
        return (
            <div className="summary">
                <h1>{title}</h1>
                <p>
                    <span>재료 {ingredients.length} 종류 | </span>
                    <span>총 {steps.length} 단계 </span>
                </p>
            </div>
        )
    }
})
  • 이렇게 코드를 작성하면 아무 프로퍼티도 지정하지 않고 Summary component를 렌더링하면 리액트는 오류가 발생하기 전에 콘솔에 경고를 표시해서 문제가 있다는 사실을 알려준다. 따라서 좀 더 쉽게 잘못된 부분을 찾을 수 있다.
  • component에서 사용하는 배열의 용도가 단지 배열의 length 프로퍼티뿐인 경우, 배열 대신 수를 프로퍼티로 받아들이도록 변경하는 편이 더 낫다.
const Summary = createClass({
    displayName: "Summary",
    propTypes: {
        ingredients: PropTypes.number.isRequired,
        steps: PropTypes.number.isRequired,
        title: PropTypes.string
    },
    render() {
        const {ingredients, steps, title} = this.props
        return (
            <div className="summary">
                <h1>{title}</h1>
                <p>
                    <span>재료 {ingredients} 종류 | </span>
                    <span>총 {steps} 단계 </span>
                </p>
            </div>
        )
    }
})
  • 프로퍼티에 대해 default 값을 지정할 수 있다. 이를 이용해 component를 더 유연하게 만들 수 있고 사용자가 실수로 프로퍼티 중 일부를 지정하지 않았을 때 발생하는 오류를 방지할 수 있다.
const Summary = createClass({
    displayName: "Summary",
    propTypes: {
        ingredients: PropTypes.number,
        steps: PropTypes.number,
        title: PropTypes.string
    },
    getDefaultProps() {
        return {
            ingredients: 0,
            steps: 0,
            title: "[무제]"
        }
    },
    render() {
        const {ingredients, steps, title} = this.props
        return (
            <div className="summary">
                <h1>{title}</h1>
                <p>
                    <span>재료 {ingredients} 종류 | </span>
                    <span>총 {steps} 단계 </span>
                </p>
            </div>
        )
    }
})
  • 프로퍼티의 type에 더해 좀 더 자세한 검증이 필요한 경우 커스텀 검증기를 추가할 수 있다.
  • 리액트의 커스텀 검증기는 함수로 구현하는데, 검증 요구사항을 만족하지 않는 경우 에러를 반환하고 요구사항을 만족하는 경우 null을 반환한다.
// 커스텀 검증기 예제
propTypes: {
    ingredients: PropTypes.number,
    steps: PropTypes.number,
    title: (props, propName) => // 이 예시에서 propName은 'title'이다.
        (typeof props[propName] !== 'string') ?
           new Error("제목(title)은 문자열이어야 합니다.") :
           (props[propName].length > 20) ?
               new Error("제목은 20자 이내여야 합니다.") :
               null
}
  • 커스텀 검증기를 구현할 때는 프로퍼티를 콜백 함수로 설정해야 한다. component를 렌더링할 때 리액트는 이 함수에 props 프로퍼티 객체와 프로퍼티 이름을 인자로 넘긴다.
  • ES6 클래스를 사용할 경우 프로퍼티 검증 기능을 추가할 때 클래스 본문 외부에서 클래스 인스턴스에 대해 이루어져야 한다. 
const Summary extends React.Component {    
    render() {
        const {ingredients, steps, title} = this.props
        return (
            <div className="summary">
                <h1>{title}</h1>
                <p>
                    <span>재료 {ingredients} 종류 | </span>
                    <span>총 {steps} 단계 </span>
                </p>
            </div>
        )
    }
}

Summary.propTypes = {
    ingredients: PropTypes.number,
    steps: PropTypes.number,
    title: (props, propName) => // 이 예시에서 propName은 'title'이다.
        (typeof props[propName] !== 'string') ?
           new Error("제목(title)은 문자열이어야 합니다.") :
           (props[propName].length > 20) ?
               new Error("제목은 20자 이내여야 합니다.") :
               null
}
    
Summary.defaultProps = {
    ingredients: 0,
    steps: 0,
    title: "[무제]"
}
  • 상태가 없는 함수형 component에도 propTypes와 defaultProps 객체 리터럴을 추가할 수 있다. defaultProps 객체 리터럴은 함수 인자 부분에 default 인자를 지정하는 것으로 대체할 수 있다.
const Summary = ({ ingredients=0, steps=0, title=`[무제]` }) => {
    return <div className="summary">
        <h1>{title}</h1>
        <p>
            <span>재료 {ingredients} 종류 |</span>
            <span>총 {steps} 단계 </span>
        </p>
    </div>
}

Summary.propTypes = {
    ingredients: React.PropTypes.number,
    steps: React.PropTypes.number,
    title: (props, propName) => // 이 예시에서 propName은 'title'이다.
        (typeof props[propName] !== 'string') ?
           new Error("제목(title)은 문자열이어야 합니다.") :
           (props[propName].length > 20) ?
               new Error("제목은 20자 이내여야 합니다.") :
               null
}

2. 참조

  • 리액트 component가 자식 element와 상호작용할 때 필요한 기능으로 사용자 입력을 받는 UI element와 상호작용할 때 자주 사용한다.
// submit method를 추가해 자식 element와 상호작용하는 예제
import { Component } from 'react'
import { render } from 'react-dom'

class AddColorForm extends Component {
  constructor(props) {
    super(props)
    this.submit = this.submit.bind(this)
  }

  submit(e) {
    const { _title, _color } = this.refs
    e.preventDefault();
    alert(`새로운 색: ${_title.value} ${_color.value}`)
    _title.value='';
    _color.value='#000000';
    _title.focus();
  }
  render() {
    return (
      <form onSubmit={this.submit}>
        <input ref="_title" type="text" placeholder="색 이름..." required />
        <input ref="_color" type="color" required />
        <button>추가</button>
      </form>
    )
  }
}
  • submit을 method로 옮겼기 때문에 ES6 component에 생성자를 추가하고 component가 사용하는 영역에서 this를 사용해 method를 직접 바인딩해야 한다.
  • render method에서는 form의 onSubmit 핸들러를 component의 submit method로 지정한다. 참조하고 싶은 component에는 ref 필드를 추가함으로써 리액트가 DOM element를 참조할 때 this.refs를 통해 식별자로 사용할 수 있다.
  • 리액트 component에서 데이터를 수집하는 일반적인 방법은 역방향 데이터 흐름을 사용하는 것이다.
  • 역방향 데이터 흐름이란 component가 데이터를 돌려줄 때 사용할 수 있는 콜백 함수를 component의 프로퍼티로 설정하는 것을 말한다. 즉, 함수를 component에 프로퍼티로 넘기고 component는 데이터를 그 함수의 인자로 돌려준다.
// 역방향 데이터 흐름 예제
const logColor = (title, color) =>
  console.log(`새로운 색: ${title} | ${value}`)

<AddColorForm onNewColor={logColor} />

// AddColorForm의 submit method
submit() {
  const {_title, _color} = this.refs
  this.props.onNewColor(_title.value, _color.value)
  _title.value=''
  _color.value='#000000'
  _title.focus()
}
  • AddColorForm component는 데이터를 모아서 전달한다. AddColorForm을 사용해서 사용자로부터 색 정보를 얻어 이를 다른 component에 전달하거나 수집한 데이터를 처리하는 method에 넘길 수 있다.
  • 양방향 데이터 바인딩을 선택적으로 만들기 위해서는 콜백 프로퍼티를 호출하기 전에 그 프로퍼티에 함수가 지정되어 있는지 검사해야 한다.
// 선택적 함수 프로퍼티 예제
if (this.props.onNewColor) {
  this.props.onNewColor(_title.value, _color.value)
}

// component의 propTypes와 defaultProps에 함수 프로퍼티를 지정하는 방법도 있다.
AddColorForm.propTypes {
  onNewColor: Proptypes.func
}

AddColorForm.defaultProps {
  onNewColor: f=>f // 이 화살표 함수는 단순히 자신이 받은 첫 번째 인자를 다시 반환한다. 
}
  • 상태가 없는 함수형 component에서는 참조를 사용할 경우 this가 없기 때문에 ref attribute를 문자열로 지정하는 대신 함수를 사용한다. 이 함수는 input element 인스턴스를 인자로 받기 때문에 이를 로컬 변수에 저장할 수 있다.
// 함수형 component 참조 예제
const AddColorForm = ({onNewColor=f=>f}) => {
  let _title, _color
  const submit = e => {
    e.preventDefault()
    onNewColor(_title.value, _color.value)
    _title.value=''
    _color.value='#000000'
    _title.focus()
  }
  return (
    <form onSubmit={submit}>
      <input ref={input => _title=input} type="text" placeholder="색 이름.." required/>
      <input ref={input => _color=input} type="color" required/>
      <button>추가</button>
    </form>
  )
}

3. 리액트 상태 관리

  • 지금까지 리액트 component는 데이터를 처리하기 위해 프로퍼티만 사용했으나 프로퍼티는 변경 불가능하기 때문에 렌더링하고 나면 UI를 바꾸기 위해서는 component tree를 새로운 프로퍼티를 새로 그려주는 다른 매커니즘이 필요하다.
  • 리액트 상태는 component 안에서 바뀌는 데이터를 관리하기 위해 리액트가 기본으로 제공하는 기능이다. 애플리케이션 상태가 바뀌면 UI는 자동으로 그 상태를 반영해 새로 렌더링된다.
// 별점을 표시하는 StarRating 예제
const Star = ({ selected=false, onClick=f=>f }) =>
  <div className={(selected) ? "star selected" : "star"} onClick={onClick}></div>

Star.propTypes = {
  selected: PropTypes.bool,
  onClick: PropTypes.func
}
// star css
.star {
    cursor: pointer;
    height: 25px;
    width: 25px;
    margin: 2px;
    float: left;
    background-color: grey;
    clip-path: polygon(
        50% 0%,
        63% 38%,
        100% 38%,
        69% 59%,
        82% 100%,
        50% 75%,
        18% 100%,
        31% 59%,
        0% 38%,
        37% 38%
    );
}

.star .selected {
    background-color: red;
}
  • Star는 상태가 없는 함수형 component다. 즉, 상태를 가질 수 없다. 상태가 없는 함수형 component는 더 복잡한 상태가 있는 component의 자식 역할을 하도록 만든 component다.
  • Star component를 이용해 평점 정보를 상태에 저장하는 StarRating component를 만든다.
// createClass에서 상태를 나타내는 예제
const StarRating = createClass({
  displayName: 'StarRating',
  propTypes: {
    totalStars: PropTypes.number
  },
  getDefaultProps() {
    return {
      totalStars: 5
    }
  },
  getInitialState() { // 상태 초기화
    return {
      starsSelected: 0
    }
  },
  change(starsSelected) {
    this.setState({starsSelected})
  },
  render() {
    const {totalStars} = this.props
    const {starsSelected} = this.state
    return (
      <div className="star-rating">
        {[...Array(totalStars)].map((n, i) => // 길이가 totalStars인 Star element의 배열을 만든다.
          <Star key={i} selected={i<starsSelected} onClick={() => this.change(i+1)} />
        )} // Star element의 인덱스에 1을 더한 값을 change method에 전달하는 이유는 
           // 배열의 인덱스가 0부터 시작하기 때문이다.
        <p>별점: {starsSelected} / {totalStars}</p>
      </div>
    )
  }
})
// ES6 class에서 상태를 나타내는 예제
class StarRating extends Component {
  constructor(props) {
    super(props)
    this.state = {
      starsSelected: 0
    }
    this.change = this.change.bind(this)
  }
  change(startsSelected) {
    this.setState({startsSelected})
  }
  render() {
    const {totalStars} = this.props
    const {starsSelected} = this.state
    return (
      <div className="star-rating">
        {[...Array(totalStars)].map((n, i) =>
          <Star key={i} selected={i<starsSelected} onClick={() => this.change(i+1)} />
        )}
        <p>별점: {starsSelected} / {totalStars}</p>
      </div>
    )
  }
}

StarRating.propTypes = {
  totalStars: PropTypes.number
}

StarRating.defaultProps = {
  totalStars: 5
}
  • ES6 component를 마운트할 때 그 component의 생성자가 호출되면서 프로퍼티가 첫 번째 인자로 전달된다. 이 프로퍼티는 다시 super 호출에 의해 상위 클래스인 React.Component에 전달돼 상태를 관리해주는 기능을 이용해 인스턴스를 꾸며준다.
  • component의 입력 프로퍼티로부터 상태 변수를 초기화할 수 있다. 이에는 componentWillMount라는 method를 이용하는데, 이 method는 component가 마운트될 때 단 한 번 호출된다. this.setState()를 이 method에서 호출할 수 있고, this.props를 사용할 수 있다.
// componentWillMount를 추가한 예제
const StarRating = createClass({
  displayName: 'StarRating',
  propTypes: {
    totalStars: PropTypes.number
  },
  getDefaultProps() {
    return {
      totalStars: 5
    }
  },
  getInitialState() { // 상태 초기화
    return {
      starsSelected: 0
    }
  },
  componentWillMount() {
    const { starsSelected } = this.props
    if (starsSelected) {
      this.setState({starsSelected})
    }
  },
  change(starsSelected) {
    this.setState({starsSelected})
  },
  render() {
    const {totalStars} = this.props
    const {starsSelected} = this.state
    return (
      <div className="star-rating">
        {[...Array(totalStars)].map((n, i) => 
          <Star key={i} selected={i<starsSelected} onClick={() => this.change(i+1)} />
        )}
        <p>별점: {starsSelected} / {totalStars}</p>
      </div>
    )
  }
})

render(
  <StarRating totalStars={7} starsSelected={3} />,
  document.getElementById('react-container')
)
// ES6 클래스 component를 사용하는 경우 더 쉽게 상태를 초기화할 수 있다.
constructor(props) {
  super(props)
  this.state = {
    starsSelected: props.starsSelected || 0
  }
  this.change = this.chage.bind(this)
}
  • 꼭 필요한 경우에만 상태 변수를 프로퍼티로부터 설정하는 것이 좋다. 리액트 component를 다룰 때는 상태가 있는 component의 수를 최소화해야 하기 때문이다.

4. component tree 안의 상태

  • 대부분의 리액트 애플리케이션은 상태 데이터를 루트 component에 모아둘 수 있다. 상태 데이터를 프로퍼티를 통해 component tree의 아랫방향으로 전달할 수 있고, 양방향 함수 바인딩을 활용해 tree 아래쪽에서 수집한 데이터를 다시 올려 보낼 수 있다.
  • 그 결과 애플리케이션 전체의 상태 데이터가 루트 component 한 곳에 존재하게 된다.
// 색 관리 앱의 데이터
{
  colors: [
    {
      "id": "0175d1f0-a8c6-41bf-8d02-df5734d829a4",
      "title": "해질녘 바다",
      "color": "#00c4e2",
      "rating": "5"
    },
    {
      "id": "83c7ba2f-7392-4d7d-9e23-35adbe186046",
      "title": "잔디",
      "color": "#26ac56",
      "rating": 3
    },
    {
      "id": "a11e3995-b0bd-4d58-8c48-5e49ae7f7f23",
      "title": "밝은 빨강",
      "color": "#ff0000",
      "rating": 0
    }
  ]
}
  • 앞에서 만든 StarRating component는 표현 component로 다루고 상태가 없는 함수형 component로 선언하는 편이 더 타당하다. 
  • 표현 component란 애플리케이션의 여러 부분을 표시하는 데만 관심이 있는 component다. 즉, DOM element나 다른 표현 component를 렌더링하는 일만 담당한다.
  • 표현 component에 데이터를 전달할 때는 프로퍼티로 전달하고, 데이터를 받고 싶을 때는 콜백함수를 이용한다.
// 상태가 없는 함수형 component의 형태로 바꾼 StarRating
const StarRating = ({starsSelected=0, totalStars=5, onRate=f=>f}) =>
  <div className="star-rating">
    {[...Array(totalStars)].map((n, i) =>
      <Star key={i} selected={i<starsSelected} onClick={()=>onRate(i+1)}/>
    )}
    <p>별점: {starsSelected} / {totalStars}</p>
  </div>
  • 사용자가 평점을 변경하면 setState를 호출하는 대신 onRate를 호출하면서 변경된 평점을 인자로 전달한다.
  • 상태를 루트 component 한 곳에만 두도록 제약을 가하고 상태 데이터를 모든 자식 component에 프로퍼티로 전달한다.
  • 색 관리 앱에서 상태는 App component에 정의된 색의 배열로 이루어진다. ColorList component에 이 색 정보를 전달할 때는 프로퍼티를 사용한다.
// 루트 component App
class App extend Component {
  constructor(props) {
    super(props)
    this.state = {
      colors: []
    }
  }

  render() {
    const { colors } = this.state
    return (
      <div className="app">
        <AddColorForm />
        <ColorList colors={colors} />
      </div>
    )
  }
}

// ColorList component
const ColorList = ({ colors=[] }) =>
  <div className="color-list">
    {(colors.length === 0) ?
      <p>색이 없습니다. (색을 추가해주세요)</p> :
      colors.map(color =>
        <Color key={color.id} {...color} />
      )
    }
  </div>

// Color component
const Color = ({ title, color, rating=0 }) =>
  <section className="color">
    <h1>{title}</h1>
    <div className="color" style={{ backgroundColor: color }}></div>
    <div>
      <StarRating starsSelected={rating} />
    </div>
  </section>
  • 위 코드와 같이 루트 component에서 받은 데이터를 자식 component에 순차적으로 전달한다.
  • 사용자가 UI에서 무언가를 변경하면 그 입력이 component tree를 타고 올라가 App component에 전달되고 App component에 있는 앱의 상태가 바뀐다. 이런 과정은 콜백 함수 프로퍼티를 통해 이루어진다.
  • 새 색을 추가하려면 각 색을 유일하게 식별할 수 있어야 한다. 이를 위해 고유 ID를 만들 때 uuid 라이브러리를 사용한다.
// uuid 라이브러리 설치
npm install uuid --save
// 색 추가 기능을 더한 루트 component App
import { Component } from 'react'
import { v4 } from 'uuid'
import AddColorForm from './AddColorForm'
import ColorList from './ColorList'

export class App extend Component {
  constructor(props) {
    super(props)
    this.state = {
      colors: []
    }
    this.addColor = this.addColor.bind(this)
  }

  addColor(title, color) {
    const colors = [
      ...this.state.colors,
      {
        id: v4(),
        title,
        color,
        rating: 0
      }
    ]
    this.setState({colors})
  }
  
  render() {
    const { addColor } = this
    const { colors } = this.state
    return (
      <div className="app">
        <AddColorForm onNewColor={addColor}/>
        <ColorList colors={colors} />
      </div>
    )
  }
}
  • setState가 호출될 때마다 render method가 호출된다. 갱신된 색 데이터는 렌더링을 하면서 UI를 구축하는 과정에서 프로퍼티를 component tree의 아랫방향으로 전달된다.
  • 이제 component에서 데이터가 바뀌었을 때 루트 component로 콜백함수를 이용해 바뀐 데이터를 전달하는 코드를 추가하면 다음과 같다.
// 색의 평점을 지정하거나 색을 제거하는 기능을 추가한 Color component
const Color = ({ title, color, rating=0, onRemove=f=>f, onRate=f=>f }) =>
  <section className="color">
    <h1>{title}</h1>
    <button onClick={onRemove}>X</button>
    <div className="color" style={{ backgroundColor: color }}></div>
    <div>
      <StarRating starsSelected={rating} onRate={onRate} />
    </div>
  </section>
  
// 위 Color component를 반영한 ColorList component
const ColorList = ({ colors=[], onRate=f=>f, onRemove=f=>f }) =>
  <div className="color-list">
    {(colors.length === 0) ?
      <p>색이 없습니다. (색을 추가해주세요)</p> :
      colors.map(color =>
        <Color key={color.id} {...color} 
          onRate={(rating) => onRate(color.id, rating)}
          onRemove={() => onRemove(color.id)} />
      )
    }
  </div>

// 위 ColorList를 반양한 App component
class App extend Component {
  constructor(props) {
    super(props)
    this.state = {
      colors: []
    }
    this.addColor = this.addColor.bind(this)
    this.rateColor = this.rateColor.bind(this)
    this.removeColor = this.removeColor.bind(this)
  }

  addColor(title, color) {
    const colors = [
      ...this.state.colors,
      {
        id: v4(),
        title,
        color,
        rating: 0
      }
    ]
    this.setState({colors})
  }

  rateColor(id, rating) {
    const colors = this.state.colors.map(color =>
      (color.id !== id) ?
        color : 
        {
          ...color,
          rating
        }
    )
    this.setState({colors})
  }

  removeColor(id) {
    const colors = this.state.colors.filter(
      color => color.id !== id
    )
    this.setState({colors})
  }
  
  render() {
    const { addColor, rateColor, removeColor } = this
    const { colors } = this.state
    return (
      <div className="app">
        <AddColorForm onNewColor={addColor}/>
        <ColorList colors={colors} onRate={rateColor} onRemove={removeColor}/>
      </div>
    )
  }
}

출처 : learning react(2018)

728x90