시간여행 추가 부분을 추가하려고 하니, 문서 길이가 너무 길어서 그런지 오류가 발생한다.
그래서 어쩔 수 없이 2로 새로 글로 작성하게 되었다.

시간여행 추가

이제 마지막으로, 게임 플레이 중 이전 턴으로 돌아갈 수 있도록 해보자.

턴 저장하기

우리가 squares 배열을 .slice()를 통해서 새로운 복사본을 만들면서 사용하기 때문에 우리는 각 턴마다의 상태를 저장할 수 있다. 우리는 history라는 새로운 배열에 이전 턴의 squares 배열을 저장할 것이다. history 배열은 처음부터 마지막 턴까지의 게임 보드판의 모습을 다 저장하게 될 것이다. 코드로 보자면 아래와 같은 형태가 될 것이다:

history = [
  // Before first move
  {
    squares: [
      null, null, null,
      null, null, null,
      null, null, null,
    ]
  },
  // After first move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, null,
    ]
  },
  // After second move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, 'O',
    ]
  },
  // ...
]

우리는 history 의 상태를 어떤 컴포넌트에서 관리해야 할 지 정해야 한다.

한 번 더 상태 전달하기

앞서 말한 history의 상태를 제일 최상단 컴포넌트인 Game 컴포넌트에서 관리하려고 한다.
그래서 state를 부모(parent) 컴포넌트에게 전달하기 에서
Square 컴포넌트의 상태를 Boardprops를 통해 전달한 것처럼, Board의 상태를 Game으로 props를 통해 전달할 것이다.
( 쉽게 설명하자면, 하위 컴포넌트의 state 로 쓰던 것-> 상위 컴포넌트에서 관리 및 props 를 통해 하위 컴포넌트로 전달)

먼저, 우리는 Game 컴포넌트에 생성자를 추가하자:

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

  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

다음으로, Board 컴포넌트가 Game 컴포넌트로부터 squaresonClickprops로 받게 하자.
아래의 단계를 통해 Board 컴포넌트를 수정하자:

  • Board 생성자 삭제
  • BoardrenderSquare 함수에서 this.state.squares[i]this.props.squares[i] 로 교체
  • BoardrenderSquare 함수에서 this.handleClick(i)this.props.onClick(i) 로 교체

그러면 Board 컴포넌트는 이렇게 될 것이다:

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

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

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      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>
    );
  }
}

그리고 게임 진행 상태와 승자 파악하는 부분을 history의 최근 상태를 이용하도록 수정하자.
Game 컴포넌트의 render 함수를 아래처럼 바꾸자:

render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

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

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i) => this.handleClick(i)}
          />

        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }

이제 Game 컴포넌트 안에서 status를 관리하기 때문에 Board에 있는 status 부분을 지우자.
Boardrender 함수를 아래처럼 수정하면 된다:

    render() {
    return (
      <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 컴포넌트에 있던 handleClick 함수를 Game 컴포넌트로 옮길 것이다. 이제 history를 가지고 보여줄 것이므로 아래처럼 좀 수정해야한다:

  handleClick(i) {
    const history = this.state.history;
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{
        squares: squares,
      }]),
      xIsNext: !this.state.xIsNext,
    });
  }
여기서 잠깐! push() vs concat()

우리는 턴이 지날 때마다 history 배열에 추가하기 위해서 concat()을 사용했다. 왜 push()를 안 쓰고 concat()을 사용했을까?

push() : The push() method adds one or more elements to the end of an array and returns the new length of the array. (MDN)

var arr1 = [‘a’, ‘b’, ‘c’];
var arr2 = [‘d’, ‘e’, ‘f’];
var arr3 = arr1.push(arr2);
console.log(arr3); // 4
console.log(arr1); // [“a”, “b”, “c”, [“d”, “e”, “f”]]

concat() : The concat() method is used to merge two or more arrays. This method does not change the existing arrays, but instead returns a new array. (MDN)

var arr1 = [‘a’, ‘b’, ‘c’];
var arr2 = [‘d’, ‘e’, ‘f’];
var arr3 = arr1.concat(arr2);
console.log(arr3); //[“a”, “b”, “c”, “d”, “e”, “f”]

쉽게 설명하자면, push()를 쓰면 기존 배열에 영향이 있고, concat()은 영향이 없다. 우리는 이전 history를 유지해야하므로 concat()을 쓰는 것이다.

Board 컴포넌트에서 handleClickGame 컴포넌트로 옮겼으므로, Board에는 이제 renderSquarerender 함수만 있으면 된다.

