Tutorial: Tic-Tac-Toe

김동현·2026년 3월 4일

튜토리얼: 틱택토 (Tic-Tac-Toe)

게임 만들기


게임 만들기

여러분, 환영합니다! 이번 튜토리얼에서는 작고 재미있는 '틱택토(Tic-Tac-Toe)' 게임을 직접 만들어볼 거예요. 이 튜토리얼은 여러분이 기본적인 React 지식을 가지고 있다고 가정하고 진행됩니다.

여기서 배우게 될 기술들은 React 애플리케이션을 구축하는 데 있어 아주 핵심적인 기본기들입니다. 이 튜토리얼을 완벽하게 이해하고 나면, React와 Zustand가 어떻게 상호작용하는지 깊은 깨달음을 얻게 되실 거예요!

참고
이 튜토리얼은 직접 손으로 코드를 치면서 눈으로 결과를 확인하는 '실습 위주'의 학습을 선호하는 분들을 위해 정성껏 만들어졌습니다. React 공식 문서의 틱택토 튜토리얼에서 영감을 받아 제작되었답니다.

튜토리얼은 다음의 여러 섹션으로 나뉘어 있습니다:

  • 튜토리얼 설정 (Setup for the tutorial): 튜토리얼을 따라가기 위한 초기 시작점을 제공합니다.
  • 개요 (Overview): 컴포넌트, props, state 등 React의 핵심 기본기를 배웁니다.
  • 게임 완성하기 (Completing the game): React 개발에서 가장 자주 쓰이는 유용한 테크닉들을 배웁니다.
  • 시간 여행 추가하기 (Adding time travel): React만이 가진 고유한 강점과 상태 관리의 진수를 깊이 있게 살펴봅니다.

무엇을 만들게 될까요?

이번 튜토리얼을 통해 여러분은 React와 Zustand를 활용해서 사용자와 상호작용하는 대화형 틱택토 게임을 만들게 됩니다.

여러분이 모든 과정을 마쳤을 때 완성될 최종 결과물의 코드는 아래와 같습니다. 미리 한 번 구경해 볼까요? (지금 다 이해하지 못해도 괜찮습니다!)

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useGameStore = create(
  combine(
    {
      history: [Array(9).fill(null)],
      currentMove: 0,
    },
    (set, get) => {
      return {
        setHistory: (nextHistory) => {
          set((state) => ({
            history:
              typeof nextHistory === 'function'
                ? nextHistory(state.history)
                : nextHistory,
          }))
        },
        setCurrentMove: (nextCurrentMove) => {
          set((state) => ({
            currentMove:
              typeof nextCurrentMove === 'function'
                ? nextCurrentMove(state.currentMove)
                : nextCurrentMove,
          }))
        },
      }
    },
  ),
)

function Square({ value, onSquareClick }) {
  return (
    <button
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 0,
        backgroundColor: '#fff',
        border: '1px solid #999',
        outline: 0,
        borderRadius: 0,
        fontSize: '1rem',
        fontWeight: 'bold',
      }}
      onClick={onSquareClick}
    >
      {value}
    </button>
  )
}

function Board({ xIsNext, squares, onPlay }) {
  const winner = calculateWinner(squares)
  const turns = calculateTurns(squares)
  const player = xIsNext ? 'X' : 'O'
  const status = calculateStatus(winner, turns, player)

  function handleClick(i) {
    if (squares[i] || winner) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    onPlay(nextSquares)
  }

  return (
    <>
      <div style={{ marginBottom: '0.5rem' }}>{status}</div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gridTemplateRows: 'repeat(3, 1fr)',
          width: 'calc(3 * 2.5rem)',
          height: 'calc(3 * 2.5rem)',
          border: '1px solid #999',
        }}
      >
        {squares.map((_, i) => (
          <Square
            key={`square-${i}`}
            value={squares[i]}
            onSquareClick={() => handleClick(i)}
          />
        ))}
      </div>
    </>
  )
}

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const currentMove = useGameStore((state) => state.currentMove)
  const setCurrentMove = useGameStore((state) => state.setCurrentMove)
  const xIsNext = currentMove % 2 === 0
  const currentSquares = history[currentMove]

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

  function jumpTo(nextMove) {
    setCurrentMove(nextMove)
  }

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>
          {history.map((_, historyIndex) => {
            const description =
              historyIndex > 0
                ? `Go to move #${historyIndex}`
                : 'Go to game start'

            return (
              <li key={historyIndex}>
                <button onClick={() => jumpTo(historyIndex)}>
                  {description}
                </button>
              </li>
            )
          })}
        </ol>
      </div>
    </div>
  )
}

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 calculateTurns(squares) {
  return squares.filter((square) => !square).length
}

function calculateStatus(winner, turns, player) {
  if (!winner && !turns) return 'Draw'
  if (winner) return `Winner ${winner}`
  return `Next player: ${player}`
}

보드(게임판) 만들기

자, 본격적으로 시작해볼까요? 가장 먼저 Board 컴포넌트의 구성 요소가 될 Square 컴포넌트를 만들어보겠습니다. 이 컴포넌트는 우리 게임판의 각각의 네모 칸 하나하나를 의미하게 됩니다.

Square 컴포넌트는 부모로부터 valueonSquareClick이라는 두 가지 prop을 전달받아야 합니다. 이 컴포넌트는 정사각형 모양으로 스타일링된 <button> 요소를 화면에 그려줍니다(return).

이 버튼은 게임의 현재 상태에 따라 value prop 값을 보여주는데, 그 값은 'X'가 될 수도 있고, 'O'가 될 수도 있으며, 아직 아무도 선택하지 않은 빈 칸이라면 null이 됩니다. 사용자가 이 버튼을 클릭하면, prop으로 전달받은 onSquareClick 함수가 실행되면서 우리 게임이 사용자의 입력에 반응할 수 있게 됩니다.

