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

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

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

앞선 1,2 섹션은 Tutorial : Tic - Tac -Toe(1) , 3섹션은 Tutorial : Tic - Tac -Toe(2)를 참고하시길 바랍니다.

4. 시간 여행 추가

 마지막으로, 이전 게임 움직임으로 "시간을 되돌리는" 것이 가능하도록 만들어 보겠습니다.

 

4.1 수의 기록 저장하기

 만약 직접적으로 squares 배열을 변경했다면, 시간 여행을 구현하는 것은 매우 어려웠을 것입니다. 그러나 매수마다 slice()를 이용해 배열의 복사본을 만들어 불변성을 유지하였습니다. 이렇게 하면 square 배열의 과거 버전을 모두 저장하고, 이미 발생한 수들 사이를 이동할 수 있습니다. 과거의 squares 배열을 다른 배열인 history에 저장할 것입니다. 이를 새로운 상태 변수로 저장할 것입니다. history 배열은 첫 번째 움직임부터 마지막 움직임까지의 모든 보드 상태를 나타내며, 다음과 같은 형태를 가지고 있습니다

[
  // Before first move
  [null, null, null, null, null, null, null, null, null],
  // After first move
  [null, null, null, null, 'X', null, null, null, null],
  // After second move
  [null, null, null, null, 'X', null, null, null, 'O'],
  // ...
]

4.2 상태 끌어올리기

이제 과거 움직임 목록을 표시할 새로운 최상위 컴포넌트인 Game을 작성할 것입니다. 여기에는 전체 게임 이력을 포함하는 history 상태가 위치합니다. Game 컴포넌트에 history 상태를 배치하면 자식인 Board 컴포넌트에서 squares 상태를 제거할 수 있습니다. 마치 Square 컴포넌트에서 Board 컴포넌트로 상태를 '올리는 것'과 같이, 이제는 Board에서 최상위 Game 컴포넌트로 상태를 '올립니다'. 이렇게 하면 Game 컴포넌트가 Board의 데이터를 완전히 제어하고 이전 턴을 history에서 가져와 Board에 렌더링할 수 있습니다.

 

 export default 키워드가 function Board() 선언 앞에서 삭제되고, function Game() 선언 앞에 추가됩니다. 이는 index.js 파일이 보드 컴포넌트 대신 게임 컴포넌트를 최상위 컴포넌트로 사용하도록 지시합니다. 게임 컴포넌트에서 반환된 추가적인 div는 이후 보드에 추가할 게임 정보에 대한 공간을 확보합니다.

function Board() {
  // ...
}

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

 Game 컴포넌트에 상태를 추가하여 다음 플레이어와 움직임의 히스토리를 추적합니다. 현재 움직임의 사각형을 렌더링하려면 history에서 마지막 squares 배열을 읽어야 합니다. 이를 위해 렌더링 중에 계산할 정보가 이미 충분하기 때문에 useState가 필요하지 않습니다. 

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

 다음으로, Game 컴포넌트 내에 handlePlay 함수를 생성하십시오. 이 함수는 Board 컴포넌트에서 호출되어 게임을 업데이트합니다. xIsNext, currentSquares 및 handlePlay를 props로 Board 컴포넌트에 전달하십시오.

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(nextSquares) {
    // TODO
  }

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

 Board 컴포넌트를 완전히 props에 의해 제어되도록 만들어 보겠습니다. Board 컴포넌트를 변경하여 세 가지 props를 받도록 합니다. xIsNext, squares 및 새로운 onPlay 함수입니다. 이 함수는 플레이어가 이동을 수행할 때 Board가 호출할 수 있도록 업데이트된 squares 배열을 전달해야 합니다. 그 다음, useState를 호출하는 첫 두 줄을 제거하세요.

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    //...
  }
  // ...
}

 이제 Board 컴포넌트의 handleClick에서 setSquares 및 setXIsNext 호출을 새로운 onPlay 함수로 대체하여 사용자가 사각형을 클릭할 때 Game 컴포넌트가 Board를 업데이트할 수 있도록합니다.

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

 Board 컴포넌트는 Game 컴포넌트에서 전달된 props에 의해 완전히 제어됩니다. 게임을 다시 작동하려면 게임 컴포넌트에서 handlePlay 함수를 구현해야 합니다. handlePlay함수가 호출될때 무엇을 해야 할까요? Board가 이제 업데이트된 배열을 onPlay에 전달하는 것으로 바뀌었습니다. handlePlay 함수는 게임의 상태를 업데이트하여 다시 렌더링을 트리거해야 합니다. 이제 더 이상 호출할 setSquares 함수가 없으므로 이제는 이 정보를 저장하기 위해 history 상태 변수를 사용합니다. 업데이트된 squares 배열을 새로운 history 항목으로 추가하여 history를 업데이트해야 합니다. 또한 Board가 수행했던 것처럼 xIsNext를 토글해야 합니다.

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