현재까지 완성본

이전 턴 보여주기

이제 우리는 tic tac toe 게임의 히스토리를 저장하고 있기 때문에, 플레이어에게 과거 턴들을 리스트로 보여줄 수 있다.
자바스크립트에서 배열은 아래와 같이 배열 내 데이터를 매핑하기 위해서 map() 메소드를 쓸 수 있다:

const numbers = [1, 2, 3];
const doubled = numbers.map(x => x * 2); // [2, 4, 6]

우리는 map() 함수를 가지고 history 배열을 화면에 보여줄 버튼으로 매핑할 수 있다.
Gamerender 함수에서 아래와 같이 수정하자:

render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

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

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }

현재까지 완성본

턴이 지날 때마다, 우리는 버튼을 포함한 태그를 만들게 된다. 버튼은 this.jumpTo() 라는 함수를 호출하는데, 이 함수는 아직 만들지 않았기 때문에, 아래와 같은 경고창이 뜰 것이다:

Warning: Each child in an array or iterator should have a unique “key” prop. Check the render method of “Game”.

키(Key) 고르기

우리가 리스트를 화면에 보여줄 때, 리액트는 화면에 보여줄 리스트들의 정보를 저장하고 있다. 우리가 리스트의 값을 업데이트하면, 리액트는 무엇이 바뀌었는 지 알아내어서 다시 화면에 보여주게 된다.

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>

위의 리스트를 아래와 같이 바꾼다고 생각해보자.

<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

우리는 AlexaBen 의 순서가 바뀌고 이 사이에 Claudia가 추가되었다는 것을 알 수 있다. 하지만 리액트는 우리의 의도를 쉽게 알 수가 없기 때문에, 우리는 비슷한 리스트들 중에 key를 통해서 무엇이 다른지 리액트에게 알려줄 필요가 있다.
예를 들면 Alexa',Ben,Claudia` 의 데이터베이스 ID를 알고 있다면 다음과 같이 표현할 수 있다:

<li key={user.id}>{user.name}: {user.taskCount} tasks left</li>

이렇게 key를 가지고 만들게되면, 리액트는 리스트 아이템의 키가 이전에 있던 것인지 확인하고 다시 화면에 그리게 된다.
이전에 없던 key면 리액트는 새로운 컴포넌트를 만든다. key가 없어졌다면 리액트는 만들었던 컴포넌트를 제거한다.
그리고 기존의 key가 변경되면 리액트는 기존의 컴포넌트를 없애고 새로운 컴포넌트를 만들게 된다.

keyprops에 종속되어 this.props.key처럼 사용된다고 생각할 수도 있지만 그렇지 않다. 컴포넌트는 keythis.props.key처럼 접근할 수 없고, 리액트가 알아서 key를 통해 업데이트 할 컴포넌트를 결정하게 된다.

동적 리스트(dynamic lists)를 만들 때, 적절한 key를 쓰는 것을 추천한다.

key를 정하지 않으면, 앞서 본 것처럼 리액트는 경고창을 띄워주고 배열의 index를 기본 key로 사용한다.
배열의 index를 key로 쓰는 것은 리스트를 재정렬하거나 기존 배열 가운데 새롭게 추가/삭제하게 될 경우 문제가 될 수 있다.

시간 여행 구현하기

우리가 만든 tic tac toe 게임에서는 플레이어의 턴이 재정렬되거나 중간에 추가, 삭제 될 일은 없기 때문에 우리는 history의 index를 key로 사용할 것이다.

Game 컴포넌트의 render 함수에서 <li key={move}>와 같이 key를 추가하자:

      const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });


그리고 아직 `this.jumpTo(move)` 에서 `jumpTo` 함수를 만들지 않았다. 이 함수를 만들기 전에 `Game` 컴포넌트에 `stepNumber`를 추가할 것이다.

먼저, `Game`의 생성자에 `stepNumber: 0`을 추가하자:

class Game extends React.Component {  
  constructor(props) {  
    super(props);  
    [this.state](this.state) = {  
      history: \[{  
        squares: Array(9).fill(null),  
      }\],  
      stepNumber: 0,  
      xIsNext: true,  
    };  
  }

그리고 stepNumber를 업데이트 해줄 jumpTo 함수를 만들자. 그리고 stepNumber가 짝수 일 때, xIsNexttrue가 되도록 수정하자:

  handleClick(i) {
    // this method has not changed
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0,
    });
  }

  render() {
    // this method has not changed
  }

