본문 바로가기

React

[Tutorial]4.게임 완성하기



※ React.org의 공식문서( https://reactjs.org/tutorial/tutorial.html ) 번역본입니다. ※

1. State 관리하기

2. 불변의 중요성

3. 함수 컴포넌트

4. 턴제로 전환하기

5. 승자 판단하기

  

tic-tac-toe 게임을 만들기 위한 기본은 마련되었다. 이 게임 개발을 끝내기 위해 X와 O가 교차로 board에 표시되게 해야 하고, 승자를 결정하는 작업이 남아있다.


1. State 관리하기


 현재, 각 Square 컴포넌트는 게임의 상태를 유지한다. 승자를 가리기 위해, 9개 Square의 value를 유지하고 하나로 모아야 한다. Board가 그냥 각 Square에게 상태를 물어볼 수도 있고 React에서 실제로 그것이 가능하다. 그러나 코드가 이해하기 어려워지고 버그 발생 가능성이 높아지며 코드 수정이 어려워지므로 추천하지 않는다. 대신, 가장 좋은 접근 방법은 각각의 Square 대신 부모인 Board 컴포넌트에 게임 상태를 저장하는 것이다. Board 컴포넌트는 각 Square에게 prop을 전달해 줌으로써 화면에 무엇을 표시할지 알릴 수 있다. 아까 숫자값을 Board에서 Square에게 전달했던 것처럼 말이다.


 여러 자식들에게서 데이터를 모아야 하거나 서로 데이터를 주고받아야 하는 2개의 자식 엘리먼트를 갖고 있을 때 부모 컴포넌트에서 같이 사용할 수 있는 State를 선언해야 한다. 부모 컴포넌트는 State를 자식들에게 props를 사용해서 전달할 수 있고 이는 자식 컴포넌트들과 부모 컴포넌트의 상태가 동기화되게 할 수 있다.


React 컴포넌트 리팩토링 방법으로 부모 컴포넌트로 상태를 올려서 관리하는 것은 자주 사용된다. 실습해 보자. 생성자 하나를 Board에 만들고 Board의 초기 State로 9칸짜리 배열 하나를 null로 만든다. 이 9개의 null 값은 9개의 Square들과 대응된다.

 

  class Board extends React.Component {
    constructor(props){
      super(props);
      this.state = {
        squares : Array(9).fill(null),
      }
    }
 
    renderSquare(i) {
      return <Square value={i}/>;
    }
  
    render() {
      const status = 'Next player: X';
      return (
        <div>
          <div className="status">{status}</div>
          <div className="board-row">
            {this.renderSquare(0)}
            {this.renderSquare(1)}
            {this.renderSquare(2)}
          </div>
          <div className="board-row">
            {this.renderSquare(3)}
            {this.renderSquare(4)}
            {this.renderSquare(5)}
          </div>
          <div className="board-row">
            {this.renderSquare(6)}
            {this.renderSquare(7)}
            {this.renderSquare(8)}
          </div>
        </div>
      );
    }
  }



 나중에 Board에 데이터가 채워지면 아래와 같이 보일 것이다.

  [

     'O', null, 'X', 

     'X', 'X', 'O',

     'O', null, null,

  ]


Board 컴포넌트의 renderSquare 함수는 현재 아래와 같다.

 

  renderSquare(i) {
      return <Square value={i}/>;
  }



시작할 때, value Prop을 Board에서 0~8까지 9개의 Square로 전달했었다. 또 Square가 클릭될 때 State를 X로 바꾸고 이것을 표시했었다. 현재 Square에 State를 표시하고 있기 때문에 부모 컴포넌트에서 전달받은 props가 보여지지 않고 있다. 이제 Props 전달하는 것을 다시 사용하고자 한다. Board가 Square에게 X,O,null 값을 전달할 수 있도록 수정한다. 이미 Board의 생성자에 squares 배열을 가지고 있고 Board의 renderSquare 함수를 다음과 같이 수정한다.

 

renderSquare(i) {
  return <Square value={this.state.squares[i]}/>;
}



각각 Square들은 이제 value로 X,O,null 값을 받을 수 있다. 다음으로 Square를 클릭할 때 이벤트를 수정해야 한다. Board 컴포넌트는 이제 Square의 상태를 관리한다. 이제 Square가 Board의 State를 업데이트 할 수 있도록 수정한다.  컴포넌트 안에 정의된 State는 private 영역이기 때문에 Square에서 직접 Board의 State를 업데이트 할 수 없다. Board의 State를 계속 private하게 유지하면서 State가 바뀌게 하기 위해 함수 하나를 자식 컴포넌트로 내려보낸다. 이 함수는 Square가 클릭될 때 호출될 것이다. Board 컴포넌트의 renderSquare 함수를 다음과 같이 변경한다.

 

    renderSquare(i) {
      return (
        <Square 
          value={this.state.squares[i]}
          onClick={() => this.handleClick(i)}  
        />
      );
    }



 

  Note

 우선 가독성을 위해 Square의 Props를 라인별로 나눴고, 괄호를 추가하여 코드가 끝난 후에 JavaScript가 세미콜론을 추가하지 않는다.



이제 2개의 props를 Board에서 Square로 전달한다 : value, onClick. onClick은 Square가 클릭될 때 호출되는 함수다. 아래와 같이 Square를 변경해 보자.

  • Square의 render 함수 안에서 this.state.value를 this.props.value로 바꾼다.
  • Square의 render 함수 안에서 this.setState()를 this.props.onClick()으로 바꾼다.
  • Square의 생성자를 삭제한다. 왜냐하면 Square는 더이상 State를 가질 필요가 없기 때문이다.

수정한 후에 Square 컴포넌트는 아래와 같다.

 

class Square extends React.Component {
  render() {
    return (
      <button 
        className="square" 
        onClick={() => this.props.onClick()}
      >
        {this.props.value}
      </button>
    );
  }
}



Square가 클릭될 때 Board에서 넘겨받은 onClick 함수가 호출된다. 어떻게 동작하는지 검토해 보자.

 1) <button> 컴포넌트에 built-in된 onClick prop은 React에 의해 클릭 이벤트 리스너로 세팅된다.

 2) button을 누르면 React는 Square의 render 함수에 정의된 onClick 이벤트 핸들러를 호출한다.

 3) 이 이벤트 핸들러는 this.props.onClick()을 호출한다. Square의 onClick prop은 Board에 의해 전달된 것이다.

 4) Board가 onClick={() => this.handleClick(i)}를 Square에 전달했기 때문에 결국 Square는 this.handleClick(i)를 호출한다.

 5) 아직 handleClick 함수를 정의하지 않았기 때문에 지금 코드는 오류가 난다.

 

  Note

 <button> 엘리먼트의 onClick 속성은 built-in 컴포넌트이기 때문에 React에서 다른 속성과는 다르다. 즉, 이름을 변경하면 안된다. Square와 같은 새로 정의된 컴포넌트 같은 경우 이름은 마음대로 정할 수 있다. 예를 들어 Square의 onClick prop이나 Board의 handleClick 함수명을 다르게 할 수 있다. 그러나 React에서 이벤트를 표현하는 함수는 on[Event]로 이벤트를 핸들하는 함수는 handle[Event]로 하는 것이 규칙이다.



