본문 바로가기

4. Node.js | React.js

4/23(일) IT K-DT(-) / react.js를 이용한 '틱택토 게임' 예제

 

 '틱택토 게임' 만들기

1-1. 초기 코드 살펴보기

초기코드는 제작할 틱택토의 '기본 틀'을 의미. (CSS 스타일은 기본적으로 제공됨.)
코드를 살펴보면, 세 가지 React 컴포넌트를 확인할 수 있음.
   * Square 
   * Board 
   * Game 


Square 컴포넌트는 <button>을 렌더링하고
Board 는 사각형 9개를 렌더링함.
Game 은 게임판을 렌더링하며 나중에 수정할 자리 표시자 값을 가짐.


지금 현재는 사용자와 상호작용하는 컴포넌트가 없는 상태.

 

<초기 코드>


    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>
        );
        }
    }
    
    // ========================================
    
    const root = ReactDOM.createRoot(document.getElementById("root"));
    root.render(<Game />);
  

 

1-2. Props를 통해 데이터 전달하기

본격적으로 시작하기 위해 Board 컴포넌트에서 Square 컴포넌트로 데이터를 전달해보자.

Square에 value prop을 전달하기 위해 Board의 renderSquare 함수 코드 수정이 필요.

 


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


값을 표시하기 위해 Square의 render 함수에서 { /* TODO */ } 를 {this.props.value}로 수정이 필요.


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


해당 코드들의 변경 후에는 렌더링된 결과에서 '각 사각형에 숫자가 표시'됨.
부모 Board 컴포넌트에서 자식 Square 컴포넌트로 'prop을 전달'함.
props 전달하기는 React 앱에서 부모에서 자식으로 정보가 어떻게 흘러가는지를 알려줌.

 

1-3. 사용자와 상호작용하는 컴포넌트 만들기

Square 컴포넌트를 클릭하면 'X'가 체크되도록 만들어보자.
먼저 Square 컴포넌트의 render() 함수에서 반환하는 버튼 태그를 아래와 같이 변경.


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


Square를 클릭하면 'click'이 브라우저 개발자 도구의 console에 출력됨.
funciton의 내용을 화살표 함수를 이용하여 수정할 수도 있음.


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


onClick={()=>console.log('click')}이 어떻게 동작하는지 살펴보면
onClick prop으로 함수를 전달하고 있음. React는 클릭했을 때에만 이 함수를 호출함.
()=>을 잊어버리고 onClick={console.log('click')}이라고 작성하는 것은
자주 발생하는 실수이며, 컴포넌트가 다시 렌더링을 할 때 마다 경고창을 띄울 것임.

다음 단계로 Square 컴포넌트를 클릭한 것을 '기억'하게 만들어 'X'표시를 채워넣으려 함.
무언가를 '기억'하기 위해서 component는 state를 사용.
React 컴포넌트는 생성자에 this.state를 설정하는 것으로 state를 가질 수 있음.
this.state는 정의된 React 컴포넌트에 대해 비공개로 간주해야 함.


이제는 Square의 현재 값을 this.state에 저장하고 Square를 클릭하는 경우 변경할 예정.
우선 class에 생성자를 추가하여 state를 초기화함.


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

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


주의: JavaScript 클래스에서 하위 클래스의 생성자를 정의할 때 항상 super를 호출해야 함. 

모든 React 컴포넌트 클래스는 생성자를 가질 때 super(props) 호출 구문부터 작성해야 함.
이제 Square를 클릭할 때 현재 state 값을 표시하기 위해 render 함수를 변경할 예정.

* <button> 태그 안의 this.props.value를 this.state.vlue로 변경
* onClick={...} 이벤트 핸들러를 onClick={()=>this.setState({value:'X'})}로 변경
* 가독성을 높이기 위해 className과 onClick props를 별도의 줄에 넣음
이와 같은 변경 후 Square의 render함수에서 반환하는 <button>태그는 아래와 같음.

 


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

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


Square의 render함수 내부에서 onClick 핸들러를 통해 this.setState를 호출하는 것으로
React에게 <button>을 클릭할 때 Square가 다시 렌더링해야 한다고 알릴 수 있음.
업데이트 이후 Square의 this.state.value는 'X'가 되어 게임 판에서 X가 나타나는 것을 확인할 수 있음. 

'어떤 Square를 클릭하건 X가 나타날 것'임.
컴포넌트에서 setState를 호출하면 React는 자동으로 컴포넌트 내부의 자식 컴포넌트까지 업데이트 함.

 

 

개발자 도구:
Chrome의 React Devtools 확장 프로그램을 사용하면 