이제 우리는 GamehandleClick 함수를 조금 바꿔야 한다.

먼저, 플레이어가 사각형을 클릭했을 때 stepNumber가 업데이트 되도록 this.setState 부분에 stepNumber: history.length,를 추가하자. 이걸 추가해야 플레이어가 사각형을 누를 때마다 턴이 증가하게 된다.

그리고 this.state.historythis.state.history.slice(0, this.state.stepNumber + 1)로 바꾸자.
이로써 게임을 플레이하다가 이전 턴으로 돌아갔을 경우에 그 이후의 턴들의 값을 다 날려버릴 수 있다.

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{
        squares: squares
      }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }

마지막으로, Game 컴포넌트의 render 함수를 수정해서 stepNumber에 맞는 현재 상태를 보여주도록 하자:

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    // the rest has not changed

이제, 턴을 표시하는 리스트 버튼을 누르게 되면 게임 화면이 바로 그 때의 턴으로 바뀌게 된다.

현재까지 완성본

마무리

이제 드디어 tic tac toe 게임을 완성했다.
여기서 좀 아쉽다면 아래의 것들을 추가해보는 것도 공부에 도움이 될 것이다:

  1. 히스토리에 선택한 (행,열) 표시하기
  2. 히스토리에 현재 상태는 진하게 표시하기
  3. Board 컴포넌트 calculateWinner 함수에서 하드코딩한 부분 2중 루프로 다시 작성하기
  4. 오름차순/내림차순 으로 히스토리를 볼 수 있는 토글 버튼 만들기
  5. 플레이어가 이겼을 경우, 승리하게 된 3개의 사각형을 하이라이트 해주기
  6. 승패가 안가려지고 사각형이 다 채워졌을 경우, 비겼다는 메세지 보여주기

기초 문법, 리액트 컴포넌트와 같은 문서들이 많으니 참고하면서 진행하면 될 것 같다.

이상 뿅!

'개발 > React.js' 카테고리의 다른 글

리액트 공식 튜토리얼을 쉽게 해보자  (0) 2019.03.29
React.js 개발 환경  (0) 2019.03.19

리액트 공식 튜토리얼

