완벽가이드 - 실습 : tic-tac-toe

primav·2024년 11월 21일

React

목록 보기
21/35
post-thumbnail

📌 Header

왜 header를 리액트 외부의 파일 index.html에 두는가?

리액트는 기본적으로 Javascript로 UI를 동적으로 렌더링하지만, 정적 콘텐츠는 React 외부에서 미리 정의하는 것이 효율적이다.

따라서 이 프로젝트에서는 header 를 React 외부에서 index.html에 작성한다.
이 코드에서 header는 로고 이미지와 페이지 제목만을 포함하므로 변하지 않는 정적 데이터이다.
이와 같은 콘텐츠는 페이지가 처음 로드될 때 한 번만 렌더링 되면 된다.
(초기 페이지 로딩이 더 빠르고, HTML의 의미론적인 구조를 더 잘 유지할 수 있다 ➡️ SEO (검색 엔진 최적화) 및 접근성 측면에서 유리)

 <body>
    <!-- 사용자들이 보는 페이지 -->
    <!-- 다른 데이터에 영향 받지 않는 정적 데이터는 여기 안에 넣어서 마크업 해도 된다. -->
    <!-- public 폴더 안에 있다면 index.html 파일과 함께 사이트 방문자에게도 공유된다. -->
    <!-- public/image.png 경로를 사용하면 루트를 따라 경로를 찾는 것이므로 x -->
    <!-- image.png 공개되는 public 안에 있는 이름 그대로 사용 o -->

    <header>
      <img src="game-logo.png" alt="Hand-drawn tic tac toe game board" />
      <h1>React Tic-Tac-Toe</h1>
    </header>
    <div id="root"></div>
    <script type="module" src="/src/index.jsx"></script>
</body>

public에 저장하는 이미지

index.html에서 사용되는 이미지는 정적 리소스로 제공되는 내용이다.
리액트는 이 파일을 public 폴더에 넣어두고, 웹사이트가 처음 로드될 때 필요한 자원을 제공하며, 그 후 리액트가 페이지 렌더링을 담당한다.

예를 들어, public/game-logo.png는 웹 서버에서 클라이언트로 직접 전달되며, React가 이 이미지를 직접 관리할 필요는 없다. 이미지는 브라우저에서 바로 로드되고, public 폴더 안에 있는 자원들은 브라우저가 바로 접근할 수 있게 된다.

그럼 이미지 저장소는 public? assets?

  • public 폴더
    여기에 저장된 이미지/파일은 프로젝트 개발 서버 및 빌드 프로세스에 의해 공개적으로 제공된다.
    이 파일들은 브라우저 내에서 직접 방문할 수 있으며, 다른 파일에 의해 요청될 수도 있다.

    예를 들어 localhost:5173/some-image.jpg를 불러오면 해당 이미지를 볼 수 있다.

  • src/assets 폴더
    이 폴더와 같이 하위 폴더에 저장된 모든 파일은 공개적으로 제공되지 않으므로, 웹 사이트 방문자가 접근할 수 없다.

대신, 코드 파일에서는 사용 가능하며 코드 파일에 가져온 이미지는 빌드 프로세스에 의해 인식되어 최적화된다.
웹사이트에 제공하기 직전에 public 폴더에 삽입되는 것이다.

👀 그렇다면 어떤 폴더를 사용해야 할까?

빌드 프로세스에 의해 처리되지 않는 이미지는 public 폴더에 저장하고, 컴포넌트 내에서 사용되는 이미지는 일반적으로 src/assets에 저장 되어야 한다.

따라서 이 프로젝트에서 header는 정적 데이터라 index.html 파일에서 사용하기로 했으므로, 빌드 프로세스에 의해 처리되지 않고 브라우저에서 직접 방문할 수 있도록 public 폴더에 이미지를 넣자.

📌 컴포넌트

컴포넌트는 어떤 상황에서 필요할까?

