[React] 리액트 따라하기: 틱택토

donguraemi·2023년 8월 20일
0

리액트

목록 보기
3/3
post-thumbnail

해당 프로젝트는 틱택토 튜토리얼를 따라만든 것입니다.

Overview

로컬 환경에서 리액트 앱을 생성하든 온라인 편집기를 이용하든 초기 프로젝트는 다음과 같은 구조를 갖는다.

├── public
│   └── index.html
├── src
│   ├── App.js
│   ├── index.js
│   └── styles.css
└── package.json
  • App.js

App.js는 컴포넌트를 생성하는 코드를 포함한다. 리액트에서 컴포넌트는 UI의 일부를 나타내고 재사용이 가능한 코드다. 컴포넌트는 어플리케이션에 UI 요소를 렌더링하고, 관리하며 업데이트 하는 데 사용된다.

export default function Square() {
	return <button className='square'>X</button>
}
  • export : 자바스크립트의 키워드로 파일 외부에서 해당 함수에 접근하는 것을 허용한다.
  • default : App.js의 파일의 메인 함수가 Square()임을 알린다.
  • styles.css
    styles.css는 어플리케이션의 스타일을 정의한 파일이다.

  • index.js
    index.jsApp.js에서 만든 컴포넌트와 웹 브라우저를 잇는 다리 역할을 한다. 모든 요소들을 모아 최종 산출물을 만들고 public/index.html에 최종 산출물을 반영한다.

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import App from './App';

const rootElement = document.getElementById('root');
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

틱택토 보드판 만들기

틱택토 보드판을 만들기 전에 먼저 예시코드의 styles.css 내용을 복붙하여 사용하는 것을 추천한다.

result
위의 사진은 최종 산출물을 캡처한 것이다. 보드판은 총 9개의 정사각형 버튼이 필요하다.

  1. 보드판 틀 만들기
import React from 'react';

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

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square value="1" />
        <Square value="2" />
        <Square value="3" />
      </div>
      <div className="board-row">
        <Square value="4" />
        <Square value="5" />
        <Square value="6" />
      </div>
      <div className="board-row">
        <Square value="7" />
        <Square value="8" />
        <Square value="9" />
      </div>
    </>
  );
}

하위 컴포넌트인 Square는 부모 컴포넌트로부터 value를 전달받아 해당 값을 버튼에 표기한다. 부모 컴포넌트인 Board는 9개의 버튼을 포함한다.

  1. 상호작용하는 버튼 만들기

첫번째 산출물은 보드판의 칸에 고유 번호를 붙인 것이다. 하지만 실제 틱택토 게임에서는 다른 로직을 사용해야 한다. 초기 상태의 버튼은 아무 내용을 가지고 있지 않다가 사용자가 클릭하면 X 상태로 표기한다. 이벤트와 useState를 사용하여 컴포넌트의 상태를 변경해보자.

  • 버튼에 onClick 이벤트 추가하기
// (사용자가 버튼을 클릭하면 handleClick 함수가 실행된다)
<button className="square" onClick={handleClick}>
  {value}
</button>
  • 이벤트가 발생하면 컴포넌트의 상태 변경하기
const [value, setValue] = useState(null);
function handleClick() {
  setValue('X');
}

useState는 컴포넌트가 정보를 기억하도록 만든다. value는 값을 저장하는 변수고 setValuevalue를 변경하는 함수다.

import React, { useState } from 'react';

function Square() {
  const [value, setValue] = useState(null);
  function handleClick() {
    setValue('X');
  }
  return (
    <button className="square" onClick={handleClick}>
      {value}
    </button>
  );
}

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

board


틱택토 완성하기

이전 단계에서 틱택토 보드판을 완성했다. 게임을 완성하기 위해서 O/X를 번갈아서 배치하고 승자를 결정하는 방법이 필요하다.

  1. 보드판에서 상태 관리하기

Square 컴포넌트는 상태를 가지고 있다. 틱택토 게임의 승자를 확인하기 위해서는 Board가 9개의 자식 컴포넌트의 상태를 알아야 한다.

BoardSquare의 정보를 조회하는 방법에 대해 고민해보자. 첫번째 방법은 BoardSquare의 상태를 물어보는 것이다. 하지만 해당 방법은 코드가 복잡해지는 단점이 있다. 두번째 방법은 Square 대신 Board에 상태 정보를 저장하는 것이다. BoardSquare의 상태 정보를 저장하고 Square에 prop으로 화면에 표시할 내용을 전달한다.

  • Board에서 Square 상태 물어보기
  • BoardSquare의 상태 정보를 저장하고 Square에 props 전달하기