리액트 공식 홈페이지를 가보면 튜토리얼을 제공하고 있다.
공식 홈페이지에서 한글 언어를 지원하길래 튜토리얼을 의미하는 자습서를 눌러보니
여긴 뭐 그대로 영어라 사람들이 편히 이해할 수 있도록 포스팅을 해보기로 했다.
튜토리얼은 tic tac toe 라고 불리는 게임을 만들어보는 내용이다. 굳이 번역을 해보자면 삼목(?)이라고 할 수 있겠다.
(크롬을 열고 tic tac toe 를 검색 해보면 바로 tic tac toe 게임을 즐겨볼 수도 있다.)
링크를 보면 알겠지만 전체적으로 JSX나 Babel, ES6 등 문법적 내용도 있고 Component, props, state와 같은 리액트 개발에 필수적인 내용도 있다.
그래서 적지 않은 양이기 때문에 이 포스팅으로 편히 따라가길 바란다. 구성도 원본과 동일하게 하였다.
(튜토리얼 한글화 작업 중이긴 하다. https://reactjs-org-ko.netlify.com/tutorial/tutorial.html)

튜토리얼 섹션

튜토리얼은 아래와 같이 구성되어 있다고한다.

  • Setup for the Tutorial will give you a starting point to follow the tutorial.
  • Overview will teach you the fundamentals of React: components, props, and state.
  • Completing the Game will teach you the most common techniques in React development.
  • Adding Time Travel will give you a deeper insight into the unique strengths of React.

한글로 표현하자면,

  • 튜토리얼 환경설정어떻게 리액트를 시작할 지 알려줄 것이다.
  • 개요에서는 리액트의 기본Components, props, state에 대해 알려줄 것이다.
  • 게임 완성하기에서는 리액트 개발에서 자주 쓰이는 기술들에 대해 배울 수 있다.
  • 시간 여행 추가하기에서는 리액트만의 강점에 대해 더 깊게 이해할 수 있다.

그래서 우리가 만드는 게 뭐죠?

결과물CodePen 이라고 하는 곳에 올려져 있다.
CodePen은 굳이 환경설정을 하지 않아도 웹 상에서 쉽게 코딩해보고 결과물을 확인 할 수 있게 도와주는 곳이라고 보면 되겠다.

필요한 선행 지식

튜토리얼은 HTML, JavaScript에 대해 기본적으로 알고 있다고 생각하고 만들어졌다.
사실 그렇게 어려운 건 없어서 아무 언어로 프로그래밍을 해봤다면 쉽게 따라할 수 있을 것이다.
그리고 ES6(Babel)에서 추가된 let, const, arrow function과 관련된 내용도 있기 때문에 모른다면
가이드를 참고하기 바란다.

튜토리얼 환경설정

두 가지 방식이 있는데, 하나는 앞서 말한 CodePen에서 진행하는 방법, 하나는 로컬에서 진행하는 방법이다.
로컬에서 진행하려면 Visual Studio Code와 같은 에디터, Node.js 설치가 필요한데, 이건 기존에 작성한 나의 포스트로 대체하겠다.
로컬에서 진행하는 것은 코딩 시작하기에서 더 자세히 쓰겠다.

  1. CodePen으로 웹에서 시작하기
  2. 로컬에서 시작하기

개요

환경설정이 다 끝났으면 이제 본격적으로 시작해보자. 개요에서는 대략적으로 리액트와 리액트 문법에 대해서 작성되었다.

리액트가 뭐죠?

리액트는 유저 인터페이스를 만들어주는 JavaScript 라이브러리다. 리액트는 components 라고 불리는 각각의 조각을 붙여 화면을 만들게 도와준다.
리액트는 여러 컴포넌트 종류가 있는데, React.Component 로 설명을 하려고 한다.

class ShoppingList extends React.Component {
  render() {
    return (
      <div className="shopping-list">
        <h1>Shopping List for {this.props.name}</h1>
        <ul>
          <li>Instagram</li>
          <li>WhatsApp</li>
          <li>Oculus</li>
        </ul>
      </div>
    );
  }
}

// Example usage: <ShoppingList name="Mark" />

리액트는 컴포넌트를 통해 화면을 구성하게 되는데, 데이터가 바뀔 때 stateprops(properties의 준말)를 통하면
리액트가 알아서 효율적으로 데이터를 업데이트하여 화면에 다시 보여주게 된다.
render 메소드(method)는 return 에 화면에 보여줄 것을 담게 되는데, 리액트에서는 React element가 된다.
이것을 앞서 작성한 JSX 방식으로도 할 수 있고, 아래와 같이 React API 를 이용할 수도 있다.(보통 JSX를 추천!)

return React.createElement('div', {className: 'shopping-list'},
  React.createElement('h1', /* ... h1 children ... */),
  React.createElement('ul', /* ... ul children ... */)
);

코딩 시작하기

튜토리얼 환경설정에서 1.CodePen으로 시작한 경우에는
여기를 눌러 시작하면 된다.

  1. 로컬에서 시작한 경우에는 VSC에서 프로젝트 폴더를 열어서 시작하자.
    CSS를 공부할 건 아니라서 CSS는 튜토리얼에서 제공해주는 그대로 사용하자.

로컬에서 환경설정하기

  1. Node.js 최신버전 설치
  2. 프로젝트 폴더를 구성할 위치에서 아래 명령어 실행
npx create-react-app my-app
  1. src/ 폴더 아래에 있는 파일들 전체 삭제(src 폴더 자체는 남겨두자). 아래 명령어를 순서대로 실행해도 된다.
cd my-app
cd src

# Mac / Linux:
rm -f *

# Windows:
del *

# 프로젝트 폴더로 복귀
cd ..
  1. CSS code를 복사해서 src/index.css 에 저장

  2. JS code를 복사해서 src/index.js 에 저장

  3. src/index.js에 아래의 3줄 추가하기:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

그러면 프로젝트 폴더에서 npm start 명령을 통해 실행이 가능하다. 실행 후, http://localhost:3000 에서 3x3의 사각형들이 보이면 끝.
나중에 코드를 수정하고 저장하면 별도로 명령을 재실행하지 않아도 반영된다.

완료 화면

코드들을 살펴보면, 튜토리얼은 3개의 컴포넌트로 구성되어 있다.

  • Square
  • Board
  • Game

Square 컴포넌트에는 하나의 <button> 태그가 있고, Board 컴포넌트를 분석해보면 9개의 Square 컴포넌트가 들어간다.
우리는 /* TODO *//* status */를 채워서 게임을 완성시키자.

props를 통해 데이터 전달하기

튜토리얼에서는 복사/붙여넣기를 하지말고 직접 타이핑하는 것이 개발 실력 향상에 도움이 될 거라고 한다.

우선, Board class의 renderSquare 메소드를 다음 내용으로 바꾸자:

class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }

Sqaurerender 메소드에서 /* TODO */{this.props.value} 로 바꾸자;

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

[Before]

Before

[After]

After

Before에서 After로 화면이 바뀌었으면 성공이다. Square가 상위 컴포넌트인(혹은 parent) Board에서 데이터를 받아 화면으로 보여주는 방식이다.
만약, 결과가 다르다면 링크의 코드와 비교하여 뭘 빠트렸는 지 확인하면 되겠다.

컴포넌트에 이벤트 추가하기

이제 Square를 클릭했을 때 X를 보여주도록 만들어보자. Squarerender 메소드를 다음과 같이 수정하자:

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

Square를 누르면 click 메세지와 함께 경고창이 뜬다. 여기서는 => 처럼 ES6 문법인 arrow function이 사용되었다.
원래 목표는 사각형에 X를 띄우는 거란 걸 잊지말자. 이걸 위해서 state를 사용할 것이다.
리액트 컴포넌트는 생성자(constructor)에 this.state 를 써서 상태를 저장할 수 있다.
현재의 값을 this.state에 저장하고, Square가 클릭되었을 때 이 값을 바꿔주자.

우선, Square 컴포넌트에 생성자를 추가하고 초기화해주자:

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button className="square" onClick={() => alert('click')}>
        {this.props.value}
      </button>
    );
  }
}

여기서 잠깐!
모든 생성자를 가지는 리액트 컴포넌트 클래스는 반드시 super(props) 를 호출해야한다.

이제, Squarerender 메소드를 수정하자.

  • 태그의 this.props.valuethis.state.value 로 교체
  • onClick={...} 내용을 onClick={() => this.setState({value: 'X'})} 로 교체
  • 가독성을 위해 className onClick 라인 분리

완성본 :

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

Square를 클릭하면 render 메소드의 onClick으로 인해 Squarethis.state.valueX가 될 것이다.
컴포넌트의 setState를 호출하면, 리액트는 자동적으로 하위 컴포넌트(혹은 자식, child)의 것도 바꾼다.
실행해서 클릭했을 때, 사각형에 X가 표시되는 지 확인하자.
안될 경우, 링크 확인!

개발 툴

크롬이나 파이어폭스에서
리액트로 만든 페이지를 검사할 수 있다.

검사 화면

리액트 개발툴에서 마우스 우클릭-검사를 하면 브라우저 오른쪽에 탭이 열리면서 propsstate를 확인할 수 있다.

CodePen에서 진행하는 방법:

  1. 로그인 / 회원가입 후 e-mail 인증(스팸 방지)
  2. Fork 버튼 클릭
  3. Change View 클릭 후 Debug mode 선택

게임 완성하기

이제는 Square를 클릭했을 때 XO를 번갈아가면서 나오게하고, 승자를 판단하는 것을 추가해야한다.

state를 부모(parent) 컴포넌트에게 전달하기

지금은 각각의 Squarestate를 가지고 있다. 누가 승자인지 판단하기 위해 9개의 Squarestate를 한 곳에서 체크해야한다.

Board가 각각의 Squarestate를 확인할 수도 있지만 이해하기가 좀 어렵고, 버그 걸리기 쉽고, refactor 하기 어려워 추천하지 않는다.
대신에, 우리는 각각의 Square가 아닌 Board에 게임의 state를 저장할 것이다. 그리고 이전에 우리가 각각의 사각형의 번호를 넘겼던 것처럼
BoardSquareprops를 전달할 것이다.

여러 자식들로부터 데이터를 가져오거나, 자식 컴포넌트 간 데이터를 주고 받아야할 때 공통된 state를 부모 컴포넌트에 선언해야한다.
부모 컴포넌트는 props를 통해 state를 자식 컴포넌트에게 전달할 수 있다. 이걸로 자식 컴포넌트들이 서로 동기화(sync) 될 수 있다.

리액트 컴포넌트를 refactor 할 때, state를 부모 컴포넌트로 올리는 것을 자주 하게 될 것이니 이번 기회에 해보는 것이 좋다.

Board에 생성자를 추가하고, 생성자 state에 9개의 Square에 대응되도록 squares라는 사이즈 9짜리 배열을 선언하고 null 로 초기화하자:

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

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

게임이 진행되면 this.state.squares는 이런 모습이 될 것이다:

[
  'O', null, 'X',
  'X', 'X', 'O',
  'O', null, null,
]

지금 BoardrenderSquare 메소드는 이럴 것이다:

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