여기 Square 컴포넌트의 코드가 있습니다:

function Square({ value, onSquareClick }) {
  return (
    <button
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 0,
        backgroundColor: '#fff',
        border: '1px solid #999',
        outline: 0,
        borderRadius: 0,
        fontSize: '1rem',
        fontWeight: 'bold',
      }}
      onClick={onSquareClick}
    >
      {value}
    </button>
  )
}

이제 격자무늬(그리드) 형태로 9개의 네모 칸이 배치될 Board 컴포넌트를 만들어보죠. 이 컴포넌트는 우리 게임의 주된 플레이 영역이 될 겁니다.

Board 컴포넌트는 CSS Grid를 사용해서 그리드 형태로 스타일링된 <div> 요소를 반환해야 합니다. CSS Grid를 이용해 가로 3열, 세로 3행을 만들고, 각각의 칸이 사용 가능한 공간을 동일한 비율로 나눠 갖도록 설정합니다. 전체 격자의 크기는 widthheight 속성으로 정해주어, 반듯한 정사각형 모양이 되도록 만들어 줍니다.

(보충 설명: CSS의 display: 'grid'gridTemplateColumns: 'repeat(3, 1fr)' 조합은 리액트에서 바둑판 모양의 레이아웃을 짤 때 정말 강력하고 유용하답니다!)

우리는 이 격자 안에 9개의 Square 컴포넌트를 배치할 건데요, 각각의 컴포넌트에는 자신의 위치를 나타내는 value prop을 넘겨줄 겁니다. 이 Square 컴포넌트들은 나중에 게임 기호('X' 또는 'O')를 담게 되고 사용자의 클릭을 처리하게 될 거예요.

여기 Board 컴포넌트의 코드가 있습니다:

export default function Board() {
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(3, 1fr)',
        gridTemplateRows: 'repeat(3, 1fr)',
        width: 'calc(3 * 2.5rem)',
        height: 'calc(3 * 2.5rem)',
        border: '1px solid #999',
      }}
    >
      <Square value="1" />
      <Square value="2" />
      <Square value="3" />
      <Square value="4" />
      <Square value="5" />
      <Square value="6" />
      <Square value="7" />
      <Square value="8" />
      <Square value="9" />
    </div>
  )
}

Board 컴포넌트는 3x3 격자 안에 9개의 칸을 보기 좋게 배치해서 게임 보드의 기본적인 뼈대를 잡아줍니다. 이 뼈대 위에 앞으로 우리는 더 많은 기능과 플레이어의 상호작용(클릭 이벤트 등)을 처리할 수 있도록 살을 붙여나갈 거예요.


상태 끌어올리기 (Lifting state up)

지금 상태로는 9개의 Square 컴포넌트가 각각 독립적으로 자기 자신의 상태를 관리할 수도 있습니다. 하지만 생각해보세요. 틱택토 게임에서 승자가 누구인지 확인하려면, Board 컴포넌트는 반드시 9개의 Square 컴포넌트가 각각 어떤 상태를 가지고 있는지 전부 알고 있어야만 합니다.

이 문제를 어떻게 해결해야 할까요? 처음에는 Board 컴포넌트가 각각의 Square 컴포넌트에게 "너 지금 무슨 상태니?"라고 일일이 물어보는 방식을 떠올릴 수 있습니다. 기술적으로 불가능한 건 아니지만, React에서는 이런 방식을 강력하게 비권장합니다. 왜냐하면 코드가 굉장히 읽기 어려워지고, 버그가 발생하기 쉬우며, 나중에 코드를 수정(리팩토링)하기도 몹시 까다로워지기 때문입니다.

대신, 아주 좋은 방법이 있습니다. 게임의 전체 상태를 각각의 Square 컴포넌트가 아니라, 부모 컴포넌트인 Board 컴포넌트에 한꺼번에 저장하는 겁니다. 이렇게 하면 Board 컴포넌트가 이전에 숫자("1", "2" 등)를 prop으로 넘겨줬던 것처럼, 이번에는 각 Square 컴포넌트에게 "너는 이거 화면에 그려!"라고 prop을 통해 상태를 내려줄 수 있게 됩니다. 이것이 바로 React에서 말하는 유명한 패턴, '상태 끌어올리기 (Lifting State Up)' 랍니다.

중요
여러 자식 컴포넌트들로부터 데이터를 하나로 모으거나, 두 개 이상의 자식 컴포넌트들이 서로 대화를 나누게(동기화되게) 하고 싶다면, 그들이 공유해야 할 상태를 부모 컴포넌트에 선언하세요. 부모 컴포넌트는 그 상태를 props를 통해 자식들에게 다시 내려보낼 수 있습니다. 이 방식을 사용하면 자식 컴포넌트들끼리, 그리고 부모 컴포넌트와 항상 똑같은 상태를 유지(동기화)할 수 있습니다.

💡 강사님의 꿀팁:
현업에서도 컴포넌트 설계 시 "이 상태를 누가 가지고 있어야 할까?"를 고민하는 것은 프론트엔드 개발자의 주된 업무 중 하나입니다. 상태를 너무 아래에 두면 데이터 공유가 어렵고, 너무 위에 두면 불필요한 렌더링이 많이 일어납니다. 하지만 데이터의 '단일 진실 공급원(Single Source of Truth)'을 부모 쪽에 두는 것은 항상 훌륭한 출발점입니다. 게다가 우리는 이 튜토리얼에서 전역 상태 관리자인 Zustand를 사용하고 있으므로 이 구조를 훨씬 더 깔끔하게 짤 수 있어요!

