튜토리얼: 틱택토 게임
- Published on
- 리액트 지식 없이 틱택토 게임을 만들며 이를 통해 리액트의 핵심 개념을 깊게 이해할 수 있습니다.
Table of Contents
이 튜토리얼은 리액트에 대한 기존 지식을 요구하지 않습니다. 튜토리얼에서 배우게 될 기술은 모든 리액트 앱을 구축하는 데 필수적이며, 이를 완전히 이해하면 리액트에 대한 깊은 이해를 갖게 될 것입니다.
이 튜토리얼은 직접 해보면서 학습하는 것을 선호하는 사람들과 빠르게 무언가 구체적인 것을 만들어 보고자 하는 사람들을 위해 설계되었습니다. 각 개념을 단계별로 배우는 것을 선호한다면 "UI 설명"부터 시작하시면 됩니다.
무엇을 만들고 있는 거죠?
이 튜토리얼에서는 리액트를 사용하여 상호작용하는 틱택토 게임을 만들 것입니다.
아직 코드가 이해되지 않거나 코드의 구문에 익숙하지 않다면 걱정하지 마세요! 이 튜토리얼의 목표는 리액트와 그 구문을 이해하는 데 도움을 주는 것입니다.
튜토리얼을 계속 진행하기 전에 위의 틱택토 게임을 확인하는 것을 권장합니다. 게임 보드 오른쪽에는 번호가 매겨진 목록이 있습니다. 이 목록은 게임에서 발생한 모든 움직임의 기록을 제공하며, 게임이 진행되면 업데이트됩니다.
완성된 틱택토 게임을 테스트해보신 후 계속해서 진행해 주세요. 이 튜토리얼에서는 더 간단한 템플릿으로 시작하게 될 것입니다. 다음 단계에서는 게임을 구축할 수 있도록 준비해드릴 예정입니다.
튜토리얼을 위한 설정
아래의 라이브 코드 편집기에서 오른쪽 상단의 Fork를 클릭하여 CodeSandbox 웹사이트에서 새 탭으로 편집기를 열어주세요. CodeSandbox를 사용하면 브라우저에서 코드를 작성하고 사용자가 생성한 앱을 미리 볼 수 있습니다. 새 탭에는 빈 사각형과 이 튜토리얼을 위한 시작 코드가 표시될 것입니다.
개요
설정이 완료되었으므로 리액트의 개요를 알아봅시다!
시작 코드 검사
CodeSandbox에서는 세 가지 주요 섹션을 볼 수 있습니다.
- 코드가 있는 CodeSandbox
- 파일 섹션에는 App.js, index.js, styles.css 및 public이라는 폴더와 같은 파일 목록이 있습니다.
- 코드 편집기에는 선택한 파일의 소스 코드가 표시됩니다.
- 브라우저 섹션에는 작성한 코드가 표시됩니다.
파일 섹션에서 App.js 파일을 선택해야 합니다. 코드 편집기에는 다음과 같은 내용이 표시됩니다.
export default function Square() {
return <button className="square">X</button>;
}
브라우저 섹션에는 다음과 같이 X가 있는 사각형이 표시됩니다.
이제 시작 코드의 파일을 살펴보겠습니다.
App.js
App.js 파일의 코드는 컴포넌트를 생성합니다. 리액트에서 컴포넌트는 사용자 인터페이스의 일부를 나타내는 재사용 가능한 코드 조각입니다. 컴포넌트는 애플리케이션의 UI 요소를 렌더링, 관리 및 업데이트하는 데 사용됩니다. 컴포넌트를 줄 단위로 살펴보면 어떤 일이 일어나는지 알아봅시다.
export default function Square() {
return <button className="square">X</button>;
}
첫 번째 줄은 Square라는 함수를 정의합니다. export 키워드는 이 함수가 이 파일 외부에서 접근 가능하도록 만듭니다. default 키워드는 코드를 사용하는 다른 파일에서 이 함수가 주 파일임을 나타냅니다.
두 번째 줄은 버튼을 반환합니다. return 키워드는 뒤에 오는 내용을 함수의 호출자에게 반환하는 값을 나타냅니다. <button>
은 JSX 요소입니다. JSX 요소는 JavaScript 코드와 HTML 태그의 조합으로, 표시하려는 내용을 설명합니다. className="square"
는 버튼의 스타일을 지정하는 버튼 속성이거나 prop입니다. X는 버튼 안에 표시되는 텍스트이며, </button>
는 JSX 요소를 닫아서 버튼 내부에 이어지는 내용을 표시하지 않음을 나타냅니다.
styles.css
CodeSandbox의 파일 섹션에서 styles.css라고 표시된 파일을 클릭하세요. 이 파일은 리액트 앱의 스타일을 정의합니다. 처음 두 개의 CSS 선택기 (* 및 body)는 앱의 큰 부분의 스타일을 정의하며, .square 선택기는 className 속성이 square로 설정된 모든 컴포넌트의 스타일을 정의합니다. 코드에서는 App.js 파일의 Square 컴포넌트의 버튼과 일치합니다.
index.js
CodeSandbox의 파일 섹션에서 index.js라고 표시된 파일을 클릭하세요. 이 튜토리얼에서는 이 파일을 편집하지 않지만 App.js 파일에서 생성한 컴포넌트와 웹 브라우저 간의 연결을 담당합니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';
1-5번 줄은 필요한 모든 구성 요소를 가져옵니다.
- 리액트
- 웹 브라우저와 대화하기 위한 리액트의 라이브러리(리액트 DOM)
- 컴포넌트의 스타일
- App.js에서 생성한 컴포넌트
파일의 나머지 부분은 모든 구성 요소를 모아 최종 결과물을 index.html 파일로 주입하는 역할을 합니다.
게시판 만들기
App.js로 돌아가 봅시다. 여기서 튜토리얼의 나머지 부분을 진행할 것입니다.
현재 게시판은 하나의 사각형만 있습니다. 하지만 9개가 필요합니다! 단순히 사각형을 복사하여 두 개의 사각형을 만들려고 하면 다음과 같은 오류가 발생합니다.
export default function Square() {
return <button className="square">X</button>
<button className="square">X</button>;
}
에러 메시지:
/src/App.js: Adjacent JSX elements must be wrapped in an enclosing tag.
Did you want a JSX fragment <>...</>?
리액트 컴포넌트는 하나의 JSX 요소를 반환해야 하며, 두 개의 인접한 JSX 요소(두 개의 버튼)는 허용되지 않습니다. 이 문제를 해결하기 위해 프래그먼트(와 )를 사용하여 여러 인접한 JSX 요소를 감싸야 합니다.
export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}
이제 두 개의 X가 채워진 사각형이 나타날 것입니다.
좋습니다! 이제 몇 번 복사하여 아홉 개의 사각형을 추가하고...
하지만 아이고! 사각형들이 모두 한 줄에 있고, 필요한 게시판 형태가 아닙니다. 이 문제를 해결하기 위해 사각형들을 div로 그룹화하고 일부 CSS 클래스를 추가해야 합니다. 그리고 혹시 모르니 각 사각형에 번호를 지정하여 위치를 확인할 수 있게 해보죠.
App.js 파일에서 Square 컴포넌트를 다음과 같이 업데이트하세요.
export default function Square() {
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>
</>
);
}
styles.css에서 정의된 CSS는 className이 board-row인 div에 스타일을 적용합니다. div로 컴포넌트를 그룹화한 후 스타일이 지정된 div로 게시판을 만들 수 있습니다.
그러나 이제 문제가 있습니다. Square라는 컴포넌트의 이름이 더 이상 사각형이 아닙니다. 이를 해결하기 위해 이름을 Board로 변경해 봅시다.
export default function Board() {
//...
}
이 시점에서 코드는 다음과 같아야 합니다.
짝짝... 많이 입력해야 하는 것 같네요! 이 페이지에서 코드를 복사하여 붙여넣기 하는 것은 괜찮습니다. 그러나 약간의 도전을 원한다면, 최소한 한 번은 직접 타이핑한 코드만 복사하는 것을 권장합니다.
props를 통한 데이터 전달
이제 사용자가 사각형을 클릭할 때 빈 상태가 아닌 "X"로 변경하고 싶습니다. 지금까지 보드를 구축한 방식으로는 각 사각형을 업데이트하는 코드를 9번 복사-붙여넣기해야 합니다! 대신에 리액트의 컴포넌트 아키텍처를 활용하여 중복되고 난잡한 코드를 피하고 재사용 가능한 컴포넌트를 만들 수 있습니다.
먼저, 보드 컴포넌트에서 첫 번째 사각형을 나타내는 라인 (<button className="square">1</button>
)을 새로운 Square 컴포넌트로 복사합니다.
function Square() {
return <button className="square">1</button>;
}
export default function Board() {
// ...
}
그런 다음 JSX 구문을 사용하여 Board 컴포넌트에서 해당 Square 컴포넌트를 렌더링하도록 Board 컴포넌트를 업데이트합니다.
// ...
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>
</>
);
}
주의: 브라우저 div와 달리 Board와 Square와 같은 사용자 정의 컴포넌트는 대문자로 시작해야 합니다.
이제 확인해보세요.
앗! 이전에 가지고 있던 번호가 사라졌습니다. 이를 수정하기 위해 부모 컴포넌트(Board)에서 자식 컴포넌트(Square)로 전달할 value 속성을 사용합니다.
Square 컴포넌트를 업데이트하여 Board에서 전달한 value 속성을 읽도록 합니다.
function Square({ value }) {
return <button className="square">1</button>;
}
function Square({ value })
는 Square 컴포넌트가 value라는 속성(prop)을 받을 수 있음을 나타냅니다.
이제 각 사각형 내부에 1이 아닌 value를 표시하려고 합니다. 다음과 같이 JSX 내부에서 value 주위에 중괄호를 추가하여 JavaScript 변수인 value를 렌더링하면 됩니다.
function Square({ value }) {
return <button className="square">{value}</button>;
}
그런데, 실수가 발생했습니다.
"value"라는 단어가 아닌 컴포넌트 내에서 JavaScript 변수인 value를 렌더링하려고 했습니다. JSX에서 "JavaScript로 이동"하기 위해 중괄호를 사용해야 합니다. 다음과 같이 JSX 내부에서 value 주위에 중괄호를 추가하세요.
function Square({ value }) {
return <button className="square">{value}</button>;
}
이제 보드는 비어있어야 합니다.
이는 Board 컴포넌트가 아직 각 Square 컴포넌트에 value 속성을 전달하지 않았기 때문입니다. 이를 수정하기 위해 Board 컴포넌트에서 렌더링된 각 Square 컴포넌트에 value 속성을 추가하세요.
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 내부에 클릭 시 X로 채우는 handleClick이라는 함수를 선언해보겠습니다. 그런 다음 Square에서 반환하는 button JSX 요소의 props에 onClick을 추가하세요.
function Square({ value }) {
function handleClick() {
console.log('clicked!');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
이제 사각형을 클릭하면 "clicked!"라는 로그가 Browser 섹션 하단의 Console 탭에 표시됩니다. 사각형을 여러 번 클릭하면 "clicked!"가 다시 로그에 표시됩니다. 동일한 메시지로 반복된 콘솔 로그는 콘솔에 추가 행을 생성하지 않고 대신 첫 번째 "clicked!" 로그 옆에 증가하는 카운터가 표시됩니다.
참고: 로컬 개발 환경에서 이 튜토리얼을 따르는 경우, 브라우저의 콘솔을 열어야 합니다. 예를 들어, Chrome 브라우저를 사용하는 경우 Shift + Ctrl + J (Windows/Linux) 또는 Option + ⌘ + J (macOS) 키보드 단축키를 사용하여 콘솔을 볼 수 있습니다.
다음 단계로, Square 컴포넌트가 클릭을 기억하고 "X" 표시로 채워지도록 하려고 합니다. 이를 위해 컴포넌트는 상태(state)를 사용합니다.
리액트는 컴포넌트 내에서 "기억"할 수 있는 특별한 함수인 useState를 제공합니다. 컴포넌트에서 이 함수를 호출하여 값을 "기억"할 수 있게 됩니다. 현재 Square의 값을 상태로 저장하고 Square가 클릭될 때 값이 변경되도록 해보겠습니다.
파일 상단에 useState를 import하세요. 그리고 Square 컴포넌트에서 value prop을 제거하세요. 대신, Square의 시작 부분에서 useState를 호출하는 새로운 라인을 추가하세요. 이 함수는 value라는 상태 변수를 반환하도록 설정하세요.
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
//...
value는 값을 저장하는 변수이고 setValue는 값을 변경하는 데 사용할 수 있는 함수입니다. useState에 전달된 null은 이 상태 변수의 초기값으로 사용되므로 value는 처음에 null로 시작됩니다.
Square 컴포넌트는 이제 더 이상 prop을 받지 않으므로 Board 컴포넌트에서 생성하는 모든 아홉 개의 Square 컴포넌트에서 value prop을 제거하세요.
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>
</>
);
}
이제 Square를 클릭할 때 "X"로 표시되도록 변경하세요. console.log("clicked!"); 이벤트 핸들러를 setValue('X');로 대체하세요. 이제 Square 컴포넌트는 다음과 같아야 합니다.
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
onClick 핸들러에서 set 함수를 호출함으로써 해당 Square가 <button>
이 클릭될 때마다 리액트에게 해당 Square를 다시 렌더링하라고 알리는 것입니다. 업데이트 후 Square의 값은 'X'가 되므로 게임 보드에 "X"가 표시됩니다. 아무 Square를 클릭해 보세요. "X"가 나타날 것입니다.
각 Square에는 고유한 상태가 있습니다. 각 Square에 저장된 값은 완전히 독립적입니다. 컴포넌트에서 set 함수를 호출하면 리액트는 자동으로 해당 Square 내부의 하위 컴포넌트도 업데이트합니다.
위의 변경 사항을 수행한 후 코드는 다음과 같아야 합니다.
리액트 개발자 도구
리액트 Developer Tools를 사용하면 리액트 컴포넌트의 props와 상태를 확인할 수 있습니다. CodeSandbox의 브라우저 섹션 하단에 있는 리액트 DevTools 탭에서 리액트 DevTools를 확인할 수 있습니다.
화면의 특정 컴포넌트를 검사하려면 리액트 DevTools의 왼쪽 상단에 있는 버튼을 사용하세요.
리액트 DevTools로 페이지에서 컴포넌트 선택하기
참고: 로컬 개발 환경에서는 리액트 DevTools를 Chrome, Firefox 및 Edge 브라우저 확장 프로그램으로 사용할 수 있습니다. 설치하면 브라우저의 개발자 도구에서 리액트를 사용하는 사이트의 Components 탭이 나타납니다.
게임 완성하기
이 시점에서, 틱택토 게임을 완성하기 위한 모든 기본 구성 요소가 준비되었습니다. 완전한 게임을 만들려면 보드 위에 "X"와 "O"를 교대로 놓을 수 있어야하며, 승자를 결정할 방법이 필요합니다.
상태 끌어올리기
현재 각 Square 컴포넌트는 게임의 일부 상태를 유지하고 있습니다. 틱택토 게임에서 승자를 확인하려면 Board 컴포넌트가 9개의 Square 컴포넌트의 상태를 알아야 합니다.
어떻게 접근할까요? 일단 Board가 각 Square로부터 Square의 상태를 "요청"해야 한다고 생각할 수도 있습니다. 기술적으로는 이 접근 방식이 가능하지만, 코드가 이해하기 어려워지고 버그가 발생하기 쉬우며, 리팩토링하기 어렵기 때문에 이러한 접근 방식을 권장하지 않습니다. 대신, 각 Square 대신 부모 컴포넌트인 Board 컴포넌트에 게임의 상태를 저장하는 것이 가장 좋은 접근 방식입니다. Board 컴포넌트는 각 Square에게 숫자를 전달할 때와 같이 prop을 통해 표시할 내용을 알려줄 수 있습니다.
여러 자식 컴포넌트에서 데이터를 수집하거나 두 자식 컴포넌트 간에 통신할 때, 부모 컴포넌트에 공유 상태를 선언하는 것이 일반적입니다. 부모 컴포넌트는 그 상태를 자식 컴포넌트에게 prop을 통해 다시 전달할 수 있습니다. 이렇게 하면 자식 컴포넌트가 서로와 부모 컴포넌트와 동기화됩니다.
상태를 부모 컴포넌트로 끌어올리는 것은 리액트 컴포넌트가 리팩토링될 때 일반적으로 사용됩니다.
이 기회를 통해 해보겠습니다. Board 컴포넌트를 편집하여 squares라는 이름의 상태 변수를 선언하고, 해당 변수가 9개의 null로 구성된 배열을 기본값으로 갖도록 합니다.
// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}
Array(9).fill(null)은 아홉 개의 요소로 구성된 배열을 생성하고, 각 요소를 null로 설정합니다. useState() 호출로 둘러싸인 부분은 초기에 해당 배열로 설정된 squares 상태 변수를 선언합니다. 배열의 각 항목은 해당 Square의 값에 해당합니다. 나중에 보드를 채우면 squares 배열은 다음과 같이 보일 것입니다.
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]
이제 Board 컴포넌트는 렌더링하는 각 Square에게 value prop을 전달해야 합니다.
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 컴포넌트의 자체적인 상태 추적 및 button의 onClick prop을 제거해야 합니다.
function Square({value}) {
return <button className="square">{value}</button>;
}
이 시점에서 빈 틱택토 보드를 볼 수 있어야 합니다.
빈 보드
코드는 다음과 같아야 합니다.
각 Square는 'X', 'O' 또는 빈 Square에 해당하는 null을 값으로 받는 value prop을 전달받게 됩니다.
다음으로 Square를 클릭할 때 어떻게 동작해야 하는지 변경해야 합니다. Board 컴포넌트가 현재 어떤 Square가 채워져 있는지 유지합니다. Square가 Board의 상태를 업데이트할 수 있도록 하는 방법을 만들어야 합니다. 상태는 그를 정의하는 컴포넌트에 대해 비공개입니다. 따라서 Square에서 직접적으로 Board의 상태를 업데이트할 수는 없습니다.
대신에 Board 컴포넌트에서 Square 컴포넌트로 함수를 전달하고, Square가 클릭되었을 때 그 함수를 호출하도록 만들 것입니다. Square 컴포넌트에서 호출할 함수를 onSquareClick으로 지정합니다.
function Square({ value }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
다음으로, Square 컴포넌트의 props에 onSquareClick 함수를 추가합니다.
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
이제 onSquareClick prop을 handleClick이라는 Board 컴포넌트의 함수와 연결해야 합니다. 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 함수를 정의하여 보드의 상태를 업데이트하도록 합니다.
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 (
// ...
)
}
handleClick 함수는 squares 배열의 복사본인 nextSquares를 생성합니다. 그런 다음 handleClick은 nextSquares 배열을 업데이트하여 첫 번째 ([0] 인덱스) square에 X를 추가합니다.
setSquares 함수를 호출하여 리액트에게 컴포넌트의 상태가 변경되었음을 알립니다. 이로 인해 squares 상태를 사용하는 컴포넌트 (Board) 및 해당 자식 컴포넌트 (보드를 구성하는 Square 컴포넌트)의 재렌더링이 트리거됩니다.
참고: JavaScript는 클로저를 지원하므로 내부 함수(handleClick)는 외부 함수(Board)에서 정의된 변수와 함수에 액세스할 수 있습니다. handleClick 함수는 squares 상태를 읽고 setSquares 메소드를 호출할 수 있습니다. 이는 둘 다 Board 함수 내부에 정의되어 있기 때문입니다.
이제 클릭한 Square에 X를 추가할 수 있지만, 첫 번째 Square (0)에만 추가됩니다. handleClick 함수가 첫 번째 Square에 대한 인덱스 (0)로 하드코딩되어 있기 때문입니다. handleClick을 업데이트하여 다른 Square를 업데이트할 수 있도록 변경해보겠습니다. handleClick 함수에 업데이트할 square의 인덱스를 나타내는 인수 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 (
// ...
)
}
다음으로, handleClick에 i를 전달해야 합니다. JSX에서 handleClick(0)
를 직접 onSquareClick
prop으로 설정하려고 하면 작동하지 않습니다.
<Square value={squares[0]} onSquareClick={handleClick(0)} />
이 작동하지 않는 이유는 handleClick(0)
호출이 보드 컴포넌트의 렌더링 일부가 될 것이기 때문입니다. handleClick(0)
은 setSquares를 호출하여 보드 컴포넌트의 상태를 변경하므로 전체 보드 컴포넌트가 다시 렌더링됩니다. 하지만 이로 인해 handleClick(0)
이 다시 실행되어 무한 루프가 발생합니다.
이러한 문제가 이전에 왜 발생하지 않았을까요? onSquareClick={handleClick}
와 같이 handleClick 함수를 전달할 때 handleClick 함수를 호출하지는 않았습니다. 하지만 지금은 바로 해당 함수를 호출하고 있으며 (handleClick(0)
에서 괄호를 확인하세요), 이렇게 일찍 실행되지 않도록 해야 합니다. 사용자가 클릭할 때까지 handleClick을 호출하면 안 됩니다!
이를 해결하기 위해 handleClick(0)
을 호출하는 handleFirstSquareClick
과 같은 함수를 만들고, handleClick(1)
을 호출하는 handleSecondSquareClick
과 같은 함수를 만들고, 이렇게 계속 진행할 수 있습니다. 이러한 함수들을 onSquareClick={handleFirstSquareClick}
과 같은 prop으로 전달하면 됩니다. 이렇게 하면 무한 루프 문제가 해결됩니다.
하지만 아홉 개의 다른 함수를 정의하고 각각에 이름을 지정하는 것은 너무 많은 코드입니다. 대신 다음과 같이 할 수 있습니다.
이제 클릭한 Square에 X를 추가할 수 있지만, 첫 번째 Square (0)에만 추가됩니다. handleClick 함수가 첫 번째 Square에 대한 인덱스 (0)로 하드코딩되어 있기 때문입니다. handleClick을 업데이트하여 다른 Square를 업데이트할 수 있도록 변경해보겠습니다. handleClick 함수에 업데이트할 square의 인덱스를 나타내는 인수 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 (
// ...
)
}
다음으로, handleClick에 i를 전달해야 합니다. JSX에서 handleClick(0)
를 직접 onSquareClick prop으로 설정하려고 하면 작동하지 않습니다.
<Square value={squares[0]} onSquareClick={handleClick(0)} />
이 작동하지 않는 이유는 handleClick(0)
호출이 보드 컴포넌트의 렌더링 일부가 될 것이기 때문입니다. handleClick(0)
은 setSquares를 호출하여 보드 컴포넌트의 상태를 변경하므로 전체 보드 컴포넌트가 다시 렌더링됩니다. 하지만 이로 인해 handleClick(0)이 다시 실행되어 무한 루프가 발생합니다.
이러한 문제가 이전에 왜 발생하지 않았을까요? onSquareClick={handleClick}
와 같이 handleClick 함수를 전달할 때 handleClick 함수를 호출하지는 않았습니다. 하지만 지금은 바로 해당 함수를 호출하고 있으며 (handleClick(0
에서 괄호를 확인하세요), 이렇게 일찍 실행되지 않도록 해야 합니다. 사용자가 클릭할 때까지 handleClick을 호출하면 안 됩니다!
이를 해결하기 위해 handleClick(0)
을 호출하는 handleFirstSquareClick과 같은 함수를 만들고, handleClick(1)
을 호출하는 handleSecondSquareClick과 같은 함수를 만들고, 이렇게 계속 진행할 수 있습니다. 이러한 함수들을 onSquareClick={handleFirstSquareClick}
과 같은 prop으로 전달하면 됩니다. 이렇게 하면 무한 루프 문제가 해결됩니다.
하지만 아홉 개의 다른 함수를 정의하고 각각에 이름을 지정하는 것은 너무 많은 코드입니다. 대신 다음과 같이 할 수 있습니다.
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
);
}
새로운 () =>
구문을 주목하세요. 여기서 () => handleClick(0)
은 화살표 함수입니다. 화살표 뒤의 코드가 실행되며 Square가 클릭되면 handleClick(0)
이 호출됩니다.
나머지 여덟 개의 Square를 업데이트하여 해당 화살표 함수에서 handleClick을 호출하도록 해야 합니다. handleClick 호출의 인수가 올바른 Square의 인덱스와 일치하는지 확인하세요:
export default function Board() {
// ...
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를 클릭하여 보드의 어떤 Square에도 X를 추가할 수 있습니다.
X로 보드를 채우는 것
이번에는 모든 상태 관리가 Board 컴포넌트에서 처리됩니다!
이제 상태 처리가 Board 컴포넌트에 있으므로 부모인 Board 컴포넌트는 자식인 Square 컴포넌트에 props를 전달하여 올바르게 표시될 수 있습니다. Square를 클릭할 때 자식인 Square 컴포넌트는 부모인 Board 컴포넌트에게 보드의 상태를 업데이트하도록 요청합니다. Board의 상태가 변경되면 Board 컴포넌트와 모든 자식인 Square가 자동으로 다시 렌더링됩니다. 모든 Square의 상태를 Board 컴포넌트에 유지하는 것은 나중에 Board가 승자를 결정할 수 있게 해줍니다.
이제 보드의 왼쪽 상단에 있는 Square를 클릭하여 X를 추가할 때 발생하는 일을 요약해보겠습니다.
왼쪽 상단 Square를 클릭하면 버튼이 onClick prop으로 전달받은 함수가 실행됩니다. Square 컴포넌트는 이 함수를 onSquareClick prop으로 부모인 Board 컴포넌트로부터 전달받았습니다. Board 컴포넌트는 이 함수를 JSX에서 직접 정의했습니다. 이 함수는 인수 0으로 handleClick을 호출합니다. handleClick은 인수(0)를 사용하여 squares 배열의 첫 번째 요소를 null에서 X로 업데이트합니다. Board 컴포넌트의 squares 상태가 업데이트되었으므로 Board와 그 하위 Square 모두 다시 렌더링됩니다. 이로 인해 인덱스 0의 Square 컴포넌트의 value prop이 null에서 X로 변경됩니다. 최종적으로 사용자는 클릭한 상단 왼쪽 Square이 비어있던 것에서 X로 변경된 것을 확인할 수 있습니다.
참고: DOM의
<button>
요소의 onClick 속성은 내장된 컴포넌트이기 때문에 리액트에서 특별한 의미를 가지고 있습니다. Square와 같은 사용자 정의 컴포넌트의 경우 네이밍은 사용자가 결정할 수 있습니다. Square의 onSquareClick prop이나 Board의 handleClick 함수에 어떤 이름을 사용해도 코드는 동일하게 작동합니다. 리액트에서는 이벤트를 나타내는 prop에는 onSomething과 같은 이름을, 해당 이벤트를 처리하는 함수에는 handleSomething과 같은 이름을 사용하는 것이 관례입니다.
왜 불변성이 중요한지
handleClick에서 기존 배열을 수정하는 대신 .slice()
를 호출하여 squares 배열의 복사본을 생성하는 것에 주목해보세요. 그 이유에 대해 설명하기 위해 불변성(Immutability)과 불변성이 왜 중요한지에 대해 이야기해야 합니다.
데이터를 변경하는 데는 일반적으로 두 가지 방법이 있습니다. 첫 번째 방법은 데이터의 값을 직접 변경하여 데이터 자체를 변형하는 것입니다. 두 번째 방법은 원하는 변경 사항을 가진 새로운 복사본으로 데이터를 대체하는 것입니다. 만약 squares 배열을 직접 변형한다면 다음과 같을 것입니다.
const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// 이제 `squares`는
// ["X", null, null, null, null, null, null, null, null]입니다.
반면에 데이터를 변경하지 않고 squares 배열을 변형하지 않는다면 다음과 같을 것입니다.
const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// 이제 `squares`는 변경되지 않았지만,
// `nextSquares`의 첫 번째 요소는 `null`이 아닌 `'X'`입니다.
결과는 같지만 직접 데이터를 변형하지 않는 것으로써 여러 가지 이점을 얻을 수 있습니다.
불변성은 복잡한 기능을 구현하기 훨씬 쉽게 만들어줍니다. 이 튜토리얼의 후반부에서는 게임의 이력을 검토하고 이전 동작으로 "돌아갈 수 있는" "타임 트래블" 기능을 구현할 것입니다. 이 기능은 게임에 특정한 것이 아니며, 특정 동작을 되돌리고 다시 실행하는 능력은 앱에서 일반적인 요구사항입니다. 직접적인 데이터 변형을 피하면 이전 버전의 데이터를 그대로 유지하고 나중에 재사용할 수 있습니다.
불변성의 또 다른 이점도 있습니다. 부모 컴포넌트의 상태가 변경되면 기본적으로 모든 하위 컴포넌트가 자동으로 다시 렌더링됩니다. 이는 변경 사항에 영향을 받지 않은 하위 컴포넌트까지 포함됩니다. 재렌더링 자체는 사용자에게 눈에 띄지 않을 수 있지만 (의도적으로 피하려고 노력해서는 안 됩니다!), 성능 상의 이유로 변경 사항과 관련이 없는 트리의 일부를 건너뛰고 재렌더링을 생략하고 싶을 수 있습니다. 불변성은 컴포넌트가 데이터가 변경되었는지 여부를 비교하는 것이 매우 저렴하게 만듭니다. 리액트에서 컴포넌트를 언제 다시 렌더링할지 선택하는 방법에 대해서는 메모 API 참조에서 자세히 알아볼 수 있습니다.
턴 전환하기
이제 이 틱택토 게임의 주요 결함을 수정할 시간입니다. "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가 교대로 표시됩니다!
하지만 문제가 있습니다. 동일한 사각형을 여러 번 클릭해 보세요:
O가 X를 덮어쓰고 있습니다! 이것은 매우 흥미로운 변화를 게임에 더할 수 있겠지만, 우리는 일단 원래 규칙을 따를 것입니다.
X나 O로 사각형을 표시하기 전에 이미 X나 O 값을 가지고 있는지 확인하지 않고 있습니다. 이를 고치기 위해 조기에 종료하도록 할 것입니다. handleClick 함수에서 먼저 사각형이 이미 X나 O 값을 가지고 있는지 확인합니다. 사각형이 이미 채워져 있다면, board 상태를 업데이트하기 전에 함수를 조기에 종료합니다.
function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}
이제 빈 사각형에만 X나 O를 추가할 수 있습니다! 여기까지 진행하면 코드는 다음과 같아야 합니다.
승자 선언하기
이제 플레이어들이 순서대로 움직일 수 있게 되었으므로, 게임이 이겨졌거나 더 이상 움직일 수 없는 경우를 보여주고 싶을 것입니다. 이를 위해, calculateWinner라는 도우미 함수를 추가할 것입니다. 이 함수는 9개의 사각형 배열을 가져와 승자가 있는지 확인하고, 'X', 'O' 또는 적절한 값인 null을 반환합니다. calculateWinner 함수는 리액트에 특정된 것이 아니므로, 너무 걱정할 필요는 없습니다.
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 위에 정의하든 아래에 정의하든 상관없습니다. 매번 컴포넌트를 편집할 때마다 스크롤을 내려가야하는 번거로움을 피하기 위해 맨 아래에 배치합시다.
Board 컴포넌트의 handleClick 함수에서 calculateWinner(squares)
를 호출하여 플레이어가 이겼는지 확인할 것입니다. 이 체크를 수행할 때, 이미 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">
// ...
)
}
축하합니다! 이제 작동하는 틱택토 게임이 있습니다. 그리고 리액트의 기본을 배웠으니 여러분은 진정한 승자입니다. 이것이 코드가 나타나야 하는 모습입니다.
시간 여행 추가하기
마지막으로, 게임의 이전 동작으로 '시간을 되돌아가는' 기능을 추가해 보겠습니다.
동작 기록 저장하기
동일한 squares 배열을 변형하면 시간 여행을 구현하는 것이 매우 어려워집니다.
그러나 각 동작 후에 slice()
를 사용하여 squares 배열의 새로운 사본을 생성하고, 이를 변경 불가능한 것으로 다루었습니다. 이를 통해 과거의 모든 squares 배열 버전을 저장하고, 이미 발생한 턴들 사이를 이동할 수 있게 됩니다.
history라는 다른 배열에 이전의 squares 배열을 저장하고, 새로운 상태 변수로 저장할 것입니다. history 배열은 첫 번째 동작부터 마지막 동작까지의 모든 보드 상태를 나타내며, 다음과 같은 형식을 가지고 있습니다.
[
// 첫 번째 동작 전
[null, null, null, null, null, null, null, null, null],
// 첫 번째 동작 후
[null, null, null, null, 'X', null, null, null, null],
// 두 번째 동작 후
[null, null, null, null, 'X', null, null, null, 'O'],
// ...
]
게임 기록 저장하기
이제 게임의 기록을 표시할 수 있는 새로운 최상위 컴포넌트인 Game을 작성합니다. 여기에는 전체 게임 기록을 저장하는 history 상태를 위치시킬 것입니다.
history 상태를 Game 컴포넌트에 위치시킴으로써 squares 상태를 자식 컴포넌트인 Board 컴포넌트에서 제거할 수 있습니다. Square 컴포넌트에서 Board 컴포넌트로 상태를 "올리는 것(lift state up)"과 마찬가지로, 이제 Board 컴포넌트에서 Game 컴포넌트로 상태를 "올립니다". 이렇게 하면 Game 컴포넌트가 Board의 데이터를 완전히 제어할 수 있으며, Game 컴포넌트는 history에서 이전 턴을 가져와 Board에 이전 동작을 렌더링할 수 있게 됩니다.
먼저, Game 컴포넌트를 추가합니다. export default
로 선언하고, Board 컴포넌트와 일부 마크업을 렌더링하도록 합니다.
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>
);
}
주목할 점은 함수 선언인 function Board() {
앞에 있는 export default
키워드를 제거하고, function Game() {
앞에 export default
키워드를 추가한 것입니다. 이렇게 하면 index.js 파일에서 Board 컴포넌트 대신에 Game 컴포넌트를 최상위 컴포넌트로 사용하게 됩니다. Game 컴포넌트에 추가된 div 요소들은 이후에 게임 정보를 추가할 공간을 만들어 줍니다.
게임의 다음 플레이어와 동작 기록을 추적하기 위해 Game 컴포넌트에 상태를 추가합니다.
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
// ...
[Array(9).fill(null)]
는 하나의 항목으로 이루어진 배열로, 해당 항목은 9개의 null로 이루어진 배열입니다.
현재 동작에 대한 squares를 렌더링하기 위해 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 함수 내부에서 useState를 호출하는 첫 두 줄을 제거합니다.
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}
이제 Board 컴포넌트 내의 handleClick에서 setSquares와 setXIsNext 호출을 삭제하고, 대신에 사용자가 square를 클릭할 때 업데이트된 squares 배열을 전달하기 위해 새로운 onPlay 함수를 호출하도록 변경합니다.
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로 완전히 제어됩니다. 이제 Game 컴포넌트에서 handlePlay 함수를 구현하여 게임을 다시 작동시킵니다.
handlePlay 함수는 호출될 때 Game 컴포넌트의 상태를 업데이트하여 다시 렌더링되도록 해야 합니다. 하지만 더 이상 호출할 수 있는 setSquares 함수가 없습니다. 이제 history 상태 변수를 사용하여 이 정보를 저장할 것입니다. history를 업데이트하기 위해 업데이트된 squares 배열을 새로운 history 항목으로 추가해야 합니다. 또한 Board가 수
행하던 것과 마찬가지로 xIsNext를 토글해야 합니다.
export default function Game() {
//...
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
//...
}
여기서 [...history, nextSquares]
는 history의 모든 항목을 열거한 다음 nextSquares를 추가한 새로운 배열을 생성합니다. (spread 구문인 ...history
는 "history의 모든 항목을 열거한다"로 해석할 수 있습니다.)
예를 들어, history가 [[null,null,null], ["X",null,null]]
이고 nextSquares가 ["X",null,"O"]
이면, 새로운 [...history, nextSquares]
배열은 [[null,null,null], ["X",null,null], ["X",null,"O"]]
가 됩니다.
이 시점에서, 상태를 Game 컴포넌트로 이동시켰고, UI는 이전 리팩터링 전과 완전히 동작해야 합니다. 이 시점에서 코드는 다음과 같이 보일 것입니다.
과거 움직임 보여주기
게임의 이력을 기록하고 있기 때문에 이제 과거의 움직임 목록을 플레이어에게 표시할 수 있습니다.
<button>
과 같은 리액트 요소는 일반적인 JavaScript 객체입니다. 애플리케이션 내에서 이러한 요소를 전달할 수 있습니다. 리액트에서 여러 항목을 렌더링하려면 리액트 요소의 배열을 사용할 수 있습니다.
이미 상태에서 이력 움직임 배열을 가지고 있으므로, 이제 이를 리액트 요소의 배열로 변환해야 합니다. JavaScript에서 배열을 다른 배열로 변환하기 위해 배열 map 메서드를 사용할 수 있습니다.
[1, 2, 3].map((x) => x * 2) // [2, 4, 6]
map을 사용하여 이력의 움직임을 화면에 표시되는 버튼을 나타내는 리액트 요소의 배열로 변환하고, 과거의 움직임으로 "이동"할 수 있는 버튼 목록을 표시해 보겠습니다. Game 컴포넌트에서 history를 map으로 순회해 보겠습니다.
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 = '이동 #' + move + '로 가기';
} else {
description = '게임 시작으로 가기';
}
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>
);
}
아래는 코드가 어떻게 보여야 하는지에 대한 예시입니다. 참고로, 개발자 도구 콘솔에 "경고: 배열이나 반복자의 각 자식은 고유한 'key' 속성을 가져야 합니다. Game
의 렌더 메서드를 확인하세요."라는 오류가 표시될 것입니다. 이 오류는 다음 섹션에서 수정하게 됩니다.
map 메서드에 전달한 함수 내에서 history 배열을 반복할 때, squares 인자는 history의 각 요소를, move 인자는 0, 1, 2, ...와 같은 배열 인덱스를 반복합니다. (대개 실제 배열 요소가 필요하지만, 움직임 목록을 렌더링하기 위해서는 인덱스만 필요합니다.)
tic-tac-toe 게임의 이력에서 각 움직임에 대해 <li>
요소에 <button>
을 생성합니다. 버튼은 onClick 핸들러를 가지고 있으며, 아직 구현하지 않은 jumpTo라는 함수를 호출합니다.
현재 게임에서 발생한 움직임 목록이 표시되는 동시에 개발자 도구 콘솔에 오류가 표시될 것입니다. 이제 "key" 오류가 무엇을 의미하는지 알아보겠습니다.
키 선택하기
리스트를 렌더링할 때, 리액트는 각 렌더링된 항목에 대한 일부 정보를 저장합니다. 리스트를 업데이트할 때 리액트는 변경된 내용을 판단해야 합니다. 항목이 추가되었을 수도 있고, 제거되었을 수도 있으며, 재정렬되거나 업데이트될 수도 있습니다.
다음과 같이 변화하는 상황을 상상해보세요.
<li>Alexa: 7개의 할 일 남음</li>
<li>Ben: 5개의 할 일 남음</li>
에서
<li>Ben: 9개의 할 일 남음</li>
<li>Claudia: 8개의 할 일 남음</li>
<li>Alexa: 5개의 할 일 남음</li>
갱신된 카운트 외에도, 이를 읽는 사람은 아마도 Alexa와 Ben의 순서를 바꾸고, Alexa와 Ben 사이에 Claudia를 삽입했다고 할 것입니다. 그러나 리액트는 컴퓨터 프로그램이며, 의도한 바를 알 수 없으므로, 각 리스트 항목을 형제 항목과 구별하기 위해 각 항목에 key 속성을 지정해야 합니다. 데이터가 데이터베이스에서 가져온 것이라면, Alexa, Ben, Claudia의 데이터베이스 ID를 키로 사용할 수 있을 것입니다.
<li key={user.id}>
{user.name}: {user.taskCount}개의 할 일 남음
</li>
리스트가 재렌더링될 때, 리액트는 각 리스트 항목의 키를 가져와 이전 리스트 항목에서 일치하는 키를 찾습니다. 현재 리스트에 이전에 없었던 키가 있는 경우, 리액트는 컴포넌트를 생성합니다. 현재 리스트에 이전 리스트에 있던 키가 빠진 경우, 리액트는 이전 컴포넌트를 제거합니다. 두 키가 일치하는 경우, 해당 컴포넌트가 이동됩니다.
키는 각 컴포넌트의 식별자를 리액트에 알려줌으로써 리액트가 재렌더링 사이에 상태를 유지할 수 있게 합니다. 컴포넌트의 키가 변경되면, 해당 컴포넌트는 파괴되고 새로운 상태로 다시 생성됩니다.
키는 리액트에서 특별하고 예약된 속성입니다. 요소가 생성될 때, 리액트는 키 속성을 추출하고 반환된 요소에 직접 키를 저장합니다. 키가 props로 전달되는 것처럼 보이지만, 리액트는 자동으로 key를 사용하여 어떤 컴포넌트를 업데이트할지 결정합니다. 컴포넌트가 부모로부터 어떤 키를 지정했는지 알 수 있는 방법은 없습니다.
동적인 리스트를 구축할 때는 적절한 키를 할당하는 것이 강력히 권장됩니다. 적절한 키가 없다면 데이터를 재구성하여 적용하는 것이 좋습니다.
키가 지정되지 않으면, 리액트는 오류를 보고하고 기본적으로 배열 인덱스를 키로 사용합니다. 배열 인덱스를 키로 사용하는 것은 리스트 항목의 재정렬이나 항목의 삽입/제거 시 문제가 발생할 수 있습니다. key={i}
와 같이 명시적으로 키를 전달하면 오류가 사라지지만, 배열 인덱스와 동일한 문제가 발생하며 대부분의 경우 권장되지 않습니다.
시간 여행 구현하기
틱택토 게임의 기록에서 각 과거의 수는 고유한 ID를 가지고 있습니다. 이것은 수의 연속적인 번호입니다. 수는 결코 재정렬되거나 삭제되거나 중간에 삽입되지 않기 때문에, 수 인덱스를 키로 사용해도 안전합니다.
Game 함수에서는 키를 <li key={move}>
로 추가할 수 있으며, 렌더링된 게임을 다시로드하면 리액트의 "key" 오류가 사라져야 합니다.
const moves = history.map((squares, move) => {
//...
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
jumpTo 함수를 구현하기 전에 사용자가 현재 어떤 단계를 보고 있는지를 추적할 수 있도록 Game 컴포넌트에서 새로운 상태 변수인 currentMove를 정의해야 합니다. 기본값은 0으로 설정합니다.
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를 클릭할 때 호출되는 Game의 handlePlay 함수를 두 가지 변경합니다.
- "과거로 돌아가"고 그 지점에서 새로운 수를 만든 다음에는 그 지점까지의 이력만 유지하려 합니다. nextSquares를 history의 모든 항목(... spread 구문) 뒤에 추가하는 대신, history.slice(0, currentMove + 1)에서 모든 항목 뒤에 추가하여 이전 이력의 그 부분만 유지합니다.
- 수를 만들 때마다 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];
// ...
}
게임의 이력에서 어떤 단계를 클릭하면 틱택토 보드가 해당 단계가 발생한 이후의 보드를 표시하도록 즉시 업데이트될 것입니다.
최종 코드 정리
코드를 매우 주의 깊게 살펴보면 currentMove가 짝수일 때 xIsNext === true
이고, currentMove가 홀수일 때 xIsNext === false
임을 알 수 있습니다. 다시 말해, currentMove의 값만 알고 있다면 항상 xIsNext가 어떻게 되어야 하는지 알 수 있습니다.
따라서 두 변수를 모두 상태로 저장할 필요가 없습니다. 실제로 중복된 상태는 항상 피하려고 해야 합니다. 상태에 저장하는 내용을 단순화하는 것은 버그를 줄이고 코드를 이해하기 쉽게 만듭니다. Game 컴포넌트를 수정하여 별도의 상태 변수로 xIsNext를 저장하지 않고 currentMove를 기반으로 값을 계산하도록 변경해보세요.
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와 동기화되지 않는 상황이 발생할 수 없습니다.
정리
축하합니다! 여러분은 다음과 같은 기능을 갖춘 틱택토 게임을 만들었습니다.
- 틱택토를 플레이할 수 있습니다.
- 어느 플레이어가 게임에서 승리했는지 알려줍니다.
- 게임이 진행됨에 따라 게임의 히스토리를 저장합니다.
- 플레이어들이 게임의 히스토리를 검토하고 이전 버전의 게임 보드를 확인할 수 있습니다.
잘 하셨습니다! 이제 리액트의 작동 방식에 대해 어느 정도 이해를 갖게 되셨을 것입니다.
만약 여분의 시간이 있거나 새로운 리액트 기술을 연습하고 싶다면, 틱택토 게임을 개선할 수 있는 몇 가지 아이디어를 소개합니다. 난이도가 증가하는 순서로 나열되어 있습니다.
- 현재 움직임에 대해서만 "현재 이동: #"과 같은 메시지를 버튼 대신에 표시합니다.
- Board를 수정하여, 하드코딩이 아닌 두 개의 루프를 사용하여 사각형을 생성합니다.
- 오름차순 또는 내림차순으로 이동을 정렬할 수 있는 토글 버튼을 추가합니다.
- 승리한 경우 승리에 영향을 준 세 개의 사각형을 강조 표시합니다. 승자가 없는 경우 무승부임을 나타내는 메시지를 표시합니다.
- 이동 기록 목록에서 각 이동의 위치를 "(행, 열)" 형식으로 표시합니다.
이 튜토리얼을 통해 요소(elements), 컴포넌트(components), props, state와 같은 리액트 개념을 다루었습니다. 이러한 개념이 어떻게 동작하는지 게임을 구축할 때 보여줬으니, "Thinking in React"를 살펴보면 같은 리액트 개념이 어떻게 앱의 UI를 구축할 때 동작하는지 알 수 있습니다.