이건 처음에 우리가 value를 props를 통해 전달해서 사각형에 0부터 8까지 숫자를 넣을 때 작성한 코드이다.
하지만 우리가 그 다음에 클릭한 경우 Xvalue를 가진 state를 가지도록 했고,
{this.state.value} 를 읽도록 했기 때문에 숫자는 볼 수 없었다.

이제 BoardrenderSquare 메소드를 수정해서 우리가 만든 squares 배열을 이용하도록 할 것이다.

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

현재까지 코드 완성본

이제는 Square를 클릭했을 때의 이벤트를 바꿔줘야한다.(지금은 X값 세팅만 있다.) 그러기 위해서는 Boardstate를 업데이트 해줘야한다.
하지만 state는 해당 컴포넌트 내부에 선언되어 있어 private하기 때문에, SquareBoardstate를 직접 변경할 수는 없다.
대신에, 우리는 함수(function)를 호출하는 것으로 이를 해결할 것이다.

BoardrenderSquare를 아래와 같이 바꾸자:

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

이제 우리는 Board에서 Square로 2개의 props를 내려줄 것이다: valueonClick.
onClick 이란 propSquare가 클릭 되었을 때 호출 될 것이다.
그리고 Square에 아래 3가지를 수정하자:

  • Squarerender 메소드에서 this.state.valuethis.props.value로 변경
  • Squarerender 메소드에서 this.setState()this.props.onClick()으로 변경
  • Square의 생성자 삭제(이제 게임의 state를 여기서 관리하지 않는다)

다 반영하면, Square 컴포넌트는 이렇게 될 것이다:

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

Square를 클릭했을 때, 어떻게 동작하게 되는 지 살펴보자:

  1. <button> 안의 onClick이 리액트가 클릭 이벤트 리스너를 세팅하도록 명령한다.
  2. 버튼이 클릭되었을 때, 리액트는 Squarerender()메소드 안에 있는 onClick 이벤트 핸들러를 호출한다.
  3. 이벤트 핸들러는 this.props.onClick()을 호출한다. SquareonClick prop은 Board에서 선언되어 있다.
  4. 클릭되었을 때 BoardSquareonClick={() => this.handleClick(i)} 하도록 했기때문에, Squarethis.handleClick(i)를 호출한다.
  5. 아직 handleClick() 메소드를 구현하지 않아서 사각형을 클릭하면 다음 에러가 발생할 것이다.
    ``TypeError: _this3.handleClick is not a function''

<button>은 built-in 컴포넌트이기 때문에 onClick 을 써도 리액트가 이해할 수 있다.
하지만 Square처럼 커스텀 컴포넌트(이름을 새로 만든 경우)는 SquareonClickprop이나 BoardhandleClick 메소드 중 아무거나 써도 된다.
리액트에서는 props의 경우에는 on[Event]로, method의 경우에는 handle[Event]를 쓰는 것이 일반적이다.

자 그럼 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>
    );
  }
}

완성본 링크

수정을 하고 실행해보면, 이전처럼 클릭했을 때 사각형에 X가 표시된다. 그럼 여태까지 뭐한거지라고 생각할 수도 있겠다.
다시 정리해보자면, 이전에는 각각의 Square에서 state를 관리했지만 이제는 Board에서 관리되기 때문에 여기서 승자를 결정할 수 있게 되었다.
BoardstateSquare가 내려 받아 사용하므로 Square는 이제 controlled components라고 할 수 있다.
Boardstate가 바뀌면 Square는 자동적으로 state를 업데이트하여 다시 화면에 보여주게 된다.

handleClick을 보면 .slice()라는 함수를 통해 기존 배열을 수정하는 대신 squares의 복사본을 만들게 되었다.
이 이유는 다음 섹션에 설명하도록 하겠다.

변경불가성이 중요한 이유

바로 앞에서 squares 배열을 그대로 쓰지 않고 .slice() 를 써서 배열의 복사본을 만들어서 접근하였다. 왜 그렇게 했는지 알아보면서 변경불가성에
대해 이해해보자.

데이터를 바꾸는 두 가지의 방식이 있는데, 하나는 데이터의 값을 직접 변경하는 것이다.
다른 하나는 바뀌었으면 하는 값의 새로운 복사본으로 교체하는 것이다.

데이터의 값을 직접 변경
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// Now player is {score: 2, name: 'Jeff'}
바뀌었으면 하는 값의 새로운 복사본으로 교체
var player = {score: 1, name: 'Jeff'};