Square를 클릭하면 handleClick 함수가 아직 작성되지 않았기 때문에 오류 난다. Board 컴포넌트의 handleClick 함수를 다음과 같이 추가하자.

 

  class Board extends React.Component {
    constructor(props){
      super(props);
      this.state = {
        squares : Array(9).fill(null),
      }
    }
 
    handleClick(i){
      const squares = this.state.squares.slice();
      squares[i] = 'X';
      this.setState({squares:squares});
    }
 
    renderSquare(i) {
      return (
        <Square 
          value={this.state.squares[i]}
          onClick={() => this.handleClick(i)}  
        />
      );
    }
  
    render() {
      const status = 'Next player: X';
      return (
        <div>
          <div className="status">{status}</div>
          <div className="board-row">
            {this.renderSquare(0)}
            {this.renderSquare(1)}
            {this.renderSquare(2)}
          </div>
          <div className="board-row">
            {this.renderSquare(3)}
            {this.renderSquare(4)}
            {this.renderSquare(5)}
          </div>
          <div className="board-row">
            {this.renderSquare(6)}
            {this.renderSquare(7)}
            {this.renderSquare(8)}
          </div>
        </div>
      );
    }
  }



 변경한 후에 Square를 클릭하면 X가 표시되는 것을 볼 수 있다. 그러나 이제 State는 각각의 Square에 저장되지 않고 Board 컴포넌트에 저장된다. Board의 State가 변경되면 Square 컴포넌트도 자동으로 다시 그려진다. Square들의 상태를 Board에 저장하는 것은 나중에 승자를 결정하는 로직에 쓰인다. handleClick 안에서 이미 있는 squares를 직접 수정하는게 아니라 .slice()로 squares 배열을 복하사여 수정했다. 다음 섹션에서 이유를 설명하겠다.