이 개념을 직접 적용해 볼 좋은 기회네요. Board 컴포넌트의 코드를 수정해 보겠습니다. 9개의 칸에 대응하는 9개의 null 값으로 채워진 배열을 기본값으로 가지는 squares라는 상태 변수를 선언해 볼까요:

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useGameStore = create(
  combine({ squares: Array(9).fill(null) }, (set) => {
    return {
      setSquares: (nextSquares) => {
        set((state) => ({
          squares:
            typeof nextSquares === 'function'
              ? nextSquares(state.squares)
              : nextSquares,
        }))
      },
    }
  }),
)

export default function Board() {
  const squares = useGameStore((state) => state.squares)
  const setSquares = useGameStore((state) => state.setSquares)

  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(3, 1fr)',
        gridTemplateRows: 'repeat(3, 1fr)',
        width: 'calc(3 * 2.5rem)',
        height: 'calc(3 * 2.5rem)',
        border: '1px solid #999',
      }}
    >
      {squares.map((square, squareIndex) => (
        <Square key={squareIndex} value={square} />
      ))}
    </div>
  )
}

(보충 설명): 여기서 Array(9).fill(null) 이라는 코드는 9개의 자리를 가진 배열을 만들고, 그 안을 전부 빈 값인 null로 꽉꽉 채워 넣는다는 뜻입니다.

우리가 만든 Zustand 스토어인 useGameStore는 초깃값으로 바로 이 배열을 가지는 squares라는 상태를 선언합니다. 이 배열의 각 항목은 보드의 각 칸이 가진 값과 1:1로 매칭됩니다. 나중에 게임을 진행하면서 보드 칸이 채워지면, squares 배열은 아래와 같은 모습으로 변하게 될 거예요.

const squares = ['O', null, 'X', 'X', 'X', 'O', 'O', null, null]

이제 각각의 Square 컴포넌트는 자신이 화면에 표시할 value prop으로 'X', 'O', 또는 비어있음을 뜻하는 null 중 하나를 넘겨받게 됩니다.

다음으로 할 일은 Square 컴포넌트가 클릭되었을 때 어떤 일이 일어날지를 변경하는 것입니다. 이제 보드의 어떤 칸이 채워졌는지 관리하는 주체는 Board 컴포넌트입니다. 따라서 우리는 Square 컴포넌트가 클릭되었을 때 Board 컴포넌트의 상태를 업데이트할 수 있는 '연결 고리'를 만들어주어야 합니다. React에서 상태는 그 상태를 정의한 컴포넌트 내부에서만 비공개(private)로 관리되기 때문에, 자식인 Square 컴포넌트가 부모인 Board 컴포넌트의 상태를 직접 수정할 수는 없거든요.

그래서 우리는 부모인 Board 컴포넌트에서 자식인 Square 컴포넌트로 '함수'를 prop으로 내려보낼 것입니다. 그리고 Square 컴포넌트가 클릭되면 그 내려받은 함수를 실행하도록 할 거예요. 칸이 클릭되었을 때 호출될 이 함수 prop의 이름을 onSquareClick이라고 지어보겠습니다.

그런 다음, Square 컴포넌트가 받을 onSquareClick prop을 Board 컴포넌트 내부에 정의할 handleClick이라는 함수와 연결해 줍니다. 이렇게 연결하기 위해 우리는 첫 번째 인덱스의 Square 컴포넌트의 onSquareClick prop에 인라인 함수(inline function)를 전달하게 됩니다:

<Square key={squareIndex} value={square} onSquareClick={() => handleClick(i)} />

마지막으로, 보드의 상태를 담고 있는 squares 배열을 업데이트하기 위해 Board 컴포넌트 내부에 handleClick 함수를 실제로 정의해 주어야 합니다.

handleClick 함수는 업데이트할 칸의 인덱스(순서)를 인자로 받은 다음, 기존의 squares 배열의 복사본인 nextSquares를 만듭니다. 그리고 방금 클릭한 칸이 비어있다면, 지정된 인덱스(i)의 자리에 'X'를 채워 넣어 nextSquares 배열을 업데이트합니다.

export default function Board() {
  const squares = useGameStore((state) => state.squares)
  const setSquares = useGameStore((state) => state.setSquares)

  function handleClick(i) {
    if (squares[i]) return
    const nextSquares = squares.slice()
    nextSquares[i] = 'X'
    setSquares(nextSquares)
  }

  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(3, 1fr)',
        gridTemplateRows: 'repeat(3, 1fr)',
        width: 'calc(3 * 2.5rem)',
        height: 'calc(3 * 2.5rem)',
        border: '1px solid #999',
      }}
    >
      {squares.map((square, squareIndex) => (
        <Square
          key={squareIndex}
          value={square}
          onSquareClick={() => handleClick(squareIndex)}
        />
      ))}
    </div>
  )
}

중요
handleClick 함수 안에서 배열을 수정할 때 기존 squares 배열을 직접 수정(mutate)하지 않고, .slice() 메서드를 호출해서 복사본을 만들어 사용했다는 점을 꼭 눈여겨보세요!

💡 강사님의 꿀팁:
"왜 귀찮게 배열을 복사해서 쓰나요?"라고 물으실 수 있어요. React와 Zustand 같은 상태 관리 도구들은 데이터가 '불변성(Immutability)'을 유지할 때 가장 완벽하게 작동합니다. 즉, 원본 데이터를 건드리지 않고 새로운 복사본을 만들어 갈아 끼워주는 방식이죠. 이렇게 해야 React가 데이터가 변했다는 사실을 빠르게 눈치채고 화면을 예쁘게 다시 그려줄(re-render) 수 있고, 나중에 우리가 구현할 '시간 여행(실행 취소)' 기능도 아주 쉽게 만들 수 있답니다.


