[프로젝트] 틱택토 게임 (1)

rin·2020년 5월 15일
0

React

목록 보기
6/16
post-thumbnail

ref. https://ko.reactjs.org/tutorial/tutorial.html
위 자습서의 틱택토 게임 개발을 클래스 컴포넌트가 아닌 함수형 컴포넌트와 Hook을 이용해 진행합니다. 필자는 리액트를 처음 사용해보므로 코드가 더럽거나 올바르지 않을 수 있습니다.
이 글을 작성하는 이유는 제 개인적인 리액트 학습을 위한 것입니다.

개발 환경 셋팅

nvm → nodejs → yran 순으로 설치하도록한다.

nvm

Node Version Manager의 약자. 여러 노드 버전이 설치 된 경우 특정 버전을 사용하도록 설정할 수도 있고, nodejs의 활성 버전을 설치하고 관리할 수 있게 해주는 툴이다.

✔️설치하기
$ sudo curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.1/install.sh | bash

$ nvm ls 를 입력하여 확인한다. 만약 -bash: nvm: command not found라고 뜬다면 vi 편집기를 이용해 환경 변수를 셋팅해 주어야한다.

✔️환경변수 설정
$ vi ~/.bash_profile

아래 코드가 있는지 확인하고, 없으면 추가한다.

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # This loads nvm

$ source ~/.bash_profile 를 입력하여 재시작.

✔️확인
$ nvm ls

아래처럼 뜨면 잘 설정된 것.

->       system
node -> stable (-> N/A) (default)
iojs -> N/A (default)

Node.js

위키에서는 Node.js를 다음처럼 정의하고 있다.

Node.js는 확장성 있는 네트워크 애플리케이션(특히 서버 사이드) 개발에 사용되는 소프트웨어 플랫폼이다. 작성 언어로 자바스크립트를 활용하며 Non-blocking I/O와 단일 스레드 이벤트 루프를 통한 높은 처리 성능을 가지고 있다.

내장 HTTP 서버 라이브러리를 포함하고 있어 웹 서버에서 아파치 등의 별도의 소프트웨어 없이 동작하는 것이 가능하며 이를 통해 웹 서버의 동작에 있어 더 많은 통제를 가능케 한다.
...
대부분의 자바스크립트가 웹 브라우저에서 실행되는 것과 달리, 서버 측에서 실행된다.

Node.js에서 JS가 해석되면 libuv를 통해 OS에서 결과가 나타는데, 유저 인터페이스 번들링을 위한 JS 라이브러리라는 React를 Node.js 환경에서 사용하기 위해 webpack과 Babel을 설정하는 것이다. webpack을 이용해 정적 파일(.js, .css, image file 등)을 번들링하고 Babel을 이용해 ES6 지원이 되지않는(IE11 등) 브라우저를 지원할 수 있도록 한다.

즉, Node.js에서 webpack과 Babel을 사용하여 빌드하면 React 코드가 compatible version of JavaScript로 바뀌고 번들링되어 브라우저에 올리기만 하면 되는 결과물이 나타난다.


nvm을 이용하여 nodejs를 설치하도록 하겠다.

✔️설치하기
$ nvm install 12.16.3

https://nodejs.org/ko/ 링크에 들어가면 안정화된 버전과 최신버전을 확인 할 수 있다. 위 커맨드에는 버전만 명시하면 되는데, 나는 현재(2020.05) 안정화된 버전인 12.16.3 버전을 다운 받았다.

✔️설치 확인하기
$ nvm ls
$ node -v

nvm lsnode -v

✔️사용할 버전 명시하기
$ nvm use 12.16.3
위의 커맨드를 사용해, 여러 버전을 설치한 경우에 원하는 버전을 선택해 사용할 수 있다.

yarn 설치