2. 불변의 중요성


  앞의 코드에서 squares 배열을 직접 수정하는 대신 .slice()를 사용해 배열을 복사했다. 이제 기존 배열을 변경하지 않았던 이유에 대해서 알아보자. 일반적으로 데이터를 변경하는 방법에는 2가지가 있다. 첫번째는 데이터를 직접 수정하는 것이다. 두번째는 변경된 내용을 갖는 복사본으로 원본을 대체하는 것이다. 

   - 데이터 직접 수정

 

var player = {score: 1name'Jeff'};
player.score = 2;
// 이제 player는 {score: 2, name: 'Jeff'}



  - 데이터 복사 후 수정

 

var player = {score: 1name'Jeff'};
var newPlayer = Object.assign({}, player, {score: 2});
// player는 변경되지 않고 newPlayer는 원하는 값을 갖는다. {score: 2, name: 'Jeff'}
// object spread 문법을 사용하면 아래와 같이 할 수도 있다:
// var newPlayer = {...player, score: 2};

데이터를 직접 수정하나 복사 후 수정하나 결과는 같다. 그러나 데이터 복사 후 수정하는 방법은 아래와 같은 몇가지 이익이 있다.

  - 복잡한 기능이 간단해짐 : 데이터의 불변성은 복잡한 기능을 더 쉽게 구현할 수 있게 해준다. 이 tutorial에서 우리는 tic-tac-toe 게임의 플레이 내역과 과거 특정 시점으로 이동하는 "jump back" 기능인 "time-travel"을 구현할 예정이다. 이 기능은 게임에 한정적인 것은 아니며 범용 프로그램에서 실행취소, 재실행과 같은 일반적인 기능이다. 직접적인 데이터 수정을 회피함으로써 게임 플레이 내역을 그대로 보존하고 나중에 재사용할 수 있다.

  - 변화 감지 : 자주 수정되는 객체는 객체가 직접 변하게 되면 감지하기 어렵다. 변화 감지는 수정된 데이터를 이전 복사본과 비교하고 전체 객체 트리를 조사해야한다. 반면 불변하는 객체의 변화감지는 상당히 쉽다. 만약 참조되고 있는 불변하는 객체가 이전과 다르다면, 그 객체는 변했다고 판단할 수 있다.

  - React에서 컴포넌트를 언제 다시 그릴지 결정할 수 있음 : 불변의 가장 큰 장점은 React에서 순수한 컴포넌트를 작성할 수 있도록 도와준다는 것이다. 불변하는 데이터는 쉽게 결정할 수 있다. 컴포넌트를 다시 그려야 할 때 객체의 변경 여부가 컴포넌트를 다시 그릴지 말지 결정하는데 도움을 준다. 이것은 shouldComponentUpdate() 함수를 통해 구현되어 있고 React의 성능 최적화를 보면 어떻게 순수한 React 컴포넌트를 만드는지 알 수 있다.


3. 함수 컴포넌트


 이제 Square 컴포넌트를 함수 컴포넌트로 변경해보자. React에서 함수 컴포넌트는 오직 render 함수만 필요한 컴포넌트(State를 갖지 않아도 되는 컴포넌트)를 작성하는 간단한 방법이다. React.Component를 확장하는 클래스를 작성하는 대신 props를 매개변수로 갖고 화면에 그릴 것을 리턴하는 함수를 작성할 수 있다. 함수 컴포넌트는 클래스보다 덜 지루하고 많은 컴포넌트들이 이렇게 표현될 수 있다.


Square 컴포는트를 아래 함수로 변경해보자.  this.props도 props로 변경했다.

 

function Square (props) {
    return (
      <button 
        className="square" 
        onClick={() => props.onClick()}
      >
        {props.value}
      </button>
    );
}



 

  Note

 Square를 함수 컴포넌트로 수정할 때 클릭 이벤트 또한 onClick={() => this.props.onClick()}에서 onClick={props.onClick}으로 변경했다(괄호도 없어졌음). 클래스에서 올바른 this에 접근하기 위해 arrow 함수를 사용했지만 함수 컴포넌트에서는 this를 상관할 필요가 없다.


 


