React - 복습) 빙고 게임

김명원·2025년 3월 10일
0

learnReact

목록 보기
26/26

React의 내용을 다시 상기시키기 위해서 복습하려고 합니다.

React 공식 홈페이지 (틱택도게임)

먼저 자습서 환경설정이 다운로드가 안되서 직접 만들어 보려고 합니다.

npm create vite@latest

App.jsx

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

export default Square;

styles.css

* {
  box-sizing: border-box;
}

body {
  font-family: sans-serif;
  margin: 20px;
  padding: 0;
}

h1 {
  margin-top: 0;
  font-size: 22px;
}

h2 {
  margin-top: 0;
  font-size: 20px;
}

h3 {
  margin-top: 0;
  font-size: 18px;
}

h4 {
  margin-top: 0;
  font-size: 16px;
}

h5 {
  margin-top: 0;
  font-size: 14px;
}

h6 {
  margin-top: 0;
  font-size: 12px;
}

code {
  font-size: 1.2em;
}

ul {
  padding-inline-start: 20px;
}

* {
  box-sizing: border-box;
}

body {
  font-family: sans-serif;
  margin: 20px;
  padding: 0;
}

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

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

.status {
  margin-bottom: 10px;
}
.game {
  display: flex;
  flex-direction: row;
}

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

세팅 완료!

보드 만들기

현재 보드에는 사각형이 하나뿐이지만 게임을 진행하려면 9개가 필요!
styles.css에 정의된 CSS는 board-row라는 className으로 지정된 div 스타일을 통해 틱택토 보드를 완성시키면 됩니다.
Square로 지어진 이름도 Baord로 컴포넌트 이름을 변경해줍니다.

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

export default Board;

Props를 통해 데이터 전달

다음으로는 사각형을 클릭할 때 사각형의 값을 비어있는 상태에서 "X"로 변경해야합니다.
새 Square 컴포넌트를 만들어서 복잡하고 지저분한 방법을 피해봅니다.
Sqaure.jsx

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

App.jsx

import Square from "./Square";

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

export default Board;

이제 값을 전달하기 위해 props를 사용하여 각 사각형이 가져야 할 값을 부모 컴포넌트(Board)에서 자식 컴포넌트(Square)로 전달합니다.

Square.jsx

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

value 값을 props로 받고 표현해줍니다. 하지만 아직 props를 전다랗지 않았기에 비어있는 값으로 나타납니다.

App.jsx로 가서 임의로 props를 전달해보죠

import Square from "./Square";

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

export default Board;

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

이제 컴포넌트를 클릭하면 X로 채워보도록 만드려고 합니다. 그러면 click 이벤트 함수를 만들면 되겠죠?
Square 컴포넌트로 가서

export default function Square({ value }) {
  function handleClick() {
    console.log("clicked");
  }
  return (
    <button className="square" onClick={handleClick}>
      {value}
    </button>
  );
}

handleClick 함수와 이벤트 핸들러를 button에 붙여줍니다.
그러면 이제 button을 클릭하는 순간 clicked 로그가 뜹니다.

이제 X 표시로 채워보도록 하려고 합니다. 그러면 useState로 상태를 저장 후 보여주면 되겠죠??
초기값은 임시로 null을 설정하도록 합니다.
이때 Square 컴포넌트는 더 이상 props를 허용하지 않으므로 value prop을 제거해줍니다.

import { useState } from "react";

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

이제 클릭을 하면 X가 생깁니다.

게임 완료하기

이제 기본적인 준비는 완료 됬습니다.
이제 X와 O를 번갈아 배치하며, 승자를 결정할 방법을 나타냅니다.

state 끌어올리기

현재 각 Square 컴포넌트는 게임 state의 일부를 유지하고 있죠, 틱택토 게임에서 승자를 확인하려면 Board가 9가의 Square 컴포넌트 각각의 state를 어떻게든 알고 있어야 합니다.
만약 Board가 각각의 Square에 해당 Square의 state를 요청해서 받는다면, 코드가 복잡해서 이해하기 어렵고 버그에 취약하며 리팩토링하기 어렵습니다.

그러면 가장 좋은 접근 방법은??
바로 state를 각 Square가 아닌 부모 Board 컴포넌트에 저장하는 것입니다. Board 컴포넌트는 각 사각형에 숫자를 전달했을 때와 같이 prop을 전달하여 각 Square에 표시할 내용을 정할 수 있습니다.

여러 자식 컴포넌트에서 데이터를 수집하거나 두 자식 컴포넌트가 서로 통신하도록 하려면, 부모 컴포넌트에서 공유 state를 선언해봅시다. 부모 컴포넌트는 props를 통해 해당 state를 자식 컴포넌트에 전달하고 그렇게 하면 자식 컴포넌트가 서로 동기화되고 부모 컴포는터와도 동기화를 유지할 수 있답니다.

이는 React 컴포넌트를 리팩토링할 때 부모 컴포넌트로 state를 끌어올리는 흔히 쓰이는 방법이죠

바로 App 컴포넌트로 가서 만들어서 각 Square 컴포넌트에 value prop을 전달까지 해봅시다.