브라우저 개발자 도구에서 React 컴포넌트 트리를 검사할 수 있음.

React Devtools를 통해 React 컴포넌트의 props와 state도 확인 가능.
React Devtools를 설치한 후 페이지의 모든 Element에 우클릭 후 '요소 검사'를 클릭하여 개발자 도구를 열면 탭의 오른쪽 맨 끝에 React 탭을 확인할 수 있음.

 

1-4. State 끌어올리기

이제 틱택토 게임을 위한 기본 구성요소를 가지고 있음.
완전한 게임을 위해 게임판의 'X'와 'O'를 번갈아 표시할 필요가 있으며 승자를 결정하는 방법이 필요함.


현재 게임의 State를 각각의 Square 컴포넌트에서 유지하고 있음.
승자를 확인하기 위해 9개 사각형의 값을 한 곳에 유지할 예정.
Board가 각 Square에 state를 요청할 수도 있으나, 

코드를 이해하기 어렵게 하고 리팩토링이 어려우므로 비추천하는 방법.
각 Square가 아닌 '부모 Board 컴포넌트에 게임의 상태를 저장하는 것'이 가장 좋은 방법.
각 Square에 숫자를 넘겨주었을 때와 같이 Board 컴포넌트는 각 Square에 prop을 전달하는 것으로 

무엇을 표시할 지를 알려줌.
여러개의 자식으로부터 데이터를 모으거나 두 개의 자식 컴포넌트들이 서로 통신하게 하려면 부모 컴포넌트에 공유 state를 정의해야 함. 

부모 컴포넌트는 props를 사용하여 자식 컴포넌트에 state를 다시 전달할 수 있음.

이것은 자식 컴포넌트들이 서로 또는 부모 컴포넌트와 동기화 하도록 만듦.
state를 부모 컴포넌트로 끌어올리는 것은 React 컴포넌트를 리팩토링할 때 흔히 사용함.
Board에 생성자를 추가하고 9개의 사각형에 해당하는 9개의 null 배열을 초기 state로 설정.

 


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

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


나중에 board를 채우면 this.state.squares 배열은 아래와 같이 보일 것임.
[
    'O', null, 'X',
    'X', 'X', 'O',
    'O', null, null,
]

Board의 renderSquare 함수는 현재 아래와 같은 형태.

    renderSquare(i) {
        return ;
    }

처음에는 모든 Square에서 0부터 8까지 숫자를 보여주기 위해 Board에서 value pop을 자식으로 전달. 또 다른 이전 단계에서는 숫자를 Square의 자체 state에 따라 'X' 표시로 바꿈.
그렇기 때문에 현재 Square는 Board에서 전달한 value prop을 무시하고 있음.
이제 prop을 전달하는 방법을 다시 사용할 예정. 각 Square에게 현재 값('X', 'O', 또는 null)을 표현하도록 Board를 수정할 것임. Board의 생성자에서 squares 배열을 이미 선언했으며 renderSquare 함수를 아래와 같이 수정할 것임.

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

Square는 이제 빈 사각형에 'X', 'O', 또는 null인 value prop을 받음.
다음으로 Square를 클릭할 때 발생하는 변화가 필요함.
Board 컴포넌트는 어떤 사각형이 채워졌는지를 여부를 관리하므로 Square가 Board를 변경할 방법이 필요함.
컴포넌트는 자신이 정의한 state에만 접근이 가능하므로 Square에서 Board의 state를 직접 변경할 수는 없음.
대신, Board에서 Square로 함수를 전달하고 Square는 사각형을 클릭할 때 함수를 호출할 예정.
Board의 renderSquare 함수를 아래와 같이 변경할 것임.

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

주의: 반환되는 element를 여러 줄로 나누어 가독성을 확보하고 괄호를 추가하여 JavaScript가 return 뒤에 세미콜론을 삽입하지 않아도 코드가 깨지지 않게 함.

이제 Board에서 Square로 value와 onClick 두 개의 props를 전달하였음. 

onClick prop은 Square를 클릭하면 호출되는 함수. 

Square를 아래와 같이 변경하는 것이 필요.
   * Square의 render 함수 내부의 this.state.value를 this.props.value로 변경
   * Square의 render 함수 내부의 this.setState()를 this.props.onClick()으로 변경
   * Square는 게임의 상태를 유지할 필요가 없으므로 constructor의 삭제가 필요

이렇게 바꾼 후 Square는 아래와 같은 모습이 됨.

 


    class Square  extends React.Component {

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

    }