해당 프로젝트에서 선택한 방식은 후자다. Square에 있는 useState를 삭제하고 props로 정보를 받아오도록 수정해보자.

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

value는 화면에 표시하는 값, onSquareClick은 해당 버튼을 클릭하면 발생하는 이벤트다.

이제 부모 컨포넌트인 Board를 수정해보자. 우선 자식 컨포넌트인 Square의 정보를 저장해야 한다.

const [squares, setSquares] = useState(Array(9).fill(null));

다음으로 Square가 클릭되었을 때 발생해야 하는 이벤트를 작성한다. idx번째 칸을 클릭하면 상태를 변경한다. setSquares 함수는 리엑트에게 컴포넌트 상태가 변경되었다는 것을 알려주고, 리렌더링을 실행한다.

function handleClick(idx) {
  const nextSquares = squares.slice();
  nextSquares[idx] = 'X';
  setSquares(nextSquares);
}

불변성이 중요한 이유

위의 코드에서 .slice() 메서드로 squares의 사본을 만들어 사용하고 있다. squares[idx] = 'X'처럼 원본을 바로 변경하지 않고 사본을 만드는 이유는 무엇일까?

첫째, 불변성은 복잡한 기능을 보다 쉽게 구현할 수 있도록 한다. 나중에 게임 히스토리를 구현하여 과거 동작으로 되돌리는 기증을 만들 것이다. 원본을 직접 수정하지 않으면 이전 데이터 버전을 저장할 수 있기 때문에 나중에 재사용하기 편리하다.
둘째, 불변성은 렌더링 비용을 줄일 수 있다. 기본적으로 모든 하위 컴포넌트는 상위 컴포넌트가 렌더링 되면 자동으로 렌더링 된다. 하지만 자식 컴포넌트 중에 변화되지 않은 컴포넌트들도 존재한다. 불변성은 컴포넌트의 변경 여부를 검사하여 렌더링 비용을 줄인다.

아래 코드는 지금까지 작성한 Board의 전문이다.

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(idx) {
    const nextSquares = squares.slice();
    nextSquares[idx] = 'X';
    setSquares(nextSquares);
  }
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

<Square value={squares[0]} onSquareClick={handleClick(0)} />가 동작하지 않는 이유

Too many re-renders. React limits the number of renders to prevent an infinite loop.

onSquareClick={handleClick}을 전달하면 되면 사용자가 클릭하기 전에 handleClick이 실행되기 때문이다.

handleClick(idx)setSquares를 호출하여 컴포넌트의 상태를 변경하므로 컴포넌트를 리렌더링하는 호출이다.

  1. 차례 정하기

이제 O/X를 번갈아 실행할 차례다. 현재 차례가 x 차례인지 확인하는 xIsNext가 필요하다.

const [xIsNext, setXIsNext] = useState(true);

클릭 이벤트가 발생했을 때 x의 차례인지 확인하고 칸을 차례에 맞게 갱신한다. 업데이트 후에 차례를 전환한다.

function handleClick(idx) {
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[idx] = 'X';
    } else {
      nextSquares[idx] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

하지만 이미 클릭한 칸을 여러 번 클릭할 수 있다. 따라서 이미 클릭한 칸을 다시 클릭할 수 없도록 처리해야 한다.

function handleClick(idx) {
    if(squares[idx]) {
      return;
    }
    ...
  }
  1. 승자 결정하기

이제 게임의 승자를 가려보자.

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는 보드판에서 만들 수 있는 줄 정보를 담고 있다. for문을 돌며 한 줄을 완성했는지 검사한다. 아래는 Board의 전문이다.

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(idx) {
    if (calculateWinner(squares) || squares[idx]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[idx] = 'X';
    } else {
      nextSquares[idx] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }
  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

클릭 이벤트가 발생할 때마다 승자가 있는지 검사하고, 승자가 나와서 이미 게임이 끝난 경우에는 이벤트가 발생하지 않도록 한다.
result01

  1. 과거 상태로 되돌리기

앞에서 언급한 불변성을 이용하여 과거 상태로 되돌려보자. Board의 길이가 길기 때문에 별도의 Game 컴포넌트를 생성하고 Board를 호출하겠다.

export default function Game() {
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
      <div className="game-info">
        <ol>{/* TODO */}</ol>
      </div>
    </div>
  );
}

Gamegame-info에 히스토리를 기록하고 Board에 히스토리 사항을 반영해야 한다. 따라서 Board의 상태 정보를 Game 컴포넌트로 끌어올려야 한다. history는 보드판의 변경 정보를 기록하고 있으며 현재 보드판의 상태인 currentSquares는 보드판의 마지막 요소다.

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]); // 2차원 배열로
  const currentSquares = history[history.length - 1];
  ...
}