import { useState } from "react";
import Square from "./Square";

function Board() {
  const [squares, setSqaures] = useState(Array(9).fill(null));
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]}/>
        <Square value={squares[1]}/>
        <Square value={squares[2]}/>
      </div>
      <div className="board-row">
        <Square value={squares[3]}/>
        <Square value={squares[4]}/>
        <Square value={squares[5]}/>
      </div>
      <div className="board-row">
        <Square value={squares[6]}/>
        <Square value={squares[7]}/>
        <Square value={squares[8]}/>
      </div>
    </>
  );
}

export default Board;

value prop을 전달했다면 Square 컴포넌트도 수정해야겠죠?
이를 위해 Square 컴포넌트에서 value의 상태 추적과 버튼의 onClick prop을 제거합니다.

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

이제 각 Square는 X, O 또는 빈 사각형의 경우 null이 되는 prop을 받습니다.
그런 다음으로 Square가 클릭 되었을 때 발생하는 동작을 변경해봅시다.
이제는 Board 컴포넌트가 사각형이 채워져ㅓㅆ는지를 관리하니 Square가 Board 의 state업데이트 할 방법을 만들어야 겠죠?
컴포넌트는 자신이 정의한 state에 접근할 수 있어서 Square에서 Board의 state를 직접 변경할 수 없기에
Board 컴포넌트에서 Square 컴포넌트로 이벤트 함수를 전달하고 Square가 클릭될 때 Square가 해당 함수를 호출하면 되겠죠?
Square 컴포넌트에서 함수를 전달 받고

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

이제 Board 컴포넌트에서 prop으로 함수를 전달해줍시다.
slice 메서드를 사용하여 square 배열의 사본 nextSquare를 생성하고 nextSquare 배열의 첫번째 사각형에 X를 추가하여 업데이트 합니다.
또한 사각형을 업데이트할 수 있도록 함수에 업데이트할 Square의 인덱스를 나타내는 인수 i를 추가해주며 i를 함수에 전달해야합니다.

import { useState } from "react";
import Square from "./Square";

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

  function handleClick(i) {
    const nextSquare = squares.slice();
    nextSquare[i] = "X";
    setSqaures(nextSquare);
  }
  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>
    </>
  );
}

export default Board;

하지만 이 방법은 작동하지 않습니다!
why? handleClick(0) 호출은 Board 컴포넌트의 렌더링의 일부가 됩니다.
handleClick(0)은 setSqaures를 호출하여 Board 컴포넌트의 state를 변경하기 때문에 Board 컴포넌트 전체가 다시 렌더링 됩니다.
이 과정에서 handleClick(0)은 다시 실행되기 때문에 무한 루프에 빠지게 됩니다.

이전에 onSqaureClick={handleClick}을 전달할 땐 함수를 호출한 것이 아니라 handleClick 함수를 prop로 전달했기 때문입니다. 하지만 지금은 handleClick(0)의 괄호를 보면 해당 함수를 호출하고 있으므로 해당 함수가 너무 일찍 실행되는 것입니다. 사용자가 클릭하기 전까지 handleClick 함수를 호출하면 안됩니다.

이 문제를 해결하기 위해서는 handleClick(0)을 호출하는 handleFristSquareClick 함수를 만들고, handleClick(1)을 호출하는 handleSecondSquareClick을 만들고... 계속해서 만들면 됩니다.

위 방법은 너무 귀찮으니 화살표 함수로 전달해주는 건 어떨까요?? 그러면 전달한 화살표 함수에서 handleClick을 호출하도록 해서 함수를 짧게 정의하면 Square를 클릭했을 때 X를 다시 추가할 수 있습니다.

<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를 클릭하면 -> button이 Square로 부터 onClick prop으로 받은 함수 실행 -> handleClick은 인수 0을 사용하여 square 배열의 첫 번째 엘리먼트를 null에서 X로 변경 -> Baord 컴포넌트의 sqaures state가 업데이트되어 Board와 그 모든 자식이 다시 렌더링 됩니다. 이에 따라 인덱스가 0인 Sqaure 컴포넌트의 value prop이 null에서 X로 변경
구동됩니다.

순서 정하기

이제 틱택토 게임에서 가장 큰 결함인 O를 보드에 표시해 봅시다.
Board에 또 다른 state를 추가해 추적해봅니다.
플레이어가 움질일 때마다 다음 플레이어를 결정하기 위해 불리언 값인 xIsNext가 반전되고 게임의 state가 저장되겠죠?
그러면 플레이어를 결정하기 위해서 불리언 값인 xIsNext를 반전시키고 게임의 state를 저장하면 됩니다!
squares[i]번째에 이미 있다면 값을 return 해주면 만약 칸에 X나 O가 있다면 중복 클릭으로 변하는것 또한 없을 것입니다.

const [xIsNext, setXIsNext] = useState(null);
const [squares, setSqaures] = useState(Array(9).fill(null));

function handleClick(i) {
  if (squares[i]) {
    return;
  }
  const nextSquare = squares.slice();
  if (xIsNext) {
    nextSquare[i] = "X";
  } else {
    nextSquare[i] = "O";
  }
  setSqaures(nextSquare);
  setXIsNext(!xIsNext);
}

