튜토리얼: 틱택토
이 튜토리얼에서 작은 틱택토 게임을 만들게 됩니다. 이 튜토리얼은 React에 대한 사전 지식을 전혀 가정하지 않습니다. 튜토리얼에서 배우게 될 기술은 모든 React 앱을 구축하는 데 기본이 되며, 이를 완전히 이해하면 React에 대한 깊은 이해를 얻을 수 있습니다.
참고
이 튜토리얼은 실습을 통해 배우는 것을 선호하고 빠르게 실체적인 무언가를 만들어 보고 싶은 사람들을 위해 설계되었습니다. 각 개념을 단계별로 배우는 것을 선호한다면,부터 시작하세요.UI 설명하기
튜토리얼은 여러 섹션으로 나뉩니다:
- 튜토리얼 준비는 튜토리얼을 따라갈 수 있도록시작점을 제공합니다.
- 개요는 React의기본 개념인 컴포넌트, props, state를 가르칩니다.
- 게임 완성하기는 React 개발에서가장 일반적인 기술을 가르칩니다.
- 시간 여행 추가하기는 React의 독특한 강점에 대한더 깊은 통찰을 제공합니다.
무엇을 만들게 되나요?
이 튜토리얼에서는 React로 상호작용 가능한 틱택토 게임을 만들게 됩니다.
완성된 모습은 여기서 확인할 수 있습니다:
아직 코드가 이해가 되지 않거나 코드의 구문이 익숙하지 않더라도 걱정하지 마세요! 이 튜토리얼의 목표는 React와 그 구문을 이해하도록 돕는 것입니다.
튜토리얼을 계속하기 전에 위의 틱택토 게임을 확인해 보시기 바랍니다. 눈에 띄는 기능 중 하나는 게임 보드 오른쪽에 번호가 매겨진 목록이 있다는 것입니다. 이 목록은 게임에서 발생한 모든 이동의 기록을 제공하며 게임이 진행됨에 따라 업데이트됩니다.
완성된 틱택토 게임을 살펴본 후에는 계속 스크롤하세요. 이 튜토리얼에서는 더 간단한 템플릿으로 시작할 것입니다. 다음 단계는 게임을 만들기 시작할 수 있도록 환경을 설정하는 것입니다.
튜토리얼 설정
아래 라이브 코드 편집기에서 오른쪽 상단의Fork를 클릭하여 CodeSandbox 웹사이트를 사용하여 새 탭에서 편집기를 엽니다. CodeSandbox를 사용하면 브라우저에서 코드를 작성하고 생성한 앱을 사용자가 어떻게 볼지 미리 볼 수 있습니다. 새 탭에는 빈 사각형과 이 튜토리얼의 시작 코드가 표시됩니다.
참고
로컬 개발 환경을 사용하여 이 튜토리얼을 따라갈 수도 있습니다. 이를 위해서는 다음을 수행해야 합니다:
- Node.js
- 이전에 연 CodeSandbox 탭에서 왼쪽 상단 모서리 버튼을 눌러 메뉴를 연 다음, 해당 메뉴에서샌드박스 다운로드를 선택하여 파일 아카이브를 로컬에 다운로드합니다.
- 아카이브를 압축 해제한 후, 터미널을 열고 압축을 푼 디렉토리로
cd명령어로 이동합니다. - 종속성을
npm install - 로컬 서버를 시작하고 브라우저에서 실행 중인 코드를 보기 위한 프롬프트를 따라가려면
npm start를 실행합니다.
막히더라도, 이 때문에 멈추지 마세요! 온라인으로 따라가며 나중에 로컬 설정을 다시 시도해 보세요.
개요
이제 준비가 되었으니, React에 대한 개요를 살펴보겠습니다!
스타터 코드 살펴보기
CodeSandbox에서는 세 가지 주요 섹션이 표시됩니다:

- 다음과 같은 파일 목록이 있는파일 섹션:
App.js,index.js,styles.css(src폴더 내) 및public라는 폴더 - 선택한 파일의 소스 코드를 볼 수 있는코드 편집기 영역
- 작성한 코드가 어떻게 표시될지 볼 수 있는브라우저 섹션
이제파일 섹션에서 App.js파일이 선택되어 있어야 합니다.코드 편집기에 표시된 해당 파일의 내용은 다음과 같습니다:
이제브라우저섹션에 다음과 같이 X가 채워진 사각형이 표시되어야 합니다:

이제 스타터 코드의 파일들을 살펴보겠습니다.
App.js
파일 App.js의 코드는컴포넌트를 생성합니다. React에서 컴포넌트는 사용자 인터페이스의 일부를 나타내는 재사용 가능한 코드 조각입니다. 컴포넌트는 애플리케이션의 UI 요소를 렌더링하고, 관리하며, 업데이트하는 데 사용됩니다. 이 컴포넌트를 한 줄씩 살펴보며 어떤 일이 일어나는지 알아보겠습니다:
첫 번째 줄은 Square라는 함수를 정의합니다.exportJavaScript 키워드는 이 함수를 이 파일 외부에서 접근 가능하게 만듭니다.default키워드는 이 코드를 사용하는 다른 파일들에게 이 파일의 주요 함수임을 알려줍니다.
두 번째 줄은 버튼을 반환합니다.returnJavaScript 키워드는 뒤에 오는 내용이 함수 호출자에게 값으로 반환됨을 의미합니다.<button>는 JSX 요소입니다. JSX 요소는 표시하고자 하는 내용을 설명하는 JavaScript 코드와 HTML 태그의 조합입니다.className="square"는 버튼의 속성 또는프롭으로, CSS에 버튼을 어떻게 스타일링할지 알려줍니다.X는 버튼 내부에 표시되는 텍스트이며,</button>는 JSX 요소를 닫아 이후 내용이 버튼 내부에 배치되지 않도록 합니다.
styles.css
CodeSandbox의파일섹션에서styles.css라는 레이블이 붙은 파일을 클릭하세요. 이 파일은 React 앱의 스타일을 정의합니다. 처음 두 개의CSS 선택자(* 및 body)는 앱의 큰 부분의 스타일을 정의하는 반면,.square 선택자는 className 속성이 square로 설정된 모든 컴포넌트의 스타일을 정의합니다. 코드에서는App.js파일의 Square 컴포넌트의 버튼과 일치합니다.
index.js
CodeSandbox의파일섹션에서index.js라는 레이블이 붙은 파일을 클릭하세요. 이 튜토리얼 동안 이 파일을 편집하지는 않지만,App.js파일에서 생성한 컴포넌트와 웹 브라우저 사이의 다리 역할을 합니다.
1-5번 줄은 필요한 모든 조각들을 함께 모읍니다:
- React
- 웹 브라우저와 통신하기 위한 React의 라이브러리(React DOM)
- 컴포넌트의 스타일
- 에서 생성한 컴포넌트
App.js.
파일의 나머지 부분은 모든 조각을 모아 최종 결과물을index.html폴더의public에 주입합니다.
보드 만들기
다시 App.js로 돌아가겠습니다. 여기서 나머지 튜토리얼을 진행하게 될 것입니다.
현재 보드는 단일 사각형 하나뿐이지만, 아홉 개가 필요합니다! 만약 사각형을 복사 붙여넣기하여 두 개를 만들려고 다음과 같이 시도한다면:
다음과 같은 오류가 발생합니다:
콘솔
/src/App.js: 인접한 JSX 요소는 하나의 감싸는 태그로 묶어야 합니다. JSX Fragment<>...</>를 원하셨나요?
React 컴포넌트는 두 개의 버튼처럼 여러 개의 인접한 JSX 요소가 아닌 단일 JSX 요소를 반환해야 합니다. 이를 수정하려면Fragment(<> 및 </>)를 사용하여 다음과 같이 여러 인접 JSX 요소를 감쌀 수 있습니다:
이제 다음과 같이 표시되어야 합니다:

좋습니다! 이제 몇 번 복사 붙여넣기하여 아홉 개의 사각형을 추가하면…

이런! 사각형들이 모두 한 줄에 있어 보드에 필요한 격자 형태가 아닙니다. 이를 수정하려면 사각형들을div로 행으로 묶고 몇 가지 CSS 클래스를 추가해야 합니다. 그 과정에서 각 사각형이 어디에 표시되는지 알 수 있도록 각 사각형에 번호를 부여하겠습니다.
파일에서App.js컴포넌트를 다음과 같이 업데이트하세요:Square
에 정의된 CSS는styles.css가 className인 board-rowdiv를 스타일링합니다. 이제 스타일이 적용된div로 컴포넌트를 행으로 그룹화했으므로 틱택토 보드가 완성되었습니다:

하지만 이제 문제가 생겼습니다.Square라는 이름의 컴포넌트는 더 이상 사각형이 아닙니다. 이름을Board로 변경하여 이를 수정해 보겠습니다:
이 시점에서 코드는 다음과 비슷하게 보일 것입니다:
참고
쉿… 입력할 내용이 많네요! 이 페이지에서 코드를 복사하여 붙여넣어도 괜찮습니다. 하지만 약간의 도전을 원한다면, 직접 최소 한 번은 타이핑한 코드만 복사하는 것을 권장합니다.
props를 통해 데이터 전달하기
다음으로, 사용자가 사각형을 클릭할 때 사각형의 값을 비어 있는 상태에서 "X"로 변경하고 싶을 것입니다. 지금까지 보드를 구축한 방식으로는 사각형을 업데이트하는 코드를 아홉 번(가지고 있는 각 사각형마다 한 번씩) 복사 붙여넣기해야 합니다! 복사 붙여넣기 대신 React의 컴포넌트 아키텍처를 사용하면 지저분하고 중복된 코드를 피하기 위해 재사용 가능한 컴포넌트를 만들 수 있습니다.
먼저, 첫 번째 사각형을 정의하는 줄(<button className="square">1</button>)을 Board
그런 다음 JSX 문법을 사용하여Square컴포넌트를 렌더링하도록 Board 컴포넌트를 업데이트합니다:
브라우저의 div와 달리, 여러분 자신의 컴포넌트인Board와 Square는 대문자로 시작해야 한다는 점에 주목하세요.
한번 살펴봅시다:

이런! 이전에 있던 번호가 매겨진 사각형들을 잃어버렸습니다. 이제 각 사각형은 "1"이라고 표시됩니다. 이 문제를 해결하려면props를 사용하여 각 사각형이 가져야 할 값을 부모 컴포넌트(Board)에서 자식 컴포넌트(Square)로 전달해야 합니다.
Board에서 전달할Square 컴포넌트가 valueprop을 읽도록 업데이트하세요:
function Square({ value })는 Square 컴포넌트에 value라는 prop을 전달할 수 있음을 나타냅니다.
이제 각 사각형 내부에1대신 그value를 표시하고 싶습니다. 다음과 같이 시도해 보세요:
이런, 원하는 결과가 아닙니다:

여러분은 컴포넌트의 JavaScript 변수인value를 렌더링하고 싶었던 것이지, "value"라는 단어를 렌더링하고 싶었던 것이 아닙니다. JSX에서 JavaScript로 "탈출"하려면 중괄호가 필요합니다. JSX 내에서value주위에 중괄호를 추가하세요:
지금은 빈 보드가 보일 것입니다:

이는 Board컴포넌트가 렌더링하는 각Square컴포넌트에valueprop을 아직 전달하지 않았기 때문입니다. 이를 해결하려면Board컴포넌트가 렌더링하는 각Square 컴포넌트에 valueprop을 추가할 것입니다:
이제 다시 숫자 그리드가 보일 것입니다:

업데이트된 코드는 다음과 같아야 합니다:
상호작용 가능한 컴포넌트 만들기
클릭했을 때Square 컴포넌트를 X로 채워봅시다. Square 내부에 handleClick라는 함수를 선언하세요. 그런 다음,Square에서 반환된 버튼 JSX 요소의 props에onClick을 추가하세요:
이제 사각형을 클릭하면 CodeSandbox의"clicked!"사각형을 여러 번 클릭하면Browser섹션 하단에 있는Console 탭에서 "clicked!"가 다시 기록됩니다. 동일한 메시지가 반복되어 콘솔에 새 줄이 생성되지는 않습니다. 대신 첫 번째"clicked!"로그 옆에 증가하는 카운터가 표시됩니다.
참고
이 튜토리얼을 로컬 개발 환경에서 진행하는 경우 브라우저의 콘솔을 열어야 합니다. 예를 들어 Chrome 브라우저를 사용하는 경우 키보드 단축키Shift + Ctrl + J(Windows/Linux) 또는Option + ⌘ + J(macOS)로 콘솔을 볼 수 있습니다.
다음 단계로, Square 컴포넌트가 클릭되었다는 것을 "기억"하고 "X" 표시로 채우도록 하려고 합니다. 무언가를 "기억"하기 위해 컴포넌트는state를 사용합니다.
React는 컴포넌트가 무언가를 "기억"할 수 있도록 해주는useState라는 특별한 함수를 제공합니다. Square의 현재 값을 state에 저장하고,Square가 클릭될 때 변경해 보겠습니다.
파일 상단에서useState를 import하세요.Square컴포넌트에서value prop을 제거하세요. 대신, Square의 시작 부분에useState를 호출하는 새 줄을 추가하세요. 이를 통해value라는 state 변수를 반환하도록 합니다:
value는 값을 저장하고setValue는 값을 변경하는 데 사용할 수 있는 함수입니다.useState에 전달된null은 이 state 변수의 초기값으로 사용되므로, 여기서value는 처음에 null과 같습니다.
이제 Square컴포넌트가 더 이상 props를 받지 않으므로, Board 컴포넌트가 생성하는 9개의 Square 컴포넌트 모두에서value prop을 제거합니다:
이제 클릭했을 때Square가 "X"를 표시하도록 변경하겠습니다.console.log("clicked!");이벤트 핸들러를setValue('X');로 바꾸세요. 이제Square컴포넌트는 다음과 같습니다:
이 set 함수를 onClick핸들러에서 호출함으로써, 해당Square의 <button>이 클릭될 때마다 React가 해당Square업데이트 후에는Square의 value가 'X'가 되므로 게임 보드에 "X"가 표시됩니다. 아무 Square나 클릭하면 "X"가 나타납니다:

각 Square는 자신만의 state를 가집니다: 각 Square에 저장된value는 서로 완전히 독립적입니다. 컴포넌트에서 set함수를 호출하면 React는 자동으로 내부의 자식 컴포넌트도 업데이트합니다.
위 변경 사항을 적용한 후 코드는 다음과 같습니다:
React 개발자 도구
React DevTools를 사용하면 React 컴포넌트의 props와 state를 확인할 수 있습니다. CodeSandbox의브라우저섹션 하단에서 React DevTools 탭을 찾을 수 있습니다:

화면에서 특정 컴포넌트를 검사하려면 React DevTools 왼쪽 상단의 버튼을 사용하세요:

게임 완성하기
이 시점에서 틱택토 게임의 모든 기본 구성 요소를 갖추었습니다. 완전한 게임을 만들려면 이제 보드에 "X"와 "O"를 번갈아 배치하고 승자를 결정하는 방법이 필요합니다.
State 끌어올리기
현재 각 Square컴포넌트는 게임 상태의 일부를 유지합니다. 틱택토 게임에서 승자를 확인하려면Board 컴포넌트가 9개의 Square컴포넌트 각각의 상태를 어떻게든 알아야 합니다.
어떻게 접근할 수 있을까요? 처음에는Board컴포넌트가 각Square컴포넌트에게 해당Square의 상태를 "물어봐야" 한다고 추측할 수 있습니다. 이 접근 방식은 React에서 기술적으로 가능하지만, 코드를 이해하기 어렵고 버그에 취약하며 리팩토링하기 어려워지기 때문에 권장하지 않습니다. 대신, 각Square컴포넌트가 아닌 부모Board컴포넌트에 게임 상태를 저장하는 것이 가장 좋은 방법입니다.Board컴포넌트는 prop을 전달하여 각Square컴포넌트에 무엇을 표시할지 알려줄 수 있습니다. 이는 각 Square에 숫자를 전달했을 때와 유사합니다.
여러 자식으로부터 데이터를 수집하거나 두 자식 컴포넌트가 서로 통신하게 하려면, 공유 state를 부모 컴포넌트에 선언하세요. 부모 컴포넌트는 props를 통해 해당 state를 자식에게 다시 전달할 수 있습니다. 이렇게 하면 자식 컴포넌트들이 서로 그리고 부모와 동기화 상태를 유지합니다.
React 컴포넌트를 리팩토링할 때 state를 부모 컴포넌트로 끌어올리는 것은 일반적입니다.
이 기회에 한번 시도해 봅시다.Board 컴포넌트를 편집하여 squares라는 이름의 state 변수를 선언하고, 기본값을 9개의 칸에 해당하는 9개의 null로 구성된 배열로 설정하세요:
Array(9).fill(null)은 9개의 요소를 가진 배열을 생성하고 각 요소를null로 설정합니다. 이를 감싸는useState()호출은 초기에 해당 배열로 설정된squaresstate 변수를 선언합니다. 배열의 각 항목은 한 칸의 값에 해당합니다. 나중에 보드를 채우면squares배열은 다음과 같이 보일 것입니다:
이제 Board컴포넌트는 렌더링하는 각Square 컴포넌트에 valueprop을 전달해야 합니다:
다음으로, Square컴포넌트를 편집하여 Board 컴포넌트로부터valueprop을 받도록 합니다. 이를 위해서는 Square 컴포넌트 자체의value상태 추적과 버튼의
이 시점에서 빈 틱택토 보드가 보여야 합니다:

그리고 코드는 다음과 같이 보일 것입니다:
이제 각 Square는valueprop을 받게 되며, 이 값은'X','O', 또는 빈 칸을 나타내는null중 하나입니다.
다음으로, Square가 클릭되었을 때 발생하는 일을 변경해야 합니다.Board컴포넌트는 이제 어떤 칸이 채워졌는지 관리합니다.Square가 Board의 state를 업데이트할 수 있는 방법을 만들어야 합니다. state는 그것을 정의하는 컴포넌트에 비공개이므로,Square에서 직접 Board의 state를 업데이트할 수 없습니다.
대신, Board컴포넌트에서Square컴포넌트로 함수를 전달하고, 칸이 클릭되었을 때Square가 그 함수를 호출하도록 할 것입니다. 먼저 Square컴포넌트가 클릭되었을 때 호출할 함수부터 시작하겠습니다. 이 함수를onSquareClick이라고 부르겠습니다:
다음으로, onSquareClick 함수를 Square컴포넌트의 props에 추가할 것입니다:
이제 onSquareClick prop을 Board 컴포넌트 내부의 handleClick이라는 함수에 연결할 것입니다.onSquareClick을 handleClick에 연결하려면 첫 번째Square 컴포넌트의 onSquareClickprop에 함수를 전달하면 됩니다:
마지막으로, 보드 상태를 보관하는squares배열을 업데이트하기 위해 Board 컴포넌트 내부에handleClick함수를 정의할 것입니다:
이 handleClick함수는 JavaScriptslice()배열 메서드를 사용하여squares배열의 복사본(nextSquares)을 만듭니다. 그런 다음,handleClick은 첫 번째([0]인덱스) 칸에X를 추가하도록nextSquares배열을 업데이트합니다.
이 setSquares함수를 호출하면 React에게 컴포넌트의 상태가 변경되었음을 알립니다. 이로 인해squares상태를 사용하는 컴포넌트(Board)와 그 자식 컴포넌트들(보드를 구성하는Square컴포넌트들)이 다시 렌더링됩니다.
참고
JavaScript는클로저를 지원합니다. 이는 내부 함수(예:handleClick)가 외부 함수(예: Board)에 정의된 변수와 함수에 접근할 수 있음을 의미합니다.handleClick 함수는 squares상태를 읽고setSquares메서드를 호출할 수 있습니다. 왜냐하면 둘 다Board함수 내부에 정의되어 있기 때문입니다.
이제 보드에 X를 추가할 수 있지만, 왼쪽 상단 사각형에만 추가할 수 있습니다. 여러분의handleClick 함수는 왼쪽 상단 사각형의 인덱스(0)를 업데이트하도록 하드코딩되어 있습니다.handleClick을 업데이트하여 어떤 사각형이든 업데이트할 수 있도록 해봅시다. 업데이트할 사각형의 인덱스를 받는 인수i를 handleClick 함수에 추가하세요:
다음으로, 그 i를 handleClick에 전달해야 합니다. 다음과 같이 JSX에서 square의onSquareClick prop을 handleClick(0)으로 직접 설정해 볼 수 있지만, 이는 작동하지 않습니다:
이것이 작동하지 않는 이유는 다음과 같습니다.handleClick(0)호출은 보드 컴포넌트를 렌더링하는 과정의 일부가 될 것입니다.handleClick(0)가 setSquares를 호출하여 보드 컴포넌트의 상태를 변경하기 때문에, 전체 보드 컴포넌트가 다시 렌더링됩니다. 하지만 이는 다시handleClick(0)를 실행하게 되어 무한 루프를 초래합니다:
콘솔
너무 많은 재렌더링이 발생했습니다. React는 무한 루프를 방지하기 위해 렌더링 횟수를 제한합니다.
왜 이 문제가 이전에는 발생하지 않았을까요?
당신이 onSquareClick={handleClick}를 전달했을 때는,handleClick함수를 prop으로 전달한 것이었습니다. 당신은 그것을 호출하지 않았습니다! 하지만 지금은 당신이 그 함수를즉시 호출하고 있습니다—handleClick(0)의 괄호를 주목하세요—그래서 그것이 너무 일찍 실행되는 것입니다. 당신은 사용자가 클릭할 때까지handleClick을 호출하고 싶지 않습니다!
이 문제는 handleFirstSquareClick처럼 handleClick(0)를 호출하는 함수,handleSecondSquareClick처럼 handleClick(1)를 호출하는 함수 등을 만들어서 해결할 수 있습니다. 당신은 이러한 함수들을 (호출하는 대신)onSquareClick={handleFirstSquareClick}와 같이 prop으로 전달할 것입니다. 이것은 무한 루프를 해결할 것입니다.
그러나 아홉 개의 서로 다른 함수를 정의하고 각각에 이름을 붙이는 것은 너무 장황합니다. 대신, 이렇게 해 봅시다:
새로운 () =>구문을 확인하세요. 여기서() => handleClick(0)는 화살표 함수로, 함수를 정의하는 더 짧은 방법입니다. 사각형을 클릭하면=>"화살표" 뒤의 코드가 실행되어handleClick(0)을 호출합니다.
이제 다른 여덟 개의 사각형도 전달한 화살표 함수에서handleClick을 호출하도록 업데이트해야 합니다. 각 handleClick호출의 인수가 올바른 사각형의 인덱스에 해당하는지 확인하세요:
이제 다시 보드의 어떤 사각형이든 클릭하여 X를 추가할 수 있습니다:

하지만 이번에는 모든 상태 관리가Board컴포넌트에서 처리됩니다!
코드는 다음과 같아야 합니다:
이제 상태 처리가 Board컴포넌트에 있으므로, 부모Board 컴포넌트가 자식 Square컴포넌트에 props를 전달하여 올바르게 표시되도록 합니다.Square를 클릭하면, 자식Square컴포넌트는 이제 부모Board컴포넌트에게 보드의 상태를 업데이트하도록 요청합니다.Board의 상태가 변경되면,Board컴포넌트와 모든 자식Square가 자동으로 다시 렌더링됩니다. 모든 사각형의 상태를Board컴포넌트에 유지하면 나중에 승자를 결정할 수 있게 됩니다.
사용자가 보드의 왼쪽 상단 사각형을 클릭하여 X를 추가할 때 어떤 일이 발생하는지 다시 살펴보겠습니다:
- 왼쪽 상단 사각형을 클릭하면
button이Square컴포넌트로부터onClickprop으로 받은 함수를 실행합니다.Square컴포넌트는 이 함수를Board컴포넌트로부터onSquareClickprop으로 받았습니다.Board컴포넌트는 이 함수를 JSX 내에서 직접 정의했습니다. 이 함수는 인수0을 사용하여handleClick을 호출합니다. handleClick은 인수(0)를 사용하여squares배열의 첫 번째 요소를null에서X로 업데이트합니다.-
Board컴포넌트의squaresstate가 업데이트되었으므로,Board와 그 모든 자식 컴포넌트가 다시 렌더링됩니다. 이로 인해 인덱스0을 가진Square컴포넌트의valueprop이null에서X로 변경됩니다.
결과적으로 사용자는 왼쪽 상단 사각형을 클릭한 후 빈 상태에서X가 표시된 상태로 변경된 것을 보게 됩니다.
참고
DOM<button> 요소의 onClick속성은 React 내장 컴포넌트이기 때문에 React에게 특별한 의미를 가집니다. Square와 같은 사용자 정의 컴포넌트의 경우 이름 지정은 사용자에게 달려 있습니다.Square의onSquareClickprop이나Board의handleClick함수에 어떤 이름을 붙여도 코드는 동일하게 작동합니다. React에서는 이벤트를 나타내는 prop에는onSomething형식의 이름을, 해당 이벤트를 처리하는 함수 정의에는handleSomething형식의 이름을 사용하는 것이 관례입니다.
불변성이 중요한 이유
handleClick에서 기존 배열을 수정하는 대신.slice()를 호출하여 squares배열의 복사본을 만드는 방법에 주목하세요. 그 이유를 설명하려면 불변성과 불변성을 배워야 하는 이유에 대해 논의해야 합니다.
일반적으로 데이터를 변경하는 데는 두 가지 접근 방식이 있습니다. 첫 번째 접근 방식은 데이터의 값을 직접 변경하여 데이터를변형(mutate)하는 것입니다. 두 번째 접근 방식은 원하는 변경 사항이 적용된 새로운 복사본으로 데이터를 교체하는 것입니다. 다음은squares배열을 변형한 경우의 모습입니다:
그리고 다음은 squares배열을 변형하지 않고 데이터를 변경한 경우의 모습입니다:
결과는 동일하지만, 직접 변형(기본 데이터 변경)하지 않음으로써 여러 가지 이점을 얻을 수 있습니다.
불변성은 복잡한 기능을 훨씬 쉽게 구현할 수 있게 해줍니다. 이 튜토리얼 후반부에서 게임 기록을 검토하고 과거 이동으로 '돌아가기'할 수 있는 '시간 여행' 기능을 구현하게 될 것입니다. 이 기능은 게임에만 국한된 것이 아닙니다. 특정 작업을 실행 취소하고 다시 실행하는 기능은 앱에서 일반적으로 요구되는 사항입니다. 직접적인 데이터 변형을 피하면 데이터의 이전 버전을 그대로 유지하고 나중에 재사용할 수 있습니다.
불변성에는 또 다른 이점이 있습니다. 기본적으로 부모 컴포넌트의 state가 변경되면 모든 자식 컴포넌트는 자동으로 다시 렌더링됩니다. 여기에는 변경의 영향을 받지 않은 자식 컴포넌트도 포함됩니다. 다시 렌더링 자체는 사용자에게 눈에 띄지 않을 수 있지만(적극적으로 피하려고 해서는 안 됩니다!), 성능상의 이유로 명확히 영향을 받지 않은 트리의 일부 다시 렌더링을 건너뛰고 싶을 수 있습니다. 불변성은 컴포넌트가 자신의 데이터가 변경되었는지 비교하는 비용을 매우 저렴하게 만듭니다. React가 컴포넌트를 언제 다시 렌더링할지 선택하는 방법에 대해 더 알아보려면memo API 레퍼런스를 참조하세요.
순서 바꾸기
이제 이 틱택토 게임의 주요 결함을 수정할 때입니다: 보드에 "O"를 표시할 수 없습니다.
첫 번째 이동을 기본적으로 "X"로 설정하겠습니다. Board 컴포넌트에 또 다른 state를 추가하여 이를 추적해 보겠습니다:
플레이어가 수를 둘 때마다,xIsNext(불리언)가 뒤집혀 다음 플레이어를 결정하고 게임 상태가 저장됩니다.Board의handleClick함수를 업데이트하여xIsNext값을 뒤집을 것입니다:
이제 다른 사각형을 클릭하면, X와 O가 번갈아 나타나게 됩니다!
하지만 잠깐, 문제가 있습니다. 같은 사각형을 여러 번 클릭해 보세요:

X가 O로 덮어쓰여졌습니다! 이는 게임에 매우 흥미로운 변주를 더할 수 있지만, 지금은 원래 규칙을 따르도록 하겠습니다.
사각형에 X나 O를 표시할 때, 해당 사각형에 이미X나 O값이 있는지 먼저 확인하지 않았습니다. 이를조기 반환으로 수정할 수 있습니다. 사각형에 이미X나 O가 있는지 확인합니다. 사각형이 이미 채워져 있다면,handleClick함수에서 보드 상태를 업데이트하려고 시도하기 전에return하여 조기 종료할 것입니다.
이제 빈 사각형에만X나 O를 추가할 수 있습니다! 이 시점에서 코드는 다음과 같아야 합니다:
승자 선언하기
이제 플레이어들이 번갈아 수를 둘 수 있으므로, 게임이 승리로 끝나고 더 이상 둘 수 있는 수가 없을 때를 표시하고 싶을 것입니다. 이를 위해 9개의 사각형 배열을 받아 승자를 확인하고 적절히'X', 'O', 또는null을 반환하는calculateWinner라는 도우미 함수를 추가할 것입니다.calculateWinner함수에 대해 너무 걱정하지 마세요; 이는 React에 특정된 것이 아닙니다:
참고
calculateWinner를 Board앞이나 뒤에 정의하는 것은 중요하지 않습니다. 컴포넌트를 편집할 때마다 스크롤을 넘기지 않도록 끝에 배치하겠습니다.
플레이어가 승리했는지 확인하기 위해Board컴포넌트의handleClick함수에서calculateWinner(squares)를 호출할 것입니다. 사용자가 이미 X나 O가 있는 사각형을 클릭했는지 확인하는 동시에 이 검사를 수행할 수 있습니다. 두 경우 모두 조기 반환하고 싶습니다:
게임이 끝났을 때 플레이어에게 알려주기 위해 "승자: X" 또는 "승자: O"와 같은 텍스트를 표시할 수 있습니다. 이를 위해status 섹션을 Board컴포넌트에 추가하겠습니다. 상태는 게임이 끝나면 승자를 표시하고, 게임이 진행 중이면 다음 차례 플레이어를 표시합니다:
축하합니다! 이제 작동하는 틱택토 게임이 완성되었습니다. 그리고 React의 기초도 배웠습니다. 따라서당신이 진정한 승자입니다. 코드는 다음과 같아야 합니다:
시간 여행 추가하기
마지막 연습으로, 게임에서 이전 수로 "시간을 거슬러 올라가는" 기능을 만들어 보겠습니다.
수 기록 저장하기
만약 squares배열을 변형했다면, 시간 여행을 구현하기는 매우 어려웠을 것입니다.
하지만, 당신은 매 수마다slice()를 사용하여 squares배열의 새 복사본을 만들고, 이를 불변으로 취급했습니다. 이를 통해squares배열의 모든 과거 버전을 저장하고, 이미 발생한 차례 사이를 탐색할 수 있습니다.
과거의 squares 배열들을 history라는 또 다른 배열에 저장할 것입니다. 이 배열은 새로운 상태 변수로 저장됩니다.history배열은 첫 번째 수부터 마지막 수까지의 모든 보드 상태를 나타내며, 다음과 같은 형태를 가집니다:
상태 끌어올리기, 다시
이제 Game이라는 새로운 최상위 컴포넌트를 작성하여 과거 수들의 목록을 표시하겠습니다. 여기에 전체 게임 기록을 포함하는history 상태를 배치할 것입니다.
상태를 history에 배치하면 자식Game 컴포넌트에서 Board의 squares 상태를 제거할 수 있습니다. Square컴포넌트에서Board컴포넌트로 "상태를 끌어올린" 것처럼, 이제Board에서 최상위Game컴포넌트로 상태를 끌어올릴 것입니다. 이를 통해Game 컴포넌트가 Board의 데이터를 완전히 제어하고,Board에게 history에서 이전 차례를 렌더링하도록 지시할 수 있습니다.
먼저, Game컴포넌트를export default와 함께 추가하세요. 이 컴포넌트가Board컴포넌트와 일부 마크업을 렌더링하도록 합니다:
여기서는 export default 키워드를 function Board() { 선언 앞에서 제거하고 function Game() {선언 앞에 추가합니다. 이렇게 하면index.js 파일이 Board 컴포넌트 대신 Game컴포넌트를 최상위 컴포넌트로 사용하도록 지시합니다.Game컴포넌트가 반환하는 추가적인div들은 나중에 보드에 추가할 게임 정보를 위한 공간을 마련합니다.
다음 플레이어와 이동 기록을 추적하기 위해Game 컴포넌트에 상태를 추가하세요:
여기서 [Array(9).fill(null)]는 단일 항목을 가진 배열이며, 그 항목 자체는 9개의null로 채워진 배열입니다.
현재 이동에 대한 사각형을 렌더링하려면 history에서 마지막 사각형 배열을 읽어야 합니다. 이를 위해useState가 필요하지 않습니다—렌더링 중에 이를 계산하기에 충분한 정보가 이미 있습니다:
다음으로, 게임을 업데이트하기 위해Board컴포넌트가 호출할Game 컴포넌트 내부에 handlePlay 함수를 만드세요. xIsNext,currentSquares 및 handlePlay를 Board컴포넌트에 props로 전달하세요:
이제 Board컴포넌트가 받는 props에 의해 완전히 제어되도록 만들어 보겠습니다.Board컴포넌트가 세 가지 props를 받도록 변경하세요:xIsNext,squares, 그리고 플레이어가 이동할 때 업데이트된 사각형 배열로Board가 호출할 수 있는 새로운onPlay 함수입니다. 다음으로, useState를 호출하는Board함수의 처음 두 줄을 제거하세요:
이제 Board컴포넌트의handleClick함수 내의setSquares 및 setXIsNext 호출을 새로운 onPlay함수에 대한 단일 호출로 대체하여 사용자가 사각형을 클릭할 때Game 컴포넌트가 Board를 업데이트할 수 있도록 하세요:
이제Board 컴포넌트는 Game컴포넌트가 전달한 props에 의해 완전히 제어됩니다. 게임이 다시 작동하도록 하려면Game컴포넌트에서handlePlay함수를 구현해야 합니다.
호출될 때handlePlay는 무엇을 해야 할까요? Board가 이전에는 업데이트된 배열로setSquares를 호출했던 것을 기억하세요; 이제는 업데이트된squares배열을 onPlay에 전달합니다.
또한 Board가 이전에 했던 것처럼 xIsNext를 토글하고 싶습니다:
여기서[...history, nextSquares]는 history의 모든 항목과 그 뒤에nextSquares를 포함하는 새 배열을 생성합니다. (...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>와 같은 React 요소는 일반 JavaScript 객체입니다. 애플리케이션에서 이를 전달할 수 있습니다. React에서 여러 항목을 렌더링하려면 React 요소의 배열을 사용할 수 있습니다.
상태에 이미 history이동 배열이 있으므로, 이제 이를 React 요소 배열로 변환해야 합니다. JavaScript에서 한 배열을 다른 배열로 변환하려면배열 map 메서드를 사용할 수 있습니다:
화면의 버튼을 나타내는 React 요소로 이동history를 변환하고, 지난 이동으로 "점프"할 수 있는 버튼 목록을 표시하기 위해map을 사용할 것입니다. Game 컴포넌트에서 history에 대해map을 적용해 보겠습니다:
아래에서 코드가 어떻게 보여야 하는지 확인할 수 있습니다. 개발자 도구 콘솔에 다음과 같은 오류가 표시될 것입니다:
이 오류는 다음 섹션에서 수정할 것입니다.
함수 내에서 history 배열을 순회하며 map에 전달할 때,squares 인자는 history의 각 요소를,move인자는 각 배열 인덱스(0,1,2, …)를 거칩니다. (대부분의 경우 실제 배열 요소가 필요하지만, 이동 목록을 렌더링할 때는 인덱스만 필요합니다.)
틱택토 게임 기록의 각 이동에 대해, 버튼<li>을 포함하는 목록 항목<button>을 생성합니다. 이 버튼에는 아직 구현되지 않은onClick 핸들러가 있으며, jumpTo라는 함수를 호출합니다.
지금은 게임에서 발생한 이동 목록과 개발자 도구 콘솔의 오류를 볼 수 있을 것입니다. "key" 오류의 의미에 대해 논의해 보겠습니다.
키 선택하기
목록을 렌더링할 때, React는 렌더링된 각 목록 항목에 대한 정보를 저장합니다. 목록을 업데이트할 때 React는 무엇이 변경되었는지 판단해야 합니다. 목록 항목을 추가, 제거, 재정렬 또는 업데이트했을 수 있습니다.
다음에서 전환되는 것을 상상해 보세요.
에서
업데이트된 카운트 외에도, 사람이 이 목록을 읽는다면 아마도 Alexa와 Ben의 순서를 바꾸고 그 사이에 Claudia를 삽입했다고 말할 것입니다. 그러나 React는 컴퓨터 프로그램이므로 여러분의 의도를 알지 못합니다. 따라서 각 목록 항목을 형제 항목들과 구분하기 위해 각 목록 항목마다key속성을 지정해야 합니다. 데이터가 데이터베이스에서 온 것이라면 Alexa, Ben, Claudia의 데이터베이스 ID를 키로 사용할 수 있습니다.
목록이 다시 렌더링될 때, React는 각 목록 항목의 키를 가져와 이전 목록의 항목들에서 일치하는 키를 검색합니다. 현재 목록에 이전에 존재하지 않던 키가 있다면 React는 컴포넌트를 생성합니다. 현재 목록에 이전 목록에 존재했던 키가 누락되었다면 React는 이전 컴포넌트를 제거합니다. 두 키가 일치하면 해당 컴포넌트가 이동됩니다.
키는 React에게 각 컴포넌트의 정체성을 알려주어, React가 다시 렌더링하는 동안 상태를 유지할 수 있게 합니다. 컴포넌트의 키가 변경되면, 해당 컴포넌트는 제거되고 새로운 상태로 다시 생성됩니다.
key는 React에서 특별하고 예약된 속성입니다. 요소가 생성될 때 React는key속성을 추출하여 반환된 요소에 직접 키를 저장합니다.key가 props로 전달되는 것처럼 보일 수 있지만, React는key를 자동으로 사용하여 어떤 컴포넌트를 업데이트할지 결정합니다. 컴포넌트가 부모가 지정한key가 무엇인지 물어볼 방법은 없습니다.
동적 목록을 구축할 때마다 적절한 키를 할당하는 것이 강력히 권장됩니다.적절한 키가 없다면, 키를 가질 수 있도록 데이터 구조를 재구성하는 것을 고려해볼 수 있습니다.
키가 지정되지 않으면 React는 오류를 보고하고 기본적으로 배열 인덱스를 키로 사용합니다. 배열 인덱스를 키로 사용하는 것은 목록 항목의 순서를 재정렬하거나 항목을 삽입/제거할 때 문제가 됩니다. 명시적으로key={i}를 전달하면 오류는 사라지지만 배열 인덱스와 동일한 문제를 가지며 대부분의 경우 권장되지 않습니다.
키는 전역적으로 고유할 필요가 없으며, 컴포넌트와 그 형제들 사이에서만 고유하면 됩니다.
시간 여행 구현하기
틱택토 게임의 기록에서, 각 과거 수는 고유한 ID와 연관되어 있습니다: 바로 수의 순차적인 번호입니다. 수는 절대 재정렬, 삭제 또는 중간에 삽입되지 않으므로, 수의 인덱스를 키로 사용해도 안전합니다.
Game 함수에서 키를 <li key={move}>로 추가할 수 있으며, 렌더링된 게임을 다시 로드하면 React의 "key" 오류가 사라져야 합니다:
이를 위해 라는 새로운 상태 변수를 정의하고, 기본값을 으로 설정하세요:
또한 를 변경하는 숫자가 짝수일 경우 를 로 설정할 것입니다
이제 사각형을 클릭할 때 호출되는 의 함수에 두 가지 변경 사항을 적용할 것입니다.
- "시간을 거슬러 올라가" 그 시점에서 새로운 이동을 하는 경우, 그 시점까지의 기록만 유지하고 싶을 것입니다. 의 모든 항목 뒤에 를 추가하는 대신, 의 모든 항목 뒤에 추가하여 오래된 기록의 해당 부분만 유지하도록 합니다.
- 이동이 수행될 때마다 를 최신 기록 항목을 가리키도록 업데이트해야 합니다.
마지막으로, 컴포넌트를 수정하여 항상 마지막 이동을 렌더링하는 대신 현재 선택된 이동을 렌더링하도록 합니다:
게임 기록의 어떤 단계를 클릭하더라도, 틱택토 보드는 해당 단계 이후의 보드 모습을 즉시 표시하도록 업데이트되어야 합니다.
최종 정리
코드를 자세히 살펴보면, xIsNext === true인 경우는currentMove가 짝수일 때이고,xIsNext === false인 경우는currentMove가 홀수일 때라는 것을 알 수 있습니다. 즉,currentMove의 값을 알면 항상 xIsNext가 무엇이어야 하는지 파악할 수 있습니다.
이 두 가지를 모두 상태로 저장할 이유가 없습니다. 사실, 항상 중복된 상태를 피하려고 노력해야 합니다. 상태에 저장하는 것을 단순화하면 버그를 줄이고 코드를 더 쉽게 이해할 수 있습니다.Game을 변경하여 xIsNext를 별도의 상태 변수로 저장하지 않고, 대신currentMove를 기반으로 계산하도록 하세요:
이제 xIsNext 상태 선언이나 setXIsNext호출이 필요하지 않습니다. 이제는 컴포넌트를 코딩하는 동안 실수를 하더라도xIsNext가 currentMove와 동기화되지 않을 가능성이 없습니다.
마무리
축하합니다! 다음과 같은 틱택토 게임을 만들었습니다:
- 틱택토를 플레이할 수 있고,
- 플레이어가 게임에서 승리했을 때를 표시하며,
- 게임이 진행됨에 따라 게임 기록을 저장하고,
- 플레이어가 게임 기록을 검토하고 이전 버전의 게임 보드를 볼 수 있습니다.
잘하셨습니다! 이제 React가 어떻게 작동하는지에 대해 어느 정도 이해하셨기를 바랍니다.
최종 결과는 여기에서 확인하세요:
시간이 남거나 새로 배운 React 기술을 연습하고 싶다면, 틱택토 게임에 적용할 수 있는 개선 아이디어를 난이도 순으로 나열했습니다:
- 현재 이동에 대해서만 버튼 대신 "현재 #...번째 이동입니다"를 표시하세요.
- 하드코딩 대신 두 개의 루프를 사용하여 사각형을 만들도록
Board를 다시 작성하세요. - 이동 목록을 오름차순 또는 내림차순으로 정렬할 수 있는 토글 버튼을 추가하세요.
- 누군가 승리하면 승리를 결정한 세 개의 사각형을 강조 표시하세요(그리고 아무도 승리하지 않으면 무승부 결과에 대한 메시지를 표시하세요).
- 이동 기록 목록에 각 이동의 위치를 (행, 열) 형식으로 표시하세요.
이 튜토리얼을 통해 엘리먼트, 컴포넌트, props, 상태를 포함한 React 개념을 다뤘습니다. 이제 게임을 만들 때 이러한 개념이 어떻게 작동하는지 보았으니,React로 사고하기를 확인하여 앱 UI를 구축할 때 동일한 React 개념이 어떻게 작동하는지 살펴보세요.