여기서 [...history, nextSquares]는 history에 있는 모든 항목 뒤에 nextSquares가 오는 새 배열을 생성합니다. (...history 펼침 구문을 "history에 있는 모든 항목을 나열하라"로 읽을 수 있습니다.)

 

4.3 이전 수들 보여주기

 틱택토 게임의 이력을 기록하고 있으므로 이제 플레이어에게 지난 수 목록을 표시할 수 있습니다. <button>과 같은 React의 요소들은 일반적인 JavaScript 객체입니다. React에서 여러 항목을 렌더링하려면 React 요소의 배열을 사용할 수 있습니다. 이미 상태에 이동 이력의 배열이 있으므로 이제 이를 React 요소의 배열로 변환해야 합니다. JavaScript에서는 배열 map 메서드를 사용하여 하나의 배열을 다른 배열로 변환할 수 있습니다.

 Game 컴포넌트에서 이동 이력을 React 요소로 나타내는 버튼으로 변환하기 위해 map을 사용하고, 이전 수로 "이동"할 수 있는 버튼 목록을 표시할 것입니다. 이제 Game 컴포넌트에서 이력을 매핑하겠습니다.

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(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  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">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

 이렇게 된다면 Warning: Each child in an array or iterator should have a unique “key” prop. Check the render method of `Game`. 이라는 오류가 표시됩니다. 다음 섹션에서 이 오류를 고칠예정입니다.

 

4.4 키 이해하기

리스트를 렌더링할 때 React는 각 렌더링된 리스트 아이템에 대한 정보를 저장합니다. 리스트를 업데이트할 때 React는 변경된 사항을 판별해야 합니다. 그래야만 리스트의 아이템을 추가, 제거, 재배열 또는 업데이트할 수 있습니다.

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>
// to
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

 변경된 카운트 외에, 사람이 이를 읽을 때는 아마도 Alexa와 Ben의 순서를 바꾸고, Alexa와 Ben 사이에 Claudia를 삽입했다고 할 것입니다. 그러나 React는 컴퓨터 프로그램이므로 의도한 대로 작동하기 위해 각 리스트 항목에 대한 키 속성을 지정해야 합니다. 데이터가 데이터베이스에서 가져온 것이라면, Alexa, Ben 및 Claudia의 데이터베이스 ID를 각 리스트 항목의 키로 사용할 수 있습니다.

<li key={user.id}>
  {user.name}: {user.taskCount} tasks left
</li>
 

 리스트가 다시 렌더링될 때, React는 각 리스트 항목의 키를 가져와 이전 리스트의 항목에서 해당 키와 일치하는 것을 찾습니다. 현재 리스트에 이전에 없던 키가 있는 경우, React는 컴포넌트를 생성합니다. 현재 리스트에 이전 리스트에 있던 키가 누락된 경우, React는 이전 컴포넌트를 파괴합니다. 두 키가 일치하는 경우, 해당 컴포넌트가 이동됩니다.

 key는 React에서 특별하고 예약된 속성입니다. 요소가 생성될 때, React는 key 속성을 추출하여 반환된 요소에 직접 저장합니다. key가 props로 전달된 것처럼 보이지만, React는 자동으로 key를 사용하여 어떤 컴포넌트를 업데이트할지 결정합니다. 컴포넌트가 부모가 지정한 키를 물어볼 수 있는 방법은 없습니다. 동적 리스트를 작성할 때 적절한 키를 할당하는 것이 강력히 권장됩니다. 적절한 키가 없는 경우, 데이터를 재구성하여 적절한 키를 가질 수 있도록 고려해야 합니다.

 만약 키가 지정되지 않았다면, React는 오류를 보고하고 기본적으로 배열 인덱스를 키로 사용합니다. 배열 인덱스를 키로 사용하는 것은 리스트 항목을 재배열하거나 리스트 항목을 삽입/제거할 때 문제가 발생할 수 있습니다. 명시적으로 key={i}를 전달하면 오류가 해결되지만, 배열 인덱스와 같은 문제가 발생하며 대부분의 경우 권장되지 않습니다.키는 전역적으로 고유할 필요가 없습니다. 컴포넌트와 해당 형제 간에만 고유하면 됩니다.

 

4.5 시간여행 삽입하기

틱택토 게임의 히스토리에서 각 이전 수는 해당하는 고유한 ID를 가지고 있습니다. 이것은 수의 연속된 번호입니다. 수는 결코 재정렬되거나 삭제되거나 중간에 삽입되지 않으므로 인덱스를 키로 사용하는 것이 안전합니다. Game 함수에서 키를 다음과 같이 추가할 수 있습니다: <li key={move}>. 그리고 렌더링된 게임을 다시로드하면 React의 "key" 오류가 사라져야 합니다.

const moves = history.map((squares, move) => {
  //...
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});
 jumpTo를 구현하기 전에 사용자가 현재 보는 단계를 추적하도록 Game 컴포넌트를 준비해야 합니다. 이를 위해 기본값이 0인 currentMove라는 새로운 상태 변수를 정의하십시오.
export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[history.length - 1];
  //...
}
 

 다음으로, Game 내부의 jumpTo 함수를 업데이트하여 currentMove를 업데이트해야 합니다. 또한, currentMove를 변경하는 숫자가 짝수일 경우 xIsNext를 true로 설정해야 합니다.

