[토이 프로젝트] 장미전쟁 score tracker 만들기: 점수 계산 알고리즘 구현

이민선(Jasmine)·2023년 6월 4일
0
post-thumbnail

이번 시간에는 대망의 점수 알고리즘 짜기!
이번 프로젝트의 핵심 기능이 될 것이다.

DFS 방식으로 알고리즘을 짜보려고 한다. 인접한 노드들의 개수를 합산하여 return하는 함수를 만들어서 합산값의 제곱을 finalScore 객체에 누적해줄 것이다.

그런데 문제가 생겼다. dfs 함수를 만들면 2중 for문을 만들어서 원소를 하나씩 순회하는데, 순회하면서 원본 배열의 숫자를 싹 다 0으로 만들어버리는 로직을 짰더니 화면에 0만 뜬다..

이럴 때 2차원 배열은 깊은 복사를 해야 한다. 알고리즘 풀면서 학습한 적이 있긴 한데.. 그렇긴한데... 나 개념 제대로 아는 거 맞아? 정리하고 로직을 다시 짜보자.

얕은 복사 vs 깊은 복사

2개 이상의 참조자료형(객체, 배열 등)이 중첩되었다고 가정하고 설명하는 맥락이다.
내부에 중첩된 참조자료형(이하 '중첩된 객체'로 표현하겠음)이 있는 참조자료형 arr와 obj가 있다고 하자.

let arr = [1, 2, [3, 4], 5];
let obj = [{ coffee1 : "MegaCoffee"},{coffee2 : "Starbucks"}];

얕은 복사