var newPlayer = Object.assign({}, player, {score: 2});
// Now player is unchanged, but newPlayer is {score: 2, name: 'Jeff'}

// Or if you are using object spread syntax proposal, you can write:
// var newPlayer = {...player, score: 2};

두 가지 방법의 결과는 동일하지만, 방식의 차이로 인해 아래의 차이가 난다.

히스토리 관리와 재사용

우리는 나중에 시간 여행 추가하기에서 tic tac toe 게임의 히스토리를 관리하고, 게임 진행을 "점프" 하게 할 것이다.
이렇게 했던 것을 또 하거나 취소하는 기능은 어플리케이션에서 자주 쓰이기 때문에, 데이터의 값을 직접 변경하지 않는 것이 좋다.
이전 버전의 값을 가지고 있을 수 있기 때문에 히스토리 관리나 재사용하기에 유리하다.

변화 감지

값을 직접 바꿨으면 변화를 감지하는 건 어렵게 된다. 하지만 새로운 복사본으로 교체하는 방식은 과거의 데이터와 바꾼 데이터가 있기 때문에 서로 비교하면 바뀌었는지 아닌지 알 수 있게 된다.

언제 화면에서 다시 그릴지 결정

우리는 변화 감지에서 본 것 처럼 데이터가 언제 바뀌었는지 알기 때문에 언제 컴포넌트가 다시 화면에 그려져야 하는지도 쉽게 정할 수 있다. shouldComponentUpdate() 를 어떻게 사용하는 지 성능 최적화 를 읽어보길 바란다.

함수 컴포넌트(Function Components)

Square 부분을 함수 컴포넌트(function component)로 바꿔보자.

리액트에서 함수 컴포넌트는 자신의 state를 따로 관리하지 않고 render() 메소드만 가지는 경우 간단히 쓰기 위해 사용된다.
React.Component 를 상속받지 않는 대신, props의 인자를 가지고 render될 부분을 return 하는 메소드를 만들 수 있다.
컴포넌트들을 무조건 React.Component를 상속받는 클래스로 선언하지 말고 위에 해당될 경우 함수형태로 간단히 만들자.

Square 클래스를 다음 함수로 바꾸자:

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

여기서 기존의 this.propsprops로 바꾸었다.
또한, 기존의 onClick={() => this.props.onClick()}onClick={props.onClick} 로 변경하였다.

현재까지 완성본

순서 바꾸기 추가

지금은 사각형에 X만 표시된다. tic tac toe 게임에 맞게 순서대로 X, O 가 번갈아가면서 나오도록 수정하자.

처음 클릭했을 때는 X가 나오도록 할 것이기 때문에 Board의 생성자를 아래처럼 수정하자:

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

플레이어의 턴에 따라서 xIsNext의 값을 truefalse로 계속 바꿀 것이다. BoardhandleClick 함수를 수정하자:

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

수정이 되었으면 실행해서 순서대로 X, O 가 번갈아가면서 나오는 지 확인해보자.

그리고 Boardrender()를 수정하여 어떤 플레이어의 차례인 지 보여주도록 수정하자:

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

    return (
      // the rest has not changed

다 수정했을 때의 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] = this.state.xIsNext ? 'X' : 'O';
    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>
    );
  }
}

현재까지 완성본

승자 결정하기

이제 플레이어의 턴이 누군지 알 수 있게 되었다. 이번에는 게임이 끝나는 상황을 위한 프로그램 작성을 하려고 한다.
이 함수를 index.js 파일 마지막에 붙여넣자:

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