과거엔 npm을 따로 설치해야했으나, 이제 nodejs를 설치하면서 npm은 함께 설치된다. 그리고 이 npm의 업그레이드 버전이라고 할 수 있는 yarn은 npm 명령어를 이용해 설치 할 수 있다.
npm install -g yarn

❗️NOTE
npm이란? Node Package Manager
nodejs에서 사용하는 모듈들을 패키지로 만들어 관리하고 배포하는데 사용된다.
Java의 jpm과 유사하다.

package manager인 npm과 yarm의 비교가 궁금하다면 Npm vs Yarn을 읽어보자.

CLI 설치

ref. https://velopert.com/2037

✔️설치하기
npm install -g create-react-app

✔️프로젝트 생성
npx create-react-app 프로젝트 이름

intellij에서 프로젝트 실행하기

인텔리제이 터미널에서 yarn build - yarn start
localhost:3000으로 접속했을 때 아래와 같은 페이지가 뜬다면 성공

css, js 코드 추가하기

지금부터는 리액트 공식 자습서를 따른다.

우선, 생성한 프로젝트의 src 내의 코드를 모두 지워준 뒤 index.css 파일과 index.js 파일을 추가하여 다음과 같은 코드를 넣어준다.

index.css

body {
    font: 14px "Century Gothic", Futura, sans-serif;
    margin: 20px;
}

ol, ul {
    padding-left: 30px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.square:focus {
    outline: none;
}

.kbd-navigation .square:focus {
    background: #ddd;
}

.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}

index.js

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

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

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

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

class Game extends React.Component {
    render() {
        return (
            <div className="game">
                <div className="game-board">
                    <Board />
                </div>
                <div className="game-info">
                    <div>{/* status */}</div>
                    <ol>{/* TODO */}</ol>
                </div>
            </div>
        );
    }
}

// ========================================

ReactDOM.render(
    <Game />,
    document.getElementById('root')
);

위 코드를 보면 처음으로 실행되는 index.js에 여러 컴포넌트를 담고 있기 때문에, ReactDOM.render()을 제외하고는 전부 다른 컴포넌트로 분리하도록 하겠다.

Component 분리

src 폴더 하위에 views/components 폴더를 만들고 Board.js, Game.js, Square.js 파일을 생성해 준다.

index. js

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

ReactDOM.render(
    <Game />,
    document.getElementById('root')
);

최상위 컴포넌트인 Game을 렌더링하는 구문을 제외하고 모두 삭제해주었다. import 구문을 이용해 Game을 가져온다.

Square.js

import React from "react";

const Square = ({index}) => {
    console.log(index);
    return (
        <button className="square">
            {/* TODO */}
        </button>
    );
}

export default Square;

Square.js를 import받을 때 기본적으로 가져올 컴포넌트를 설정하기 위해 export default Square;를 추가하였다.

Board.js

import React from "react";
import Square from "./Square.js";