Square를 클릭하면 Board에서 넘겨받은 onClick 함수가 호출됨. 이때 발생하는 일을 정리해봄.


  * 내장된 DOM <button> 컴포넌트에 있는 onClick prop은 React에게 클릭 이벤트 리스너의 설정을 알림.
  * 버튼을 클릭하면 React는 Square의 render() 함수에 정의된 onClick 이벤트 핸들러를 호출함.
  * 이벤트 핸들러는 this.props.onClick()을 호출함. Square의 onClick prop은 Board에서 정의됨.
  * Board에서 Square로 onClick={()=> this.handleClick(i)}를 전달했기 때문에

     Square를 클릭하면 Board의 handleClick(i)를 호출함.
  * 아직 handleClick()을 정의하지 않았으므로 코드가 깨질 것임. 

     지금은 사각형을 클릭하면 'this.handleClick is not a function'이라는 붉은 error화면만 보일 것임.


주의: DOM <button> element의 onClick attribute는 내장된 컴포넌트라는 점에서 중요.

Square같은 사용자 정의 컴포넌트의 경우 이름 지정은 자유로움.

Square의 onClick prop이나 Board의 handleClick 함수에는 아무 이름이나 가능하며 코드는 동일하게 작동. React에서 이벤트를 나타내는 prop에는 on[Event], 이벤트를 처리하는 함수에는 handle[Event]를 사용.
handleClick을 아직 정의하지 않았으므로 Square를 클릭하려 할 때 error가 발생.

이제 Board 클래스에 handleClick을 추가해 볼 예정.

 


    class Board extends React.Component {
        constructor(props) {
            this.state = {
                squares[i] = 'X';
                this.setState({squares:squares});
            }

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

            render() {
                const status = 'Next player: X';

                return (
                    
 

                        
{status}</div classname='status'>
                        
</div classname = 'board-nrw'>
                        {this.renderSquare(0)}
                        {this.renderSquare(1)}
                        {this.renderSquare(2)}
                        
                        
</div classname='board-row>
                        {this.renderSquare(3)}
                        {this.renderSquare(4)}
                        {this.renderSquare(5)}
                        
                        
</div classname='board-row>
                        {this.renderSquare(6)}
                        {this.renderSquare(7)}
                        {this.renderSquare(8)}
                        
                    
                );
            }
        }
    }


이제, 이전과 마찬가지로 'Square를 클릭하여 사각형을 채울 수 있음'. 그러나 이제는 state가 각 Square 컴포넌트 대신에 Board 컴포넌트에 저장됨. Board의 상태가 변화할 때 Square 컴포넌트는 자동으로 다시 렌더링함. Board 컴포넌트의 모든 사각형의 상태를 유지하는 것으로 이후에 '승자를 결정하는 것이 가능'함.
Square 컴포넌트가 더 이상 state를 유지하지 않기 때문에 Square 컴포넌트는 Board 컴포넌트에서 값을 받아 클릭될 때 Board 컴포넌트로 정보를 전달함. React 용어로 Square 컴포넌트는 이제 '제어되는 컴포넌트'임. Board는 이들을 완전히 제어함.
handleClick에서는 .slice()를 호출하는 것으로 기존 배열을 수정하지 않고 square 배열의 복사본을 생성하여 수정하는 것에 주의. 


불변성이 중요한 이유:

이전 코드 예시에서 기존 배열을 수정하는 것이 아니라 .slice() 연산자를 이용하여 squares 배열의 사본 만들기를 추천함. 왜 불변성이 중요한지를 알아보겠음.
일반적으로 데이터의 변경에는 두 가지 방법이 있음. 첫 번째는 데이터의 값을 '직접' 변경하는 것, 두 번째는 원하는 변경 값을 가진 새로운 사본으로 데이터를 교체하는 것.

객체 변경을 통해 데이터 수정하기
    int player = {score:1, name:'Jeff'};
    player.score = 2;
    // 이제 player는 {score:2, name:'Jeff'}임.

객체 변경 없이 데이터 수정하기
    int player = {score:1, name:'Jeff'};
    int newPlayer = Object.assign({}, player, {score:2});
    // 이제 player는 변하지 않았지만, newPlayer는 {score:2, name:'Jeff'}임.

최종 결과는 동일하나 직접적인 객체 변경이나 기본 데이터의 변경을 하지 않으면 아래의 몇 가지 이점을 얻을 수 있음.

  1) 복잡한 특징들을 단순하게 만듦.