lines 배열에 흔히 빙고가 이뤄지는 경우를 미리 적어두었다.(배열은 index가 0부터 인 것을 잊지말자)
Boardrender 함수에서 calculateWinner(squares) 를 호출하여 승자를 확인할 것이다.
승자가 정해지면 Winner: XWinner: O 로 보여줄 것이다.
Boardrender 함수에서 status 부분을 아래처럼 바꾸자:

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

    return (
      // the rest has not changed

그리고 BoardhandleClick에서 이미 클릭되어 XO로 채워진 사각형을 클릭하거나, 승자가 이미 결정된 경우
클릭을 무시하도록 다음과 같이 변경하자:

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

현재까지 완성본

이제 정상적으로 동작하는 tic tac toe 게임이 완성되었다. 충실히 따라했다면 리액트의 기본도 어느 정도 이해할 수 있었을 것이다.

'개발 > React.js' 카테고리의 다른 글

리액트 공식 튜토리얼을 쉽게 해보자 2  (0) 2019.04.01
React.js 개발 환경  (0) 2019.03.19

리액트가 뭐죠?

React.js는 웹 개발용 javascript 라이브러리다. 이게 뭐냐고 물어보면 그냥 웹 화면을 쉽게 개발할 수 있게 도와주는 거라고 말하고 싶다. Spring으로 웹 개발을 해봤다면 JavaApache Tomcat가 익숙하겠지? React.js 에서는 JavaScriptNode.js 가 그 역할을 대신한다고 볼 수 있다.

좀 더 개념을 정리해보자면 Spring이나 React.js 는 웹 페이지를 개발을 쉽게 도와주는 Framework 이다. Java나 JavaScript는 개발 언어가 되겠고, Apache Tomcat과 Node.js 는 엄밀히 말하면 좀 다를 수 있겠지만 서버를 띄워주는 역할을 한다. 이를 간단히 표로 비교해보면 아래와 같다.


FrameworkSpringReact.js
LanguageJavaJavaScript
Server SideApache Tomcat(Apache Maven)Node.js(npm)


Spring은 우리나라가 유난히 Java를 많이 쓰는 곳이라 아직도 많은 곳에서 쓴다. 하나의 페이지를 추가 개발하려면 Controller, Service, ServiceImpl, DAO 등등 거의 하나씩 새로 또 만들어줘야했다. 보통 Spring으로 개발할 경우에는 Dependency가 필요한 데, 이를 Maven을 통해서 관리되도록 했다. 이 때, Java등 버전을 또 제대로 못 맞추면 빌드하기가 힘들었다. Spring에서도 위와 같은 문제 때문에 Spring Boot로 좀 더 쉽게 개발할 수 있도록 지원하고 있다.

React.js는 facebook에서 밀고 있는 framework이다. Spring Boot처럼 쉽게 리액트 개발을 시작하기 위해서는 facebook에서 제공하는 create-react-app을 이용하면 된다. 우선 create-react-app 으로 이동해서 README.md 파일을 한 번 보자. Creating an App 여기 밑으로 개발 환경을 어떻게 맞추면 될 지도 상세히 적어두었다. 나같이 어줍잖은 블로그보다 이런 공식 깃헙을 보는게 개발 환경을 정할 때는 중요하다. (보니까 내가 쓰던 npm도 버전이 올라가면서 npx로 쓸 수 있게 변경이 되었나보다)

개발 환경

  1. Node.js설치(최신 LTS 버전: 10.15.1 (includes npm 6.4.1))
  2. Visual Studio Code(자바스크립트 개발툴;구글에서 VSC로 검색하면 편함)
  3. Yarn(Stable: v1.13.0; 굳이 없어도 되지만 하는 김에 같이 해보자 )

설치는 그냥 다운로드 받고 실행-Next-Finish 만 하면 된다.(자동으로 PATH 등록이 될 것이다)

실행하기

이제 Node.js를 이용해서 create-react-app을 가져올 것이다. 개발환경을 위와 동일하게 맞췄다면 터미널을 열어서 아래의 명령을 실행하자.

음..터미널이 뭐에요? 터미널 중에 뭘 해야하죠?
  1. 윈도우 키-모든 프로그램을 눌러보면 Node.js가 보일 것이다. Node.js command prompt 를 실행하자.
  2. 윈도우 키-프로그램 및 파일 검색-cmd 입력
  3. VSC 에서 Ctrl+` 을 누르면 터미널이 열린다.

npx

npx create-react-app my-app

실행이 잘 된다. 나는 Yarn도 미리 설치가 되어 있어서 그런지 yarn 명령어를 추천해준다.

cd my-app
yarn start

이렇게하니 바로 http://localhost:3000/ 가 띄워지고 거기서 리액트 로고가 열심히 돌고 있다. 이러면 리액트 환경설정이 끝났다. 이제 js나 css를 수정하면 나만의 웹 페이지를 개발할 수 있게 된다. Yarn이 설치가 안되어 있는 경우는 어떻게 뜨는 지 확인하기가 좀 귀찮으니 Node.js 에서 Yarn 설치하는 방법만 추가로 작성하겠다.

Node.js 에서 Yarn 설치하기
npm install yarn

여기서 -g 옵션을 줘서 글로벌하게 쓸 수 있게 할 수도 있는데, 글로벌을 남용하면 이상하게 꼬일 수 있으니 조심하자. Node.js 명령어들에 대해서는 다음에 한 번 정리해야겠다. 이상 끝. 

+ Recent posts