아래의 코드는 각 플레이어의 정보를 나타내는 코드이다.
이 두 플레이어를 나타내는 코드는 클래스와 태그까지 거의 동일하다.
이런 경우 재사용성을 위해 컴포넌트를 만들어주면 된다.

✔️ 컴포넌트 이전

📁 App.jsx
<ol id="players">
      {/* 플레이어1 플레이어2 를 구분할 것이므로 의미론적으로 순서 없는 리스트를 사용하자 */}
      <li>
        <span>
          <span className="player-name">Player 1</span>
          <span className="player-symbol">X</span>
        </span>
        <button>Edit</button>
      </li>
      <li>
        <span>
          <span className="player-name">Player 2</span>
          <span className="player-symbol">O</span>
        </span>            
        <button>Edit</button>
     </li>
</ol>

✔️ 컴포넌트 이후

📁 App.jsx
<main>
      <div id="game-container">
        <ol id="players">
          <Player name="Player 1" symbol="X" />
          <Player name="Player 2" symbol="O" />
        </ol>
      </div>
    </main>

  
📁 Player.jsx
export default function Player({ name, symbol }) {
  return (
    <>
      {/* 플레이어1 플레이어2 를 구분할 것이므로 의미론적으로 순서 없는 리스트를 사용하자 */}
      <li>
        <span className="player">
          <span className="player-name">{name}</span>
          <span className="player-symbol">{symbol}</span>
        </span>
        <button>Edit</button>
      </li>
    </>
  );
}

📌 useState

useState는 React의 상태(state)를 관리하기 위한 Hook이다.

React 컴포넌트에서 데이터의 상태를 유지하고, 상태가 변경되었을 때 UI를 자동으로 업데이트해주는 역할을 한다.

useState 구조

useState는 배열을 반환한다.

  • 상태 변수 - 현재 상태 값을 저장
  • 상태 업데이트 함수 - 상태 값을 변경할 때 사용
    ❗️ 상태가 변경되면 해당 컴포넌트를 재렌더링

상태 변경 특징

  • 재렌더링 발생 - 함수로 상태를 변경하면 리액트가 해당 컴포넌트 다시 렌더링
  • 비동기적으로 작동 - 상태가 즉시 변경x 렌더링 사이클에 맞춰 업데이트
  • 상태가 새로 렌더링에 반영 - 변경된 상태는 컴포넌트가 다시 렌더링 되었을 때 적용

상태 업데이트 방식

1. 새로운 값으로 상태 설정

	setCount(count + 1)

2. 함수형 업데이트

	setCount((prevCount) => prevCount + 1);

useState의 장점

  • 간단하고 직관적 - 상태를 선언하고 사용할 수 있는 쉬운 API
  • 컴포넌트별 상태 관리 - 상태가 각 컴포넌트에 독립적으로 존재
  • UI와 상태의 동기화 - 상태가 변경되면 UI가 자동으로 업데이트

조건부 렌더링 처리 (변수 vs 삼항 연산자)

✔️ JSX 분리 - 변수처리
playerName 변수에 JSX를 미리 저장한 후, 조건에 따라 해당 변수를 변경
if 조건문을 사용해 상태를 처리하며, return 내부가 상대적으로 깔끔해보인다.
조건이 복잡하고 로직이 많은 경우

const [isEditing, setIsEditing] = useState(false);

  const handleEditing = () => {
    setIsEditing(true); 
    // useState 함수를 불러오면 알아서 재렌더링됨 --> 알아서 상태 변경
    // 이때 리액트는 jsx코드를 검토해 수정된 부분이 있는 지 확인하고
    // DOM과 다른 컴포넌트 함수에 반영한다.
  };

  let playerName = <span className="player-name">{name}</span>;

  if (isEditing) {
    playerName = <input type="text" required />;
  }

return (
    <>
      <li>
        <span className="player">
          {playerName}
          <span className="player-symbol">{symbol}</span>
        </span>
        <button onClick={handleEditing}>Edit</button>
      </li>
    </>
  );