불변성은 복잡한 특징들을 구현하기 쉽게 만듦. 자습서에서는 '시간여행'기능을 구현하여 틱택토 게임의 이력을 확인하고 이전 동작으로 '되돌아갈 수 있음'. 이 기능은 게임에만 국한되지 않음. 특정 행동을 취소하고 다시 실행하는 기능은 애플리케이션에서 요구하는 일반적인 요구사항임. 직접적인 데이터 변이를 피하는 것은 이전 버전의 게임 이력을 유지하고 나중에 재사용이 가능하게 함.

  2) 변화를 감지함
객체가 직접적으로 수정되기 때문에 복제가 가능한 객체에서 변화를 감지하는 것은 어려움. 감지는 복제가 가능한 객체를 이전 사본과 비교하고 전체 객체 트리를 돌아야 함.
불변 객체에서 변화를 감지하는 것은 상당히 쉬움. 참조하고 있는 불변객체가 이전객체와 다르다면 객체는 변한것임.

  3) React에서 다시 렌더링하는 시기를 결정함.
불변성의 가장 큰 장점은 React에서 순수 컴포넌트를 만드는 데 도움을 준다는 것임. 변하지 않는 데이터는 변경이 이루어졌는지 쉽게 판단할 수 있으며 이를 바탕으로 컴포넌트가 다시 렌더링할지를 결정할 수 있음.

 

1-5. 함수 컴포넌트

이제 Square를 함수 컴포넌트로 바꾸어 보겠음.
React에서 함수 컴포넌트는 더 간단하게 컴포넌트를 작성하는 방법이며, state없이 render함수만을 가짐.
React.Component를 확장하는 클래스를 정의하는 대신 props를 입력받아 렌더링할 대상을 반환하는 

함수를 작성할 수 있음.
함수 컴포넌트는 클래스로 작성하는 것 보다 빠르게 작성할 수 있으며 많은 컴포넌트를 함수 컴포넌트로 

표현할 수 있음.
Square 클래스를 아래와 같이 변경해보겠음.


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

모든 this.props를 props로 변경함.
주의: Square를 함수 컴포넌트로 수정했을 때 onClick={()=>this.props.onClick()}을 onClick={props.onClick}로 간결하게 작성함. 양쪽 모두 괄호가 사라짐.

 

1-6. 순서 만들기

게임판에서 'O'가 표시되도록 만들어 보기.
첫번째 차례를 'X'로 시작해보겠음.
Board 생성자의 초기 state를 수정하는 것으로 기본 값을 설정할 수 있음.

 


    class Board extends React.Component

        constructor(props) {
            super(props);
            this.state = {
                square: Array(9).fill(null),
                xIsNext: true,
            };
        }


플레이어가 수를 둘 때마다 xIsNExt (boolean 값)이 뒤집혀 다음 플레이어가 누군지 결정하고 게임의 state가 저장될 예정. Board의 handleClick 함수를 수정하여 xIsNext값을 뒤집을 예정.


    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'가 번갈아가면서 나타남.
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();
        square[i] = this.state.xIsNext ? 'X' : 'O';
        this.setState({
            squares: squares,
            xIsNext: !this.state.xIsNext,
        });
    }

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

            
{status}</div classname='status'>
            
</div classname='board-row'>
                {this.renderSquare(0)}
                {this.renderSquare(1)}
                {this.renderSquare(2)}
            
            
</div classname='board-row'>
                {this.renderSquare(3)}
                {this.renderSquare(4)}
                {this.renderSquare(5)}
            
            
</div classname='board-row'>
                {this.renderSquare(6)}
                {this.renderSquare(7)}
                {this.renderSquare(8)}
            
        
        );
    }
}

 

1-7. 승자 결정하기

이제 어떤 선수가 다음 차례인지를 알려주었으니 

승부가 나는 때와 더이상 둘 곳이 없을 때를 알려주어야 함. 다음의 도우미 함수를 작성.

 


    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
            const [a,b,c]=lines[i];
            if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
                return squares[a];
            }
        }
        return null;
    }


9개의 사각형의 배열을 갖고 함수는 승자를 확인하여 적절한 값으로 'X', 'O', null을 반환.
어떤 플레이어가 우승했는지 확인하기 위해 Board의 render함수에서 calculateWinner(squares)를 호출할 것. 
한 플레이어가 이긴다면 'Winner:X' 또는 'Winner:O'와 같은 문구를 표시할 수 있음.
Board의 render 함수에서 선언한 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(
            // 나머지는 그대로


누군가가 승리하거나 square가 이미 채워졌다면 Board의 handleClick 함수가 클릭을 무시하도록 변경 예정.

 


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


제대로 동작하는 틱택토 게임을 만듦.