승자 결정하기

이제 승자를 결정하는 함수를 만들어보죠
틱택토게임은 빙고 게임이죠? 이기려면 한줄이 O나 X로 이루어져야 합니다.
함수를 따로 관리하기 위해 CalculateWinner 컴포넌트를 만들어 줍니다.

export default function calculateWinner(sqaures) {
  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 (sqaures[a] && sqaures[a] === sqaures[b] && sqaures[a] === sqaures[c]) {
      return sqaures[a];
    }
  }

  return null;
}

Board 컴포넌트의 handleClick 함수에서 calculateWinner(squares)를 호출하여 플레이어가 이겼는지 확인하면 됩니다. 사용자가 이미 x또는 o가 있는 사각형을 클릭했는지 확인하는 것과 동시에 수행하면 더 좋겠죠??

function handleClick(i) {
  if (squares[i] || calculateWinner(squares)) {
    return
  }
  const nextSquare = squares.slice();
  if (xIsNext) {
    nextSquare[i] = "X";
  } else {
    nextSquare[i] = "O";
  }
  setSqaures(nextSquare);
  setXIsNext(!xIsNext);
}

이제 승자를 알리기 위해 승자를 표시해줍니다.
Baord 컴포넌트에 status 구역을 추가하여 게임이 끝나면 status는 승자를 표시하고 게임이 진행중인 경우 다음 플레이어의 차례를 표시하게 만듭니다.

import { useState } from "react";
import Square from "./Square";
import calculateWinner from "./CalculateWinner";

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

  function handleClick(i) {
    if (squares[i] || calculateWinner(squares)) {
      return;
    }
    const nextSquare = squares.slice();
    if (xIsNext) {
      nextSquare[i] = "X";
    } else {
      nextSquare[i] = "O";
    }
    setSqaures(nextSquare);
    setXIsNext(!xIsNext);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = "승자 :" + winner;
  } else {
    status = "다음 순서 :" + (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 Board;

시간을 거슬러 올라가보자

게임의 이전 동작으로 이동하는 것이 마지막으로 구현해보고자 합니다.

이동 히스토리 저장

slice 메서드를 사용해서 매번 이동할 때마다 squares 배열의 새 복사본을 만들고 이를 불변으로 처리했기에, squares 배열의 모든 과거 버전을 저장할 수 있고 이미 발생한 턴 사이를 탐색할 수 있습니다!

state 끌어올리기

새로운 Game 컴포넌트를 만들어서 과거 이동 목록을 표시하며, 전체 게임 기록을 포함하는 history state를 만들어줍니다.

history state를 Game 컴포넌트에 배치함면 자식 Board 컴포넌트에서 sqaures state를 제거할 수 있습니다.
Sqaure 컴포넌트에서 Board 컴포넌트로 state를 보내줬던 것 처럼 Board 컴포넌트에서 최상위 Game 컴포넌트로 state를 보내주면 되겠죠?

import { useState } from "react";
import Board from "./App";

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquare) {
    setHistory([...history, nextSquare]);
    setXIsNext(!xIsNext);
  }
  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  );
}
function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (squares[i] || calculateWinner(squares)) {
      return;
    }
    const nextSquare = squares.slice();
    if (xIsNext) {
      nextSquare[i] = "X";
    } else {
      nextSquare[i] = "O";
    }
    onPlay(nextSquare);
  }

이제 빙고 게임의 히스토를 기록해서 플레이어에게 과거 이동 목록을 보여주면 됩니다.
React에서 여러 요소를 렌더링 하려면 React 요소 배열을 사용할 수 있답니다.
이미 state에 이동 history 배열이 있으므로 이를 React 요소 배열로 변환하며 Javascript에서 한 배열을 다른 배열로 변환하려면 배열 map 메서드를 사용하면 됩니다.

import { useState } from "react";
import Board from "./App";

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquare) {
    setHistory([...history, nextSquare]);
    setXIsNext(!xIsNext);
  }
  function jumpTo(nextMove) {}

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = "되돌아가기 # " + move;
    } else {
      description = "시작하기";
    }
    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>
  );
}

이제 jumpTo를 구현해야겠죠?
구현하기 위해서 사용자가 현재 어떤 단계를 보고 있는지를 추적할 수 있는 Game 컴포넌트가 필요합니다. 이를 위해 currentMove라는 새 state 변수를 정의하고 jumpTo 함수를 업데이트하여 해당 currentMove를 업데이트 해줍니다. 또한 currentMove를 변경하는 숫자가 짝수면 xIsNext를 true 설정또한 변경해주면 됩니다.

import { useState } from "react";
import Board from "./App";

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

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

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = "되돌아가기 # " + move;
    } else {
      description = "시작하기";
    }
    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>
  );
}

이제 빙고 게임을 하며
플레이어가 게임에서 이겼을 때를 표시하며
게임이 진행됨에 따라 히스토리를 저장하고
플레이어가 게임 히스토리를 검토하고 게임 보드의 이전 버전을 볼 수 있습니다.

profile
개발자가 되고 싶은 정치학도생의 기술 블로그

0개의 댓글