✔️ 삼항연산자
조건부 렌더링을 return 내부에 포함시킨다. (삼항연산자)
조건이 단순하고 JSX가 짧은 경우

 return (
    <>
      <li>
        <span className="player">
		{!isEditing 
  			? (<span className="player-name">{name}</span>)  
			: (<input type="text" required />)
		}
          <span className="player-symbol">{symbol}</span>
        </span>
        <button onClick={handleEditing}>Edit</button>
      </li>
    </>

📌 상태 변경

아래의 코드는 버튼 클릭으로 편집 / 저장이 왔다갔다 하는 코드를 작성한 것이다.

1️⃣ !를 사용하여 상태 변경

 const handleEdit = () => {
    setIsEditing(!isEditing);
  };

하지만, 상태를 이전 값에 기반하여 변경할 경우에는 다른 방법을 써야한다.

2️⃣ 함수를 사용하여 상태 변경

 const handleEdit = () => {
    setIsEditing((editing) => !editing);
  };

리액트가 선호하는 방식은 2번째 함수를 사용하여 상태를 변경하는 방식이다.

왜 함수를 사용해서 상태를 변경해야할까?

1. 리액트의 비동기 상태 업데이트

setState는 비동기적으로 동작한다.
이 때문에 상태가 즉시 업데이트되지 않고, 이전 상태 값이 예상과 다를 수 있다.

  • 1️⃣ 방식
    setIsEditing(!isEditing) 을 사용할 경우, 리액트가 비동기로 상태를 업데이트 하는 과정에서 최신 상태를 보장하지 못할 가능성이 생긴다.

  • 2️⃣ 방식
    함수형 업데이트를 사용하므로 현재 상태를 정확히 참조할 수 있다.

2. 상태 변경이 중첩될 때

만약 상태 변경이 빠르게 연속으로 일어나거나 여러 이벤트에서 동시에 상태를 변경하려 한다면, 이전 상태 값을 기반으로 하는 업데이트가 오류를 방지한다.

const [isEditing, setIsEditing] = useState(false);

 
export default function Player({ name, symbol }) {setIsEditing(!isEditing); // => true
	setIsEditing(!isEditing); // => true
	// 두 변화 모두 현재 상태의 isEditing을 기준으로 한다. (false 기준)

 ✅ 함수형
	setIsEditing((editing) => !editing); // => true   
	setIsEditing((editing) => !editing); // => false
}
  • 1️⃣ 방식
    Player 라는 컴포넌트 함수가 처음 실행되는 시점에 isEditing 값 (false)가 기준이 되므로, 상태가 변경되지 않고 유지된다.
  • 2️⃣ 방식
    setIsEditing 함수는 현재 상태 값을 직접 참조하는 것이 아니라, 상태 변경 함수가 호출될 때마다 최신 상태를 반영하여 업데이트한다.
    이전 상태의 값인 editing 을 인자로 받아 새로운 상태를 계산

따라서 2️⃣ 함수 형태로 상태를 변경하는 것이 좋다.

비동기 상태 업데이트란 무엇일까?

리액트에서 setState 같은 상태 업데이트 함수는 비동기적으로 동작한다.
즉, 상태 업데이트가 즉시 적용되지 않고, 여러 상태 변경 요청이 있더라도 한 번의 렌더링 주기에서 합쳐질 수 있다.

상태 업데이트는 왜 비동기적일까?

리액트는 한 번의 렌더링 주기에서 여러 상태 변경 요청을 모아 최적화한다.
즉, 여러 상태 변경 요청을 묶어 하나의 렌더링만 수행해 효율적으로 화면을 업데이트하려는 목적이 있다.
이때는, setState를 호출한 직후 상태가 바로 바뀌지 않고, 업데이트가 예약된다.

비유: 우체국의 우편 처리

  1. 우편함에 편지 넣기 (상태 변경 요청)
    편지를 우체국에 넣는 순간, 편지가 즉시 배달되지 않는다.
    우체국은 편지를 한곳에 모아 둔다.

  2. 우편물을 한꺼번에 배달 준비 (비동기 처리)
    우체국은 일정한 시간이 되거나, 충분히 많은 편지가 모였을 때 배달 작업을 시작한다.
    이렇게 하면 효율적으로 배달할 수 있다.

  3. 최신 주소 확인 (함수형 업데이트)
    만약 편지를 보낼 때 주소가 바뀔 가능성이 있다면, 우체국은 최신 주소를 확인하며 배달한다.
    즉, 함수형 업데이트는 최신 상태를 보장하는 역할을 한다.

📌 event

onChange

리액트에서 폼 요소의 값이 변경될 때 실행되는 이벤트이다.

event 객체

  • e - 필드에서 발생한 이벤트에 대한 정보
  • e.target - 이벤트가 발생한 DOM 요소 (아래에서는 input)
  • e.target.value - 해당 입력 필드의 현재값
  const handleEdit = () => {
    setIsEditing((editing) => !editing);
  };

  if (isEditing) {
    editablePlayerName = (
      <input type="text" required value={playerName} onChange={handleChange} />
    );
  }

이벤트 흐름 (위의 코드)

  1. 사용자가 입력 필드에 새로운 값을 입력
    ex) 기존 값이 "Player 1"인데 "Player 2"로 수정한다면:

    • 첫 번째 입력 (P → "Player 2")에서 이벤트가 발생한다.
    • e.target.value는 현재 입력된 값 "Player 2"를 가져온다.
    • handleChange 실행
  2. setPlayerName(e.target.value)가 호출된다.

    • playerName 상태가 "Player 2"로 업데이트된다.
  3. 컴포넌트 재렌더링

    • React는 상태 변경(playerName)을 감지하고 컴포넌트를 다시 렌더링한다.
    • 렌더링 과정에서:
      새로운 상태 값 playerName이 <input>의 value 속성에 전달된다.
      사용자가 입력한 내용이 그대로 유지된다.

