본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 튜토리얼을 통해 간단한 Tic-Tac-Toe 게임을 구현합니다. 기존의 React 지식을 요구하지 않으며 구현을 통해 React 구축에 기본이 되는 개념들을 이해할 수 있습니다. 튜토리얼은 4가지 섹션으로 나누어져 있습니다.

  1. 튜토리얼 설정은 튜토리얼을 따라가기 위한 시작점을 제공합니다.
  2. 구현하기에서는 React의 기본요소인 컴포넌트, props, state에 대해 공부합니다.
  3. 게임 완성에서는 React 개발에 대해 가장 기본적인 기술을 공부합니다.
  4. 시간여행 추가에서는 React의 고유한 강점에 대해 더욱 깊게 알아봅니다.

앞선 1,2 섹션은 이전 글을 참고하시길 바랍니다.

3. 게임 완료하기

 이제 tic-tac-toe 게임을 위한 모든 기본 구성 요소가 완성되었습니다. 완전한 게임을 위해서는 이제 보드에 "X"와 "O"를 번갈아 배치해야 하며 승자를 결정하는 방법이 필요합니다.

 

3.1 상태 끌어올리기

현재 각 Square 컴포넌트는 게임의 일부 상태를 유지합니다. 틱택톡 게임에서 승자를 확인하려면 Board가 9개의 Square 컴포넌트 각각의 상태를 알아야합니다. 어떻게 접근하면 좋을까요? 먼저, Board가 각 Square에게 해당 상태를 Request해야한다고 생각할 수 있습니다. 이 접근방식은 React에서 기술적을 가능하지만 코드가 이해하기 어려워지고 버그발생과 리팩토링를 하기 어려워지기에 권장하지 않습니다. 최선의 방법은 각 Square가 아닌 부모 Board 컴포넌트에게 게임 상태를 저장하는 것입니다. Board 컴포넌트는 각 Square에게 숫자를 전달했을 떄와 같이 props를 통해 어떤 것을 표시할지 알려줄 수 있습니다.

 여러 하위 컴포넌트에서 데이터를 수집하거나 두 하위 컴포넌트가 서로 통신하도록 하려면 대신 상위 컴포넌트에서 공유 상태를 선언하세요. 상위 컴포넌트는 props를 통해 해당 상태를 하위 컴포넌트로 다시 전달할 수 있습니다. 이렇게 하면 하위 컴포넌트가 서로 및 해당 상위 컴포넌트와 동기화됩니다. 9개의 사각형에 해당하는 9개의 null 배열을 기본으로 하는 상태 변수를 선언하도록  Board 컴포넌트를 편집합니다.

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

 

Array(9).fill(null)은 아홉 개의 요소가 있는 배열을 생성하고 각각을 null로 설정합니다, 그 주위의 useState() 호출은 초기 값으로 해당 배열이 설정된 squares 상태 변수를 선언합니다. 배열의 각 항목은 사각형의 값에 해당합니다. 이후 Board컴포넌트에서 렌더링하는 각 Square 항목에 value props 을 전달합니다.

export default function Board() {
  const [squares, setSquares] = 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>
    </>
  );
}

다음으로, Square 컴포넌트를 편집하여 Board 컴포넌트에서 value prop을 받도록합니다. 이를 위해 Square 컴포넌트의 자체 상태 추적 및 버튼의 onClick prop을 제거해야합니다.

 

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

이제 각 Square는 'X', 'O' 또는 빈 사각형에 대한 null이 될 value prop을 받게됩니다. 다음으로, Square가 클릭될 때 발생하는 일을 변경해야합니다. Board 컴포넌트는 이제 어떤 사각형이 채워졌는지 유지합니다. Square가 Board의 상태를 업데이트하는 방법을 만들어야합니다. 상태는 정의한 컴포넌트에 대해 비공개이므로 Square에서 Board의 상태를 직접 업데이트할 수 없습니다.

대신에, Board 컴포넌트에서 Square 컴포넌트로 함수를 전달하고, Square가 클릭되었을 때 해당 함수를 호출하도록 만들 것입니다. Square 컴포넌트가 클릭될 때 호출 할 함수로 시작합니다. 해당 함수를 onSquareClick으로 호출할 것입니다.

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

이제 onSquareClick prop을 Board 컴포넌트의 handleClick이라는 함수에 연결할 것입니다. onSquareClick을 handleClick에 연결하기 위해 첫 번째 Square 컴포넌트의 onSquareClick prop에 함수를 전달합니다

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

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={handleClick} />
        //...
  );
}

 