const Board = ({squares}) => {
    const status = 'Next player: X';

    return (
        <div>
            <div className="status">{status}</div>
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square index={column}/>
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

square는 각 네모칸을 뜻하고, argument로 들어온 squares는 2차원 배열이다. 따라서 이중 map을 이용하여 행, 열에 맞춰 Square 컴포넌트를 생성한다.

Game.js

import React from "react";
import Board from './Board.js';

const Game = () => {
    const squares = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8]
        ];

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

export default Game;

컴포넌트 수정하기

Square의 각 칸에 값 표시하기

우선, Square.js의 버튼 사이에 {index}를 추가해보자

Square.js변경된 화면

이전엔 비어있던 각 칸에 index 값이 들어간 것을 확인 할 수 있다.
이는 상위 컴포넌트인 Board에서 전달한 값이다. 이제 위의 콘솔 로그 메소드는 지워주자.

사용자와 상호작용하도록 만들기

✔️스퀘어 컴포넌트를 클릭하면 숫자값 → X로 바뀌도록 만들기
공식 자습서에서는 클래스 컴포넌트를 사용하기 때문에 다음처럼 this.setState를 사용하고 있다.
하지만 필자는 함수형 컴포넌트를 사용하고 있으므로 useState Hook을 이용하는 코드로 변경하도록 하겠다.

import React, {useState} from "react";

const Square = ({index}) => {
    const [value, setValue] = useState(index);

    return (
        <button className="square" onClick={() => setValue('X')}>
            {value}
        </button>
    );
}

export default Square;

Board로부터 받아온 "값"인 index는 상태 변수인 value초기값 으로만 사용된다. return의 html 코드 내에도 statevalue를 사용하고 있음을 볼 수 있다. onClick 이벤트로 setValue('X')를 추가해 주었다. 이제 스퀘어를 클릭해보자.

State를 Store에서 관리하도록 만들기

현재 value state는 각 Square 컴포넌트에서 관리되고 있고, 승자를 판단하기 위해선 이를 한꺼번에 관리할 주체가 필요하다.
원래 예제에서는 Square의 상위 컴포넌트인 Board에서 이를 관리하도록 만들고 있지만, 우리는 Redux라는 좋은 라이브러리를 사용할 것이다.

✔️Board가 상태를 관리하도록 만들기
리덕스와 스토어를 사용하기 이전에, 일단 예제처럼 Board에서 상태를 관리하게 만들어 볼 것이다.
Game.js에 하드코딩되어 있던 Squares를 다음처럼 변경한다.

const squares =  Array(3).fill(null).map(() => Array(3).fill(null));

전부 null로 채워진 3*3 배열이 생성된다.

Board.js는 다음처럼 변경하여 상태로써 squares를 가질 수 있도록한다.

const Board = ({init}) => {
    const status = 'Next player: X';
    const [squares, setSquares] = useState(init);
  	
  	// .. 후략
})

그럼 Square가 클릭 될 때 마다 Board가 가진 상태가 변경되야하는데, 이를 함수를 전달하는 방식으로 해결하도록 하겠다.

Board가 가지고 있는 상태인 squares가 변경되는 것은 확인했으나 재렌더링되지 않는 문제가 있어, View에 나타내기위해 Square가 가지고 있는 상태 check도 함께 변경하도록 작성하였다.
Square 컴포넌트는 상태 변경에 따라 재렌더링되는 것까지 확인했다.

위 이미지에서 확인 할 수 있듯이 Board 컴포넌트의 상태인 squares는 변경되었으나, 재랜더링되지 않아 Square 컴포넌트가 받은 argument value는 여전히 null값이다.
하지만 동시에 Square 컴포넌트의 상태인 check를 변경하였으므로 Square는 재랜더링되고, check 값은 X로 변경된 모습이다.

원인을 파악하여 추후에 고치도록 하겠다.

❗️해결했음 → 아래 삽질 코드를 보기 싫다면 다음 섹션인 State를 Store에서 관리하도록 만들기 (수정)으로 바로 넘어가면된다.

✔️Game.js

import React from "react";
import Board from './Board.js';