양방향 바인딩이란?

양방향 바인딩은 상태(state)와 UI 간의 실시간 상호작용을 말한다.
(위의 코드에서는 상태 playerNameinput 필드가 양방향 바인딩 되어 있다.)

  • UI 업데이트 → 상태 변경
    사용자가 값을 입력하면 onChange를 통해 상태가 업데이트 된다.
  • 상태 변경 → UI 업데이트
    상태가 변경되면 React가 다시 렌더링하며, <input>value에 새로운 상태를 반영한다.

➡️ 리액트에서는 이벤트 핸들러를 통해 양방향 바인딩을 구현한다.

그럼 단방향 데이터 흐름은 무엇일까?

React에서는 상태를 한 방향으로 전달하는 속성이있다.

  • 부모 컴포넌트에서 state(상태)를 자식 컴포넌트에 props로 전달
  • 자식 컴포넌트는 이 props를 읽기만 할 수 있고, 직접 수정할 수는 없음

결론

React는 기본적으로 단방향 데이터 흐름을 따르지만, 입력 필드처럼 상태와 UI 간 실시간 동기화가 필요한 경우 양방향 바인딩을 활용한다.

이를 통해 상태와 UI가 밀접하게 연결되어 사용자의 입력을 쉽게 처리할 수 있다.

구분단방향 바인딩 (One-way Binding)양방향 바인딩 (Two-way Binding)
데이터 흐름부모 → 자식부모 ↔ 자식
상태 관리상태가 한쪽에서만 관리상태와 UI가 상호작용
UI 업데이트상태 변경 시 UI가 업데이트상태 변경 시 UI 업데이트 + UI 변경 시 상태 업데이트
제어 방식상태 관리와 이벤트 핸들링이 별도로 이루어짐상태 관리와 이벤트 핸들링이 결합
코드 예시<p>{value}</p><input value={state} onChange={(e) => setState(e.target.value)}
장점- 구조가 단순
- 예측 가능
- 실시간 데이터 동기화
- 사용자 입력 처리 용이
단점- UI와 상태의 동기화 작업 필요- 복잡한 로직에서는 관리 어려움
사용 예시정적 텍스트 출력, 상태 변경이 필요 없는 컴포넌트입력 필드, 체크박스, 드롭다운 등 사용자 입력 처리 컴포넌트