중첩된 객체를 가진 객체를 복사하면 한 단계의 복사본만 만든다.

  • 배열: slice(), spread 연산자

    • slice()로 복사
      const copiedArr1 = arr.slice();
      console.log(arr === copiedArr1) // false
      console.log(arr[2] === copiedArr1[2]) // true
    • spread 연산자로 복사
      const copiedArr2 = [...arr];
      console.log(arr === copiedArr2) // false
      console.log(arr[2] === copiedArr2[2] // true

arr의 복사본이 만들어졌지만(1단계만 복사), 중첩된 객체(배열)인 arr[2] = [3, 4]는 복사본이 만들어지지 않았다. arr[2], copiedArr1[2], copiedArr2[2]는 복사된 적이 없어 유일한 중첩된 객체를 가리키고 있다.

  • 객체: Object.assign(), spread 연산자
	const copiedObj1 = Object.assign({},obj);
	const copiedObj2 = {...obj};
	console.log(obj === copiedObj1) // false
	console.log(obj === copiedObj2) // false
	console.log(obj[1] === copiedObj1[1]) // true
	console.log(obj[1] === copiedObj2[1] // true

obj의 복사본이 만들어졌지만(1단계만 복사), 중첩된 객체인 { coffee1 : "MegaCoffee"}와 {coffee2 : "Starbucks"}는 복사본이 만들어지지 않았다.
arr[1], copiedObj1[1], copiedObj2[1]는 복사된 적 없어 유일한 중첩된 객체를 가리키고 있다.

깊은 복사

객체가 중첩되어 있을 때 중첩된 객체의 복사본까지 전부 만듦.

  • JSON.parse(JSON.stringify(arr))
const copiedArr_JSON = JSON.parse(JSON.stringify(arr));
console.log(arr === copiedArr_JSON); // false
console.log(arr[2] === copiedArr1[2]); // false

❗️ 단 이 방법을 함수가 중첩되어 있는 객체에 사용하면 함수는 복사되지 않고 null로 바뀐다.

const copiedArrContainsFn_JSON = JSON.parse(JSON.stringify(["1", function(){}]))
console.log(copiedArrContainsFn_JSON) // ["1", null]
  • lodash의 cloneDeep 사용(외부 라이브러리)
    npm install lodash
    깊복을 원한다면 lodash를 설치하자!! (node.js 환경에서 실행)
const copiedArr_JSON = JSON.parse(JSON.stringify(arr));
console.log(arr === copiedArr_JSON); // false
console.log(arr[2] === copiedArr1[2]); // false
중첩된 객체인 arr[2]까지 모두 복사되었다.

깊은 복사본이 필요하다....! 그래서 lodash library를 사용해보기로 선택!

lodash library 사용 준비

근데 require로 lodash library를 가져왔는데 밑에 ...이 뜨네? 뭔가 권고되고 있는 사항이 있나보다.

import가 require보다 권장되는 이유 학습 (tree-shaking 관점)

import로 바꿔라? 왜 바꾸라고 권장할까? 궁금해서 찾아보았다.
https://stackdiary.com/require-vs-import-in-javascript/

  • tree-shaking 관점:
    • import 구문은 정적으로 분석되기 때문에 번들러가 실제로 사용되는 파일을 추적하여 사용되지 않는 파일을 제거한다고 한다. 반면 require는 동적으로 분석되기 때문에 사용되지 않는 파일의 추적이 어려울 수 있으며, 일반적으로 필요없는 바인딩까지 전부 불러온다. 따라서 성능 측면에서 import가 낫다.

require는 node.js의 내장 함수이고, import는 es6 문법이며 더욱 모던하고 권장되는 방식이라고 한다. 그래서 import로 바꿈~

import cloneDeep from "lodash/cloneDeep";

이렇게 cloneDeep을 가져올 때는 상대경로를 지정해주어야 용량이 줄어든다는 흥미로운 블로그 글을 봐서 나도 상대경로를 잘 지정해주었다.

https://techblog.wclub.co.kr/posts/0001.tree-shaking/Tree%20Shaking%20%EC%9D%84%20%ED%86%B5%ED%95%9C%20%EB%AA%A8%EB%93%88%20%EC%9A%A9%EB%9F%89%20%EC%B5%9C%EC%A0%81%ED%99%94

이제 lodash libary도 import해왔으니 지인짜로 알고리즘을 짜보자.

점수 계산 알고리즘 구현

dfs 방식으로 짜보았다.

우선 저번 시간에 클릭한 유저의 상태에 따라 클릭한 숫자가 달라지게끔 구현해놓았으므로, 점수 계산 예시를 위해 내 맘대로 클릭해놓았다.

GameBoard.tsx

  // 한 번 탐색할 때마다 user1인지 user2인지에 따라 score1 또는 score2를 count해서 반환할 것.
  let score1 = 0;
  let score2 = 0;

  // 처음 함수를 호출할 때는 userNumber를 인자로 전달하지 않음. 재귀 호출일 때만 userNumber 인자로 전달.
  const getUsersScoreByDFS = (i: number, j: number, userNumber?: number) => {
    // out of range 처리
    if (i < 0 || i >= 9 || j < 0 || j >= 9) return false;
    // 빈 칸이면 함수 종료
    if (deepCopiedTable[i][j] === 0) return false;
    // 재귀 호출인데 맨 처음 할당한 userNumber가 아니고 상대편 숫자일 경우 함수 종료
    if (userNumber && deepCopiedTable[i][j] !== userNumber) return false;

    // 현재 칸의 숫자가 1이면 score1 1만큼 증가
    if (deepCopiedTable[i][j] === 1) {
      score1 += 1;
    } else {
      // 현재 칸의 숫자가 2이면 score2 1만큼 증가
      score2 += 1;
    }

    // 재귀 호출이 아니라 신규 호출인 상태이고 현재 칸의 숫자가 1일 경우, userNumber에 1 할당.
    if (!userNumber && deepCopiedTable[i][j] === 1) {
      userNumber = 1;
    } else if (!userNumber && deepCopiedTable[i][j] === 2) {
      // 2일 경우 userNumber에 2 할당
      userNumber = 2;
    }

    // 재귀 호출 전 방금 탐색한 칸의 숫자를 다시 방문하지 않도록 0으로 변경 (방문 처리)
    deepCopiedTable[i][j] = 0;

    // 상하좌우 탐색. 이 때 재귀호출이므로 현재 userNumber를 인자로 넘긴다.
    getUsersScoreByDFS(i - 1, j, userNumber);
    getUsersScoreByDFS(i + 1, j, userNumber);
    getUsersScoreByDFS(i, j - 1, userNumber);
    getUsersScoreByDFS(i, j + 1, userNumber);

    // score1과 score2 둘 중 하나는 0일 것이다.
    return [score1, score2];
  };

// 최종 score를 저장하는 객체
  const finalScore = { user1: 0, user2: 0 };

// 2중 for문을 이용하여 모든 칸의 인덱스를 순회하며 dfs 함수 호출
  for (let i = 0; i < 9; i++) {
    for (let j = 0; j < 9; j++) {
      const result = getUsersScoreByDFS(i, j);
      // result가 false일 때는 진입하지 않음.
      if (result) {
        console.log(i, j, result);
        let [score1, score2] = result;
        // 최종 점수에 누적해주는 부분
        finalScore.user1 += score1 ** 2;
        finalScore.user2 += score2 ** 2;
      }
      // 최종 점수에 누적이 끝났으므로 다시 0으로 만들어준다.
      score1 = 0;
      score2 = 0;
    }
  }

  console.log("finalScore", finalScore);


와우 점수가 나왔따!
user1은 115점이고 user2는 135점이란다.
손으로 계산해봐도 맞구나!

나 핵심 기능 구현 성공한거야?!!
는 아직 할 게 많이 남았다 ㅎㅎㅎ..
그래도 뿌듯~!~!

이제 useGetScore라는 custum hook을 사용해서 점수 계산 로직을 숨기려고 한다.

custom hook 생성하여 점수 계산 로직 숨기기

useGetScore.tsx

import cloneDeep from "lodash/cloneDeep";

// useGetScore라는 custom hook을 만들어서 로직을 따로 숨겨놓았다. 이 때 매개변수는 user가 클릭한 말들이 숫자로 나타나 있는 table이다.
export const useGetScore = (
  table: number[][]
): { user1: number; user2: number } => {
  const deepCopiedTable = cloneDeep(table);

  let score1 = 0;
  let score2 = 0;

  const getUsersScoreByDFS = (i: number, j: number, userNumber?: number) => {
    if (i < 0 || i >= 9 || j < 0 || j >= 9) return false;
    if (deepCopiedTable[i][j] === 0) return false;
    if (userNumber && deepCopiedTable[i][j] !== userNumber) return false;

    if (deepCopiedTable[i][j] === 1) {
      score1 += 1;
    } else {
      score2 += 1;
    }

    if (!userNumber && deepCopiedTable[i][j] === 1) {
      userNumber = 1;
    } else if (!userNumber && deepCopiedTable[i][j] === 2) {
      userNumber = 2;
    }

    deepCopiedTable[i][j] = 0;

    getUsersScoreByDFS(i - 1, j, userNumber);
    getUsersScoreByDFS(i + 1, j, userNumber);
    getUsersScoreByDFS(i, j - 1, userNumber);
    getUsersScoreByDFS(i, j + 1, userNumber);

    return [score1, score2];
  };
  const finalScore = { user1: 0, user2: 0 };

  for (let i = 0; i < 9; i++) {
    for (let j = 0; j < 9; j++) {
      const result = getUsersScoreByDFS(i, j);
      if (result) {
        console.log(i, j, result);
        let [score1, score2] = result;
        finalScore.user1 += score1 ** 2;
        finalScore.user2 += score2 ** 2;
      }
      score1 = 0;
      score2 = 0;
    }
  }

  // 마지막에는 최종 점수 객체만 return한다.
  return finalScore;
};

GameBoard.tsx

 const finalScore = useGetScore(table);
  console.log("custom hook으로 받은 finalScore", finalScore);
  // -----------------------------------------------------bye👋
  // let score1 = 0;
  // let score2 = 0;

  // const getUsersScoreByDFS = (i: number, j: number, userNumber?: number) => {
  //   if (i < 0 || i >= 9 || j < 0 || j >= 9) return false;
  //   if (deepCopiedTable[i][j] === 0) return false;
  //   if (userNumber && deepCopiedTable[i][j] !== userNumber) return false;

  //   if (deepCopiedTable[i][j] === 1) {
  //     score1 += 1;
  //   } else {
  //     score2 += 1;
  //   }

  //   if (!userNumber && deepCopiedTable[i][j] === 1) {
  //     userNumber = 1;
  //   } else if (!userNumber && deepCopiedTable[i][j] === 2) {
  //     userNumber = 2;
  //   }

  //   deepCopiedTable[i][j] = 0;

  //   getUsersScoreByDFS(i - 1, j, userNumber);
  //   getUsersScoreByDFS(i + 1, j, userNumber);
  //   getUsersScoreByDFS(i, j - 1, userNumber);
  //   getUsersScoreByDFS(i, j + 1, userNumber);

  //   return [score1, score2];
  // };
  // const finalScore = { user1: 0, user2: 0 };

  // for (let i = 0; i < 9; i++) {
  //   for (let j = 0; j < 9; j++) {
  //     const result = getUsersScoreByDFS(i, j);
  //     if (result) {
  //       console.log(i, j, result);
  //       let [score1, score2] = result;
  //       finalScore.user1 += score1 ** 2;
  //       finalScore.user2 += score2 ** 2;
  //     }
  //     score1 = 0;
  //     score2 = 0;
  //   }
  // }

  // console.log("finalScore", finalScore);

아래 주석 처리된 코드들은 전부 custom hook으로 이사 갔고, 이제 GameBoardContainer 컴포넌트에서는 한 줄로 결과를 받아올 수 있다.


이제 Get Score 버튼에 onClickShowResult 함수를 생성하여 연결해주자.

Get Score 버튼이 있는 GameResult 컴포넌트에서 gameBoardStore에서 useSelector로 받아온 table을 useGetScore에 인자로 전달하여 finalScore에 접근이 가능하다.

GameResult.tsx

//...import 생략


function GameResult() {
  const userNames = useSelector((store: RootState) => store.userNames);
  const table = useSelector((store: RootState) => store.gameBoardStore);
  // custom hook인 useGetScore 호출
  const finalScore = useGetScore(table);
  const onClickShowResult = () => {
    console.log("최종 점수", finalScore);
  };
  // 최종 점수 {user1: 73, user2: 84}

  return (
    <>
      <User
        userNumber={1}
        src={GameResultImgURL.user1}
        userName={userNames.userName1}
      />
      <GameBoardContainer />
      <User
        userNumber={2}
        src={GameResultImgURL.user2}
        userName={userNames.userName2}
      />
      <GetScoreBtnWrapper>
        // 버튼에 onClick 연결
        <GetScoreBtn onClick={onClickShowResult}>
          {GET_SCORE_BUTTON_PHRASE}
        </GetScoreBtn>
      </GetScoreBtnWrapper>
    </>
  );
}
export default GameResult;

드디어 점수 알고리즘을 짜서 버튼에 onClick 연결하는 것까지 마무리했다!!

profile
기록에 진심인 개발자 🌿

0개의 댓글