구현기/웹 프로젝트

GPT 주도 개발로 코딩하기 - HTML/CSS/JS 기반 프로젝트를 리액트 프로젝트에 이식하기(feat. 카드 짝 맞추기 게임)

Sadie Kim 2023. 10. 19. 16:21

이전에 GDD(Gpt Driven Development)로 만들었던 카드 짝 맞추기 게임.

GPT 기반으로 코딩하기에는 확실히 HTML+CSS+Vanilla JS 스택을 선택하는 것이 가장 심플하고 대답도 잘 해주는 선택지겠지만...

나는 웹 게임을 붙여서 개발하겠다는 취지로 빌드해 둔 Next.js 앱이 있었고 거기에 카드 짝 맞추기 게임을 이식하고 싶었다.

그래서 GPT가 1부터 100까지 짜 준 게임 코드를 리액트 기반으로 바꾸기로 했다! 물론 GPT에게 코드를 맡겨서.

 

하지만 HTML 기반 프로젝트를 처음부터 코딩하는 것과 다르게, 이미 존재하는 리액트 기반 프로젝트에 코드를 변환하여 이식하는 일은 쉽지 않았다...

예전엔 순도 100%의 GPT 코드만으로도 잘 돌아가는 게임을 만들 수 있었지만, 이번에는 코드를 부분부분 이식하고 문법오류를 알아서 고치는 등 인간의 입김이 필요했다.

또 HTML 기반으로 만들 땐 개발 관련 프롬프트를 1도 안 작성하고도 기능 완성이 됐지만...

이번에는 직접 코드를 검수하고 피드백하는 과정이 많이 들어갔다.

 

상세한 기록을 프롬프트 위주로 공유하고자 한다!


1. 간단하게 요구하기

지피티 컨텍스트는 이전의 카드 짝 맞추기 게임 프롬프트와 이어진다.

우선 바로 간단하게 리액트로 바꿔달라고 했다.

방금 네가 보내준 코드를 ts 기반의 React project에 적용하려고 해. 혹시 리액트 프로젝트에 맞게 코드를 수정해줄 수 있을까?

지피티는 자신만만하게 코드를 줬다!

친절하게 CRA하는 과정부터 알려준다.

그러나 분명 ts 기반의 리액트 프로젝트라고 했음에도 tsx가 아닌 js로 코드를 짜 줬다. 이 점을 지적했다.

방금 네가 짜 준 App.js 코드를 타입스크립트로 바꿔줄 수 있을까?

해당 프롬프트의 대답으로 GPT가 짜 준 App.tsx는 다음과 같다.

import React, { useState, useEffect } from 'react';
import './App.css';

const colors: string[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'teal'];