📌 객체/배열 상태 변경

객체/배열 상태를 변경할 때는 원본을 복제하여 새 객체 / 배열을 저장한 후 복제한 객체나 배열을 수정하는 것이 좋다.

원본 배열을 직접 수정하는 것은 리액트의 상태 관리 원칙에 어긋나기 때문에 버그를 초래할 수 있다. 리액트는 불변성을 중요시하므로, 상태를 업데이트 할 때 항상 원본을 복제한 후 수정해야한다.

✔️ 원본 배열 수정

 function handleSelect(rowIdx, colIdx, symbol) {
    setGameBoard((prevGameBoard) => {
      prevGameBoard[rowIdx][colIdx] = "X"; // 직접 수정 --> 이전 상태의 참조를 가리켜 변경 사항을 감지하지 못함 (리액트가)
      return prevGameBoard;
    });
  }

✔️ 복제본 배열 수정 (내부 배열까지 새 참조로 복사)

  1. prevGameBoard 배열의 최상위 레벨을 [...prevGameBoard]로 복사 → 얕은 복사
  2. 내부 배열도 [...innerArray]로 다시 복사 → 얕은 복사

➡️ 이렇게 복사하면 배열과 내부 배열이 모두 새 참조를 가진다.

결과 )

내부 배열도 새로운 참조를 가지므로 깊은 복사와 유사하게 동작한다.
하지만 내부 배열 안의 더 깊은 중첩 데이터(예: 객체)가 있다면 여전히 원본과 연결된 상태이다.

 function handleSelect(rowIdx, colIdx) {
    setGameBoard((prevGameBoard) => { // 얕은 복사 --> 겉 / 속 배열 모두 복사
      const updatedBoard = [...prevGameBoard].map((innerArray) => [ // 얕은복사
        ...innerArray, // 얕은 복사
      ]); // 복제품 생성
      updatedBoard[rowIdx][colIdx] = "X"; // 복제품 수정
      return updatedBoard; // 복제품 반환
    });
  }

// setState 함수는 참조 값이 달라지면 컴포넌트를 재렌더링 한다.
// 여기서는 onClick 이벤트가 실행될때마다 새로운 참조 값(updatedBoard)이 반환되기 때문에 재렌더링된다.
// 

💡 배열 / 객체 상태 관리 시 주의점

  • 상태를 업데이트할 때는 항상 새로운 객체나 배열을 반환한다.
  • 배열 내 특정 요소를 수정하려면 해당 요소도 복사해야 한다.
  • setState는 항상 새로운 참조 값을 기대한다. (리액트는 참조 비교로 상태 변경 여부를 판단)

참조 값이란?

  • 객체나 배열은 메모리에서 특정 위치를 가리키는 참조 값을 통해 관리된다.
  • 같은 참조 값을 가지는 두 객체는 같은 메모리 위치를 가리키므로, 리액트는 변경이 없다고 판단한다.

➡️ 리액트는 상태가 변경되었는지 확인할 때, 새로운 상태와 이전 상태의 참조 값이 다르면 변경되었다고 간주한다.
참조 값이 같으면 상태가 변경되지 않았다고 판단하고, 컴포넌트를 재렌더링하지 않는다.

💡 기존 객체를 직접 수정하면 참조 값이 유지되므로, 상태 변경을 리액트가 감지하지 못한다.

상태를 업데이트 할 때는 항상 새로운 참조 값을 가진 객체나 배열을 반환하자!

📌 얕은 복사 vs 깊은 복사

얕은 복사 (Shallow Copy)란?

