해당 프로젝트는 틱택토 튜토리얼를 따라만든 것입니다.
로컬 환경에서 리액트 앱을 생성하든 온라인 편집기를 이용하든 초기 프로젝트는 다음과 같은 구조를 갖는다.
├── 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.js
는 App.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
내용을 복붙하여 사용하는 것을 추천한다.
위의 사진은 최종 산출물을 캡처한 것이다. 보드판은 총 9개의 정사각형 버튼이 필요하다.
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개의 버튼을 포함한다.
첫번째 산출물은 보드판의 칸에 고유 번호를 붙인 것이다. 하지만 실제 틱택토 게임에서는 다른 로직을 사용해야 한다. 초기 상태의 버튼은 아무 내용을 가지고 있지 않다가 사용자가 클릭하면 X 상태로 표기한다. 이벤트와 useState
를 사용하여 컴포넌트의 상태를 변경해보자.
onClick
이벤트 추가하기// (사용자가 버튼을 클릭하면 handleClick 함수가 실행된다)
<button className="square" onClick={handleClick}>
{value}
</button>
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
useState
는 컴포넌트가 정보를 기억하도록 만든다.value
는 값을 저장하는 변수고setValue
는value
를 변경하는 함수다.
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>
</>
);
}
이전 단계에서 틱택토 보드판을 완성했다. 게임을 완성하기 위해서 O/X를 번갈아서 배치하고 승자를 결정하는 방법이 필요하다.
각 Square
컴포넌트는 상태를 가지고 있다. 틱택토 게임의 승자를 확인하기 위해서는 Board
가 9개의 자식 컴포넌트의 상태를 알아야 한다.
Board
가 Square
의 정보를 조회하는 방법에 대해 고민해보자. 첫번째 방법은 Board
가 Square
의 상태를 물어보는 것이다. 하지만 해당 방법은 코드가 복잡해지는 단점이 있다. 두번째 방법은 Square
대신 Board
에 상태 정보를 저장하는 것이다. Board
가 Square
의 상태 정보를 저장하고 Square
에 prop으로 화면에 표시할 내용을 전달한다.
Board
에서 Square
상태 물어보기Board
가 Square
의 상태 정보를 저장하고 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
를 호출하여 컴포넌트의 상태를 변경하므로 컴포넌트를 리렌더링하는 호출이다.
이제 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;
}
...
}
이제 게임의 승자를 가려보자.
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>
</>
);
}
클릭 이벤트가 발생할 때마다 승자가 있는지 검사하고, 승자가 나와서 이미 게임이 끝난 경우에는 이벤트가 발생하지 않도록 한다.
앞에서 언급한 불변성을 이용하여 과거 상태로 되돌려보자. 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>
);
}
Game
의 game-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>
);
}