마지막으로, Board 컴포넌트 내에서 handleClick 함수를 정의하여 보드의 상태를 저장하는 squares 배열을 업데이트할 것입니다.

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

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

  return (
    // ...
  )
}

 handleClick 함수는 JavaScript의 slice() 배열 메서드를 사용하여 squares 배열의 사본 (nextSquares)을 생성합니다. 그런 다음,handleClick은 nextSquares 배열을 업데이트하여 첫 번째 ([0] 인덱스) 사각형에 X를 추가합니다.setSquares 함수를 호출하면 React에게 컴포넌트의 상태가 변경되었음을 알립니다. 이로 인해 squares 상태를 사용하는 컴포넌트 (Board) 및 해당 하위 컴포넌트 (보드를 구성하는 Square 컴포넌트)의 다시 렌더링이 트리거됩니다.

  • JavaScript는 클로저를 지원합니다. 이는 내부 함수 (예: handleClick)가 외부 함수 (예: Board)에 정의된 변수와 함수에 접근할 수 있음을 의미합니다. handleClick 함수는 squares 상태를 읽고 setSquares 메서드를 호출할 수 있습니다. 이는 두 함수 모두 Board 함수 내에서 정의되었기 때문입니다.

handleClick가 어떤 사각형이든 업데이트할 수 있도록 업데이트 할 사각형의 인덱스를 취하는 인수 i를 추가합니다.

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

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

  return (
    // ...
  )
}

 

다음으로, 그 i를 handleClick에 전달해야합니다. JSX에서 onSquareClick prop을 handleClick(0)으로 직접 설정해보실 수 있지만 다음과 같이 하면 작동하지 않습니다.

<Square value={squares[0]} onSquareClick={handleClick(0)} />

이렇게 하면 작동하지 않는 이유는 handleClick(0) 호출이 보드 컴포넌트를 렌더링하는 일부가 될 것이기 때문입니다. handleClick(0)은 setSquares를 호출하여 보드 컴포넌트의 상태를 변경합니다. 따라서 전체 보드 컴포넌트가 다시 렌더링됩니다. 하지만 이는 handleClick(0)을 다시 실행하여 무한 루프에 이르게합니다.

 

이 문제가 이전에 발생하지 않은 이유는 onSquareClick={handleClick}를 전달할 때 handleClick 함수를 prop으로 전달했기 때문입니다. 이는 실제로 함수를 호출하는 것이 아니였습니다. 그러나방금의 코드는 함수를 바로 호출하고 있습니다. 즉, handleClick(0)에서 괄호를 볼 수 있습니다. 이 문제를 해결하려면 handleClick(0)을 호출하는 handleFirstSquareClick과 같은 함수를 만들거나 handleSecondSquareClick과 같은 함수를 만들어야합니다. 그런 다음 onSquareClick={handleFirstSquareClick}과 같은 형태로 이러한 함수를 prop으로 전달합니다. 이렇게하면 무한 루프가 해결됩니다.

그러나 아홉 개의 서로 다른 함수를 정의하고 각각에 이름을 지정하는 것은 너무 장황합니다. 대신 화살표 함수를 사용해 봅시다.

export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        // ...
  );
}

 이제 상태 처리가 Board 컴포넌트에 있으므로 부모 Board 컴포넌트는 자식 Square 컴포넌트에게 올바르게 표시될 수 있도록 props를 전달합니다. 사각형을 클릭하면 자식 Square 컴포넌트가 상태를 업데이트하기 위해 부모 Board 컴포넌트에게 요청합니다. Board의 상태가 변경되면 Board 컴포넌트와 모든 자식 Square가 자동으로 다시 렌더링됩니다. Board 컴포넌트에 모든 사각형의 상태를 유지하면서 나중에 승자를 결정할 수 있습니다. 사용자가 보드의 왼쪽 상단 사각형을 클릭하여 X를 추가하는 과정을 요약해보겠습니다.

  1. 상단 왼쪽 사각형을 클릭하면 버튼이 onClick prop으로 전달 받은 함수가 실행됩니다. Square 컴포넌트는 이 함수를 Board로부터 onSquareClick을 prop으로 전달 받았습니다. Board 컴포넌트는 이 함수를 JSX에서 직접 정의했습니다. 이 함수는 handleClick을 0 인수와 함께 호출합니다.
  2. handleClick은 인수(0)를 사용하여 squares 배열의 첫 번째 요소를 null에서 X로 업데이트합니다.
  3. Board 컴포넌트의 squares 상태가 업데이트되었으므로 Board와 모든 자식이 다시 렌더링됩니다. 이로 인해 인덱스 0의 Square 컴포넌트의 value prop이 null에서 X로 변경됩니다.
  4. 사용자는 상단 왼쪽 사각형을 클릭한 후 빈 상태에서 X로 변경된 것을 확인할 수 있습니다.
  • DOM의 <button> 요소의 onClick 속성은 내장 컴포넌트이기 때문에 React에서 특별한 의미를 갖습니다. Square와 같은 사용자 지정 컴포넌트의 경우, 이름 지정은 사용자 마음입니다. Square의 onSquareClick prop이나 Board의 handleClick 함수에 아무 이름을 부여해도 코드가 동일하게 작동합니다. React에서는 이벤트를 나타내는 props에 대해 onSomething 이름을 사용하고 해당 이벤트를 처리하는 함수 정의에 대해서는 handleSomething을 사용하는 것이 관례입니다.