순서 번갈아가며 진행하기 (Taking turns)

이제 우리 게임에 있는 치명적인 결함을 고칠 차례입니다. 아직 우리 게임판에는 'O'를 놓을 수가 없거든요!

기본적으로 첫 번째 턴은 'X'가 되도록 설정해 보겠습니다. 누구의 차례인지 기억하기 위해 useGameStore 훅에 새로운 상태를 하나 더 추가해 볼게요:

const useGameStore = create(
  combine({ squares: Array(9).fill(null), xIsNext: true }, (set) => {
    return {
      setSquares: (nextSquares) => {
        set((state) => ({
          squares:
            typeof nextSquares === 'function'
              ? nextSquares(state.squares)
              : nextSquares,
        }))
      },
      setXIsNext: (nextXIsNext) => {
        set((state) => ({
          xIsNext:
            typeof nextXIsNext === 'function'
              ? nextXIsNext(state.xIsNext)
              : nextXIsNext,
        }))
      },
    }
  }),
)

플레이어가 돌을 둘 때마다, 어떤 플레이어의 다음 차례인지 결정하기 위해 xIsNext (불리언 값, true/false) 값이 뒤집히고 게임의 상태가 저장될 것입니다. Board 컴포넌트의 handleClick 함수를 수정해서 돌을 둘 때마다 xIsNext의 값이 반대가 되도록 만들어 봅시다:

export default function Board() {
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const squares = useGameStore((state) => state.squares)
  const setSquares = useGameStore((state) => state.setSquares)
  const player = xIsNext ? 'X' : 'O'

  function handleClick(i) {
    if (squares[i]) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    setSquares(nextSquares)
    setXIsNext(!xIsNext)
  }

  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(3, 1fr)',
        gridTemplateRows: 'repeat(3, 1fr)',
        width: 'calc(3 * 2.5rem)',
        height: 'calc(3 * 2.5rem)',
        border: '1px solid #999',
      }}
    >
      {squares.map((square, squareIndex) => (
        <Square
          key={squareIndex}
          value={square}
          onSquareClick={() => handleClick(squareIndex)}
        />
      ))}
    </div>
  )
}

승자 또는 무승부 선언하기

자, 이제 플레이어들이 번갈아 가며 턴을 진행할 수 있게 되었습니다. 그렇다면 게임이 승리하거나 무승부로 끝나서 더 이상 진행할 턴이 없을 때 이를 화면에 알려주고 싶을 텐데요. 이를 위해 우리는 3개의 '헬퍼(helper) 함수'를 추가할 겁니다.

  1. 첫 번째 헬퍼 함수 calculateWinner는 9칸의 배열을 받아서 승자가 있는지 확인하고, 상황에 맞게 'X', 'O', 또는 승자가 아직 없으면 null을 반환합니다.
  2. 두 번째 헬퍼 함수 calculateTurns는 동일한 배열을 받아서, 비어있는 null 항목들을 걸러내고(filter) 남은 턴의 개수를 세어 반환합니다.
  3. 세 번째 헬퍼 함수 calculateStatus는 남은 턴 수, 승자 정보, 그리고 현재 플레이어('X' 또는 'O')를 전달받아 화면에 보여줄 텍스트를 만들어 줍니다.
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 calculateTurns(squares) {
  return squares.filter((square) => !square).length
}

function calculateStatus(winner, turns, player) {
  if (!winner && !turns) return 'Draw'
  if (winner) return `Winner ${winner}`
  return `Next player: ${player}`
}

💡 강사님의 꿀팁:
실제 복잡한 게임을 개발할 때 승리 조건을 체크하는 로직은 생각보다 까다로울 수 있습니다. 이렇게 로직과 화면 그리는 부분(React 컴포넌트)을 분리해서 별도의 함수(calculateWinner 등)로 빼두면 코드가 훨씬 깔끔해지고 나중에 테스트하기도 쉬워집니다.

이제 Board 컴포넌트의 handleClick 함수 안에서 calculateWinner(squares)의 결과를 사용하여 플레이어가 이겼는지 여부를 확인해 볼까요? 이미 칸이 채워져 있거나(XO가 있는 경우), 승자가 이미 정해진 경우를 동시에 체크해서 그럴 때는 더 이상 진행되지 않고 일찍 함수를 종료(return early) 시키도록 해보겠습니다.

function handleClick(i) {
  if (squares[i] || winner) return
  const nextSquares = squares.slice()
  nextSquares[i] = player
  setSquares(nextSquares)
  setXIsNext(!xIsNext)
}

게임이 끝났을 때 플레이어들에게 알려주기 위해, 'Winner: X''Winner: O' 같은 텍스트를 표시해 주면 좋습니다. 이를 위해 Board 컴포넌트 상단에 status를 보여줄 영역을 추가할게요. 이 status는 게임이 끝나면 승자나 무승부를 표시하고, 진행 중이라면 다음 차례가 누구인지 알려줄 것입니다:

export default function Board() {
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const squares = useGameStore((state) => state.squares)
  const setSquares = useGameStore((state) => state.setSquares)
  const winner = calculateWinner(squares)
  const turns = calculateTurns(squares)
  const player = xIsNext ? 'X' : 'O'
  const status = calculateStatus(winner, turns, player)

  function handleClick(i) {
    if (squares[i] || winner) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    setSquares(nextSquares)
    setXIsNext(!xIsNext)
  }

  return (
    <>
      <div style={{ marginBottom: '0.5rem' }}>{status}</div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gridTemplateRows: 'repeat(3, 1fr)',
          width: 'calc(3 * 2.5rem)',
          height: 'calc(3 * 2.5rem)',
          border: '1px solid #999',
        }}
      >
        {squares.map((square, squareIndex) => (
          <Square
            key={squareIndex}
            value={square}
            onSquareClick={() => handleClick(squareIndex)}
          />
        ))}
      </div>
    </>
  )
}