상태 정보를 부모 컴포넌트로 끌어올렸기 때문에 하위 컴포넌트로 props를 전달해야 한다. onPlay는 부모 컴포넌트의 함수로 handleClick 내에서 호출된다. Board에서 보드판을 갱신하는 것은 가능하지만, 플레이어의 순서를 변경하고 히스토리를 쌓는 것은 부모 컴포넌트인 Game에서 할 수 있는 작업이기 때문이다.

function Board({ xIsNext, squares, onPlay }) { 
  ...
  function handleClick(idx) {
    if (calculateWinner(squares) || squares[idx]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[idx] = 'X';
    } else {
      nextSquares[idx] = 'O';
    }
    onPlay(nextSquares);
  }
}

플레이어의 차례를 변경하고 히스토리를 갱신하는 함수가 바로 handlePlay다. handlePlay를 다음과 같이 정의하고, Board에 props를 전달한다.

export default function Game() {
  ...
  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      ...
    </div>
  );
}

[...history, nextSquares]history를 포함하는 새로운 배열을 만들고, 마지막에 nextSquares를 추가한다는 의미이다. ...은 스프레드 구문으로 모든 항목을 순회한다.

이제 모든 상태 정보를 끌어올렸고 props도 올바르게 전달한다. 다음으로 화면에 히스토리 정보를 출력해보자. moves를 만들어 <ol>의 하위 태그로 넣어준다. moves는 버튼을 포함하고 있는데, 해당 버튼을 클릭하면 jumpTo가 호출되고 과거의 상태로 복원된다.

export default function Game() {
  function jumpTo(nextMove) {/* TODO */}

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        ...
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

위의 코드를 작성하고 나면 다음과 같은 경고문이 나온다.

Warning: Each child in a list should have a unique "key" prop.

리스트에 요소를 추가하거나 제거하고, 리스트의 요소를 수정하기 위해서는 리스트의 요소들을 서로 구분할 수 있어야 한다. 즉, 아이템의 고유 key가 필요하다.

리스트가 리렌더링될 때, 리액트는 리스트에 존재하는 모든 키들을 가져가서 이전 리스트의 아이템과 매칭되는 키를 탐색한다.

  • 현재 리스트가 이전 리스트에 존재하지 않는 키를 가지고 있으면 컴포넌트를 생성한다.
  • 현재 리스트가 이전 리스트에는 존재하는 키를 가지고 있지 않다면 컴포넌트를 파괴한다.

key는 전역적으로 고유한 값을 가질 필요는 없다. 그들의 형제 사이에서만 고유한 값을 가지면 된다.

key의 필요성을 알았으니 <li>에 키 속성값을 추가하자. key={move}

이제 과거의 상태로 되돌리는 것만 남았다. jumpTo를 구현하기 전에 Game 컴포넌트는 사용자가 보고 있는 단계 정보 를 추적해야 한다.

const [currentMove, setCurrentMove] = useState(0);

currentMove는 사용자가 보고 있는 단계를 의미한다. 해당 변수를 사용하여 xIsNext를 보다 간단하게 계산하고, 화면 정보를 갱신할 수 있다.

짝수번 차례일 때 X의 차례, 홀수번 차례일 때 O의 차례이므로 const xIsNext = currentMove % 2 === 0;로 누구의 차례인지 알 수 있다. 보드판은 마지막 결과를 렌더링 하던 것을 currentMove로 렌더링 하도록 만들면 된다.


최종 코드

import React, { useState } from 'react';

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

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

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(idx) {
    if (calculateWinner(squares) || squares[idx]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[idx] = 'X';
    } else {
      nextSquares[idx] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }
  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];
  const xIsNext = currentMove % 2 === 0;

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>
          {description}
        </button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

0개의 댓글