export default function Game() {
  // ...
  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }
  //...
}

 

이제 클릭한 square에 대한 handlePlay 함수는 Game에 두 가지 변경점를 만들 것입니다.
  1. "과거로 돌아가"서 그 이후 새로운 수를 만든 경우, 해당 지점까지의 히스토리만 유지하려고 합니다. 따라서 history의 모든 항목 (... spread syntax) 이후가 아니라 history.slice(0, currentMove + 1) 이후에 nextSquares를 추가합니다. 이렇게 하면 이전 히스토리의 해당 부분만 유지됩니다.
  2. 움직임이 만들어질 때마다, currentMove를 가장 최신의 히스토리 항목을 가리키도록 업데이트해야 합니다.
function handlePlay(nextSquares) {
  const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
  setHistory(nextHistory);
  setCurrentMove(nextHistory.length - 1);
  setXIsNext(!xIsNext);
}

 

마지막으로, Game 컴포넌트를 수정하여 항상 최종 움직임을 렌더링하는 대신 현재 선택된 움직임을 렌더링하도록 변경할 것입니다.
export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

  // ...
}

 게임의 히스토리에서 어떤 단계를 클릭하면, 틱택토 보드는 즉시 해당 단계가 발생한 후 보드가 어떻게 보였는지 업데이트되어야 합니다.

4.6 마무리 작업

코드를 매우 주의 깊게 살펴보면, currentMove가 짝수일 때 xIsNext === true이고, currentMove가 홀수일 때 xIsNext === false임을 알 수 있습니다. 즉, currentMove의 값을 알고 있다면 언제나 xIsNext가 어떻게 되어야 하는지 알 수 있습니다. 두 가지를 모두 상태에 저장할 필요가 없습니다. 사실, 중복된 상태를 피하는 것이 좋습니다. 상태에 저장하는 내용을 단순화하면 버그가 줄어들고 코드를 이해하기 쉬워집니다. 따라서 Game이 xIsNext를 별도의 상태 변수로 저장하지 않고 현재의 움직임을 기반으로 계산하도록 변경하세요.

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(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

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

 이제 xIsNext 상태 선언이나 setXIsNext 호출이 더이상 필요하지 않습니다. 이제는 컴포넌트를 코딩하는 동안 실수를 해도 xIsNext가 currentMove와 동기화되지 않을 가능성이 없습니다.

4.7 끝마치며

축하합니다! 여러분은 틱택토 게임을 만들었습니다. 이 게임은 다음과 같은 기능을 제공합니다:

  • 틱택토를 플레이할 수 있습니다.
  • 플레이어가 게임에서 이겼을 때를 알려줍니다.
  • 게임이 진행됨에 따라 게임의 히스토리를 저장합니다.
  • 플레이어가 게임의 히스토리를 검토하고 이전 버전의 게임 보드를 볼 수 있습니다.

잘 하셨습니다! 이제 여러분은 React가 어떻게 작동하는지에 대해 꽤 괜찮은 이해를 가지고 있다고 느낄 것입니다. 여유 시간이 있거나 새로운 React 기술을 연습하고 싶다면, 틱택토 게임을 개선할 수 있는 몇 가지 아이디어가 있습니다. 이는 난이도가 증가하는 순서대로 나열되어 있습니다:

  1. 현재 움직임만 표시하는 대신 버튼 대신 "당신은 #번째 움직임에 있습니다..."을 표시합니다.
  2. 제곱형을 하드코딩하는 대신 두 개의 루프를 사용하여 보드를 다시 작성합니다.
  3. 오름차순 또는 내림차순으로 움직임을 정렬할 수 있는 토글 버튼을 추가합니다.
  4. 누군가가 이기면 이긴 세 개의 제곱형을 강조 표시합니다(누가 이기지 않으면 결과가 무승부임을 메시지로 표시합니다).
  5. 이동 히스토리 목록에 각 움직임의 위치를 (행, 열) 형식으로 표시합니다.

 이 튜토리얼을 통해 요소(elements), 컴포넌트(components), 프롭스(props), 상태(state)와 같은 React 개념에 대해 다루었습니다. 이러한 개념이 게임을 만들 때 어떻게 작동하는지 보았으므로, Thinking in React를 확인하여 같은 React 개념이 앱의 UI를 구축할 때 어떻게 작동하는지 살펴보세요.

 

 

 

+ Recent posts