const Game = () => {

    const initializeSquares = () => {
        const ROW = 3, COLUMN = 3;
        const init = Array(ROW).fill(null);
        for(var i = 0; i<ROW; ++i){
            var columns = Array(COLUMN).fill(null);
            for(var j = 0; j<COLUMN; ++j){
                // eslint-disable-next-line no-unused-expressions
                columns[j] = {index: i*COLUMN+j, value: null};
            }
            init[i] = columns;
        }
        return init;
    }

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

export default Game;

initializeSquares 함수는 squares의 초기값, 즉 N*N 크기의 {index, value=null}로 이뤄진 2차원 배열을 만든다. 이를 Board 컴포넌트의 init argument로 전달한다.

✔️Board.js

import React, {useEffect, useState} from "react";
import Square from "./Square.js";

const Board = ({init}) => {
    const status = 'Next player: X';
    const [squares, setSquares] = useState(init);

    const handleClick = (event) => {
        var index = parseInt(event.target.dataset.index);
        var row = parseInt(index / squares[0].length);
        var column = index - squares[0].length * row;
        var newSquares = squares;
        // eslint-disable-next-line no-unused-expressions
        newSquares[row][column].value = 'X';
        setSquares(newSquares);
        console.log(squares);
    }

    return (
        <div>
            <div className="status">{status}</div>
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square
                            value={column.value}
                            index={column.index}
                            handleClick={handleClick}
                        />
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

Square 컴포넌트에 value, index, handleClick을 Argument로 넘겨주고 있다. Square 컴포넌트는 버튼을 클릭했을 때 handleClick 메소드를 실행시킨다.

✔️Square.js

import React, {useEffect, useState} from "react";

const Square = ({value, index, handleClick}) => {
    const [check, setCheck] = useState(value);

    useEffect(()=>{
        console.log(index+" : "+value);
        console.log(index+" : "+check);
    })

    const setStates = (event) => {
        handleClick(event);
        setCheck('X');
    }

    return (
        <button className="square" data-index={index} onClick={setStates}>
            {check}
        </button>
    );
}

export default Square;

useEffect를 이용해서 Boardsquares상태가 변경됨에 따라 value 아규먼트가 변경되어서 들어오는지 확인하기 위해 작성하였다. 🤔 위에서 언급했듯이, value 아규먼트는 변경되지 않고(여전히 null) Square의 상태인 check가 변경된 것은 확인 할 수 있었다.

Square 컴포넌트의 재렌더링으로 변경된 화면콘솔

State를 Store에서 관리하도록 만들기 (수정)

위에서 상태 변경을 감지하지 못해 재렌더링이 안됐다고 썼는데, 복사가 잘못되고 있음을 깨달았다.
리액트에서 저장된 상태를 직접 변경하는 것은 감지가 안된다.
따라서 위에서 var newSquares = squares;로 새로운 객체를 생성해 주었는데, 사실 이는 복사가 아니라 참조(reference)였다. (즉, javascript의 = 이꼴은 복사가 아니라 참조이다.)

이 글에서 참고하고 있는 리액트 자습서에는 사본을 만들고, 불변성을 유지하는 이유에 대해서 이러한 내용이 있다.

변화를 감지함
객체가 직접적으로 수정되기 때문에 복제가 가능한 객체에서 변화를 감지하는 것은 어렵다. 감지는 복제가 가능한 객체를 이전 사본과 비교하고 전체 객체 트리를 돌아야한다.

불변 객체에서 변화를 감지하는 것은 상당히 쉽습니다. 참조하고 있는 불변 객체가 이전 객체와 다르다면 객체는 변한 것입니다.

var newSquares = squares;문을 var newSquares = squares.slice();로 변경 함으로써 깔끔하게 해결되었다. 코드는 아래와 같다.

✔️Game.js

import React from "react";
import Board from './Board.js';

const Game = () => {

    const initializeSquares = () => {
        const ROW = 3, COLUMN = 3;
        const init = Array(ROW).fill(null);
        for(var i = 0; i<ROW; ++i){
            var columns = Array(COLUMN).fill(null);
            for(var j = 0; j<COLUMN; ++j){
                // eslint-disable-next-line no-unused-expressions
                columns[j] = {index: i*COLUMN+j, value: null};
            }
            init[i] = columns;
        }
        return init;
    }

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

export default Game;

✔️Board.js

import React, {useEffect, useState} from "react";
import Square from "./Square.js";

const Board = ({init}) => {
    const status = 'Next player: X';
    const [squares, setSquares] = useState(init);

    const handleClick = (event) => {
        var index = parseInt(event.target.dataset.index);
        var row = parseInt(index / squares[0].length);
        var column = index - squares[0].length * row;
        var newSquares = squares.slice();
        // eslint-disable-next-line no-unused-expressions
        newSquares[row][column].value = 'X';
        setSquares(newSquares);
    }

    return (
        <div>
            <div className="status">{status}</div>
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square
                            value={column.value}
                            index={column.index}
                            handleClick={handleClick}
                        />
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

✔️Square.js

import React, {useEffect, useState} from "react";

const Square = ({value, index, handleClick}) => {
    return (
        <button className="square" data-index={index} onClick={handleClick}>
            {value}
        </button>
    );
}

export default Square;

Square 컴포넌트는 상위 컴포넌트인 Board의 상태를 그대로 사용하므로 고유한 상태를 가지지 않는다.

각 칸을 클릭했을 때 아래처럼 "X" 표시가 나타나는지 확인하자.

기능 추가하기

지금의 틱택토 게임에서는 "X" 밖에 표시가 되지 않는다. "O"와 "X"가 번갈아 나타날 수 있도록 수정할 것이다. 다음 플레이어가 누군지 Board 컴포넌트에서 알려주고 있었기 때문에 플레이어 관리 또한 Board에서 수행하도록 하겠다.

위 이미지를 따라 차분히 따라가 보자

  1. status에서 Next player 하드 코딩한 부분을 지운다.
  2. xIsNext라는 상태를 추가하고, 초기값은 true로 설정한다.
    이 상태가 ture이면 "X"가 찍히도록 하고, false이면 "O"가 찍히도록 할 것이다.
  3. handleClick에서 선택된 Square의 값을 변경할 때 xIsNext? 'X':'O' 삼항연산자를 이용하여 적절한 값으로 변경하도록 한다.
  4. 클릭이 될 때 마다 토글이 되어야 하므로 setXIsNext로 상태를 변경해준다.
  5. 출력문(위 이미지에서 마지막 줄)에서도 삼항연산자를 이용해 적절한 값이 노출되도록한다.

🤔 이렇게 두어도 당연히 코드는 잘 작동한다. 하지만 너무 많은 비즈니스 로직이 한 함수안에 들어가 있는것은 좋지 않다. 리팩토링을 통해 함수를 적절히 분해할 것이다.

리팩토링 전리팩토링 후

변경할 좌표를 가져오는 함수 getLocation, 새로운 값으로 대체하는 함수 getNewSquares, 실제로 상태값을 변경하는 함수 handleClick으로 분해하였다.
전체 코드는 아래와 같다.

import React, {useEffect, useState} from "react";
import Square from "./Square.js";

const Board = ({init}) => {
    const status = 'Next player: ';
    const [squares, setSquares] = useState(init);
    const [xIsNext, setXIsNext] = useState(true);

    const handleClick = (event) => {
        setSquares(getNewSquares(event));
        setXIsNext(!xIsNext);
    }

    const getNewSquares = (event) => {
        const [row, column] = getLocation(event);
        var newSquares = squares.slice();
        // eslint-disable-next-line no-unused-expressions
        newSquares[row][column].value = (xIsNext ? 'X' : 'O')
        return newSquares;
    }

    const getLocation = (event) => {
        var index = parseInt(event.target.dataset.index);
        var row = parseInt(index / squares[0].length);
        var column = index - squares[0].length * row;
        return [row, column];
    }

    return (
        <div>
            <div className="status">{status + (xIsNext ? 'X' : 'O')}</div>
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square
                            value={column.value}
                            index={column.index}
                            handleClick={handleClick}
                        />
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

게임을 다시 한 번 실행시켜 보자.
'X'로 시작하여 'O' → 'X' → 'O' → ... 번갈아서 값이 잘 출력된다면 성공이다.
지금도 이 게임에는 문제가 있다. 이미 값이 들어간 곳을 클릭하면 새로운 값으로 덮어씌어 진다는 것이다. 다음 포스트에 이어서 개발하도록 하겠다.

모든 코드는 github에서 확인 할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글