4. 턴제로 전환하기


 이제 이 tic-tac-toe 게임에서 명백한 오류를 수정할 차례다. "O"를 board에 표시해 주어야 한다. 기본값으로 첫번째 턴에 X를 표시하도록 했다. Board 생성자의 state를 수정함으로써 이 기본값을 변경할 수 있다. 

 

class Board extends React.Component {
  constructor(props){
    super(props);
    this.state = {
      squares : Array(9).fill(null),
      xIsNext: true,
    };
  }
}


플레이어가 클릭할 때마다 xIsNext값이 뒤집어지면서 어떤 플레이어가 다음차례인지 판단하고 게임의 state가 저장될 것이다. xIsNext 값을 뒤집기 위해 Board의 handleClick 함수를 수정해야 한다.

 

handleClick(i){
  const squares = this.state.squares.slice();
  squares[i] = 'X';
  this.setState({
    squares:squares,
    xIsNext : !this.state.xIsNext,
  });
}


이렇게 하면 "X"와 "O"가 턴제로 플레이할 수 있다. 이제 Board의 render 함수의 "status"를 수정하면 화면에 누구의 턴인지 알려줄 수 있다.

 

render() {
  const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
  return ( ...


수정 후에 Board 컴포넌트의 소스는 아래와 같다.

 

  class Board extends React.Component {
    constructor(props){
      super(props);
      this.state = {
        squares : Array(9).fill(null),
        xIsNext: true,
      };
    }
 
    handleClick(i){
      const squares = this.state.squares.slice();
      squares[i] = 'X';
      this.setState({
        squares:squares,
        xIsNext : !this.state.xIsNext,
      });
    }
 
    renderSquare(i) {
      return (
        <Square 
          value={this.state.squares[i]}
          onClick={() => this.handleClick(i)}  
        />
      );
    }
  
    render() {
      const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
      return (
        <div>
          <div className="status">{status}</div>
          <div className="board-row">
            {this.renderSquare(0)}
            {this.renderSquare(1)}
            {this.renderSquare(2)}
          </div>
          <div className="board-row">
            {this.renderSquare(3)}
            {this.renderSquare(4)}
            {this.renderSquare(5)}
          </div>
          <div className="board-row">
            {this.renderSquare(6)}
            {this.renderSquare(7)}
            {this.renderSquare(8)}
          </div>
        </div>
      );
    }
  }


5. 승자 판단하기


  이제 다음턴이 누구 차례인지 알수 있고, 승자가 가려졌을 때 턴이 진행되지 않도록 해야 한다. 파일 마지막에 승자를 결정할 수 있는 함수를 추가해보자.

 

  function calculateWinner(squares){
    const lines = [
      [0,1,2],
      [3,4,5],
      [6,7,8],
      [0,3,6],
      [1,4,7],
      [2,5,8],
      [0,4,8],
      [2,4,6],
    ];
    for(let i = 0; i<lines.length; i++){
      const [a,b,c] = lines[i];
      if(squares[a] && squares[a] === squares[b] && squares[a] === squares[c]){
        return squares[a];
      }
    }
    return null;
  }


Board의 render 함수에서 승자를 판단하기 위해 calculateWinner(squares)를 호출할 것이다. 만약 승자가 있다면, 화면에 "Winner:X" 또는 "Winner:O"를 보여줄 것이다. status 변수를 아래와 같이 수정하자.

 

    render() {
      const winner = calculateWinner(this.state.squares);
      let status;
      if(winner){
        status = 'Winner: ' + winner;
      }else{
        status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
      }


이제 Board의 handleClick 함수를 수정해서 승자가 결정되었거나 Square에 이미 값이 있을 때 클릭이벤트를 무시하도록 하자. 

 

    handleClick(i){
      const squares = this.state.squares.slice();
      if(calculateWinner(squares) || squares[i]){
        return;
      }
      squares[i] = this.state.xIsNext?'O':'X';
      this.setState({
        squares:squares,
        xIsNext : !this.state.xIsNext,
      });
    }


이제 동작하는 tic-tac-toe가 완성되었다. 그리고 React의 기본도 알게 되었다. 

'React' 카테고리의 다른 글

[Tutorial]6.마무리  (0) 2019.02.15
[Tutorial]5.Time Trave 기능 추가  (0) 2019.02.15
[Tutorial]3.개요  (0) 2019.02.15
[Tutorial]2.Tutorial 환경설정  (0) 2019.02.15
[Tutorial]1.Tutorial을 시작하면서...  (0) 2019.02.15