축하합니다! 이제 완벽하게 동작하는 틱택토 게임을 완성하셨습니다. 게다가 덤으로 React와 Zustand의 아주 중요한 기초까지 마스터하셨죠. 여러분이 진정한 승자입니다! 완성된 코드의 전체 모습은 대략 이렇습니다:

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useGameStore = create(
  combine({ squares: Array(9).fill(null), xIsNext: true }, (set) => {
    return {
      setSquares: (nextSquares) => {
        set((state) => ({
          squares:
            typeof nextSquares === 'function'
              ? nextSquares(state.squares)
              : nextSquares,
        }))
      },
      setXIsNext: (nextXIsNext) => {
        set((state) => ({
          xIsNext:
            typeof nextXIsNext === 'function'
              ? nextXIsNext(state.xIsNext)
              : nextXIsNext,
        }))
      },
    }
  }),
)

function Square({ value, onSquareClick }) {
  return (
    <button
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 0,
        backgroundColor: '#fff',
        border: '1px solid #999',
        outline: 0,
        borderRadius: 0,
        fontSize: '1rem',
        fontWeight: 'bold',
      }}
      onClick={onSquareClick}
    >
      {value}
    </button>
  )
}

export default function Board() {
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const squares = useGameStore((state) => state.squares)
  const setSquares = useGameStore((state) => state.setSquares)
  const winner = calculateWinner(squares)
  const turns = calculateTurns(squares)
  const player = xIsNext ? 'X' : 'O'
  const status = calculateStatus(winner, turns, player)

  function handleClick(i) {
    if (squares[i] || winner) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    setSquares(nextSquares)
    setXIsNext(!xIsNext)
  }

  return (
    <>
      <div style={{ marginBottom: '0.5rem' }}>{status}</div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gridTemplateRows: 'repeat(3, 1fr)',
          width: 'calc(3 * 2.5rem)',
          height: 'calc(3 * 2.5rem)',
          border: '1px solid #999',
        }}
      >
        {squares.map((square, squareIndex) => (
          <Square
            key={squareIndex}
            value={square}
            onSquareClick={() => handleClick(squareIndex)}
          />
        ))}
      </div>
    </>
  )
}

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 calculateTurns(squares) {
  return squares.filter((square) => !square).length
}

function calculateStatus(winner, turns, player) {
  if (!winner && !turns) return 'Draw'
  if (winner) return `Winner ${winner}`
  return `Next player: ${player}`
}

시간 여행 추가하기 (Adding time travel)

마지막 심화 연습으로, 과거의 어느 순간으로 "시간 여행"을 해서 이전 수(move)로 돌아갈 수 있는 멋진 기능을 만들어 볼까요?

만약 여러분이 원본 squares 배열을 직접 수정(mutate)했다면, 이 시간 여행 기능을 구현하는 것은 무척이나 험난했을 겁니다. 하지만 다행히도 우리는 매번 수를 둘 때마다 .slice()를 이용해서 squares 배열의 새로운 복사본을 만들었고 이를 변경 불가능한(immutable) 데이터처럼 취급했습니다. 덕분에 이제 우리는 과거의 모든 버전의 squares 배열을 고스란히 저장해 두고, 그 사이를 자유롭게 넘나들 수 있게 되었습니다!

이 과거의 배열들을 저장해두기 위해 우리는 history라는 새로운 상태 변수를 만들 겁니다. 이 history 배열은 첫 번째 수부터 가장 최근의 수까지 모든 보드의 상태를 차곡차곡 기록하게 될 텐데, 아마 아래와 같은 모습이 될 거예요:

const history = [
  // First move
  [null, null, null, null, null, null, null, null, null],
  // Second move
  ['X', null, null, null, null, null, null, null, null],
  // Third move
  ['X', 'O', null, null, null, null, null, null, null],
  // and so on...
]

이 방식을 사용하면, 각 게임 상태 사이를 아주 부드럽게 오가면서 시간 여행 기능을 손쉽게 구현할 수 있답니다.


상태 다시 한 번 끌어올리기 (Lifting state up, again)

자, 이제 과거의 이동 내역 리스트를 화면에 보여주기 위해 게임 전체를 감싸는 최상위 컴포넌트인 Game을 새롭게 만들겠습니다. 바로 이곳이 전체 게임 역사를 품고 있을 history 상태가 저장될 곳입니다.

history 상태를 최상단인 Game 컴포넌트로 끌어올려 배치하게 되면, 기존에 Board 컴포넌트가 가지고 있던 squares 상태는 지워버려도 됩니다. 이제 Board에서 Game으로 상태를 한 번 더 '끌어올리기' 하는 셈이죠! 이렇게 구조를 바꾸면, 최상단 Game 컴포넌트가 보드의 데이터를 완벽하게 제어할 수 있고, history 기록을 바탕으로 이전 턴들을 보여주도록 Board 컴포넌트에게 지시를 내릴 수 있게 됩니다.

먼저, 맨 아래에 export default를 사용해 Game 컴포넌트를 추가하고, Board에 있던 export default는 지워주세요. 코드는 대략 아래와 같은 형태가 됩니다:

function Board() {
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const squares = useGameStore((state) => state.squares)
  const setSquares = useGameStore((state) => state.setSquares)
  const winner = calculateWinner(squares)
  const turns = calculateTurns(squares)
  const player = xIsNext ? 'X' : 'O'
  const status = calculateStatus(winner, turns, player)

  function handleClick(i) {
    if (squares[i] || winner) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    setSquares(nextSquares)
    setXIsNext(!xIsNext)
  }

  return (
    <>
      <div style={{ marginBottom: '0.5rem' }}>{status}</div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gridTemplateRows: 'repeat(3, 1fr)',
          width: 'calc(3 * 2.5rem)',
          height: 'calc(3 * 2.5rem)',
          border: '1px solid #999',
        }}
      >
        {squares.map((square, squareIndex) => (
          <Square
            key={squareIndex}
            value={square}
            onSquareClick={() => handleClick(squareIndex)}
          />
        ))}
      </div>
    </>
  )
}

export default function Game() {
  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>{/* TODO */}</ol>
      </div>
    </div>
  )
}

이제 useGameStore 훅에 게임 이동 기록을 추적할 수 있도록 새로운 상태를 추가해 볼까요?

const useGameStore = create(
  combine({ history: [Array(9).fill(null)], xIsNext: true }, (set) => {
    return {
      setHistory: (nextHistory) => {
        set((state) => ({
          history:
            typeof nextHistory === 'function'
              ? nextHistory(state.history)
              : nextHistory,
        }))
      },
      setXIsNext: (nextXIsNext) => {
        set((state) => ({
          xIsNext:
            typeof nextXIsNext === 'function'
              ? nextXIsNext(state.xIsNext)
              : nextXIsNext,
        }))
      },
    }
  }),
)

여기서 살짝 주의해서 봐야 할 점이 있어요! [Array(9).fill(null)] 코드를 보면, 바깥쪽 대괄호 []가 하나 더 있죠? 이건 9개의 null이 들어있는 배열 하나만을 유일한 아이템으로 품고 있는 '또 다른 배열(2차원 배열)'을 만든다는 뜻입니다.

이제 현재 턴의 게임판을 화면에 그려주려면, history 상태에서 가장 마지막 배열(가장 최신 기록)을 가져와서 읽어야 합니다. 이걸 위해 복잡하게 추가적인 상태를 만들 필요는 없어요. 컴포넌트가 그려질 때(렌더링 시점에) 계산만 해줘도 충분하거든요:

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const currentSquares = history[history.length - 1]

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  )
}

다음으로, Game 컴포넌트 내부에 handlePlay라는 함수를 새로 하나 만들겠습니다. 이 함수는 게임 정보를 업데이트하기 위해 Board 컴포넌트 내부에서 호출될 함수입니다. Board 컴포넌트가 넘겨받아야 할 xIsNext, currentSquares, 그리고 handlePlay를 props로 시원하게 쏴주세요!

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const currentSquares = history[history.length - 1]

  function handlePlay(nextSquares) {
    // TODO
  }

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  )
}

이제 Board 컴포넌트가 자신이 받는 props에 의해서만 움직이는 철저한 '제어 컴포넌트(controlled component)'가 되도록 바꿔봅시다. 이를 위해 Board 컴포넌트가 3개의 props를 받도록 수정해야 합니다: xIsNext, squares, 그리고 새롭게 추가된 onPlay 함수 말이죠. 이 onPlay 함수는 플레이어가 수를 두었을 때 새롭게 변경된 배열을 인자로 받아서 Board 컴포넌트가 바깥(부모)으로 실행시켜 줄 함수입니다.

function Board({ xIsNext, squares, onPlay }) {
  const winner = calculateWinner(squares)
  const turns = calculateTurns(squares)
  const player = xIsNext ? 'X' : 'O'
  const status = calculateStatus(winner, turns, player)

  function handleClick(i) {
    if (squares[i] || winner) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    onPlay(nextSquares)
  }

  return (
    <>
      <div style={{ marginBottom: '0.5rem' }}>{status}</div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gridTemplateRows: 'repeat(3, 1fr)',
          width: 'calc(3 * 2.5rem)',
          height: 'calc(3 * 2.5rem)',
          border: '1px solid #999',
        }}
      >
        {squares.map((square, squareIndex) => (
          <Square
            key={squareIndex}
            value={square}
            onSquareClick={() => handleClick(squareIndex)}
          />
        ))}
      </div>
    </>
  )
}

이제 Board 컴포넌트는 오로지 부모인 Game에서 내려주는 props에 의해서만 통제됩니다. 게임이 제대로 다시 돌아가게 하려면 부모인 Game 컴포넌트의 handlePlay 함수에 생명을 불어넣어 줘야겠죠.

handlePlay가 호출될 때 어떤 일을 해야 할까요? 예전에는 Board 컴포넌트가 스스로 setSquares를 부르며 뚝딱뚝딱 상태를 고쳤지만, 이제는 새롭게 변한 배열을 onPlay를 통해 부모에게 휙 던져주기만 합니다.

그러면 호출을 받은 부모의 handlePlay 함수는 Game 컴포넌트의 상태를 업데이트해서 화면이 다시 렌더링되게 만들어야 합니다. 예전처럼 setSquares를 쓰는 대신, 최신 배열이 담긴 데이터를 새로운 기록으로 간주하고 history 배열의 맨 뒤에 이어 붙여(append) history 상태를 업데이트하게 될 겁니다. 그리고 차례를 나타내는 xIsNext도 반대로 뒤집어 주어야 하죠. 예전에 Board가 했던 일들을 부모가 대신하는 겁니다.

function handlePlay(nextSquares) {
  setHistory(history.concat([nextSquares]))
  setXIsNext(!xIsNext)
}