얕은 복사는 객체나 배열의 1단계까지만 복사하는 방식이다. 복사된 객체나 배열 내부에 있는 중첩된 객체나 배열은 원본과 같은 참조를 공유한다.

  • 최상위 레벨(루트)의 데이터만 복사
  • 중첩된 객체나 배열은 복사되지 않고, 원본과 같은 참조를 가리킨다.
  • 내부 데이터가 변경되면 원본과 복사본에 모두 영향을 미친다.

얕은복사는 왜 문제가 될까?

얕은 복사를 하면 내부 중첩 객체나 배열이 원본과 연결된 상태로 남아있다. 따라서 복사본을 수정했을 때도 의도치않게 원본이 변경될 수 있다.

깊은 복사와 비교하면?

구분얕은 복사 (Shallow Copy)깊은 복사 (Deep Copy)
복사 깊이1단계까지만 복사중첩된 객체와 배열까지 모두 복사
참조 공유중첩된 데이터는 원본과 참조를 공유중첩된 데이터도 새로운 복사본 생성
성능빠름느림
사용 사례중첩 구조가 없거나, 내부 데이터 변경이 필요 없는 경우중첩 구조가 있고, 원본과 독립적으로 동작해야 하는 경우

정리하자면

얕은 복사는 성능이 좋지만, 중첩된 데이터가 원본과 연결되므로 조심해서 사용해야 한다.
깊은 복사는 원본과 완전히 독립적인 복사를 제공하며, 중첩된 데이터 구조를 안전하게 다룰 수 있다.

📌 이벤트 함수에 매개변수 넘기기

onClick={handleSelect()} 와 같이 함수를 바로 실행하는 것은 절대 안된다.
클릭했을 때 함수가 실행되는 것이 아니라 코드를 읽어오면서 바로 실행되기 때문이다!

그러면 인수를 보내고 싶으면 어떻게 보내야할까?
화살표 함수를 이용해서 인수를 보내고 함수를 호출하자!

<button onClick={() => handleSelect(rowIdx, colIdx)}>

👀 헷갈린 점

인수를 전달할 때 보내는 위치가 어딘지 헷갈렸다.
어떻게 동작하는 지 원리를 알면 헷갈리지 않을 수 있다.

이 이벤트 함수는 어떻게 동작하는 것일까?

1️⃣ 이벤트가 발생할 때
onClick 이벤트가 발생하면, () => handleSelect(rowIdx, colIdx)라는 화살표 함수가 실행된다.

2️⃣ 함수가 실행될 때
handleSelect(rowIdx, colIdx)가 실행되며, 그때 rowIdxcolIdx 값이 전달된다.

❌ 함수가 바로 실행됨
<button onClick={(rowIdx, colIdx) => handleSelect}>
// onClick은 이벤트 핸들러를 기대하는데, 이 코드는 함수를 바로 실행하는 형식으로 되어있다.
// 이 방식으로 하면 버튼이 클릭되기 전에 이미 함수가 호출됨 
// 따라서 rowIdx, colIdx의 값이 없기 때문에 제대로 동작하지 않는다.<button onClick={() => handleSelect(rowIdx, colIdx)}>
// 이벤트가 발생했을 때 실행될 함수

➡️ (rowIdx, colIdx) => handleSelect() 처럼 화살표 함수를 실행하려면 앞에서 얻은 변수를 함수로 보낼 때 사용하는 것이다

📌 Lifting State Up

상태를 제어할 때 가장 가까운 부모 컴포넌트에서 하는 이유는 이 컴포넌트가 해당 정보를 필요로 하는 두 컴포넌트 모두에 접근이 가능하기 때문이다.

📌 Strict Mode란?

애플리케이션에서 잠재적인 문제를 감지하고 개선하기 위해 사용되는 개발 도구이다.
StrictMode 컴포넌트로 래핑된 부분은 추가적인 검사를 수행한다. (2번 실행)

개발 모드에서만 활성화되며, 프로덕션 환경에서는 영향을 미치지 않는다.

✔️ index.jsx


ReactDom.createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode> 

0개의 댓글