๐ [Recat] React - tic tac toe ๊ฒ์
![๐ [Recat] React - tic tac toe ๊ฒ์](/assets/img/thumbnail/react-thumbnail.jpg)
[Recat] React - tic tac toe ๊ฒ์
์ปดํฌ๋ํธ ๋ถ๋ฆฌ & ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ ๊ตฌ์ถ
์ปดํฌ๋ํธ ๋ถ๋ฆฌ
src/components/Player.jsx
ํ์ผ์ ์์ฑํ์ฌ, ํ๋ ์ด์ด ์ ๋ณด๋ฅผ ๋ณ๋ ์ปดํฌ๋ํธ๋ก ์ถ์ถํฉ๋๋ค.
function Player({ name, symbol }) {
return (
<li>
<span className="player">
<span className="player-name">{name}</span>
<span className="player-symbol">{symbol}</span>
<button>Edit</button>
</span>
</li>
);
}
name
๊ณผsymbol
์ props๋ก ์ ๋ฌ๋ฐ์ ์ถ๋ ฅํฉ๋๋ค.- ๊ธฐ์กด ๋งํฌ์ ์ ๊ตฌ์กฐ๋ ๊ทธ๋๋ก ์ ์งํ๋ฉด์ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ๊ฒ ํ์ต๋๋ค.
App.jsx์์ ์ปดํฌ๋ํธ ์ฌ์ฉ
- ๊ธฐ์กด์ ํ๋์ฝ๋ฉ ๋์ด ์๋ ํ๋ ์ด์ด ๋งํฌ์ ์ ์ญ์ ํ๊ณ ,
- ๋์
Player
์ปดํฌ๋ํธ๋ฅผ ๋ ๋ฒ ํธ์ถํด ์ฌ์ฉํฉ๋๋ค. ```jsx
### State(์ํ) ํ์ฉ๋ฒ
Edit(์์ ) ๋ฒํผ์ ํด๋ฆญํด ํ๋ ์ด์ด ์ด๋ฆ์ ์์ ํ ์ ์๋ ์ํธ์์ฉ ๊ธฐ๋ฅ์ ๊ตฌํ
`useState` ํ
์ ์ฌ์ฉํด ํ๋ ์ด์ด ์ด๋ฆ์ด ํธ์ง ์ค์ธ์ง ์ฌ๋ถ๋ฅผ ๊ด๋ฆฌํจ.
```jsx
const [isEditing, setIsEditing] = useState(false);
handleEditClick
ํจ์ ์ ์ โ ๋ฒํผ ํด๋ฆญ ์ setIsEditing(true)
ํธ์ถ
function handleEditClick() {
setIsEditing(true);
}
isEditing
๊ฐ์ ๋ฐ๋ผ ์ด๋ฆ์ ๋ณด์ฌ์ฃผ๊ฑฐ๋ ์
๋ ฅ ํ๋๋ฅผ ์ถ๋ ฅํจ.
let playerName = <span className="player-name">{name}</span>;
if (isEditing) {
playerName = <input type="text" required />;
}
์ดํ JSX ๋ด๋ถ์์ playerName
์ ์ถ๋ ฅํ๋ฉด ์กฐ๊ฑด์ ๋ฐ๋ผ ๋์ ์ผ๋ก UI๊ฐ ๋ ๋๋ง๋จ ํ์ฌ๋ ์ด๋ฆ๋ง ๋ณ๊ฒฝํ ์ ์๋ ์
๋ ฅ์ฐฝ์ด ๋ณด์ด๋ฉฐ, ์ ์ฅ ๊ธฐ๋ฅ์ ์์ง ๊ตฌํํ์ง ์์
์ปดํฌ๋ํธ ์ธ์คํด์ค์ ๋ถ๋ฆฌ๋ ๋์๋ฒ
function App() {
return (
<div id="game-container">
<ol id="players">
<Player name="Player 1" symbol="X" />
<Player name="Player 2" symbol="O" />
</ol>
</div>
);
}
Player
์ปดํฌ๋ํธ๋ ํ๋์ ์ ์๋ ์ปดํฌ๋ํธ์ง๋ง, ์ฑ์์ ๋ ๋ฒ ์ฌ์ฉ ์ค.- ๊ฐ ์ฌ์ฉ ์ ๋ฆฌ์กํธ๋ ๊ฐ๊ฐ ๋ ๋ฆฝ๋ ์ปดํฌ๋ํธ ์ธ์คํด์ค๋ฅผ ์์ฑํจ.
- ์ด ์ธ์คํด์ค๋ ์์ฒด ์ํ (
useState
)๋ฅผ ๊ฐ๊ณ ์์ผ๋ฉฐ, ๋ค๋ฅธ ์ธ์คํด์ค์ ๊ณต์ ํ์ง ์์. - ๊ฐ ์ปดํฌ๋ํธ๋ ์์ ๋ง์ ์ํ์ ๋์์ ์ ์งํจ.
์กฐ๊ฑด์ ์ฝํ ์ธ & State(์ํ) ์ ๋ฐ์ดํธ
๋ฒํผ ํ ์คํธ๋ฅผ ์ํ์ ๋ฐ๋ผ ๋์ ์ผ๋ก ๋ณ๊ฒฝ
const btnCaption = isEditing ? 'Save' : 'Edit';
๋ฒํผ์ ํ
์คํธ๋ฅผ Edit(์์ )
๋๋ Save(์ ์ฅ)
์ผ๋ก ์กฐ๊ฑด๋ถ๋ก ํ์.
<input type="text" value={name} />
์
๋ ฅ ํ๋์ value
์์ฑ์ ์ถ๊ฐํด ํ์ฌ ํ๋ ์ด์ด ์ด๋ฆ์ด ์๋์ผ๋ก ์ฑ์์ง๊ฒ ์ค์
const handleEditClick = () => {
setIsEditing(!isEditing);
};
๋ฒํผ์ ํด๋ฆญํ๋ฉด isEditing
์ํ๋ฅผ ํ ๊ธ(toggle) ํ๋๋ก handleEditClick
ํจ์ ๊ฐ์
์ํ ์ ๋ฐ์ดํธ ์ ์ฃผ์ํ ์ : ์ด์ ๊ฐ์ ๊ธฐ๋ฐํ ๋ณ๊ฒฝ์ ํจ์ํ ์ ๋ฐ์ดํธ๋ฅผ ์ฌ์ฉํด์ผ ํจ
setIsEditing(!isEditing);
- ์ด ๋ฐฉ์์ ๊ฐ๋จํ์ง๋ง, ํ์ฌ ์ปดํฌ๋ํธ ์คํ ์ฃผ๊ธฐ์
isEditing
๊ฐ์ ๊ธฐ์ค์ผ๋ก ์๋ํฉ๋๋ค. - ๋น๋๊ธฐ ์ ๋ฐ์ดํธ ํน์ฑ์, ์ฐ์ ํธ์ถ ์ ์์๊ณผ ๋ค๋ฅธ ๊ฒฐ๊ณผ๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค.
setIsEditing((prevEditing) => !isEditing);
- ์ด ๋ฐฉ์์ React๊ฐ ์ต์ ์ํ๊ฐ์ ์๋์ผ๋ก ๋งค๊ฐ๋ณ์(prevEditing)๋ก ์ ๋ฌํฉ๋๋ค.
- ์ํ๊ฐ ์ฌ๋ฌ ๋ฒ ๋ณ๊ฒฝ๋์ด๋ ํญ์ ์ต์ ์ํ๊ฐ์ ๊ธฐ์ค์ผ๋ก ๋ณ๊ฒฝ ๊ฐ๋ฅ.
- React ๊ณต์ ๊ถ์ฅ ๋ฐฉ์.
์ด์ : React์ ์ํ ๋ณ๊ฒฝ์ ์ค์ผ์ค๋ง๋๊ณ ๋น๋๊ธฐ์ ์ผ๋ก ์ฒ๋ฆฌ๋จ
setState
(์:setIsEditing
) ํธ์ถ ํ ์ํ๊ฐ ์ฆ์ ๋ณ๊ฒฝ๋์ง ์์.- ๋์ React๊ฐ ์ปดํฌ๋ํธ๋ฅผ ๋ค์ ๋ ๋๋งํ ๋ ๋ณ๊ฒฝ๋ ์ํ๋ฅผ ๋ฐ์ํจ.
- ๋ฐ๋ผ์ ๊ฐ์ ์คํ ์ฃผ๊ธฐ ๋ด์์ ์ํ๋ฅผ ์ฐ์์ผ๋ก ์ค์ ํ๋ฉด, ๊ทธ ๊ฐ์ด ์ด๋ฏธ ์ต์ ์ด ์๋ ์ ์์
์ฌ์ฉ์ ์ ๋ ฅ & ์๋ฐฉํฅ ๋ฐ์ธ๋ฉ
- React์์๋
useState
๋ฅผ ์ด์ฉํด ์ ๋ ฅ ํ๋์ ๊ฐ์ ์ํ๋ก ์ ์ดํจ. value
๋ฅผ ์ง์ ์ค์ ํ๋ ๋์ ,state
๋ฅผ ์ด์ฉํ ๋์ ๊ฐ์ผ๋ก ๊ด๋ฆฌ. ```jsx const [editablePlayerName, setEditablePlayerName] = useState(initialName);
<input type=โtextโ value={editablePlayerName} onChange={handleChange} />
#### ์ด๋ฒคํธ ์ฒ๋ฆฌ ํจ์: handleChange
- `input` ๊ฐ์ด ๋ฐ๋ ๋๋ง๋ค ์คํ๋จ (`onChange` ์ด๋ฒคํธ)
- `event.target.value`๋ฅผ ํตํด ์ฌ์ฉ์๊ฐ ์
๋ ฅํ ๊ฐ์ ์ฝ์
- ์ด ๊ฐ์ ์ํ๋ก ์ ์ฅํ์ฌ ์ฆ์ ๋ฐ์
```jsx
function handleChange(event) {
setEditablePlayerName(event.target.value);
}
์๋ฐฉํฅ ๋ฐ์ธ๋ฉ (Two-way Binding)
- ์ฌ์ฉ์๊ฐ ์
๋ ฅํ ๊ฐ์
state
๋ก ์ ๋ฐ์ดํธํ๊ณ state
์ ๊ฐ์ ๋ค์input
์value
๋ก ๋ณด์ฌ์ค- ์ฌ์ฉ์ ์ ๋ ฅ๊ณผ UI๊ฐ ๋๊ธฐํ๋จ
๋ค์ฐจ์ ๋ฆฌ์คํธ ๋ ๋๋ง
const initialGameBoard = [
[null, null, null],
[null, null, null],
[null, null, null],
];
export default function GameBoard() {
return (
<ol id="game-board">
{initialGameBoard.map((row, rowIndex) => (
<li key={rowIndex}>
<ol>
{row.map((playerSymbol, colIndex) => (
<li key={colIndex}>
<button>{playerSymbol}</button>
</li>
))}
</ol>
</li>
))}
</ol>
);
}
GameBoard.jsx
ํ์ผ์ ์์ฑ<ol>
ํ๊ทธ๋ฅผ ์ฌ์ฉํด ๊ฒฉ์ ๊ตฌ์กฐ๋ฅผ ์ถ๋ ฅ- ์ค์ฒฉ
<ol>
์ ํตํด 3ํ 3์ด ๊ตฌ์ฑ .map()
์ ๋ ๋ฒ ์ฌ์ฉํด ํ(row)๊ณผ ์ด(col)์ ๊ฐ๊ฐ ๋ ๋๋ง- ํ์ฌ๋ ๋ฒํผ ํด๋ฆญ ์ ์๋ฌด ๋์ ์์
const [gameBoard, setGameBoard] = useState(initialGameBoard);
function handleSelectSquare(rowIndex, colIndex) {
setGameBoard((prevGameBoard) => {
// ๊น์ ๋ณต์ฌ๋ก ๋ถ๋ณ์ฑ ์ ์ง
const updatedBoard = [
...prevGameBoard.map((innerArray) => [...innerArray]),
];
// ํด๋น ์์น์ ํ๋ ์ด์ด ๊ธฐํธ ์ง์ (์์๋ก 'X')
updatedBoard[rowIndex][colIndex] = "X";
return updatedBoard;
});
}
setGameBoard
์ ํจ์ ์ ๋ฌ โ ์ด์ ์ํ์ ๊ธฐ๋ฐํ ์ ๋ฐ์ดํธ- ๊น์ ๋ณต์ฌ(deep copy) ์ฌ์ฉ
- ์๋ณธ ๋ฐฐ์ด์ ์ง์ ์์ ํ์ง ์์ (๋ถ๋ณ์ฑ ์ ์ง)
<button onClick={() => handleSelectSquare(rowIndex, colIndex)}>
{playerSymbol}
</button>
- ๊ฐ ๋ฒํผ์ ํด๋ฆญ ์ ํด๋น ์ขํ(
rowIndex
,colIndex
)๋ฅผhandleSelectSquare
๋ก ์ ๋ฌ - ์ด๋ก์จ ๊ฐ ์นธ์ ์ํ๊ฐ ๋์ ์ผ๋ก ์ ๋ฐ์ดํธ๋จ
App
์ปดํฌ๋ํธ์ ์ํ ์ถ๊ฐ (์ํ ๋์ด์ฌ๋ฆฌ๊ธฐ)
const [activePlayer, setActivePlayer] = useState('X');
- ์ด๋ค ํ๋ ์ด์ด ์ฐจ๋ก์ธ์ง ์ค์(App)์์ ๊ด๋ฆฌ
- ์ด์ : GameBoard์ Player ์ปดํฌ๋ํธ ๋ชจ๋ ์ด ์ ๋ณด๋ฅผ ํ์๋ก ํจ
function handleSelectSquare() {
setActivePlayer((prevPlayer) => prevPlayer === 'X' ? 'O' : 'X');
}
- ๋ฒํผ์ ๋๋ฅผ ๋๋ง๋ค ์ฐจ๋ก๊ฐ ๋ฐ๋๋๋ก ๊ตฌํ
- ์ด์ ์ํ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ค์ ์ํ ๊ฒฐ์
๊ฒ์ํ ์ํ ์ ๊ฑฐ ๋ฐ ์ํ ๋์ด์ฌ๋ฆฌ๊ธฐ
const [gameBoard, setGameBoard] = useState(initialGameBoard);//์ ๊ฑฐ
GameBoard.jsx
์์ ๊ด๋ฆฌํ๋ ์ํ๊ฐ์ ๋ถ๋ชจ ์ปดํผ๋ํธ์์ ๊ด๋ฆฌํ๊ฒ ๋ณ๊ฒฝ ์ํ๋ ๋ ์ด์ ์ด ์ปดํฌ๋ํธ์์ ๊ด๋ฆฌํ์ง ์์
//์๋ก์ด ์ํ ์ถ๊ฐ
const [gameTurns, setGameTurns] = useState([]);
function handleSelectSquare(rowIndex, colIndex) {
setGameTurns((prevTurns) => {
let currentPlayer = 'X';
if (prevTurns.length > 0 && prevTurns[0].player === 'X') {
currentPlayer = 'O';
}
//๊น์๋ณต์ฌ ์๋ณธ ๋ฐฐ์ด์ ์ง์ ์์ ํ์ง ์์ (๋ถ๋ณ์ฑ ์ ์ง)
const updatedTurns = [
{ square: { row: rowIndex, col: colIndex }, player: currentPlayer },
...prevTurns,
];
return updatedTurns;
});
}
- ์ด ๋ฐฐ์ด์ โ๋๊ฐ ์ธ์ ์ด๋๋ฅผ ํด๋ฆญํ๋์งโ ์ ๋ณด๋ฅผ ๋ด์
- ๋ฐฐ์ด์ ์ฒซ ์์๊ฐ ๊ฐ์ฅ ์ต๊ทผ ํด์ด ๋๋๋ก ์์ชฝ์ ์ถ๊ฐ
prevTurns[0]
: ๊ฐ์ฅ ์ต๊ทผ ํด- ๊ฐ์ฅ ์ต๊ทผ์ X๊ฐ ๋๋ ธ๋ค๋ฉด โ ํ์ฌ๋ O์ ์ฐจ๋ก
- ์ํ๋ฅผ ์ง์ ๋ณ๊ฒฝํ์ง ์๊ณ , ๋ณต์ฌ ํ ์ ๋ฐฐ์ด์ ๋ง๋ค์ด ๋ถ๋ณ์ฑ ์ ์ง
player
,row
,col
์ ๋ณด๋ฅผ ๊ฐ์ฒด๋ก ๋ฌถ์ด ๊ธฐ๋ก
ย Props(์์ฑ)์์ State(์ํ) ํ์ํ๊ธฐ
์ํ๋ App์์๋ง ๊ด๋ฆฌ GameBoard.jsx์์๋ ์ํ๊ฐ์ ๊ด๋ฆฌ ํ์ง ์์ ๋ถ๋ชจ ์ปดํฌ๋ํธ๋ก๋ถํฐ ๋ฐ์์จ props ๋ฐ์์ ๊ฐ๋ง ์ธํ ํด์ค๋ค.
let gameBoard = initialGameBoard;
for (const turn of turns) {
const { square, player } = turn;
const { row, col } = square;
gameBoard[row][col] = player;
}
์กฐ๊ฑด์ ๋ฒํผ ๋นํ์ฑํ
GameBoard.jsx
์์ playerSymbol
ํ์ธ
<button
onClick={() => onSelectSquare(rowIndex, colIndex)}
disabled={playerSymbol !== null}
>
{playerSymbol}
</button>
playerSymbol
์ ํ์ฌ ์นธ์ ํ์๋ ๊ธฐํธ (null
,'X'
,'O'
)playerSymbol !== null
- ์ด๋ฏธ ํด๋ฆญ๋ ์นธ์ด๋ผ๋ฉด โ
disabled=true
โ ํด๋ฆญ ๋ถ๊ฐ๋ฅ
- ์ด๋ฏธ ํด๋ฆญ๋ ์นธ์ด๋ผ๋ฉด โ
playerSymbol === null
- ์์ง ํด๋ฆญ๋์ง ์์ ์นธ โ
disabled=false
โ ํด๋ฆญ ๊ฐ๋ฅ
- ์์ง ํด๋ฆญ๋์ง ์์ ์นธ โ
์น์ ์ ํ๊ธฐ
export const WINNING_COMBINATIONS = [
[
{ row: 0, column: 0 },
{ row: 0, column: 1 },
{ row: 0, column: 2 },
],
[
{ row: 1, column: 0 },
{ row: 1, column: 1 },
{ row: 1, column: 2 },
],
[
{ row: 2, column: 0 },
{ row: 2, column: 1 },
{ row: 2, column: 2 },
],
[
{ row: 0, column: 0 },
{ row: 1, column: 0 },
{ row: 2, column: 0 },
],
[
{ row: 0, column: 1 },
{ row: 1, column: 1 },
{ row: 2, column: 1 },
],
[
{ row: 0, column: 2 },
{ row: 1, column: 2 },
{ row: 2, column: 2 },
],
[
{ row: 0, column: 0 },
{ row: 1, column: 1 },
{ row: 2, column: 2 },
],
[
{ row: 0, column: 2 },
{ row: 1, column: 1 },
{ row: 2, column: 0 },
],
];
์ฐ์น ์กฐํฉ ๋ฐฐ์ด ๊ณต์ winning-combination.js ์์ฑ ํฑํํ ๊ฒ์์ ์ด๊ธฐ๊ธฐ ์ํด์๋ ํ ์ค์ ์์ฑํด์ผ ํจ ์ ์ฝ๋๋ ๊ฒ์์ ์ด๊ธฐ๊ธฐ ์ํ ์กฐ๊ฑด ์ ๋ณด๋ฅผ ๋ด์ ๋ฐฐ์ด
๋ฐ๋ณต๋ฌธ์ ํตํด ์ ๋ฐฐ์ด์ ์ํํ์ฌ ์น๋ฆฌ์กฐ๊ฑด์ ๋ง์กฑํ๋์ง ๊ฒ์ฌํ๋ค.
let winner;
for (const combination of winningCombinations) {
const firstSquare = gameBoard[combination[0].row][combination[0].col];
const secondSquare = gameBoard[combination[1].row][combination[1].col];
const thirdSquare = gameBoard[combination[2].row][combination[2].col];
if (
firstSquare &&
firstSquare === secondSquare &&
firstSquare === thirdSquare
) {
winner = firstSquare; // 'X' or 'O'
break;
}
}
- ์ฐ์น ์กฐํฉ ๋ฃจํ ์ํ
- ๊ฐ ์กฐํฉ์์ 3์นธ์ ๊ธฐํธ ์ถ์ถ
- ๋ชจ๋ ๋์ผํ ๊ธฐํธ(
'X'
,'O'
)์ด๊ณnull
์ด ์๋๋ฉด โ ์น๋ฆฌ - ํด๋น ๊ธฐํธ๋ฅผ
winner
๋ณ์์ ์ ์ฅ
๊ฒ์ ์ค๋ฒ & ๋ฌด์น๋ถ ์ฌ๋ถ ํ์ธ
GameOver.jsx
์ปดํฌ๋ํธ ์์ฑ
export default function GameOver({ winner }) {
return (
<div id="game-over">
<h2>Game Over!</h2>
<p>{winner ? `${winner} won!` : "It's a draw!"}</p>
<p>
<button>Rematch!</button>
</p>
</div>
);
}
winner
๋ฅผ props๋ก ๋ฐ์์ ๋ฉ์์ง๋ฅผ ์กฐ๊ฑด๋ถ๋ก ์ถ๋ ฅwinner
๊ฐ ์์ผ๋ฉด ๋ฌด์น๋ถ ์ฒ๋ฆฌ
app์ปดํฌ๋ํธ์ ๋ฌด์น๋ฌด ํ๋จ ๋ก์ง ์ถ๊ฐ
const hasDraw = gameTurns.length === 9 && !winner;
๊ฒ์ ํด์ด 9๋ฒ ์งํ๋๊ณ ์น์๊ฐ ์์ผ๋ฉด โ ๋ฌด์น๋ถ
{(winner || hasDraw) && <GameOver winner={winner} />}
์น์ ์๊ฑฐ๋ ๋ฌด์น๋ถ์ธ ๊ฒฝ์ฐ์๋ง GameOver ์ถ๋ ฅ
ReMatch ๋ฒํผ ํด๋ฆญ ์ฒ๋ฆฌ
gameTurns
์ํ๋ฅผ ์ด๊ธฐํํ์ฌ ๊ฒ์์ ์ฌ์์
function handleRestart() {
setGameTurns([]);
}
GameOver
์ปดํฌ๋ํธ์์ ๋ฒํผ๊ณผ ์ด๋ฒคํธ ์ ๋ฌ
export default function GameOver({ winner, onRestart }) {
return (
<div id="game-over">
<h2>Game Over!</h2>
<p>{winner ? `${winner} won!` : "It's a draw!"}</p>
<p>
<button onClick={onRestart}>Rematch!</button>
</p>
</div>
);
}
๋ฒ๊ทธ ๋ฐ์ - Rematch ๋ฒํผ ๋๋ฌ๋ ๊ฒ์ํ ์ด๊ธฐํ๋์ง ์์
- ๋ฌธ์ :
initialGameBoard
๋ ์ฐธ์กฐ ํ์ (๋ฐฐ์ด) - ์์ ์ :
gameBoard
๋ ํญ์ ๊ฐ์ ๋ฉ๋ชจ๋ฆฌ ์ฃผ์๋ฅผ ์ฐธ์กฐ - ๊ทธ๋์ ๊ฒ์ ์ข ๋ฃ ํ Rematch๋ฅผ ๋๋ฌ๋ ๊ฒ์ํ์ด ์ด๊ธฐํ๋์ง ์์
ย let gameBoard = initialGameBoard; //์์ ์
ย let gameBoard = [...initialGameBoard.map((array) => [...array])];//์์ ํ
ย
- ๊น์ ๋ณต์ฌ๋ก ๋ฌธ์ ํด๊ฒฐ
์ต์ข ์ฝ๋ ์ฐธ๊ณ
์๊ฐ๋ณด๋ค ๋ถ๋์ด ๋ง์์ ์ค๊ฐ์ค๊ฐ ๋น ์ง ๋ถ๋ถ์ด ์์ต๋๋ค. ์ต์ข ์ฝ๋๋ ์๋ ์ฃผ์์์ ํ์ธ๋ฐ๋๋๋ค. react-guide-2025/04 Essentials Deep Dive/07-tic-tac-toe-starting-project at main ยท sosiluv/react-guide-2025 ยท GitHub
๐ Reference
- ใํ๊ธ์๋งใ React ์๋ฒฝ ๊ฐ์ด๋ 2025 with React Router & Redux | Udemy
- ์ด๋ฏธ์ง ์ถ์ Freepik | ์ฌ์ธ์ AI ํฌ๋ฆฌ์์ดํฐ๋ธ ํด