function shuffleArray(array: string[]): string[] {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

function App() {
  const [score, setScore] = useState<number>(0);
  const [flippedCards, setFlippedCards] = useState<HTMLDivElement[]>([]);
  const [matchedPairs, setMatchedPairs] = useState<number>(0);

  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);
    const gameBoard = document.querySelector('.game-board') as HTMLDivElement;

    shuffledColors.forEach((cardColor, index) => {
      const card = document.createElement('div');
      card.className = 'card';
      card.style.backgroundColor = 'white'; // 처음에는 모두 하얀색으로 시작

      card.addEventListener('click', () => {
        if (!card.classList.contains('flipped') && flippedCards.length < 2) {
          flipCard(card, cardColor);
        }
      });

      gameBoard.appendChild(card);
    });
  }, []);

  const flipCard = (card: HTMLDivElement, cardColor: string) => {
    card.style.backgroundColor = cardColor;
    card.classList.add('flipped');
    setFlippedCards([...flippedCards, card]);

    if (flippedCards.length === 1) {
      setTimeout(checkMatch, 1000);
    }
  };

  const checkMatch = () => {
    if (flippedCards[0].style.backgroundColor === flippedCards[1].style.backgroundColor) {
      flippedCards.forEach((card) => {
        card.style.visibility = 'hidden';
      });
      setScore(score + 10);
      setFlippedCards([]);
      setMatchedPairs(matchedPairs + 1);
    } else {
      flippedCards.forEach((card) => {
        card.style.backgroundColor = 'white';
        card.classList.remove('flipped');
      });
      setFlippedCards([]);
    }
  };

  return (
    <div className="App">
      <div className="score">Score: {score}</div>
      <div className="game-board"></div>
      {matchedPairs === colors.length && (
        <div className="message">Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
}

export default App;

유심히 보면 알겠지만 사실 제대로 짜인 리액트 코드는 아니다.

하지만 우선 내 Next 앱은 SCSS + TS 스택 기반이었기 때문에...

css를 먼저 SCSS로 변환해 달라고 요구했다.

네가 두 번째 전에 전해준 답변에서 App.css를 글로벌로 import하지 않고 scss를 이용해서 css modules로 추가하려고 해. 혹시 css를 scss 형태로 바꿔 주고, 그걸 리액트 상에서 임포트하는 것까지 코딩해줄 수 있을까? 네가 까먹었을 수도 있어서, 변환할 css 내용을 """ 기호 안에 넣어 줄게.

"""
body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f0f0f0; }
...코드들...
"""

이 프롬프트에 대한 GPT의 답변에서 나온 코드는 다음과 같다.

/** App.scss **/

body {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100vh;
    margin: 0;
    background-color: #f0f0f0;
}

.score {
    font-size: 24px;
    color: #333;
    margin-bottom: 10px;
}

.game-board {
    display: grid;
    grid-template-columns: repeat(5, 120px);
    grid-template-rows: repeat(4, 180px);
    gap: 10px;
    justify-content: center;
}

.card {
    width: 100px;
    height: 150px;
    background: #fff;
    border: 2px solid #333;
    cursor: pointer;
    transition: transform 0.2s;
}

.card.flipped {
    transform: rotateY(180deg);
}

.message {
    font-size: 28px;
    color: green;
    display: none;
}
//App.js

import React, { useState, useEffect } from 'react';
import styles from './App.module.scss'; // SCSS 파일을 import

const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'teal'];

function shuffleArray(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

function App() {
  const [score, setScore] = useState(0);
  const [flippedCards, setFlippedCards] = useState([]);
  const [matchedPairs, setMatchedPairs] = useState(0);

  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);
    const gameBoard = document.querySelector(`.${styles['game-board']}`); // CSS Modules 스타일을 사용

    shuffledColors.forEach((cardColor, index) => {
      const card = document.createElement('div');
      card.className = styles.card; // CSS Modules 스타일을 사용
      card.style.backgroundColor = 'white'; // 처음에는 모두 하얀색으로 시작

      card.addEventListener('click', () => {
        if (!card.classList.contains(styles.flipped) && flippedCards.length < 2) {
          flipCard(card, cardColor);
        }
      });

      gameBoard.appendChild(card);
    });
  }, []);

  const flipCard = (card, cardColor) => {
    card.style.backgroundColor = cardColor;
    card.classList.add(styles.flipped); // CSS Modules 스타일을 사용
    setFlippedCards([...flippedCards, card]);

    if (flippedCards.length === 1) {
      setTimeout(checkMatch, 1000);
    }
  };

  const checkMatch = () => {
    if (flippedCards[0].style.backgroundColor === flippedCards[1].style.backgroundColor) {
      flippedCards.forEach((card) => {
        card.style.visibility = 'hidden';
      });
      setScore(score + 10);
      setFlippedCards([]);
      setMatchedPairs(matchedPairs + 1);
    } else {
      flippedCards.forEach((card) => {
        card.style.backgroundColor = 'white';
        card.classList.remove(styles.flipped); // CSS Modules 스타일을 사용
      });
      setFlippedCards([]);
    }
  };

  return (
    <div className={styles.App}>
      <div className={styles.score}>Score: {score}</div>
      <div className={styles['game-board']}></div>
      {matchedPairs === colors.length && (
        <div className={styles.message}>Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
}

export default App;

다시 js로 회귀했다. 그리고 슥 보기에도 React 코드라기보다는 바닐라 JS에서 볼 법한 로직들이 많다.

그 점을 먼저 고쳐달라고 요구했다.

네가 준 코드에서 고칠 부분을 말해 줄게.
1. 우선 document.createElement로 직접 원소를 추가하는 것 자체가 reactful하지 않아. 컴포넌트와 상태관리를 통해 요소를 추가해줘.
2. scss를 import했을 때 style을 직접적으로 바꾸는 것이 많이 쓰는 패턴이야? 만약 아니라면, 좀 더 commonly used되는 패턴으로 고쳐 줘.
3. 아까도 말했듯 tsx 형식으로 코딩해 줘. 위 요구사항을 적용해서 코드를 고쳐 줄래?

2번은 사실 내가 scss에 능통하지가 않아서 많이 쓰는 패턴인지 아닌지 모른다.

하지만 딱 봐도 저렇겐 잘 안할 것 같아서, GPT가 판단하고 만약 아닐 경우 더 잘 쓰이는 패턴으로 수정해달라 요구했다.

 

답변은 생각보다 잘 왔다.

vanilla JS 느낌이 많이 났던 이전 코드들과 다르게 App에서 Card와 GameBoard를 컴포넌트로 분리해서 짜 주었다.

하지만 ts화를 벌써 몇 번째 요구했는데도 계속 jsx로 답변한다. 왜이러는거야

인내심을 갖고 GPT에게 tsx로 번역할 시간을 주기로 했다.

아래 """로 구분한 js기반 리액트 코드를 tsx로 바꿔줄 수 있을까?
"""
import React, { useState } from 'react';
import styles from './Card.module.scss';

const Card = ({ color, onCardClick }) => {
...
};

export default Card;
"""

GPT가 짜 준 Card.jsx를 그대로 복붙해서 tsx로 바꿔달라 요구했다.

이런 식으로 Card와 GameBoard 코드를 분리해서 해석 요청을 하니 tsx로 잘 변환해 주었다.

//Card.tsx

import React, { useState } from 'react';
import styles from './Card.module.scss';

interface CardProps {
  color: string;
  onCardClick: (color: string) => void;
}

const Card: React.FC<CardProps> = ({ color, onCardClick }) => {
  const [flipped, setFlipped] = useState(false);

  const handleCardClick = () => {
    if (!flipped) {
      setFlipped(true);
      onCardClick(color);
    }
  };

  return (
    <div className={`${styles.card} ${flipped && styles.flipped}`} style={{ backgroundColor: flipped ? color : 'white' }} onClick={handleCardClick}>
      {/* 카드 내용 */}
    </div>
  );
};

export default Card;

그런데 이렇게 보니 이상한 점이 있다. {카드 내용} 부분이 비어 있다.

GPT가 아까 전 Card.js를 컴포넌트로 분리해줄 때 세부 로직을 생략하고 보내줬던 것이다.

GameBoard.js도 이렇게 듬성듬성 빈 부분이 많았다.

셀프로 코드를 칠 마음이 전혀 없던 나는 다시 부탁했다.

너가 세 번째 전에 Card.js와 GameBoard.js를 나눠서 Reactful하게 코드를 다시 짜줬잖아. 그런데 세부적으로 완성되지 않은 로직이 있는데, 이것까지 채워서 알려줬으면 해. 그리고 ts로 작성해줘.

GPT군이 대답해준 코드는 다음과 같다.

//Card.tsx

import React, { useState } from 'react';
import styles from './Card.module.scss';

interface CardProps {
  color: string;
  onCardClick: (color: string) => void;
}

const Card: React.FC<CardProps> = ({ color, onCardClick }) => {
  const [flipped, setFlipped] = useState(false);

  const handleCardClick = () => {
    if (!flipped) {
      setFlipped(true);
      onCardClick(color);
    }
  };

  return (
    <div className={`${styles.card} ${flipped && styles.flipped}`} style={{ backgroundColor: flipped ? color : 'white' }} onClick={handleCardClick}>
      {/* 카드 내용 */}
    </div>
  );
};

export default Card;
//GameBoard.tsx

import React, { useState, useEffect } from 'react';
import styles from './GameBoard.module.scss';
import Card from './Card';

const colors: string[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'teal'];

const shuffleArray = (array: string[]): string[] => {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
};

const GameBoard: React.FC = () => {
  const [score, setScore] = useState<number>(0);
  const [flippedCards, setFlippedCards] = useState<string[]>([]);
  const [matchedPairs, setMatchedPairs] = useState<number>(0);

  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);
    const gameBoard = document.querySelector(`.${styles['game-board']}`);

    const handleCardClick = (color: string) => {
      // 카드 클릭 처리 로직
      if (flippedCards.length < 2 && !flippedCards.includes(color)) {
        const newFlippedCards = [...flippedCards, color];
        setFlippedCards(newFlippedCards);

        if (newFlippedCards.length === 2) {
          setTimeout(() => checkMatch(newFlippedCards), 1000);
        }
      }
    };

    const checkMatch = (flippedCards: string[]) => {
      const [card1, card2] = flippedCards;
      if (card1 === card2) {
        // 짝이 맞을 때의 처리
        setScore(score + 10);
        setMatchedPairs(matchedPairs + 1);
        setFlippedCards([]);
      } else {
        // 짝이 맞지 않을 때의 처리
        setTimeout(() => {
          const cards = gameBoard.querySelectorAll(`.${styles.card}`);
          cards.forEach((card) => {
            card.classList.remove(styles.flipped);
          });
          setFlippedCards([]);
        }, 1000);
      }
    };

    shuffledColors.forEach((cardColor) => {
      const card = document.createElement('div');
      card.className = `${styles.card}`;
      card.style.backgroundColor = 'white'; // 처음에는 모두 하얀색으로 시작

      card.addEventListener('click', () => {
        handleCardClick(cardColor);
      });

      gameBoard.appendChild(card);
    });
  }, []);

  return (
    <div className={styles['game-board']}>
      {matchedPairs === colors.length && (
        <div className={styles.message}>Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
};

export default GameBoard;

빼곡한 코드를 보니 기분이 좋아진다. 하지만 GameBoard 코드에 appendChild가 쓰인 것을 발견했다.

마찬가지로 html->리액트 변환을 할 때 제대로 처리되지 않은 잔재이다. 이를 피드백해 보았다.

내 생각엔 cards들을 appendChild로 추가할 게 아니라, cards 목록을 state로 관리해서 추가해줘야 할 것 같아. 이 요구사항을 적용해서 코드를 다시 수정해줘. 또 ts로 작성하는 것 잊지 마.

답변으로 온 GameBoard.tsx는 다음과 같다.

import React, { useState, useEffect } from 'react';
import styles from './GameBoard.module.scss';
import Card from './Card';

const colors: string[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'teal'];

const shuffleArray = (array: string[]): string[] => {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
};

const GameBoard: React.FC = () => {
  const [score, setScore] = useState<number>(0);
  const [flippedCards, setFlippedCards] = useState<string[]>([]);
  const [matchedPairs, setMatchedPairs] = useState<number>(0);
  const [cards, setCards] = useState<string[]>([]);

  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);

    setCards(shuffledColors);

    const handleCardClick = (color: string) => {
      // 카드 클릭 처리 로직
      if (flippedCards.length < 2 && !flippedCards.includes(color)) {
        const newFlippedCards = [...flippedCards, color];
        setFlippedCards(newFlippedCards);

        if (newFlippedCards.length === 2) {
          setTimeout(() => checkMatch(newFlippedCards), 1000);
        }
      }
    };

    const checkMatch = (flippedCards: string[]) => {
      const [card1, card2] = flippedCards;
      if (card1 === card2) {
        // 짝이 맞을 때의 처리
        setScore(score + 10);
        setMatchedPairs(matchedPairs + 1);
        setFlippedCards([]);
      } else {
        // 짝이 맞지 않을 때의 처리
        setTimeout(() => {
          setFlippedCards([]);
        }, 1000);
      }
    };
  }, [score, flippedCards, matchedPairs]);

  return (
    <div className={styles['game-board']}>
      {cards.map((cardColor, index) => (
        <Card key={index} color={cardColor} onCardClick={handleCardClick} />
      ))}
      {matchedPairs === colors.length && (
        <div className={styles.message}>Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
};

export default GameBoard;

appendChild 구문이 state를 활용하는 방식으로 바뀌면서, querySelectorAll, createElement 등 다른 html 코드의 잔재들도 싹 사라진 것을 확인했다.

확실히 GPT가 똑똑하긴 하다.

 

그대로 붙여서 저장하는데 오류가 난다. handleCardClick과 checkMatch를 useEffect 블록 안에서 정의해서 바깥에서 불러올 수 없는 오류이다.

또 이때부터였는지 예전부터였는지 모르겠는데... 잘 보면 리턴문에 여는 괄호 하나가 부족해서 문법오류는 사실 계속 나고 있었다. 붙여넣을 때마다 셀프로 수정하고 있었음

리액트 코드는 조금 버거운 것일까?

 

아무튼 handleCardClick과 checkMatch를 셀프 수정으로 밖으로 빼내고 코드를 유심히 본 결과, useEffect 의존성에 뭔가 많은 것들이 있음을 발견. 이 점을 지적했다.

네가 짜 준 코드를 보면 score, flippedCards, matchedPairs가 변할 때마다 shuffle을 해주고 있는데 이건 잘못된 로직 같아. 또, handleCardClick과 checkMatch가 useEffect 블록 안에 있어서 바깥에서 호출하지 못하고 있어. 다시 생각해서 코드를 수정해줘.

대답은 다음과 같다.

피드백을 바탕으로 코드를 수정하겠습니다. 카드를 다시 섞는 로직는 게임을 초기화할 때 한 번만 실행되어야 합니다. 또한 `handleCardClick`와 `checkMatch` 함수는 외부에서 호출할 수 있도록 밖으로 이동시켜야 합니다. 아래는 수정된 코드입니다.

**GameBoard.tsx**:

```tsx
import React, { useState, useEffect } from 'react';
import styles from './GameBoard.module.scss';
import Card from './Card';

...{기타 코드}...
  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);
    setCards(shuffledColors);
  }, []);
...{기타 코드}...

export default GameBoard;
```

이렇게 수정하면 카드를 다시 섞는 로직은 게임 시작 시에만 실행되고, `handleCardClick`와 `checkMatch` 함수는 외부에서 호출할 수 있습니다.

코드 부분은 자체 생략했다. 보면 피드백을 잘 받아들여서 useEffect를 예쁘게 쓴 모습이 보인다.

이제 실행해 보려고 하는데 지금 보니 GPT가 분리해 준 Card.module.scss나 GameBoard.module.scss가 나에겐 없다.

이를 부탁했다.

잘했어. 그런데 네가 지금까지 준 코드들을 보면 css가 모듈별로 나뉘어 있어.
Card.tsx는 Card.module.scss를 가져오고 GameBoard는 GameBoard.module.scss, App은 App.module.scss를 임포트하지. 그럼 이 구현에 맞게 하나로 되어 있는 현재 scss 파일을 나눠줄 수 있어? 현재 scss 코드는 아래 """로 구분해 둘게.
"""
.App {
display: flex;
flex-direction: column;
align-items: center;
...
"""

GPT는 친절하게 분리해 주었다.

이제 실행해 보는데, 카드 뒤집기까지만 되고 그 이후는 잘 작동하지 않는다.

이때부터 슬슬 코드를 분석해야 한다는 귀찮음에 괴로웠는데... 머리를 잘 잡고 코드를 열심히 읽어 보았다.

그리고 자세히 보니 Card 상태관리와, 이를 화면에 적용하는 플로우가 제대로 안 되어 있는 것 같아서 이를 피드백했다.

네 코드를 적용했는데 카드를 뒤집는 데까진 잘 작동하는데 그 이후는 작동하지 않아. 보니까 checkMatch를 하고 난 이후의 카드 상태는 상태관리 플로우에 적용되지 않은 것 같은데, 그래서 이후 로직이 작동하지 않는 게 아닌가 싶어. 내 생각엔 setFlippedCards가 화면에 보이는 cards들을 수정하도록 바꾸거나, 아예 cards의 자료구조를 바꿔서 정답 처리가 된 카드들의 상태까지 cards에 담고, 클릭할 때마다 cards를 수정하거나.. 해야 할 것 같아. 단지 이건 정답이 아니고 내 생각에 불과해. 너도 이를 참고해서 수정방안 코드를 전달해 줄래?

GPT씨의 대답은 다음과 같다.

문제를 해결하기 위해 아래와 같은 수정 사항을 고려할 수 있습니다:
cards 배열을 사용하여 각 카드의 상태를 관리합니다.클릭한 카드의 id를 사용하여 해당 카드를 식별하고, 정답 처리된 카드의 matched 상태를 변경합니다.
아래는 이러한 수정 사항을 반영한 코드입니다:
//Card.tsx

import React from 'react';
import styles from './Card.module.scss';

interface CardProps {
  id: number;
  color: string;
  matched: boolean;
  onCardClick: (id: number, color: string) => void;
}

const Card: React.FC<CardProps> = ({ id, color, matched, onCardClick }) => {
  const handleCardClick = () => {
    if (!matched) {
      onCardClick(id, color);
    }
  };

  return (
    <div
      className={`${styles.card} ${matched ? styles.flipped : ''}`}
      style={{ backgroundColor: matched ? color : 'white' }}
      onClick={handleCardClick}
    ></div>
  );
};

export default Card;​
//GameBoard.tsx

import React, { useState, useEffect } from 'react';
import styles from './GameBoard.module.scss';
import Card from './Card';

const colors: string[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'teal'];

const shuffleArray = (array: string[]): string[] => {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
};

const GameBoard: React.FC = () => {
  const [score, setScore] = useState<number>(0);
  const [cards, setCards] = useState<{ id: number; color: string; matched: boolean }[]>([]);
  const [flippedCards, setFlippedCards] = useState<{ id: number; color: string }[]>([]);

  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);
    const initialCards = shuffledColors.map((color, index) => ({
      id: index,
      color: color,
      matched: false,
    }));
    setCards(initialCards);
  }, []);

  const handleCardClick = (id: number, color: string) => {
    if (flippedCards.length < 2) {
      const newFlippedCards = [...flippedCards, { id, color }];
      setFlippedCards(newFlippedCards);

      if (newFlippedCards.length === 2) {
        checkMatch(newFlippedCards);
      }
    }
  };

  const checkMatch = (flippedCards: { id: number; color: string }[]) => {
    const [card1, card2] = flippedCards;
    if (card1.color === card2.color) {
      // 짝이 맞을 때의 처리
      const updatedCards = cards.map((card) =>
        card.id === card1.id || card.id === card2.id ? { ...card, matched: true } : card
      );
      setCards(updatedCards);
      setScore(score + 10);
    } else {
      // 짝이 맞지 않을 때의 처리
      setTimeout(() => {
        setFlippedCards([]);
      }, 1000);
    }
  };

  return (
    <div className={styles['game-board']}>
      {cards.map((card) => (
        <Card
          key={card.id}
          id={card.id}
          color={card.color}
          matched={card.matched}
          onCardClick={handleCardClick}
        />
      )}
      {cards.every((card) => card.matched) && (
        <div className={styles.message}>Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
};

export default GameBoard;

열심히 읽을 필요는 없다. 어차피 작동하지 않는 코드이기 때문이다.

문제해결을 위해 GPT에게 질문을 건넸다.

우선, 클릭하고 나서 작동이 안 되는 것은 같아. 그런데 네 코드에서 궁금한 게 있어. GameBoard.tsx에서 flippedCards state의 역할이 뭔지 설명해줘.
`flippedCards` state는 현재 뒤집힌 상태의 카드를 추적하고 두 개의 카드가 뒤집혔을 때 이들을 일시적으로 저장하기 위한 목적으로 사용됩니다. 게임 진행 중에 `flippedCards` 배열에 뒤집힌 카드를 추가하고, 두 개의 카드가 뒤집히면 `checkMatch` 함수를 호출하여 두 카드가 같은지 여부를 확인합니다. 만약 두 카드가 같다면 정답 처리를 하고, 다르다면 다시 뒤집히도록 하는 등의 로직을 처리합니다. 이 배열은 현재 뒤집힌 카드를 추적하는 데 사용되며, 두 개의 카드가 뒤집힌 상태로 존재하는 동안만 저장되어야 합니다. 그 후, `checkMatch` 함수가 실행되고 이 배열이 비워집니다.

Card.tsx에서 카드가 흰색 뒷면으로 보이는 것은 matched 속성에 좌우된다.

이 matched를 변경하는 setCard 함수는 checkMatch에서만 호출되는데, 이 checkMatch는 newFlippedCards가 2개일 때만 호출된다.

즉 하나만 뒤집힌 상태에서는 checkMatch가 안 되고, 덩달아 setCard도 안 되니 클릭하고 나서 아무 일도 안 일어나는 것처럼 보이는 것.

 

이를 지적해 보았다.

그럼 처음에 handleCardClick을 호출한다고 가정했을 때, 카드가 뒤집히게 보이려면 카드의 matched 속성이 변해야 할 것 같은데, checkMatch는 newFlippedCards의 length가 2일 때만 호출되니 로직이 잘못된 것 같은데?
네, 당신이 지적한 대로 handleCardClick 함수를 호출할 때 카드가 뒤집히게 보이려면 카드의 matched 속성을 업데이트해야 합니다. 현재 handleCardClick 함수에서는 matched 속성을 변경하지 않고, 대신 flippedCards 배열에 뒤집힌 카드를 저장하고 checkMatch 함수가 호출될 때 matched 속성을 업데이트합니다. 이것은 현재 코드에서 로직을 약간 더 복잡하게 만들고 있습니다.
카드가 뒤집히게 보이도록 하려면 handleCardClick 함수에서 바로 카드의 matched 속성을 변경하고, 그 다음에 checkMatch 함수를 호출하도록 코드를 수정해야 합니다.
아래는 수정된 handleCardClick 함수의 예시입니다:
const handleCardClick = (id: number, color: string) => {
  const updatedCards = cards.map((card) =>
    card.id === id && !card.matched ? { ...card, matched: true } : card
  );
  setCards(updatedCards);

  if (flippedCards.length < 2) {
    const newFlippedCards = [...flippedCards, { id, color }];
    setFlippedCards(newFlippedCards);

    if (newFlippedCards.length === 2) {
      checkMatch(newFlippedCards);
    }
  }
};​

 

이 handleCardClick을 고대로 복사해서 붙여넣었음에도 실행은 되지 않았다.

그런데 가만 보니 matched가 약간 이상하다. 카드가 뒤집혔는지를 볼 때도 matched를 확인하고, 카드가 다 맞혀졌는지를 볼 때도 matched를 확인한다.

이에 대한 피드백을 해 보았다.

다시 검토해보니, 카드의 matched 속성이 혼용되고 있는 것 같아. Card.tsx 안에서는 flipped된 상태를 나타내는 state로, GameBoard.tsx 안에선 유저가 맞춘 카드라는 뜻으로 사용되고 있는 것 같은데. 내 생각엔 별개의 속성을 하나 더 둬서 flip된 상태와 유저가 맞춘 상태를 분리해야 할 것 같아. 네가 보기엔 이 접근이 괜찮을까?
카드의 `matched` 속성을 flip된 상태와 유저가 맞춘 상태를 구분하기 위해 두 개의 별개 속성을 사용하는 접근은 합리적입니다. 이렇게 하면 두 가지 상태를 명확하게 추적하고 구분할 수 있으며 코드가 더 명확해집니다. 예를 들어, `flipped` 속성은 카드가 뒤집혔는지 여부를 나타내고, `matched` 속성은 유저가 맞춘 카드인지를 나타낼 수 있습니다. 이렇게 구분하면 게임의 상태를 관리하는 데 도움이 되며, 코드가 더 이해하기 쉬워집니다. 따라서 `flipped`와 `matched` 두 가지 속성을 사용하여 각 카드의 상태를 나타내는 것은 합리적인 접근입니다.
그럼 내 피드백을 바탕으로 GameBoard.tsx와 Card.tsx의 코드를 수정해 줘.
카드의 flipped와 matched 속성을 별개로 구분하기 위해 Card.tsx와 GameBoard.tsx 코드를 수정했습니다. 아래는 수정된 코드입니다:
Card.tsx:
import React from 'react';
import styles from './Card.module.scss';

interface CardProps {
  id: number;
  color: string;
  flipped: boolean;
  onCardClick: (id: number, color: string) => void;
}

const Card: React.FC<CardProps> = ({ id, color, flipped, onCardClick }) => {
  const handleCardClick = () => {
    if (!flipped) {
      onCardClick(id, color);
    }
  };

  return (
    <div
      className={`${styles.card} ${flipped ? styles.flipped : ''}`}
      style={{ backgroundColor: flipped ? color : 'white' }}
      onClick={handleCardClick}
    ></div>
  );
};

export default Card;​

GameBoard.tsx:
import React, { useState, useEffect } from 'react';
import styles from './GameBoard.module.scss';
import Card from './Card';

const colors: string[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'teal'];

const shuffleArray = (array: string[]): string[] => {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
};

const GameBoard: React.FC = () => {
  const [score, setScore] = useState<number>(0);
  const [cards, setCards] = useState<{ id: number; color: string; flipped: boolean; matched: boolean }[]>([]);
  const [flippedCards, setFlippedCards] = useState<{ id: number; color: string }[]>([]);

  useEffect(() => {
    const shuffledColors = shuffleArray([...colors, ...colors]);
    const initialCards = shuffledColors.map((color, index) => ({
      id: index,
      color: color,
      flipped: false,
      matched: false,
    }));
    setCards(initialCards);
  }, []);

  const handleCardClick = (id: number, color: string) => {
    const updatedCards = cards.map((card) =>
      card.id === id && !card.matched ? { ...card, flipped: true } : card
    );
    setCards(updatedCards);

    if (flippedCards.length < 2) {
      const newFlippedCards = [...flippedCards, { id, color }];
      setFlippedCards(newFlippedCards);

      if (newFlippedCards.length === 2) {
        checkMatch(newFlippedCards);
      }
    }
  };

  const checkMatch = (flippedCards: { id: number; color: string }[]) => {
    const [card1, card2] = flippedCards;
    if (card1.color === card2.color) {
      const updatedCards = cards.map((card) =>
        card.id === card1.id || card.id === card2.id ? { ...card, matched: true } : card
      );
      setCards(updatedCards);
      setScore(score + 10);
    } else {
      setTimeout(() => {
        const unflippedCards = cards.map((card) =>
          card.id === card1.id || card.id === card2.id ? { ...card, flipped: false } : card
        );
        setCards(unflippedCards);
      }, 1000);
    }
    setFlippedCards([]);
  };

  return (
    <div className={styles['game-board']}>
      {cards.map((card) => (
        <Card
          key={card.id}
          id={card.id}
          color={card.color}
          flipped={card.flipped}
          onCardClick={handleCardClick}
        />
      )}
      {cards.every((card) => card.matched) && (
        <div className={styles.message}>Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
};

export default GameBoard;​

드디어 카드가 제대로 뒤집어지기 시작했다!!🥹

하지만 예전 html 시절에는 저절로 구현해 주었던 카드 중복클릭 방지가 되지 않는 것을 확인했다.

다시 프롬프트를 보냈다.

여기에 추가해서, 만약 두 개의 카드를 뒤집은 상태이면 다른 카드들이 클릭이 되지 않도록(혹은 클릭해도 flip 되지 않도록) 코드를 보완해줘.
두 개의 카드가 뒤집힌 상태에서 다른 카드들을 클릭하지 못하도록 하려면, 각 카드의 클릭 이벤트 핸들러에서 클릭 가능 여부를 검사하면 됩니다. 이를 위해 추가적인 로직을 구현하겠습니다. 아래는 수정된 `Card.tsx`와 `GameBoard.tsx` 코드입니다: 
Card.tsx
import React from 'react';
import styles from './Card.module.scss';

interface CardProps {
  id: number;
  color: string;
  flipped: boolean;
  matched: boolean;
  onCardClick: (id: number, color: string) => void;
  canClick: boolean;
}

const Card: React.FC<CardProps> = ({ id, color, flipped, matched, onCardClick, canClick }) => {
  const handleCardClick = () => {
    if (canClick && !flipped && !matched) {
      onCardClick(id, color);
    }
  };

  return (
    <div
      className={`${styles.card} ${flipped ? styles.flipped : ''}`}
      style={{ backgroundColor: flipped ? color : 'white' }}
      onClick={handleCardClick}
    ></div>
  );
};

export default Card;​

GameBoard.tsx:

// ...

const GameBoard: React.FC = () => {
  // ...

  // 클릭 가능 여부 상태
  const [canClick, setCanClick] = useState<boolean>(true);

  const handleCardClick = (id: number, color: string) => {
    if (canClick) {
      const updatedCards = cards.map((card) =>
        card.id === id && !card.matched ? { ...card, flipped: true } : card
      );
      setCards(updatedCards);

      if (flippedCards.length < 2) {
        const newFlippedCards = [...flippedCards, { id, color }];
        setFlippedCards(newFlippedCards);

        if (newFlippedCards.length === 2) {
          // 두 개의 카드를 뒤집은 상태이므로 클릭 불가능 상태로 설정
          setCanClick(false);
          checkMatch(newFlippedCards);
        }
      }
    }
  };

  const checkMatch = (flippedCards: { id: number; color: string }[]) => {
    // 정답 처리 로직

    // 모든 로직 처리 후 클릭 가능 상태로 변경
    setCanClick(true);
  };

  // ...

  return (
    <div className={styles['game-board']}>
      {cards.map((card) => (
        <Card
          key={card.id}
          id={card.id}
          color={card.color}
          flipped={card.flipped}
          matched={card.matched}
          onCardClick={handleCardClick}
          canClick={canClick}
        />
      )}
      {cards.every((card) => card.matched) && (
        <div className={styles.message}>Game Ends! Your score is: {score}</div>
      )}
    </div>
  );
};

export default GameBoard;

제대로 해결되었다.

그런데 이제 보니 한번 클릭한 카드는 다시 클릭이 안 된다. 다시 프롬프트를 날린다.

좋아. 다른 카드가 클릭되지 않도록 하는 건 해결됐어. 그런데 지금 보니 한 번 클릭한 카드는 다시 클릭했을 때 플립이 안 되거나, 혹은 클릭이 아예 안 되는 것 같아. 이유를 찾아서 코드를 수정해 줄래?
문제를 해결하기 위해 Card.tsx에서 canClick 상태를 관리해야 합니다. 클릭 가능 여부를 제어하는 부분을 수정하겠습니다. 아래는 수정된 Card.tsx
코드입니다:
...

뭐라 코드를 쏼라쏼라 줬는데 복사해서 붙여 보니 수정된 곳이 없다.

슬슬 GPT가 과부하가 오고 있는 걸까?

뭘 수정한 건지 물어 보았다.

어디를 수정한 거고, 문제가 뭐였는지 말해줄래?
저번 답변에서 수정한 부분은 Card.tsx 파일입니다. 이전에는 클릭 가능 여부를 체크하는 부분이 if (!flipped && !matched)로 설정되어 있었습니다. 이로 인해 카드가 뒤집힌 상태인 경우 클릭이 불가능했고, 이미 짝이 맞춰진 카드도 클릭할 수 없었습니다. 이 문제를 해결하기 위해 클릭 가능 여부를 canClick 상태를 확인하도록 수정하였습니다.
문제는 카드를 다시 클릭했을 때 플립이 안 되거나, 클릭이 아예 안 되는 것이었습니다. 이제 수정된 코드로 인해 클릭 가능 여부를 canClick 상태를 통해 체크하고, 클릭 가능한 상황에서만 카드를 클릭할 수 있게 되었습니다. 이로써 클릭 가능한 상태에서는 카드를 다시 클릭했을 때도 플립되며, 클릭이 안 되는 상태에서는 클릭이 무시되게 됩니다.

말은 이렇게 하는데 이전에도 내 코드는 ' if (canClick && !flipped && !matched) '로 설정되어 있었다.

GPT가 길을 잃은 것 같아 자세한 컨텍스트를 다시 담아서 재질문했다.

이전에도 내 코드에서는 ' if (canClick && !flipped && !matched) '로 설정되어 있었어. 아마 다른 문제점이 있을 것 같아.
네가 context를 헷갈린 것 같으니, 코드와 문제 상황을 다시 보고할게.
현재 나는 카드 짝 맞추기 게임을 구현 중이고, 현재 한 번 클릭된 적이 있는 카드는 다시 클릭했을 때 flip되지 않는 버그가 있어. (canClick이 true여도 말이지) 난 이를 해결하고 싶어.
GameBoard.tsx는 아래 """로 구분된 코드야.

""" 이하 GameBoard 코드"""

그리고 GameBoard에서 불러오는 Card.tsx는 아래 """로 구분된 코드야.

""" 이하 Card코드 """

버그의 원인을 찾고, 설명해 주고, 해결된 코드를 알려줄래?

그리고 이 프롬프트의 답을 붙여넣어서!!

나와 GPT는 제대로 작동하는 카드 매칭 게임을 만들 수 있었다.

마지막 답변 전문은 다음과 같다.

버그의 원인은 Card.tsx 파일의 handleCardClick 함수에서 일어납니다. 클릭한 카드를 뒤집을 때 if (canClick && !flipped && !matched) 조건을 사용하고 있지만, 이 조건은 클릭 가능 여부를 체크하면서 이미 뒤집힌 카드 또는 이미 짝이 맞춰진 카드를 클릭할 때도 뒤집히지 않도록 제한합니다. 이것이 버그의 원인입니다.
버그를 해결하기 위해 handleCardClick 함수를 수정하여 클릭 가능 여부 canClick을 체크하는 조건을 삭제하고, 클릭 시 onCardClick 함수를 호출하도록 코드를 변경해야 합니다. 여기에 해당 코드가 수정된 Card.tsx와 GameBoard.tsx 파일의 일부를 제시하겠습니다:
Card.tsx:
const Card: React.FC<CardProps> = ({ id, color, flipped, matched, onCardClick, canClick }) => {
  const handleCardClick = () => {
    if (!flipped && !matched) {
      onCardClick(id, color);
    }
  };
  // ...
};​

GameBoard.tsx:

const handleCardClick = (id: number, color: string) => {
  if (canClick) {
    const updatedCards = cards.map((card) =>
      card.id === id && !card.matched ? { ...card, flipped: true } : card
    );
    setCards(updatedCards);

    if (flippedCards.length < 2) {
      const newFlippedCards = [...flippedCards, { id, color }];
      setFlippedCards(newFlippedCards);

      if (newFlippedCards.length === 2) {
        // 두 개의 카드를 뒤집은 상태이므로 클릭 불가능 상태로 설정
        setCanClick(false);
        checkMatch(newFlippedCards);
      }
    }
  };
};

이렇게 수정된 코드에서 클릭 가능 여부를 체크하는 조건을 삭제하여 모든 클릭한 카드가 뒤집히도록 하였습니다. 그러나 클릭 가능 여부는 checkMatch 함수에서 관리되므로, 두 개의 카드를 뒤집은 후에 클릭 불가능 상태로 설정됩니다. 이로써 카드가 한 번 클릭된 후에 다시 클릭했을 때도 정상적으로 플립됩니다.

문제 상황의 원인을 내가 알려주지 않았음에도, context를 구체적으로 담아 재질문한 것만으로 오작동의 원인을 찾고 수정해 주는 모습을 볼 수 있었다.

코드리뷰를 많이 해서 약간 머리가 아파왔지만, 그래도 내가 직접 바꾸는 것보단 훨씬 빠르게 vanilla JS->리액트 변환이 이루어진 것 같다.

 


이 경험으로 GPT에 대해 알게 된 점들은 다음과 같다.

 

1. GPT는 간단한 프롬프트만으로는 제대로 된 코드를 바로 답변해주지 않는다. 반복적인 재질문과 상세한 프롬프트, 인내심이 필요하다.
2. A스택의 코드를 B스택으로 바꿔달라는 요청을 할 때는 필연적으로 질문자의 B스택에 대한 지식이 필요하다.
(알잘딱깔센이 안되기 때문이다.)
3. 웹게임을 구현할 때 스택 상관 없이 구현만이 목적이라면, 개발에 대한 지식이 없어도 가능할 것 같다.
하지만 특정 스택에 맞춰/특정 프로젝트에 이식하고자 세부적으로 개발하고자 한다면 결국 개발지식이 필요하다.

 

아무튼.......

조금 쉬려고 시작했는데 생각보다 나도 머리를 많이 써서 힘겨운 과정이었다.

남의 코드 읽기는 역시 쉽지 않다.

 

그래도 확실히 GPT를 사용하여 생산성을 크게 높일 수는 있을 것 같다.