3.2 불변성의 중요성

 handleClick에서 기존 배열을 수정하는 대신 .slice()를 호출하여 squares 배열의 사본을 생성하는 방법에 주목하세요. 이를 설명하기 위해서는 불변성과 불변성이 왜 중요한지에 대해 논의해야합니다. 데이터를 변경하는 데 일반적으로 두 가지 접근 방법이 있습니다. 첫 번째 접근 방법은 데이터의 값을 직접 변경하여 데이터를 변경하는 것입니다. 두 번째 접근 방법은 원하는 변경 사항이 있는 새로운 사본으로 데이터를 교체하는 것입니다. 결과는 동일하지만 직접 데이터를 변형하지 않는다면 여러 가지 이점을 얻을 수 있습니다.

const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
setSquare(nextSquares);
  1. 복잡한 기능을 훨씬 쉽게 구현할 수 있습니다. 튜토리얼 후반부터는 게임의 이력을 검토하고 과거로 돌아가는 "타임 트래블" 기능을 구현합니다. 이는 게임에만 특정한 것이 아닙니다. 특정 작업을 실행 취소하고 다시 실행하는 기능은 앱에서 일반적으로 요구되는 기능입니다. 직접 데이터 변형을 피함으로써 이전 데이터 버전을 그대로 유지하고 나중에 재사용할 수 있습니다.
  2. 컴포넌트가 데이터가 변경되었는지 여부를 비교하는 것을 매우 쉽게 만듭니다. 기본적으로 부모 컴포넌트의 상태가 변경되면 모든 하위 컴포넌트가 자동으로 다시 렌더링됩니다. 이 변경 사항에 영향을받지 않은 하위 컴포넌트도 포함됩니다.불변성을 유지함으로써 성능을 최적화할 수 있습니다. 불변성은 데이터 변경 여부를 쉽게 비교할 수 있도록 해줍니다. 변경된 데이터와 이전 데이터를 비교하여 실제 변경된 부분만을 재렌더링할 수 있습니다.

3.3 차례나누기

 현재 게임은  "O"를 표시할 수 없습니다. 이를 수정하기 위해서 첫 번째 동작을 기본적으로 "X"로 설정할 것입니다. Board 컴포넌트에 다른 상태를 추가하여 이를 추적해 봅시다.

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

  // ...
}

각 플레이어가 클릭할 때마다, xIsNext(boolean 값)는 다음에 어떤 플레이어가 진행될지를 결정하기 위해 반전됩니다. Board의 handleClick 함수를 업데이트하여 xIsNext의 값을 반전시킬 것입니다.

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

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

  return (
    //...
  );
}

이제 다른 사각형을 클릭할 때마다 X와 O가 번갈아가며 표시됩니다. 그러나 문제가 있습니다. 동일한 사각형을 여러 번 클릭하면 이미 채워진 사각형 X가 O에 덮어씌워집니다. X 또는 O로 사각형을 표시할 때 사각형에 이미 X 또는 O 값이 있는지 확인하지 않고 있습니다. 이 문제를 해결하려면 먼저 조기 반환을 수행해야 합니다. handleClick 함수 내에서 보드 상태를 업데이트하기 전에 사각형에 이미 X 또는 O가 있는지 확인할 것입니다.

function handleClick(i) {
  if (squares[i]) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

 

3.4 승자 판별

 플레이어가 순서대로 수를 둘 수 있게 되었으므로, 게임이 이겼을 때와 더 이상 수를 둘 수 없는 경우를 표시하고 싶을 것입니다. 이를 위해 calculateWinner라는 도우미 함수를 추가할 것입니다. 이 함수는 9개의 사각형 배열을 가져와 승자를 확인하고 적절한 'X', 'O' 또는 null을 반환합니다. 

export default function Board() {
  //...
}

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;
}
  • calculateWinner 함수를 Board 컴포넌트 이전에 정의하든 이후에 정의하든 상관없습니다. 매번 컴포넌트를 편집할 때마다 스크롤을 내려야 하는 번거로움을 피하기 위해 끝에 위치시키겠습니다.

 calculateWinner(squares) 함수를 Board 컴포넌트의 handleClick 함수에서 호출하여 플레이어가 이겼는지 확인할 것입니다. 이미 X 또는 O가 있는 사각형을 클릭했는지 확인하는 동시에 이 확인을 수행할 수 있습니다. 우리는 두 경우 모두 조기 반환하는 것을 원합니다.

function handleClick(i) {
  if (squares[i] || calculateWinner(squares)) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

 게임이 종료된 경우 "Winner: X" 또는 "Winner: O"와 같은 텍스트를 플레이어에게 표시하여 알려줄 수 있습니다. 이를 위해 Board 컴포넌트에 상태 섹션을 추가할 것입니다. 상태는 게임이 종료된 경우 승자를 표시하고, 게임이 진행 중인 경우 다음 플레이어의 차례를 표시할 것입니다.

export default function Board() {
  // ...
  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">
        // ...
  )
}

이로써 틱택톡 게임과 React의 기본 구현에 대해 배웠습니다.

+ Recent posts