드디어 상태가 최상단 Game 컴포넌트에 안전하게 이사 왔습니다. 코드를 리팩토링하기 전처럼 UI가 완벽하게 작동해야 정상입니다. 현재까지의 코드를 정리하면 이런 모습일 거예요:

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useGameStore = create(
  combine({ history: [Array(9).fill(null)], xIsNext: true }, (set) => {
    return {
      setHistory: (nextHistory) => {
        set((state) => ({
          history:
            typeof nextHistory === 'function'
              ? nextHistory(state.history)
              : nextHistory,
        }))
      },
      setXIsNext: (nextXIsNext) => {
        set((state) => ({
          xIsNext:
            typeof nextXIsNext === 'function'
              ? nextXIsNext(state.xIsNext)
              : nextXIsNext,
        }))
      },
    }
  }),
)

function Square({ value, onSquareClick }) {
  return (
    <button
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 0,
        backgroundColor: '#fff',
        border: '1px solid #999',
        outline: 0,
        borderRadius: 0,
        fontSize: '1rem',
        fontWeight: 'bold',
      }}
      onClick={onSquareClick}
    >
      {value}
    </button>
  )
}

function Board({ xIsNext, squares, onPlay }) {
  const winner = calculateWinner(squares)
  const turns = calculateTurns(squares)
  const player = xIsNext ? 'X' : 'O'
  const status = calculateStatus(winner, turns, player)

  function handleClick(i) {
    if (squares[i] || winner) return
    const nextSquares = squares.slice()
    nextSquares[i] = player
    onPlay(nextSquares)
  }

  return (
    <>
      <div style={{ marginBottom: '0.5rem' }}>{status}</div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gridTemplateRows: 'repeat(3, 1fr)',
          width: 'calc(3 * 2.5rem)',
          height: 'calc(3 * 2.5rem)',
          border: '1px solid #999',
        }}
      >
        {squares.map((square, squareIndex) => (
          <Square
            key={squareIndex}
            value={square}
            onSquareClick={() => handleClick(squareIndex)}
          />
        ))}
      </div>
    </>
  )
}

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const currentSquares = history[history.length - 1]

  function handlePlay(nextSquares) {
    setHistory(history.concat([nextSquares]))
    setXIsNext(!xIsNext)
  }

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  )
}

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 calculateTurns(squares) {
  return squares.filter((square) => !square).length
}

function calculateStatus(winner, turns, player) {
  if (!winner && !turns) return 'Draw'
  if (winner) return `Winner ${winner}`
  return `Next player: ${player}`
}

과거의 이동 내역 보여주기 (Showing the past moves)

자, 이제 틱택토 게임의 역사가 고스란히 저장되고 있으니 플레이어들에게 과거의 이동 내역들을 리스트로 보여줄 시간입니다.

우린 이미 history 배열을 가지고 있죠? 이제 이걸 화면에 표시할 수 있는 React 엘리먼트 배열로 샤라락 변환해주면 됩니다. 자바스크립트에서 어떤 배열을 다른 배열로 변환할 때 가장 많이 쓰는 단짝 친구가 바로 .map() 메서드입니다!

Game 컴포넌트 안에서 history 배열을 맵핑(map)하여 화면에 렌더링될 버튼(과거로 점프할 수 있는 버튼) 엘리먼트들로 둔갑시켜 볼게요.

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const currentSquares = history[history.length - 1]

  function handlePlay(nextSquares) {
    setHistory(history.concat([nextSquares]))
    setXIsNext(!xIsNext)
  }

  function jumpTo(nextMove) {
    // TODO
  }

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>
          {history.map((_, historyIndex) => {
            const description =
              historyIndex > 0
                ? `Go to move #${historyIndex}`
                : 'Go to game start'

            return (
              <li key={historyIndex}>
                <button onClick={() => jumpTo(historyIndex)}>
                  {description}
                </button>
              </li>
            )
          })}
        </ol>
      </div>
    </div>
  )
}

여기서 jumpTo 함수를 진짜로 구현하기 전에, 유저가 지금 정확히 '몇 번째 턴(스텝)'을 구경하고 있는지 Game 컴포넌트가 알아야 합니다. 이를 위해서 currentMove라는 새로운 상태 변수를 선언하고 기본값을 0으로 시작하게 해볼게요:

const useGameStore = create(
  combine(
    { history: [Array(9).fill(null)], currentMove: 0, xIsNext: true },
    (set) => {
      return {
        setHistory: (nextHistory) => {
          set((state) => ({
            history:
              typeof nextHistory === 'function'
                ? nextHistory(state.history)
                : nextHistory,
          }))
        },
        setCurrentMove: (nextCurrentMove) => {
          set((state) => ({
            currentMove:
              typeof nextCurrentMove === 'function'
                ? nextCurrentMove(state.currentMove)
                : nextCurrentMove,
          }))
        },
        setXIsNext: (nextXIsNext) => {
          set((state) => ({
            xIsNext:
              typeof nextXIsNext === 'function'
                ? nextXIsNext(state.xIsNext)
                : nextXIsNext,
          }))
        },
      }
    },
  ),
)

이제 Game 컴포넌트 안의 jumpTo 함수 안에서 방금 만든 currentMove 값을 업데이트 하도록 코드를 채워보겠습니다. 한 가지 더! 만약 여러분이 시간 여행을 하려는 그 시점(currentMove)이 짝수라면, xIsNexttrue로 설정해주어야 턴이 맞겠죠? (0번 턴은 X, 1번 턴은 O, 2번 턴은 X...)

function jumpTo(nextMove) {
  setCurrentMove(nextMove)
  setXIsNext(currentMove % 2 === 0)
}

이제 우리가 보드를 클릭할 때마다 실행되는 handlePlay 함수에 두 가지 마법 같은 변화를 줄 시간입니다.

  • 만약 여러분이 "과거로 시간 여행"을 간 상태에서 새로운 위치에 수를 둔다면, 미래의 역사는 지워지고 그 시점부터 새로운 역사가 쓰여야 합니다! 그래서 방금 전처럼 concat()으로 끝에 무조건 붙이는 게 아니라, history.slice(0, currentMove + 1)를 이용해서 내가 있는 그 시점까지만 역사를 잘라낸 뒤 그 뒤에 nextSquares를 붙여야 합니다.
  • 수를 새로 둘 때마다 최신 역사를 바라보도록 currentMove의 값도 새롭게 기록된 역사 배열의 맨 끝 인덱스로 업데이트 해줘야 합니다.
function handlePlay(nextSquares) {
  const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
  setHistory(nextHistory)
  setCurrentMove(nextHistory.length - 1)
  setXIsNext(!xIsNext)
}

마침내, Game 컴포넌트가 항상 게임의 맨 마지막 상황만 그리는 것이 아니라, 우리가 현재 선택한(currentMove) 시점의 상황을 제대로 보여주도록 수정합니다:

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const currentMove = useGameStore((state) => state.currentMove)
  const setCurrentMove = useGameStore((state) => state.setCurrentMove)
  const xIsNext = useGameStore((state) => state.xIsNext)
  const setXIsNext = useGameStore((state) => state.setXIsNext)
  const currentSquares = history[currentMove]

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

  function jumpTo(nextMove) {
    setCurrentMove(nextMove)
    setXIsNext(nextMove % 2 === 0)
  }

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>
          {history.map((_, historyIndex) => {
            const description =
              historyIndex > 0
                ? `Go to move #${historyIndex}`
                : 'Go to game start'

            return (
              <li key={historyIndex}>
                <button onClick={() => jumpTo(historyIndex)}>
                  {description}
                </button>
              </li>
            )
          })}
        </ol>
      </div>
    </div>
  )
}

마무리 정리 (Final cleanup)

코드를 매의 눈으로 한 번 스윽 살펴보시면 재미있는 사실을 발견할 수 있습니다. currentMove가 짝수일 때는 xIsNext가 무조건 true이고, 홀수일 때는 무조건 false입니다. 즉, currentMove의 값만 알면 xIsNext가 어떤 값이어야 하는지 우리가 항상 100% 알아낼 수 있다는 뜻이죠!

이럴 때는 굳이 xIsNext를 별도의 상태로 저장해 둘 필요가 전혀 없습니다. 중복된(파생될 수 있는) 상태를 피하면 버그가 생길 확률을 줄여주고 코드도 훨씬 깔끔해지기 마련입니다. currentMove를 가지고 계산식으로 알아내는 방법, 이른바 '파생 상태(Derived State)'를 써보겠습니다:

export default function Game() {
  const history = useGameStore((state) => state.history)
  const setHistory = useGameStore((state) => state.setHistory)
  const currentMove = useGameStore((state) => state.currentMove)
  const setCurrentMove = useGameStore((state) => state.setCurrentMove)
  const xIsNext = currentMove % 2 === 0
  const currentSquares = history[currentMove]

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

  function jumpTo(nextMove) {
    setCurrentMove(nextMove)
  }

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'row',
        fontFamily: 'monospace',
      }}
    >
      <div>
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div style={{ marginLeft: '1rem' }}>
        <ol>
          {history.map((_, historyIndex) => {
            const description =
              historyIndex > 0
                ? `Go to move #${historyIndex}`
                : 'Go to game start'

            return (
              <li key={historyIndex}>
                <button onClick={() => jumpTo(historyIndex)}>
                  {description}
                </button>
              </li>
            )
          })}
        </ol>
      </div>
    </div>
  )
}

💡 강사님의 꿀팁:
"어떤 상태 값으로 다른 상태를 충분히 계산해낼 수 있다면, 그건 상태(state)로 선언하지 마라!" 이것은 리액트 장인들이 항상 입버릇처럼 하는 말입니다. 불필요하게 xIsNext를 상태로 가지고 있다 보면 currentMove는 업데이트했는데 실수로 xIsNext는 업데이트하지 않는 인적 오류(버그)가 생길 수 있거든요. 이렇게 수식으로 해결하면 코드를 실수로 짜더라도 둘의 싱크가 어긋날 일은 영원히 사라지게 됩니다.

이제 xIsNext 상태 선언이나 setXIsNext를 부르는 함수들은 싹 날려버려도 됩니다. 더 이상 프로그래머가 실수하더라도 둘의 관계가 꼬여서 게임이 고장 날 일은 제로(0)입니다!


마치며 (Wrapping up)

정말 고생 많으셨습니다, 여러분! 성공적으로 이런 기능이 담긴 틱택토 게임을 직접 만들어내셨습니다:

  • 즐겁게 플레이할 수 있는 틱택토 게임판
  • 승리하거나 무승부가 되었을 때 알려주는 똑똑한 기능
  • 게임이 진행됨에 따라 역사를 차곡차곡 저장하는 메모리
  • 원하는 과거 시점의 보드로 슝 날아갈 수 있는 신기한 타임머신 기능까지!

수고하셨습니다. 이제 여러분은 React와 Zustand가 어떻게 맞물려 돌아가는지 그 기본기에 대해 훌륭한 감을 잡으셨을 거라 확신합니다!


이 페이지 수정하기

이전: 비교하기 (Comparison)
다음: 상태 업데이트하기 (Updating state)

profile
프론트에_가까운_풀스택_개발자

0개의 댓글